Initial code commit
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s

This commit is contained in:
2026-09-25 11:16:19 +01:00
parent 427daa7f1a
commit aded37d491
64 changed files with 8201 additions and 24 deletions
+1
View File
@@ -0,0 +1 @@
* @mdaleo404
+73
View File
@@ -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
+188
View File
@@ -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
-1
View File
@@ -173,4 +173,3 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
+31
View File
@@ -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"]
+135
View File
@@ -1,2 +1,137 @@
[![Licence](https://img.shields.io/badge/GPL--3.0--or--later-orange?label=Licence)](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/LICENSE)
[![Gitea Release](https://img.shields.io/gitea/v/release/mdaleo404/schedls?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/mdaleo404/schedls/releases)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?logo=pre-commit&style=flat)](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/.pre-commit-config.yaml)
# schedls # schedls
<div align="center">
<img src="https://git.sysmd.uk/mdaleo404/schedls/raw/branch/main/schedls.png" alt="schedls logo" width="256" />
</div>
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.
+52
View File
@@ -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
```
+127
View File
@@ -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.
+283
View File
@@ -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.
+90
View File
@@ -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.
+110
View File
@@ -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-<name>.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.
+106
View File
@@ -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.
Generated
+819 -3
View File
@@ -1,7 +1,823 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. # 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] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<4.0" python-versions = ">=3.11,<4.0"
content-hash = "7b8fc01b274bd807fb00372bbc8e138330f15ae7978ed61e180f3b17ec076725" content-hash = "25a3f2935d10e944921652fc64a2d0209fb0a0c98cf35d743c934cee62137cf3"
+55 -2
View File
@@ -1,14 +1,31 @@
[project] [project]
name = "schedls" name = "schedls"
version = "0.0.1" version = "0.1.0"
description = "Inspect and manage Linux scheduled jobs" description = "Inspect and manage Linux scheduled jobs"
authors = [ authors = [
{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" } { name = "Marco D'Aleo", email = "marco@marcodaleo.com" }
] ]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10,<4.0" requires-python = ">=3.11,<4.0"
dependencies = [] 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] [project.scripts]
schedls = "schedls.cli:main" schedls = "schedls.cli:main"
@@ -16,3 +33,39 @@ schedls = "schedls.cli:main"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" 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",
]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+35
View File
@@ -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__"]
+10
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Scheduler backends."""
+98
View File
@@ -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
+443
View File
@@ -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:(?P<kind>begin|end)\s+(?P<meta>.*)$")
_META_RE = re.compile(r"(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>\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)
+727
View File
@@ -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
+715 -18
View File
@@ -1,35 +1,732 @@
"""Command-line interface for schedls."""
from __future__ import annotations
import argparse 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( parser = argparse.ArgumentParser(
prog="schedls", prog="schedls",
description="Inspect and manage Linux scheduled jobs.", 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( parser.add_argument(
"--version", "--color",
action="version", choices=("auto", "always", "never"),
version="schedls 0.0.1", 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") subparsers = parser.add_subparsers(dest="command")
calendar = subparsers.add_parser( subparsers.add_parser("list", help="list visible scheduled jobs")
"calendar",
help="Validate and inspect a systemd calendar expression", 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": subparsers.add_parser("doctor", help="inspect scheduler capabilities")
result = subprocess.run(
["systemd-analyze", "calendar", args.expression], return parser
check=False,
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())
+126
View File
@@ -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<h>\d{1,2}):(?P<m>\d{2})(?::(?P<s>\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} * *"
+95
View File
@@ -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 "—"
+77
View File
@@ -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."""
+36
View File
@@ -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"}
+148
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""High-level operations."""
+105
View File
@@ -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)
+116
View File
@@ -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"
+154
View File
@@ -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"
+103
View File
@@ -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)}")
+177
View File
@@ -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),
}
+201
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Renderers for native scheduler configuration."""
+127
View File
@@ -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:(?P<kind>begin|end)\s+(?P<meta>.*)$")
_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"
+153
View File
@@ -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)))
+194
View File
@@ -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}=<redacted>")
else:
redacted.append(arg)
return tuple(redacted)
def _debug(self, message: str) -> None:
if self.debug:
print(f"[schedls] {message}", file=sys.stderr)
+234
View File
@@ -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)
+206
View File
@@ -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<sign>-)?(?:\d+(?:\.\d+)?(?:{_UNITS})\s*)+$",
re.IGNORECASE,
)
_DURATION_PART_RE = re.compile(
rf"(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>{_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)
+63
View File
@@ -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)
View File
+73
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
# schedls:begin name=backup
0 2 * * * /usr/local/bin/backup /srv/data
# schedls:end name=backup
+9
View File
@@ -0,0 +1,9 @@
# Managed by schedls
# Name: backup
[Unit]
Description=schedls job backup
[Service]
Type=oneshot
ExecStart="/usr/local/bin/backup" "/srv/data"
+13
View File
@@ -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
View File
@@ -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)
+64
View File
@@ -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)
@@ -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))
View File
+33
View File
@@ -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
View File
+122
View File
@@ -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"])
+43
View File
@@ -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")
+95
View File
@@ -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
+28
View File
@@ -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"
+393
View File
@@ -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"
+105
View File
@@ -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"
+203
View File
@@ -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 == []
+129
View File
@@ -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'"
+111
View File
@@ -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
+175
View File
@@ -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")
+69
View File
@@ -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")