Skip to main content

librus-synergia

librus-synergia

PyPI Python Downloads CI License

An unofficial, async, fully typed Python client for Librus Synergia, the Polish school e-register ("e-dziennik"). It covers grades, attendance, timetable, agenda, homework, behaviour notes, announcements, the lucky number and private messages.

This is the engine behind the Librus Synergia Home Assistant integration, extracted so anyone can use it: in scripts, bots, dashboards or their own apps.

⭐ Useful? A star helps other parents and developers find it.

☕ Want to say thanks another way? You can buy me a coffee.

Star this repo Buy me a coffee

Don't want to read? Watch the video

Two and a half minutes: install, the command line, the Python API, the parsers, ChangeTracker, unattended sessions and the API notes. English voice-over, Polish subtitles.

Watch the librus-synergia video

Why another Librus library?

librus-synergia trailer: install, log in, typed data, the command line

  • Uses the current login flow. The old OAuth password grant (client_id=28) has returned unsupported_grant_type since 2026. This library uses the flow Librus's own web portal uses, with no captcha on a normal login.
  • Parses real responses, not guesses. The parsers are built from real account responses and cover the traps: timetables are nested period slots, ids are sometimes ints and sometimes strings, FirstName can be null, message bodies are truncated base64 wrapped in CDATA, and more. See the API notes.
  • Runs unattended. It persists the session and the long-lived device cookie, logs in again automatically when Librus drops the session early, and treats an unpublished timetable (HTTP 403) as "no timetable yet" rather than an error.
  • Async and typed. It uses aiohttp, returns dataclasses, and passes mypy --strict.

Install

pip install librus-synergia

Requires Python 3.12+.

From the command line

No code needed to have a look at an account:

librus-synergia                          # summary: grades, next days' timetable, absences, lucky number
librus-synergia --json > librus.json     # everything, as JSON
librus-synergia --watch --state seen.json   # check every 15 min and print what's new

Credentials come from the LIBRUS_LOGIN / LIBRUS_PASSWORD environment variables, or you're asked for them. Add --session session.json to reuse the login between runs; the file is written readable by you only (mode 0600), keep it private. --watch asks only for what it compares (fetch_changes()), keeps lookups that rarely change (subject names, attendance types) between rounds (cache_reference_data=True), keeps going through outages and timeouts (it prints the error and tries again next time), and stops only when the login fails.

Quick start

import asyncio
from librus_synergia import Librus, parse_grade_value

async def main():
    async with Librus("1234567u", "password") as librus:
        me = await librus.me()
        subjects = await librus.subjects()          # {subject_id: "Matematyka", ...}

        for grade in await librus.grades():
            print(subjects.get(grade.subject_id), grade.value, parse_grade_value(grade.value))

        for day, lessons in (await librus.timetable()).items():
            print(day, [subjects.get(l.subject_id) for l in lessons])

asyncio.run(main())

See examples/quickstart.py for a runnable version that also saves the session between runs.

What you can fetch

