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 @@
+[](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/LICENSE)
+[](https://git.sysmd.uk/mdaleo404/schedls/releases)
+[](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/.pre-commit-config.yaml)
+
# schedls
+
+

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