Automation scripts

A script is one of the two kinds of action an automation rule can take. When the rule fires, the script runs once, with the data of the event that triggered it, and calls platform functions directly. Scripts suit fixed, predictable steps — award an achievement, move a member into a group, set a medal — that should run the same way every time.

The language

Scripts are written in Starlark, a small deterministic dialect of Python. Assignment, if/elif/else, for, lists, dicts, tuples, comprehensions and function definitions all work as they do in Python.

A script cannot open files, make network requests, or import anything. It can read the event data and call the functions on this page. Both are predeclared, so a script sees them without any import.

The script is checked for syntax when you save the rule; it is not run at that point.

Limits

Limit

Value

Running time

5 minutes

Execution steps

1,000,000

The deadline is what actually bounds a run. The step limit is a backstop against a runaway loop — walking a contest scoreboard costs a few hundred steps per row, so it sits far above what a real rule needs. Exceeding either limit stops the script, and the error is written to the rule's log.

What the environment disables

Beyond the usual Starlark restrictions, several optional features are turned off:

Restriction

What to do instead

No while loops

Iterate with for.

No recursion

A function cannot call itself.

No set() type

Use a list or a dict.

No global rebinding

Keep counters and accumulators inside a function.

if and for are allowed at the top level.

Global rebinding is the one that catches people out. A top-level name may be bound once; a second assignment to the same top-level name — including one inside the body of a top-level for — is a compile error, cannot reassign global …. A local variable inside a function has no such restriction:

def count_official(participants):
    n = 0
    for p in participants:
        if not p.unofficial:
            n = n + 1   # local, fine
    return n

total, participants = eolymp_participants_list(contest_id = contest.id, size = 500)
printf("official entrants: %d", count_official(participants))

Reading the event data

The variables a script receives depend on the rule's trigger; Triggers and conditions lists which. Each is an object whose fields you read with a dot — submission.verdict, member.attributes.grade, contest.problem_count. Timestamps are RFC 3339 text.

Functions

All functions take keyword arguments. Arguments written as name= may be omitted. Reading functions return a value; writing functions return nothing unless noted.

Achievements

eolymp_achievements_assign(member_id, achievement_id, qty=, inc_by=, reference=)

Grant an achievement. The count is set to 1 by default; qty sets it to an exact number and inc_by increases it. reference is a deduplication key — repeated runs carrying the same reference apply once.

Credits

eolymp_credits_grant(member_id, amount, reference, note=, expires_at=)

Grant credits to a member. amount must be positive. reference is required and makes the grant unique per member: the first grant returns True, and a second grant with the same reference returns False instead of paying out again. expires_at is an RFC 3339 timestamp.

eolymp_credits_list_grants(reference=, member_id=, size=, offset=)   # → (total, [grant])

Lists grants already made, narrowed to one reference, one member, or both. A rule that pays out in rounds uses it to see who has been paid under a reference.

Emails

eolymp_emails_send(member_id, template, params=, locale=, reference=, email_type=)

Send a templated email to a member. template is the path of the email template and params is a dict of values passed to it. A non-empty reference makes the send permanently unique per member — that member is never emailed twice under the same reference — and an empty reference switches that off. email_type defaults to a general, quota-counted, unsubscribable type; the account and security type is reserved and is rejected.

Members

eolymp_members_get(id)                                                  # → member
eolymp_members_list(filters=, search=, size=, offset=, sort=, order=)    # → (total, [member])
eolymp_members_set_attributes(member_id, attributes)
eolymp_members_set_preferences(member_id, locale=, timezone=, runtime=)
eolymp_members_add_group(member_id, group_id)
eolymp_members_remove_group(member_id, group_id)
eolymp_members_set_active(member_id, active)

attributes is a dict of attribute keys to text or whole numbers, merged onto the member's existing attributes; keys you do not list are untouched, and passing an empty dict does nothing. set_preferences changes only the fields you pass, and runtime is the member's default solution language.

Groups

eolymp_groups_get(id)                          # → group
eolymp_groups_list(filters=, size=, offset=)   # → (total, [group])

Participants

eolymp_participants_get(contest_id, participant_id)   # → participant
eolymp_participants_list(contest_id, filters=, search=, size=, offset=, sort=, order=)   # → (total, [participant])
eolymp_participants_set_official(contest_id, participant_id, official)
eolymp_participants_set_medal(contest_id, participant_id, medal)
eolymp_participants_set_extra_time(contest_id, participant_id, seconds)
eolymp_participants_set_active(contest_id, participant_id, active)
eolymp_participants_disqualify(contest_id, participant_id, requalify=, reason=)

medal accepts "gold", "silver", "bronze", "honorable_mention" or "none"; anything else is an error rather than a silent no-medal. disqualify takes requalify = True to reverse itself, and reason is explanatory text stored with the disqualification.

