Skip to content

Features and limits

What a network can do is fixed, and stated once by its platform file. What a particular post is allowed to contain changes while the app runs - a Mastodon server's post length, an Instagram account's posts left today - so it is looked up instead. See Networks for what each one actually supports.

Feature

Feature

Bases: Flag

Things a network either can or cannot do.

A platform file lists the ones it supports. Combine them with | and test them with in:

supported = Feature.POST_TEXT | Feature.POST_IMAGE
Feature.POST_VIDEO in supported    # False

CREATE_APP class-attribute instance-attribute

CREATE_APP = auto()

socialchimp can register your app automatically.

True for Mastodon only, today. Everywhere else you create the app by hand in the network's developer portal first.

NEEDS_NO_APP class-attribute instance-attribute

NEEDS_NO_APP = auto()

There is no app to register, so nothing has to be saved first.

Bluesky only, today. It has no developer portal at all - a person signs in with their handle and an app password they made themselves - so there is no client id and no secret, and nothing for Storage.save_app to hold. SocialChimp.start_login works against empty storage there, and the platform is handed LoginRequest.app as None.

Leave it off for a network that does need credentials, including one that can register itself: Mastodon creates the app for you, but the app still exists and a sign-in without it fails. A sign-in with none saved is then refused with a message naming Storage.save_app, which is the right answer everywhere but here.

POST_TEXT class-attribute instance-attribute

POST_TEXT = auto()

Can publish words.

POST_IMAGE class-attribute instance-attribute

POST_IMAGE = auto()

Can publish pictures.

POST_VIDEO class-attribute instance-attribute

POST_VIDEO = auto()

Can publish video.

SCHEDULE class-attribute instance-attribute

SCHEDULE = auto()

Can be asked to publish later.

REPLY class-attribute instance-attribute

REPLY = auto()

Can reply to another post.

DELETE_POST class-attribute instance-attribute

DELETE_POST = auto()

Can remove a post it published.

READ_POSTS class-attribute instance-attribute

READ_POSTS = auto()

Can read posts back.

READ_STATS class-attribute instance-attribute

READ_STATS = auto()

Can read numbers such as likes and views.

PUSH_UPDATES class-attribute instance-attribute

PUSH_UPDATES = auto()

Tells us when something happens, instead of us having to check.

Where this is missing, socialchimp checks on a timer and gives you the same updates anyway. Your code does not need to know which is happening.

READ_POST class-attribute instance-attribute

READ_POST = auto()

Can read one post back in full, with Account.read_post.

READ_THREAD class-attribute instance-attribute

READ_THREAD = auto()

Can read a post together with its replies, with Account.read_thread.

REPLY_TO_COMMENTS class-attribute instance-attribute

REPLY_TO_COMMENTS = auto()

Can reply to any post or comment at any depth, with Account.reply.

publish(Post(reply_to=...)) keeps working without this; reply() is the recommended way once a network has it.

LIKE class-attribute instance-attribute

LIKE = auto()

Can like and unlike a post or a comment, with Account.like and Account.unlike.

READ_LIKES class-attribute instance-attribute

READ_LIKES = auto()

Can list who liked a post, with Account.read_likes.

Some networks that can like cannot list who did - a Facebook Page can like a comment but only ever sees the count, so it lists LIKE without this.

READ_UPDATES_AFTER class-attribute instance-attribute

READ_UPDATES_AFTER = auto()

Can be asked for everything new since a marker, with Account.fetch_updates_after, and told a marker has been seen with Account.mark_seen.

Unlike Feature.PUSH_UPDATES, this is a resumable poll rather than something the network sends us - the marker is what makes it resumable across a restart.

MESSAGES class-attribute instance-attribute

MESSAGES = auto()

Can read and send direct messages, with Account.read_conversations, Account.read_messages, Account.send_message and Account.mark_read.

START_CONVERSATIONS class-attribute instance-attribute