Method Returns
login(force=False) sign in now (every method below logs in lazily on first use)
me() the student (Me.User), not the parent login
student_number() class register number (nr w dzienniku), from the student's Users record (web page as fallback)
grades(), grade_categories() grades with teacher comments resolved; weights and categories
descriptive_grades(), behaviour_grades() descriptive grades (grade, skill, teacher, comments); formal behaviour grade (ocena zachowania)
partial_grades() grades from the new descriptive grading some schools use for grade 1 from 2026 (source="partial"; the item fields are not yet confirmed on a real grade)
student_identifier() the child's LID-AUTH-USER-... that partial_grades() needs, or None
grading_system() the school's scale: what + adds, what - takes away, whether 0 counts - pass it to parse_grade_value(value, grading)
point_grades(), point_grades_enabled() point grades (schools grading 0-100 or in points) with each category's maximum and weight - parsers.point_grades_percentage() averages them; whether the school has point grades on (Units)
notes(), note_categories() behaviour notes (uwagi) with positive/negative/neutral sentiment
attendances(), attendance_types() attendance records; types with is_presence_kind / is_excused_absence
justifications() absence justifications the parent submitted, with their status (is_accepted / is_pending / is_rejected); parsers.justified_dates() gives the days already covered
timetable(week_of=None) {date: [LessonData]} for one week, including parallel groups, cancellations and substitutions; a substitution's original says what lesson, teacher and room it replaces, room_changed flags a new room
agenda(), agenda_categories() terminarz: tests, quizzes, trips, parent meetings
homework() homework assignments (zadania domowe), with their attachment list
free_days(), parent_teacher_conferences() days off (school-wide and the student's class; a class entry is named after its type, e.g. "Wycieczka", and whole_day is False when only some lessons are off); conferences
text_grades() free-text grades, which grades() doesn't contain
lesson_topics() every lesson held, with its topic and subject (Realizations)
standing_timetable() the standing weekly plan (TimetableEntries); parsers.plan_differences() shows how a real week differs from it
school_trips(), school_files() school trips; documents the school shares with parents
library_lendings() books lent by the school library (title, author, dates)
plan_changes() every cancelled lesson and substitution this school year, not just the two weeks a timetable covers
homework_categories() homework assignment categories
download_attachment(attachment_id, message_id) a message attachment (name, type, bytes), without opening the message
download_homework_attachment(attachment_id) a homework attachment (name, type, bytes)
download_school_file(download_path) a document from school_files() (name, type, bytes)
announcements() school notice board (tablica ogłoszeń)
mark_announcement_read(id) marks a notice read in Librus, as opening it there does
lucky_number() szczęśliwy numerek, with the day it applies to
unread_messages(), messages(mailbox="inbox", *, limit=10) unread count per mailbox; message previews (listing never marks read); mailbox="outbox" gives sent messages with receiver_name, "archive/inbox" past school years
message(id, mailbox) the full message body, which marks it read like opening it in the app
message_recipients() who a message can go to: tutor, teachers (with the subjects they teach the student), head teachers, secretary's office, other staff
send_message(account_ids, topic, text) / reply_to_message(id, text) sends a message, or a reply in a received message's thread - once, never retried; LibrusMessageSendError.sent says whether it went out when Librus doesn't confirm
withdraw_message(id) withdraws a message you sent, while Librus still allows it
subjects(), teachers(), classrooms() id → name lookups
school(), school_class() school details; class, homeroom teacher and semester dates
kindergartener_id() the child's LID-AUTH-USER-... on a kindergarten account, else None (timetable() uses it on its own)
fetch_all() most of the above as one LibrusData snapshot (about 35 requests) - not student_number(), message() or the downloads, and messages are the 10 latest from the inbox only. Sections that failed are listed in failed_sections
fetch_changes() only what ChangeTracker needs (grades, notes, announcements, agenda, attendances, two weeks of timetable, inbox messages, subject names) - about 10 requests, for polling

Every method logs in lazily and retries once after a fresh login if the session has expired; requests that hit the same dead session share one login. A failed login is remembered for 60 s instead of sending the password again right away. A session older than 2 hours is renewed through Librus's own refreshToken (retried at most every 30 minutes when it doesn't work), so a long-running program doesn't log in with the password every day. Modules Librus refuses for the account (a 403/404, ...) come back empty and aren't asked for again for a day.

Options of Librus(...):

  • request_timeout - per data/login request, default 30 s (10 s to connect); None leaves it to the session. File downloads have their own 150 s limit.
  • max_concurrent_requests - default 6, at least 1. A session the library creates itself allows that many connections per host. File downloads have their own limit of 2 at a time, so slow downloads never hold up other requests.
  • cache_reference_data=True - keep subjects, teachers, classrooms, categories (grade, note, agenda, homework, text-grade, point-grade), attendance types, the lesson map, the standing plan, free days, school and class for reference_ttl seconds (default a day). fetch_all() fetches one again early when the data mentions an id it doesn't know. Off by default. Comment texts and the descriptive-grade skill list are always kept (fetched again for an id they don't have, or after a day).