Contests

eolymp_contests_get(id)                                    # → contest
eolymp_contests_list(filters=, search=, size=, offset=)     # → (total, [contest])
eolymp_contests_get_submission(contest_id, id)              # → contest submission
eolymp_contests_list_submissions(contest_id, filters=, after=, size=, offset=)   # → (total, [contest submission])
eolymp_contests_update(id, contest)

eolymp_contests_update takes a dict shaped like the contest eolymp_contests_get returns and writes exactly the keys it carries; read-only keys such as id, format and status are ignored, so a contest read back can be changed and handed straight to it. A nested block such as scoreboard_config is replaced as a whole, so carry forward the parts of it you are not changing.

A contest's own pages, such as its overview and rules, are read and written with their own functions:

eolymp_contests_list_pages(contest_id, filters=, locale=, size=, offset=)   # → (total, [page])
eolymp_contests_get_page(contest_id, id, locale=)                           # → page
eolymp_contests_create_page(contest_id, path, title, content, visibility=)  # → page id
eolymp_contests_update_page(contest_id, id, locale=, path=, title=, content=, visibility=)

content is Markdown. eolymp_contests_update_page writes the page itself when locale is empty and that language's translation otherwise.

Scoreboard

eolymp_scoreboard_list_rows(contest_id, mode=, filters=, size=, offset=, sort=, order=)   # → (total, [row])
eolymp_scoreboard_list_attributes(contest_id, size=, offset=)                            # → (total, [attribute])
eolymp_scoreboard_set_attribute(contest_id, attribute_key, label=, index=)
eolymp_scoreboard_remove_attribute(contest_id, attribute_key)

Read a contest's standings. mode is "main" — the default, the final standing — or "frozen", "upsolve" or "virtual"; an unrecognised mode is an error. A participant's own score says nothing about placement, so a rule handing out medals or prizes works from here.

The attribute functions manage the scoreboard's attribute columns, each showing a member attribute such as a school or a grade. attribute_key is the member attribute's key, label the column header, and index the column's position. eolymp_scoreboard_set_attribute adds the column when the scoreboard does not show it yet and changes it otherwise; a label or index it is not given keeps its current value.

Combined scoreboards

A combined scoreboard ranks members across several contests.

eolymp_scoreboards_list(filters=, search=, size=, offset=)                     # → (total, [scoreboard])
eolymp_scoreboards_get(id)                                                     # → scoreboard
eolymp_scoreboards_create(name, slug=, visibility=, best_of=)                  # → scoreboard id
eolymp_scoreboards_update(id, name=, slug=, visibility=, best_of=)
eolymp_scoreboards_set_contest(scoreboard_id, contest_id, label=, index=)
eolymp_scoreboards_remove_contest(scoreboard_id, contest_id)
eolymp_scoreboards_set_attribute(scoreboard_id, attribute_key, label=, index=)
eolymp_scoreboards_remove_attribute(scoreboard_id, attribute_key)
eolymp_scoreboards_list_rows(scoreboard_id, mode=, filters=, size=, offset=, order=)   # → (total, [combined row])

visibility is "public" or "private", and best_of counts only a member's best results toward the total. eolymp_scoreboards_update changes only the fields it is given.

eolymp_scoreboards_set_contest and eolymp_scoreboards_set_attribute add the contest or column when the scoreboard does not have it yet and change it otherwise, so a rule can call them on every run. A label or index they are not given keeps its current value, and a contest added without an index goes to the end. Removing a contest also removes the members who were on the scoreboard only through it.

mode is "main" — the default — "frozen" or "upsolve"; an unrecognised mode or visibility is an error.

Problems

eolymp_problems_get(id)                                                  # → problem
eolymp_problems_list(filters=, search=, size=, offset=, sort=, order=)    # → (total, [problem])

Submissions

eolymp_submissions_get(id)                                    # → submission
eolymp_submissions_list(filters=, after=, size=, offset=)      # → (total, [submission])
eolymp_submissions_aggregate(group_by=, filters=, metric=, range_start=, range_end=)   # → [bucket]

group_by takes one or more dimensions — "SUBMITTED_AT", "VERDICT", "STATUS" — and an unknown name is an error. metric defaults to "COUNT". Without range_start and range_end only the last 30 days are counted. Each bucket has dimensions and count.

Pages

eolymp_pages_get(id, locale=)                          # → page
eolymp_pages_list(filters=, locale=, size=, offset=)   # → (total, [page])

Posts

eolymp_posts_list(filters=, search=, locale=, size=, offset=)   # → (total, [post])
eolymp_posts_get(id, locale=)                                   # → post
eolymp_posts_create(content, type_id=, labels=, featured=)      # → post id
eolymp_posts_update(id, locale=, content=)