START_CONVERSATIONS = auto()

Can start a new conversation, with Account.start_conversation.

Meta cannot: the customer has to write first. A network that has Feature.MESSAGES but not this one can still be replied to - it just cannot open the first message.

Limits

Limits dataclass

Limits(
    max_text_length: int | None = None,
    max_text_bytes: int | None = None,
    text_counted_in: TextCount = CHARACTERS,
    max_images: int | None = None,
    max_image_bytes: int | None = None,
    max_title_length: int | None = None,
    max_videos: int | None = None,
    max_video_bytes: int | None = None,
    posts_left_today: int | None = None,
)

Numbers that a network enforces right now.

Every field may be None, which means "we do not know" - never "zero". An unknown limit is not checked.

Attributes:

Name Type Description
max_text_length int | None

Longest post this network or server accepts, counted the way text_counted_in says.

max_text_bytes int | None

Longest post once written out, for a network that has this limit as well as the first. Bluesky has both: 300 letters and 3,000 bytes, and a post has to be inside both.

text_counted_in TextCount

What max_text_length is counted in. Left out, characters, which is what most code assumes and what Mastodon means.

max_images int | None

Most pictures allowed on one post.

max_image_bytes int | None

Largest picture file allowed.

max_title_length int | None

Longest title allowed, on the networks that have a title separate from the post itself. YouTube does, and requires one; Pinterest and Reddit have them too. Worth reading if you are showing somebody a character count as they type.

max_videos int | None

Most videos allowed on one post.

max_video_bytes int | None

Largest video file allowed.

posts_left_today int | None

How many more posts are allowed today, where the network tells us. Instagram and Threads both do.

The two file sizes are here to be shown to your users and to size things before uploading. Nothing here opens a file to check them, because that would mean reading every picture off disk to send one post - the network is what enforces those, and a file that is too big comes back as an InvalidPostError from the platform.

How text is counted

Hardly any network means "characters" when it says "300". TextCount says which counting a network actually uses, and measure_text counts a string the same way.

TextCount

Bases: Enum

How a network counts the length of a post.

Nearly every network says "300 characters" and means something else by it, and the difference only shows up once somebody posts an emoji.

A platform names one of these on its Limits, and check_post then counts the same way that network will. Left out, it is CHARACTERS, which is what Python's own len gives - so a platform written before this existed behaves exactly as it did.

CHARACTERS class-attribute instance-attribute

CHARACTERS = 'characters'

One per character, which is what len(text) gives.

Mastodon counts this way, and so does anything that has not said otherwise.

GRAPHEMES class-attribute instance-attribute

GRAPHEMES = 'graphemes'

Letters as a person would count them.

A family emoji is seven characters and one letter; a flag is two and one. Bluesky's 300 is counted this way, so counting characters instead refuses posts it would happily have taken. See count_graphemes.

UTF8_BYTES class-attribute instance-attribute

UTF8_BYTES = 'utf8_bytes'

Bytes, once the text is written out.

An emoji takes four of them and an accented letter two. Threads counts this way, and Bluesky has a second limit of this kind on top of its first.

UTF16_UNITS class-attribute instance-attribute

UTF16_UNITS = 'utf16_units'

The way Java and JavaScript count, where an emoji is two.

Networks built on either tend to count this way - TikTok does - so a post of 200 emoji is 200 to Python and 400 to them.

in_words property

in_words: str

What to call this in a message somebody has to read.

the_catch property

the_catch: str

The surprise worth naming, or nothing when there is none.

measure_text

measure_text(
    text: str, counted_in: TextCount = CHARACTERS
) -> int

Measure some text the way one network counts it.

Parameters:

Name Type Description Default
text str

The words to measure.

required
counted_in TextCount

How that network counts. Left out, characters.

CHARACTERS

Returns:

Type Description
int

How long that network will think this post is.

