From aded37d491b294497977d94eaf5656c3cafd3f3c Mon Sep 17 00:00:00 2001 From: Marco D'Aleo Date: Fri, 25 Sep 2026 11:16:19 +0100 Subject: [PATCH] Initial code commit --- .gitea/CODEOWNERS | 1 + .gitea/workflows/ci.yml | 73 ++ .gitea/workflows/security-scan.yml | 188 ++++ .gitignore | 1 - .pre-commit-config.yaml | 31 + README.md | 135 +++ SECURITY.md | 52 ++ docs/architecture.md | 127 +++ docs/cli.md | 283 ++++++ docs/cron.md | 90 ++ docs/security-model.md | 110 +++ docs/systemd.md | 106 +++ poetry.lock | 822 +++++++++++++++++- pyproject.toml | 57 +- schedls.png | Bin 0 -> 76683 bytes src/schedls/__init__.py | 35 + src/schedls/__main__.py | 10 + src/schedls/backends/__init__.py | 1 + src/schedls/backends/base.py | 98 +++ src/schedls/backends/cron.py | 443 ++++++++++ src/schedls/backends/systemd.py | 727 ++++++++++++++++ src/schedls/cli.py | 733 +++++++++++++++- src/schedls/convenience.py | 126 +++ src/schedls/describe.py | 95 ++ src/schedls/errors.py | 77 ++ src/schedls/interact.py | 36 + src/schedls/models.py | 148 ++++ src/schedls/operations/__init__.py | 1 + src/schedls/operations/calendar.py | 105 +++ src/schedls/operations/doctor.py | 116 +++ src/schedls/operations/inspect.py | 154 ++++ src/schedls/operations/mutate.py | 103 +++ src/schedls/output.py | 177 ++++ src/schedls/prompt.py | 201 +++++ src/schedls/renderers/__init__.py | 1 + src/schedls/renderers/cron.py | 127 +++ src/schedls/renderers/systemd.py | 153 ++++ src/schedls/runner.py | 194 +++++ src/schedls/security.py | 234 +++++ src/schedls/timefmt.py | 206 +++++ src/schedls/unitfile.py | 63 ++ tests/__init__.py | 0 tests/conftest.py | 73 ++ tests/fixtures/cron/basic.crontab | 3 + tests/fixtures/systemd/basic.service | 9 + tests/fixtures/systemd/basic.timer | 13 + tests/integration/__init__.py | 0 .../integration/test_calendar_integration.py | 36 + tests/integration/test_cron_lifecycle.py | 64 ++ tests/integration/test_systemd_lifecycle.py | 81 ++ tests/security/__init__.py | 0 tests/security/test_hardening.py | 33 + tests/unit/__init__.py | 0 tests/unit/test_cli.py | 122 +++ tests/unit/test_convenience.py | 43 + tests/unit/test_crontab.py | 95 ++ tests/unit/test_describe.py | 28 + tests/unit/test_operations.py | 393 +++++++++ tests/unit/test_output.py | 105 +++ tests/unit/test_prompt.py | 203 +++++ tests/unit/test_renderers_cron.py | 129 +++ tests/unit/test_renderers_systemd.py | 111 +++ tests/unit/test_security.py | 175 ++++ tests/unit/test_timefmt.py | 69 ++ 64 files changed, 8201 insertions(+), 24 deletions(-) create mode 100644 .gitea/CODEOWNERS create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/security-scan.yml create mode 100644 .pre-commit-config.yaml create mode 100644 SECURITY.md create mode 100644 docs/architecture.md create mode 100644 docs/cli.md create mode 100644 docs/cron.md create mode 100644 docs/security-model.md create mode 100644 docs/systemd.md create mode 100644 schedls.png create mode 100644 src/schedls/__main__.py create mode 100644 src/schedls/backends/__init__.py create mode 100644 src/schedls/backends/base.py create mode 100644 src/schedls/backends/cron.py create mode 100644 src/schedls/backends/systemd.py create mode 100644 src/schedls/convenience.py create mode 100644 src/schedls/describe.py create mode 100644 src/schedls/errors.py create mode 100644 src/schedls/interact.py create mode 100644 src/schedls/models.py create mode 100644 src/schedls/operations/__init__.py create mode 100644 src/schedls/operations/calendar.py create mode 100644 src/schedls/operations/doctor.py create mode 100644 src/schedls/operations/inspect.py create mode 100644 src/schedls/operations/mutate.py create mode 100644 src/schedls/output.py create mode 100644 src/schedls/prompt.py create mode 100644 src/schedls/renderers/__init__.py create mode 100644 src/schedls/renderers/cron.py create mode 100644 src/schedls/renderers/systemd.py create mode 100644 src/schedls/runner.py create mode 100644 src/schedls/security.py create mode 100644 src/schedls/timefmt.py create mode 100644 src/schedls/unitfile.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/cron/basic.crontab create mode 100644 tests/fixtures/systemd/basic.service create mode 100644 tests/fixtures/systemd/basic.timer create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_calendar_integration.py create mode 100644 tests/integration/test_cron_lifecycle.py create mode 100644 tests/integration/test_systemd_lifecycle.py create mode 100644 tests/security/__init__.py create mode 100644 tests/security/test_hardening.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_convenience.py create mode 100644 tests/unit/test_crontab.py create mode 100644 tests/unit/test_describe.py create mode 100644 tests/unit/test_operations.py create mode 100644 tests/unit/test_output.py create mode 100644 tests/unit/test_prompt.py create mode 100644 tests/unit/test_renderers_cron.py create mode 100644 tests/unit/test_renderers_systemd.py create mode 100644 tests/unit/test_security.py create mode 100644 tests/unit/test_timefmt.py 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 0000000000000000000000000000000000000000..2535ac5036367f6f9ca454ade6a9df6bdbc41ca4 GIT binary patch literal 76683 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

2}xa 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 LW$8L8)3E;n$GYal literal 0 HcmV?d00001 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")