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

This commit is contained in:
2026-09-25 11:16:19 +01:00
parent 427daa7f1a
commit aded37d491
64 changed files with 8201 additions and 24 deletions
View File
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import pytest
from schedls.cli import _parse_environment, _spec_for_new, build_parser, main, split_command
from schedls.errors import InvalidNameError, UsageError
from schedls.models import Backend, Scope
def test_split_command() -> None:
head, tail = split_command(["new", "x", "--timer", "--", "/bin/true", "arg"])
assert head == ["new", "x", "--timer"]
assert tail == ["/bin/true", "arg"]
head, tail = split_command(["show", "x"])
assert head == ["show", "x"]
assert tail == []
def test_edit_command_dest_does_not_shadow_subcommand() -> None:
args = build_parser().parse_args(["edit", "backup", "--command"])
assert args.command == "edit"
assert args.replace_command is True
def test_spec_for_new_systemd() -> None:
args = build_parser().parse_args(["new", "backup", "--timer", "--daily", "02:00", "--persistent"])
spec = _spec_for_new(args, ["/usr/local/bin/backup", "/srv/data"])
assert spec.backend is Backend.SYSTEMD
assert spec.scope is Scope.USER
assert spec.calendar == ("*-*-* 02:00:00",)
assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data")
assert spec.persistent is True
def test_spec_for_new_cron() -> None:
args = build_parser().parse_args(["new", "cleanup", "--cron", "--cron-expr", "0 4 * * 0"])
spec = _spec_for_new(args, ["/usr/local/bin/cleanup"])
assert spec.backend is Backend.CRON
assert spec.cron_expression == "0 4 * * 0"
def test_spec_for_new_system_scope() -> None:
args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--system"])
spec = _spec_for_new(args, ["/bin/true"])
assert spec.scope is Scope.SYSTEM
def test_spec_for_new_invalid_name() -> None:
args = build_parser().parse_args(["new", "../evil", "--timer", "--daily", "02:00"])
with pytest.raises(InvalidNameError):
_spec_for_new(args, ["/bin/true"])
def test_spec_for_new_requires_schedule() -> None:
args = build_parser().parse_args(["new", "x", "--timer"])
with pytest.raises(UsageError):
_spec_for_new(args, ["/bin/true"])
def test_new_without_backend_is_usage_error() -> None:
assert main(["new", "x", "--daily", "02:00"]) == 2
def test_new_without_name_is_usage_error() -> None:
assert main(["new", "--timer", "--daily", "02:00"]) == 2
def test_interactive_conflicts_with_json() -> None:
assert main(["--json", "new", "x", "-i", "--timer", "--daily", "02:00"]) == 2
def test_interactive_requires_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", _FakeStdin(isatty=False))
assert main(["new", "x", "-i", "--timer", "--daily", "02:00"]) == 3
class _FakeStdin:
def __init__(self, *, isatty: bool) -> None:
self._isatty = isatty
def isatty(self) -> bool:
return self._isatty
def test_spec_for_new_shell_mode() -> None:
args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--shell", "echo hi | cat"])
spec = _spec_for_new(args, [])
assert spec.command.shell is True
assert spec.command.raw == "echo hi | cat"
def test_spec_for_new_rejects_systemd_flags_for_cron() -> None:
for extra in (["--env", "A=1"], ["--working-directory", "/srv/data"], ["--persistent"], ["--jitter", "5min"]):
args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", *extra])
with pytest.raises(UsageError):
_spec_for_new(args, ["/bin/true"])
def test_spec_for_new_rejects_cron_expr_for_timer() -> None:
args = build_parser().parse_args(["new", "x", "--timer", "--cron-expr", "0 4 * * *"])
with pytest.raises(UsageError):
_spec_for_new(args, ["/bin/true"])
def test_spec_for_new_rejects_system_scope_for_cron() -> None:
args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", "--system"])
with pytest.raises(UsageError):
_spec_for_new(args, ["/bin/true"])
@pytest.mark.parametrize("value", ["0", "-1", "1000001", "abc"])
def test_logs_lines_must_be_a_bounded_positive_integer(value: str) -> None:
with pytest.raises(SystemExit):
build_parser().parse_args(["logs", "x", "--lines", value])
def test_parse_environment() -> None:
assert _parse_environment(["A=1", "B=x=y"]) == (("A", "1"), ("B", "x=y"))
with pytest.raises(UsageError):
_parse_environment(["NOVALUE"])
with pytest.raises(UsageError):
_parse_environment(["1BAD=1"])
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import pytest
from schedls import convenience
from schedls.errors import InvalidScheduleError
@pytest.mark.parametrize(
("time", "expected"),
[("02:00", "*-*-* 02:00:00"), ("2:05", "*-*-* 02:05:00"), ("23:59:59", "*-*-* 23:59:59")],
)
def test_daily_calendar(time: str, expected: str) -> None:
assert convenience.daily_calendar(time) == expected
def test_weekdays_and_weekly_calendar() -> None:
assert convenience.weekdays_calendar("08:30") == "Mon..Fri *-*-* 08:30:00"
assert convenience.weekly_calendar("sun", "04:00") == "Sun *-*-* 04:00:00"
assert convenience.weekly_calendar("Saturday", "4:00") == "Sat *-*-* 04:00:00"
assert convenience.monthly_calendar("1", "06:00") == "*-*-01 06:00:00"
def test_cron_variants() -> None:
assert convenience.daily_cron("02:00") == "0 2 * * *"
assert convenience.weekdays_cron("08:30") == "30 8 * * 1-5"
assert convenience.weekly_cron("sun", "04:00") == "0 4 * * 0"
assert convenience.monthly_cron("15", "06:00") == "0 6 15 * *"
@pytest.mark.parametrize("bad", ["25:00", "02:60", "nope", "2", "02:00:99"])
def test_invalid_time(bad: str) -> None:
with pytest.raises(InvalidScheduleError):
convenience.daily_calendar(bad)
def test_invalid_weekday_and_day() -> None:
with pytest.raises(InvalidScheduleError):
convenience.weekly_calendar("funday", "04:00")
with pytest.raises(InvalidScheduleError):
convenience.monthly_calendar("32", "06:00")
with pytest.raises(InvalidScheduleError):
convenience.monthly_calendar("x", "06:00")
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import pytest
from schedls.backends.cron import CrontabDocument
from schedls.errors import ConflictError, SafetyRefusalError
SAMPLE = (
"MAILTO=admin@example.net\n"
"SHELL=/bin/bash\n"
"PATH=/usr/local/bin:/usr/bin\n"
"\n"
"# personal job\n"
"15 7 * * * ~/bin/foo\n"
"@daily /usr/local/bin/nightly\n"
"\n"
"# schedls:begin name=backup\n"
"0 2 * * * /usr/local/bin/backup /srv/data\n"
"# schedls:end name=backup\n"
)
def test_lossless_round_trip() -> None:
document = CrontabDocument(SAMPLE)
assert document.render() == SAMPLE
def test_managed_block_detected() -> None:
document = CrontabDocument(SAMPLE)
blocks = document.managed_blocks()
assert list(blocks) == ["backup"]
assert blocks["backup"].job_lines[0].expression == "0 2 * * *"
assert blocks["backup"].job_lines[0].command == "/usr/local/bin/backup /srv/data"
assert not document.has_malformed_markers()
def test_environment_and_nickname_parsed() -> None:
document = CrontabDocument(SAMPLE)
kinds = [entry.kind for entry in document.entries]
assert kinds.count("env") == 3
assert kinds.count("comment") == 1
assert kinds.count("managed_begin") == 1
assert kinds.count("managed_end") == 1
jobs = [entry for entry in document.entries if entry.kind == "job"]
assert jobs[0].expression == "15 7 * * *"
assert jobs[1].expression == "@daily"
def test_with_block_preserves_unrelated_content() -> None:
document = CrontabDocument(SAMPLE)
updated = document.with_block("report", ["30 6 * * * /usr/local/bin/report"])
assert "MAILTO=admin@example.net" in updated
assert "15 7 * * * ~/bin/foo" in updated
assert "0 2 * * * /usr/local/bin/backup /srv/data" in updated
assert "# schedls:begin name=report" in updated
reparsed = CrontabDocument(updated)
assert set(reparsed.managed_blocks()) == {"backup", "report"}
def test_with_block_conflict() -> None:
document = CrontabDocument(SAMPLE)
with pytest.raises(ConflictError):
document.with_block("backup", ["0 3 * * * /bin/true"])
def test_without_block_removes_only_block() -> None:
document = CrontabDocument(SAMPLE)
updated = document.without_block("backup")
assert "backup" not in updated
assert "MAILTO=admin@example.net" in updated
assert "15 7 * * * ~/bin/foo" in updated
assert "@daily /usr/local/bin/nightly" in updated
def test_without_unknown_block_refused() -> None:
document = CrontabDocument(SAMPLE)
with pytest.raises(SafetyRefusalError):
document.without_block("nope")
def test_malformed_marker_blocks_mutation() -> None:
document = CrontabDocument("# schedls:begin name=broken\n0 2 * * * /bin/true\n")
assert document.has_malformed_markers()
with pytest.raises(SafetyRefusalError):
document.with_block("new", ["0 3 * * * /bin/true"])
with pytest.raises(SafetyRefusalError):
document.without_block("broken")
def test_empty_crontab() -> None:
document = CrontabDocument("")
assert document.render() == ""
updated = document.with_block("x", ["0 0 * * * /bin/true"])
assert updated.endswith("\n")
assert "name=x" in updated
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from schedls.describe import describe_calendar, describe_cron, describe_schedule
from schedls.models import Schedule, ScheduleKind
def test_describe_calendar() -> None:
assert describe_calendar("*-*-* 02:00:00") == "daily at 02:00"
assert describe_calendar("Mon..Fri *-*-* 08:30:00") == "weekdays at 08:30"
assert describe_calendar("Sun *-*-* 04:00:00") == "Sunday at 04:00"
assert describe_calendar("*-*-01 06:00:00") == "monthly on day 1 at 06:00"
assert describe_calendar("*-*-* *:00:00") is None
def test_describe_cron() -> None:
assert describe_cron("0 2 * * *") == "daily at 02:00"
assert describe_cron("30 8 * * 1-5") == "weekdays at 08:30"
assert describe_cron("0 4 * * 0") == "Sunday at 04:00"
assert describe_cron("0 6 15 * *") == "monthly on day 15 at 06:00"
assert describe_cron("0 4 1,15 * 5") is None
assert describe_cron("@daily") is None
def test_describe_schedule() -> None:
assert describe_schedule(Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00")) == "daily at 02:00"
assert describe_schedule(Schedule(ScheduleKind.CRON, "@reboot")) == "@reboot"
multi = Schedule(ScheduleKind.CALENDAR, "A", ("A", "B"))
assert describe_schedule(multi) == "A + B"
+393
View File
@@ -0,0 +1,393 @@
from __future__ import annotations
import io
import os
import tempfile
from dataclasses import replace
import pytest
from schedls.backends.base import CommandPlan, FileChange, MutationResult, Plan
from schedls.backends.cron import CronBackend
from schedls.backends.systemd import SystemdBackend
from schedls.errors import InvalidScheduleError, OperationalError, SafetyRefusalError
from schedls.interact import Interaction
from schedls.models import (
Backend,
Command,
JobSource,
JobSpec,
Schedule,
ScheduledJob,
ScheduleKind,
Scope,
SystemdDetails,
)
from schedls.operations import doctor as doctor_ops
from schedls.operations import inspect as inspect_ops
from schedls.operations import mutate as mutate_ops
from schedls.output import Output
from ..conftest import FakeRunner
def _job(name: str = "backup") -> ScheduledJob:
return ScheduledJob(
name=name,
backend=Backend.SYSTEMD,
scope=Scope.USER,
managed=True,
enabled=True,
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
command=Command(argv=("/usr/local/bin/backup",)),
source=JobSource("systemd user timer"),
)
def _output() -> tuple[Output, io.StringIO]:
stream = io.StringIO()
return Output(color="never", stdout=stream), stream
def test_render_list_table() -> None:
output, stream = _output()
inspect_ops.render_list(output, [_job(), _job("cleanup")], [])
text = stream.getvalue()
assert "NAME" in text and "SCHEDULE" in text
assert "backup" in text and "cleanup" in text
def test_render_list_json() -> None:
output, stream = _output()
output.json_mode = True
inspect_ops.render_list(output, [_job()], ["note"])
assert '"schema_version": 1' in stream.getvalue()
def test_render_show() -> None:
output, stream = _output()
inspect_ops.render_show(output, _job())
text = stream.getvalue()
assert "Managed by schedls" in text
assert "systemd user timer" in text
def test_filter_jobs() -> None:
jobs = [_job(), _job("other")]
assert inspect_ops.filter_jobs(jobs, managed=True) == jobs
assert inspect_ops.filter_jobs(jobs, managed=False) == []
assert inspect_ops.filter_jobs(jobs, enabled=False) == []
assert len(inspect_ops.filter_jobs(jobs, backend=Backend.SYSTEMD)) == 2
def test_collect_reports_unavailable_backend() -> None:
backend = SystemdBackend(FakeRunner())
jobs, warnings = inspect_ops.collect([backend], [Scope.USER])
assert jobs == []
assert any("unavailable" in warning for warning in warnings)
class _DummyBackend:
name = "systemd"
def __init__(self, result: MutationResult) -> None:
self.result = result
self.applied = False
def apply(self, plan: Plan) -> MutationResult:
self.applied = True
return self.result
def test_run_plan_dry_run(tmp_path) -> None:
output, stream = _output()
plan = Plan(backend="systemd", action="create")
plan.files = [FileChange(str(tmp_path / "example.service"), "data")]
plan.commands = [CommandPlan(("systemctl", "reload"), "reload")]
backend = _DummyBackend(MutationResult(changed=True))
result = mutate_ops.run_plan(backend, plan, output, Interaction(output=output), dry_run=True)
assert result.dry_run is True
assert backend.applied is False
assert "No changes made." in stream.getvalue()
def test_run_plan_confirms_and_applies() -> None:
output, stream = _output()
plan = Plan(backend="systemd", action="create")
backend = _DummyBackend(MutationResult(changed=True, messages=["done"]))
interaction = Interaction(output=output, assume_yes=True)
result = mutate_ops.run_plan(backend, plan, output, interaction)
assert backend.applied is True
assert result.changed is True
assert "done" in stream.getvalue()
def test_run_plan_json_mode() -> None:
output, stream = _output()
output.json_mode = True
plan = Plan(backend="systemd", action="create")
backend = _DummyBackend(MutationResult(changed=True))
mutate_ops.run_plan(backend, plan, output, Interaction(output=output, assume_yes=True))
assert '"action": "create"' in stream.getvalue()
def _doctor_runner() -> FakeRunner:
def systemctl(argv, _input):
if "is-system-running" in argv:
return ("running\n", "")
return ("", "")
def crontab(argv, _input):
if "-V" in argv:
return ("cronie 1.7.2\n", "")
return ("", "")
return FakeRunner(
handlers={
"systemctl": systemctl,
"crontab": crontab,
"loginctl": lambda argv, _input: ("Linger=no\n", ""),
},
available={"systemctl", "systemd-analyze", "crontab", "loginctl"},
)
def test_doctor_human() -> None:
runner = _doctor_runner()
output, stream = _output()
doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner))
text = stream.getvalue()
assert "Cronie" in text
assert "usable" in text
def test_doctor_json() -> None:
runner = _doctor_runner()
output, stream = _output()
output.json_mode = True
doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner))
assert '"result": "usable"' in stream.getvalue()
def test_systemd_backend_discovery_parses_units() -> None:
timer = (
"# Managed by schedls\n"
"# Name: backup\n"
"[Unit]\nDescription=foo\n[Timer]\nOnCalendar=*-*-* 02:00:00\nPersistent=true\nUnit=schedls-backup.service\n"
)
service = '[Service]\nExecStart="/usr/local/bin/backup" "/srv/data"\n'
with tempfile.TemporaryDirectory() as tmp:
timer_path = os.path.join(tmp, "schedls-backup.timer")
service_path = os.path.join(tmp, "schedls-backup.service")
with open(timer_path, "w") as handle:
handle.write(timer)
with open(service_path, "w") as handle:
handle.write(service)
def systemctl(argv, _input):
if "is-system-running" in argv:
return ("running\n", "")
if "list-unit-files" in argv or "list-units" in argv:
return ("schedls-backup.timer enabled\n", "")
if "show" in argv:
unit = argv[argv.index("show") + 1]
if unit.endswith(".timer"):
return (
f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n"
"UnitFileState=enabled\nActiveState=active\nSubState=waiting\n"
"NextElapseUSecRealtime=1758700800000000\nTriggers=schedls-backup.service\n",
"",
)
return (
f"Id={unit}\nLoadState=loaded\nFragmentPath={service_path}\nResult=success\n",
"",
)
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
backend = SystemdBackend(runner)
jobs = backend.discover([Scope.USER])
assert len(jobs) == 1
job = jobs[0]
assert job.name == "backup"
assert job.managed is True
assert job.schedule.expression == "*-*-* 02:00:00"
assert job.command.argv == ("/usr/local/bin/backup", "/srv/data")
assert job.enabled is True
assert job.next_run is not None
def test_plan_update_refuses_unmanaged() -> None:
runner = FakeRunner()
backend = SystemdBackend(runner)
unmanaged = replace(_job("certbot"), managed=False)
spec = JobSpec(
name="certbot",
backend=Backend.SYSTEMD,
scope=Scope.USER,
command=Command(argv=("/bin/true",)),
calendar=("*-*-* 01:00:00",),
)
with pytest.raises(SafetyRefusalError):
backend.plan_update(unmanaged, spec)
def _managed_job(name: str = "backup", *, scope: Scope = Scope.USER, service_unit: str | None = None) -> ScheduledJob:
return replace(
_job(name),
scope=scope,
systemd=SystemdDetails(
timer_unit=f"schedls-{name}.timer",
service_unit=service_unit or f"schedls-{name}.service",
on_calendar=("*-*-* 02:00:00",),
),
)
def _spec(name: str = "backup") -> JobSpec:
return JobSpec(
name=name,
backend=Backend.SYSTEMD,
scope=Scope.USER,
command=Command(argv=("/bin/true",)),
calendar=("*-*-* 03:00:00",),
)
def _analyze_runner() -> FakeRunner:
def analyze(argv, _input):
if "calendar" in argv:
return ("Normalized form: *-*-* 03:00:00\n", "")
return ("", "")
return FakeRunner(handlers={"systemd-analyze": analyze}, available={"systemd-analyze"})
def test_plan_update_derives_unit_names(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
backend = SystemdBackend(_analyze_runner())
job = _managed_job(service_unit="../../../../tmp/evil.service")
plan = backend.plan_update(job, _spec())
expected_dir = os.path.join(str(tmp_path), "systemd", "user")
assert sorted(change.path for change in plan.files) == [
os.path.join(expected_dir, "schedls-backup.service"),
os.path.join(expected_dir, "schedls-backup.timer"),
]
assert "evil" not in " ".join(change.path for change in plan.files)
def test_verify_units_rejects_traversal() -> None:
backend = SystemdBackend(_analyze_runner())
with pytest.raises(SafetyRefusalError):
backend._verify_units({"../../evil.service": "content"})
def test_unit_dir_rejects_relative_xdg(monkeypatch) -> None:
monkeypatch.setenv("XDG_CONFIG_HOME", "relative")
backend = SystemdBackend(FakeRunner())
with pytest.raises(SafetyRefusalError):
backend._unit_dir(Scope.USER)
def test_plan_update_requires_scope(monkeypatch) -> None:
monkeypatch.setattr(os, "geteuid", lambda: 1000)
backend = SystemdBackend(_analyze_runner())
with pytest.raises(SafetyRefusalError):
backend.plan_update(_managed_job(scope=Scope.SYSTEM), _spec())
def test_validate_spec_rejects_unsafe_calendar() -> None:
backend = SystemdBackend(FakeRunner())
spec = replace(_spec(), calendar=("-x",))
with pytest.raises(InvalidScheduleError):
backend._validate_spec(spec)
def test_validate_spec_rejects_unsafe_jitter() -> None:
backend = SystemdBackend(_analyze_runner())
for jitter in ("5min\nExecStart=/bin/true", "-5min"):
with pytest.raises(InvalidScheduleError):
backend._validate_spec(replace(_spec(), jitter=jitter))
def test_apply_rolls_back_on_keyboard_interrupt(tmp_path) -> None:
target = tmp_path / "schedls-backup.service"
raised = []
def systemctl(argv, _input):
if not raised:
raised.append(True)
raise KeyboardInterrupt
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
backend = SystemdBackend(runner)
plan = Plan(backend="systemd", action="update")
plan.files = [FileChange(str(target), "content", mode=0o600)]
plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")]
plan.payload = {"scope": Scope.USER, "snapshots": {}}
with pytest.raises(KeyboardInterrupt):
backend.apply(plan)
assert not target.exists()
def test_rollback_disables_timer_on_create_failure(tmp_path) -> None:
target = tmp_path / "schedls-backup.timer"
def systemctl(argv, _input):
if "daemon-reload" in argv:
return (1, "", "boom")
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
backend = SystemdBackend(runner)
plan = Plan(backend="systemd", action="create")
plan.files = [FileChange(str(target), "content", mode=0o600)]
plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")]
plan.payload = {"scope": Scope.USER, "timer_unit": "schedls-backup.timer", "snapshots": {}}
with pytest.raises(OperationalError):
backend.apply(plan)
assert any("disable" in call[0] for call in runner.calls)
assert not target.exists()
def test_logs_rejects_unsafe_service_unit() -> None:
backend = SystemdBackend(FakeRunner(available={"journalctl"}))
job = _managed_job(service_unit="--output=json.service")
with pytest.raises(SafetyRefusalError):
backend.logs(job, lines=5, since=None)
def test_logs_returns_journal_text() -> None:
runner = FakeRunner(
handlers={"journalctl": lambda argv, _input: ("hello\n", "")},
available={"journalctl"},
)
backend = SystemdBackend(runner)
assert backend.logs(_managed_job(), lines=5, since="2 hours ago") == "hello\n"
argv = runner.calls[0][0]
assert "--since" in argv and "--lines=5" in argv
def test_prefix_named_unit_without_marker_is_unmanaged(tmp_path) -> None:
timer_path = tmp_path / "schedls-backup.timer"
timer_path.write_text("[Unit]\n[Timer]\nOnCalendar=*-*-* 02:00:00\n")
def systemctl(argv, _input):
if "is-system-running" in argv:
return ("running\n", "")
if "list-unit-files" in argv or "list-units" in argv:
return ("schedls-backup.timer enabled\n", "")
if "show" in argv:
unit = argv[argv.index("show") + 1]
if unit.endswith(".timer"):
return (f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n", "")
return ("", "")
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
jobs = SystemdBackend(runner).discover([Scope.USER])
assert len(jobs) == 1
assert jobs[0].managed is False
assert jobs[0].name == "schedls-backup"
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import io
import json
from datetime import UTC, datetime
from schedls.models import (
Backend,
Command,
CronDetails,
JobSource,
Schedule,
ScheduledJob,
ScheduleKind,
Scope,
SystemdDetails,
)
from schedls.output import Output, job_to_dict, jobs_document, sanitize_text
def _systemd_job() -> ScheduledJob:
return ScheduledJob(
name="backup",
backend=Backend.SYSTEMD,
scope=Scope.USER,
managed=True,
enabled=True,
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00", ("*-*-* 02:00:00",)),
command=Command(argv=("/usr/local/bin/backup", "/srv/data")),
source=JobSource("systemd user timer", path="/home/u/.config/systemd/user/schedls-backup.timer"),
next_run=datetime(2026, 9, 25, 2, 0, tzinfo=UTC),
systemd=SystemdDetails(
timer_unit="schedls-backup.timer",
service_unit="schedls-backup.service",
persistent=True,
),
)
def _cron_job() -> ScheduledJob:
return ScheduledJob(
name="cron-3",
backend=Backend.CRON,
scope=Scope.USER,
managed=False,
enabled=None,
schedule=Schedule(ScheduleKind.CRON, "0 4 * * 0"),
command=Command(raw="/usr/local/bin/cleanup"),
source=JobSource("current user's crontab", line=3),
cron=CronDetails(expression="0 4 * * 0", line=3),
)
def test_job_to_dict_systemd() -> None:
data = job_to_dict(_systemd_job())
assert data["name"] == "backup"
assert data["backend"] == "systemd"
assert data["managed"] is True
assert data["next_run"].endswith("+00:00")
assert data["systemd"]["persistent"] is True
def test_job_to_dict_cron_missing_data_is_null() -> None:
data = job_to_dict(_cron_job())
assert data["next_run"] is None
assert data["last_run"] is None
assert data["enabled"] is None
def test_jobs_document_schema_and_no_ansi() -> None:
document = jobs_document([_systemd_job(), _cron_job()], ["a warning"])
assert document["schema_version"] == 1
assert len(document["jobs"]) == 2
dumped = json.dumps(document)
assert "\x1b" not in dumped
assert "backup" in dumped
def test_output_table_and_key_values() -> None:
stream = io.StringIO()
output = Output(json_mode=False, color="never", stdout=stream)
output.table(["NAME", "BACKEND"], [["backup", "systemd"], ["cleanup", "cron"]])
text = stream.getvalue()
assert "NAME" in text
assert "backup" in text
assert "\x1b" not in text
def test_output_json_mode() -> None:
stream = io.StringIO()
output = Output(json_mode=True, color="never", stdout=stream)
output.emit_json({"schema_version": 1, "jobs": []})
assert json.loads(stream.getvalue())["schema_version"] == 1
def test_sanitize_text_escapes_control_characters() -> None:
text = "a\x1b[31mred\x1b[0m\nb\tc\x00d\x7f"
assert sanitize_text(text) == "a\\x1b[31mred\\x1b[0m\nb\tc\\x00d\\x7f"
def test_output_write_is_verbatim() -> None:
stream = io.StringIO()
output = Output(color="never", stdout=stream)
output.write("line one\nline two")
assert stream.getvalue() == "line one\nline two"
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
import io
import pytest
from schedls.cli import _spec_for_edit, _spec_for_new, _wizard_edit, _wizard_new, build_parser
from schedls.errors import ConfirmationRequiredError, InvalidScheduleError
from schedls.models import (
Backend,
Command,
JobSource,
Schedule,
ScheduledJob,
ScheduleKind,
Scope,
SystemdDetails,
)
from schedls.output import Output
from schedls.prompt import Prompter
class FakeInput:
def __init__(self, answers: list[str]) -> None:
self.answers = list(answers)
self.prompts: list[str] = []
def __call__(self, label: str) -> str:
self.prompts.append(label)
if not self.answers:
raise EOFError
return self.answers.pop(0)
class FakeStdin:
def __init__(self, *, isatty: bool) -> None:
self._isatty = isatty
def isatty(self) -> bool:
return self._isatty
def make_prompter(answers: list[str], *, calendar_validator=None) -> tuple[Prompter, FakeInput]:
fake = FakeInput(answers)
output = Output(stdout=io.StringIO(), stderr=io.StringIO())
return Prompter(output=output, input_fn=fake, calendar_validator=calendar_validator), fake
def _systemd_job() -> ScheduledJob:
return ScheduledJob(
name="backup",
backend=Backend.SYSTEMD,
scope=Scope.USER,
managed=True,
enabled=True,
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
command=Command(argv=("/usr/local/bin/backup",)),
source=JobSource(detail="systemd user timer"),
systemd=SystemdDetails(on_calendar=("*-*-* 02:00:00",)),
)
def test_text_requires_a_value() -> None:
prompter, fake = make_prompter(["", "backup"])
assert prompter.text("Name") == "backup"
assert len(fake.prompts) == 2
def test_text_uses_default_on_blank() -> None:
prompter, _ = make_prompter([""])
assert prompter.text("Name", default="backup") == "backup"
def test_optional_text() -> None:
prompter, _ = make_prompter(["", ""])
assert prompter.optional_text("Jitter") is None
assert prompter.optional_text("Jitter", default="5min") == "5min"
def test_yes_no_rejects_unknown_answers() -> None:
prompter, _ = make_prompter(["maybe", "yes"])
assert prompter.yes_no("Continue?") is True
def test_choice_by_number_and_default() -> None:
prompter, _ = make_prompter(["9", "2", ""])
options = [("daily", "Daily"), ("weekly", "Weekly")]
assert prompter.choice("Schedule:", options, default="daily") == "weekly"
assert prompter.choice("Schedule:", options, default="daily") == "daily"
def test_command_builds_argv() -> None:
prompter, _ = make_prompter(["n", "/usr/bin/echo hello world"])
command = prompter.command()
assert command.shell is False
assert command.argv == ("/usr/bin/echo", "hello", "world")
def test_command_shell_mode() -> None:
prompter, _ = make_prompter(["y", "echo hi | cat"])
command = prompter.command()
assert command.shell is True
assert command.raw == "echo hi | cat"
def test_environment_collects_valid_entries() -> None:
prompter, _ = make_prompter(["A=1", "1BAD=2", "B=x=y", ""])
assert prompter.environment() == ["A=1", "B=x=y"]
def test_schedule_daily_reprompts_on_bad_time() -> None:
prompter, _ = make_prompter(["", "25:00", "02:30"])
assert prompter.schedule(Backend.SYSTEMD) == {"daily": "02:30"}
def test_schedule_weekly_and_monthly() -> None:
prompter, _ = make_prompter(["3", "Mon", "02:00"])
assert prompter.schedule(Backend.SYSTEMD) == {"weekly": ["Mon", "02:00"]}
prompter, _ = make_prompter(["4", "15", "06:00"])
assert prompter.schedule(Backend.SYSTEMD) == {"monthly": ["15", "06:00"]}
def test_schedule_custom_systemd_validates() -> None:
def validator(expression: str) -> None:
if expression != "daily":
raise InvalidScheduleError("bad expression")
prompter, _ = make_prompter(["5", "bogus", "daily"], calendar_validator=validator)
assert prompter.schedule(Backend.SYSTEMD) == {"calendar": ["daily"]}
def test_schedule_custom_cron() -> None:
prompter, _ = make_prompter(["5", "0 4 * * *"])
assert prompter.schedule(Backend.CRON) == {"cron_expr": "0 4 * * *"}
def test_require_terminal_rejects_non_tty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=False))
prompter, _ = make_prompter([])
with pytest.raises(ConfirmationRequiredError):
prompter.require_terminal()
def test_eof_raises_confirmation_required() -> None:
prompter, _ = make_prompter([])
with pytest.raises(ConfirmationRequiredError):
prompter.text("Name")
def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
args = build_parser().parse_args(["new", "-i"])
answers = ["backup", "", "n", "/usr/local/bin/backup /srv/data", "", "02:00", "n"]
prompter, _ = make_prompter(answers)
args, tail = _wizard_new(args, [], prompter)
spec = _spec_for_new(args, tail)
assert spec.name == "backup"
assert spec.backend is Backend.SYSTEMD
assert spec.calendar == ("*-*-* 02:00:00",)
assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data")
def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
args = build_parser().parse_args(["new", "-i"])
answers = ["cleanup", "2", "n", "/usr/local/bin/cleanup", "", "04:00"]
prompter, _ = make_prompter(answers)
args, tail = _wizard_new(args, [], prompter)
spec = _spec_for_new(args, tail)
assert spec.backend is Backend.CRON
assert spec.cron_expression == "0 4 * * *"
def test_wizard_new_skips_provided_fields(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00"])
prompter, fake = make_prompter(["n"])
args, tail = _wizard_new(args, ["/bin/true"], prompter)
spec = _spec_for_new(args, tail)
assert spec.calendar == ("*-*-* 02:00:00",)
assert fake.prompts == ["Set advanced timer options? [y/N] "]
def test_wizard_edit_changes_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
job = _systemd_job()
args = build_parser().parse_args(["edit", "backup", "-i"])
answers = ["n", "y", "", "03:00", "n"]
prompter, _ = make_prompter(answers)
args, tail = _wizard_edit(job, args, [], prompter)
spec = _spec_for_edit(job, args, tail)
assert spec.calendar == ("*-*-* 03:00:00",)
def test_wizard_edit_keeps_changes_optional(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
job = _systemd_job()
args = build_parser().parse_args(["edit", "backup", "-i"])
prompter, _ = make_prompter(["n", "n", "n"])
args, tail = _wizard_edit(job, args, [], prompter)
spec = _spec_for_edit(job, args, tail)
assert spec.calendar == ("*-*-* 02:00:00",)
assert tail == []
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import json
import shlex
import subprocess
import sys
import pytest
from schedls.errors import InvalidScheduleError, SafetyRefusalError
from schedls.models import Command
from schedls.renderers import cron as renderer
NASTY_ARGS = [
"plain",
"a b",
"a\tb",
'a"b',
"a'b",
"a\\b",
"a\\\\b",
"$HOME",
"$(touch /tmp/pwned)",
"`touch /tmp/pwned`",
"%",
"100%",
"a%b",
"a\\%b",
"a\\\\%b",
"",
" leading",
"trailing ",
"semi;colon",
"pipe|cmd",
"new&line",
">out",
"unicode-\u00e9\u4e2d\U0001f600",
]
def cron_resolve(text: str) -> str:
"""Emulate Cronie's ``%`` processing of a command field."""
out: list[str] = []
index = 0
while index < len(text):
char = text[index]
if char == "\\" and index + 1 < len(text) and text[index + 1] == "%":
out.append("%")
index += 2
continue
if char == "%":
out.append("\n")
index += 1
continue
out.append(char)
index += 1
return "".join(out)
def test_escape_percent() -> None:
assert renderer.escape_percent("100%") == "100\\%"
assert renderer.escape_percent("a\\%b") == "a\\\\%b"
def test_render_command_argv() -> None:
rendered = renderer.render_command(Command(argv=("/usr/local/bin/backup", "/srv/My Data")))
assert rendered == "/usr/local/bin/backup '/srv/My Data'"
def test_render_command_shell() -> None:
rendered = renderer.render_command(Command(shell=True, raw="echo 50% > /tmp/x"))
assert rendered == "echo 50\\% > /tmp/x"
def test_expression_percent_not_double_escaped() -> None:
line = renderer.render_line("0 2 * * *", Command(argv=("/bin/echo", "a%b")))
assert line == "0 2 * * * /bin/echo a\\%b"
@pytest.mark.parametrize(
"bad",
["", "0 2 * *", "0 2 * * * *", "60 2 * * *", "0 25 * * *", "0 2 32 * *", "@bogus", "a b c d e"],
)
def test_invalid_expressions(bad: str) -> None:
with pytest.raises(InvalidScheduleError):
renderer.validate_expression(bad)
@pytest.mark.parametrize(
"good",
["* * * * *", "0 2 * * *", "*/5 * * * *", "0 2 * * 1-5", "0 2 1,15 * 5", "@daily", "@reboot"],
)
def test_valid_expressions(good: str) -> None:
assert renderer.validate_expression(good)
@pytest.mark.parametrize("arg", NASTY_ARGS)
def test_argv_round_trip_through_shell(tmp_path, arg: str) -> None:
dump = tmp_path / "dump.py"
dump.write_text("import json,sys\nopen(sys.argv[1],'w').write(json.dumps(sys.argv[2:]))\n")
out = tmp_path / "out.json"
command = Command(argv=(sys.executable, str(dump), str(out), arg))
serialized = renderer.render_command(command)
resolved = cron_resolve(serialized)
subprocess.run(["/bin/sh", "-c", resolved], check=True)
received = json.loads(out.read_text())
assert received == [arg]
def test_no_command_injection(tmp_path) -> None:
marker = tmp_path / "pwned"
command = Command(argv=("/bin/true", f"x; touch {marker}", f"$(touch {marker})", f"`touch {marker}`"))
resolved = cron_resolve(renderer.render_command(command))
subprocess.run(["/bin/sh", "-c", resolved], check=True)
assert not marker.exists()
def test_newline_rejected() -> None:
with pytest.raises(SafetyRefusalError):
renderer.render_command(Command(argv=("/bin/echo", "a\nb")))
def test_managed_block() -> None:
block = renderer.render_block("backup", ["0 2 * * * /bin/true"])
assert block == ("# schedls:begin name=backup\n0 2 * * * /bin/true\n# schedls:end name=backup\n")
def test_shlex_reference() -> None:
assert shlex.quote("a b") == "'a b'"
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
from pathlib import Path
import pytest
from schedls.errors import SafetyRefusalError
from schedls.models import Backend, Command, JobSpec, Scope
from schedls.renderers import systemd as renderer
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "systemd"
NASTY_ARGS = [
"plain",
"a b",
"a\tb",
'a"b',
"a'b",
"a\\b",
"a\\\\b",
"$HOME",
"$$",
"$(touch /tmp/pwned)",
"`touch /tmp/pwned`",
"%n",
"%%",
"%i",
"",
" leading",
"trailing ",
"semi;colon",
"pipe|cmd",
"new&line",
"unicode-\u00e9\u4e2d\U0001f600",
"-dash",
"@at",
"!bang",
]
def test_quote_basic() -> None:
assert renderer.quote_systemd_arg("plain") == '"plain"'
assert renderer.quote_systemd_arg("a b") == '"a b"'
assert renderer.quote_systemd_arg("") == '""'
assert renderer.quote_systemd_arg("$HOME") == '"$$HOME"'
assert renderer.quote_systemd_arg("%n") == '"%%n"'
assert renderer.quote_systemd_arg('a"b') == '"a\\"b"'
assert renderer.quote_systemd_arg("a\\b") == '"a\\\\b"'
@pytest.mark.parametrize("arg", NASTY_ARGS)
def test_argv_round_trip(arg: str) -> None:
command = Command(argv=(arg,))
rendered = renderer.render_exec_start(command)
value = rendered.split("=", 1)[1]
parsed = renderer.parse_exec_start(value)
assert parsed.argv == (arg,)
def test_multi_argv_round_trip() -> None:
command = Command(argv=tuple(NASTY_ARGS))
value = renderer.render_exec_start(command).split("=", 1)[1]
assert renderer.parse_exec_start(value).argv == tuple(NASTY_ARGS)
def test_shell_mode_round_trip() -> None:
command = Command(shell=True, raw="generate-report | gzip > /srv/report.gz")
value = renderer.render_exec_start(command).split("=", 1)[1]
parsed = renderer.parse_exec_start(value)
assert parsed.shell is True
assert parsed.raw == "generate-report | gzip > /srv/report.gz"
def test_shell_mode_quotes_script() -> None:
command = Command(shell=True, raw="echo $HOME > /tmp/x")
rendered = renderer.render_exec_start(command)
assert rendered.startswith('ExecStart=/bin/sh -c "')
assert "$$HOME" in rendered
@pytest.mark.parametrize("bad", ["a\nb", "a\rb", "a\x00b"])
def test_control_characters_rejected(bad: str) -> None:
with pytest.raises(SafetyRefusalError):
renderer.render_exec_start(Command(argv=(bad,)))
def test_golden_service() -> None:
spec = JobSpec(
name="backup",
backend=Backend.SYSTEMD,
scope=Scope.USER,
command=Command(argv=("/usr/local/bin/backup", "/srv/data")),
calendar=("*-*-* 02:00:00",),
persistent=True,
)
files = renderer.render_units(spec, service_unit="schedls-backup.service", timer_unit="schedls-backup.timer")
assert files["schedls-backup.service"] == (FIXTURES / "basic.service").read_text()
assert files["schedls-backup.timer"] == (FIXTURES / "basic.timer").read_text()
def test_timer_multiple_calendars() -> None:
spec = JobSpec(
name="multi",
backend=Backend.SYSTEMD,
scope=Scope.USER,
command=Command(argv=("/bin/true",)),
calendar=("Mon..Fri 02:00:00", "Sat,Sun 04:00:00"),
)
timer = renderer.render_timer(spec, "schedls-multi.timer", "schedls-multi.service")
assert "OnCalendar=Mon..Fri 02:00:00" in timer
assert "OnCalendar=Sat,Sun 04:00:00" in timer
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import os
import stat
import pytest
from schedls.errors import InvalidNameError, SafetyRefusalError
from schedls.runner import CommandRunner
from schedls.security import (
atomic_write_text,
check_replaceable,
check_trusted_directory,
is_managed_unit,
is_safe_unit_name,
remove_file,
resolve_helper,
unit_name,
validate_managed_unit_name,
validate_name,
)
@pytest.mark.parametrize(
"name",
["backup", "postgres-backup", "report.daily", "sync_home", "a", "A1._-", "x" * 64],
)
def test_valid_names(name: str) -> None:
assert validate_name(name) == name
@pytest.mark.parametrize(
"name",
["", "../foo", "foo/bar", "name with spaces", "$(command)", ".hidden", "-lead", "x" * 65, "foo\nbar", "foo\n"],
)
def test_invalid_names(name: str) -> None:
with pytest.raises(InvalidNameError):
validate_name(name)
def test_unit_name() -> None:
assert unit_name("backup", "timer") == "schedls-backup.timer"
assert is_managed_unit("schedls-backup.timer")
assert not is_managed_unit("certbot.timer")
with pytest.raises(InvalidNameError):
unit_name("../evil", "timer")
def test_atomic_write_and_remove(tmp_path) -> None:
target = tmp_path / "file.txt"
atomic_write_text(str(target), "hello\n", mode=0o600)
assert target.read_text() == "hello\n"
assert stat.S_IMODE(target.stat().st_mode) == 0o600
assert remove_file(str(target)) is True
assert not target.exists()
assert remove_file(str(target)) is False
def test_atomic_write_refuses_symlink(tmp_path) -> None:
real = tmp_path / "real.txt"
real.write_text("secret")
link = tmp_path / "link.txt"
link.symlink_to(real)
with pytest.raises(SafetyRefusalError):
atomic_write_text(str(link), "overwrite")
assert real.read_text() == "secret"
def test_remove_refuses_symlink(tmp_path) -> None:
real = tmp_path / "real.txt"
real.write_text("secret")
link = tmp_path / "link.txt"
link.symlink_to(real)
with pytest.raises(SafetyRefusalError):
remove_file(str(link))
def test_check_replaceable_ownership(tmp_path) -> None:
target = tmp_path / "file.txt"
target.write_text("x")
check_replaceable(str(target), expected_uid=os.getuid())
with pytest.raises(SafetyRefusalError):
check_replaceable(str(target), expected_uid=os.getuid() + 99999)
def test_resolve_helper_rejects_missing() -> None:
assert resolve_helper("definitely-not-a-real-helper-xyz") is None
def test_resolve_helper_rejects_world_writable_dir(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "evilhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
helper_dir.chmod(0o777)
monkeypatch.setenv("PATH", str(helper_dir))
assert resolve_helper("evilhelper") is None
def test_resolve_helper_rejects_group_writable_file(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "evilhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o775)
monkeypatch.setenv("PATH", str(helper_dir))
assert resolve_helper("evilhelper") is None
def test_resolve_helper_rejects_user_owned_dir_when_root(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "evilhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
monkeypatch.setenv("PATH", str(helper_dir))
monkeypatch.setattr(os, "geteuid", lambda: 0)
assert resolve_helper("evilhelper") is None
def test_resolve_helper_accepts_own_dir_when_not_root(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "goodhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
monkeypatch.setenv("PATH", str(helper_dir))
monkeypatch.setattr(os, "geteuid", lambda: 4242)
assert resolve_helper("goodhelper") == str(helper)
def test_runner_refuses_untrusted_absolute_helper(tmp_path, monkeypatch) -> None:
helper = tmp_path / "evil"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
monkeypatch.setattr(os, "geteuid", lambda: 0)
with pytest.raises(SafetyRefusalError):
CommandRunner().run([str(helper)])
def test_runner_refuses_relative_executable() -> None:
with pytest.raises(SafetyRefusalError):
CommandRunner().run(["bin/evil"])
def test_check_trusted_directory_owner_and_mode(tmp_path) -> None:
check_trusted_directory(str(tmp_path), expected_uid=os.getuid())
with pytest.raises(SafetyRefusalError):
check_trusted_directory(str(tmp_path), expected_uid=os.getuid() + 99999)
tmp_path.chmod(0o777)
with pytest.raises(SafetyRefusalError):
check_trusted_directory(str(tmp_path), expected_uid=os.getuid())
def test_validate_managed_unit_name() -> None:
assert validate_managed_unit_name("schedls-backup.timer") == "schedls-backup.timer"
assert validate_managed_unit_name("schedls-a_b.service") == "schedls-a_b.service"
for unit in (
"../../etc/passwd",
"schedls-../x.timer",
"certbot.timer",
"schedls-x.timer/../y",
"schedls-x.timer\n",
):
with pytest.raises(SafetyRefusalError):
validate_managed_unit_name(unit)
def test_is_safe_unit_name() -> None:
assert is_safe_unit_name("certbot.service")
assert is_safe_unit_name("foo@bar.timer")
assert not is_safe_unit_name("--output=json")
assert not is_safe_unit_name("../../evil.service")
assert not is_safe_unit_name("unit with spaces.service")
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta, timezone
import pytest
from schedls import timefmt
@pytest.mark.parametrize(
("value", "seconds"),
[
("1s", 1.0),
("30m", 1800.0),
("1h", 3600.0),
("2h30m", 9000.0),
("1d", 86400.0),
("1w", 604800.0),
("500ms", 0.5),
("1min", 60.0),
],
)
def test_duration_seconds(value: str, seconds: float) -> None:
assert timefmt.duration_seconds(value) == pytest.approx(seconds)
@pytest.mark.parametrize("bad", ["", "abc", "1x", "h", "1h2x"])
def test_invalid_duration(bad: str) -> None:
with pytest.raises(ValueError):
timefmt.duration_seconds(bad)
assert not timefmt.is_valid_duration(bad)
def test_parse_microseconds() -> None:
parsed = timefmt.parse_systemd_timestamp("1758700800000000")
assert parsed is not None
assert parsed.year == 2025
def test_parse_pretty() -> None:
parsed = timefmt.parse_systemd_timestamp("Thu 2026-09-24 06:49:28 BST")
assert parsed is not None
assert (parsed.year, parsed.month, parsed.day) == (2026, 9, 24)
assert parsed.tzinfo is not None
def test_parse_placeholders() -> None:
assert timefmt.parse_systemd_timestamp("") is None
assert timefmt.parse_systemd_timestamp("n/a") is None
assert timefmt.parse_systemd_timestamp("0") is None
def test_format_datetime_round_trip_zone() -> None:
dt = datetime(2026, 9, 25, 2, 0, 0, tzinfo=timezone(timedelta(hours=1)))
text = timefmt.format_datetime(dt)
assert text.startswith("Fri 25 Sep 2026 02:00:00")
def test_format_short_relative() -> None:
now = datetime(2026, 9, 24, 12, 0, tzinfo=UTC)
today = datetime(2026, 9, 24, 18, 0, tzinfo=UTC)
tomorrow = datetime(2026, 9, 25, 3, 0, tzinfo=UTC)
assert timefmt.format_short(today, now=now, utc=True) == "today 18:00"
assert timefmt.format_short(tomorrow, now=now, utc=True) == "tomorrow 03:00"
def test_isoformat_has_offset() -> None:
dt = datetime(2026, 9, 25, 2, 0, tzinfo=UTC)
assert timefmt.isoformat(dt).endswith("+00:00")