Metadata-Version: 2.5
Name: librus-synergia
Version: 0.3.18
Summary: Unofficial async Python client for Librus Synergia (Polish school e-gradebook)
Project-URL: Homepage, https://github.com/MichalZaniewicz/librus-synergia
Project-URL: Documentation, https://michalzaniewicz.github.io/librus-synergia/
Project-URL: Issues, https://github.com/MichalZaniewicz/librus-synergia/issues
Project-URL: Home Assistant integration, https://github.com/MichalZaniewicz/ha-librus-synergia
Author: Michał Zaniewicz
License-Expression: MIT
License-File: LICENSE
Keywords: async,e-dziennik,gradebook,librus,school,synergia
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: Polish
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: aiohttp>=3.9
Requires-Dist: tzdata; sys_platform == 'win32'
Requires-Dist: yarl>=1.9
Provides-Extra: dev
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs-material<10,>=9.6; extra == 'docs'
Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
Description-Content-Type: text/markdown

# librus-synergia

<p align="center">
  <img src="https://raw.githubusercontent.com/MichalZaniewicz/librus-synergia/main/docs/hero-banner.svg" alt="librus-synergia">
</p>

<p align="center">
  <a href="https://pypi.org/project/librus-synergia/"><img alt="PyPI" src="https://img.shields.io/pypi/v/librus-synergia"></a>
  <a href="https://pypi.org/project/librus-synergia/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/librus-synergia"></a>
  <a href="https://pepy.tech/projects/librus-synergia"><img alt="Downloads" src="https://static.pepy.tech/badge/librus-synergia"></a>
  <a href="https://github.com/MichalZaniewicz/librus-synergia/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/MichalZaniewicz/librus-synergia/actions/workflows/ci.yml/badge.svg"></a>
  <a href="https://github.com/MichalZaniewicz/librus-synergia/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/MichalZaniewicz/librus-synergia"></a>
</p>

An unofficial, async, fully typed Python client for [Librus Synergia](https://synergia.librus.pl/), 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](https://github.com/MichalZaniewicz/ha-librus-synergia), 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](https://buymeacoffee.com/zanula).

[![Star this repo](https://img.shields.io/github/stars/MichalZaniewicz/librus-synergia?style=for-the-badge&logo=github&label=STAR%20THIS%20REPO&labelColor=555555&color=ffc107)](https://github.com/MichalZaniewicz/librus-synergia) [![Buy me a coffee](https://img.shields.io/badge/BUY%20ME%20A%20COFFEE-FFDD00?style=for-the-badge&logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/zanula)

## 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](https://raw.githubusercontent.com/MichalZaniewicz/librus-synergia/main/docs/demo-thumbnail.jpg)](https://github.com/MichalZaniewicz/librus-synergia#dont-want-to-read-watch-the-video)

## Why another Librus library?

<p align="center">
  <img src="https://raw.githubusercontent.com/MichalZaniewicz/librus-synergia/main/docs/trailer.webp" alt="librus-synergia trailer: install, log in, typed data, the command line">
</p>

- **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](https://michalzaniewicz.github.io/librus-synergia/).
- **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

```bash
pip install librus-synergia
```

Requires Python 3.12+.

## From the command line

No code needed to have a look at an account:

```bash
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 going through outages and timeouts (it prints the error and tries again next time), and stops only when the login fails.

## Quick start

```python
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`](https://github.com/MichalZaniewicz/librus-synergia/blob/main/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; 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) |
| `download_school_file(download_path)` | a document from `school_files()` (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 (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. A session the library creates itself also allows 6 connections per host.
- `cache_reference_data=True` - keep subjects, teachers, classrooms, categories, attendance types, the lesson map, 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.

"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:

```python
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](https://michalzaniewicz.github.io/librus-synergia/kindergarten/).

## 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:

```python
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`:

```python
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](https://michalzaniewicz.github.io/librus-synergia/)** (source in [`docs/`](https://github.com/MichalZaniewicz/librus-synergia/blob/main/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](https://michalzaniewicz.github.io/librus-synergia/errors/).

## 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](https://github.com/emsi/librus_pyapi) (MIT). Some endpoint and field names were cross-checked against [szkolny-eu/szkolny-android](https://github.com/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
