librus-synergia
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.
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.
Why another Librus library?
- Uses the current login flow. The old OAuth password grant (
client_id=28) has returnedunsupported_grant_typesince 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,
FirstNamecan benull, 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 passesmypy --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, and keep that file private.
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; formal behaviour grade (ocena zachowania) |
point_grades() |
point grades (schools grading 0-100 or in points) with each category's maximum and weight; parsers.point_grades_percentage() averages them |
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; 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 |
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) |
announcements() |
school notice board (tablica ogłoszeń) |
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 |
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 - not student_number(), message(), download_attachment() or download_homework_attachment(), and messages are the 10 latest from the inbox only |
Every method logs in lazily and retries once after a fresh login if the session has expired. A session older than 2 hours is renewed through Librus's own refreshToken, so a long-running program doesn't log in with the password every day.
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.
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 once 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)))
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
Everything raises a subclass of LibrusError:
LibrusAuthError:LibrusInvalidCredentialsError,LibrusCaptchaRequiredError,LibrusSessionExpiredError(carriesstatus_code)LibrusConnectionError:LibrusServerMaintenanceError(HTTP 503)LibrusUnexpectedResponseError: a response shape we didn't expect
Please be gentle
These are real children's school accounts. Cache lookups (subjects(), teachers() and similar change rarely), 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.11
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| librus_synergia-0.3.11.tar.gz | 4.7 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| librus_synergia-0.3.11-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 4.7 MB
Release files / librus_synergia-0.3.11.tar.gz
| Download URL | librus_synergia-0.3.11.tar.gz |
|---|---|
| Size | 4.7 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eacdc195b6e59a9ffdc4631b318ebb1b230ae17983afd9ca63d78a8a81f11758
|
|
BLAKE2b-256 checksum How to use checksums |
2701acf5ef16b39ad7077d97d2dd95bd07ed9258ecbe89ab0638cdf8bd376c0d
|
| 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 9, 2026.
Transparency logRelease files / librus_synergia-0.3.11-py3-none-any.whl
| Download URL | librus_synergia-0.3.11-py3-none-any.whl |
|---|---|
| Size | 62.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e6efae53e51085effdab9440cc7bf16bc322433abb39c05c8afcb7d53a708c29
|
|
BLAKE2b-256 checksum How to use checksums |
b887dd4c137685fc68854fc3df616dd323d55bb4e6efeb57264201d214a79ac5
|
| 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 9, 2026.
Transparency log