diff --git a/.gitea/CODEOWNERS b/.gitea/CODEOWNERS new file mode 100644 index 0000000..c40bdfb --- /dev/null +++ b/.gitea/CODEOWNERS @@ -0,0 +1 @@ +* @mdaleo404 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7a750a1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + pull_request: + +jobs: + precommit-and-security: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install pre-commit + run: pip install pre-commit + + - name: Run pre-commit hooks + run: pre-commit run --all-files --color always + + - name: Install Poetry and export plugin + run: | + pip install poetry + poetry self add poetry-plugin-export + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit dev dependencies (Poetry lockfile) + run: | + poetry export -f requirements.txt --without-hashes --with dev \ + | pip-audit -r /dev/stdin + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install mypy + - run: mypy + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e . pytest + - run: pytest tests/unit tests/security + - name: Integration tests (best effort) + continue-on-error: true + run: pytest tests/integration + + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build + - run: python -m build diff --git a/.gitea/workflows/security-scan.yml b/.gitea/workflows/security-scan.yml new file mode 100644 index 0000000..8428c2a --- /dev/null +++ b/.gitea/workflows/security-scan.yml @@ -0,0 +1,188 @@ +name: Security Scan + +on: + schedule: + - cron: 27 8 * * * + workflow_dispatch: + +jobs: + security-scan: + runs-on: ubuntu-latest + + env: + TARGET_DIR: . + COSIGN_VERSION: v3.0.5 + SYFT_VERSION: v1.42.3 + GRYPE_VERSION: v0.110.0 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Cosign (bootstrap) + run: | + set -euo pipefail + + FILE="cosign-linux-amd64" + + curl -fLO https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${FILE} + + chmod +x ${FILE} + mv ${FILE} /usr/local/bin/cosign + + cosign version + + - name: Install Syft (verified) + run: | + set -euo pipefail + + VERSION_NO_V="${SYFT_VERSION#v}" + FILE="syft_${VERSION_NO_V}_linux_amd64.tar.gz" + BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}" + + curl -fLO ${BASE_URL}/${FILE} + curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt + curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.sig + curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.pem + + cosign verify-blob \ + --signature syft_${VERSION_NO_V}_checksums.txt.sig \ + --certificate syft_${VERSION_NO_V}_checksums.txt.pem \ + --certificate-identity-regexp "https://github.com/anchore/syft" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + syft_${VERSION_NO_V}_checksums.txt + + CHECKSUM_LINE=$(grep " ${FILE}$" syft_${VERSION_NO_V}_checksums.txt) + if [ -z "$CHECKSUM_LINE" ]; then + echo "Missing checksum entry for ${FILE}" + exit 1 + fi + + echo "$CHECKSUM_LINE" | sha256sum -c - + + tar -xzf ${FILE} + mv syft /usr/local/bin/ + + syft version + + - name: Install Grype (verified) + run: | + set -euo pipefail + + VERSION_NO_V="${GRYPE_VERSION#v}" + FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz" + BASE_URL="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}" + + curl -fLO ${BASE_URL}/${FILE} + curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt + curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.sig + curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.pem + + cosign verify-blob \ + --signature grype_${VERSION_NO_V}_checksums.txt.sig \ + --certificate grype_${VERSION_NO_V}_checksums.txt.pem \ + --certificate-identity-regexp "https://github.com/anchore/grype" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + grype_${VERSION_NO_V}_checksums.txt + + CHECKSUM_LINE=$(grep " ${FILE}$" grype_${VERSION_NO_V}_checksums.txt) + if [ -z "$CHECKSUM_LINE" ]; then + echo "Missing checksum entry for ${FILE}" + exit 1 + fi + + echo "$CHECKSUM_LINE" | sha256sum -c - + + tar -xzf ${FILE} + mv grype /usr/local/bin/ + + grype version + + - name: Generate SBOM + working-directory: ${{ env.TARGET_DIR }} + run: | + syft dir:. -o json > sbom.json + + - name: Show SBOM contents + working-directory: ${{ env.TARGET_DIR }} + run: | + echo "Packages discovered by Syft:" + jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort + + - name: Run Grype scan (JSON) + id: audit + continue-on-error: true + working-directory: ${{ env.TARGET_DIR }} + run: | + grype sbom:sbom.json -o json > grype.json + + echo "Vulnerabilities (fixable only):" + jq -r ' + .matches[] + | select((.vulnerability.fix.versions | length) > 0) + | "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])" + ' grype.json + + # Fail only on fixable MEDIUM/HIGH/CRITICAL + jq -e ' + [ + .matches[]? + | select( + ( + .vulnerability.severity == "Medium" or + .vulnerability.severity == "High" or + .vulnerability.severity == "Critical" + ) + and + ( + (.vulnerability.fix.versions | length) > 0 + ) + ) + ] + | length == 0 + ' grype.json + + - name: Show full Grype table + working-directory: ${{ env.TARGET_DIR }} + run: | + echo "Full Grype report:" + grype sbom:sbom.json -o table + + - name: Notify Node-RED on vulnerabilities + if: steps.audit.outcome == 'failure' + working-directory: ${{ env.TARGET_DIR }} + run: | + jq ' + { + repo: "mdaleo404/schedls", + summary: ( + "Total: " + + ( + [ + .matches[] + | select((.vulnerability.fix.versions | length) > 0) + ] | length | tostring + ) + ), + vulnerabilities: [ + .matches[] + | select((.vulnerability.fix.versions | length) > 0) + | { + library: .artifact.name, + cve: .vulnerability.id, + severity: .vulnerability.severity, + installed: .artifact.version, + fixed: (.vulnerability.fix.versions[0]), + title: .vulnerability.description, + url: .vulnerability.dataSource + } + ] + } + ' grype.json \ + | curl -s -X POST https://nodered.sysmd.uk/vulns-alert \ + -H "Content-Type: application/json" \ + --data-binary @- + + - name: Fail workflow if vulnerabilities found + if: steps.audit.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index 36b13f1..69169fd 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,3 @@ cython_debug/ # PyPI configuration file .pypirc - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..230ca77 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + name: Enforce removal of trailing whitespace + - id: end-of-file-fixer + name: Enforce end of the file format + - id: check-yaml + name: Check YAML syntax + - id: check-toml + name: Check TOML syntax + - id: check-merge-conflict + name: Check for merge conflict markers + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.8 + hooks: + - id: ruff + name: Lint with ruff + args: ["--fix"] + - id: ruff-format + name: Format with ruff + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 + hooks: + - id: bandit + name: Security scan with Bandit + files: ^src/ + args: ["-lll", "-iii", "-s", "B603,B604,B607"] diff --git a/README.md b/README.md index e9bfba3..363c013 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,137 @@ +[![Licence](https://img.shields.io/badge/GPL--3.0--or--later-orange?label=Licence)](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/LICENSE) +[![Gitea Release](https://img.shields.io/gitea/v/release/mdaleo404/schedls?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/mdaleo404/schedls/releases) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?logo=pre-commit&style=flat)](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/.pre-commit-config.yaml) + # schedls +
+ schedls logo +
+ +Inspect and manage the things Linux runs later. + +`schedls` is a CLI tool for inspecting and managing cron jobs and +systemd timers through one transparent interface. It is a management and +inspection layer over the operating system's native schedulers — it is not a +daemon, not a queue, and not a replacement scheduler. + +```console +$ schedls +NAME SCHEDULE NEXT BACKEND SCOPE STATUS +backup daily at 02:00 tomorrow 02:00 systemd user waiting +cleanup 0 4 * * 0 — cron user active +logrotate daily — systemd system waiting +``` + +If you uninstall `schedls`, everything it created keeps working: a timer is an +ordinary systemd unit, and a cron job is an ordinary crontab entry. + +## Install + +```console +$ pipx install schedls +# or +$ pip install --user schedls +``` + +`python -m schedls` works too. Zero runtime dependencies; Python 3.11+. + +## Examples + +Discover everything visible to you: + +```console +$ schedls +$ schedls --system +$ schedls --backend cron +``` + +Explore a native calendar expression without creating anything: + +```console +$ schedls calendar 'Mon..Fri 02:30' +$ schedls calendar --next 10 --utc daily +``` + +Create a user systemd timer: + +```console +$ schedls new backup --timer --daily 02:00 --persistent -- /usr/local/bin/backup /srv/data +``` + +Create a cron job: + +```console +$ schedls new cleanup --cron --cron-expr '0 4 * * 0' -- /usr/local/bin/cleanup +``` + +Prefer to be guided? Add `-i`/`--interactive` to `new` or `edit` and any +omitted field is prompted for. Flags you do pass become pre-filled defaults: + +```console +$ schedls new backup -i +$ schedls edit backup -i +``` + +Interactive mode is line-based (no external editor or pager), requires a +terminal, and cannot be combined with `--json`. The collected values are shown +in the usual preview and still require confirmation before anything is written. + +Inspect, change, disable, remove: + +```console +$ schedls show backup +$ schedls edit backup --daily 03:00 +$ schedls disable backup +$ schedls rm backup +$ schedls logs backup +``` + +Check what this host can do: + +```console +$ schedls doctor +``` + +Every mutating command supports `--dry-run`, and `--yes` for scripts. Machine +output is available with `--json`. + +## Design promises + +`schedls` does not: + +- run a background daemon; +- listen on a network port; +- make network requests; +- automatically invoke `sudo`; +- execute discovered scheduled commands; +- open an editor or pager while managing jobs; +- use a shell internally for helper commands; +- modify unmanaged schedules by default. + +See [SECURITY.md](SECURITY.md) and [docs/security-model.md](docs/security-model.md). + +## Documentation + +- [docs/cli.md](docs/cli.md) — every command, option and exit code +- [docs/architecture.md](docs/architecture.md) +- [docs/security-model.md](docs/security-model.md) +- [docs/systemd.md](docs/systemd.md) +- [docs/cron.md](docs/cron.md) + +## Development + +```console +$ poetry install +$ poetry run pre-commit install # check hooks on every commit +$ poetry run pre-commit run --all-files +$ poetry run pytest +$ poetry run mypy +``` + +Formatting and linting use [ruff](https://docs.astral.sh/ruff/); security +scanning uses [Bandit](https://bandit.readthedocs.io/); both run through +[pre-commit](https://pre-commit.com/) alongside trailing-whitespace, end-of-file, +YAML and TOML checks. CI runs the same hooks on every push and pull request, +followed by a strict type check, the test matrix (Python 3.11–3.14) and a package +build. A scheduled workflow builds an SBOM and scans it with Grype. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..51c72e1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Security policy + +`schedls` writes scheduler configuration and, when run as root, writes files in +privileged locations. Security is therefore a primary design concern. + +## Reporting a vulnerability + +Report suspected vulnerabilities privately to the maintainer listed in +`pyproject.toml`. Do not open a public issue for a security-sensitive report. + +## Promises + +`schedls` does not: + +- run a background daemon; +- listen on a network port; +- make network requests; +- automatically invoke `sudo`, `su`, `pkexec`, or any other privilege + escalation mechanism; +- execute discovered scheduled commands; +- open an editor or pager while managing jobs; +- use a shell internally for helper commands (`shell=True` is forbidden); +- modify unmanaged schedules by default. + +## Security model + +See [docs/security-model.md](docs/security-model.md) for the threat model and +the mitigations implemented in this repository. + +## Release checklist + +Before every release: + +```text +[ ] No new shell=True usage +[ ] No automatic privilege escalation +[ ] No new implicit command execution +[ ] No pager/editor invocation under privilege +[ ] Unit serialization tests pass +[ ] Cron serialization tests pass +[ ] Symlink tests pass +[ ] Atomic-write tests pass +[ ] Trusted-directory ownership/mode tests pass +[ ] Helper resolution ownership tests pass +[ ] Unit-name path traversal tests pass +[ ] Log sanitization tests pass +[ ] Dependency audit passes +[ ] JSON schema changes reviewed +[ ] New mutating operations support --dry-run +[ ] New mutating operations have clear confirmation behavior +[ ] Documentation describes new filesystem/state changes +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..938f8c9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,127 @@ +# Architecture + +`schedls` is a thin CLI over two native scheduling backends. It holds no state +of its own: the scheduler configuration on the host is the source of truth. + +```text +parse args + | + v +validate request + | + v +operation / service layer operations/ + | + v +backend backends/systemd.py, backends/cron.py + | + v +result model models.py + | + v +formatter output.py +``` + +## Package layout + +```text +src/schedls/ + __init__.py version + __main__.py python -m schedls + cli.py argparse, dispatch, request building + errors.py exception hierarchy and exit codes + models.py common model (jobs, specs, schedules, commands) + output.py human tables, key/value views, JSON schema + runner.py the only place subprocesses are executed + security.py name validation, trusted paths, atomic writes + timefmt.py durations, timestamps, formatting + describe.py presentation-only schedule descriptions + convenience.py convenience flags -> native expressions + unitfile.py read-only systemd unit-file parser + interact.py confirmation handling + prompt.py opt-in interactive input collection (wizard) + backends/ + base.py capability model, plans, mutation results + systemd.py discovery + transactional mutation + cron.py lossless crontab model + discovery + mutation + renderers/ + systemd.py ExecStart quoting, unit rendering, ExecStart parsing + cron.py shell-safe command rendering, managed blocks + operations/ + inspect.py collect, filter, list, show + calendar.py schedls calendar + doctor.py schedls doctor + mutate.py preview, confirm, apply +``` + +## Layers + +- **Backends** implement discovery and mutation against native facilities and + never format output. +- **Renderers** are pure functions from an internal model to native text. They + are the most security-sensitive code and are covered by exhaustive tests. +- **Operations** orchestrate: gather, preview, confirm, apply. +- **CLI** translates arguments into a request, calls one operation, and maps + errors to exit codes. With `--interactive` it first fills missing fields via + **prompt** (line-based, terminal-only), then continues down the normal path. +- **Runner** is the single process-execution boundary. `shell=True` never + appears in production code. + +## Mutation plans + +Mutating commands do not write directly. A backend produces a `Plan`: + +```text +Plan + action create | update | remove | enable | disable + summary key/value lines for the preview + files FileChange(path, content | None) + commands CommandPlan(argv, description, input_text) + warnings messages to show the user + payload backend-private state (scope, unit names, snapshots) +``` + +The caller previews the plan, obtains confirmation, then calls `apply`. Plans +snapshot existing content so a failed step can be rolled back. Cron plans also +record the previous crontab text and re-check it at apply time to detect +concurrent edits. + +## Exit codes + +```text +0 success +1 operational failure +2 invalid command-line input or invalid schedule +3 safety refusal / conflict +``` + +## JSON schema + +`schedls --json` emits a single document on stdout; diagnostics go to stderr. +The schema version is `1`. + +```json +{ + "schema_version": 1, + "jobs": [ + { + "name": "backup", + "backend": "systemd", + "scope": "user", + "managed": true, + "enabled": true, + "schedule": {"kind": "calendar", "expression": "*-*-* 02:00:00"}, + "command": {"argv": ["/usr/local/bin/backup"], "shell": false, "raw": null}, + "source": {"detail": "systemd user timer", "path": "...", "line": null}, + "next_run": "2026-09-25T02:00:00+01:00", + "last_run": null, + "last_result": null, + "warnings": [] + } + ], + "warnings": [] +} +``` + +Rules: no ANSI sequences; ISO 8601 timestamps with explicit offsets; stable +field names; missing data is `null`, never invented. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..d617127 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,283 @@ +# Command-line reference + +Complete reference for every `schedls` command, option and exit code. For +background on the backends see [systemd.md](systemd.md) and [cron.md](cron.md); +for the JSON document layout see [architecture.md](architecture.md#json-schema). + +## Synopsis + +```text +schedls [GLOBAL OPTIONS] [COMMAND] [COMMAND OPTIONS] [-- COMMAND ARGS...] +``` + +With no command, `schedls` behaves like `schedls list`. + +## Global options + +Global options may appear before the command. `--user`/`--system`, +`--backend`, `--managed`/`--unmanaged` and `--enabled`/`--disabled` filter what +`list` discovers and prints. + +| Option | Meaning | +|-------------------------|---------------------------------------------------------------------| +| `--version` | print the version and exit | +| `--debug` | print helper commands and diagnostics on stderr | +| `--json` | emit a single JSON document on stdout (schema version 1) | +| `--color auto\|always\|never` | colorize human output (default `auto`; `NO_COLOR` disables) | +| `--utc` | display all times in UTC instead of local time | +| `--user` / `--system` | only show user-scope / system-scope jobs | +| `--backend systemd\|cron` | only show jobs from one backend | +| `--managed` / `--unmanaged` | only schedls-managed / only unmanaged jobs | +| `--enabled` / `--disabled` | only enabled / only disabled jobs | + +`--json` is intended for scripts. It prints one document on stdout and sends +diagnostics to stderr; no ANSI escapes are emitted. + +## Commands + +### `list` + +```console +$ schedls +$ schedls --system +$ schedls --backend cron +$ schedls --unmanaged --json +``` + +Lists visible scheduled jobs. Columns are `NAME`, `SCHEDULE`, `NEXT`, `BACKEND`, +`SCOPE`, `STATUS`. A missing next run is shown as `—`. Discovery warnings are +written to stderr as `Note:` lines. + +### `show NAME` + +```console +$ schedls show backup +``` + +Shows one job in detail: status, backend and scope, whether schedls manages it, +the schedule, the next and previous runs, the command, the source paths, and any +systemd extras (persistent, jitter, accuracy, working directory, environment). + +### `new NAME` + +```console +$ schedls new backup --timer --daily 02:00 --persistent -- /usr/local/bin/backup /srv/data +$ schedls new cleanup --cron --cron-expr '0 4 * * 0' -- /usr/local/bin/cleanup +$ schedls new backup -i +``` + +Creates a scheduled job. `NAME` and a backend (`--timer` or `--cron`) are +required unless `--interactive` is used. + +The command to run is everything after `--`: + +```console +$ schedls new backup --timer --daily 02:00 -- /usr/local/bin/backup "/srv/my data" +``` + +Arguments after `--` are passed literally as an argv; `schedls` does not +interpret `|`, `>`, `$()` or `$VAR`. Use `--shell SCRIPT` instead when you +explicitly want `/bin/sh -c`: + +```console +$ schedls new rotate --timer --daily 03:00 --shell 'find /tmp -mtime +7 -delete' +``` + +`--shell` and a command after `--` are mutually exclusive. + +### `edit NAME` + +```console +$ schedls edit backup --daily 03:00 +$ schedls edit backup --jitter 5min --no-persistent +$ schedls edit backup --command -- /usr/local/bin/backup /srv/data +$ schedls edit backup -i +``` + +Changes a job created by `schedls`. Only the options you pass are changed; +everything else keeps its current value. + +- To replace the command, pass `--command` before the new command after `--`. + Passing `--` without `--command` is an error, so the command can never be + changed by accident. +- `--no-persistent` clears `Persistent=` on a systemd timer. +- Editing is supported for systemd timers only. Editing cron jobs is not yet + supported and reports a usage error. + +### `rm NAME` + +```console +$ schedls rm backup +$ schedls rm backup --dry-run +``` + +Removes a schedls-managed job and its files. Unmanaged jobs cannot be removed. + +### `enable NAME` / `disable NAME` + +```console +$ schedls enable backup +$ schedls disable backup +``` + +Enable or disable a systemd timer. Cron has no portable enabled/disabled +concept, so these commands explain that limitation and exit without changing +anything. + +### `logs NAME` + +```console +$ schedls logs backup +$ schedls logs backup --lines 200 +$ schedls logs backup --since '2026-09-01' --lines 500 +``` + +Shows recent journal output for a systemd timer's service unit. The command is +never re-executed; this only reads the journal through `journalctl --no-pager`. + +| Option | Default | Meaning | +|----------------|---------|-------------------------------------------------------------------------| +| `--lines N` | `50` | number of recent journal entries to show (`journalctl --lines=N`); `N` must be between `1` and `1000000` | +| `--since TIME` | none | only entries since `TIME`, passed verbatim to `journalctl --since` | + +`TIME` accepts anything `journalctl --since` accepts, such as +`'2026-09-01'`, `'2 hours ago'` or `'2026-09-01 10:00:00'`. `--since` bounds the +range and `--lines` caps how many entries within it are shown, so raise +`--lines` to see more of a longer history. + +Journal output is untrusted program output. When stdout is a terminal, control +characters are escaped (`\x1b` and friends) so a log line cannot inject terminal +escapes; when output is redirected to a file or pipe the bytes are passed +through unchanged. With `--json`, the command emits a document instead of raw +text: + +```json +{ + "schema_version": 1, + "name": "backup", + "backend": "systemd", + "unit": "schedls-backup.service", + "content": "..." +} +``` + +Cron jobs have no portable per-job log interface. For a cron job, `schedls logs` +explains this (cron may mail output, redirect it, or write to the system log) +and exits `0`. + +### `calendar EXPR` + +```console +$ schedls calendar 'Mon..Fri 02:30' +$ schedls calendar --next 10 --utc daily +``` + +Validates a systemd `OnCalendar` expression with `systemd-analyze calendar` and +prints its normalized form and upcoming occurrences. Nothing is created. + +| Option | Default | Meaning | +|-------------|---------|-------------------------------------| +| `--next N` | `5` | how many upcoming occurrences to show | + +Requires `systemd-analyze`. Without it the command reports a missing dependency. + +### `doctor` + +```console +$ schedls doctor +``` + +Reports which native facilities are usable on this host: systemd manager +availability, `systemd-analyze`, user lingering, `crontab` availability, the +detected cron implementation, whether the current user may use cron, and +whether the local `crontab` supports syntax validation. It only inspects; it +never changes anything. + +## Schedule options + +`new` and `edit` accept the same schedule options. Convenience flags compile to +native syntax; see the per-backend tables in [systemd.md](systemd.md#calendar-expressions) +and [cron.md](cron.md#schedule-shortcuts). + +| Option | Backend | Example | +|-------------------------|----------------|----------------------------------| +| `--calendar EXPR` | systemd only | `--calendar 'Mon..Fri 02:30'` | +| `--daily TIME` | both | `--daily 02:00` | +| `--weekdays TIME` | both | `--weekdays 08:30` | +| `--weekly DAY TIME` | both | `--weekly sun 04:00` | +| `--monthly DAY TIME` | both | `--monthly 15 06:00` | +| `--cron-expr EXPR` | cron only | `--cron-expr '0 4 * * 0'` | +| `--persistent` | systemd only | run events missed while powered off | +| `--jitter DURATION` | systemd only | `--jitter 5min` | +| `--accuracy DURATION` | systemd only | `--accuracy 1min` | +| `--working-directory PATH` | systemd only | `--working-directory /srv/data` | +| `--env KEY=VALUE` | systemd only | `--env TZ=UTC` (repeatable) | +| `--shell SCRIPT` | both | `--shell 'echo hi \| tee /tmp/log'` | + +Notes: + +- `--calendar` may be repeated; systemd supports multiple `OnCalendar=` entries. +- Do not combine `--calendar`/`--cron-expr` with the convenience flags; `schedls` + rejects mixing them rather than guessing which you meant. +- Backend-specific options are rejected for the wrong backend (for example + `--cron-expr` with `--timer`, or `--persistent` with `--cron`) instead of + being silently ignored. +- Cron accepts a single schedule. +- Cron environment variables are not supported yet; passing `--env` with + `--cron` is rejected rather than silently ignored. +- Cron jobs are always created for the current user. `--system` selects system + scope for systemd timers and is rejected for cron. +- `new --timer` defaults to user scope. System scope requires appropriate + privileges; `schedls` never runs `sudo` for you. + +## Mutation options + +These options apply to `new`, `edit`, `rm`, `enable` and `disable`: + +| Option | Meaning | +|----------------|-------------------------------------------------------------------------| +| `--dry-run` | print the plan and exit without changing anything | +| `--yes` | do not ask for confirmation (for scripts) | +| `--show-files` | include the rendered file contents in the preview | +| `-i, --interactive` | collect missing fields with guided prompts (see below) | + +Every mutation prints a preview of exactly what will be written (paths, and with +`--show-files` the contents) and asks for confirmation before applying. Without +a terminal, confirmation fails unless `--yes` is passed. + +## Interactive mode + +`new` and `edit` accept `-i`/`--interactive` to fill missing fields with guided, +line-based prompts. Provided flags act as pre-filled defaults and are not +re-asked. + +```console +$ schedls new backup -i +$ schedls edit backup -i +``` + +- Prompts use stdin only; no external editor or pager is ever launched. +- A terminal is required. With piped or redirected input, `schedls` exits `3` + and asks you to pass flags instead. +- Interactive mode cannot be combined with `--json` (exit `2`). +- Collected values still go through the normal preview and confirmation, so + nothing is written without showing you the resolved schedule and files first. + +## Exit codes + +| Code | Meaning | +|------|--------------------------------------------------------------| +| `0` | success | +| `1` | operational failure (helper command, filesystem, discovery) | +| `2` | invalid command-line input or invalid schedule | +| `3` | safety refusal / conflict (unmanaged object, concurrency, no TTY for confirmation) | + +## Environment + +| Variable | Effect | +|------------|----------------------------------------------------| +| `NO_COLOR` | when set, disables color even with `--color auto` | + +Helper commands run with a controlled environment (`LC_ALL=C`, +`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and are never run through +a shell. diff --git a/docs/cron.md b/docs/cron.md new file mode 100644 index 0000000..8c9dfeb --- /dev/null +++ b/docs/cron.md @@ -0,0 +1,90 @@ +# cron backend + +`schedls` reads and edits the **current user's crontab** through the `crontab` +utility. It never writes to `/var/spool/cron*` directly. + +## Reading + +The crontab is read with `crontab -l` and parsed losslessly into lines: + +- blank lines; +- comments; +- environment assignments (`KEY=VALUE`); +- five-field entries and `@` nicknames; +- `schedls:begin` / `schedls:end` managed-block markers. + +Every unrelated line is preserved exactly. `schedls` does not reformat, sort, +normalize, or rewrite crontab content it did not create. + +## Managed blocks + +Jobs created by `schedls` live in a delimited block: + +```cron +# schedls:begin name=backup +0 2 * * * /usr/local/bin/backup /srv/data +# schedls:end name=backup +``` + +If markers are malformed or a `begin` has no matching `end`, mutations refuse +to proceed rather than guess. + +## Command rendering + +Cron executes command text through a shell. `schedls` keeps an argv internally +and serializes it safely: + +1. each argument is quoted for `/bin/sh` (`shlex.quote`); +2. every `%` is escaped as `\%`, because cron implementations such as Cronie + treat an unescaped `%` as a newline. + +Arguments containing NUL or newline are rejected, because no safe single-line +representation exists. + +Raw shell syntax is available explicitly with `--shell`. `schedls` never infers +shell mode from characters such as `|`, `>`, `&&` or `$()`. + +## Schedule shortcuts + +| Flag | Example | Compiles to | +|-------------------|-----------------------|-------------------| +| `--daily TIME` | `--daily 02:00` | `0 2 * * *` | +| `--weekdays TIME` | `--weekdays 08:30` | `30 8 * * 1-5` | +| `--weekly D TIME` | `--weekly sun 04:00` | `0 4 * * 0` | +| `--monthly D TIME`| `--monthly 15 06:00` | `0 6 15 * *` | +| `--cron-expr EXPR`| `--cron-expr '0 4 * * 0'` | used verbatim | + +## Installation and validation + +When the local `crontab` supports syntax testing (`crontab -T`), the new +crontab is validated before installation. The native `crontab` install remains +authoritative. The previous crontab text is retained and restored if +installation fails or the crontab changed since the plan was prepared. + +Capabilities are feature-detected, never assumed: + +```python +CronCapabilities( + supports_validation=True, + supports_user_selection=False, + implementation="Cronie", +) +``` + +## Enable / disable + +Cron has no universal native enabled/disabled concept. `schedls enable` and +`schedls disable` support systemd only and explain this limitation for cron. + +## Logs + +There is no portable per-job log interface for cron. `schedls logs` says so +rather than pretending otherwise. `--lines` and `--since` are accepted for +command-line compatibility but have no effect on cron jobs; see +[cli.md](cli.md#logs-name). + +## Not yet supported + +- system cron (`/etc/crontab`, `/etc/cron.d`, periodic directories); +- editing existing cron jobs; +- cross-user crontabs. diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..001ba6d --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,110 @@ +# Security model + +This document describes what `schedls` trusts, what it does not, and how the +main threats are mitigated. It complements [SECURITY.md](../SECURITY.md). + +## Trust model + +- **Native schedulers are authoritative.** systemd and cron own execution. + `schedls` only reads and writes their configuration. +- **Discovered configuration is data.** Commands and schedules found on the + host are parsed and displayed, never executed. +- **Unmanaged objects are protected.** A timer or cron entry not created by + `schedls` cannot be modified or removed by default. +- **The user chooses privilege.** `schedls` never escalates on the user's + behalf. + +## Threats and mitigations + +### Malicious schedule names + +Names must match `[A-Za-z0-9][A-Za-z0-9_.-]{0,63}`. Path separators, traversal, +whitespace and shell metacharacters are rejected. Names are never interpolated +into shell strings. + +### Malicious command arguments + +Arguments are kept as an argv internally. systemd units are rendered with a +dedicated serializer that always quotes arguments and escapes backslash, +double quotes, literal `$` and `%`; arguments containing NUL or newline are +rejected. cron commands are serialized with shell quoting and then have every +`%` escaped for cron's own parser. Calendar expressions are validated with +`systemd-analyze` and must not contain control characters or begin with `-`; +timer durations (`--jitter`, `--accuracy`) are validated with +`systemd-analyze timespan`. If a safe representation cannot be guaranteed, +creation is refused. + +### Symlink attacks during privileged writes + +Before replacing a file, `schedls` uses `lstat`, rejects symlinks and +non-regular files, and verifies ownership. Destination directories must be +real directories owned by the expected user (`root` when running as root) and +must not be group- or other-writable. Temporary files are created in the same +directory and atomically renamed. + +### PATH hijacking + +Critical helpers (`systemctl`, `systemd-analyze`, `journalctl`, `crontab`, +`loginctl`) are resolved once with a controlled lookup. A helper is rejected +unless the executable **and** its realpath target are regular files, are owned +by root (when `schedls` runs as root) or by root or the current user, and live +in a directory that is not group- or other-writable. Helper processes run with +a fixed `PATH` rather than the caller's. + +### Path traversal through discovered unit names + +A `Unit=` value read from a timer file is treated as untrusted data. Mutation +never uses it to build a filesystem path: unit names are always derived from +the validated job name (`schedls-.timer` / `.service`). Generated unit +names are re-validated before they are written to a temporary directory, so a +crafted unit cannot redirect writes or removals outside the trusted unit +directory. + +### Untrusted output + +`journalctl` output is program output, not configuration. When stdout is a +terminal, control characters are escaped before display so a log line cannot +inject terminal escape sequences; redirected output is passed through +unchanged. Machine-readable `--json` output escapes control characters as part +of JSON encoding. + +### Environment injection and pagers + +Helper commands run with a controlled environment (`LC_ALL=C`, +`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and never with a shell. +Pagers and editors are never launched while `schedls` is running. Environment +values passed with `--env` are written to unit files with systemd quoting and +are world-readable like any other unit; do not put secrets in them. + +### TOCTOU races + +Plans snapshot existing content. At apply time the current content is compared +against the snapshot; a mismatch aborts the operation. Privileged critical +sections are kept small and use file descriptors / atomic rename where +practical. + +### Corrupt or malicious existing configuration + +Configuration is treated as opaque data. Malformed `schedls` cron markers cause +mutation to fail closed rather than guess. Unknown crontab lines are preserved +byte-for-byte. A systemd unit whose name starts with `schedls-` is only treated +as managed when its file carries the `# Managed by schedls` and `# Name:` +markers, so a same-named foreign unit is not modified. + +### Partial operations + +systemd creation writes both units, reloads the manager, and enables the timer; +if a later step fails, or the operation is interrupted, the previous state is +restored and a partially enabled timer is disabled again. Cron installs are +validated first and restored from the previous crontab text on failure. + +### Supply chain + +Runtime dependencies are zero. Development dependencies are locked and audited +in CI. + +## Availability, privacy + +`0.1.0` requires `systemd-analyze` to create or validate calendar expressions +and refuses the operation if it is unavailable. `schedls` makes no network +requests and collects no telemetry. diff --git a/docs/systemd.md b/docs/systemd.md new file mode 100644 index 0000000..9d2fc1b --- /dev/null +++ b/docs/systemd.md @@ -0,0 +1,106 @@ +# systemd backend + +`schedls` manages systemd **timers**. A timer is always paired with a +oneshot service that holds the command. + +## Layout + +| Scope | Unit directory | Manager | +|--------|------------------------------|----------------------| +| user | `~/.config/systemd/user` | `systemctl --user` | +| system | `/etc/systemd/system` | `systemctl` | + +`new --timer` defaults to user scope. System scope must be requested +explicitly with `--system` and appropriate privileges; `schedls` never invokes +`sudo` for you. + +## Created files + +For a job named `backup`: + +```ini +# ~/.config/systemd/user/schedls-backup.service +# Managed by schedls +# Name: backup + +[Unit] +Description=schedls job backup + +[Service] +Type=oneshot +ExecStart="/usr/local/bin/backup" "/srv/data" +``` + +```ini +# ~/.config/systemd/user/schedls-backup.timer +# Managed by schedls +# Name: backup + +[Unit] +Description=schedls job backup (timer) + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true +Unit=schedls-backup.service + +[Install] +WantedBy=timers.target +``` + +The `schedls-` prefix and the header comments provide provenance without a +database. + +## Argument handling + +`ExecStart=` is not shell syntax. `schedls` serializes each argument with +systemd's own quoting rules and always quotes arguments, so a value such as +`$(...)`, `%i`, `$HOME` or an argument beginning with `-` is passed literally. +Use `--shell` to explicitly request `/bin/sh -c`. + +Every rendered unit is validated with `systemd-analyze verify` before +installation. + +## Calendar expressions + +Convenience flags compile to native `OnCalendar=` values: + +| Flag | Example | Compiles to | +|------------------|----------------------------|------------------------------------| +| `--daily TIME` | `--daily 02:00` | `*-*-* 02:00:00` | +| `--weekdays TIME`| `--weekdays 08:30` | `Mon..Fri *-*-* 08:30:00` | +| `--weekly D TIME`| `--weekly sun 04:00` | `Sun *-*-* 04:00:00` | +| `--monthly D TIME`| `--monthly 1 06:00` | `*-*-01 06:00:00` | +| `--calendar EXPR`| `--calendar 'Mon..Fri 02:30'` | used verbatim | + +`--calendar` may be repeated; systemd supports multiple `OnCalendar=` entries. + +## Discovery + +Timers are listed with `systemctl list-unit-files` and `list-units`, then +properties are read with `systemctl show`. OnCalendar, Persistent and the +command are read from the unit fragments. The next elapse is taken from +`NextElapseUSecRealtime`; monotonic timers have no wall-clock next run and are +reported as unknown. + +## Logs + +`schedls logs NAME` queries the service unit's journal via `journalctl +--no-pager`. The job command is never re-executed. + +Two options control how much is shown, both passed to `journalctl`: + +| Option | Default | Meaning | +|----------------|---------|---------------------------------------------------------------| +| `--lines N` | `50` | number of recent entries (`journalctl --lines=N`) | +| `--since TIME` | none | lower bound on entry time (`journalctl --since=TIME`) | + +`TIME` accepts any value `journalctl --since` accepts, for example +`'2026-09-01'`, `'2 hours ago'` or `'2026-09-01 10:00:00'`. `--since` bounds the +range and `--lines` caps how many entries within it are shown. See +[cli.md](cli.md#logs-name) for the full command reference. + +## Lingering + +User timers only run while the user's systemd manager is alive. `schedls` +warns when lingering is disabled but never enables it. diff --git a/poetry.lock b/poetry.lock index f930611..cd9b1e7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,7 +1,823 @@ # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. -package = [] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d"}, + {file = "ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1"}, + {file = "ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4"}, + {file = "ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e"}, + {file = "ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99"}, + {file = "ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484"}, + {file = "ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27"}, + {file = "ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450"}, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.16.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853"}, + {file = "coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097"}, + {file = "coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a"}, + {file = "coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881"}, + {file = "coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43"}, + {file = "coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b"}, + {file = "coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec"}, + {file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9"}, + {file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38"}, + {file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a"}, + {file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594"}, + {file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773"}, + {file = "coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc"}, + {file = "coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90"}, + {file = "coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da"}, + {file = "coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07"}, + {file = "coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5"}, + {file = "coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475"}, + {file = "coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567"}, + {file = "coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e"}, + {file = "coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d"}, + {file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b"}, + {file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf"}, + {file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01"}, + {file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08"}, + {file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572"}, + {file = "coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2"}, + {file = "coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7"}, + {file = "coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22"}, + {file = "coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af"}, + {file = "coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c"}, + {file = "coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809"}, + {file = "coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf"}, + {file = "coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f"}, + {file = "coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360"}, + {file = "coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08"}, + {file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941"}, + {file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489"}, + {file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776"}, + {file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6"}, + {file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0"}, + {file = "coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d"}, + {file = "coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e"}, + {file = "coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8"}, + {file = "coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9"}, + {file = "coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245"}, + {file = "coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5"}, + {file = "coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5"}, + {file = "coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f"}, + {file = "coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41"}, + {file = "coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878"}, + {file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2"}, + {file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080"}, + {file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037"}, + {file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b"}, + {file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0"}, + {file = "coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a"}, + {file = "coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa"}, + {file = "coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d"}, + {file = "coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15"}, + {file = "coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57"}, + {file = "coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3"}, + {file = "coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc"}, + {file = "coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751"}, + {file = "coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab"}, + {file = "coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9"}, + {file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412"}, + {file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a"}, + {file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540"}, + {file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9"}, + {file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361"}, + {file = "coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b"}, + {file = "coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b"}, + {file = "coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f"}, + {file = "coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152"}, + {file = "coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb"}, + {file = "coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741"}, + {file = "coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1"}, + {file = "coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72"}, + {file = "coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8"}, + {file = "coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c"}, + {file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a"}, + {file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da"}, + {file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2"}, + {file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e"}, + {file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae"}, + {file = "coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2"}, + {file = "coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d"}, + {file = "coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6"}, + {file = "coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743"}, + {file = "coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e"}, + {file = "coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae"}, + {file = "coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370"}, + {file = "coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae"}, + {file = "coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de"}, + {file = "coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1"}, + {file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c"}, + {file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6"}, + {file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f"}, + {file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df"}, + {file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040"}, + {file = "coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827"}, + {file = "coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d"}, + {file = "coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855"}, + {file = "coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6"}, + {file = "coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d"}, + {file = "coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6"}, + {file = "coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1"}, + {file = "coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1"}, + {file = "coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b"}, + {file = "coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de"}, + {file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef"}, + {file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876"}, + {file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8"}, + {file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220"}, + {file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0"}, + {file = "coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5"}, + {file = "coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db"}, + {file = "coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0"}, + {file = "coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4"}, + {file = "coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "distlib" +version = "0.4.3" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, +] + +[[package]] +name = "filelock" +version = "4.0.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-4.0.3-py3-none-any.whl", hash = "sha256:30cd166e2aee2c7534ce2c33c6367c4cd051b8368e20e2c4eb0c34f699bacfab"}, + {file = "filelock-4.0.3.tar.gz", hash = "sha256:87296d60478e14204fd9406e79831400fef76693bae2895deec236c98e87a8aa"}, +] + +[[package]] +name = "identify" +version = "2.6.19" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, + {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.15.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489"}, + {file = "librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702"}, + {file = "librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c"}, + {file = "librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e"}, + {file = "librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053"}, + {file = "librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22"}, + {file = "librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a"}, + {file = "librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0"}, + {file = "librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb"}, + {file = "librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db"}, + {file = "librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56"}, + {file = "librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e"}, + {file = "librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa"}, + {file = "librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2"}, + {file = "librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1"}, + {file = "librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022"}, + {file = "librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570"}, + {file = "librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26"}, + {file = "librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2"}, + {file = "librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b"}, + {file = "librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab"}, + {file = "librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890"}, + {file = "librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8"}, + {file = "librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad"}, + {file = "librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993"}, + {file = "librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879"}, + {file = "librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65"}, + {file = "librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622"}, + {file = "librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15"}, + {file = "librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28"}, + {file = "librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95"}, + {file = "librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714"}, + {file = "librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc"}, + {file = "librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf"}, + {file = "librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915"}, + {file = "librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605"}, + {file = "librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca"}, + {file = "librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0"}, + {file = "librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d"}, + {file = "librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374"}, + {file = "librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9"}, + {file = "librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8"}, + {file = "librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b"}, + {file = "librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54"}, + {file = "librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b"}, + {file = "librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162"}, + {file = "librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1"}, + {file = "librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc"}, + {file = "librt-0.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0e2d0c0acf5b0ada7d045912b7cf787c21315c95b38b1fa939ef72d45d366b3d"}, + {file = "librt-0.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9ca190fe9edc0eb08eec558a509a16d28d91c35667b8f043cba40ed5e77a959"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80811e1c42386ea95c6fb30571d3250ad43d7863f883f787f70517f441150e59"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:88c2a17815c266e6d8180204ff62cb739ab869ada4a746d4c505331526ac58f1"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a5fa8f1f916988d0bf1afea005bda37f56ac41a18016e813ccf0097a8d460ca4"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:355e3a4c725225a14262004fc1872a552b9d3634b4f791a0dfc80804aafbfd55"}, + {file = "librt-0.15.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc1ed11c4ad0b91af24def2050f2840ea4567828e3dd058fbe608d982f6e5465"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1f4ef2e71db33df4309167ed7f1520c4fae5e611226e159fa9cf33f93e6ddb3d"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1a1a8cd430c7dd0c083f455cb1b328d7fc682b05c31b940906f7845bdff80881"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:1172c6ad2a88b646e7fe3b480e3fac4ab4418b3443fd8a4061fdd531e0622fc7"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52e8db01f603f5da0ca30987479acff98769382efc8e142fa3962395dcf3ffdb"}, + {file = "librt-0.15.0-cp39-cp39-win32.whl", hash = "sha256:e4c911f15a1652ca94ae9f1abd92e74cbb1b3597d2d92fdd556202f94e8cd455"}, + {file = "librt-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:68242379c9b65a582b6e97318a1e9fbd6d445e58954f2d437991c4804ab11578"}, + {file = "librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162"}, +] + +[[package]] +name = "mypy" +version = "2.3.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41"}, + {file = "mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0"}, + {file = "mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167"}, + {file = "mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13"}, + {file = "mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53"}, + {file = "mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90"}, + {file = "mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531"}, + {file = "mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8"}, + {file = "mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8"}, + {file = "mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01"}, + {file = "mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1"}, + {file = "mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6"}, + {file = "mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff"}, + {file = "mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff"}, + {file = "mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080"}, + {file = "mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355"}, + {file = "mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb"}, + {file = "mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b"}, + {file = "mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d"}, + {file = "mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb"}, + {file = "mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226"}, + {file = "mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74"}, + {file = "mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6"}, + {file = "mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac"}, + {file = "mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d"}, + {file = "mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3"}, + {file = "mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f"}, + {file = "mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82"}, + {file = "mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9"}, + {file = "mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d"}, + {file = "mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595"}, + {file = "mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2"}, + {file = "mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc"}, + {file = "mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045"}, + {file = "mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0"}, + {file = "mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63"}, + {file = "mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4"}, + {file = "mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57"}, + {file = "mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b"}, + {file = "mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561"}, + {file = "mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133"}, + {file = "mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9"}, + {file = "mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3"}, + {file = "mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523"}, + {file = "mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306"}, + {file = "mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021"}, + {file = "mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e"}, + {file = "mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc"}, + {file = "mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4"}, + {file = "mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29"}, + {file = "mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb"}, + {file = "mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419"}, +] + +[package.dependencies] +ast-serialize = ">=0.6.0,<1.0.0" +librt = {version = ">=0.13.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + +[[package]] +name = "packaging" +version = "26.3" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "platformdirs" +version = "4.11.13" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.11.13-py3-none-any.whl", hash = "sha256:228b2283af5c1f32673cdf55271216a52bbd8258cb31374e302ad7bd26cd7513"}, + {file = "platformdirs-4.11.13.tar.gz", hash = "sha256:6985eefdc2298693e4ce1fe124645524cb967428eb6384e0ce5b49767e7ea8ba"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "4.6.2" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e"}, + {file = "pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pygments" +version = "2.21.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, + {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-discovery" +version = "1.6.1" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3"}, + {file = "python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e"}, +] + +[package.dependencies] +filelock = ">=3.15.4" + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "ruff" +version = "0.16.8" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"}, + {file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"}, + {file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"}, + {file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"}, + {file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"}, + {file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"}, + {file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"}, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "virtualenv" +version = "21.12.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "virtualenv-21.12.1-py3-none-any.whl", hash = "sha256:878db9e963d0ef0b38561a344a4f9b12dc5c7741b6245d7ed10e9291cfea28bc"}, + {file = "virtualenv-21.12.1.tar.gz", hash = "sha256:be5a0a62cb2d1529ff6999652e2e7826f95bf7faa9b96b88cc39847c023d90a0"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<5", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +python-discovery = ">=1.6" [metadata] lock-version = "2.1" -python-versions = ">=3.10,<4.0" -content-hash = "7b8fc01b274bd807fb00372bbc8e138330f15ae7978ed61e180f3b17ec076725" +python-versions = ">=3.11,<4.0" +content-hash = "25a3f2935d10e944921652fc64a2d0209fb0a0c98cf35d743c934cee62137cf3" diff --git a/pyproject.toml b/pyproject.toml index 0004308..5378de9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,31 @@ [project] name = "schedls" -version = "0.0.1" +version = "0.1.0" description = "Inspect and manage Linux scheduled jobs" authors = [ { name = "Marco D'Aleo", email = "marco@marcodaleo.com" } ] license = "GPL-3.0-or-later" readme = "README.md" -requires-python = ">=3.10,<4.0" +requires-python = ">=3.11,<4.0" dependencies = [] +keywords = ["cron", "crontab", "systemd", "timer", "scheduler", "cli", "sysadmin"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: System Administrators", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: System :: Systems Administration", +] + +[project.urls] +Homepage = "https://git.sysmd.uk/mdaleo404/schedls" +Repository = "https://git.sysmd.uk/mdaleo404/schedls" [project.scripts] schedls = "schedls.cli:main" @@ -16,3 +33,39 @@ schedls = "schedls.cli:main" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.poetry.group.dev.dependencies] +pre-commit = ">=4.0" +pytest = ">=8.0" +pytest-cov = ">=5.0" +ruff = ">=0.6" +mypy = ">=1.11" + +[tool.ruff] +line-length = 120 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "S"] +# S603/S607: subprocess is confined to runner.py and always uses an argv. +# S604: matches our own Command(shell=...) dataclass field, not subprocess usage. +ignore = ["S603", "S604", "S607"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "S105", "S106"] + +[tool.mypy] +python_version = "3.11" +packages = ["schedls"] +mypy_path = "src" +strict = true +warn_unreachable = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-q --strict-markers" +markers = [ + "integration: tests that require native systemd/cron facilities", +] diff --git a/schedls.png b/schedls.png new file mode 100644 index 0000000..2535ac5 Binary files /dev/null and b/schedls.png differ diff --git a/src/schedls/__init__.py b/src/schedls/__init__.py index e69de29..65c6d23 100644 --- a/src/schedls/__init__.py +++ b/src/schedls/__init__.py @@ -0,0 +1,35 @@ +"""schedls — one interface for the things Linux runs later.""" + +from __future__ import annotations + +import tomllib +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + + +def _version_from_pyproject() -> str | None: + for directory in Path(__file__).resolve().parents: + candidate = directory / "pyproject.toml" + if not candidate.is_file(): + continue + with candidate.open("rb") as handle: + data = tomllib.load(handle) + project = data.get("project") + if isinstance(project, dict) and isinstance(project.get("version"), str): + return str(project["version"]) + return None + + +def _detect_version() -> str: + found = _version_from_pyproject() + if found is not None: + return found + try: + return version("schedls") + except PackageNotFoundError: + return "0.0.0" + + +__version__ = _detect_version() + +__all__ = ["__version__"] diff --git a/src/schedls/__main__.py b/src/schedls/__main__.py new file mode 100644 index 0000000..cf15a50 --- /dev/null +++ b/src/schedls/__main__.py @@ -0,0 +1,10 @@ +"""Entry point for ``python -m schedls``.""" + +from __future__ import annotations + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/schedls/backends/__init__.py b/src/schedls/backends/__init__.py new file mode 100644 index 0000000..5be503e --- /dev/null +++ b/src/schedls/backends/__init__.py @@ -0,0 +1 @@ +"""Scheduler backends.""" diff --git a/src/schedls/backends/base.py b/src/schedls/backends/base.py new file mode 100644 index 0000000..344faa1 --- /dev/null +++ b/src/schedls/backends/base.py @@ -0,0 +1,98 @@ +"""Backend interface, capability model and mutation plans.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +from ..models import JobSpec, ScheduledJob, Scope + + +@dataclass(frozen=True) +class Capabilities: + discovery: bool = False + create: bool = False + update: bool = False + remove: bool = False + enable: bool = False + disable: bool = False + logs: bool = False + next_run: bool = False + validation: bool = False + + +@dataclass(frozen=True) +class FileChange: + path: str + content: str | None + mode: int = 0o644 + expected_uid: int | None = None + + +@dataclass(frozen=True) +class CommandPlan: + argv: Sequence[str] + description: str + env_policy: str = "minimal" + input_text: str | None = None + + +@dataclass +class Plan: + backend: str + action: str + summary: list[tuple[str, str]] = field(default_factory=list) + files: list[FileChange] = field(default_factory=list) + commands: list[CommandPlan] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + payload: dict[str, Any] = field(default_factory=dict) + + def display_files(self) -> list[str]: + return [change.path for change in self.files] + + +@dataclass +class MutationResult: + changed: bool + messages: list[str] = field(default_factory=list) + files_written: tuple[str, ...] = () + warnings: tuple[str, ...] = () + dry_run: bool = False + + +class SchedulerBackend: + """Base class for backends. Unsupported operations must not be faked.""" + + name: str = "unknown" + capabilities = Capabilities() + + def available(self) -> bool: + raise NotImplementedError + + def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]: + raise NotImplementedError + + def find(self, name: str) -> ScheduledJob | None: + for job in self.discover([Scope.USER, Scope.SYSTEM]): + if job.name == name: + return job + return None + + def plan_create(self, spec: JobSpec) -> Plan: + raise NotImplementedError + + def plan_update(self, current: ScheduledJob, spec: JobSpec) -> Plan: + raise NotImplementedError + + def plan_remove(self, job: ScheduledJob) -> Plan: + raise NotImplementedError + + def plan_set_enabled(self, job: ScheduledJob, enabled: bool) -> Plan: + raise NotImplementedError + + def logs(self, job: ScheduledJob, *, lines: int, since: str | None) -> str: + raise NotImplementedError + + def apply(self, plan: Plan) -> MutationResult: + raise NotImplementedError diff --git a/src/schedls/backends/cron.py b/src/schedls/backends/cron.py new file mode 100644 index 0000000..d58ab5c --- /dev/null +++ b/src/schedls/backends/cron.py @@ -0,0 +1,443 @@ +"""cron backend: lossless crontab parsing, discovery and managed blocks.""" + +from __future__ import annotations + +import contextlib +import os +import re +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass, field + +from ..errors import ( + ConflictError, + InvalidScheduleError, + OperationalError, + SafetyRefusalError, +) +from ..models import ( + Backend, + Command, + CronDetails, + JobSource, + JobSpec, + Schedule, + ScheduledJob, + ScheduleKind, + Scope, +) +from ..renderers import cron as renderer +from ..runner import CommandRunner +from ..security import validate_name +from .base import ( + Capabilities, + CommandPlan, + MutationResult, + Plan, + SchedulerBackend, +) + +_MARKER_RE = re.compile(r"^#\s*schedls:(?Pbegin|end)\s+(?P.*)$") +_META_RE = re.compile(r"(?P[A-Za-z_][A-Za-z0-9_]*)=(?P\S+)") +_ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\s*=") +_NICKNAME_RE = re.compile(r"^@[A-Za-z]+") + + +@dataclass +class CronCapabilities: + supports_validation: bool = False + supports_user_selection: bool = False + implementation: str | None = None + + +@dataclass +class CrontabEntry: + index: int + raw: str + kind: str + name: str | None = None + expression: str | None = None + command: str | None = None + marker_name: str | None = None + extra: dict[str, str] = field(default_factory=dict) + + +@dataclass +class ManagedBlock: + name: str + begin_index: int + end_index: int + job_lines: list[CrontabEntry] + extra: dict[str, str] = field(default_factory=dict) + + +class CrontabDocument: + """An exact, line-preserving view of a crontab.""" + + def __init__(self, text: str) -> None: + self.had_trailing_newline = text.endswith("\n") if text else False + body = text[:-1] if self.had_trailing_newline else text + self.lines: list[str] = body.split("\n") if body != "" else [] + self.entries: list[CrontabEntry] = [] + self.warnings: list[str] = [] + self._blocks: dict[str, ManagedBlock] = {} + self._parse() + + @classmethod + def parse(cls, text: str) -> CrontabDocument: + return cls(text) + + @property + def text(self) -> str: + return self.render() + + def render(self) -> str: + body = "\n".join(self.lines) + if self.had_trailing_newline and body != "": + return body + "\n" + return body + + # -- parsing -------------------------------------------------------------- + + def _parse(self) -> None: + open_begin: CrontabEntry | None = None + open_lines: list[CrontabEntry] = [] + for number, raw in enumerate(self.lines, start=1): + entry = self._classify(number, raw) + self.entries.append(entry) + if entry.kind == "managed_begin": + if open_begin is not None: + self.warnings.append( + f"line {number}: nested schedls block; previous block on line {open_begin.index} is malformed" + ) + open_begin = entry + open_lines = [] + elif entry.kind == "managed_end": + if open_begin is None: + self.warnings.append(f"line {number}: schedls:end without a matching begin") + continue + if entry.marker_name != open_begin.marker_name: + self.warnings.append( + f"line {number}: schedls:end name={entry.marker_name!r} does not match " + f"begin name={open_begin.marker_name!r}" + ) + open_begin = None + open_lines = [] + continue + name = open_begin.marker_name + if name is None: + self.warnings.append(f"line {open_begin.index}: schedls:begin is missing a name") + open_begin = None + open_lines = [] + continue + self._blocks[name] = ManagedBlock( + name=name, + begin_index=open_begin.index, + end_index=entry.index, + job_lines=list(open_lines), + extra=open_begin.extra, + ) + open_begin = None + open_lines = [] + elif open_begin is not None: + if entry.kind in {"job", "comment", "env", "blank"}: + open_lines.append(entry) + if open_begin is not None: + self.warnings.append( + f"line {open_begin.index}: schedls:begin name={open_begin.marker_name!r} has no matching end" + ) + + def _classify(self, number: int, raw: str) -> CrontabEntry: + stripped = raw.strip() + if not stripped: + return CrontabEntry(number, raw, "blank") + if stripped.startswith("#"): + match = _MARKER_RE.match(stripped) + if match: + extra = {item.group("key"): item.group("value") for item in _META_RE.finditer(match.group("meta"))} + name = extra.get("name") + kind = "managed_begin" if match.group("kind") == "begin" else "managed_end" + return CrontabEntry(number, raw, kind, marker_name=name, extra=extra) + return CrontabEntry(number, raw, "comment") + if _ENV_RE.match(stripped): + return CrontabEntry(number, raw, "env") + expression, command = _split_job(stripped) + if expression is None: + return CrontabEntry(number, raw, "comment") + return CrontabEntry(number, raw, "job", expression=expression, command=command) + + # -- queries -------------------------------------------------------------- + + def managed_blocks(self) -> dict[str, ManagedBlock]: + return dict(self._blocks) + + def find_block(self, name: str) -> ManagedBlock | None: + return self._blocks.get(name) + + def has_malformed_markers(self) -> bool: + return bool(self.warnings) + + # -- transformations ------------------------------------------------------ + + def with_block(self, name: str, lines: list[str]) -> str: + if self.has_malformed_markers(): + raise SafetyRefusalError("existing crontab contains malformed schedls markers; refusing to edit.") + if name in self._blocks: + raise ConflictError( + f"schedule {name!r} already exists in the crontab.", + hint="Use 'schedls edit' or 'schedls rm' instead.", + ) + block = renderer.render_block(name, lines).splitlines() + lines_out = list(self.lines) + if lines_out and lines_out[-1].strip() != "": + lines_out.append("") + lines_out.extend(block) + return "\n".join(lines_out) + "\n" + + def without_block(self, name: str) -> str: + if self.has_malformed_markers(): + raise SafetyRefusalError("existing crontab contains malformed schedls markers; refusing to edit.") + block = self._blocks.get(name) + if block is None: + raise SafetyRefusalError(f"no schedls-managed cron block named {name!r}.") + keep: list[str] = [] + for entry in self.entries: + if block.begin_index <= entry.index <= block.end_index: + continue + keep.append(entry.raw) + while keep and keep[-1].strip() == "": + keep.pop() + if not keep: + return "" + return "\n".join(keep) + "\n" + + +def _split_job(stripped: str) -> tuple[str | None, str | None]: + nickname = _NICKNAME_RE.match(stripped) + if nickname: + rest = stripped[nickname.end() :].strip() + if not rest: + return None, None + return nickname.group(0), rest + parts = stripped.split(None, 5) + if len(parts) < 6: + return None, None + return " ".join(parts[:5]), parts[5] + + +class CronBackend(SchedulerBackend): + name = "cron" + capabilities = Capabilities( + discovery=True, + create=True, + remove=True, + next_run=False, + validation=True, + ) + + def __init__(self, runner: CommandRunner) -> None: + self.runner = runner + self._capabilities: CronCapabilities | None = None + + def available(self) -> bool: + return self.runner.has("crontab") + + def capabilities_info(self) -> CronCapabilities: + if self._capabilities is not None: + return self._capabilities + info = CronCapabilities() + if self.available(): + completed = self.runner.run(["crontab", "-V"], env_policy="identity", check=False) + output = (completed.stdout or completed.stderr).strip() + if completed.returncode != 0 or not output: + completed = self.runner.run(["crontab", "--version"], env_policy="identity", check=False) + output = (completed.stdout or completed.stderr).strip() + if output: + info.implementation = output.splitlines()[0].strip() + lowered = output.lower() + if "cronie" in lowered: + info.implementation = "Cronie" + elif "vixie" in lowered: + info.implementation = "Vixie cron" + info.supports_validation = self._probe_validation() + info.supports_user_selection = self._probe_user_selection() + self._capabilities = info + return info + + def _probe_validation(self) -> bool: + completed = self.runner.run( + ["crontab", "-T", "/dev/null"], + env_policy="identity", + check=False, + ) + return completed.returncode == 0 + + def _probe_user_selection(self) -> bool: + if os.geteuid() != 0: + return False + completed = self.runner.run(["crontab", "-u", "root", "-l"], env_policy="identity", check=False) + return completed.returncode == 0 or "no crontab" in (completed.stderr + completed.stdout).lower() + + # -- read ----------------------------------------------------------------- + + def read(self) -> CrontabDocument: + completed = self.runner.run(["crontab", "-l"], env_policy="identity", check=False) + if completed.returncode != 0: + if "no crontab" in (completed.stderr + completed.stdout).lower(): + return CrontabDocument("") + detail = completed.stderr.strip() or completed.stdout.strip() + raise OperationalError("could not read the current user's crontab", hint=detail or None) + return CrontabDocument(completed.stdout) + + def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]: + if Scope.USER not in scopes or not self.available(): + return [] + document = self.read() + jobs: list[ScheduledJob] = [] + managed_indices: set[int] = set() + for name, block in document.managed_blocks().items(): + managed_indices.update(range(block.begin_index, block.end_index + 1)) + job_line = next((line for line in block.job_lines if line.kind == "job"), None) + job = ScheduledJob( + name=name, + backend=Backend.CRON, + scope=Scope.USER, + managed=True, + enabled=None, + schedule=Schedule(ScheduleKind.CRON, (job_line.expression or "") if job_line else ""), + command=Command(argv=(), raw=job_line.command if job_line else ""), + source=JobSource("current user's crontab", line=block.begin_index), + next_run=None, + last_run=None, + cron=CronDetails( + expression=job_line.expression if job_line else None, + line=block.begin_index, + ), + ) + jobs.append(job) + for entry in document.entries: + if entry.kind != "job" or entry.index in managed_indices: + continue + jobs.append( + ScheduledJob( + name=f"cron-{entry.index}", + backend=Backend.CRON, + scope=Scope.USER, + managed=False, + enabled=None, + schedule=Schedule(ScheduleKind.CRON, entry.expression or ""), + command=Command(argv=(), raw=entry.command or ""), + source=JobSource("current user's crontab", line=entry.index), + cron=CronDetails( + expression=entry.expression, + raw_line=entry.raw, + line=entry.index, + ), + ) + ) + return jobs + + # -- planning ------------------------------------------------------------- + + def plan_create(self, spec: JobSpec) -> Plan: + validate_name(spec.name) + if spec.scope is not Scope.USER: + raise SafetyRefusalError("system cron management is not supported.") + if not self.available(): + raise OperationalError("crontab is not available") + expression = renderer.validate_expression(spec.cron_expression or "") + line = renderer.render_line(expression, spec.command) + document = self.read() + new_text = document.with_block(spec.name, [line]) + plan = Plan(backend=self.name, action="create") + plan.summary = [ + ("Backend", "cron (current user)"), + ("Schedule", expression), + ("Command", spec.command.display()), + ("Execute via", "/bin/sh"), + ] + plan.files = [] + plan.commands = self._install_commands(new_text) + plan.warnings.append("Cron executes command text through /bin/sh (or the crontab's configured SHELL).") + plan.payload = { + "action": "create", + "name": spec.name, + "old_text": document.text, + "new_text": new_text, + } + return plan + + def plan_remove(self, job: ScheduledJob) -> Plan: + if not job.managed: + raise SafetyRefusalError( + f"{job.name} was not created by schedls.", + hint="schedls will not modify unmanaged schedules by default.", + ) + document = self.read() + new_text = document.without_block(job.name) + plan = Plan(backend=self.name, action="remove") + plan.summary = [ + ("Backend", "cron (current user)"), + ("Will remove", f"schedls block name={job.name}"), + ] + plan.commands = self._install_commands(new_text) + plan.payload = { + "action": "remove", + "name": job.name, + "old_text": document.text, + "new_text": new_text, + } + return plan + + def _install_commands(self, new_text: str) -> list[CommandPlan]: + return [CommandPlan(("crontab", "-"), "install crontab", "identity", input_text=new_text)] + + # -- applying ------------------------------------------------------------- + + def apply(self, plan: Plan) -> MutationResult: + old_text = plan.payload.get("old_text", "") + new_text = plan.payload.get("new_text", "") + current_document = self.read() + if current_document.text != old_text: + raise SafetyRefusalError("the crontab changed after the plan was prepared; refusing to apply.") + self._validate(new_text) + try: + for command in plan.commands: + self.runner.run( + list(command.argv), + env_policy=command.env_policy, + input_text=command.input_text, + ) + except Exception: + self._restore(old_text) + raise + self._verify_installed(plan) + return MutationResult( + changed=True, + messages=["Crontab updated."], + ) + + def _validate(self, text: str) -> None: + if not self.capabilities_info().supports_validation: + return + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".cron", delete=False) as handle: + handle.write(text) + name = handle.name + try: + completed = self.runner.run(["crontab", "-T", name], env_policy="identity", check=False) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise InvalidScheduleError("generated crontab failed syntax validation", hint=detail or None) + finally: + with contextlib.suppress(OSError): + os.unlink(name) + + def _verify_installed(self, plan: Plan) -> None: + document = self.read() + name = plan.payload.get("name") + if plan.payload.get("action") == "create" and name and name not in document.managed_blocks(): + raise OperationalError("the crontab was installed but the schedls block could not be verified.") + + def _restore(self, text: str) -> None: + with contextlib.suppress(Exception): + self.runner.run(["crontab", "-"], env_policy="identity", check=False, input_text=text) diff --git a/src/schedls/backends/systemd.py b/src/schedls/backends/systemd.py new file mode 100644 index 0000000..5ae61b1 --- /dev/null +++ b/src/schedls/backends/systemd.py @@ -0,0 +1,727 @@ +"""systemd timer backend: discovery, rendering and transactional mutation.""" + +from __future__ import annotations + +import contextlib +import os +import re +import tempfile +from collections.abc import Sequence +from datetime import UTC, datetime + +from ..errors import ( + ConflictError, + DependencyMissingError, + InvalidScheduleError, + OperationalError, + SafetyRefusalError, +) +from ..models import ( + Backend, + Command, + JobSource, + JobSpec, + Schedule, + ScheduledJob, + ScheduleKind, + Scope, + SystemdDetails, +) +from ..renderers import systemd as renderer +from ..runner import CommandRunner +from ..security import ( + atomic_write_text, + has_unsafe_control_characters, + is_managed_unit, + is_safe_unit_name, + remove_file, + unit_name, + validate_absolute_path, + validate_managed_unit_name, + validate_name, +) +from ..timefmt import parse_systemd_timestamp +from ..unitfile import first, has_managed_marker, read_units, values +from .base import ( + Capabilities, + CommandPlan, + FileChange, + MutationResult, + Plan, + SchedulerBackend, +) + +_ENABLED_STATES = {"enabled", "enabled-runtime"} +_TIMER_SUFFIX = ".timer" +_SERVICE_SUFFIX = ".service" + +_ITERATION_RE = re.compile(r"(?:Next elapse|Iteration #\d+):\s+(.+?)\s*$") +_UTC_LINE_RE = re.compile(r"\(in UTC\):\s+(.+?)\s*$") +_NORMALIZED_RE = re.compile(r"^\s*Normalized form:\s+(.+?)\s*$", re.MULTILINE) +_ORIGINAL_RE = re.compile(r"^\s*Original form:\s+(.+?)\s*$", re.MULTILINE) + + +class SystemdBackend(SchedulerBackend): + name = "systemd" + capabilities = Capabilities( + discovery=True, + create=True, + update=True, + remove=True, + enable=True, + disable=True, + logs=True, + next_run=True, + validation=True, + ) + + def __init__(self, runner: CommandRunner) -> None: + self.runner = runner + + # -- capability detection ------------------------------------------------- + + def has_systemctl(self) -> bool: + return self.runner.has("systemctl") + + def has_analyze(self) -> bool: + return self.runner.has("systemd-analyze") + + def manager_ok(self, scope: Scope) -> bool: + if not self.has_systemctl(): + return False + try: + completed = self.runner.run( + self._systemctl(scope, "is-system-running"), + env_policy=self._env_policy(scope), + check=False, + ) + except OperationalError: + return False + state = completed.stdout.strip() + return state not in {"", "offline", "unknown"} + + def available(self) -> bool: + return self.has_systemctl() and (self.manager_ok(Scope.USER) or self.manager_ok(Scope.SYSTEM)) + + def user_lingering(self) -> bool | None: + if not self.runner.has("loginctl"): + return None + try: + completed = self.runner.run( + ["loginctl", "show-user", os.environ.get("USER", ""), "-p", "Linger"], + env_policy="identity", + check=False, + ) + except OperationalError: + return None + if completed.returncode != 0: + return None + return completed.stdout.strip().endswith("yes") + + # -- calendar validation -------------------------------------------------- + + def validate_calendar(self, expression: str, *, iterations: int = 1) -> str: + self._check_expression(expression) + if not self.has_analyze(): + raise DependencyMissingError("systemd-analyze is not available; cannot validate calendar expressions.") + completed = self.runner.run( + [ + "systemd-analyze", + "calendar", + f"--iterations={max(iterations, 1)}", + expression, + ], + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise InvalidScheduleError( + f"invalid calendar expression: {expression!r}", + hint=detail or None, + ) + match = _NORMALIZED_RE.search(completed.stdout) + return match.group(1) if match else expression + + def calendar_occurrences(self, expression: str, *, iterations: int = 5) -> list[datetime]: + self._check_expression(expression) + completed = self.runner.run( + [ + "systemd-analyze", + "calendar", + f"--iterations={max(iterations, 1)}", + expression, + ], + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise InvalidScheduleError(f"invalid calendar expression: {expression!r}", hint=detail or None) + return _parse_occurrences(completed.stdout) + + # -- discovery ------------------------------------------------------------ + + def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]: + jobs: list[ScheduledJob] = [] + for scope in scopes: + if not self.manager_ok(scope): + continue + for unit in self._list_timer_units(scope): + job = self._build_job(unit, scope) + if job is not None: + jobs.append(job) + return jobs + + def _list_timer_units(self, scope: Scope) -> list[str]: + units: list[str] = [] + seen: set[str] = set() + for args in ( + ("list-unit-files", "--type=timer", "--no-legend", "--plain", "--all"), + ("list-units", "--type=timer", "--all", "--no-legend", "--plain"), + ): + completed = self.runner.run( + self._systemctl(scope, *args), + env_policy=self._env_policy(scope), + check=False, + ) + if completed.returncode != 0: + continue + for line in completed.stdout.splitlines(): + parts = line.split() + if not parts: + continue + unit = parts[0].lstrip("●").strip() + if unit.endswith(_TIMER_SUFFIX) and unit not in seen: + seen.add(unit) + units.append(unit) + return units + + def _build_job(self, unit: str, scope: Scope) -> ScheduledJob | None: + properties = self._show(unit, scope) + if not properties: + return None + if properties.get("LoadState") == "not-found": + return None + fragment = properties.get("FragmentPath") or None + timer_sections = read_units(fragment) if fragment else None + on_calendar = tuple(values(timer_sections, "Timer", "OnCalendar")) + if not on_calendar: + on_calendar = _timers_calendar(properties.get("TimersCalendar", "")) + + service_unit = self._service_unit(unit, properties, timer_sections) + if service_unit and not is_safe_unit_name(service_unit): + service_unit = None + service_fragment = None + service_sections = None + if service_unit: + service_props = self._show(service_unit, scope) + service_fragment = service_props.get("FragmentPath") or None + if service_fragment: + service_sections = read_units(service_fragment) + + prefix_managed = is_managed_unit(os.path.basename(unit)) + name = _job_name(unit, prefix_managed) + managed = prefix_managed and fragment is not None and has_managed_marker(fragment, name=name) + if prefix_managed and not managed: + name = _job_name(unit, False) + enabled = _enabled_from_state(properties.get("UnitFileState")) + command = _command_from_service(service_sections) + next_run = parse_systemd_timestamp(properties.get("NextElapseUSecRealtime", "")) + last_run = parse_systemd_timestamp(properties.get("LastTriggerUSec", "")) + last_result = properties.get("Result") or None + + details = SystemdDetails( + timer_unit=unit, + service_unit=service_unit, + timer_path=fragment, + service_path=service_fragment, + on_calendar=on_calendar, + persistent=_is_true(first(timer_sections, "Timer", "Persistent")), + jitter=first(timer_sections, "Timer", "RandomizedDelaySec"), + accuracy=first(timer_sections, "Timer", "AccuracySec"), + working_directory=first(service_sections, "Service", "WorkingDirectory"), + environment=_environment_from_service(service_sections), + active_state=properties.get("ActiveState") or None, + sub_state=properties.get("SubState") or None, + unit_file_state=properties.get("UnitFileState") or None, + result=last_result, + description=properties.get("Description") or None, + ) + + warnings: list[str] = [] + if scope is Scope.USER and not managed and self.user_lingering() is False: + warnings.append("user lingering is disabled; this timer may not run while logged out") + + return ScheduledJob( + name=name, + backend=Backend.SYSTEMD, + scope=scope, + managed=managed, + enabled=enabled, + schedule=Schedule(ScheduleKind.CALENDAR, on_calendar[0] if on_calendar else "", on_calendar), + command=command, + source=JobSource( + detail=f"systemd {scope.value} timer", + path=fragment, + line=None, + ), + next_run=next_run, + last_run=last_run, + last_result=last_result, + warnings=tuple(warnings), + systemd=details, + ) + + def _service_unit( + self, unit: str, properties: dict[str, str], timer_sections: dict[str, list[str]] | None + ) -> str | None: + declared = first(timer_sections, "Timer", "Unit") + if declared: + return declared + triggers = properties.get("Triggers", "") + for token in triggers.split(): + if token.endswith(_SERVICE_SUFFIX): + return token + return unit[: -len(_TIMER_SUFFIX)] + _SERVICE_SUFFIX + + def _show(self, unit: str, scope: Scope) -> dict[str, str]: + properties = ( + "Id LoadState FragmentPath UnitFileState ActiveState SubState Description Result " + "Triggers NextElapseUSecRealtime LastTriggerUSec Persistent " + "RandomizedDelaySec AccuracySec TimersCalendar" + ) + completed = self.runner.run( + self._systemctl(scope, "show", unit, f"--property={properties.replace(' ', ',')}"), + env_policy=self._env_policy(scope), + check=False, + ) + if completed.returncode != 0: + return {} + result: dict[str, str] = {} + for line in completed.stdout.splitlines(): + key, sep, value = line.partition("=") + if sep: + result[key] = value + return result + + # -- planning ------------------------------------------------------------- + + def plan_create(self, spec: JobSpec) -> Plan: + validate_name(spec.name) + self._require_scope(spec.scope) + self._validate_spec(spec) + service_unit = unit_name(spec.name, "service") + timer_unit = unit_name(spec.name, "timer") + directory = self._unit_dir(spec.scope) + files = renderer.render_units(spec, service_unit=service_unit, timer_unit=timer_unit) + changes: list[FileChange] = [] + for unit, content in files.items(): + path = os.path.join(directory, unit) + if os.path.lexists(path): + raise ConflictError( + f"schedule {spec.name!r} already exists ({path}).", + hint="Use 'schedls edit' or 'schedls rm' instead.", + ) + changes.append(FileChange(path=path, content=content, mode=0o644)) + self._verify_units(files) + + plan = Plan(backend=self.name, action="create") + plan.files = changes + plan.summary = self._create_summary(spec, service_unit, timer_unit) + plan.commands = [ + CommandPlan( + self._systemctl(spec.scope, "daemon-reload"), + "reload systemd manager", + self._env_policy(spec.scope), + ), + CommandPlan( + self._systemctl(spec.scope, "enable", "--now", timer_unit), + "enable and start timer", + self._env_policy(spec.scope), + ), + ] + if spec.scope is Scope.USER and self.user_lingering() is False: + plan.warnings.append( + "user lingering is disabled; this timer may not run while you are logged out. " + "schedls will not change lingering automatically." + ) + plan.payload = { + "scope": spec.scope, + "timer_unit": timer_unit, + "service_unit": service_unit, + "timer_path": os.path.join(directory, timer_unit), + "created": True, + "snapshots": {}, + } + return plan + + def plan_update(self, current: ScheduledJob, spec: JobSpec) -> Plan: + if not current.managed: + raise SafetyRefusalError( + f"{current.name} was not created by schedls.", + hint="schedls will not modify unmanaged schedules by default.", + ) + if current.backend is not Backend.SYSTEMD or current.systemd is None: + raise SafetyRefusalError(f"{current.name} is not a systemd timer.") + self._require_scope(current.scope) + self._validate_spec(spec) + service_unit = unit_name(current.name, "service") + timer_unit = unit_name(current.name, "timer") + directory = self._unit_dir(current.scope) + files = renderer.render_units(spec, service_unit=service_unit, timer_unit=timer_unit) + changes: list[FileChange] = [] + snapshots: dict[str, str | None] = {} + for unit, content in files.items(): + path = os.path.join(directory, unit) + snapshots[path] = _read_text(path) + changes.append(FileChange(path=path, content=content, mode=0o644)) + self._verify_units(files) + plan = Plan(backend=self.name, action="update") + plan.files = changes + plan.summary = self._create_summary(spec, service_unit, timer_unit) + plan.commands = [ + CommandPlan( + self._systemctl(current.scope, "daemon-reload"), + "reload systemd manager", + self._env_policy(current.scope), + ), + ] + plan.payload = { + "scope": current.scope, + "timer_unit": timer_unit, + "service_unit": service_unit, + "timer_path": os.path.join(directory, timer_unit), + "created": False, + "snapshots": snapshots, + } + return plan + + def plan_remove(self, job: ScheduledJob) -> Plan: + if not job.managed: + raise SafetyRefusalError( + f"{job.name} was not created by schedls.", + hint="schedls will not modify unmanaged schedules by default.", + ) + if job.systemd is None: + raise SafetyRefusalError(f"{job.name} is not a systemd timer.") + self._require_scope(job.scope) + directory = self._unit_dir(job.scope) + timer_unit = unit_name(job.name, "timer") + service_unit = unit_name(job.name, "service") + paths = [ + os.path.join(directory, timer_unit), + os.path.join(directory, service_unit), + ] + plan = Plan(backend=self.name, action="remove") + plan.files = [FileChange(path=path, content=None) for path in paths] + plan.summary = [("Backend", f"systemd {job.scope.value} timer")] + if job.command.display(): + plan.summary.append(("Will NOT remove", job.command.display())) + plan.commands = [ + CommandPlan( + self._systemctl(job.scope, "disable", "--now", timer_unit), + "disable and stop timer", + self._env_policy(job.scope), + ), + CommandPlan( + self._systemctl(job.scope, "daemon-reload"), + "reload systemd manager", + self._env_policy(job.scope), + ), + ] + plan.payload = { + "scope": job.scope, + "timer_unit": timer_unit, + "service_unit": service_unit, + "created": False, + "snapshots": {path: _read_text(path) for path in paths}, + } + return plan + + def plan_set_enabled(self, job: ScheduledJob, enabled: bool) -> Plan: + if not job.managed: + raise SafetyRefusalError( + f"{job.name} was not created by schedls.", + hint="schedls will not modify unmanaged schedules by default.", + ) + if job.systemd is None: + raise SafetyRefusalError(f"{job.name} is not a systemd timer.") + self._require_scope(job.scope) + timer_unit = unit_name(job.name, "timer") + if enabled: + argv = self._systemctl(job.scope, "enable", "--now", timer_unit) + description = "enable and start timer" + else: + argv = self._systemctl(job.scope, "disable", "--now", timer_unit) + description = "disable and stop timer" + plan = Plan(backend=self.name, action="enable" if enabled else "disable") + plan.summary = [ + ("Backend", f"systemd {job.scope.value} timer"), + ("Timer", timer_unit), + ] + plan.commands = [CommandPlan(argv, description, self._env_policy(job.scope))] + plan.payload = {"scope": job.scope, "timer_unit": timer_unit, "created": False, "snapshots": {}} + return plan + + # -- applying ------------------------------------------------------------- + + def apply(self, plan: Plan) -> MutationResult: + snapshots: dict[str, str | None] = plan.payload.get("snapshots", {}) + written: list[str] = [] + result = MutationResult(changed=False) + + try: + for change in plan.files: + if change.path in snapshots: + if _read_text(change.path) != snapshots[change.path]: + raise SafetyRefusalError( + f"{change.path} changed on disk after the plan was prepared; refusing to apply." + ) + elif change.content is not None and os.path.lexists(change.path): + raise ConflictError(f"{change.path} appeared after the plan was prepared.") + if change.content is None: + if remove_file(change.path): + written.append(change.path) + else: + atomic_write_text(change.path, change.content, mode=change.mode) + written.append(change.path) + result.changed = True + + for command in plan.commands: + self.runner.run(list(command.argv), env_policy=command.env_policy) + except BaseException as exc: + self._rollback(plan, written, snapshots) + if isinstance(exc, KeyboardInterrupt | SystemExit): + raise + if isinstance(exc, OperationalError | SafetyRefusalError): + raise + raise OperationalError(str(exc)) from exc + + result.files_written = tuple(written) + result.messages.extend(self._post_check(plan)) + return result + + def _rollback(self, plan: Plan, written: list[str], snapshots: dict[str, str | None]) -> None: + scope: Scope = plan.payload.get("scope", Scope.USER) + timer_unit = plan.payload.get("timer_unit") + if plan.action == "create" and timer_unit: + with contextlib.suppress(Exception): + self.runner.run( + self._systemctl(scope, "disable", "--now", timer_unit), + env_policy=self._env_policy(scope), + check=False, + ) + for path in reversed(written): + previous = snapshots.get(path) + with contextlib.suppress(Exception): + if previous is None: + remove_file(path) + else: + atomic_write_text(path, previous, mode=0o644) + with contextlib.suppress(Exception): + self.runner.run( + self._systemctl(scope, "daemon-reload"), + env_policy=self._env_policy(scope), + check=False, + ) + + def _post_check(self, plan: Plan) -> list[str]: + scope: Scope = plan.payload["scope"] + timer_unit = plan.payload["timer_unit"] + action = plan.action + if action in {"create", "enable", "disable"}: + completed = self.runner.run( + self._systemctl(scope, "is-enabled", timer_unit), + env_policy=self._env_policy(scope), + check=False, + ) + state = completed.stdout.strip() or "unknown" + return [f"Timer state: {state}"] + return [] + + # -- logs ----------------------------------------------------------------- + + def logs(self, job: ScheduledJob, *, lines: int, since: str | None) -> str: + if job.systemd is None or not job.systemd.service_unit: + raise OperationalError(f"no service unit known for {job.name}") + service_unit = job.systemd.service_unit + if not is_safe_unit_name(service_unit): + raise SafetyRefusalError(f"refusing to query unexpected unit name: {service_unit!r}") + if not self.runner.has("journalctl"): + raise DependencyMissingError("journalctl is not available") + argv = ["journalctl", "--no-pager", "--unit", service_unit, f"--lines={lines}"] + if since: + argv.extend(["--since", since]) + if job.scope is Scope.USER: + argv.insert(1, "--user") + completed = self.runner.run(argv, env_policy=self._env_policy(job.scope), check=False) + if completed.returncode != 0 and completed.stderr: + raise OperationalError(completed.stderr.strip()) + return completed.stdout + + # -- helpers -------------------------------------------------------------- + + def _validate_spec(self, spec: JobSpec) -> None: + if spec.command.is_empty(): + raise InvalidScheduleError("no command given", hint="Provide a command after '--'.") + if not spec.calendar: + raise InvalidScheduleError("no schedule given") + for expression in spec.calendar: + self.validate_calendar(expression) + if spec.jitter is not None: + self._validate_timespan("--jitter", spec.jitter) + if spec.accuracy is not None: + self._validate_timespan("--accuracy", spec.accuracy) + + def _check_expression(self, expression: str) -> str: + if has_unsafe_control_characters(expression): + raise InvalidScheduleError("calendar expressions must not contain control characters") + if expression.startswith("-"): + raise InvalidScheduleError(f"calendar expression must not start with '-': {expression!r}") + return expression + + def _validate_timespan(self, option: str, value: str) -> str: + if has_unsafe_control_characters(value): + raise InvalidScheduleError(f"{option} must not contain control characters") + if value.startswith("-"): + raise InvalidScheduleError(f"{option} must not start with '-': {value!r}") + if not self.has_analyze(): + raise DependencyMissingError("systemd-analyze is required to validate timer durations.") + completed = self.runner.run(["systemd-analyze", "timespan", value], check=False) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise InvalidScheduleError(f"invalid {option} value: {value!r}", hint=detail or None) + return value + + def _verify_units(self, files: dict[str, str]) -> None: + if not self.has_analyze(): + raise DependencyMissingError("systemd-analyze is required to validate generated units.") + with tempfile.TemporaryDirectory(prefix="schedls-verify-") as tmp: + paths = [] + for unit, content in files.items(): + validate_managed_unit_name(unit) + path = os.path.join(tmp, unit) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + paths.append(path) + completed = self.runner.run(["systemd-analyze", "verify", *paths], check=False) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise SafetyRefusalError( + "generated units failed systemd-analyze verify.", + hint=detail or None, + ) + + def _create_summary(self, spec: JobSpec, service_unit: str, timer_unit: str) -> list[tuple[str, str]]: + return [ + ("Backend", f"systemd {spec.scope.value} timer"), + ("Schedule", ", ".join(spec.calendar)), + ("Command", spec.command.display()), + ("Timer", timer_unit), + ("Service", service_unit), + ] + + def _require_scope(self, scope: Scope) -> None: + if scope is Scope.SYSTEM and os.geteuid() != 0: + raise SafetyRefusalError( + "creating a system timer requires appropriate privileges.", + hint="Run the command under sudo yourself if that is your intention:\n sudo schedls new ... --system", + ) + + def _unit_dir(self, scope: Scope) -> str: + if scope is Scope.SYSTEM: + return "/etc/systemd/system" + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + base = validate_absolute_path(xdg, what="XDG_CONFIG_HOME") + else: + home = validate_absolute_path(os.path.expanduser("~"), what="HOME") + base = os.path.join(home, ".config") + return os.path.join(base, "systemd", "user") + + def _systemctl(self, scope: Scope, *args: str) -> list[str]: + if scope is Scope.USER: + return ["systemctl", "--user", *args] + return ["systemctl", *args] + + def _env_policy(self, scope: Scope) -> str: + return "systemd" if scope is Scope.USER else "minimal" + + +def _parse_occurrences(text: str) -> list[datetime]: + occurrences: list[datetime | None] = [] + for line in text.splitlines(): + match = _ITERATION_RE.search(line) + if match: + occurrences.append(parse_systemd_timestamp(match.group(1).strip())) + continue + utc_match = _UTC_LINE_RE.search(line) + if utc_match and occurrences: + parsed = _parse_utc_stamp(utc_match.group(1).strip()) + if parsed is not None: + occurrences[-1] = parsed + return [item for item in occurrences if item is not None] + + +def _parse_utc_stamp(stamp: str) -> datetime | None: + text = stamp.strip() + if text.endswith(" UTC"): + text = text[: -len(" UTC")] + try: + parsed = datetime.strptime(text, "%a %Y-%m-%d %H:%M:%S") + except ValueError: + return None + return parsed.replace(tzinfo=UTC) + + +def _timers_calendar(value: str) -> tuple[str, ...]: + found: list[str] = [] + for match in re.finditer(r"OnCalendar=([^;}\s]+(?:\s+[^;}\s]+)*)", value): + expression = match.group(1).strip() + if expression and not expression.startswith("n/a"): + found.append(expression) + return tuple(found) + + +def _job_name(unit: str, managed: bool) -> str: + base = os.path.basename(unit) + if base.endswith(_TIMER_SUFFIX): + base = base[: -len(_TIMER_SUFFIX)] + if managed and base.startswith("schedls-"): + return base[len("schedls-") :] + return base + + +def _enabled_from_state(state: str | None) -> bool | None: + if state is None: + return None + return state in _ENABLED_STATES + + +def _is_true(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _environment_from_service(sections: dict[str, list[str]] | None) -> tuple[tuple[str, str], ...]: + result: list[tuple[str, str]] = [] + for line in values(sections, "Service", "Environment"): + key, sep, value = line.partition("=") + if sep: + result.append((key, value.strip('"'))) + return tuple(result) + + +def _command_from_service(sections: dict[str, list[str]] | None) -> Command: + exec_lines = values(sections, "Service", "ExecStart") + if not exec_lines: + return Command() + return renderer.parse_exec_start(exec_lines[0]) + + +def _read_text(path: str) -> str | None: + try: + with open(path, encoding="utf-8") as handle: + return handle.read() + except OSError: + return None diff --git a/src/schedls/cli.py b/src/schedls/cli.py index 27f1637..ba0bdcf 100644 --- a/src/schedls/cli.py +++ b/src/schedls/cli.py @@ -1,35 +1,732 @@ +"""Command-line interface for schedls.""" + +from __future__ import annotations + import argparse -import subprocess +import re +import sys +from dataclasses import dataclass + +from . import __version__, convenience +from .backends.base import SchedulerBackend +from .backends.cron import CronBackend +from .backends.systemd import SystemdBackend +from .errors import EXIT_FAILURE, EXIT_SUCCESS, NotFoundError, SchedlsError, UsageError +from .interact import Interaction +from .models import Backend, Command, JobSpec, ScheduledJob, Scope +from .operations import calendar as calendar_ops +from .operations import doctor as doctor_ops +from .operations import inspect as inspect_ops +from .operations import mutate as mutate_ops +from .output import Output, sanitize_text +from .prompt import Prompter +from .renderers import cron as cron_renderer +from .runner import CommandRunner +from .security import validate_name +from .timefmt import format_datetime + +_ENV_NAME_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z") +_MAX_LOG_LINES = 1_000_000 -def main() -> int: +def _positive_int(value: str) -> int: + try: + number = int(value) + except ValueError: + raise argparse.ArgumentTypeError("expected an integer") from None + if number < 1 or number > _MAX_LOG_LINES: + raise argparse.ArgumentTypeError(f"must be between 1 and {_MAX_LOG_LINES}") + return number + + +@dataclass +class Context: + runner: CommandRunner + output: Output + interaction: Interaction + systemd: SystemdBackend + cron: CronBackend + + @property + def backends(self) -> list[SchedulerBackend]: + return [self.systemd, self.cron] + + +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="schedls", description="Inspect and manage Linux scheduled jobs.", ) - + parser.add_argument("--version", action="version", version=f"schedls {__version__}") + parser.add_argument("--debug", action="store_true", help="enable debug output on stderr") + parser.add_argument("--json", action="store_true", dest="json_mode", help="emit JSON on stdout") parser.add_argument( - "--version", - action="version", - version="schedls 0.0.1", + "--color", + choices=("auto", "always", "never"), + default="auto", + help="colorize human output (default: auto)", + ) + parser.add_argument("--utc", action="store_true", help="display times in UTC") + scope = parser.add_mutually_exclusive_group() + scope.add_argument("--user", dest="scope_filter", action="store_const", const=Scope.USER, help="only user scope") + scope.add_argument( + "--system", dest="scope_filter", action="store_const", const=Scope.SYSTEM, help="only system scope" + ) + parser.add_argument( + "--backend", + choices=("systemd", "cron"), + dest="backend_filter", + help="only show jobs from this backend", + ) + managed = parser.add_mutually_exclusive_group() + managed.add_argument( + "--managed", dest="managed_filter", action="store_const", const=True, help="only show jobs created by schedls" + ) + managed.add_argument( + "--unmanaged", + dest="managed_filter", + action="store_const", + const=False, + help="only show jobs schedls does not manage", + ) + enabled = parser.add_mutually_exclusive_group() + enabled.add_argument( + "--enabled", dest="enabled_filter", action="store_const", const=True, help="only show enabled jobs" + ) + enabled.add_argument( + "--disabled", dest="enabled_filter", action="store_const", const=False, help="only show disabled jobs" ) subparsers = parser.add_subparsers(dest="command") - calendar = subparsers.add_parser( - "calendar", - help="Validate and inspect a systemd calendar expression", + subparsers.add_parser("list", help="list visible scheduled jobs") + + show = subparsers.add_parser("show", help="show one scheduled job") + show.add_argument("name", help="job name") + + new = subparsers.add_parser("new", help="create a scheduled job") + new.add_argument("name", nargs="?", help="job name (prompted for with --interactive)") + backend = new.add_mutually_exclusive_group() + backend.add_argument("--timer", action="store_true", help="create a systemd timer") + backend.add_argument("--cron", action="store_true", help="create a cron job") + _add_creation_options(new) + _add_interactive_flag(new) + + edit = subparsers.add_parser("edit", help="modify a schedls-managed job") + edit.add_argument("name", help="job name") + _add_edit_options(edit) + _add_interactive_flag(edit) + + rm = subparsers.add_parser("rm", help="remove a schedls-managed job") + rm.add_argument("name", help="job name") + _add_mutation_flags(rm) + + for action, help_text in (("enable", "enable a job"), ("disable", "disable a job")): + command = subparsers.add_parser(action, help=help_text) + command.add_argument("name", help="job name") + _add_mutation_flags(command) + + logs = subparsers.add_parser("logs", help="show available execution logs") + logs.add_argument("name", help="job name") + logs.add_argument( + "--lines", + type=_positive_int, + default=50, + help="number of recent journal lines to show (default: 50)", ) - calendar.add_argument("expression") + logs.add_argument("--since", help="only show entries since this time (systemd timers only)") - args = parser.parse_args() + calendar = subparsers.add_parser("calendar", help="validate a systemd calendar expression") + calendar.add_argument("expression", help="OnCalendar expression to validate, e.g. 'Mon..Fri 02:30'") + calendar.add_argument( + "--next", type=int, default=5, dest="next_count", help="how many upcoming occurrences to show (default: 5)" + ) - if args.command == "calendar": - result = subprocess.run( - ["systemd-analyze", "calendar", args.expression], - check=False, + subparsers.add_parser("doctor", help="inspect scheduler capabilities") + + return parser + + +def _add_schedule_options(parser: argparse.ArgumentParser, *, creation: bool) -> None: + parser.add_argument("--calendar", action="append", metavar="EXPR", help="native OnCalendar expression (repeatable)") + parser.add_argument("--daily", metavar="TIME", help="daily at TIME (HH:MM)") + parser.add_argument("--weekdays", metavar="TIME", help="Mon..Fri at TIME") + parser.add_argument("--weekly", nargs=2, metavar=("DAY", "TIME"), help="weekly on DAY at TIME") + parser.add_argument("--monthly", nargs=2, metavar=("DAY", "TIME"), help="monthly on DAY at TIME") + parser.add_argument("--cron-expr", metavar="EXPR", help="five-field cron expression") + parser.add_argument("--persistent", action="store_true", help="run missed events (systemd timers)") + parser.add_argument("--jitter", metavar="DURATION", help="randomized delay, e.g. 5min (systemd timers)") + parser.add_argument("--accuracy", metavar="DURATION", help="timer accuracy window, e.g. 1min (systemd timers)") + parser.add_argument("--working-directory", metavar="PATH", help="run the command from PATH (systemd timers)") + parser.add_argument("--env", action="append", metavar="KEY=VALUE", help="set an environment variable (repeatable)") + parser.add_argument("--shell", metavar="SCRIPT", help="run SCRIPT through /bin/sh instead of an argv") + if creation: + scope = parser.add_mutually_exclusive_group() + scope.add_argument( + "--user", dest="scope_group", action="store_const", const=Scope.USER, help="user scope (default)" + ) + scope.add_argument( + "--system", dest="scope_group", action="store_const", const=Scope.SYSTEM, help="system scope (root)" ) - return result.returncode - parser.print_help() - return 0 + +def _add_creation_options(parser: argparse.ArgumentParser) -> None: + _add_schedule_options(parser, creation=True) + _add_mutation_flags(parser) + + +def _add_edit_options(parser: argparse.ArgumentParser) -> None: + _add_schedule_options(parser, creation=False) + parser.add_argument( + "--command", + action="store_true", + dest="replace_command", + help="replace the command using the text after '--'", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument("--no-persistent", action="store_true", help="disable Persistent") + _add_mutation_flags(parser) + + +def _add_mutation_flags(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--dry-run", action="store_true", help="show what would change without doing it") + parser.add_argument("--yes", action="store_true", help="assume yes; do not prompt") + parser.add_argument("--show-files", action="store_true", help="include rendered files in previews") + + +def _add_interactive_flag(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="fill missing fields with guided prompts (requires a terminal)", + ) + + +def split_command(argv: list[str]) -> tuple[list[str], list[str]]: + if "--" in argv: + index = argv.index("--") + return argv[:index], argv[index + 1 :] + return argv, [] + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + head, tail = split_command(arguments) + parser = build_parser() + args = parser.parse_args(head) + + output = Output(json_mode=args.json_mode, color=args.color, utc=args.utc) + runner = CommandRunner(debug=args.debug) + interaction = Interaction(output=output, assume_yes=getattr(args, "yes", False)) + context = Context( + runner=runner, + output=output, + interaction=interaction, + systemd=SystemdBackend(runner), + cron=CronBackend(runner), + ) + + try: + return _dispatch(context, args, tail) + except SchedlsError as exc: + output.diagnostic(exc.render()) + return exc.exit_code + except KeyboardInterrupt: + output.diagnostic("Interrupted.") + return EXIT_FAILURE + except BrokenPipeError: + return EXIT_FAILURE + + +def _dispatch(context: Context, args: argparse.Namespace, tail: list[str]) -> int: + command = args.command or "list" + if command == "list": + return _cmd_list(context, args) + if command == "show": + return _cmd_show(context, args) + if command == "new": + return _cmd_new(context, args, tail) + if command == "edit": + return _cmd_edit(context, args, tail) + if command == "rm": + return _cmd_rm(context, args) + if command in {"enable", "disable"}: + return _cmd_set_enabled(context, args, command == "enable") + if command == "logs": + return _cmd_logs(context, args) + if command == "calendar": + return _cmd_calendar(context, args) + if command == "doctor": + return _cmd_doctor(context, args) + raise UsageError(f"unknown command: {command}") + + +def _cmd_list(context: Context, args: argparse.Namespace) -> int: + scopes = [args.scope_filter] if args.scope_filter else [Scope.USER, Scope.SYSTEM] + jobs, warnings = inspect_ops.collect(context.backends, scopes) + backend_filter = Backend(args.backend_filter) if args.backend_filter else None + jobs = inspect_ops.filter_jobs( + jobs, + scope=args.scope_filter, + backend=backend_filter, + managed=args.managed_filter, + enabled=args.enabled_filter, + ) + inspect_ops.render_list(context.output, jobs, warnings) + return EXIT_SUCCESS + + +def _cmd_show(context: Context, args: argparse.Namespace) -> int: + _, job = _find_job(context, args.name) + inspect_ops.render_show(context.output, job) + return EXIT_SUCCESS + + +def _cmd_new(context: Context, args: argparse.Namespace, tail: list[str]) -> int: + if args.interactive: + _require_interactive(context) + args, tail = _wizard_new(args, tail, _make_prompter(context)) + elif args.name is None: + raise UsageError("no schedule name given.", hint="Pass a name, e.g. 'schedls new backup ...'.") + elif not (args.timer or args.cron): + raise UsageError("choose a backend.", hint="Pass --timer or --cron.") + spec = _spec_for_new(args, tail) + backend = context.systemd if spec.backend is Backend.SYSTEMD else context.cron + plan = backend.plan_create(spec) + result = mutate_ops.run_plan( + backend, + plan, + context.output, + context.interaction, + dry_run=args.dry_run, + show_files=args.show_files, + ) + if result.changed and not result.dry_run: + _report_created(context, spec) + return EXIT_SUCCESS + + +def _cmd_edit(context: Context, args: argparse.Namespace, tail: list[str]) -> int: + backend, job = _find_job(context, args.name) + if not backend.capabilities.update: + raise UsageError(f"editing {job.backend.value} jobs is not supported yet.") + if args.interactive: + _require_interactive(context) + args, tail = _wizard_edit(job, args, tail, _make_prompter(context)) + spec = _spec_for_edit(job, args, tail) + plan = backend.plan_update(job, spec) + result = mutate_ops.run_plan( + backend, + plan, + context.output, + context.interaction, + dry_run=args.dry_run, + show_files=args.show_files, + ) + if result.changed and not result.dry_run and not context.output.json_mode: + context.output.line(f"Updated {job.name}.") + return EXIT_SUCCESS + + +def _cmd_rm(context: Context, args: argparse.Namespace) -> int: + backend, job = _find_job(context, args.name) + plan = backend.plan_remove(job) + result = mutate_ops.run_plan( + backend, + plan, + context.output, + context.interaction, + dry_run=args.dry_run, + show_files=args.show_files, + ) + if result.changed and not result.dry_run and not context.output.json_mode: + context.output.line(f"Removed {job.name}.") + return EXIT_SUCCESS + + +def _cmd_set_enabled(context: Context, args: argparse.Namespace, enabled: bool) -> int: + backend, job = _find_job(context, args.name) + if not (backend.capabilities.enable if enabled else backend.capabilities.disable): + raise UsageError( + f"{job.backend.value} does not support enable/disable.", + hint="Cron has no universal native enabled/disabled concept.", + ) + plan = backend.plan_set_enabled(job, enabled) + result = mutate_ops.run_plan( + backend, + plan, + context.output, + context.interaction, + dry_run=args.dry_run, + show_files=args.show_files, + ) + if result.changed and not result.dry_run and not context.output.json_mode: + state = "Enabled" if enabled else "Disabled" + context.output.line(f"{state} {job.name}.") + return EXIT_SUCCESS + + +def _cmd_logs(context: Context, args: argparse.Namespace) -> int: + backend, job = _find_job(context, args.name) + if not backend.capabilities.logs: + message = ( + "Per-job logs are not available through the cron backend.\n\n" + "Cron output may be delivered by mail, redirected by the command,\n" + "or written to system logs depending on the local cron implementation." + ) + if context.output.json_mode: + context.output.emit_json( + { + "schema_version": 1, + "name": job.name, + "backend": job.backend.value, + "content": None, + "message": message, + } + ) + else: + context.output.line(message) + return EXIT_SUCCESS + text = backend.logs(job, lines=args.lines, since=args.since) + if context.output.json_mode: + context.output.emit_json( + { + "schema_version": 1, + "name": job.name, + "backend": job.backend.value, + "unit": job.systemd.service_unit if job.systemd else None, + "content": text, + } + ) + return EXIT_SUCCESS + if context.output.stdout.isatty(): + text = sanitize_text(text) + context.output.write(text) + return EXIT_SUCCESS + + +def _cmd_calendar(context: Context, args: argparse.Namespace) -> int: + calendar_ops.run_calendar(context.runner, context.output, args.expression, next_count=args.next_count) + return EXIT_SUCCESS + + +def _cmd_doctor(context: Context, args: argparse.Namespace) -> int: + doctor_ops.run_doctor(context.runner, context.output, context.systemd, context.cron) + return EXIT_SUCCESS + + +def _find_job(context: Context, name: str) -> tuple[SchedulerBackend, ScheduledJob]: + for backend in context.backends: + if not backend.available(): + continue + job = backend.find(name) + if job is not None: + return backend, job + raise NotFoundError( + f"schedule {name!r} not found.", + hint="Run 'schedls' to list visible schedules.", + ) + + +def _resolve_scope(args: argparse.Namespace) -> Scope: + chosen = getattr(args, "scope_group", None) or getattr(args, "scope_filter", None) + return chosen or Scope.USER + + +def _resolve_command(args: argparse.Namespace, tail: list[str], *, required: bool) -> Command: + if getattr(args, "shell", None): + if tail: + raise UsageError("cannot combine --shell with a command after '--'.") + return Command(shell=True, raw=args.shell) + if not tail and required: + raise UsageError("no command given.", hint="Pass the command after '--'.") + return Command(argv=tuple(tail)) + + +def _parse_environment(entries: list[str] | None) -> tuple[tuple[str, str], ...]: + result: list[tuple[str, str]] = [] + for entry in entries or []: + key, sep, value = entry.partition("=") + if not sep or not _ENV_NAME_RE.match(key): + raise UsageError(f"invalid environment entry: {entry!r}", hint="Expected KEY=VALUE.") + result.append((key, value)) + return tuple(result) + + +def _calendar_from_args(args: argparse.Namespace) -> list[str]: + convenience_exprs: list[str] = [] + if args.daily: + convenience_exprs.append(convenience.daily_calendar(args.daily)) + if args.weekdays: + convenience_exprs.append(convenience.weekdays_calendar(args.weekdays)) + if args.weekly: + convenience_exprs.append(convenience.weekly_calendar(*args.weekly)) + if args.monthly: + convenience_exprs.append(convenience.monthly_calendar(*args.monthly)) + if args.calendar and convenience_exprs: + raise UsageError("combine --calendar with convenience flags is not allowed.") + return list(args.calendar or []) + convenience_exprs + + +def _cron_expression_from_args(args: argparse.Namespace) -> str | None: + convenience_exprs: list[str] = [] + if args.daily: + convenience_exprs.append(convenience.daily_cron(args.daily)) + if args.weekdays: + convenience_exprs.append(convenience.weekdays_cron(args.weekdays)) + if args.weekly: + convenience_exprs.append(convenience.weekly_cron(*args.weekly)) + if args.monthly: + convenience_exprs.append(convenience.monthly_cron(*args.monthly)) + if args.cron_expr and convenience_exprs: + raise UsageError("combine --cron-expr with convenience flags is not allowed.") + if len(convenience_exprs) > 1: + raise UsageError("cron supports a single schedule; specify one convenience flag.") + if args.cron_expr: + return cron_renderer.validate_expression(args.cron_expr) + return convenience_exprs[0] if convenience_exprs else None + + +def _spec_for_new(args: argparse.Namespace, tail: list[str]) -> JobSpec: + validate_name(args.name) + scope = _resolve_scope(args) + command = _resolve_command(args, tail, required=True) + environment = _parse_environment(args.env) + if args.timer: + if args.cron_expr: + raise UsageError("--cron-expr is a cron option; use --calendar for systemd timers.") + calendar = _calendar_from_args(args) + if not calendar: + raise UsageError("no schedule given.", hint="Use --calendar or a convenience flag such as --daily.") + return JobSpec( + name=args.name, + backend=Backend.SYSTEMD, + scope=scope, + command=command, + calendar=tuple(calendar), + persistent=args.persistent, + jitter=args.jitter, + accuracy=args.accuracy, + working_directory=args.working_directory, + environment=environment, + ) + if args.calendar: + raise UsageError("--calendar is a systemd option; use --cron-expr for cron.") + if args.jitter or args.accuracy or args.persistent or args.working_directory: + raise UsageError("--jitter/--accuracy/--persistent/--working-directory are systemd options.") + if args.env: + raise UsageError("--env is a systemd option; cron environment variables are not supported yet.") + if _resolve_scope(args) is Scope.SYSTEM: + raise UsageError( + "--system is not supported for cron jobs.", + hint="Cron jobs are always created for the current user.", + ) + expression = _cron_expression_from_args(args) + if not expression: + raise UsageError("no schedule given.", hint="Use --cron-expr or a convenience flag such as --daily.") + return JobSpec( + name=args.name, + backend=Backend.CRON, + scope=Scope.USER, + command=command, + cron_expression=expression, + ) + + +def _spec_for_edit(job: ScheduledJob, args: argparse.Namespace, tail: list[str]) -> JobSpec: + if tail and not args.replace_command: + raise UsageError("to replace the command use --command before '--'.") + command = job.command + if args.replace_command: + command = _resolve_command(args, tail, required=True) + elif args.shell: + command = Command(shell=True, raw=args.shell) + + if job.backend is Backend.SYSTEMD: + calendar = _calendar_from_args(args) + if args.cron_expr: + raise UsageError("--cron-expr cannot be used for a systemd timer.") + if not calendar: + calendar = list(job.systemd.on_calendar if job.systemd else []) + persistent = args.persistent or (job.systemd.persistent if job.systemd else False) + if args.no_persistent: + persistent = False + return JobSpec( + name=job.name, + backend=Backend.SYSTEMD, + scope=job.scope, + command=command, + calendar=tuple(calendar), + persistent=persistent, + jitter=args.jitter if args.jitter is not None else (job.systemd.jitter if job.systemd else None), + accuracy=args.accuracy if args.accuracy is not None else (job.systemd.accuracy if job.systemd else None), + working_directory=args.working_directory + if args.working_directory is not None + else (job.systemd.working_directory if job.systemd else None), + environment=_parse_environment(args.env) if args.env else (job.systemd.environment if job.systemd else ()), + ) + raise UsageError(f"editing {job.backend.value} jobs is not supported yet.") + + +_SCHEDULE_DESTS = ("daily", "weekdays", "weekly", "monthly", "calendar", "cron_expr") + + +def _require_interactive(context: Context) -> None: + if context.output.json_mode: + raise UsageError("--interactive cannot be combined with --json.") + + +def _make_prompter(context: Context) -> Prompter: + return Prompter(output=context.output, calendar_validator=context.systemd.validate_calendar) + + +def _has_schedule(args: argparse.Namespace) -> bool: + return any(getattr(args, dest, None) is not None for dest in _SCHEDULE_DESTS) + + +def _apply_schedule(args: argparse.Namespace, fragment: dict[str, object]) -> None: + for dest in _SCHEDULE_DESTS: + setattr(args, dest, fragment.get(dest)) + + +def _wizard_backend(args: argparse.Namespace, prompter: Prompter) -> Backend: + if args.timer: + return Backend.SYSTEMD + if args.cron: + return Backend.CRON + selected = prompter.choice( + "Backend:", + [("timer", "systemd timer"), ("cron", "cron job")], + default="timer", + ) + if selected == "timer": + args.timer = True + return Backend.SYSTEMD + args.cron = True + return Backend.CRON + + +def _wizard_command(args: argparse.Namespace, tail: list[str], prompter: Prompter) -> list[str]: + if args.shell or tail: + return tail + command = prompter.command() + if command.shell: + args.shell = command.raw + return [] + return list(command.argv) + + +def _wizard_new(args: argparse.Namespace, tail: list[str], prompter: Prompter) -> tuple[argparse.Namespace, list[str]]: + prompter.require_terminal() + if args.name is None: + args.name = prompter.text("Schedule name", validator=validate_name) + backend = _wizard_backend(args, prompter) + tail = _wizard_command(args, tail, prompter) + if not _has_schedule(args): + _apply_schedule(args, prompter.schedule(backend)) + if backend is Backend.SYSTEMD: + _wizard_advanced(args, prompter) + return args, tail + + +def _wizard_advanced(args: argparse.Namespace, prompter: Prompter) -> None: + provided = ( + args.persistent + or args.jitter is not None + or args.accuracy is not None + or args.working_directory is not None + or bool(args.env) + ) + if provided or not prompter.yes_no("Set advanced timer options?", default=False): + return + args.persistent = prompter.yes_no("Persistent (run missed events)?", default=False) + jitter = prompter.optional_text("Randomized delay") + if jitter is not None: + args.jitter = jitter + accuracy = prompter.optional_text("Timer accuracy") + if accuracy is not None: + args.accuracy = accuracy + working_directory = prompter.optional_text("Working directory (absolute path)") + if working_directory is not None: + args.working_directory = working_directory + if prompter.yes_no("Add environment variables?", default=False): + entries = prompter.environment() + if entries: + args.env = entries + + +def _wizard_edit( + job: ScheduledJob, args: argparse.Namespace, tail: list[str], prompter: Prompter +) -> tuple[argparse.Namespace, list[str]]: + prompter.require_terminal() + if ( + not args.replace_command + and not args.shell + and not tail + and prompter.yes_no("Replace the command?", default=False) + ): + command = prompter.command() + if command.shell: + args.shell = command.raw + else: + args.replace_command = True + tail = list(command.argv) + if job.backend is Backend.SYSTEMD: + if not _has_schedule(args) and prompter.yes_no("Change the schedule?", default=False): + _apply_schedule(args, prompter.schedule(job.backend)) + _wizard_edit_advanced(job, args, prompter) + return args, tail + + +def _wizard_edit_advanced(job: ScheduledJob, args: argparse.Namespace, prompter: Prompter) -> None: + if not prompter.yes_no("Change advanced timer options?", default=False): + return + details = job.systemd + persistent = prompter.yes_no( + "Persistent (run missed events)?", + default=bool(details and details.persistent), + ) + args.persistent = persistent + args.no_persistent = not persistent + jitter = prompter.optional_text("Randomized delay", default=details.jitter if details else None) + if jitter is not None: + args.jitter = jitter + accuracy = prompter.optional_text("Timer accuracy", default=details.accuracy if details else None) + if accuracy is not None: + args.accuracy = accuracy + working_directory = prompter.optional_text( + "Working directory (absolute path)", + default=details.working_directory if details else None, + ) + if working_directory is not None: + args.working_directory = working_directory + if prompter.yes_no("Replace environment variables?", default=False): + args.env = prompter.environment() + + +def _report_created(context: Context, spec: JobSpec) -> None: + output = context.output + if output.json_mode: + return + output.line(f"Created {spec.name}.") + if spec.backend is Backend.SYSTEMD and spec.calendar: + try: + occurrences = context.systemd.calendar_occurrences(spec.calendar[0], iterations=1) + except SchedlsError: + occurrences = [] + if occurrences: + output.line() + output.line("Next run:") + output.line(f" {format_datetime(occurrences[0], utc=output.utc)}") + output.line() + output.line("Inspect:") + output.line(f" schedls show {spec.name}") + if spec.backend is Backend.SYSTEMD: + output.line() + output.line("Logs:") + output.line(f" schedls logs {spec.name}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/schedls/convenience.py b/src/schedls/convenience.py new file mode 100644 index 0000000..1274520 --- /dev/null +++ b/src/schedls/convenience.py @@ -0,0 +1,126 @@ +"""Compile convenience flags into native scheduler expressions. + +These forms are sugar only; they always compile to native ``OnCalendar=`` or +five-field cron syntax and never introduce a new scheduling engine. +""" + +from __future__ import annotations + +import re + +from .errors import InvalidScheduleError + +_TIME_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})(?::(?P\d{2}))?$") + +_WEEKDAY_CALENDAR = { + "sun": "Sun", + "sunday": "Sun", + "mon": "Mon", + "monday": "Mon", + "tue": "Tue", + "tuesday": "Tue", + "wed": "Wed", + "wednesday": "Wed", + "thu": "Thu", + "thursday": "Thu", + "fri": "Fri", + "friday": "Fri", + "sat": "Sat", + "saturday": "Sat", +} + +_CRON_DOW = { + "sun": "0", + "sunday": "0", + "mon": "1", + "monday": "1", + "tue": "2", + "tuesday": "2", + "wed": "3", + "wednesday": "3", + "thu": "4", + "thursday": "4", + "fri": "5", + "friday": "5", + "sat": "6", + "saturday": "6", +} + + +def _parse_time(value: str) -> tuple[int, int, int]: + match = _TIME_RE.match(value.strip()) + if not match: + raise InvalidScheduleError( + f"invalid time of day: {value!r}", + hint="Expected HH:MM or HH:MM:SS (24-hour clock).", + ) + hour = int(match.group("h")) + minute = int(match.group("m")) + second = int(match.group("s") or 0) + if hour > 23 or minute > 59 or second > 59: + raise InvalidScheduleError(f"invalid time of day: {value!r}") + return hour, minute, second + + +def daily_calendar(time: str) -> str: + hour, minute, second = _parse_time(time) + return f"*-*-* {hour:02d}:{minute:02d}:{second:02d}" + + +def weekdays_calendar(time: str) -> str: + hour, minute, second = _parse_time(time) + return f"Mon..Fri *-*-* {hour:02d}:{minute:02d}:{second:02d}" + + +def weekly_calendar(day: str, time: str) -> str: + dow = _WEEKDAY_CALENDAR.get(day.strip().lower()) + if dow is None: + raise InvalidScheduleError( + f"invalid weekday: {day!r}", + hint="Use one of: sun, mon, tue, wed, thu, fri, sat.", + ) + hour, minute, second = _parse_time(time) + return f"{dow} *-*-* {hour:02d}:{minute:02d}:{second:02d}" + + +def monthly_calendar(day: str, time: str) -> str: + try: + day_i = int(day) + except ValueError: + raise InvalidScheduleError(f"invalid day of month: {day!r}") from None + if not 1 <= day_i <= 31: + raise InvalidScheduleError(f"day of month out of range: {day!r}") + hour, minute, second = _parse_time(time) + return f"*-*-{day_i:02d} {hour:02d}:{minute:02d}:{second:02d}" + + +def daily_cron(time: str) -> str: + hour, minute, _ = _parse_time(time) + return f"{minute} {hour} * * *" + + +def weekdays_cron(time: str) -> str: + hour, minute, _ = _parse_time(time) + return f"{minute} {hour} * * 1-5" + + +def weekly_cron(day: str, time: str) -> str: + dow = _CRON_DOW.get(day.strip().lower()) + if dow is None: + raise InvalidScheduleError( + f"invalid weekday: {day!r}", + hint="Use one of: sun, mon, tue, wed, thu, fri, sat.", + ) + hour, minute, _ = _parse_time(time) + return f"{minute} {hour} * * {dow}" + + +def monthly_cron(day: str, time: str) -> str: + try: + day_i = int(day) + except ValueError: + raise InvalidScheduleError(f"invalid day of month: {day!r}") from None + if not 1 <= day_i <= 31: + raise InvalidScheduleError(f"day of month out of range: {day!r}") + hour, minute, _ = _parse_time(time) + return f"{minute} {hour} {day_i} * *" diff --git a/src/schedls/describe.py b/src/schedls/describe.py new file mode 100644 index 0000000..e788664 --- /dev/null +++ b/src/schedls/describe.py @@ -0,0 +1,95 @@ +"""Presentation-only human descriptions of schedules. + +This module never validates or interprets schedule *meaning* for execution. +When a form is not recognised with full confidence the native expression is +shown unchanged. Correctness beats prettiness. +""" + +from __future__ import annotations + +import re + +from .models import Schedule, ScheduleKind + +_WEEKDAY_NAMES = { + 0: "Sunday", + 1: "Monday", + 2: "Tuesday", + 3: "Wednesday", + 4: "Thursday", + 5: "Friday", + 6: "Saturday", +} + +_WEEKDAYS_INDEX = {"Mon": 1, "Tue": 2, "Wed": 3, "Thu": 4, "Fri": 5, "Sat": 6, "Sun": 0} + +_CAL_DAILY = re.compile(r"^\*-\*-\*\s+(\d{1,2}):(\d{2}):(\d{2})$") +_CAL_WEEKDAYS = re.compile(r"^Mon\.\.Fri\s+\*-\*-\*\s+(\d{1,2}):(\d{2}):(\d{2})$") +_CAL_SINGLE_WEEKDAY = re.compile(r"^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\*-\*-\*\s+(\d{1,2}):(\d{2}):(\d{2})$") +_CAL_MONTHLY = re.compile(r"^\*-\*\-(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})$") + +_CRON_FIELDS = re.compile(r"^\S+\s+\S+\s+\S+\s+\S+\s+\S+$") + + +def _clock(hour: str, minute: str, second: str) -> str: + if second in {"00", "0"}: + return f"{int(hour):02d}:{int(minute):02d}" + return f"{int(hour):02d}:{int(minute):02d}:{int(second):02d}" + + +def describe_calendar(expression: str) -> str | None: + expr = expression.strip() + match = _CAL_WEEKDAYS.match(expr) + if match: + return f"weekdays at {_clock(*match.groups())}" + match = _CAL_SINGLE_WEEKDAY.match(expr) + if match: + return f"{_WEEKDAY_NAMES[_WEEKDAYS_INDEX[match.group(1)]]} at {_clock(*match.groups()[1:])}" + match = _CAL_MONTHLY.match(expr) + if match: + day, hour, minute, second = match.groups() + return f"monthly on day {int(day)} at {_clock(hour, minute, second)}" + match = _CAL_DAILY.match(expr) + if match: + return f"daily at {_clock(*match.groups())}" + if expr == "daily": + return "daily" + return None + + +def describe_cron(expression: str) -> str | None: + expr = expression.strip() + if not _CRON_FIELDS.match(expr): + return None + minute, hour, dom, month, dow = expr.split() + try: + minute_i = int(minute) + hour_i = int(hour) + except ValueError: + return None + if not (0 <= minute_i <= 59 and 0 <= hour_i <= 23): + return None + clock = f"{hour_i:02d}:{minute_i:02d}" + if dom == "*" and month == "*": + if dow == "*": + return f"daily at {clock}" + if dow == "1-5": + return f"weekdays at {clock}" + if dow.isdigit() and 0 <= int(dow) <= 6: + return f"{_WEEKDAY_NAMES[int(dow)]} at {clock}" + if dom.isdigit() and month == "*" and dow == "*": + return f"monthly on day {int(dom)} at {clock}" + return None + + +def describe_schedule(schedule: Schedule) -> str: + if schedule.kind is ScheduleKind.CALENDAR: + expression = schedule.expression + described = describe_calendar(expression) + if described and len(schedule.all_expressions()) == 1: + return described + if len(schedule.all_expressions()) > 1: + return " + ".join(schedule.all_expressions()) + return expression or "—" + described = describe_cron(schedule.expression) + return described or schedule.expression or "—" diff --git a/src/schedls/errors.py b/src/schedls/errors.py new file mode 100644 index 0000000..fe88632 --- /dev/null +++ b/src/schedls/errors.py @@ -0,0 +1,77 @@ +"""Exception hierarchy and stable exit codes for schedls. + +Exit codes are part of the public interface and must remain stable: + +* ``0`` success +* ``1`` operational failure +* ``2`` invalid command-line input or invalid schedule +* ``3`` safety refusal / conflict +""" + +from __future__ import annotations + +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_USAGE = 2 +EXIT_REFUSED = 3 + + +class SchedlsError(Exception): + """Base class for all expected schedls failures.""" + + exit_code = EXIT_FAILURE + + def __init__(self, message: str, *, hint: str | None = None) -> None: + super().__init__(message) + self.message = message + self.hint = hint + + def render(self) -> str: + text = f"Error: {self.message}" + if self.hint: + text = f"{text}\n\n{self.hint}" + return text + + +class UsageError(SchedlsError): + """The caller supplied invalid input.""" + + exit_code = EXIT_USAGE + + +class InvalidNameError(UsageError): + """A schedule name does not match the restricted grammar.""" + + +class InvalidScheduleError(UsageError): + """A schedule expression could not be validated.""" + + +class NotFoundError(SchedlsError): + """The requested schedule does not exist.""" + + +class OperationalError(SchedlsError): + """A helper command or filesystem operation failed.""" + + +class DependencyMissingError(OperationalError): + """A required native helper executable is unavailable.""" + + +class CommandTimeoutError(OperationalError): + """A helper command exceeded its allowed execution time.""" + + +class SafetyRefusalError(SchedlsError): + """schedls declined to perform a risky operation.""" + + exit_code = EXIT_REFUSED + + +class ConflictError(SafetyRefusalError): + """A destination already exists or an unmanaged object was targeted.""" + + +class ConfirmationRequiredError(SafetyRefusalError): + """Confirmation was required but cannot be obtained non-interactively.""" diff --git a/src/schedls/interact.py b/src/schedls/interact.py new file mode 100644 index 0000000..cd3c3e7 --- /dev/null +++ b/src/schedls/interact.py @@ -0,0 +1,36 @@ +"""Interactive confirmation handling.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass + +from .errors import ConfirmationRequiredError +from .output import Output + + +@dataclass +class Interaction: + output: Output + assume_yes: bool = False + + def confirm(self, prompt: str, *, default: bool = False) -> bool: + if self.assume_yes: + return True + self.output.stdout.flush() + if not sys.stdin.isatty(): + raise ConfirmationRequiredError( + "confirmation required but stdin is not interactive.", + hint="Re-run with --yes after reviewing the command.", + ) + suffix = "[Y/n]" if default else "[y/N]" + try: + answer = input(f"{prompt} {suffix} ").strip().lower() + except EOFError: + raise ConfirmationRequiredError( + "confirmation required but no input was available.", + hint="Re-run with --yes after reviewing the command.", + ) from None + if not answer: + return default + return answer in {"y", "yes"} diff --git a/src/schedls/models.py b/src/schedls/models.py new file mode 100644 index 0000000..5bbe234 --- /dev/null +++ b/src/schedls/models.py @@ -0,0 +1,148 @@ +"""Common data model shared by every backend. + +The common model describes shared concepts but never erases backend-specific +facts: those live in typed nested structures attached to each job. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import StrEnum + + +class Backend(StrEnum): + SYSTEMD = "systemd" + CRON = "cron" + + +class Scope(StrEnum): + USER = "user" + SYSTEM = "system" + + +class ScheduleKind(StrEnum): + CALENDAR = "calendar" + CRON = "cron" + + +@dataclass(frozen=True) +class Schedule: + """A schedule expressed in a backend's native syntax.""" + + kind: ScheduleKind + expression: str + expressions: tuple[str, ...] = () + + def all_expressions(self) -> tuple[str, ...]: + if self.expressions: + return self.expressions + return (self.expression,) + + +@dataclass(frozen=True) +class Command: + """An execution request. + + ``argv`` is always the authoritative form. ``shell`` requests that the + command be executed through ``/bin/sh -c`` using ``raw``. + """ + + argv: tuple[str, ...] = () + shell: bool = False + raw: str | None = None + + def display(self) -> str: + if self.shell and self.raw is not None: + return self.raw + if not self.argv and self.raw: + return self.raw + return " ".join(self.argv) + + def is_empty(self) -> bool: + return not self.argv and not self.raw + + +@dataclass(frozen=True) +class JobSource: + """Where a discovered job came from.""" + + detail: str + path: str | None = None + line: int | None = None + + +@dataclass(frozen=True) +class SystemdDetails: + timer_unit: str | None = None + service_unit: str | None = None + timer_path: str | None = None + service_path: str | None = None + on_calendar: tuple[str, ...] = () + persistent: bool = False + jitter: str | None = None + accuracy: str | None = None + working_directory: str | None = None + environment: tuple[tuple[str, str], ...] = () + active_state: str | None = None + sub_state: str | None = None + unit_file_state: str | None = None + result: str | None = None + description: str | None = None + + +@dataclass(frozen=True) +class CronDetails: + expression: str | None = None + shell: str | None = None + mailto: str | None = None + raw_line: str | None = None + line: int | None = None + environment: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class ScheduledJob: + name: str + backend: Backend + scope: Scope + managed: bool + enabled: bool | None + schedule: Schedule + command: Command + source: JobSource + next_run: datetime | None = None + last_run: datetime | None = None + last_result: str | None = None + warnings: tuple[str, ...] = () + systemd: SystemdDetails | None = None + cron: CronDetails | None = None + + def schedule_text(self) -> str: + from .describe import describe_schedule + + return describe_schedule(self.schedule) + + +@dataclass(frozen=True) +class JobSpec: + """A request to create or update a scheduled job.""" + + name: str + backend: Backend + scope: Scope + command: Command + calendar: tuple[str, ...] = () + cron_expression: str | None = None + persistent: bool = False + jitter: str | None = None + accuracy: str | None = None + working_directory: str | None = None + environment: tuple[tuple[str, str], ...] = field(default_factory=tuple) + + def schedule(self) -> Schedule: + if self.backend is Backend.SYSTEMD: + expr = self.calendar[0] if self.calendar else "" + return Schedule(ScheduleKind.CALENDAR, expr, tuple(self.calendar)) + expr = self.cron_expression or "" + return Schedule(ScheduleKind.CRON, expr) diff --git a/src/schedls/operations/__init__.py b/src/schedls/operations/__init__.py new file mode 100644 index 0000000..00c3d45 --- /dev/null +++ b/src/schedls/operations/__init__.py @@ -0,0 +1 @@ +"""High-level operations.""" diff --git a/src/schedls/operations/calendar.py b/src/schedls/operations/calendar.py new file mode 100644 index 0000000..a4395ba --- /dev/null +++ b/src/schedls/operations/calendar.py @@ -0,0 +1,105 @@ +"""``schedls calendar`` — validate and explore systemd calendar syntax.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from ..errors import DependencyMissingError, InvalidScheduleError +from ..output import Output +from ..runner import CommandRunner +from ..security import has_unsafe_control_characters +from ..timefmt import format_datetime + + +def run_calendar( + runner: CommandRunner, + output: Output, + expression: str, + *, + next_count: int = 5, +) -> None: + if has_unsafe_control_characters(expression): + raise InvalidScheduleError("calendar expressions must not contain control characters") + if expression.startswith("-"): + raise InvalidScheduleError(f"calendar expression must not start with '-': {expression!r}") + if not runner.has("systemd-analyze"): + raise DependencyMissingError("systemd-analyze is not available; cannot evaluate calendar expressions.") + completed = runner.run( + [ + "systemd-analyze", + "calendar", + f"--iterations={max(next_count, 1)}", + expression, + ], + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise InvalidScheduleError(f"invalid calendar expression: {expression!r}", hint=detail or None) + original, normalized, occurrences = _parse(completed.stdout) + if output.json_mode: + output.emit_json( + { + "schema_version": 1, + "expression": expression, + "original": original, + "normalized": normalized, + "next_occurrences": [_iso(dt, utc=output.utc) for dt in occurrences], + } + ) + return + output.key_values([("Expression", original or expression)]) + output.line() + output.key_values([("Normalized", normalized or expression)]) + output.line() + output.heading("Next occurrences") + for occurrence in occurrences: + output.line(f" {format_datetime(occurrence, utc=output.utc)}") + + +def _iso(dt: datetime, *, utc: bool) -> str: + if utc: + dt = dt.astimezone(UTC) + return dt.isoformat() + + +def _parse(text: str) -> tuple[str, str, list[datetime]]: + original = "" + normalized = "" + occurrences: list[datetime | None] = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("Original form:"): + original = stripped.split(":", 1)[1].strip() + elif stripped.startswith("Normalized form:"): + normalized = stripped.split(":", 1)[1].strip() + elif stripped.startswith("Next elapse:") or stripped.startswith("Iteration #"): + occurrences.append(_parse_stamp(stripped.split(":", 1)[1].strip())) + elif stripped.startswith("(in UTC):") and occurrences: + parsed = _parse_utc(stripped.split(":", 1)[1].strip()) + if parsed is not None: + occurrences[-1] = parsed + return original, normalized, [item for item in occurrences if item is not None] + + +def _parse_stamp(stamp: str) -> datetime | None: + parts = stamp.split() + if len(parts) >= 3: + candidate = " ".join(parts[:3]) + try: + parsed = datetime.strptime(candidate, "%a %Y-%m-%d %H:%M:%S") + except ValueError: + return None + return parsed.astimezone() + return None + + +def _parse_utc(stamp: str) -> datetime | None: + text = stamp.strip() + if text.endswith(" UTC"): + text = text[: -len(" UTC")] + try: + parsed = datetime.strptime(text, "%a %Y-%m-%d %H:%M:%S") + except ValueError: + return None + return parsed.replace(tzinfo=UTC) diff --git a/src/schedls/operations/doctor.py b/src/schedls/operations/doctor.py new file mode 100644 index 0000000..2deba0f --- /dev/null +++ b/src/schedls/operations/doctor.py @@ -0,0 +1,116 @@ +"""``schedls doctor`` — diagnostic capability report. Never fixes anything.""" + +from __future__ import annotations + +import platform +from typing import Any + +from .. import __version__ +from ..backends.cron import CronBackend +from ..backends.systemd import SystemdBackend +from ..models import Scope +from ..output import Output +from ..runner import CommandRunner + + +def run_doctor( + runner: CommandRunner, + output: Output, + systemd_backend: SystemdBackend, + cron_backend: CronBackend, +) -> None: + systemd = _systemd_report(runner, systemd_backend) + cron = _cron_report(runner, cron_backend) + usable = systemd["available"] or cron["crontab_available"] + + if output.json_mode: + output.emit_json( + { + "schema_version": 1, + "schedls": {"version": __version__, "python": platform.python_version()}, + "systemd": systemd, + "cron": cron, + "result": "usable" if usable else "unusable", + } + ) + return + + output.heading("schedls") + output.key_values([("version", __version__), ("Python", platform.python_version())]) + output.line() + output.heading("systemd") + output.key_values( + [ + ("available", _yes_no(systemd["available"])), + ("system manager", _reachable(systemd["system_manager"])), + ("user manager", _reachable(systemd["user_manager"])), + ("systemd-analyze", _yes_no(systemd["analyze"])), + ("calendar validation", _yes_no(systemd["calendar_validation"])), + ("user lingering", _lingering(systemd["user_lingering"])), + ] + ) + output.line() + output.heading("cron") + output.key_values( + [ + ("crontab", _yes_no(cron["crontab_available"])), + ("implementation", cron["implementation"] or "unknown"), + ("current user allowed", _yes_no(cron["user_allowed"])), + ("syntax validation", _yes_no(cron["validation"])), + ] + ) + output.line() + output.heading("Result") + output.line(f" {'usable' if usable else 'unusable'}") + + +def _systemd_report(runner: CommandRunner, systemd_backend: SystemdBackend) -> dict[str, Any]: + analyze = runner.has("systemd-analyze") + user_manager = systemd_backend.manager_ok(Scope.USER) + system_manager = systemd_backend.manager_ok(Scope.SYSTEM) + available = runner.has("systemctl") and (user_manager or system_manager) + return { + "available": available, + "system_manager": system_manager, + "user_manager": user_manager, + "analyze": analyze, + "calendar_validation": analyze and available, + "user_lingering": systemd_backend.user_lingering(), + } + + +def _cron_report(runner: CommandRunner, cron_backend: CronBackend) -> dict[str, Any]: + if not cron_backend.available(): + return { + "crontab_available": False, + "implementation": None, + "user_allowed": False, + "validation": False, + } + info = cron_backend.capabilities_info() + completed = runner.run(["crontab", "-l"], env_policy="identity", check=False) + allowed = completed.returncode == 0 or "no crontab" in (completed.stderr + completed.stdout).lower() + return { + "crontab_available": True, + "implementation": info.implementation, + "user_allowed": allowed, + "validation": info.supports_validation, + } + + +def _yes_no(value: bool | None) -> str: + if value is None: + return "unknown" + return "yes" if value else "no" + + +def _reachable(value: bool | None) -> str: + if value is None: + return "unknown" + return "reachable" if value else "unreachable" + + +def _lingering(value: bool | None) -> str: + if value is None: + return "unknown" + return "enabled" if value else "disabled" diff --git a/src/schedls/operations/inspect.py b/src/schedls/operations/inspect.py new file mode 100644 index 0000000..2441815 --- /dev/null +++ b/src/schedls/operations/inspect.py @@ -0,0 +1,154 @@ +"""Discovery, listing and detail views.""" + +from __future__ import annotations + +import os +from collections.abc import Sequence + +from ..backends.base import SchedulerBackend +from ..errors import SchedlsError +from ..models import Backend, ScheduledJob, Scope +from ..output import Output, jobs_document +from ..timefmt import format_short + + +def collect(backends: Sequence[SchedulerBackend], scopes: Sequence[Scope]) -> tuple[list[ScheduledJob], list[str]]: + jobs: list[ScheduledJob] = [] + warnings: list[str] = [] + for backend in backends: + if not backend.available(): + warnings.append(f"{backend.name} backend is unavailable; skipping it.") + continue + try: + jobs.extend(backend.discover(scopes)) + except SchedlsError as exc: + warnings.append(f"{backend.name} discovery failed: {exc.message}") + jobs.sort(key=lambda job: (job.name, job.backend.value)) + if os.geteuid() != 0 and Scope.SYSTEM in scopes: + warnings.append( + "some system schedules could not be inspected with your current permissions.\n" + "Run schedls as root if you intentionally want wider visibility." + ) + return jobs, warnings + + +def job_status(job: ScheduledJob) -> str: + if job.enabled is False: + return "disabled" + if job.backend is Backend.SYSTEMD and job.systemd is not None: + sub = job.systemd.sub_state or "" + if sub in {"waiting", "running"}: + return sub + return job.systemd.active_state or "unknown" + if job.backend is Backend.CRON: + return "active" + return "unknown" + + +def filter_jobs( + jobs: Sequence[ScheduledJob], + *, + scope: Scope | None = None, + backend: Backend | None = None, + managed: bool | None = None, + enabled: bool | None = None, +) -> list[ScheduledJob]: + selected = [] + for job in jobs: + if scope is not None and job.scope is not scope: + continue + if backend is not None and job.backend is not backend: + continue + if managed is not None and job.managed is not managed: + continue + if enabled is not None and job.enabled is not enabled: + continue + selected.append(job) + return selected + + +def render_list(output: Output, jobs: Sequence[ScheduledJob], warnings: Sequence[str]) -> None: + if output.json_mode: + output.emit_json(jobs_document(jobs, warnings)) + return + if not jobs: + output.line("No scheduled jobs found.") + else: + rows = [] + for job in jobs: + next_text = format_short(job.next_run, utc=output.utc) if job.next_run else "—" + rows.append( + [ + job.name, + job.schedule_text(), + next_text, + job.backend.value, + job.scope.value, + job_status(job), + ] + ) + output.table(["NAME", "SCHEDULE", "NEXT", "BACKEND", "SCOPE", "STATUS"], rows) + for warning in warnings: + output.diagnostic(f"Note: {warning}") + + +def render_show(output: Output, job: ScheduledJob) -> None: + if output.json_mode: + output.emit_json(jobs_document([job])) + return + output.heading(job.name) + output.line() + output.key_values([("Status", job_status(job))]) + output.line() + output.key_values([("Backend", _backend_label(job))]) + output.line() + output.key_values([("Managed by schedls", "yes" if job.managed else "no")]) + output.line() + output.key_values([("Schedule", job.schedule_text())]) + if job.schedule.expressions: + for expression in job.schedule.expressions: + output.key_values([("OnCalendar", expression)]) + output.line() + output.key_values([("Next", output.job_datetime(job.next_run) or "unknown")]) + output.line() + previous = output.job_datetime(job.last_run) or "unknown" + if job.last_result: + previous = f"{previous}\nresult: {job.last_result}" + output.key_values([("Previous", previous)]) + output.line() + output.key_values([("Command", job.command.display() or "unknown")]) + if job.systemd is not None: + if job.systemd.timer_path: + output.line() + output.key_values([("Timer", job.systemd.timer_path)]) + if job.systemd.service_path: + output.key_values([("Service", job.systemd.service_path)]) + extras = [] + extras.append(("Persistent", "yes" if job.systemd.persistent else "no")) + if job.systemd.jitter: + extras.append(("Randomized delay", job.systemd.jitter)) + if job.systemd.accuracy: + extras.append(("Accuracy", job.systemd.accuracy)) + if job.systemd.working_directory: + extras.append(("Working directory", job.systemd.working_directory)) + for key, value in job.systemd.environment: + extras.append(("Environment", f"{key}={value}")) + if extras: + output.line() + output.key_values(extras) + if job.cron is not None: + output.line() + output.key_values([("Source", job.source.detail)]) + if job.cron.shell: + output.key_values([("SHELL", job.cron.shell)]) + if job.cron.mailto: + output.key_values([("MAILTO", job.cron.mailto)]) + for warning in job.warnings: + output.line() + output.diagnostic(f"Warning: {warning}") + + +def _backend_label(job: ScheduledJob) -> str: + if job.backend is Backend.SYSTEMD: + return f"systemd {job.scope.value} timer" + return "cron" diff --git a/src/schedls/operations/mutate.py b/src/schedls/operations/mutate.py new file mode 100644 index 0000000..ca6c945 --- /dev/null +++ b/src/schedls/operations/mutate.py @@ -0,0 +1,103 @@ +"""Generic mutation preview, confirmation and application.""" + +from __future__ import annotations + +from ..backends.base import MutationResult, Plan, SchedulerBackend +from ..interact import Interaction +from ..output import Output + +_TITLES = { + "create": "Create scheduled job", + "update": "Change scheduled job", + "remove": "Remove scheduled job", + "enable": "Enable scheduled job", + "disable": "Disable scheduled job", +} + + +def run_plan( + backend: SchedulerBackend, + plan: Plan, + output: Output, + interaction: Interaction, + *, + dry_run: bool = False, + show_files: bool = False, +) -> MutationResult: + if not output.json_mode: + _preview(output, plan, dry_run=dry_run, show_files=show_files) + + if dry_run: + result = MutationResult(changed=False, dry_run=True, warnings=tuple(plan.warnings)) + if output.json_mode: + output.emit_json(_plan_document(plan, result)) + else: + output.line() + output.line("No changes made.") + return result + + title = _TITLES.get(plan.action, "Apply changes") + if not interaction.confirm(f"{title}?"): + if output.json_mode: + output.emit_json(_plan_document(plan, MutationResult(changed=False))) + else: + output.line("Aborted. No changes made.") + return MutationResult(changed=False) + + result = backend.apply(plan) + if output.json_mode: + output.emit_json(_plan_document(plan, result)) + else: + for warning in result.warnings: + output.warning(warning) + for message in result.messages: + output.line(message) + return result + + +def _plan_document(plan: Plan, result: MutationResult) -> dict[str, object]: + return { + "schema_version": 1, + "action": plan.action, + "backend": plan.backend, + "changed": result.changed, + "dry_run": result.dry_run, + "files": list(result.files_written) or plan.display_files(), + "commands": [list(command.argv) for command in plan.commands], + "warnings": list(result.warnings) or list(plan.warnings), + "messages": list(result.messages), + } + + +def _preview(output: Output, plan: Plan, *, dry_run: bool, show_files: bool) -> None: + title = _TITLES.get(plan.action, "Apply changes") + output.heading(f"Would {plan.action}:" if dry_run else f"{title}:") + + if plan.summary: + output.line() + output.key_values(plan.summary) + + for warning in plan.warnings: + output.warning(warning) + + created = [change for change in plan.files if change.content is not None] + removed = [change for change in plan.files if change.content is None] + if created: + output.line() + output.line("Would create:" if dry_run else "Will write:") + for change in created: + output.line(f" {change.path}") + if show_files and change.content is not None: + for line in change.content.splitlines(): + output.line(f" {line}") + if removed: + output.line() + output.line("Would remove:" if dry_run else "Will remove:") + for change in removed: + output.line(f" {change.path}") + + if plan.commands: + output.line() + output.line("Would run:" if dry_run else "Will run:") + for command in plan.commands: + output.line(f" {' '.join(command.argv)}") diff --git a/src/schedls/output.py b/src/schedls/output.py new file mode 100644 index 0000000..b6e60de --- /dev/null +++ b/src/schedls/output.py @@ -0,0 +1,177 @@ +"""Output abstraction: human tables, key/value views and stable JSON.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from collections.abc import Iterable, Sequence +from datetime import datetime +from typing import IO, Any + +from .models import ScheduledJob +from .timefmt import format_datetime, isoformat + +SCHEMA_VERSION = 1 + +_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") + + +def sanitize_text(text: str) -> str: + """Escape control characters that could manipulate a terminal. + + Newlines and tabs are preserved; every other C0/C1/DEL byte is rendered as + a visible ``\\xNN`` sequence. + """ + return _CONTROL_RE.sub(lambda match: f"\\x{ord(match.group(0)):02x}", text) + + +_COLORS = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", +} + + +class Output: + def __init__( + self, + *, + json_mode: bool = False, + color: str = "auto", + utc: bool = False, + stdout: IO[str] | None = None, + stderr: IO[str] | None = None, + ) -> None: + self.json_mode = json_mode + self.color = color + self.utc = utc + self.stdout = stdout or sys.stdout + self.stderr = stderr or sys.stderr + + def use_color(self) -> bool: + if self.color == "always": + return True + if self.color == "never": + return False + if os.environ.get("NO_COLOR") is not None: + return False + return self.stdout.isatty() + + def style(self, text: str, *names: str) -> str: + if not self.use_color() or not names: + return text + prefix = "".join(_COLORS[name] for name in names if name in _COLORS) + return f"{prefix}{text}{_COLORS['reset']}" + + def line(self, text: str = "") -> None: + print(text, file=self.stdout) + + def write(self, text: str) -> None: + """Write text verbatim, without adding a trailing newline.""" + print(text, end="", file=self.stdout) + + def heading(self, text: str) -> None: + self.line(self.style(text, "bold")) + + def diagnostic(self, text: str) -> None: + print(text, file=self.stderr) + + def warning(self, text: str) -> None: + print(self.style(f"Warning: {text}", "yellow"), file=self.stderr) + + def emit_json(self, payload: dict[str, Any]) -> None: + self.line(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False)) + + def table(self, headers: Sequence[str], rows: Iterable[Sequence[str]]) -> None: + materialized = [[str(cell) for cell in row] for row in rows] + widths = [len(header) for header in headers] + for row in materialized: + for index, cell in enumerate(row): + widths[index] = max(widths[index], len(cell)) + header_line = " ".join(header.ljust(widths[i]) for i, header in enumerate(headers)) + self.line(self.style(header_line.rstrip(), "bold")) + for row in materialized: + self.line(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip()) + + def key_values(self, pairs: Sequence[tuple[str, str]]) -> None: + if not pairs: + return + width = max(len(key) for key, _ in pairs) + for key, value in pairs: + self.line(f"{key.ljust(width)} {value}".rstrip()) + + def job_datetime(self, value: datetime | None) -> str | None: + if value is None: + return None + return format_datetime(value, utc=self.utc) + + +def job_to_dict(job: ScheduledJob) -> dict[str, Any]: + schedule: dict[str, Any] = { + "kind": job.schedule.kind.value, + "expression": job.schedule.expression, + } + if job.schedule.expressions: + schedule["expressions"] = list(job.schedule.expressions) + + data: dict[str, Any] = { + "name": job.name, + "backend": job.backend.value, + "scope": job.scope.value, + "managed": job.managed, + "enabled": job.enabled, + "schedule": schedule, + "command": { + "argv": list(job.command.argv), + "shell": job.command.shell, + "raw": job.command.raw, + }, + "source": { + "detail": job.source.detail, + "path": job.source.path, + "line": job.source.line, + }, + "next_run": isoformat(job.next_run) if job.next_run else None, + "last_run": isoformat(job.last_run) if job.last_run else None, + "last_result": job.last_result, + "warnings": list(job.warnings), + } + if job.systemd is not None: + data["systemd"] = { + "timer_unit": job.systemd.timer_unit, + "service_unit": job.systemd.service_unit, + "timer_path": job.systemd.timer_path, + "service_path": job.systemd.service_path, + "on_calendar": list(job.systemd.on_calendar), + "persistent": job.systemd.persistent, + "jitter": job.systemd.jitter, + "accuracy": job.systemd.accuracy, + "working_directory": job.systemd.working_directory, + "environment": [list(item) for item in job.systemd.environment], + "active_state": job.systemd.active_state, + "sub_state": job.systemd.sub_state, + "unit_file_state": job.systemd.unit_file_state, + "result": job.systemd.result, + } + if job.cron is not None: + data["cron"] = { + "expression": job.cron.expression, + "shell": job.cron.shell, + "mailto": job.cron.mailto, + "line": job.cron.line, + "environment": [list(item) for item in job.cron.environment], + } + return data + + +def jobs_document(jobs: Sequence[ScheduledJob], warnings: Sequence[str] = ()) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "jobs": [job_to_dict(job) for job in jobs], + "warnings": list(warnings), + } diff --git a/src/schedls/prompt.py b/src/schedls/prompt.py new file mode 100644 index 0000000..34414dc --- /dev/null +++ b/src/schedls/prompt.py @@ -0,0 +1,201 @@ +"""Interactive input collection for the opt-in guided wizard. + +The wizard only gathers input. It never builds a plan, writes a file or runs a +command: callers turn the collected values into a :class:`JobSpec` and reuse the +normal preview/confirm/apply path. Prompts are line-based and use the standard +input stream only; no editor, pager or extra dependency is involved. +""" + +from __future__ import annotations + +import re +import shlex +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +from .convenience import ( + daily_calendar, + monthly_calendar, + weekly_calendar, +) +from .errors import ConfirmationRequiredError, SchedlsError +from .models import Backend, Command +from .output import Output +from .renderers import cron as cron_renderer + +InputFn = Callable[[str], str] +Validator = Callable[[str], object] +CalendarValidator = Callable[[str], object] + +_ENV_NAME_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z") + + +def _valid_time(value: str) -> str: + daily_calendar(value) + return value + + +def _valid_day(value: str) -> str: + weekly_calendar(value, "00:00") + return value + + +def _valid_month_day(value: str) -> str: + monthly_calendar(value, "00:00") + return value + + +def _valid_cron(value: str) -> str: + cron_renderer.validate_expression(value) + return value + + +@dataclass +class Prompter: + """Collects values from an interactive terminal. + + ``input_fn`` is resolved at call time so callers and tests can substitute + the builtin. ``calendar_validator`` is an optional callable (typically the + systemd backend's ``validate_calendar``) used to re-prompt on a bad native + ``OnCalendar`` expression. + """ + + output: Output + input_fn: InputFn | None = None + calendar_validator: CalendarValidator | None = None + + def require_terminal(self) -> None: + if not sys.stdin.isatty(): + raise ConfirmationRequiredError( + "interactive mode requires a terminal.", + hint="Re-run without --interactive and pass flags, or run from a terminal.", + ) + + def text( + self, + prompt: str, + *, + default: str | None = None, + validator: Validator | None = None, + ) -> str: + label = f"{prompt} [{default}]" if default else prompt + while True: + raw = self._read(f"{label}: ").strip() + if not raw: + if default is not None: + raw = default + else: + self.output.line("A value is required.") + continue + if validator is None: + return raw + try: + validator(raw) + except SchedlsError as exc: + hint = f" {exc.hint}" if exc.hint else "" + self.output.line(f"{exc.message}{hint}") + continue + return raw + + def optional_text(self, prompt: str, *, default: str | None = None) -> str | None: + label = f"{prompt} [{default}]" if default else f"{prompt} (optional)" + raw = self._read(f"{label}: ").strip() + if not raw: + return default + return raw + + def yes_no(self, prompt: str, *, default: bool = False) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + while True: + raw = self._read(f"{prompt} {suffix} ").strip().lower() + if not raw: + return default + if raw in {"y", "yes"}: + return True + if raw in {"n", "no"}: + return False + self.output.line("Please answer 'y' or 'n'.") + + def choice(self, prompt: str, options: Sequence[tuple[str, str]], *, default: str | None = None) -> str: + self.output.line(prompt) + width = len(str(len(options))) + for index, (_, label) in enumerate(options, start=1): + self.output.line(f" {str(index).rjust(width)}. {label}") + values = [value for value, _ in options] + hint = f"{prompt} [{default}]" if default else prompt + while True: + raw = self._read(f"{hint}: ").strip() + if not raw and default is not None: + return default + if raw.isdigit() and 1 <= int(raw) <= len(options): + return options[int(raw) - 1][0] + if raw in values: + return raw + self.output.line("Enter a number from the list.") + + def command(self) -> Command: + if self.yes_no("Run the command through a shell (/bin/sh -c)?", default=False): + return Command(shell=True, raw=self.text("Shell command")) + while True: + raw = self.text("Command") + try: + argv = tuple(shlex.split(raw)) + except ValueError as exc: + self.output.line(f"Could not parse the command: {exc}") + continue + if not argv: + self.output.line("A command is required.") + continue + return Command(argv=argv) + + def environment(self) -> list[str]: + self.output.line("Environment variables (KEY=VALUE, blank line to finish):") + entries: list[str] = [] + while True: + raw = self._read(" KEY=VALUE: ").strip() + if not raw: + return entries + key, sep, _ = raw.partition("=") + if not sep or not _ENV_NAME_RE.match(key): + self.output.line("Expected KEY=VALUE with a valid variable name.") + continue + entries.append(raw) + + def schedule(self, backend: Backend) -> dict[str, object]: + options = [ + ("daily", "Daily at a time"), + ("weekdays", "Weekdays (Mon-Fri) at a time"), + ("weekly", "Weekly on a day of the week"), + ("monthly", "Monthly on a day of the month"), + ("custom", "Custom native expression"), + ] + kind = self.choice("Schedule:", options, default="daily") + if kind == "daily": + return {"daily": self.text("Time (HH:MM)", validator=_valid_time)} + if kind == "weekdays": + return {"weekdays": self.text("Time (HH:MM)", validator=_valid_time)} + if kind == "weekly": + day = self.text("Day of week (sun..sat)", validator=_valid_day) + return {"weekly": [day, self.text("Time (HH:MM)", validator=_valid_time)]} + if kind == "monthly": + day = self.text("Day of month (1-31)", validator=_valid_month_day) + return {"monthly": [day, self.text("Time (HH:MM)", validator=_valid_time)]} + if backend is Backend.SYSTEMD: + return {"calendar": [self.text("OnCalendar expression", validator=self._validate_calendar)]} + return {"cron_expr": self.text("Cron expression (five fields)", validator=_valid_cron)} + + def _validate_calendar(self, value: str) -> str: + if self.calendar_validator is not None: + self.calendar_validator(value) + return value + + def _read(self, label: str) -> str: + fn = self.input_fn or input + try: + return fn(label) + except EOFError: + raise ConfirmationRequiredError( + "interactive input ended unexpectedly.", + hint="Run without --interactive or provide answers in a terminal.", + ) from None diff --git a/src/schedls/renderers/__init__.py b/src/schedls/renderers/__init__.py new file mode 100644 index 0000000..c3c9485 --- /dev/null +++ b/src/schedls/renderers/__init__.py @@ -0,0 +1 @@ +"""Renderers for native scheduler configuration.""" diff --git a/src/schedls/renderers/cron.py b/src/schedls/renderers/cron.py new file mode 100644 index 0000000..860f4d2 --- /dev/null +++ b/src/schedls/renderers/cron.py @@ -0,0 +1,127 @@ +"""Render cron entries and managed blocks. + +Cron executes command text through a shell. schedls keeps an argument vector +internally and serializes it into a shell-safe command string, then escapes +``%`` because cron implementations such as Cronie give it special meaning. If +a safe representation cannot be guaranteed the operation is refused. +""" + +from __future__ import annotations + +import re +import shlex + +from ..errors import InvalidScheduleError, SafetyRefusalError +from ..models import Command +from ..security import has_unsafe_control_characters + +BLOCK_BEGIN = "# schedls:begin name={name}{extra}" +BLOCK_END = "# schedls:end name={name}" + +_MARKER_RE = re.compile(r"^#\s*schedls:(?Pbegin|end)\s+(?P.*)$") +_FIELD_RE = re.compile(r"^[0-9A-Za-z*,/\-]+$") +_NICKNAMES = { + "@reboot", + "@yearly", + "@annually", + "@monthly", + "@weekly", + "@daily", + "@midnight", + "@hourly", +} +_RANGE_RE = re.compile(r"^[0-9]{1,2}-[0-9]{1,2}$") + + +def escape_percent(text: str) -> str: + """Escape ``%`` for a cron command field. + + cron implementations such as Cronie translate an unescaped ``%`` into a + newline and ``\\%`` into a literal ``%`` before handing the line to the + shell. Escaping *every* ``%`` is correct even when it follows a backslash: + ``\\`` + ``%`` becomes ``\\\\%``, which cron reduces back to ``\\%``. + """ + return text.replace("%", "\\%") + + +def render_command(command: Command) -> str: + if command.shell: + raw = command.raw or "" + if has_unsafe_control_characters(raw): + raise SafetyRefusalError("shell command must not contain NUL or newline") + return escape_percent(raw) + if not command.argv: + raise InvalidScheduleError("no command given") + for arg in command.argv: + if has_unsafe_control_characters(arg): + raise SafetyRefusalError("cron command arguments must not contain NUL or newline") + quoted = " ".join(shlex.quote(arg) for arg in command.argv) + return escape_percent(quoted) + + +def _validate_field(field: str, *, low: int, high: int, names: bool) -> None: + value = field + if value == "*": + return + for part in value.split(","): + if not part: + raise InvalidScheduleError(f"invalid cron field: {field!r}") + step_text, _, step = part.partition("/") + if step and (not step.isdigit() or int(step) == 0): + raise InvalidScheduleError(f"invalid cron step in field: {field!r}") + if step_text == "*": + continue + if _RANGE_RE.match(step_text): + start_str, end_str = step_text.split("-") + start, end = int(start_str), int(end_str) + if start > end or not (low <= start <= high) or not (low <= end <= high): + raise InvalidScheduleError(f"cron range out of bounds: {field!r}") + continue + if step_text.isdigit(): + number = int(step_text) + if not low <= number <= high: + raise InvalidScheduleError(f"cron value out of range: {field!r}") + continue + if names and step_text.isalpha(): + continue + raise InvalidScheduleError(f"invalid cron field: {field!r}") + + +def validate_expression(expression: str) -> str: + expr = expression.strip() + if not expr: + raise InvalidScheduleError("empty cron expression") + if expr.startswith("@"): + nickname = expr.split()[0].lower() + if nickname not in _NICKNAMES: + raise InvalidScheduleError(f"unknown cron nickname: {expr!r}") + if expr.lower() != nickname: + raise InvalidScheduleError(f"unexpected text after cron nickname: {expr!r}") + return nickname + fields = expr.split() + if len(fields) != 5: + raise InvalidScheduleError( + f"cron expression must have five fields: {expression!r}", + hint="minute hour day-of-month month day-of-week", + ) + minute, hour, dom, month, dow = fields + for value, low, high, names in ( + (minute, 0, 59, False), + (hour, 0, 23, False), + (dom, 1, 31, False), + (month, 1, 12, True), + (dow, 0, 7, True), + ): + if not _FIELD_RE.match(value): + raise InvalidScheduleError(f"invalid cron field: {value!r}") + _validate_field(value, low=low, high=high, names=names) + return " ".join(fields) + + +def render_line(expression: str, command: Command) -> str: + return f"{validate_expression(expression)} {render_command(command)}" + + +def render_block(name: str, lines: list[str], *, extra: str = "") -> str: + body = [BLOCK_BEGIN.format(name=name, extra=extra), *lines, BLOCK_END.format(name=name)] + return "\n".join(body) + "\n" diff --git a/src/schedls/renderers/systemd.py b/src/schedls/renderers/systemd.py new file mode 100644 index 0000000..2c4bf2b --- /dev/null +++ b/src/schedls/renderers/systemd.py @@ -0,0 +1,153 @@ +"""Render systemd ``.service`` and ``.timer`` units. + +``ExecStart=`` is not ordinary shell syntax. Arguments are serialized with a +dedicated quoting routine that accounts for whitespace, quotes, backslashes, +literal ``$`` and ``%`` characters. Every argument is always quoted so an +argument that begins with a systemd prefix character (``-@:+!``) cannot change +the meaning of the command line. +""" + +from __future__ import annotations + +from ..errors import SafetyRefusalError +from ..models import Command, JobSpec +from ..security import MANAGED_COMMENT, has_unsafe_control_characters + + +def quote_systemd_arg(arg: str) -> str: + """Quote one argument using systemd unit-file quoting rules.""" + if has_unsafe_control_characters(arg): + raise SafetyRefusalError("command arguments must not contain NUL or newlines") + escaped = arg.replace("\\", "\\\\").replace('"', '\\"') + escaped = escaped.replace("$", "$$").replace("%", "%%") + return f'"{escaped}"' + + +def render_exec_start(command: Command) -> str: + if command.shell: + raw = command.raw or "" + return "ExecStart=/bin/sh -c " + quote_systemd_arg(raw) + tokens = [quote_systemd_arg(arg) for arg in command.argv] + return "ExecStart=" + " ".join(tokens) + + +def render_service(spec: JobSpec, unit: str) -> str: + lines = [ + MANAGED_COMMENT, + f"# Name: {spec.name}", + "", + "[Unit]", + f"Description=schedls job {spec.name}", + "", + "[Service]", + "Type=oneshot", + render_exec_start(spec.command), + ] + if spec.working_directory: + lines.append(f"WorkingDirectory={quote_systemd_arg(spec.working_directory)}") + for key, value in spec.environment: + lines.append(f"Environment={quote_systemd_arg(f'{key}={value}')}") + lines.append("") + return "\n".join(lines) + + +def render_timer(spec: JobSpec, unit: str, service_unit: str) -> str: + lines = [ + MANAGED_COMMENT, + f"# Name: {spec.name}", + "", + "[Unit]", + f"Description=schedls job {spec.name} (timer)", + "", + "[Timer]", + ] + for expression in spec.schedule().all_expressions(): + lines.append(f"OnCalendar={expression}") + if spec.persistent: + lines.append("Persistent=true") + if spec.jitter: + lines.append(f"RandomizedDelaySec={spec.jitter}") + if spec.accuracy: + lines.append(f"AccuracySec={spec.accuracy}") + lines.append(f"Unit={service_unit}") + lines.extend( + [ + "", + "[Install]", + "WantedBy=timers.target", + "", + ] + ) + return "\n".join(lines) + + +def render_units(spec: JobSpec, *, service_unit: str, timer_unit: str) -> dict[str, str]: + return { + service_unit: render_service(spec, service_unit), + timer_unit: render_timer(spec, timer_unit, service_unit), + } + + +def unquote_systemd_args(value: str) -> list[str]: + """Inverse of :func:`quote_systemd_arg` for a full ExecStart value.""" + tokens: list[str] = [] + current: list[str] = [] + started = False + in_quotes = False + index = 0 + length = len(value) + while index < length: + char = value[index] + if char == "\\" and index + 1 < length: + current.append(value[index + 1]) + started = True + index += 2 + continue + if char == '"': + in_quotes = not in_quotes + started = True + index += 1 + continue + if char.isspace() and not in_quotes: + if started: + tokens.append("".join(current)) + current = [] + started = False + index += 1 + continue + current.append(char) + started = True + index += 1 + if started: + tokens.append("".join(current)) + return [_collapse_specials(token) for token in tokens] + + +def _collapse_specials(token: str) -> str: + result: list[str] = [] + index = 0 + while index < len(token): + char = token[index] + if char in "$%" and index + 1 < len(token) and token[index + 1] == char: + result.append(char) + index += 2 + continue + result.append(char) + index += 1 + return "".join(result) + + +def parse_exec_start(value: str, *, shell: str | None = None) -> Command: + """Rebuild a :class:`Command` from a native ExecStart value.""" + stripped = value.strip() + if not stripped: + return Command() + if stripped[0] in "-@:+!": + return Command(raw=stripped) + prefix = shell or "/bin/sh" + shell_prefix = f"{prefix} -c " + if stripped.startswith(shell_prefix): + remainder = stripped[len(shell_prefix) :] + parsed = unquote_systemd_args(remainder) + return Command(shell=True, raw=parsed[0] if parsed else "") + return Command(argv=tuple(unquote_systemd_args(stripped))) diff --git a/src/schedls/runner.py b/src/schedls/runner.py new file mode 100644 index 0000000..84fd9b2 --- /dev/null +++ b/src/schedls/runner.py @@ -0,0 +1,194 @@ +"""Centralized, shell-free process execution. + +Every helper process launched by schedls goes through :class:`CommandRunner`. +This is the single place that guarantees ``shell=False``, bounded timeouts, +controlled environments and debug logging. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from collections.abc import Sequence +from dataclasses import dataclass, field + +from .errors import CommandTimeoutError, DependencyMissingError, OperationalError, SafetyRefusalError +from .security import is_acceptable_helper, resolve_helper + +DEFAULT_TIMEOUT = 10.0 +DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +_BASE_ENV = { + "LC_ALL": "C", + "LANG": "C", + "SYSTEMD_COLORS": "0", + "SYSTEMD_PAGER": "cat", + "PAGER": "cat", + "GIT_PAGER": "cat", +} + +# Preserved for user-scoped systemd/dbus operations. +_SESSION_ENV_KEYS = ( + "DBUS_SESSION_BUS_ADDRESS", + "XDG_RUNTIME_DIR", + "XDG_SESSION_ID", + "XDG_SESSION_TYPE", +) + +# Preserved for crontab, which consults identity variables. +_IDENTITY_ENV_KEYS = ("HOME", "USER", "LOGNAME", "SHELL", "TERM") + +_SECRET_RE = re.compile(r"(?i)(pass|secret|token|key|credential)") + + +@dataclass +class Completed: + argv: tuple[str, ...] + returncode: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +@dataclass +class _RunResult: + completed: Completed + timed_out: bool = False + missing: str | None = None + detail: str = "" + + +@dataclass +class CommandRunner: + debug: bool = False + timeout: float = DEFAULT_TIMEOUT + log: list[_RunResult] = field(default_factory=list) + + def __post_init__(self) -> None: + self._resolved: dict[str, str | None] = {} + + def resolve(self, name: str) -> str | None: + if os.sep in name: + if not os.path.isabs(name) or not is_acceptable_helper(name): + return None + return name + if name not in self._resolved: + self._resolved[name] = resolve_helper(name) + return self._resolved[name] + + def has(self, name: str) -> bool: + return self.resolve(name) is not None + + def run( + self, + argv: Sequence[str], + *, + env_policy: str = "minimal", + timeout: float | None = None, + check: bool = True, + input_text: str | None = None, + ) -> Completed: + if not argv: + raise OperationalError("attempted to run an empty command") + if os.sep not in argv[0]: + resolved = self.resolve(argv[0]) + if resolved is None: + raise DependencyMissingError(f"required command not found: {argv[0]}") + executable = resolved + else: + if not os.path.isabs(argv[0]) or not is_acceptable_helper(argv[0]): + raise SafetyRefusalError(f"refusing to run untrusted executable: {argv[0]!r}") + executable = argv[0] + + env = self._build_env(env_policy) + effective = list(argv) + effective[0] = executable + self._debug(f"run {' '.join(self._redact(effective))}") + + try: + proc = subprocess.run( # noqa: S603 - argv form, shell disabled + effective, + shell=False, + capture_output=True, + text=True, + env=env, + timeout=timeout if timeout is not None else self.timeout, + input=input_text, + check=False, + ) + except FileNotFoundError as exc: + raise DependencyMissingError(f"required command not found: {argv[0]}") from exc + except subprocess.TimeoutExpired as exc: + raise CommandTimeoutError( + f"{argv[0]} did not respond within {timeout if timeout is not None else self.timeout:g} seconds." + ) from exc + + completed = Completed( + argv=tuple(effective), + returncode=proc.returncode, + stdout=proc.stdout or "", + stderr=proc.stderr or "", + ) + self._debug(f"exit {completed.returncode}") + if check and completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise OperationalError( + f"command failed ({completed.returncode}): {' '.join(effective)}", + hint=detail or None, + ) + return completed + + def try_run( + self, + argv: Sequence[str], + *, + env_policy: str = "minimal", + timeout: float | None = None, + input_text: str | None = None, + ) -> Completed | None: + """Run a command, returning ``None`` when the helper is unavailable.""" + if os.sep not in argv[0] and self.resolve(argv[0]) is None: + return None + try: + return self.run(argv, env_policy=env_policy, timeout=timeout, input_text=input_text) + except OperationalError: + return None + + def _build_env(self, policy: str) -> dict[str, str]: + env = dict(_BASE_ENV) + env["PATH"] = DEFAULT_PATH + if policy == "minimal": + return env + keys: tuple[str, ...] + if policy == "systemd": + keys = _SESSION_ENV_KEYS + elif policy == "identity": + keys = _IDENTITY_ENV_KEYS + elif policy == "passthrough": + return {**os.environ, **_BASE_ENV} + else: + raise OperationalError(f"unknown environment policy: {policy}") + for key in keys: + value = os.environ.get(key) + if value is not None: + env[key] = value + return env + + def _redact(self, argv: Sequence[str]) -> tuple[str, ...]: + redacted = [] + for arg in argv: + if "=" in arg and _SECRET_RE.search(arg.split("=", 1)[0]): + key, _, _ = arg.partition("=") + redacted.append(f"{key}=") + else: + redacted.append(arg) + return tuple(redacted) + + def _debug(self, message: str) -> None: + if self.debug: + print(f"[schedls] {message}", file=sys.stderr) diff --git a/src/schedls/security.py b/src/schedls/security.py new file mode 100644 index 0000000..9abda69 --- /dev/null +++ b/src/schedls/security.py @@ -0,0 +1,234 @@ +"""Security primitives: name validation, trusted paths and safe writes.""" + +from __future__ import annotations + +import contextlib +import os +import re +import shutil +import stat +import tempfile + +from .errors import InvalidNameError, SafetyRefusalError + +NAME_PATTERN = r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}" +_NAME_RE = re.compile(rf"\A{NAME_PATTERN}\Z") +UNIT_PREFIX = "schedls-" +MANAGED_COMMENT = "# Managed by schedls" + +_MANAGED_UNIT_RE = re.compile(rf"\A{UNIT_PREFIX}{NAME_PATTERN}\.(?:service|timer)\Z") +_SAFE_UNIT_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9:_.@\\-]{0,254}\.(?:service|timer)\Z") + +_HELPER_DIR_ALLOWED_MODE_MASK = stat.S_IWGRP | stat.S_IWOTH + + +def validate_name(name: str) -> str: + if not _NAME_RE.match(name): + raise InvalidNameError( + f"invalid schedule name: {name!r}", + hint=( + "Names must match [A-Za-z0-9][A-Za-z0-9_.-]{0,63}: " + "start alphanumeric, then letters, digits, '_', '.' or '-'." + ), + ) + return name + + +def unit_name(name: str, suffix: str) -> str: + """Build a schedls-owned systemd unit name from a validated schedule name.""" + return f"{UNIT_PREFIX}{validate_name(name)}.{suffix}" + + +def validate_managed_unit_name(unit: str) -> str: + """Accept only unit names schedls itself would create. + + Used before a unit name is turned into a filesystem path so a crafted + ``Unit=`` value cannot escape the trusted unit directory. + """ + if not _MANAGED_UNIT_RE.match(unit): + raise SafetyRefusalError(f"refusing to use unexpected unit name: {unit!r}") + return unit + + +def is_safe_unit_name(unit: str) -> bool: + """Whether a discovered unit name is safe to pass to a helper command.""" + return bool(_SAFE_UNIT_RE.match(unit)) + + +def is_managed_unit(unit: str) -> bool: + return unit.startswith(UNIT_PREFIX) + + +def validate_absolute_path(path: str, *, what: str) -> str: + if not path: + raise SafetyRefusalError(f"{what} path is empty") + if not os.path.isabs(path): + raise SafetyRefusalError(f"{what} path must be absolute: {path!r}") + if "\x00" in path: + raise SafetyRefusalError(f"{what} path contains a NUL byte") + return path + + +def has_unsafe_control_characters(value: str) -> bool: + return any(ch in value for ch in ("\x00", "\n", "\r")) + + +def _owner_is_acceptable(uid: int) -> bool: + if os.geteuid() == 0: + return uid == 0 + return uid in {0, os.getuid()} + + +def _helper_file_ok(path: str) -> bool: + try: + st = os.stat(path) + except OSError: + return False + if not stat.S_ISREG(st.st_mode): + return False + if not _owner_is_acceptable(st.st_uid): + return False + return not st.st_mode & _HELPER_DIR_ALLOWED_MODE_MASK + + +def _helper_dir_ok(path: str) -> bool: + try: + st = os.stat(path) + except OSError: + return False + if not stat.S_ISDIR(st.st_mode): + return False + if not _owner_is_acceptable(st.st_uid): + return False + return not st.st_mode & _HELPER_DIR_ALLOWED_MODE_MASK + + +def is_acceptable_helper(path: str) -> bool: + """Check an executable path is safe to run. + + The file and its containing directory must be owned by root (when running + as root) or by root or the current user, and must not be group- or + other-writable. Symlinks are resolved so the final target is checked too. + """ + resolved = os.path.abspath(path) + for candidate in {resolved, os.path.realpath(resolved)}: + if not _helper_file_ok(candidate): + return False + if not _helper_dir_ok(os.path.dirname(candidate)): + return False + return True + + +def resolve_helper(name: str) -> str | None: + """Resolve a critical helper executable using a controlled lookup. + + Returns an absolute path or ``None``. A resolved helper that is not owned + by an acceptable user, or lives in a group-/other-writable directory, is + rejected to reduce PATH-hijacking risk. + """ + resolved = shutil.which(name) + if not resolved: + return None + resolved = os.path.abspath(resolved) + if not is_acceptable_helper(resolved): + return None + return resolved + + +def _expected_owner_uid() -> int: + return 0 if os.geteuid() == 0 else os.getuid() + + +def check_trusted_directory(path: str, *, expected_uid: int) -> None: + """Ensure a destination directory is trusted: real, owned and not writable.""" + try: + st = os.lstat(path) + except FileNotFoundError: + raise SafetyRefusalError(f"trusted directory does not exist: {path}") from None + if stat.S_ISLNK(st.st_mode): + raise SafetyRefusalError(f"trusted directory must not be a symlink: {path}") + if not stat.S_ISDIR(st.st_mode): + raise SafetyRefusalError(f"trusted path is not a directory: {path}") + if st.st_uid != expected_uid: + raise SafetyRefusalError(f"trusted directory is owned by uid {st.st_uid} (expected {expected_uid}): {path}") + if st.st_mode & _HELPER_DIR_ALLOWED_MODE_MASK: + raise SafetyRefusalError(f"trusted directory is group- or other-writable: {path}") + + +def check_replaceable(path: str, *, expected_uid: int) -> None: + """Reject unexpected symlinks and ownership before overwriting a file.""" + try: + st = os.lstat(path) + except FileNotFoundError: + return + if stat.S_ISLNK(st.st_mode): + raise SafetyRefusalError(f"refusing to replace symlink: {path}") + if not stat.S_ISREG(st.st_mode): + raise SafetyRefusalError(f"refusing to replace non-regular file: {path}") + if st.st_uid != expected_uid: + raise SafetyRefusalError(f"refusing to replace file owned by uid {st.st_uid} (expected {expected_uid}): {path}") + + +def atomic_write_text( + path: str, + content: str, + *, + mode: int = 0o644, + expected_uid: int | None = None, +) -> None: + """Write ``content`` to ``path`` atomically. + + The file is created in the same directory, flushed and fsynced before an + atomic rename, so a partial write can never be observed at ``path``. + """ + owner = _expected_owner_uid() if expected_uid is None else expected_uid + directory = os.path.dirname(path) or "." + check_trusted_directory(directory, expected_uid=owner) + check_replaceable(path, expected_uid=owner) + + fd, tmp_path = tempfile.mkstemp(prefix=".schedls-", dir=directory) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, path) + os.chmod(path, mode) + except BaseException: + with contextlib.suppress(FileNotFoundError): + os.unlink(tmp_path) + raise + _fsync_directory(directory) + + +def remove_file(path: str, *, expected_uid: int | None = None) -> bool: + """Remove a regular file, refusing symlinks and ownership mismatches.""" + owner = _expected_owner_uid() if expected_uid is None else expected_uid + try: + st = os.lstat(path) + except FileNotFoundError: + return False + if stat.S_ISLNK(st.st_mode): + raise SafetyRefusalError(f"refusing to remove symlink: {path}") + if not stat.S_ISREG(st.st_mode): + raise SafetyRefusalError(f"refusing to remove non-regular file: {path}") + if st.st_uid != owner: + raise SafetyRefusalError(f"refusing to remove file owned by uid {st.st_uid} (expected {owner}): {path}") + check_trusted_directory(os.path.dirname(path) or ".", expected_uid=owner) + os.unlink(path) + _fsync_directory(os.path.dirname(path) or ".") + return True + + +def _fsync_directory(directory: str) -> None: + try: + dir_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) + except OSError: + return + try: + os.fsync(dir_fd) + except OSError: + pass + finally: + os.close(dir_fd) diff --git a/src/schedls/timefmt.py b/src/schedls/timefmt.py new file mode 100644 index 0000000..c595632 --- /dev/null +++ b/src/schedls/timefmt.py @@ -0,0 +1,206 @@ +"""Time and duration formatting helpers. + +Time is displayed in the host's local timezone by default; exact values always +include timezone information. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime, timedelta + +_UNITS = ( + r"us|ms|secs|seconds|second|sec|s|mins|minutes|minute|min|m|" + r"hrs|hours|hour|hr|h|days|day|d|weeks|week|w" +) + +_DURATION_RE = re.compile( + rf"^(?P-)?(?:\d+(?:\.\d+)?(?:{_UNITS})\s*)+$", + re.IGNORECASE, +) + +_DURATION_PART_RE = re.compile( + rf"(?P\d+(?:\.\d+)?)\s*(?P{_UNITS})", + re.IGNORECASE, +) + +_UNIT_SECONDS = { + "us": 1e-6, + "ms": 1e-3, + "s": 1.0, + "sec": 1.0, + "secs": 1.0, + "second": 1.0, + "seconds": 1.0, + "m": 60.0, + "min": 60.0, + "mins": 60.0, + "minute": 60.0, + "minutes": 60.0, + "h": 3600.0, + "hr": 3600.0, + "hrs": 3600.0, + "hour": 3600.0, + "hours": 3600.0, + "d": 86400.0, + "day": 86400.0, + "days": 86400.0, + "w": 604800.0, + "week": 604800.0, + "weeks": 604800.0, +} + +_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +_MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + + +def is_valid_duration(value: str) -> bool: + return bool(_DURATION_RE.match(value.strip())) + + +def duration_seconds(value: str) -> float: + """Parse a systemd-style duration into seconds. Raises ValueError.""" + text = value.strip() + if not text: + raise ValueError("empty duration") + sign = 1.0 + if text.startswith("-"): + sign = -1.0 + text = text[1:] + total = 0.0 + matched = False + pos = 0 + for match in _DURATION_PART_RE.finditer(text): + if match.start() != pos: + raise ValueError(f"invalid duration: {value!r}") + pos = match.end() + matched = True + total += float(match.group("value")) * _UNIT_SECONDS[match.group("unit").lower()] + if not matched or pos != len(text): + raise ValueError(f"invalid duration: {value!r}") + return sign * total + + +def _display(dt: datetime, *, utc: bool) -> datetime: + if utc: + return dt.astimezone(UTC) + return dt.astimezone() + + +def local_zone_name(dt: datetime) -> str: + return dt.tzname() or "" + + +def format_datetime(dt: datetime, *, utc: bool = False) -> str: + """Full, unambiguous timestamp such as ``Fri 25 Sep 2026 02:00:00 BST``.""" + dt = _display(dt, utc=utc) + weekday = _WEEKDAYS[dt.weekday()] + month = _MONTHS[dt.month - 1] + stamp = f"{weekday} {dt.day:02d} {month} {dt.year} {dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}" + zone = dt.tzname() + if zone: + stamp = f"{stamp} {zone}" + return stamp + + +def format_clock(dt: datetime, *, utc: bool = False) -> str: + dt = _display(dt, utc=utc) + zone = dt.tzname() + clock = f"{dt.hour:02d}:{dt.minute:02d}" + return f"{clock} {zone}" if zone else clock + + +def format_short(dt: datetime, *, now: datetime | None = None, utc: bool = False) -> str: + """Compact relative-ish timestamp such as ``today 02:00``/``tomorrow 02:00``.""" + dt = _display(dt, utc=utc) + reference = now or datetime.now(dt.tzinfo or None) + if utc: + reference = reference.astimezone(UTC) + day_delta = (dt.date() - reference.date()).days + clock = f"{dt.hour:02d}:{dt.minute:02d}" + if day_delta == 0: + return f"today {clock}" + if day_delta == 1: + return f"tomorrow {clock}" + if day_delta == -1: + return f"yesterday {clock}" + return f"{_WEEKDAYS[dt.weekday()]} {clock}" + + +def isoformat(dt: datetime) -> str: + """ISO 8601 with an explicit timezone offset.""" + return dt.isoformat() + + +def parse_systemd_timestamp(value: str) -> datetime | None: + """Parse the timestamp forms emitted by ``systemctl show``. + + Handles microsecond epoch values, ``usec``/``s`` suffixed values and the + ``n/a`` placeholder. + """ + text = value.strip() + if not text or text in {"n/a", "0", "infinity"}: + return None + if text.isdigit(): + # systemctl prints realtime values in microseconds since the epoch. + micros = int(text) + if micros == 0: + return None + return datetime.fromtimestamp(micros / 1_000_000, tz=UTC).astimezone() + for suffix, scale in (("us", 1e-6), ("ms", 1e-3), ("s", 1.0)): + if text.endswith(suffix): + try: + seconds = float(text[: -len(suffix)]) * scale + except ValueError: + return None + if seconds <= 0: + return None + return datetime.fromtimestamp(seconds, tz=UTC).astimezone() + try: + parsed = datetime.fromisoformat(text) + except ValueError: + parsed = None + if parsed is None: + pretty = _parse_pretty(text) + return pretty + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone() + + +def _parse_pretty(text: str) -> datetime | None: + """Parse ``Thu 2026-09-24 06:49:28 BST`` (systemd's human form). + + The trailing zone abbreviation is ignored; the naive value is interpreted + in the host's local timezone, which is how systemd renders it by default. + """ + parts = text.split() + if len(parts) >= 3: + candidate = " ".join(parts[:3]) + try: + parsed = datetime.strptime(candidate, "%a %Y-%m-%d %H:%M:%S") + except ValueError: + return None + return parsed.astimezone() + return None + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +def add_seconds(dt: datetime, seconds: float) -> datetime: + return dt + timedelta(seconds=seconds) diff --git a/src/schedls/unitfile.py b/src/schedls/unitfile.py new file mode 100644 index 0000000..6655d6a --- /dev/null +++ b/src/schedls/unitfile.py @@ -0,0 +1,63 @@ +"""Minimal, read-only systemd unit-file parser. + +Configuration is treated strictly as data. Values are never executed and only +the keys schedls needs are interpreted. +""" + +from __future__ import annotations + +from .security import MANAGED_COMMENT + + +def has_managed_marker(path: str, *, name: str) -> bool: + """Whether a unit file carries the schedls ownership marker for ``name``.""" + try: + with open(path, encoding="utf-8", errors="replace") as handle: + head = handle.read(4096) + except OSError: + return False + lines = {line.strip() for line in head.splitlines()} + return MANAGED_COMMENT in lines and f"# Name: {name}" in lines + + +def read_units(path: str) -> dict[str, list[str]] | None: + """Return section -> list of raw ``key=value`` lines, or ``None``.""" + try: + with open(path, encoding="utf-8", errors="replace") as handle: + raw_lines = handle.read().splitlines() + except OSError: + return None + + sections: dict[str, list[str]] = {} + current: str | None = None + for raw in raw_lines: + line = raw.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if line.startswith("[") and line.endswith("]"): + current = line[1:-1] + sections.setdefault(current, []) + continue + if current is not None: + sections[current].append(line) + return sections + + +def values(sections: dict[str, list[str]] | None, section: str, key: str) -> list[str]: + if not sections: + return [] + result = [] + prefix = f"{key}=" + for line in sections.get(section, []): + if line.startswith(prefix): + result.append(line[len(prefix) :]) + return result + + +def first(sections: dict[str, list[str]] | None, section: str, key: str) -> str | None: + found = values(sections, section, key) + return found[-1] if found else None + + +def read_fragment(path: str) -> dict[str, list[str]] | None: + return read_units(path) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a36bed2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,73 @@ +"""Shared test fixtures and a controllable fake command runner.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable + +import pytest + +from schedls.errors import DependencyMissingError, OperationalError +from schedls.runner import Completed + + +class FakeRunner: + """A duck-typed stand-in for :class:`schedls.runner.CommandRunner`.""" + + def __init__( + self, + handlers: dict[str, Callable] | None = None, + available: Iterable[str] = (), + ) -> None: + self.handlers = handlers or {} + self.available = set(available) + self.calls: list[tuple[list[str], str, str | None]] = [] + + def has(self, name: str) -> bool: + return name in self.available + + def resolve(self, name: str) -> str | None: + return name if name in self.available else None + + def run(self, argv, *, env_policy="minimal", timeout=None, check=True, input_text=None): + args = list(argv) + self.calls.append((args, env_policy, input_text)) + handler = self.handlers.get(args[0]) + if handler is None: + if args[0] not in self.available: + raise DependencyMissingError(f"required command not found: {args[0]}") + completed = Completed(tuple(args), 0, "", "") + else: + completed = _coerce(args, handler(args, input_text)) + if check and completed.returncode != 0: + raise OperationalError( + f"command failed ({completed.returncode}): {' '.join(args)}", + hint=completed.stderr or completed.stdout or None, + ) + return completed + + def try_run(self, argv, *, env_policy="minimal", timeout=None, input_text=None): + if argv[0] not in self.available and argv[0] not in self.handlers: + return None + try: + return self.run(argv, env_policy=env_policy, input_text=input_text) + except OperationalError: + return None + + +def _coerce(args: list[str], result) -> Completed: + if isinstance(result, Completed): + return result + if isinstance(result, str): + return Completed(tuple(args), 0, result, "") + if isinstance(result, tuple): + if len(result) == 3: + return Completed(tuple(args), *result) + if len(result) == 2: + return Completed(tuple(args), 0, result[0], result[1]) + raise TypeError(f"unsupported fake result: {result!r}") + raise TypeError(f"unsupported fake result: {result!r}") + + +@pytest.fixture +def fake_runner() -> FakeRunner: + return FakeRunner() diff --git a/tests/fixtures/cron/basic.crontab b/tests/fixtures/cron/basic.crontab new file mode 100644 index 0000000..e476e1e --- /dev/null +++ b/tests/fixtures/cron/basic.crontab @@ -0,0 +1,3 @@ +# schedls:begin name=backup +0 2 * * * /usr/local/bin/backup /srv/data +# schedls:end name=backup diff --git a/tests/fixtures/systemd/basic.service b/tests/fixtures/systemd/basic.service new file mode 100644 index 0000000..047ef70 --- /dev/null +++ b/tests/fixtures/systemd/basic.service @@ -0,0 +1,9 @@ +# Managed by schedls +# Name: backup + +[Unit] +Description=schedls job backup + +[Service] +Type=oneshot +ExecStart="/usr/local/bin/backup" "/srv/data" diff --git a/tests/fixtures/systemd/basic.timer b/tests/fixtures/systemd/basic.timer new file mode 100644 index 0000000..e3c3d2b --- /dev/null +++ b/tests/fixtures/systemd/basic.timer @@ -0,0 +1,13 @@ +# Managed by schedls +# Name: backup + +[Unit] +Description=schedls job backup (timer) + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true +Unit=schedls-backup.service + +[Install] +WantedBy=timers.target diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_calendar_integration.py b/tests/integration/test_calendar_integration.py new file mode 100644 index 0000000..0f3af0c --- /dev/null +++ b/tests/integration/test_calendar_integration.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import io + +import pytest + +from schedls.errors import InvalidScheduleError +from schedls.operations.calendar import run_calendar +from schedls.output import Output +from schedls.runner import CommandRunner + +pytestmark = pytest.mark.integration + + +def _has_analyze() -> bool: + return CommandRunner().has("systemd-analyze") + + +def test_calendar_valid() -> None: + if not _has_analyze(): + pytest.skip("systemd-analyze not available") + stream = io.StringIO() + output = Output(color="never", stdout=stream) + run_calendar(CommandRunner(), output, "Mon..Fri 02:30", next_count=3) + text = stream.getvalue() + assert "Normalized" in text + assert "Mon..Fri *-*-* 02:30:00" in text + + +def test_calendar_invalid() -> None: + if not _has_analyze(): + pytest.skip("systemd-analyze not available") + stream = io.StringIO() + output = Output(color="never", stdout=stream) + with pytest.raises(InvalidScheduleError): + run_calendar(CommandRunner(), output, "definitely not a calendar", next_count=1) diff --git a/tests/integration/test_cron_lifecycle.py b/tests/integration/test_cron_lifecycle.py new file mode 100644 index 0000000..e31de19 --- /dev/null +++ b/tests/integration/test_cron_lifecycle.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os + +import pytest + +from schedls.backends.cron import CronBackend +from schedls.models import Backend, Command, JobSpec, Scope +from schedls.runner import CommandRunner + +pytestmark = pytest.mark.integration + +NAME = "schedls-it-cron" + + +def _backend_or_skip() -> CronBackend: + backend = CronBackend(CommandRunner()) + if not backend.available(): + pytest.skip("crontab not available") + if os.environ.get("SCHEDLS_RUN_CRON_TESTS") != "1": + pytest.skip("set SCHEDLS_RUN_CRON_TESTS=1 to allow crontab mutation") + return backend + + +def _restore(backend: CronBackend, original: str) -> None: + backend.runner.run(["crontab", "-"], env_policy="identity", input_text=original, check=False) + + +def test_cron_lifecycle_preserves_content() -> None: + backend = _backend_or_skip() + original_document = backend.read() + original = original_document.text + # Add an unrelated line first so we can prove preservation. + seeded = original + ("\n" if original and not original.endswith("\n") else "") + seeded += "# unrelated comment\n" + backend.runner.run(["crontab", "-"], env_policy="identity", input_text=seeded) + try: + spec = JobSpec( + name=NAME, + backend=Backend.CRON, + scope=Scope.USER, + command=Command(argv=("/usr/bin/true", "a b", "50%")), + cron_expression="15 2 * * *", + ) + plan = backend.plan_create(spec) + result = backend.apply(plan) + assert result.changed + + jobs = [job for job in backend.discover([Scope.USER]) if job.name == NAME] + assert len(jobs) == 1 + assert jobs[0].managed is True + assert jobs[0].schedule.expression == "15 2 * * *" + + raw = backend.read().text + assert "# unrelated comment" in raw + assert "a\\%b" not in raw # sanity: percent is escaped, not raw in a bad way + + job = backend.find(NAME) + assert job is not None + backend.apply(backend.plan_remove(job)) + assert "# unrelated comment" in backend.read().text + assert backend.find(NAME) is None + finally: + _restore(backend, original) diff --git a/tests/integration/test_systemd_lifecycle.py b/tests/integration/test_systemd_lifecycle.py new file mode 100644 index 0000000..cd180cf --- /dev/null +++ b/tests/integration/test_systemd_lifecycle.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os + +import pytest + +from schedls.backends.systemd import SystemdBackend +from schedls.models import Backend, Command, JobSpec, Scope +from schedls.runner import CommandRunner + +pytestmark = pytest.mark.integration + +NAME = "schedls-it" + + +def _backend_or_skip() -> SystemdBackend: + backend = SystemdBackend(CommandRunner()) + if not backend.has_systemctl(): + pytest.skip("systemctl not available") + if not backend.manager_ok(Scope.USER): + pytest.skip("user systemd manager not reachable") + if not backend.has_analyze(): + pytest.skip("systemd-analyze not available") + return backend + + +def _spec(calendar: str) -> JobSpec: + return JobSpec( + name=NAME, + backend=Backend.SYSTEMD, + scope=Scope.USER, + command=Command(argv=("/bin/true",)), + calendar=(calendar,), + persistent=True, + ) + + +def test_systemd_timer_lifecycle() -> None: + backend = _backend_or_skip() + directory = backend._unit_dir(Scope.USER) + timer_path = os.path.join(directory, f"schedls-{NAME}.timer") + service_path = os.path.join(directory, f"schedls-{NAME}.service") + # Clean any leftover from a previous run. + if os.path.exists(timer_path): + existing = backend.find(NAME) + if existing is not None: + backend.apply(backend.plan_remove(existing)) + try: + plan = backend.plan_create(_spec("*-*-* 03:30:00")) + result = backend.apply(plan) + assert result.changed + assert os.path.exists(timer_path) + assert os.path.exists(service_path) + + job = backend.find(NAME) + assert job is not None + assert job.managed is True + assert job.schedule.expression == "*-*-* 03:30:00" + assert job.command.argv == ("/bin/true",) + + backend.apply(backend.plan_set_enabled(job, False)) + job = backend.find(NAME) + assert job is not None and job.enabled is False + + backend.apply(backend.plan_set_enabled(job, True)) + job = backend.find(NAME) + assert job is not None and job.enabled is True + + backend.apply(backend.plan_update(job, _spec("*-*-* 05:15:00"))) + job = backend.find(NAME) + assert job is not None + assert job.schedule.expression == "*-*-* 05:15:00" + + backend.apply(backend.plan_remove(job)) + assert not os.path.exists(timer_path) + assert not os.path.exists(service_path) + assert backend.find(NAME) is None + finally: + leftover = backend.find(NAME) + if leftover is not None: + backend.apply(backend.plan_remove(leftover)) diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/security/test_hardening.py b/tests/security/test_hardening.py new file mode 100644 index 0000000..7330e85 --- /dev/null +++ b/tests/security/test_hardening.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +SRC = Path(__file__).resolve().parents[2] / "src" / "schedls" + + +def test_no_shell_true_in_production_code() -> None: + offenders = [] + for path in SRC.rglob("*.py"): + text = path.read_text() + if "subprocess" in text and "shell=True" in text: + offenders.append(str(path)) + assert offenders == [] + + +def test_subprocess_confined_to_runner() -> None: + offenders = [] + for path in SRC.rglob("*.py"): + if "import subprocess" in path.read_text() and path.name != "runner.py": + offenders.append(str(path)) + assert offenders == [] + + +def test_runner_explicitly_disables_shell() -> None: + assert "shell=False" in (SRC / "runner.py").read_text() + + +def test_no_os_system_or_popen() -> None: + for path in SRC.rglob("*.py"): + text = path.read_text() + assert "os.system(" not in text, path + assert "os.popen(" not in text, path diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..f716224 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import pytest + +from schedls.cli import _parse_environment, _spec_for_new, build_parser, main, split_command +from schedls.errors import InvalidNameError, UsageError +from schedls.models import Backend, Scope + + +def test_split_command() -> None: + head, tail = split_command(["new", "x", "--timer", "--", "/bin/true", "arg"]) + assert head == ["new", "x", "--timer"] + assert tail == ["/bin/true", "arg"] + head, tail = split_command(["show", "x"]) + assert head == ["show", "x"] + assert tail == [] + + +def test_edit_command_dest_does_not_shadow_subcommand() -> None: + args = build_parser().parse_args(["edit", "backup", "--command"]) + assert args.command == "edit" + assert args.replace_command is True + + +def test_spec_for_new_systemd() -> None: + args = build_parser().parse_args(["new", "backup", "--timer", "--daily", "02:00", "--persistent"]) + spec = _spec_for_new(args, ["/usr/local/bin/backup", "/srv/data"]) + assert spec.backend is Backend.SYSTEMD + assert spec.scope is Scope.USER + assert spec.calendar == ("*-*-* 02:00:00",) + assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data") + assert spec.persistent is True + + +def test_spec_for_new_cron() -> None: + args = build_parser().parse_args(["new", "cleanup", "--cron", "--cron-expr", "0 4 * * 0"]) + spec = _spec_for_new(args, ["/usr/local/bin/cleanup"]) + assert spec.backend is Backend.CRON + assert spec.cron_expression == "0 4 * * 0" + + +def test_spec_for_new_system_scope() -> None: + args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--system"]) + spec = _spec_for_new(args, ["/bin/true"]) + assert spec.scope is Scope.SYSTEM + + +def test_spec_for_new_invalid_name() -> None: + args = build_parser().parse_args(["new", "../evil", "--timer", "--daily", "02:00"]) + with pytest.raises(InvalidNameError): + _spec_for_new(args, ["/bin/true"]) + + +def test_spec_for_new_requires_schedule() -> None: + args = build_parser().parse_args(["new", "x", "--timer"]) + with pytest.raises(UsageError): + _spec_for_new(args, ["/bin/true"]) + + +def test_new_without_backend_is_usage_error() -> None: + assert main(["new", "x", "--daily", "02:00"]) == 2 + + +def test_new_without_name_is_usage_error() -> None: + assert main(["new", "--timer", "--daily", "02:00"]) == 2 + + +def test_interactive_conflicts_with_json() -> None: + assert main(["--json", "new", "x", "-i", "--timer", "--daily", "02:00"]) == 2 + + +def test_interactive_requires_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", _FakeStdin(isatty=False)) + assert main(["new", "x", "-i", "--timer", "--daily", "02:00"]) == 3 + + +class _FakeStdin: + def __init__(self, *, isatty: bool) -> None: + self._isatty = isatty + + def isatty(self) -> bool: + return self._isatty + + +def test_spec_for_new_shell_mode() -> None: + args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--shell", "echo hi | cat"]) + spec = _spec_for_new(args, []) + assert spec.command.shell is True + assert spec.command.raw == "echo hi | cat" + + +def test_spec_for_new_rejects_systemd_flags_for_cron() -> None: + for extra in (["--env", "A=1"], ["--working-directory", "/srv/data"], ["--persistent"], ["--jitter", "5min"]): + args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", *extra]) + with pytest.raises(UsageError): + _spec_for_new(args, ["/bin/true"]) + + +def test_spec_for_new_rejects_cron_expr_for_timer() -> None: + args = build_parser().parse_args(["new", "x", "--timer", "--cron-expr", "0 4 * * *"]) + with pytest.raises(UsageError): + _spec_for_new(args, ["/bin/true"]) + + +def test_spec_for_new_rejects_system_scope_for_cron() -> None: + args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", "--system"]) + with pytest.raises(UsageError): + _spec_for_new(args, ["/bin/true"]) + + +@pytest.mark.parametrize("value", ["0", "-1", "1000001", "abc"]) +def test_logs_lines_must_be_a_bounded_positive_integer(value: str) -> None: + with pytest.raises(SystemExit): + build_parser().parse_args(["logs", "x", "--lines", value]) + + +def test_parse_environment() -> None: + assert _parse_environment(["A=1", "B=x=y"]) == (("A", "1"), ("B", "x=y")) + with pytest.raises(UsageError): + _parse_environment(["NOVALUE"]) + with pytest.raises(UsageError): + _parse_environment(["1BAD=1"]) diff --git a/tests/unit/test_convenience.py b/tests/unit/test_convenience.py new file mode 100644 index 0000000..fad8f0a --- /dev/null +++ b/tests/unit/test_convenience.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import pytest + +from schedls import convenience +from schedls.errors import InvalidScheduleError + + +@pytest.mark.parametrize( + ("time", "expected"), + [("02:00", "*-*-* 02:00:00"), ("2:05", "*-*-* 02:05:00"), ("23:59:59", "*-*-* 23:59:59")], +) +def test_daily_calendar(time: str, expected: str) -> None: + assert convenience.daily_calendar(time) == expected + + +def test_weekdays_and_weekly_calendar() -> None: + assert convenience.weekdays_calendar("08:30") == "Mon..Fri *-*-* 08:30:00" + assert convenience.weekly_calendar("sun", "04:00") == "Sun *-*-* 04:00:00" + assert convenience.weekly_calendar("Saturday", "4:00") == "Sat *-*-* 04:00:00" + assert convenience.monthly_calendar("1", "06:00") == "*-*-01 06:00:00" + + +def test_cron_variants() -> None: + assert convenience.daily_cron("02:00") == "0 2 * * *" + assert convenience.weekdays_cron("08:30") == "30 8 * * 1-5" + assert convenience.weekly_cron("sun", "04:00") == "0 4 * * 0" + assert convenience.monthly_cron("15", "06:00") == "0 6 15 * *" + + +@pytest.mark.parametrize("bad", ["25:00", "02:60", "nope", "2", "02:00:99"]) +def test_invalid_time(bad: str) -> None: + with pytest.raises(InvalidScheduleError): + convenience.daily_calendar(bad) + + +def test_invalid_weekday_and_day() -> None: + with pytest.raises(InvalidScheduleError): + convenience.weekly_calendar("funday", "04:00") + with pytest.raises(InvalidScheduleError): + convenience.monthly_calendar("32", "06:00") + with pytest.raises(InvalidScheduleError): + convenience.monthly_calendar("x", "06:00") diff --git a/tests/unit/test_crontab.py b/tests/unit/test_crontab.py new file mode 100644 index 0000000..8e24aa3 --- /dev/null +++ b/tests/unit/test_crontab.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from schedls.backends.cron import CrontabDocument +from schedls.errors import ConflictError, SafetyRefusalError + +SAMPLE = ( + "MAILTO=admin@example.net\n" + "SHELL=/bin/bash\n" + "PATH=/usr/local/bin:/usr/bin\n" + "\n" + "# personal job\n" + "15 7 * * * ~/bin/foo\n" + "@daily /usr/local/bin/nightly\n" + "\n" + "# schedls:begin name=backup\n" + "0 2 * * * /usr/local/bin/backup /srv/data\n" + "# schedls:end name=backup\n" +) + + +def test_lossless_round_trip() -> None: + document = CrontabDocument(SAMPLE) + assert document.render() == SAMPLE + + +def test_managed_block_detected() -> None: + document = CrontabDocument(SAMPLE) + blocks = document.managed_blocks() + assert list(blocks) == ["backup"] + assert blocks["backup"].job_lines[0].expression == "0 2 * * *" + assert blocks["backup"].job_lines[0].command == "/usr/local/bin/backup /srv/data" + assert not document.has_malformed_markers() + + +def test_environment_and_nickname_parsed() -> None: + document = CrontabDocument(SAMPLE) + kinds = [entry.kind for entry in document.entries] + assert kinds.count("env") == 3 + assert kinds.count("comment") == 1 + assert kinds.count("managed_begin") == 1 + assert kinds.count("managed_end") == 1 + jobs = [entry for entry in document.entries if entry.kind == "job"] + assert jobs[0].expression == "15 7 * * *" + assert jobs[1].expression == "@daily" + + +def test_with_block_preserves_unrelated_content() -> None: + document = CrontabDocument(SAMPLE) + updated = document.with_block("report", ["30 6 * * * /usr/local/bin/report"]) + assert "MAILTO=admin@example.net" in updated + assert "15 7 * * * ~/bin/foo" in updated + assert "0 2 * * * /usr/local/bin/backup /srv/data" in updated + assert "# schedls:begin name=report" in updated + reparsed = CrontabDocument(updated) + assert set(reparsed.managed_blocks()) == {"backup", "report"} + + +def test_with_block_conflict() -> None: + document = CrontabDocument(SAMPLE) + with pytest.raises(ConflictError): + document.with_block("backup", ["0 3 * * * /bin/true"]) + + +def test_without_block_removes_only_block() -> None: + document = CrontabDocument(SAMPLE) + updated = document.without_block("backup") + assert "backup" not in updated + assert "MAILTO=admin@example.net" in updated + assert "15 7 * * * ~/bin/foo" in updated + assert "@daily /usr/local/bin/nightly" in updated + + +def test_without_unknown_block_refused() -> None: + document = CrontabDocument(SAMPLE) + with pytest.raises(SafetyRefusalError): + document.without_block("nope") + + +def test_malformed_marker_blocks_mutation() -> None: + document = CrontabDocument("# schedls:begin name=broken\n0 2 * * * /bin/true\n") + assert document.has_malformed_markers() + with pytest.raises(SafetyRefusalError): + document.with_block("new", ["0 3 * * * /bin/true"]) + with pytest.raises(SafetyRefusalError): + document.without_block("broken") + + +def test_empty_crontab() -> None: + document = CrontabDocument("") + assert document.render() == "" + updated = document.with_block("x", ["0 0 * * * /bin/true"]) + assert updated.endswith("\n") + assert "name=x" in updated diff --git a/tests/unit/test_describe.py b/tests/unit/test_describe.py new file mode 100644 index 0000000..458b45a --- /dev/null +++ b/tests/unit/test_describe.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from schedls.describe import describe_calendar, describe_cron, describe_schedule +from schedls.models import Schedule, ScheduleKind + + +def test_describe_calendar() -> None: + assert describe_calendar("*-*-* 02:00:00") == "daily at 02:00" + assert describe_calendar("Mon..Fri *-*-* 08:30:00") == "weekdays at 08:30" + assert describe_calendar("Sun *-*-* 04:00:00") == "Sunday at 04:00" + assert describe_calendar("*-*-01 06:00:00") == "monthly on day 1 at 06:00" + assert describe_calendar("*-*-* *:00:00") is None + + +def test_describe_cron() -> None: + assert describe_cron("0 2 * * *") == "daily at 02:00" + assert describe_cron("30 8 * * 1-5") == "weekdays at 08:30" + assert describe_cron("0 4 * * 0") == "Sunday at 04:00" + assert describe_cron("0 6 15 * *") == "monthly on day 15 at 06:00" + assert describe_cron("0 4 1,15 * 5") is None + assert describe_cron("@daily") is None + + +def test_describe_schedule() -> None: + assert describe_schedule(Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00")) == "daily at 02:00" + assert describe_schedule(Schedule(ScheduleKind.CRON, "@reboot")) == "@reboot" + multi = Schedule(ScheduleKind.CALENDAR, "A", ("A", "B")) + assert describe_schedule(multi) == "A + B" diff --git a/tests/unit/test_operations.py b/tests/unit/test_operations.py new file mode 100644 index 0000000..655e368 --- /dev/null +++ b/tests/unit/test_operations.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import io +import os +import tempfile +from dataclasses import replace + +import pytest + +from schedls.backends.base import CommandPlan, FileChange, MutationResult, Plan +from schedls.backends.cron import CronBackend +from schedls.backends.systemd import SystemdBackend +from schedls.errors import InvalidScheduleError, OperationalError, SafetyRefusalError +from schedls.interact import Interaction +from schedls.models import ( + Backend, + Command, + JobSource, + JobSpec, + Schedule, + ScheduledJob, + ScheduleKind, + Scope, + SystemdDetails, +) +from schedls.operations import doctor as doctor_ops +from schedls.operations import inspect as inspect_ops +from schedls.operations import mutate as mutate_ops +from schedls.output import Output + +from ..conftest import FakeRunner + + +def _job(name: str = "backup") -> ScheduledJob: + return ScheduledJob( + name=name, + backend=Backend.SYSTEMD, + scope=Scope.USER, + managed=True, + enabled=True, + schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"), + command=Command(argv=("/usr/local/bin/backup",)), + source=JobSource("systemd user timer"), + ) + + +def _output() -> tuple[Output, io.StringIO]: + stream = io.StringIO() + return Output(color="never", stdout=stream), stream + + +def test_render_list_table() -> None: + output, stream = _output() + inspect_ops.render_list(output, [_job(), _job("cleanup")], []) + text = stream.getvalue() + assert "NAME" in text and "SCHEDULE" in text + assert "backup" in text and "cleanup" in text + + +def test_render_list_json() -> None: + output, stream = _output() + output.json_mode = True + inspect_ops.render_list(output, [_job()], ["note"]) + assert '"schema_version": 1' in stream.getvalue() + + +def test_render_show() -> None: + output, stream = _output() + inspect_ops.render_show(output, _job()) + text = stream.getvalue() + assert "Managed by schedls" in text + assert "systemd user timer" in text + + +def test_filter_jobs() -> None: + jobs = [_job(), _job("other")] + assert inspect_ops.filter_jobs(jobs, managed=True) == jobs + assert inspect_ops.filter_jobs(jobs, managed=False) == [] + assert inspect_ops.filter_jobs(jobs, enabled=False) == [] + assert len(inspect_ops.filter_jobs(jobs, backend=Backend.SYSTEMD)) == 2 + + +def test_collect_reports_unavailable_backend() -> None: + backend = SystemdBackend(FakeRunner()) + jobs, warnings = inspect_ops.collect([backend], [Scope.USER]) + assert jobs == [] + assert any("unavailable" in warning for warning in warnings) + + +class _DummyBackend: + name = "systemd" + + def __init__(self, result: MutationResult) -> None: + self.result = result + self.applied = False + + def apply(self, plan: Plan) -> MutationResult: + self.applied = True + return self.result + + +def test_run_plan_dry_run(tmp_path) -> None: + output, stream = _output() + plan = Plan(backend="systemd", action="create") + plan.files = [FileChange(str(tmp_path / "example.service"), "data")] + plan.commands = [CommandPlan(("systemctl", "reload"), "reload")] + backend = _DummyBackend(MutationResult(changed=True)) + result = mutate_ops.run_plan(backend, plan, output, Interaction(output=output), dry_run=True) + assert result.dry_run is True + assert backend.applied is False + assert "No changes made." in stream.getvalue() + + +def test_run_plan_confirms_and_applies() -> None: + output, stream = _output() + plan = Plan(backend="systemd", action="create") + backend = _DummyBackend(MutationResult(changed=True, messages=["done"])) + interaction = Interaction(output=output, assume_yes=True) + result = mutate_ops.run_plan(backend, plan, output, interaction) + assert backend.applied is True + assert result.changed is True + assert "done" in stream.getvalue() + + +def test_run_plan_json_mode() -> None: + output, stream = _output() + output.json_mode = True + plan = Plan(backend="systemd", action="create") + backend = _DummyBackend(MutationResult(changed=True)) + mutate_ops.run_plan(backend, plan, output, Interaction(output=output, assume_yes=True)) + assert '"action": "create"' in stream.getvalue() + + +def _doctor_runner() -> FakeRunner: + def systemctl(argv, _input): + if "is-system-running" in argv: + return ("running\n", "") + return ("", "") + + def crontab(argv, _input): + if "-V" in argv: + return ("cronie 1.7.2\n", "") + return ("", "") + + return FakeRunner( + handlers={ + "systemctl": systemctl, + "crontab": crontab, + "loginctl": lambda argv, _input: ("Linger=no\n", ""), + }, + available={"systemctl", "systemd-analyze", "crontab", "loginctl"}, + ) + + +def test_doctor_human() -> None: + runner = _doctor_runner() + output, stream = _output() + doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner)) + text = stream.getvalue() + assert "Cronie" in text + assert "usable" in text + + +def test_doctor_json() -> None: + runner = _doctor_runner() + output, stream = _output() + output.json_mode = True + doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner)) + assert '"result": "usable"' in stream.getvalue() + + +def test_systemd_backend_discovery_parses_units() -> None: + timer = ( + "# Managed by schedls\n" + "# Name: backup\n" + "[Unit]\nDescription=foo\n[Timer]\nOnCalendar=*-*-* 02:00:00\nPersistent=true\nUnit=schedls-backup.service\n" + ) + service = '[Service]\nExecStart="/usr/local/bin/backup" "/srv/data"\n' + + with tempfile.TemporaryDirectory() as tmp: + timer_path = os.path.join(tmp, "schedls-backup.timer") + service_path = os.path.join(tmp, "schedls-backup.service") + with open(timer_path, "w") as handle: + handle.write(timer) + with open(service_path, "w") as handle: + handle.write(service) + + def systemctl(argv, _input): + if "is-system-running" in argv: + return ("running\n", "") + if "list-unit-files" in argv or "list-units" in argv: + return ("schedls-backup.timer enabled\n", "") + if "show" in argv: + unit = argv[argv.index("show") + 1] + if unit.endswith(".timer"): + return ( + f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n" + "UnitFileState=enabled\nActiveState=active\nSubState=waiting\n" + "NextElapseUSecRealtime=1758700800000000\nTriggers=schedls-backup.service\n", + "", + ) + return ( + f"Id={unit}\nLoadState=loaded\nFragmentPath={service_path}\nResult=success\n", + "", + ) + return ("", "") + + runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"}) + backend = SystemdBackend(runner) + jobs = backend.discover([Scope.USER]) + assert len(jobs) == 1 + job = jobs[0] + assert job.name == "backup" + assert job.managed is True + assert job.schedule.expression == "*-*-* 02:00:00" + assert job.command.argv == ("/usr/local/bin/backup", "/srv/data") + assert job.enabled is True + assert job.next_run is not None + + +def test_plan_update_refuses_unmanaged() -> None: + runner = FakeRunner() + backend = SystemdBackend(runner) + unmanaged = replace(_job("certbot"), managed=False) + spec = JobSpec( + name="certbot", + backend=Backend.SYSTEMD, + scope=Scope.USER, + command=Command(argv=("/bin/true",)), + calendar=("*-*-* 01:00:00",), + ) + with pytest.raises(SafetyRefusalError): + backend.plan_update(unmanaged, spec) + + +def _managed_job(name: str = "backup", *, scope: Scope = Scope.USER, service_unit: str | None = None) -> ScheduledJob: + return replace( + _job(name), + scope=scope, + systemd=SystemdDetails( + timer_unit=f"schedls-{name}.timer", + service_unit=service_unit or f"schedls-{name}.service", + on_calendar=("*-*-* 02:00:00",), + ), + ) + + +def _spec(name: str = "backup") -> JobSpec: + return JobSpec( + name=name, + backend=Backend.SYSTEMD, + scope=Scope.USER, + command=Command(argv=("/bin/true",)), + calendar=("*-*-* 03:00:00",), + ) + + +def _analyze_runner() -> FakeRunner: + def analyze(argv, _input): + if "calendar" in argv: + return ("Normalized form: *-*-* 03:00:00\n", "") + return ("", "") + + return FakeRunner(handlers={"systemd-analyze": analyze}, available={"systemd-analyze"}) + + +def test_plan_update_derives_unit_names(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + backend = SystemdBackend(_analyze_runner()) + job = _managed_job(service_unit="../../../../tmp/evil.service") + plan = backend.plan_update(job, _spec()) + expected_dir = os.path.join(str(tmp_path), "systemd", "user") + assert sorted(change.path for change in plan.files) == [ + os.path.join(expected_dir, "schedls-backup.service"), + os.path.join(expected_dir, "schedls-backup.timer"), + ] + assert "evil" not in " ".join(change.path for change in plan.files) + + +def test_verify_units_rejects_traversal() -> None: + backend = SystemdBackend(_analyze_runner()) + with pytest.raises(SafetyRefusalError): + backend._verify_units({"../../evil.service": "content"}) + + +def test_unit_dir_rejects_relative_xdg(monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", "relative") + backend = SystemdBackend(FakeRunner()) + with pytest.raises(SafetyRefusalError): + backend._unit_dir(Scope.USER) + + +def test_plan_update_requires_scope(monkeypatch) -> None: + monkeypatch.setattr(os, "geteuid", lambda: 1000) + backend = SystemdBackend(_analyze_runner()) + with pytest.raises(SafetyRefusalError): + backend.plan_update(_managed_job(scope=Scope.SYSTEM), _spec()) + + +def test_validate_spec_rejects_unsafe_calendar() -> None: + backend = SystemdBackend(FakeRunner()) + spec = replace(_spec(), calendar=("-x",)) + with pytest.raises(InvalidScheduleError): + backend._validate_spec(spec) + + +def test_validate_spec_rejects_unsafe_jitter() -> None: + backend = SystemdBackend(_analyze_runner()) + for jitter in ("5min\nExecStart=/bin/true", "-5min"): + with pytest.raises(InvalidScheduleError): + backend._validate_spec(replace(_spec(), jitter=jitter)) + + +def test_apply_rolls_back_on_keyboard_interrupt(tmp_path) -> None: + target = tmp_path / "schedls-backup.service" + raised = [] + + def systemctl(argv, _input): + if not raised: + raised.append(True) + raise KeyboardInterrupt + return ("", "") + + runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"}) + backend = SystemdBackend(runner) + plan = Plan(backend="systemd", action="update") + plan.files = [FileChange(str(target), "content", mode=0o600)] + plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")] + plan.payload = {"scope": Scope.USER, "snapshots": {}} + with pytest.raises(KeyboardInterrupt): + backend.apply(plan) + assert not target.exists() + + +def test_rollback_disables_timer_on_create_failure(tmp_path) -> None: + target = tmp_path / "schedls-backup.timer" + + def systemctl(argv, _input): + if "daemon-reload" in argv: + return (1, "", "boom") + return ("", "") + + runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"}) + backend = SystemdBackend(runner) + plan = Plan(backend="systemd", action="create") + plan.files = [FileChange(str(target), "content", mode=0o600)] + plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")] + plan.payload = {"scope": Scope.USER, "timer_unit": "schedls-backup.timer", "snapshots": {}} + with pytest.raises(OperationalError): + backend.apply(plan) + assert any("disable" in call[0] for call in runner.calls) + assert not target.exists() + + +def test_logs_rejects_unsafe_service_unit() -> None: + backend = SystemdBackend(FakeRunner(available={"journalctl"})) + job = _managed_job(service_unit="--output=json.service") + with pytest.raises(SafetyRefusalError): + backend.logs(job, lines=5, since=None) + + +def test_logs_returns_journal_text() -> None: + runner = FakeRunner( + handlers={"journalctl": lambda argv, _input: ("hello\n", "")}, + available={"journalctl"}, + ) + backend = SystemdBackend(runner) + assert backend.logs(_managed_job(), lines=5, since="2 hours ago") == "hello\n" + argv = runner.calls[0][0] + assert "--since" in argv and "--lines=5" in argv + + +def test_prefix_named_unit_without_marker_is_unmanaged(tmp_path) -> None: + timer_path = tmp_path / "schedls-backup.timer" + timer_path.write_text("[Unit]\n[Timer]\nOnCalendar=*-*-* 02:00:00\n") + + def systemctl(argv, _input): + if "is-system-running" in argv: + return ("running\n", "") + if "list-unit-files" in argv or "list-units" in argv: + return ("schedls-backup.timer enabled\n", "") + if "show" in argv: + unit = argv[argv.index("show") + 1] + if unit.endswith(".timer"): + return (f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n", "") + return ("", "") + return ("", "") + + runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"}) + jobs = SystemdBackend(runner).discover([Scope.USER]) + assert len(jobs) == 1 + assert jobs[0].managed is False + assert jobs[0].name == "schedls-backup" diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py new file mode 100644 index 0000000..644ab88 --- /dev/null +++ b/tests/unit/test_output.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import io +import json +from datetime import UTC, datetime + +from schedls.models import ( + Backend, + Command, + CronDetails, + JobSource, + Schedule, + ScheduledJob, + ScheduleKind, + Scope, + SystemdDetails, +) +from schedls.output import Output, job_to_dict, jobs_document, sanitize_text + + +def _systemd_job() -> ScheduledJob: + return ScheduledJob( + name="backup", + backend=Backend.SYSTEMD, + scope=Scope.USER, + managed=True, + enabled=True, + schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00", ("*-*-* 02:00:00",)), + command=Command(argv=("/usr/local/bin/backup", "/srv/data")), + source=JobSource("systemd user timer", path="/home/u/.config/systemd/user/schedls-backup.timer"), + next_run=datetime(2026, 9, 25, 2, 0, tzinfo=UTC), + systemd=SystemdDetails( + timer_unit="schedls-backup.timer", + service_unit="schedls-backup.service", + persistent=True, + ), + ) + + +def _cron_job() -> ScheduledJob: + return ScheduledJob( + name="cron-3", + backend=Backend.CRON, + scope=Scope.USER, + managed=False, + enabled=None, + schedule=Schedule(ScheduleKind.CRON, "0 4 * * 0"), + command=Command(raw="/usr/local/bin/cleanup"), + source=JobSource("current user's crontab", line=3), + cron=CronDetails(expression="0 4 * * 0", line=3), + ) + + +def test_job_to_dict_systemd() -> None: + data = job_to_dict(_systemd_job()) + assert data["name"] == "backup" + assert data["backend"] == "systemd" + assert data["managed"] is True + assert data["next_run"].endswith("+00:00") + assert data["systemd"]["persistent"] is True + + +def test_job_to_dict_cron_missing_data_is_null() -> None: + data = job_to_dict(_cron_job()) + assert data["next_run"] is None + assert data["last_run"] is None + assert data["enabled"] is None + + +def test_jobs_document_schema_and_no_ansi() -> None: + document = jobs_document([_systemd_job(), _cron_job()], ["a warning"]) + assert document["schema_version"] == 1 + assert len(document["jobs"]) == 2 + dumped = json.dumps(document) + assert "\x1b" not in dumped + assert "backup" in dumped + + +def test_output_table_and_key_values() -> None: + stream = io.StringIO() + output = Output(json_mode=False, color="never", stdout=stream) + output.table(["NAME", "BACKEND"], [["backup", "systemd"], ["cleanup", "cron"]]) + text = stream.getvalue() + assert "NAME" in text + assert "backup" in text + assert "\x1b" not in text + + +def test_output_json_mode() -> None: + stream = io.StringIO() + output = Output(json_mode=True, color="never", stdout=stream) + output.emit_json({"schema_version": 1, "jobs": []}) + assert json.loads(stream.getvalue())["schema_version"] == 1 + + +def test_sanitize_text_escapes_control_characters() -> None: + text = "a\x1b[31mred\x1b[0m\nb\tc\x00d\x7f" + assert sanitize_text(text) == "a\\x1b[31mred\\x1b[0m\nb\tc\\x00d\\x7f" + + +def test_output_write_is_verbatim() -> None: + stream = io.StringIO() + output = Output(color="never", stdout=stream) + output.write("line one\nline two") + assert stream.getvalue() == "line one\nline two" diff --git a/tests/unit/test_prompt.py b/tests/unit/test_prompt.py new file mode 100644 index 0000000..97948f0 --- /dev/null +++ b/tests/unit/test_prompt.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import io + +import pytest + +from schedls.cli import _spec_for_edit, _spec_for_new, _wizard_edit, _wizard_new, build_parser +from schedls.errors import ConfirmationRequiredError, InvalidScheduleError +from schedls.models import ( + Backend, + Command, + JobSource, + Schedule, + ScheduledJob, + ScheduleKind, + Scope, + SystemdDetails, +) +from schedls.output import Output +from schedls.prompt import Prompter + + +class FakeInput: + def __init__(self, answers: list[str]) -> None: + self.answers = list(answers) + self.prompts: list[str] = [] + + def __call__(self, label: str) -> str: + self.prompts.append(label) + if not self.answers: + raise EOFError + return self.answers.pop(0) + + +class FakeStdin: + def __init__(self, *, isatty: bool) -> None: + self._isatty = isatty + + def isatty(self) -> bool: + return self._isatty + + +def make_prompter(answers: list[str], *, calendar_validator=None) -> tuple[Prompter, FakeInput]: + fake = FakeInput(answers) + output = Output(stdout=io.StringIO(), stderr=io.StringIO()) + return Prompter(output=output, input_fn=fake, calendar_validator=calendar_validator), fake + + +def _systemd_job() -> ScheduledJob: + return ScheduledJob( + name="backup", + backend=Backend.SYSTEMD, + scope=Scope.USER, + managed=True, + enabled=True, + schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"), + command=Command(argv=("/usr/local/bin/backup",)), + source=JobSource(detail="systemd user timer"), + systemd=SystemdDetails(on_calendar=("*-*-* 02:00:00",)), + ) + + +def test_text_requires_a_value() -> None: + prompter, fake = make_prompter(["", "backup"]) + assert prompter.text("Name") == "backup" + assert len(fake.prompts) == 2 + + +def test_text_uses_default_on_blank() -> None: + prompter, _ = make_prompter([""]) + assert prompter.text("Name", default="backup") == "backup" + + +def test_optional_text() -> None: + prompter, _ = make_prompter(["", ""]) + assert prompter.optional_text("Jitter") is None + assert prompter.optional_text("Jitter", default="5min") == "5min" + + +def test_yes_no_rejects_unknown_answers() -> None: + prompter, _ = make_prompter(["maybe", "yes"]) + assert prompter.yes_no("Continue?") is True + + +def test_choice_by_number_and_default() -> None: + prompter, _ = make_prompter(["9", "2", ""]) + options = [("daily", "Daily"), ("weekly", "Weekly")] + assert prompter.choice("Schedule:", options, default="daily") == "weekly" + assert prompter.choice("Schedule:", options, default="daily") == "daily" + + +def test_command_builds_argv() -> None: + prompter, _ = make_prompter(["n", "/usr/bin/echo hello world"]) + command = prompter.command() + assert command.shell is False + assert command.argv == ("/usr/bin/echo", "hello", "world") + + +def test_command_shell_mode() -> None: + prompter, _ = make_prompter(["y", "echo hi | cat"]) + command = prompter.command() + assert command.shell is True + assert command.raw == "echo hi | cat" + + +def test_environment_collects_valid_entries() -> None: + prompter, _ = make_prompter(["A=1", "1BAD=2", "B=x=y", ""]) + assert prompter.environment() == ["A=1", "B=x=y"] + + +def test_schedule_daily_reprompts_on_bad_time() -> None: + prompter, _ = make_prompter(["", "25:00", "02:30"]) + assert prompter.schedule(Backend.SYSTEMD) == {"daily": "02:30"} + + +def test_schedule_weekly_and_monthly() -> None: + prompter, _ = make_prompter(["3", "Mon", "02:00"]) + assert prompter.schedule(Backend.SYSTEMD) == {"weekly": ["Mon", "02:00"]} + prompter, _ = make_prompter(["4", "15", "06:00"]) + assert prompter.schedule(Backend.SYSTEMD) == {"monthly": ["15", "06:00"]} + + +def test_schedule_custom_systemd_validates() -> None: + def validator(expression: str) -> None: + if expression != "daily": + raise InvalidScheduleError("bad expression") + + prompter, _ = make_prompter(["5", "bogus", "daily"], calendar_validator=validator) + assert prompter.schedule(Backend.SYSTEMD) == {"calendar": ["daily"]} + + +def test_schedule_custom_cron() -> None: + prompter, _ = make_prompter(["5", "0 4 * * *"]) + assert prompter.schedule(Backend.CRON) == {"cron_expr": "0 4 * * *"} + + +def test_require_terminal_rejects_non_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", FakeStdin(isatty=False)) + prompter, _ = make_prompter([]) + with pytest.raises(ConfirmationRequiredError): + prompter.require_terminal() + + +def test_eof_raises_confirmation_required() -> None: + prompter, _ = make_prompter([]) + with pytest.raises(ConfirmationRequiredError): + prompter.text("Name") + + +def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True)) + args = build_parser().parse_args(["new", "-i"]) + answers = ["backup", "", "n", "/usr/local/bin/backup /srv/data", "", "02:00", "n"] + prompter, _ = make_prompter(answers) + args, tail = _wizard_new(args, [], prompter) + spec = _spec_for_new(args, tail) + assert spec.name == "backup" + assert spec.backend is Backend.SYSTEMD + assert spec.calendar == ("*-*-* 02:00:00",) + assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data") + + +def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True)) + args = build_parser().parse_args(["new", "-i"]) + answers = ["cleanup", "2", "n", "/usr/local/bin/cleanup", "", "04:00"] + prompter, _ = make_prompter(answers) + args, tail = _wizard_new(args, [], prompter) + spec = _spec_for_new(args, tail) + assert spec.backend is Backend.CRON + assert spec.cron_expression == "0 4 * * *" + + +def test_wizard_new_skips_provided_fields(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True)) + args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00"]) + prompter, fake = make_prompter(["n"]) + args, tail = _wizard_new(args, ["/bin/true"], prompter) + spec = _spec_for_new(args, tail) + assert spec.calendar == ("*-*-* 02:00:00",) + assert fake.prompts == ["Set advanced timer options? [y/N] "] + + +def test_wizard_edit_changes_schedule(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True)) + job = _systemd_job() + args = build_parser().parse_args(["edit", "backup", "-i"]) + answers = ["n", "y", "", "03:00", "n"] + prompter, _ = make_prompter(answers) + args, tail = _wizard_edit(job, args, [], prompter) + spec = _spec_for_edit(job, args, tail) + assert spec.calendar == ("*-*-* 03:00:00",) + + +def test_wizard_edit_keeps_changes_optional(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True)) + job = _systemd_job() + args = build_parser().parse_args(["edit", "backup", "-i"]) + prompter, _ = make_prompter(["n", "n", "n"]) + args, tail = _wizard_edit(job, args, [], prompter) + spec = _spec_for_edit(job, args, tail) + assert spec.calendar == ("*-*-* 02:00:00",) + assert tail == [] diff --git a/tests/unit/test_renderers_cron.py b/tests/unit/test_renderers_cron.py new file mode 100644 index 0000000..67607fc --- /dev/null +++ b/tests/unit/test_renderers_cron.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +import shlex +import subprocess +import sys + +import pytest + +from schedls.errors import InvalidScheduleError, SafetyRefusalError +from schedls.models import Command +from schedls.renderers import cron as renderer + +NASTY_ARGS = [ + "plain", + "a b", + "a\tb", + 'a"b', + "a'b", + "a\\b", + "a\\\\b", + "$HOME", + "$(touch /tmp/pwned)", + "`touch /tmp/pwned`", + "%", + "100%", + "a%b", + "a\\%b", + "a\\\\%b", + "", + " leading", + "trailing ", + "semi;colon", + "pipe|cmd", + "new&line", + ">out", + "unicode-\u00e9\u4e2d\U0001f600", +] + + +def cron_resolve(text: str) -> str: + """Emulate Cronie's ``%`` processing of a command field.""" + out: list[str] = [] + index = 0 + while index < len(text): + char = text[index] + if char == "\\" and index + 1 < len(text) and text[index + 1] == "%": + out.append("%") + index += 2 + continue + if char == "%": + out.append("\n") + index += 1 + continue + out.append(char) + index += 1 + return "".join(out) + + +def test_escape_percent() -> None: + assert renderer.escape_percent("100%") == "100\\%" + assert renderer.escape_percent("a\\%b") == "a\\\\%b" + + +def test_render_command_argv() -> None: + rendered = renderer.render_command(Command(argv=("/usr/local/bin/backup", "/srv/My Data"))) + assert rendered == "/usr/local/bin/backup '/srv/My Data'" + + +def test_render_command_shell() -> None: + rendered = renderer.render_command(Command(shell=True, raw="echo 50% > /tmp/x")) + assert rendered == "echo 50\\% > /tmp/x" + + +def test_expression_percent_not_double_escaped() -> None: + line = renderer.render_line("0 2 * * *", Command(argv=("/bin/echo", "a%b"))) + assert line == "0 2 * * * /bin/echo a\\%b" + + +@pytest.mark.parametrize( + "bad", + ["", "0 2 * *", "0 2 * * * *", "60 2 * * *", "0 25 * * *", "0 2 32 * *", "@bogus", "a b c d e"], +) +def test_invalid_expressions(bad: str) -> None: + with pytest.raises(InvalidScheduleError): + renderer.validate_expression(bad) + + +@pytest.mark.parametrize( + "good", + ["* * * * *", "0 2 * * *", "*/5 * * * *", "0 2 * * 1-5", "0 2 1,15 * 5", "@daily", "@reboot"], +) +def test_valid_expressions(good: str) -> None: + assert renderer.validate_expression(good) + + +@pytest.mark.parametrize("arg", NASTY_ARGS) +def test_argv_round_trip_through_shell(tmp_path, arg: str) -> None: + dump = tmp_path / "dump.py" + dump.write_text("import json,sys\nopen(sys.argv[1],'w').write(json.dumps(sys.argv[2:]))\n") + out = tmp_path / "out.json" + command = Command(argv=(sys.executable, str(dump), str(out), arg)) + serialized = renderer.render_command(command) + resolved = cron_resolve(serialized) + subprocess.run(["/bin/sh", "-c", resolved], check=True) + received = json.loads(out.read_text()) + assert received == [arg] + + +def test_no_command_injection(tmp_path) -> None: + marker = tmp_path / "pwned" + command = Command(argv=("/bin/true", f"x; touch {marker}", f"$(touch {marker})", f"`touch {marker}`")) + resolved = cron_resolve(renderer.render_command(command)) + subprocess.run(["/bin/sh", "-c", resolved], check=True) + assert not marker.exists() + + +def test_newline_rejected() -> None: + with pytest.raises(SafetyRefusalError): + renderer.render_command(Command(argv=("/bin/echo", "a\nb"))) + + +def test_managed_block() -> None: + block = renderer.render_block("backup", ["0 2 * * * /bin/true"]) + assert block == ("# schedls:begin name=backup\n0 2 * * * /bin/true\n# schedls:end name=backup\n") + + +def test_shlex_reference() -> None: + assert shlex.quote("a b") == "'a b'" diff --git a/tests/unit/test_renderers_systemd.py b/tests/unit/test_renderers_systemd.py new file mode 100644 index 0000000..73bc226 --- /dev/null +++ b/tests/unit/test_renderers_systemd.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from schedls.errors import SafetyRefusalError +from schedls.models import Backend, Command, JobSpec, Scope +from schedls.renderers import systemd as renderer + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "systemd" + +NASTY_ARGS = [ + "plain", + "a b", + "a\tb", + 'a"b', + "a'b", + "a\\b", + "a\\\\b", + "$HOME", + "$$", + "$(touch /tmp/pwned)", + "`touch /tmp/pwned`", + "%n", + "%%", + "%i", + "", + " leading", + "trailing ", + "semi;colon", + "pipe|cmd", + "new&line", + "unicode-\u00e9\u4e2d\U0001f600", + "-dash", + "@at", + "!bang", +] + + +def test_quote_basic() -> None: + assert renderer.quote_systemd_arg("plain") == '"plain"' + assert renderer.quote_systemd_arg("a b") == '"a b"' + assert renderer.quote_systemd_arg("") == '""' + assert renderer.quote_systemd_arg("$HOME") == '"$$HOME"' + assert renderer.quote_systemd_arg("%n") == '"%%n"' + assert renderer.quote_systemd_arg('a"b') == '"a\\"b"' + assert renderer.quote_systemd_arg("a\\b") == '"a\\\\b"' + + +@pytest.mark.parametrize("arg", NASTY_ARGS) +def test_argv_round_trip(arg: str) -> None: + command = Command(argv=(arg,)) + rendered = renderer.render_exec_start(command) + value = rendered.split("=", 1)[1] + parsed = renderer.parse_exec_start(value) + assert parsed.argv == (arg,) + + +def test_multi_argv_round_trip() -> None: + command = Command(argv=tuple(NASTY_ARGS)) + value = renderer.render_exec_start(command).split("=", 1)[1] + assert renderer.parse_exec_start(value).argv == tuple(NASTY_ARGS) + + +def test_shell_mode_round_trip() -> None: + command = Command(shell=True, raw="generate-report | gzip > /srv/report.gz") + value = renderer.render_exec_start(command).split("=", 1)[1] + parsed = renderer.parse_exec_start(value) + assert parsed.shell is True + assert parsed.raw == "generate-report | gzip > /srv/report.gz" + + +def test_shell_mode_quotes_script() -> None: + command = Command(shell=True, raw="echo $HOME > /tmp/x") + rendered = renderer.render_exec_start(command) + assert rendered.startswith('ExecStart=/bin/sh -c "') + assert "$$HOME" in rendered + + +@pytest.mark.parametrize("bad", ["a\nb", "a\rb", "a\x00b"]) +def test_control_characters_rejected(bad: str) -> None: + with pytest.raises(SafetyRefusalError): + renderer.render_exec_start(Command(argv=(bad,))) + + +def test_golden_service() -> None: + spec = JobSpec( + name="backup", + backend=Backend.SYSTEMD, + scope=Scope.USER, + command=Command(argv=("/usr/local/bin/backup", "/srv/data")), + calendar=("*-*-* 02:00:00",), + persistent=True, + ) + files = renderer.render_units(spec, service_unit="schedls-backup.service", timer_unit="schedls-backup.timer") + assert files["schedls-backup.service"] == (FIXTURES / "basic.service").read_text() + assert files["schedls-backup.timer"] == (FIXTURES / "basic.timer").read_text() + + +def test_timer_multiple_calendars() -> None: + spec = JobSpec( + name="multi", + backend=Backend.SYSTEMD, + scope=Scope.USER, + command=Command(argv=("/bin/true",)), + calendar=("Mon..Fri 02:00:00", "Sat,Sun 04:00:00"), + ) + timer = renderer.render_timer(spec, "schedls-multi.timer", "schedls-multi.service") + assert "OnCalendar=Mon..Fri 02:00:00" in timer + assert "OnCalendar=Sat,Sun 04:00:00" in timer diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..de60a4f --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import os +import stat + +import pytest + +from schedls.errors import InvalidNameError, SafetyRefusalError +from schedls.runner import CommandRunner +from schedls.security import ( + atomic_write_text, + check_replaceable, + check_trusted_directory, + is_managed_unit, + is_safe_unit_name, + remove_file, + resolve_helper, + unit_name, + validate_managed_unit_name, + validate_name, +) + + +@pytest.mark.parametrize( + "name", + ["backup", "postgres-backup", "report.daily", "sync_home", "a", "A1._-", "x" * 64], +) +def test_valid_names(name: str) -> None: + assert validate_name(name) == name + + +@pytest.mark.parametrize( + "name", + ["", "../foo", "foo/bar", "name with spaces", "$(command)", ".hidden", "-lead", "x" * 65, "foo\nbar", "foo\n"], +) +def test_invalid_names(name: str) -> None: + with pytest.raises(InvalidNameError): + validate_name(name) + + +def test_unit_name() -> None: + assert unit_name("backup", "timer") == "schedls-backup.timer" + assert is_managed_unit("schedls-backup.timer") + assert not is_managed_unit("certbot.timer") + with pytest.raises(InvalidNameError): + unit_name("../evil", "timer") + + +def test_atomic_write_and_remove(tmp_path) -> None: + target = tmp_path / "file.txt" + atomic_write_text(str(target), "hello\n", mode=0o600) + assert target.read_text() == "hello\n" + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + assert remove_file(str(target)) is True + assert not target.exists() + assert remove_file(str(target)) is False + + +def test_atomic_write_refuses_symlink(tmp_path) -> None: + real = tmp_path / "real.txt" + real.write_text("secret") + link = tmp_path / "link.txt" + link.symlink_to(real) + with pytest.raises(SafetyRefusalError): + atomic_write_text(str(link), "overwrite") + assert real.read_text() == "secret" + + +def test_remove_refuses_symlink(tmp_path) -> None: + real = tmp_path / "real.txt" + real.write_text("secret") + link = tmp_path / "link.txt" + link.symlink_to(real) + with pytest.raises(SafetyRefusalError): + remove_file(str(link)) + + +def test_check_replaceable_ownership(tmp_path) -> None: + target = tmp_path / "file.txt" + target.write_text("x") + check_replaceable(str(target), expected_uid=os.getuid()) + with pytest.raises(SafetyRefusalError): + check_replaceable(str(target), expected_uid=os.getuid() + 99999) + + +def test_resolve_helper_rejects_missing() -> None: + assert resolve_helper("definitely-not-a-real-helper-xyz") is None + + +def test_resolve_helper_rejects_world_writable_dir(tmp_path, monkeypatch) -> None: + helper_dir = tmp_path / "bin" + helper_dir.mkdir() + helper = helper_dir / "evilhelper" + helper.write_text("#!/bin/sh\ntrue\n") + helper.chmod(0o755) + helper_dir.chmod(0o777) + monkeypatch.setenv("PATH", str(helper_dir)) + assert resolve_helper("evilhelper") is None + + +def test_resolve_helper_rejects_group_writable_file(tmp_path, monkeypatch) -> None: + helper_dir = tmp_path / "bin" + helper_dir.mkdir() + helper = helper_dir / "evilhelper" + helper.write_text("#!/bin/sh\ntrue\n") + helper.chmod(0o775) + monkeypatch.setenv("PATH", str(helper_dir)) + assert resolve_helper("evilhelper") is None + + +def test_resolve_helper_rejects_user_owned_dir_when_root(tmp_path, monkeypatch) -> None: + helper_dir = tmp_path / "bin" + helper_dir.mkdir() + helper = helper_dir / "evilhelper" + helper.write_text("#!/bin/sh\ntrue\n") + helper.chmod(0o755) + monkeypatch.setenv("PATH", str(helper_dir)) + monkeypatch.setattr(os, "geteuid", lambda: 0) + assert resolve_helper("evilhelper") is None + + +def test_resolve_helper_accepts_own_dir_when_not_root(tmp_path, monkeypatch) -> None: + helper_dir = tmp_path / "bin" + helper_dir.mkdir() + helper = helper_dir / "goodhelper" + helper.write_text("#!/bin/sh\ntrue\n") + helper.chmod(0o755) + monkeypatch.setenv("PATH", str(helper_dir)) + monkeypatch.setattr(os, "geteuid", lambda: 4242) + assert resolve_helper("goodhelper") == str(helper) + + +def test_runner_refuses_untrusted_absolute_helper(tmp_path, monkeypatch) -> None: + helper = tmp_path / "evil" + helper.write_text("#!/bin/sh\ntrue\n") + helper.chmod(0o755) + monkeypatch.setattr(os, "geteuid", lambda: 0) + with pytest.raises(SafetyRefusalError): + CommandRunner().run([str(helper)]) + + +def test_runner_refuses_relative_executable() -> None: + with pytest.raises(SafetyRefusalError): + CommandRunner().run(["bin/evil"]) + + +def test_check_trusted_directory_owner_and_mode(tmp_path) -> None: + check_trusted_directory(str(tmp_path), expected_uid=os.getuid()) + with pytest.raises(SafetyRefusalError): + check_trusted_directory(str(tmp_path), expected_uid=os.getuid() + 99999) + tmp_path.chmod(0o777) + with pytest.raises(SafetyRefusalError): + check_trusted_directory(str(tmp_path), expected_uid=os.getuid()) + + +def test_validate_managed_unit_name() -> None: + assert validate_managed_unit_name("schedls-backup.timer") == "schedls-backup.timer" + assert validate_managed_unit_name("schedls-a_b.service") == "schedls-a_b.service" + for unit in ( + "../../etc/passwd", + "schedls-../x.timer", + "certbot.timer", + "schedls-x.timer/../y", + "schedls-x.timer\n", + ): + with pytest.raises(SafetyRefusalError): + validate_managed_unit_name(unit) + + +def test_is_safe_unit_name() -> None: + assert is_safe_unit_name("certbot.service") + assert is_safe_unit_name("foo@bar.timer") + assert not is_safe_unit_name("--output=json") + assert not is_safe_unit_name("../../evil.service") + assert not is_safe_unit_name("unit with spaces.service") diff --git a/tests/unit/test_timefmt.py b/tests/unit/test_timefmt.py new file mode 100644 index 0000000..2211122 --- /dev/null +++ b/tests/unit/test_timefmt.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from schedls import timefmt + + +@pytest.mark.parametrize( + ("value", "seconds"), + [ + ("1s", 1.0), + ("30m", 1800.0), + ("1h", 3600.0), + ("2h30m", 9000.0), + ("1d", 86400.0), + ("1w", 604800.0), + ("500ms", 0.5), + ("1min", 60.0), + ], +) +def test_duration_seconds(value: str, seconds: float) -> None: + assert timefmt.duration_seconds(value) == pytest.approx(seconds) + + +@pytest.mark.parametrize("bad", ["", "abc", "1x", "h", "1h2x"]) +def test_invalid_duration(bad: str) -> None: + with pytest.raises(ValueError): + timefmt.duration_seconds(bad) + assert not timefmt.is_valid_duration(bad) + + +def test_parse_microseconds() -> None: + parsed = timefmt.parse_systemd_timestamp("1758700800000000") + assert parsed is not None + assert parsed.year == 2025 + + +def test_parse_pretty() -> None: + parsed = timefmt.parse_systemd_timestamp("Thu 2026-09-24 06:49:28 BST") + assert parsed is not None + assert (parsed.year, parsed.month, parsed.day) == (2026, 9, 24) + assert parsed.tzinfo is not None + + +def test_parse_placeholders() -> None: + assert timefmt.parse_systemd_timestamp("") is None + assert timefmt.parse_systemd_timestamp("n/a") is None + assert timefmt.parse_systemd_timestamp("0") is None + + +def test_format_datetime_round_trip_zone() -> None: + dt = datetime(2026, 9, 25, 2, 0, 0, tzinfo=timezone(timedelta(hours=1))) + text = timefmt.format_datetime(dt) + assert text.startswith("Fri 25 Sep 2026 02:00:00") + + +def test_format_short_relative() -> None: + now = datetime(2026, 9, 24, 12, 0, tzinfo=UTC) + today = datetime(2026, 9, 24, 18, 0, tzinfo=UTC) + tomorrow = datetime(2026, 9, 25, 3, 0, tzinfo=UTC) + assert timefmt.format_short(today, now=now, utc=True) == "today 18:00" + assert timefmt.format_short(tomorrow, now=now, utc=True) == "tomorrow 03:00" + + +def test_isoformat_has_offset() -> None: + dt = datetime(2026, 9, 25, 2, 0, tzinfo=UTC) + assert timefmt.isoformat(dt).endswith("+00:00")