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
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:
@@ -0,0 +1 @@
|
||||
* @mdaleo404
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -173,4 +173,3 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,2 +1,137 @@
|
||||
[](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/LICENSE)
|
||||
[](https://git.sysmd.uk/mdaleo404/schedls/releases)
|
||||
[](https://git.sysmd.uk/mdaleo404/schedls/src/branch/main/.pre-commit-config.yaml)
|
||||
|
||||
# schedls
|
||||
|
||||
<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
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -1,7 +1,823 @@
|
||||
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
|
||||
package = []
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.11.2"
|
||||
description = "Python bindings for mypy AST serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b"},
|
||||
{file = "ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f"},
|
||||
{file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d"},
|
||||
{file = "ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484"},
|
||||
{file = "ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27"},
|
||||
{file = "ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
description = "Validate configuration and produce human readable error messages."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"},
|
||||
{file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.16.1"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0"},
|
||||
{file = "coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4"},
|
||||
{file = "coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.3"
|
||||
description = "Distribution utilities"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"},
|
||||
{file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "4.0.3"
|
||||
description = "A platform independent file lock."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "filelock-4.0.3-py3-none-any.whl", hash = "sha256:30cd166e2aee2c7534ce2c33c6367c4cd051b8368e20e2c4eb0c34f699bacfab"},
|
||||
{file = "filelock-4.0.3.tar.gz", hash = "sha256:87296d60478e14204fd9406e79831400fef76693bae2895deec236c98e87a8aa"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.19"
|
||||
description = "File identification library for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"},
|
||||
{file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
license = ["ukkonen"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.15.0"
|
||||
description = "Mypyc runtime library"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489"},
|
||||
{file = "librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52"},
|
||||
{file = "librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8"},
|
||||
{file = "librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1"},
|
||||
{file = "librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d"},
|
||||
{file = "librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702"},
|
||||
{file = "librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9"},
|
||||
{file = "librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b"},
|
||||
{file = "librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db"},
|
||||
{file = "librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451"},
|
||||
{file = "librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389"},
|
||||
{file = "librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c"},
|
||||
{file = "librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e"},
|
||||
{file = "librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053"},
|
||||
{file = "librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22"},
|
||||
{file = "librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63"},
|
||||
{file = "librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef"},
|
||||
{file = "librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f"},
|
||||
{file = "librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8"},
|
||||
{file = "librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a"},
|
||||
{file = "librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18"},
|
||||
{file = "librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c"},
|
||||
{file = "librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a"},
|
||||
{file = "librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091"},
|
||||
{file = "librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40"},
|
||||
{file = "librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0"},
|
||||
{file = "librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb"},
|
||||
{file = "librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db"},
|
||||
{file = "librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56"},
|
||||
{file = "librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e"},
|
||||
{file = "librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d"},
|
||||
{file = "librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1"},
|
||||
{file = "librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa"},
|
||||
{file = "librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd"},
|
||||
{file = "librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa"},
|
||||
{file = "librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d"},
|
||||
{file = "librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656"},
|
||||
{file = "librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81"},
|
||||
{file = "librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d"},
|
||||
{file = "librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0"},
|
||||
{file = "librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2"},
|
||||
{file = "librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1"},
|
||||
{file = "librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022"},
|
||||
{file = "librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570"},
|
||||
{file = "librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26"},
|
||||
{file = "librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801"},
|
||||
{file = "librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc"},
|
||||
{file = "librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95"},
|
||||
{file = "librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b"},
|
||||
{file = "librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2"},
|
||||
{file = "librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3"},
|
||||
{file = "librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785"},
|
||||
{file = "librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6"},
|
||||
{file = "librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101"},
|
||||
{file = "librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218"},
|
||||
{file = "librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b"},
|
||||
{file = "librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab"},
|
||||
{file = "librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890"},
|
||||
{file = "librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8"},
|
||||
{file = "librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad"},
|
||||
{file = "librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993"},
|
||||
{file = "librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8"},
|
||||
{file = "librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21"},
|
||||
{file = "librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953"},
|
||||
{file = "librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa"},
|
||||
{file = "librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879"},
|
||||
{file = "librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae"},
|
||||
{file = "librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd"},
|
||||
{file = "librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285"},
|
||||
{file = "librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239"},
|
||||
{file = "librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60"},
|
||||
{file = "librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65"},
|
||||
{file = "librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622"},
|
||||
{file = "librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15"},
|
||||
{file = "librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28"},
|
||||
{file = "librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915"},
|
||||
{file = "librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605"},
|
||||
{file = "librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca"},
|
||||
{file = "librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965"},
|
||||
{file = "librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad"},
|
||||
{file = "librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9"},
|
||||
{file = "librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328"},
|
||||
{file = "librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0"},
|
||||
{file = "librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9"},
|
||||
{file = "librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659"},
|
||||
{file = "librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0"},
|
||||
{file = "librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d"},
|
||||
{file = "librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c"},
|
||||
{file = "librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d"},
|
||||
{file = "librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374"},
|
||||
{file = "librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9"},
|
||||
{file = "librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8"},
|
||||
{file = "librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1"},
|
||||
{file = "librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc"},
|
||||
{file = "librt-0.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0e2d0c0acf5b0ada7d045912b7cf787c21315c95b38b1fa939ef72d45d366b3d"},
|
||||
{file = "librt-0.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9ca190fe9edc0eb08eec558a509a16d28d91c35667b8f043cba40ed5e77a959"},
|
||||
{file = "librt-0.15.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80811e1c42386ea95c6fb30571d3250ad43d7863f883f787f70517f441150e59"},
|
||||
{file = "librt-0.15.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:88c2a17815c266e6d8180204ff62cb739ab869ada4a746d4c505331526ac58f1"},
|
||||
{file = "librt-0.15.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a5fa8f1f916988d0bf1afea005bda37f56ac41a18016e813ccf0097a8d460ca4"},
|
||||
{file = "librt-0.15.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:355e3a4c725225a14262004fc1872a552b9d3634b4f791a0dfc80804aafbfd55"},
|
||||
{file = "librt-0.15.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc1ed11c4ad0b91af24def2050f2840ea4567828e3dd058fbe608d982f6e5465"},
|
||||
{file = "librt-0.15.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1f4ef2e71db33df4309167ed7f1520c4fae5e611226e159fa9cf33f93e6ddb3d"},
|
||||
{file = "librt-0.15.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1a1a8cd430c7dd0c083f455cb1b328d7fc682b05c31b940906f7845bdff80881"},
|
||||
{file = "librt-0.15.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738"},
|
||||
{file = "librt-0.15.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:1172c6ad2a88b646e7fe3b480e3fac4ab4418b3443fd8a4061fdd531e0622fc7"},
|
||||
{file = "librt-0.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52e8db01f603f5da0ca30987479acff98769382efc8e142fa3962395dcf3ffdb"},
|
||||
{file = "librt-0.15.0-cp39-cp39-win32.whl", hash = "sha256:e4c911f15a1652ca94ae9f1abd92e74cbb1b3597d2d92fdd556202f94e8cd455"},
|
||||
{file = "librt-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:68242379c9b65a582b6e97318a1e9fbd6d445e58954f2d437991c4804ab11578"},
|
||||
{file = "librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.3.1"
|
||||
description = "Optional static typing for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41"},
|
||||
{file = "mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0"},
|
||||
{file = "mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167"},
|
||||
{file = "mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13"},
|
||||
{file = "mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53"},
|
||||
{file = "mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90"},
|
||||
{file = "mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531"},
|
||||
{file = "mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8"},
|
||||
{file = "mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8"},
|
||||
{file = "mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01"},
|
||||
{file = "mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1"},
|
||||
{file = "mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6"},
|
||||
{file = "mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff"},
|
||||
{file = "mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff"},
|
||||
{file = "mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080"},
|
||||
{file = "mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355"},
|
||||
{file = "mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb"},
|
||||
{file = "mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b"},
|
||||
{file = "mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d"},
|
||||
{file = "mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb"},
|
||||
{file = "mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226"},
|
||||
{file = "mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74"},
|
||||
{file = "mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6"},
|
||||
{file = "mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d"},
|
||||
{file = "mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595"},
|
||||
{file = "mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2"},
|
||||
{file = "mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc"},
|
||||
{file = "mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045"},
|
||||
{file = "mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0"},
|
||||
{file = "mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63"},
|
||||
{file = "mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3"},
|
||||
{file = "mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523"},
|
||||
{file = "mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306"},
|
||||
{file = "mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021"},
|
||||
{file = "mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e"},
|
||||
{file = "mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc"},
|
||||
{file = "mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4"},
|
||||
{file = "mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29"},
|
||||
{file = "mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb"},
|
||||
{file = "mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
ast-serialize = ">=0.6.0,<1.0.0"
|
||||
librt = {version = ">=0.13.0", markers = "platform_python_implementation != \"PyPy\""}
|
||||
mypy_extensions = ">=1.0.0"
|
||||
pathspec = ">=1.0.0"
|
||||
typing_extensions = [
|
||||
{version = ">=4.6.0", markers = "python_version < \"3.15\""},
|
||||
{version = ">=4.14.0", markers = "python_version >= \"3.15\""},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
faster-cache = ["orjson"]
|
||||
install-types = ["pip"]
|
||||
mypyc = ["setuptools (>=50)"]
|
||||
reports = ["lxml"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
description = "Type system extensions for programs checked with the mypy type checker."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
|
||||
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
description = "Node.js virtual environment builder"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"},
|
||||
{file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"},
|
||||
{file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
|
||||
{file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
hyperscan = ["hyperscan (>=0.7)"]
|
||||
optional = ["typing-extensions (>=4)"]
|
||||
re2 = ["google-re2 (>=1.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.13"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.11.13-py3-none-any.whl", hash = "sha256:228b2283af5c1f32673cdf55271216a52bbd8258cb31374e302ad7bd26cd7513"},
|
||||
{file = "platformdirs-4.11.13.tar.gz", hash = "sha256:6985eefdc2298693e4ce1fe124645524cb967428eb6384e0ce5b49767e7ea8ba"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.6.2"
|
||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e"},
|
||||
{file = "pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cfgv = ">=2.0.0"
|
||||
identify = ">=1.0.0"
|
||||
nodeenv = ">=0.11.1"
|
||||
pyyaml = ">=5.1"
|
||||
virtualenv = ">=20.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
|
||||
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
|
||||
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||
iniconfig = ">=1.0.1"
|
||||
packaging = ">=22"
|
||||
pluggy = ">=1.5,<2"
|
||||
pygments = ">=2.7.2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
description = "Pytest plugin for measuring coverage."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"},
|
||||
{file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
coverage = {version = ">=7.10.6", extras = ["toml"]}
|
||||
pluggy = ">=1.2"
|
||||
pytest = ">=7"
|
||||
|
||||
[package.extras]
|
||||
testing = ["process-tests", "pytest-xdist", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.6.1"
|
||||
description = "Python interpreter discovery"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3"},
|
||||
{file = "python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
filelock = ">=3.15.4"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
description = "YAML parser and emitter for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
|
||||
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.8"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"},
|
||||
{file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"},
|
||||
{file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"},
|
||||
{file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"},
|
||||
{file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"},
|
||||
{file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"},
|
||||
{file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"},
|
||||
{file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"},
|
||||
{file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"},
|
||||
{file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"},
|
||||
{file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"},
|
||||
{file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
|
||||
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.12.1"
|
||||
description = "Virtual Python Environment builder"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "virtualenv-21.12.1-py3-none-any.whl", hash = "sha256:878db9e963d0ef0b38561a344a4f9b12dc5c7741b6245d7ed10e9291cfea28bc"},
|
||||
{file = "virtualenv-21.12.1.tar.gz", hash = "sha256:be5a0a62cb2d1529ff6999652e2e7826f95bf7faa9b96b88cc39847c023d90a0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
distlib = ">=0.3.7,<1"
|
||||
filelock = {version = ">=3.24.2,<5", markers = "python_version >= \"3.10\""}
|
||||
platformdirs = ">=3.9.1,<5"
|
||||
python-discovery = ">=1.6"
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "7b8fc01b274bd807fb00372bbc8e138330f15ae7978ed61e180f3b17ec076725"
|
||||
python-versions = ">=3.11,<4.0"
|
||||
content-hash = "25a3f2935d10e944921652fc64a2d0209fb0a0c98cf35d743c934cee62137cf3"
|
||||
|
||||
+55
-2
@@ -1,14 +1,31 @@
|
||||
[project]
|
||||
name = "schedls"
|
||||
version = "0.0.1"
|
||||
version = "0.1.0"
|
||||
description = "Inspect and manage Linux scheduled jobs"
|
||||
authors = [
|
||||
{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" }
|
||||
]
|
||||
license = "GPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
requires-python = ">=3.11,<4.0"
|
||||
dependencies = []
|
||||
keywords = ["cron", "crontab", "systemd", "timer", "scheduler", "cli", "sysadmin"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: System Administrators",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: System :: Systems Administration",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.sysmd.uk/mdaleo404/schedls"
|
||||
Repository = "https://git.sysmd.uk/mdaleo404/schedls"
|
||||
|
||||
[project.scripts]
|
||||
schedls = "schedls.cli:main"
|
||||
@@ -16,3 +33,39 @@ schedls = "schedls.cli:main"
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pre-commit = ">=4.0"
|
||||
pytest = ">=8.0"
|
||||
pytest-cov = ">=5.0"
|
||||
ruff = ">=0.6"
|
||||
mypy = ">=1.11"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "S"]
|
||||
# S603/S607: subprocess is confined to runner.py and always uses an argv.
|
||||
# S604: matches our own Command(shell=...) dataclass field, not subprocess usage.
|
||||
ignore = ["S603", "S604", "S607"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["S101", "S105", "S106"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
packages = ["schedls"]
|
||||
mypy_path = "src"
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "-q --strict-markers"
|
||||
markers = [
|
||||
"integration: tests that require native systemd/cron facilities",
|
||||
]
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -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__"]
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Scheduler backends."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+716
-19
@@ -1,35 +1,732 @@
|
||||
"""Command-line interface for schedls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import __version__, convenience
|
||||
from .backends.base import SchedulerBackend
|
||||
from .backends.cron import CronBackend
|
||||
from .backends.systemd import SystemdBackend
|
||||
from .errors import EXIT_FAILURE, EXIT_SUCCESS, NotFoundError, SchedlsError, UsageError
|
||||
from .interact import Interaction
|
||||
from .models import Backend, Command, JobSpec, ScheduledJob, Scope
|
||||
from .operations import calendar as calendar_ops
|
||||
from .operations import doctor as doctor_ops
|
||||
from .operations import inspect as inspect_ops
|
||||
from .operations import mutate as mutate_ops
|
||||
from .output import Output, sanitize_text
|
||||
from .prompt import Prompter
|
||||
from .renderers import cron as cron_renderer
|
||||
from .runner import CommandRunner
|
||||
from .security import validate_name
|
||||
from .timefmt import format_datetime
|
||||
|
||||
_ENV_NAME_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z")
|
||||
_MAX_LOG_LINES = 1_000_000
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def _positive_int(value: str) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError("expected an integer") from None
|
||||
if number < 1 or number > _MAX_LOG_LINES:
|
||||
raise argparse.ArgumentTypeError(f"must be between 1 and {_MAX_LOG_LINES}")
|
||||
return number
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
runner: CommandRunner
|
||||
output: Output
|
||||
interaction: Interaction
|
||||
systemd: SystemdBackend
|
||||
cron: CronBackend
|
||||
|
||||
@property
|
||||
def backends(self) -> list[SchedulerBackend]:
|
||||
return [self.systemd, self.cron]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="schedls",
|
||||
description="Inspect and manage Linux scheduled jobs.",
|
||||
)
|
||||
|
||||
parser.add_argument("--version", action="version", version=f"schedls {__version__}")
|
||||
parser.add_argument("--debug", action="store_true", help="enable debug output on stderr")
|
||||
parser.add_argument("--json", action="store_true", dest="json_mode", help="emit JSON on stdout")
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version="schedls 0.0.1",
|
||||
"--color",
|
||||
choices=("auto", "always", "never"),
|
||||
default="auto",
|
||||
help="colorize human output (default: auto)",
|
||||
)
|
||||
parser.add_argument("--utc", action="store_true", help="display times in UTC")
|
||||
scope = parser.add_mutually_exclusive_group()
|
||||
scope.add_argument("--user", dest="scope_filter", action="store_const", const=Scope.USER, help="only user scope")
|
||||
scope.add_argument(
|
||||
"--system", dest="scope_filter", action="store_const", const=Scope.SYSTEM, help="only system scope"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=("systemd", "cron"),
|
||||
dest="backend_filter",
|
||||
help="only show jobs from this backend",
|
||||
)
|
||||
managed = parser.add_mutually_exclusive_group()
|
||||
managed.add_argument(
|
||||
"--managed", dest="managed_filter", action="store_const", const=True, help="only show jobs created by schedls"
|
||||
)
|
||||
managed.add_argument(
|
||||
"--unmanaged",
|
||||
dest="managed_filter",
|
||||
action="store_const",
|
||||
const=False,
|
||||
help="only show jobs schedls does not manage",
|
||||
)
|
||||
enabled = parser.add_mutually_exclusive_group()
|
||||
enabled.add_argument(
|
||||
"--enabled", dest="enabled_filter", action="store_const", const=True, help="only show enabled jobs"
|
||||
)
|
||||
enabled.add_argument(
|
||||
"--disabled", dest="enabled_filter", action="store_const", const=False, help="only show disabled jobs"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
calendar = subparsers.add_parser(
|
||||
"calendar",
|
||||
help="Validate and inspect a systemd calendar expression",
|
||||
subparsers.add_parser("list", help="list visible scheduled jobs")
|
||||
|
||||
show = subparsers.add_parser("show", help="show one scheduled job")
|
||||
show.add_argument("name", help="job name")
|
||||
|
||||
new = subparsers.add_parser("new", help="create a scheduled job")
|
||||
new.add_argument("name", nargs="?", help="job name (prompted for with --interactive)")
|
||||
backend = new.add_mutually_exclusive_group()
|
||||
backend.add_argument("--timer", action="store_true", help="create a systemd timer")
|
||||
backend.add_argument("--cron", action="store_true", help="create a cron job")
|
||||
_add_creation_options(new)
|
||||
_add_interactive_flag(new)
|
||||
|
||||
edit = subparsers.add_parser("edit", help="modify a schedls-managed job")
|
||||
edit.add_argument("name", help="job name")
|
||||
_add_edit_options(edit)
|
||||
_add_interactive_flag(edit)
|
||||
|
||||
rm = subparsers.add_parser("rm", help="remove a schedls-managed job")
|
||||
rm.add_argument("name", help="job name")
|
||||
_add_mutation_flags(rm)
|
||||
|
||||
for action, help_text in (("enable", "enable a job"), ("disable", "disable a job")):
|
||||
command = subparsers.add_parser(action, help=help_text)
|
||||
command.add_argument("name", help="job name")
|
||||
_add_mutation_flags(command)
|
||||
|
||||
logs = subparsers.add_parser("logs", help="show available execution logs")
|
||||
logs.add_argument("name", help="job name")
|
||||
logs.add_argument(
|
||||
"--lines",
|
||||
type=_positive_int,
|
||||
default=50,
|
||||
help="number of recent journal lines to show (default: 50)",
|
||||
)
|
||||
calendar.add_argument("expression")
|
||||
logs.add_argument("--since", help="only show entries since this time (systemd timers only)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "calendar":
|
||||
result = subprocess.run(
|
||||
["systemd-analyze", "calendar", args.expression],
|
||||
check=False,
|
||||
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)"
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
subparsers.add_parser("doctor", help="inspect scheduler capabilities")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _add_schedule_options(parser: argparse.ArgumentParser, *, creation: bool) -> None:
|
||||
parser.add_argument("--calendar", action="append", metavar="EXPR", help="native OnCalendar expression (repeatable)")
|
||||
parser.add_argument("--daily", metavar="TIME", help="daily at TIME (HH:MM)")
|
||||
parser.add_argument("--weekdays", metavar="TIME", help="Mon..Fri at TIME")
|
||||
parser.add_argument("--weekly", nargs=2, metavar=("DAY", "TIME"), help="weekly on DAY at TIME")
|
||||
parser.add_argument("--monthly", nargs=2, metavar=("DAY", "TIME"), help="monthly on DAY at TIME")
|
||||
parser.add_argument("--cron-expr", metavar="EXPR", help="five-field cron expression")
|
||||
parser.add_argument("--persistent", action="store_true", help="run missed events (systemd timers)")
|
||||
parser.add_argument("--jitter", metavar="DURATION", help="randomized delay, e.g. 5min (systemd timers)")
|
||||
parser.add_argument("--accuracy", metavar="DURATION", help="timer accuracy window, e.g. 1min (systemd timers)")
|
||||
parser.add_argument("--working-directory", metavar="PATH", help="run the command from PATH (systemd timers)")
|
||||
parser.add_argument("--env", action="append", metavar="KEY=VALUE", help="set an environment variable (repeatable)")
|
||||
parser.add_argument("--shell", metavar="SCRIPT", help="run SCRIPT through /bin/sh instead of an argv")
|
||||
if creation:
|
||||
scope = parser.add_mutually_exclusive_group()
|
||||
scope.add_argument(
|
||||
"--user", dest="scope_group", action="store_const", const=Scope.USER, help="user scope (default)"
|
||||
)
|
||||
scope.add_argument(
|
||||
"--system", dest="scope_group", action="store_const", const=Scope.SYSTEM, help="system scope (root)"
|
||||
)
|
||||
|
||||
|
||||
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())
|
||||
|
||||
@@ -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} * *"
|
||||
@@ -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 "—"
|
||||
@@ -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."""
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""High-level operations."""
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)}")
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Renderers for native scheduler configuration."""
|
||||
@@ -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"
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# schedls:begin name=backup
|
||||
0 2 * * * /usr/local/bin/backup /srv/data
|
||||
# schedls:end name=backup
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Managed by schedls
|
||||
# Name: backup
|
||||
|
||||
[Unit]
|
||||
Description=schedls job backup
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart="/usr/local/bin/backup" "/srv/data"
|
||||
Vendored
+13
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
@@ -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'"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user