"Today" (this week's timetable, which lessons are still ahead) is today in Poland, whatever the machine's time zone.

What's new since last time?

ChangeTracker compares snapshots and reports only new grades, behaviour notes, announcements, messages, agenda entries, absences and timetable changes (cancelled lessons, substitutions). It's the building block for notification bots:

from librus_synergia import ChangeTracker, SeenIds

tracker = ChangeTracker(SeenIds.from_dict(saved) if saved else None)
changes = tracker.update(await librus.fetch_all())
for grade in changes.grades:
    notify(f"New grade: {grade.value}")
save(tracker.seen.to_dict())      # plain JSON-able dict

The first update only remembers what already exists, so a whole school year isn't reported as "new". An item that drops out of Librus's window and comes back isn't reported twice. A kind whose data failed to fetch (LibrusData.failed_sections) is skipped - neither reported nor seeded - so a failed first fetch doesn't make a whole year look new later. For polling, fetch_changes() gets the same answers with far fewer requests than fetch_all().

Kindergarten accounts

Kindergarten (przedszkole) accounts don't have the regular timetable: Librus answers it with HTTP 403. After such a 403, timetable() looks for the child (at most once a day while none is found) and switches to the kindergarten timetable API on its own. subjects(), teachers(), classrooms() and school_class() then include the kindergarten activities, rooms and group. Lessons there are time blocks, so lesson_no is None. See kindergarten accounts.

Keeping the session between runs

A Librus session lasts about a day unless it is renewed: the client refreshes it through Librus's /refreshToken once it is two hours old, but a session that has already lapsed needs a new password login. Persist librus.session_data and pass it back to avoid logging in on every run. It also keeps Librus's year-long device cookie, which is what keeps logins captcha-free:

from dataclasses import asdict
saved = asdict(librus.session_data)            # store it (e.g. JSON)
librus = Librus(login, password, session_data=LibrusSessionData(**saved))

⚠️ Session data grants access to the account while it is valid. Store it like a password.

Multiple children / accounts

Use one Librus instance and one aiohttp.ClientSession per account. The login lives in the cookie jar, and two accounts sharing one session will silently get each other's data. If you pass your own session, don't share it between accounts.

Low-level client

Librus covers the common cases. For anything else, librus.client (a LibrusApiClient) has a method for every endpoint the library itself uses, returning raw JSON. Client methods don't log in on their own - call await librus.login() first if you haven't used a high-level method yet. You can pair it with the pure functions in librus_synergia.parsers:

from librus_synergia import parsers

await librus.login()
raw = await librus.client.async_get_units()                 # no high-level wrapper yet
week = parsers.merge_timetables(await librus.client.async_get_timetable(date(2026, 9, 7)))

Using it with Claude Code

This repository is also a Claude Code plugin marketplace with a skill for this library. With it, Claude knows the Librus API, the data models, saved sessions, polling with ChangeTracker, the errors and the traps of the Librus API while it writes code for you. In Claude Code, run:

/plugin marketplace add MichalZaniewicz/librus-synergia
/plugin install librus-synergia@librus-synergia

Claude picks the skill up when you work with librus_synergia, or you can call it with /librus-synergia:librus-synergia. To get later improvements, update the marketplace and the plugin from /plugin. The skill lives in plugins/librus-synergia, and CI type-checks its example programs against the library.

Unofficial Librus API notes

The unofficial Librus API notes (source in docs/) describe the private API itself: the login flow, every endpoint's shape, and what each status code really means. Every claim is marked as confirmed against a real account, taken from another client's source, or still unverified. It's language-agnostic, so it's useful even if you're not writing Python.

Errors

Every problem with Librus - an HTTP error, an odd answer, a network error, a timeout - raises a subclass of LibrusError:

  • LibrusAuthError: LibrusInvalidCredentialsError, LibrusCaptchaRequiredError, LibrusSessionExpiredError (carries status_code); LibrusAccountActionRequiredError is reserved and not raised yet
  • LibrusConnectionError: network errors and timeouts, HTTP 429/502/504 (with status_code), and LibrusServerMaintenanceError (HTTP 503)
  • LibrusUnexpectedResponseError: a response shape we didn't expect (carries status_code for an HTTP error such as 404)

See errors and status codes.

Please be gentle

These are real children's school accounts. Cache lookups (subjects(), teachers() and similar change rarely - cache_reference_data=True does it for you), poll with fetch_changes() rather than fetch_all(), don't poll more than every ~15 minutes, and don't retry in a loop.

Credits

The login flow is based on emsi/librus_pyapi (MIT). Some endpoint and field names were cross-checked against szkolny-eu/szkolny-android. No code was copied from it.

Disclaimer

Not affiliated with or endorsed by Librus. This uses a private API that can change at any time and may be against Librus's Terms of Service. Use it with your own account, at your own risk.

License

MIT

Metadata

Release files for librus-synergia 0.3.29

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for librus-synergia 0.3.29
File Size Uploaded
librus_synergia-0.3.29.tar.gz 4.8 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for librus-synergia 0.3.29
File Interpreter ABI Platform
librus_synergia-0.3.29-py3-none-any.whl Python 3 none any Details

Total release size: 4.9 MB

Release files / librus_synergia-0.3.29.tar.gz

Download URL librus_synergia-0.3.29.tar.gz
Size 4.8 MB
Tags Source
SHA-256 checksum
How to use checksums
4dc127ba50a3c1078e6b203fcf9fbd81ddd6d82e10b7907b7c27818fec3002cf
BLAKE2b-256 checksum
How to use checksums
8c85399e0c4bff1532ad1ccec174c5f0682e3e3c983ae05b79e44cd0fd7750eb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Release files / librus_synergia-0.3.29-py3-none-any.whl

Download URL librus_synergia-0.3.29-py3-none-any.whl
Size 98.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5cac14dc54b487c468a4ac543281fba235005aa0a97b42c0669b119ef7bdfa50
BLAKE2b-256 checksum
How to use checksums
89158977f1b7e10474cddce0098d242cb592e5c5a33f89943abb5ba3a1830925
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page