Source code in src/socialchimp/features.py
def measure_text(text: str, counted_in: TextCount = TextCount.CHARACTERS) -> int:
    """Measure some text the way one network counts it.

    Args:
        text: The words to measure.
        counted_in: How that network counts. Left out, characters.

    Returns:
        How long that network will think this post is.
    """
    if counted_in is TextCount.GRAPHEMES:
        return count_graphemes(text)
    if counted_in is TextCount.UTF8_BYTES:
        return len(text.encode())
    if counted_in is TextCount.UTF16_UNITS:
        # Two bytes to a unit. The little-endian form is used because the
        # plain one puts a marker at the front that would count as a unit.
        return len(text.encode("utf-16-le")) // 2
    return len(text)

count_graphemes

count_graphemes(text: str) -> int

Count what a person would call the letters in some text.

Bluesky's limit of 300 is counted this way, not in characters. A family emoji is seven characters and one letter; a flag is two and one; an accented letter can be either. Counting characters instead refuses posts Bluesky would have taken.

This is an approximation, and worth being honest about where it sits. Python has no grapheme splitter of its own and we add no dependencies, so this handles accents and other marks, skin tones, joined emoji and flags - which is what people actually type. It over-counts a few writing systems where a syllable is built from several characters, and the cost of that is refusing a post that would have been fine. If you need it exact, count with a library of your own and check before you post.

Parameters:

Name Type Description Default
text str

The words to count.

required

Returns:

Type Description
int

How many letters a person would say that is.

Source code in src/socialchimp/features.py
def count_graphemes(text: str) -> int:
    """Count what a person would call the letters in some text.

    Bluesky's limit of 300 is counted this way, not in characters. A family
    emoji is seven characters and one letter; a flag is two and one; an
    accented letter can be either. Counting characters instead refuses posts
    Bluesky would have taken.

    This is an approximation, and worth being honest about where it sits.
    Python has no grapheme splitter of its own and we add no dependencies,
    so this handles accents and other marks, skin tones, joined emoji and
    flags - which is what people actually type. It over-counts a few writing
    systems where a syllable is built from several characters, and the cost
    of that is refusing a post that would have been fine. If you need it
    exact, count with a library of your own and check before you post.

    Args:
        text: The words to count.

    Returns:
        How many letters a person would say that is.
    """
    count = 0
    joined_to_the_last = False
    half_a_flag = False

    for character in text:
        if character == _JOINER:
            joined_to_the_last = True
            continue

        if _attaches_to_the_letter_before(character):
            continue

        if joined_to_the_last:
            joined_to_the_last = False
            continue

        if _FIRST_FLAG_LETTER <= character <= _LAST_FLAG_LETTER:
            # Flags come in pairs, and the pair is one letter.
            half_a_flag = not half_a_flag
            if not half_a_flag:
                continue
            count += 1
            continue

        half_a_flag = False
        count += 1

    return count

Checking a post before it is sent

The two checks every platform runs before spending a request. Both refuse in plain words rather than letting the network answer with a code.

check_post

check_post(
    post: Post,
    *,
    platform: str,
    features: Feature,
    limits: Limits,
    words_alone_advice: str | None = None,
) -> None

Check a post against a network's rules before sending it.

Catching problems here means a clear message instead of a network error code, and one less wasted request against a rate limit.

Parameters:

Name Type Description Default
post Post

The post about to be sent.

required
platform str

Name of the network, used in messages.

required
features Feature

What the network can do.

required
limits Limits

What the network currently allows.

required
words_alone_advice str | None

One more sentence for the message a network with no text-only post gives back, for anything worth saying that "attach a picture or a video" does not cover. YouTube uses it to say its community posts are not in the API at all, which is the first thing somebody argues back.

None

Raises:

Type Description
NotSupportedError

If the post needs something the network cannot do.

InvalidPostError

If the post breaks one of the network's limits.