content is Markdown, and a post's title is its first heading. A post created by a rule has no author and stays a draft: publishing it is left to a person. eolymp_posts_update writes the post itself when locale is empty and that language's translation otherwise.

Newsletters

eolymp_newsletters_list(search=, locale=, size=, offset=)   # → (total, [newsletter])
eolymp_newsletters_get(id, locale=)                          # → newsletter
eolymp_newsletters_create(name, subject, content)            # → newsletter id
eolymp_newsletters_update(id, locale=, subject=, content=)
eolymp_newsletters_import_recipients(id, filters=)

A rule can prepare a newsletter but not send it. content is Markdown. A created newsletter has no recipients; eolymp_newsletters_import_recipients adds every member matching filters — for example {"group_id": "<group-id>"} or {"inactive": False} — and the import completes in the background after the call returns. Sending or scheduling the newsletter is left to a person.

Rules

eolymp_rules_trigger(id, references=)   # → log id

Starts another rule, as if it had been run by hand from its Trigger entry. references is a dict of the entities that rule's trigger needs, keyed as the trigger's references are: contest_id, member_id, problem_id and so on. In a dry run the call is recorded and not made.

Time

Timestamps are RFC 3339 text and Starlark has no clock or duration type, so time arithmetic goes through these.

time_diff(a, b)                    # → a − b, in whole seconds; negative when a is earlier
time_shift(timestamp, seconds)     # → RFC 3339 string
convert_time(timestamp, timezone)  # → (local RFC 3339 string, UTC offset such as "+02:00")
format_time(timestamp, layout)     # → text

timezone is an IANA name, for example "Europe/Kyiv". An empty or malformed timestamp is an error, not a zero date.

format_time writes a timestamp out using a Go layout: "2006-01-02" for a date, "15:04" for a time of day, "Monday" for the day name, which is always in English. It uses the offset the timestamp already carries, so pass it the local value convert_time returned to get the date and time in that timezone.

Templates

render_template(template, data=)   # → text

Renders a Go template, with {{ }} directives, against the values in data. It suits building an email body or a reply from values the script computed.

Random

random_int(min, max)     # inclusive at both ends
random_choice(seq)
random_sample(seq, k)    # k distinct elements, shuffled
random_shuffle(seq)

Every value a script sees is identical on every run, so a hand-rolled shuffle would produce a fixed permutation. These exist for raffles, prize draws and sampling. random_sample asked for more elements than the sequence holds returns all of them shuffled rather than failing, and passing a string where a list is expected is an error. The generator is not suitable for tokens or keys.

Logging

printf(format, *args)   # %s text, %d whole numbers, %v any value
print(*args)

Both append a message to the rule's log.

Lists and filters

Every *_list function returns two values — the total number of matches and the current page:

total, members = eolymp_members_list(size = 5)
printf("space has %d members", total)
for m in members:
    printf("- %s", m.display_name)

size and offset page through results. search, sort and order ("asc" or "desc") are available where the underlying list supports them.

filters is a dict keyed by field name. Each field maps either to a bare value, meaning equality, or to a dict of operator to value:

{"verdict": "ACCEPTED"}                         # equals
{"level": {"gte": 5}}                           # greater than or equal
{"display_name": {"contains": "team"}}          # substring
{"created_at": {"gt": "2026-01-01T00:00:00Z"}}  # after a date

Operator

Meaning

eq

equals (the default)

ne, neq

not equal

gt

greater than

ge, gte

greater than or equal

lt

less than

le, lte

less than or equal

contains, containing

contains substring

starts, starting, prefix

starts with

Values may be text, whole numbers, True or False, or an RFC 3339 timestamp string. An unknown field name or operator stops the script with an error.

What a script does in a dry run

Reading functions work normally. The first writing function is recorded in the log as Dry run, with the arguments it would have used, and then raises — which stops the script there. A dry run therefore shows what the first write would have been, not the whole sequence of writes a real run would perform.

Objects

The fields available on each object, whether it arrives as event data or comes back from a function.

Object

Fields

submission

id, problem_id, user_id, member_id, lang, runtime, status, verdict, score, cost, percentage, submitted_at, judged_at, time_usage, cpu_usage, memory_usage, resource_usage

contest submission

id, contest_id, problem_id, participant_id, lang, runtime, status, verdict, score, cost, percentage, deleted, submitted_at, time_usage, cpu_usage, memory_usage, resource_usage

member

id, external_ref, display_name, rank (number), rating (number), level, inactive, attributes, user_name, user_nickname, user_email, user_email_verified, user_picture, user_city, user_country

contest

id, key, slug, name, url, image_url, format, series, classification, status, visibility, participation_mode, duration (seconds), starts_at, ends_at, problem_count, participant_count, allow_upsolve, allow_followup, display_editorials, require_admission, rated, rating_config, scoreboard_config, certification_config