Source code in src/socialchimp/features.py
def check_post(
    post: Post,
    *,
    platform: str,
    features: Feature,
    limits: Limits,
    words_alone_advice: str | None = None,
) -> None:
    """Check a post against a network's rules before sending it.

    Catching problems here means a clear message instead of a network error
    code, and one less wasted request against a rate limit.

    Args:
        post: The post about to be sent.
        platform: Name of the network, used in messages.
        features: What the network can do.
        limits: What the network currently allows.
        words_alone_advice: One more sentence for the message a network with
            no text-only post gives back, for anything worth saying that
            "attach a picture or a video" does not cover. YouTube uses it to
            say its community posts are not in the API at all, which is the
            first thing somebody argues back.

    Raises:
        NotSupportedError: If the post needs something the network cannot do.
        InvalidPostError: If the post breaks one of the network's limits.
    """
    if post.publish_at is not None and Feature.SCHEDULE not in features:
        raise NotSupportedError(platform=platform, what="scheduling posts")

    if post.reply_to is not None and Feature.REPLY not in features:
        raise NotSupportedError(platform=platform, what="replying to posts")

    _check_length(post, platform, limits)

    if limits.posts_left_today is not None and limits.posts_left_today <= 0:
        message = (
            f"No posts left on {platform} today. Its daily limit has been "
            f"used up. Try again tomorrow."
        )
        raise InvalidPostError(message)

    _check_media(post, platform, features, limits)

    # Last of all. A post that is both too long and has nothing attached is
    # refused for the length, because that is the half the person can fix
    # without changing what they were trying to post.
    _check_it_is_not_words_alone(post, platform, features, words_alone_advice)

check_option_names

check_option_names(
    options: RawData,
    *,
    platform: str,
    allowed: Sequence[str],
    advice: str | None = None,
) -> None

Refuse a setting in Post.options that this network never heard of.

Every platform needs this and every platform wrote it, so it is written once here instead. It only checks the names. What each value has to be is different on every network - a web address on Facebook, true or false on Instagram, one of four words on Mastodon - and the message that says so is the useful half, so that part stays in the platform file.

Called before anything is sent, so a typo costs no request and no part of a rate limit.

Parameters:

Name Type Description Default
options RawData

What was put in Post.options.

required
platform str

Name of the network, used in the message.

required
allowed Sequence[str]

The settings this network takes, in the order to list them.

required
advice str | None

One more sentence, for a network where the mistake has an obvious cause. Pinterest uses it to say that Post.text is the pin's description, because description is what people reach for first.

None

Raises:

Type Description
InvalidPostError

If any setting is not one this network takes. The message names the first one and lists what is accepted.

Source code in src/socialchimp/features.py
def check_option_names(
    options: RawData,
    *,
    platform: str,
    allowed: Sequence[str],
    advice: str | None = None,
) -> None:
    """Refuse a setting in `Post.options` that this network never heard of.

    Every platform needs this and every platform wrote it, so it is written
    once here instead. It only checks the names. What each value has to be
    is different on every network - a web address on Facebook, true or false
    on Instagram, one of four words on Mastodon - and the message that says
    so is the useful half, so that part stays in the platform file.

    Called before anything is sent, so a typo costs no request and no part
    of a rate limit.

    Args:
        options: What was put in `Post.options`.
        platform: Name of the network, used in the message.
        allowed: The settings this network takes, in the order to list them.
        advice: One more sentence, for a network where the mistake has an
            obvious cause. Pinterest uses it to say that `Post.text` is the
            pin's description, because `description` is what people reach
            for first.

    Raises:
        InvalidPostError: If any setting is not one this network takes. The
            message names the first one and lists what is accepted.
    """
    for key in options:
        if key in allowed:
            continue
        message = (
            f"{platform} does not know the post option {key!r}. "
            f"It accepts: {', '.join(allowed)}."
        )
        if advice is not None:
            message = f"{message} {advice}"
        raise InvalidPostError(message, platform=platform)