participant

id, member_id, display_name, role, status, unofficial, ghost, inactive, disqualified, finalized, started_at, end_at

row

id, member_id, rank, rank_length, rank_all, rank_all_length, score, penalty, unofficial, disqualified, medal

attribute

attribute_key, index, label, type

scoreboard

id, slug, name, best_of, visibility, format, contests, attributes

combined row

member_id, display_name, rank, rank_length, rank_all, rank_all_length, score, penalty, unofficial, disqualified, contests, attributes

score

score, penalty, solved, upsolve, breakdown

ticket

id, contest_id, member_id, participant_id, subject, message, status, reply_count, created_at, updated_at, last_reply_at

reply

id, ticket_id, author (participant or jury), member_id, user_id, message, created_at

problem

id, url, type, number, title, language, languages, topics, difficulty, score, acceptance_rate, submissions_count, submissions_accepted, author, source, origin, visible, time_limit, time_limit_min, time_limit_max, cpu_limit, cpu_limit_min, cpu_limit_max, memory_limit, memory_limit_min, memory_limit_max

statement

id, locale, title, automatic, draft, author, source

comment

id, url, thread_url, member_id, reply_to, content, revision, posted_at, edited_at

group

id, name, description, external_ref, icon, badge, color

grant

id, member_id, reference, note, amount, redeemed, active, revoked, granted_at, expires_at

page

id, path, locale, locales, title, draft, automatic, content, labels

post

id, title, content, locale, locales, draft, public, featured, type_id, labels, created_at

newsletter

id, type, name, subject, content, locale, locales

schedule

year, month, day, hour, weekday (1 for Monday to 7 for Sunday)

space

id, key, name, url, status

A few of those fields need unpacking:

  • member attributes is an object keyed by attribute key, read as member.attributes.grade.

  • contest classification is an object with year, series, scale, difficulty, country, region and city.

  • contest rating_config has rated and max_rating; scoreboard_config has visibility, tie_breaker, freezing_time, unfreeze_delay, attempt_penalty, no_spoiler_ui, share_key and hide_disqualified; certification_config has enabled, affiliation and signers, a list of items with name and title. These are the blocks eolymp_contests_update replaces as a whole.

  • contest submission problem_id is the contest's problem, not the archive problem.

  • row is a scoreboard row, and its id is the participant id. It is also the row variable of the Participant finalized trigger.

  • scoreboard contests is a list in scoreboard order, each item with contest_id, index, label, name, status, starts_at and ends_at; attributes is a list of attribute columns.

  • combined row contests is a list, each item with contest_id, score, penalty and counted — whether that result is among the ones best_of counts. attributes is an object keyed by attribute key, read as row.attributes.school, holding the member's value for each attribute column.

  • score breakdown is a list, each item with problem_id, solved, score, percentage and attempts.

  • submission lang is the short language, such as cpp, while runtime is the full runtime id.

  • comment content is the comment as plain text, code blocks included. reply_to is the id of the comment it answers, empty for a top-level comment, and revision counts edits.

Examples

Grant an achievement for an accepted submission, once per problem — trigger Submission completed:

if submission.verdict == "ACCEPTED":
    eolymp_achievements_assign(
        member_id = submission.member_id,
        achievement_id = "<achievement-id>",
        reference = submission.problem_id,
    )

Award medals from the final standings — trigger Contest action, run after the contest ends and before finalizing it:

total, rows = eolymp_scoreboard_list_rows(contest_id = contest.id, mode = "main", size = 500)

def medal_for(rank):
    if rank <= 1:
        return "gold"
    elif rank <= 3:
        return "silver"
    elif rank <= 6:
        return "bronze"
    return ""

for r in rows:
    if r.unofficial or r.disqualified:
        continue
    m = medal_for(r.rank)
    if m:
        eolymp_participants_set_medal(contest_id = contest.id, participant_id = r.id, medal = m)
        printf("%s → %s", r.id, m)

Add every contest of a series to a season scoreboard, with a school column — trigger Contest action, run on any contest of the series:

total, contests = eolymp_contests_list(filters = {"series": contest.series}, size = 100)

for c in contests:
    eolymp_scoreboards_set_contest(scoreboard_id = "<scoreboard-id>", contest_id = c.id, label = c.name)

eolymp_scoreboards_set_attribute(scoreboard_id = "<scoreboard-id>", attribute_key = "school", label = "School")

Route new members into a group by attribute — trigger Member changed:

if member.attributes.grade > 9:
    eolymp_members_add_group(member_id = member.id, group_id = "<senior-group-id>")
else:
    eolymp_members_add_group(member_id = member.id, group_id = "<junior-group-id>")