svcforge: reference implementation
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped

Complete working build of the system learn-python/ teaches.
164 tests, mypy --strict clean, domain coverage 99%.
This commit is contained in:
Nguyen Minh Phuc
2026-07-17 10:44:54 +00:00
commit 50c2fe2a1e
102 changed files with 12018 additions and 0 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
"""Unit tests for retry backoff math."""
from datetime import UTC, datetime, timedelta
import pytest
from hypothesis import given
from hypothesis import strategies as st
from svcforge_core.domain.backoff import next_attempt_at
T = datetime(2026, 7, 17, 12, 0, 0, tzinfo=UTC)
def test_attempt_zero_full_jitter_is_base() -> None:
assert next_attempt_at(0, now=T, rand=lambda: 1.0) == T + timedelta(seconds=2)
def test_zero_jitter_returns_now() -> None:
assert next_attempt_at(0, now=T, rand=lambda: 0.0) == T
def test_negative_attempt_raises_value_error() -> None:
with pytest.raises(ValueError, match="attempt"):
next_attempt_at(-1, now=T)
@given(attempt=st.integers(min_value=0, max_value=64), r=st.floats(min_value=0.0, max_value=1.0))
def test_delay_is_always_within_zero_and_cap(attempt: int, r: float) -> None:
cap_s = 300.0
got = next_attempt_at(attempt, now=T, cap_s=cap_s, rand=lambda: r)
delay = (got - T).total_seconds()
assert 0.0 <= delay <= cap_s
@given(attempt=st.integers(min_value=0, max_value=63))
def test_delay_is_non_decreasing_in_attempt(attempt: int) -> None:
def ceiling_of(a: int) -> float:
return (next_attempt_at(a, now=T, rand=lambda: 1.0) - T).total_seconds()
assert ceiling_of(attempt) <= ceiling_of(attempt + 1)
+155
View File
@@ -0,0 +1,155 @@
"""Unit tests for catalog loading and validation."""
import textwrap
from pathlib import Path
import pytest
from svcforge_core.domain.catalog import CatalogError, load_catalog
from svcforge_core.domain.models import CatalogEntry
VALID_YAML = textwrap.dedent("""
services:
redis:
chart: bitnamilegacy/redis
chart_version: 20.6.2
sizes:
small:
replicas: 1
resources:
requests: {cpu: 100m, memory: 256Mi}
medium:
replicas: 3
resources:
requests: {cpu: 500m, memory: 1Gi}
postgres:
chart: bitnamilegacy/postgresql
chart_version: 16.4.5
sizes:
small:
replicas: 1
resources:
requests: {cpu: 250m, memory: 512Mi}
""")
MISSING_CHART_VERSION_YAML = textwrap.dedent("""
services:
redis:
chart: bitnamilegacy/redis
sizes:
small:
replicas: 1
resources: {}
""")
ZERO_REPLICAS_YAML = textwrap.dedent("""
services:
redis:
chart: bitnamilegacy/redis
chart_version: 20.6.2
sizes:
small:
replicas: 0
resources: {}
""")
def _write(tmp_path: Path, body: str) -> Path:
path = tmp_path / "catalog.yaml"
path.write_text(body)
return path
def test_valid_yaml_loads_to_catalog_entries(tmp_path: Path) -> None:
catalog = load_catalog(_write(tmp_path, VALID_YAML))
assert set(catalog) == {"redis", "postgres"}
assert all(isinstance(entry, CatalogEntry) for entry in catalog.values())
redis = catalog["redis"]
assert redis.service_type == "redis"
assert redis.chart_version == "20.6.2"
assert set(redis.sizes) == {"small", "medium"}
assert redis.sizes["medium"].replicas == 3
def test_missing_chart_version_raises_catalog_error(tmp_path: Path) -> None:
with pytest.raises(CatalogError) as excinfo:
load_catalog(_write(tmp_path, MISSING_CHART_VERSION_YAML))
assert excinfo.value.key == "redis"
assert "redis" in str(excinfo.value)
def test_zero_replicas_raises_catalog_error(tmp_path: Path) -> None:
with pytest.raises(CatalogError) as excinfo:
load_catalog(_write(tmp_path, ZERO_REPLICAS_YAML))
assert excinfo.value.key == "redis"
def test_missing_file_raises_catalog_error(tmp_path: Path) -> None:
with pytest.raises(CatalogError, match="cannot read catalog"):
load_catalog(tmp_path / "nope.yaml")
def test_unparseable_yaml_raises_catalog_error(tmp_path: Path) -> None:
with pytest.raises(CatalogError, match="not valid YAML"):
load_catalog(_write(tmp_path, "services: [unclosed\n"))
def test_scalar_root_raises_catalog_error(tmp_path: Path) -> None:
with pytest.raises(CatalogError, match="must be a mapping"):
load_catalog(_write(tmp_path, "just-a-string\n"))
def test_non_mapping_services_raises_catalog_error(tmp_path: Path) -> None:
with pytest.raises(CatalogError, match="'services' must be a mapping"):
load_catalog(_write(tmp_path, "services:\n - redis\n"))
def test_non_mapping_entry_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
with pytest.raises(CatalogError) as excinfo:
load_catalog(_write(tmp_path, "services:\n redis: just-a-string\n"))
assert excinfo.value.key == "redis"
assert "must be a mapping" in str(excinfo.value)
def test_non_string_field_key_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
"""A non-string YAML key inside an entry breaks `**body`; it must surface as CatalogError."""
body = textwrap.dedent("""
services:
redis:
1: oops
chart: bitnamilegacy/redis
chart_version: 20.6.2
sizes: {}
""")
with pytest.raises(CatalogError) as excinfo:
load_catalog(_write(tmp_path, body))
assert excinfo.value.key == "redis"
def test_bare_mapping_without_services_key_is_accepted(tmp_path: Path) -> None:
"""The top-level `services:` wrapper is optional; a bare service_type mapping also loads."""
body = textwrap.dedent("""
redis:
chart: bitnamilegacy/redis
chart_version: 20.6.2
sizes:
small:
replicas: 1
resources: {}
""")
catalog = load_catalog(_write(tmp_path, body))
assert set(catalog) == {"redis"}
def test_repo_catalog_yaml_is_valid() -> None:
catalog = load_catalog(Path(__file__).parents[2] / "catalog.yaml")
assert set(catalog) == {"elasticsearch", "redis", "postgres"}
for entry in catalog.values():
assert set(entry.sizes) == {"small", "medium"}
+272
View File
@@ -0,0 +1,272 @@
"""obs.py: the three things that are wrong by default.
Not tested here: that structlog logs, that prometheus counts, that OTEL traces. Those are
the libraries' tests. What is tested is every place where the default is a bug — the
histogram buckets, the context that does not cross a queue, and the contextvars that leak
between tasks.
"""
from __future__ import annotations
import io
import json
import logging
import re
from collections.abc import Iterator
from typing import Any
from uuid import uuid4
import pytest
import structlog
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from prometheus_client import REGISTRY
from svcforge_core import obs
from svcforge_core.settings import Settings
# W3C traceparent: version-traceid-spanid-flags.
#
# The flags byte is matched loosely and the sampled bit checked separately, on purpose.
# Module 7's acceptance line says `00-<32 hex>-<16 hex>-01`, and current SDKs emit `-03`:
# bit 0x01 is `sampled`, and bit 0x02 is `random-trace-id` from trace-context level 2. An
# assertion pinned to `-01` fails on a spec revision that changed nothing we care about.
# What we care about is the trace being sampled, which is bit 0x01 and nothing else.
TRACEPARENT_RE = re.compile(
r"^00-(?P<trace_id>[0-9a-f]{32})-(?P<span_id>[0-9a-f]{16})-(?P<flags>[0-9a-f]{2})$"
)
SAMPLED_BIT = 0x01
# A provision takes minutes. prometheus_client's default buckets end at 10 seconds.
DEFAULT_TOP_BUCKET = 10.0
def _settings(**over: object) -> Settings:
return Settings(pg_dsn="postgresql://u:p@localhost:5432/db", **over) # type: ignore[arg-type]
@pytest.fixture
def logs() -> Iterator[io.StringIO]:
"""setup() against a captured stream, restoring the process-wide config afterwards.
setup() is deliberately global and deliberately once-only, which makes it deliberately
awkward to test. Reaching into the module flag is the honest price of that; the
alternative is a seam that exists only for tests.
"""
stream = io.StringIO()
saved_handlers = logging.getLogger().handlers[:]
saved_level = logging.getLogger().level
saved_config = structlog.get_config()
obs._configured = False
obs.setup("test-service", _settings())
# Re-point the handler setup() installed at our buffer; everything else it configured —
# processors, formatter, the JSON renderer last — is exactly what production gets.
handler = logging.getLogger().handlers[0]
assert isinstance(handler, logging.StreamHandler)
handler.setStream(stream)
try:
yield stream
finally:
structlog.contextvars.clear_contextvars()
structlog.configure(**saved_config)
logging.getLogger().handlers = saved_handlers
logging.getLogger().setLevel(saved_level)
obs._configured = False
def _lines(stream: io.StringIO) -> list[dict[str, Any]]:
return [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()]
# --- Buckets ------------------------------------------------------------------------------
def test_provision_histogram_has_a_bucket_for_a_thirty_minute_provision() -> None:
"""The acceptance check, as a unit test: `le="1800"` exists.
With the library defaults the top finite bucket is 10s, every provision lands in +Inf,
and histogram_quantile interpolates across a bucket spanning 10s to infinity. The p95
it returns is not slow or fast, it is meaningless — and it is meaningless silently,
which is why this is asserted rather than eyeballed on a dashboard.
"""
bounds = obs.PROVISION_TIME._upper_bounds
assert 1800.0 in bounds
assert bounds[-1] == float("inf")
assert max(b for b in bounds if b != float("inf")) > DEFAULT_TOP_BUCKET
def test_provision_histogram_exports_the_1800_bucket() -> None:
"""Same claim, checked through the exposition format the scrape actually reads."""
obs.PROVISION_TIME.observe(42.0)
bucket = REGISTRY.get_sample_value("svcforge_provision_duration_seconds_bucket", {"le": "1800.0"})
assert bucket is not None
def test_metric_names_are_the_ones_the_alerts_query() -> None:
"""The four alerts are PromQL strings in values.yaml; nothing type-checks them.
A rename here is a silent alert that never fires again. This test is the link between
the chart's rules and the code.
"""
for name in (
"svcforge_tasks_claimed_total",
"svcforge_tasks_failed_total",
"svcforge_provision_duration_seconds",
"svcforge_queue_depth",
"svcforge_instances",
"svcforge_reconciler_last_tick_timestamp_seconds",
):
assert REGISTRY._names_to_collectors.get(name) is not None, f"{name} is queried by an alert"
# --- Trace context across the queue -------------------------------------------------------
def test_traceparent_round_trips_through_a_string() -> None:
"""inject -> a W3C string -> extract -> the same trace. This is the queue crossing."""
provider = TracerProvider()
tracer = provider.get_tracer("test")
with tracer.start_as_current_span("api.post") as span:
traceparent = obs.inject_traceparent()
api_trace_id = span.get_span_context().trace_id
assert traceparent is not None
match = TRACEPARENT_RE.match(traceparent)
assert match is not None, traceparent
assert match["trace_id"] == format(api_trace_id, "032x")
assert int(match["flags"], 16) & SAMPLED_BIT, "unsampled: the worker's span would be dropped"
# The worker's side: a fresh context, minutes later, in another process.
ctx = obs.context_from_traceparent(traceparent)
with tracer.start_as_current_span("worker.claim", context=ctx) as worker_span:
assert worker_span.get_span_context().trace_id == api_trace_id
def test_inject_returns_none_without_a_span() -> None:
"""A task the reconciler enqueued has no inbound request. Null column, not an error."""
assert obs.inject_traceparent() is None
def test_context_from_traceparent_survives_none_and_garbage() -> None:
"""A malformed traceparent must start a new trace, never fail a provision.
Extract does not raise on bad input — it returns a context with no span. Asserted here
because the alternative would be a tenant's provision failing over a telemetry header.
"""
for bad in (None, "", "not-a-traceparent", "00-tooshort-01"):
ctx = obs.context_from_traceparent(bad)
assert not trace.get_current_span(ctx).get_span_context().is_valid
# --- Contextvars --------------------------------------------------------------------------
def test_every_line_carries_instance_id_task_id_and_team(logs: io.StringIO) -> None:
"""The acceptance check: bind once at claim, and the keys ride on every line after."""
instance_id = uuid4()
obs.bind_task_context(instance_id, 42, "platform")
obs.get_logger("t").info("provision.started")
obs.get_logger("t").warning("helm.slow")
for line in _lines(logs):
assert line["instance_id"] == str(instance_id)
assert line["task_id"] == 42
assert line["team"] == "platform"
def test_foreign_stdlib_logs_are_json_and_carry_the_context(logs: io.StringIO) -> None:
"""psycopg and uvicorn log through stdlib `logging`, and their lines must parse too.
Without the ProcessorFormatter bridge these arrive as bare text on the same stdout, and
every one of them is a parse failure in the collector.
"""
obs.bind_task_context(uuid4(), 7, "payments")
logging.getLogger("some.library").warning("connection reset")
line = _lines(logs)[-1]
assert line["event"] == "connection reset"
assert line["task_id"] == 7
assert line["team"] == "payments"
def test_bind_task_context_clears_the_previous_task(logs: io.StringIO) -> None:
"""The bug this prevents: task 2's log line naming task 1's tenant.
A worker coroutine reuses its context across claim-loop iterations. Bind without
clearing and the stale instance_id survives into the next task, which means the log for
the incident you are debugging points at the wrong customer.
"""
obs.bind_task_context(uuid4(), 1, "team-a")
second = uuid4()
obs.bind_task_context(second, 2, "team-b")
obs.get_logger("t").info("claimed")
line = _lines(logs)[-1]
assert line["instance_id"] == str(second)
assert line["task_id"] == 2
assert line["team"] == "team-b"
def test_json_renderer_is_last_and_output_is_one_object_per_line(logs: io.StringIO) -> None:
"""JSONRenderer last in the chain, and nothing after it.
A processor appended after the renderer receives a `str` where it expects a dict and
raises. The symptom is not a crash — structlog's default is to fail the log call, so
the line simply never appears.
"""
obs.get_logger("t").info("hello", extra_key="value")
lines = _lines(logs)
assert len(lines) == 1
assert lines[0]["event"] == "hello"
assert lines[0]["extra_key"] == "value"
assert lines[0]["level"] == "info"
assert lines[0]["service"] == "test-service"
assert "timestamp" in lines[0]
def test_setup_is_idempotent(logs: io.StringIO) -> None:
"""Called twice, one handler, one line. Not a nicety: two handlers is two of every line.
Each service calls setup() from its entrypoint, and an entrypoint that imports another
entrypoint (the CLI shelling into the reconciler) calls it twice.
"""
obs.setup("test-service", _settings())
obs.setup("test-service", _settings())
# Count ours, not pytest's — its capture handler is on the root logger too.
ours = [
h
for h in logging.getLogger().handlers
if isinstance(h.formatter, structlog.stdlib.ProcessorFormatter)
]
assert len(ours) == 1
obs.get_logger("t").info("once")
assert len(_lines(logs)) == 1
def test_metrics_are_single_process_in_memory() -> None:
"""One process per pod, scale with replicas. Multiprocess mode is not in use.
`ValueClass` is prometheus_client's fork in the road, chosen at import from
PROMETHEUS_MULTIPROC_DIR: MutexValue keeps counters in memory, MultiProcessValue mmaps
them into a shared directory. This repo takes the other fix for `uvicorn --workers 4`
corrupting counters — one process per pod — so MutexValue is the correct answer, and a
stray PROMETHEUS_MULTIPROC_DIR in a Deployment's env would silently change it to the
other one along with the meaning of every gauge.
"""
from prometheus_client import values
assert values.ValueClass is values.MutexValue
# And the registry the services expose is the default in-memory one, not a
# MultiProcessCollector reading files off disk.
obs.QUEUE_DEPTH.set(3)
assert REGISTRY.get_sample_value("svcforge_queue_depth") == 3.0
+109
View File
@@ -0,0 +1,109 @@
"""Every Protocol, with both of its implementations, checked by mypy.
These functions have no assertions and cannot fail at runtime — the check happens under
`mypy --strict`. If FakeProvisioner drifts from Provisioner (a renamed parameter, a changed
return type), the type-check fails here rather than the fake quietly diverging from the real
adapter and the worker's fast tests proving nothing about production.
The test bodies exist so pytest runs the imports too: a Protocol satisfied statically but
broken at import time is still broken.
"""
from __future__ import annotations
from pathlib import Path
from svcforge_core.adapters.clock import Clock, SystemClock
from svcforge_core.adapters.helm import HelmProvisioner, Provisioner
from svcforge_core.adapters.notify import LogNotifier, Notifier, WebhookNotifier
from svcforge_core.adapters.redis import (
IdempotencyStore,
IdempotencyStoreProto,
InstanceCache,
InstanceCacheProto,
RateLimiter,
RateLimiterProto,
make_redis,
)
from svcforge_core.settings import Settings
from tests.fakes import (
FakeClock,
FakeIdempotencyStore,
FakeInstanceCache,
FakeNotifier,
FakeProvisioner,
FakeRateLimiter,
)
def take(p: Provisioner) -> None:
"""Accepts anything structurally a Provisioner. The whole assertion is the annotation."""
def take_clock(c: Clock) -> None: ...
def take_notifier(n: Notifier) -> None: ...
def test_provisioner_implementations_conform() -> None:
take(HelmProvisioner(kubeconfig=Path("/dev/null")))
take(FakeProvisioner())
print("ok")
def test_clock_implementations_conform() -> None:
from datetime import UTC, datetime
take_clock(SystemClock())
take_clock(FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC)))
print("ok")
def test_notifier_implementations_conform() -> None:
take_notifier(LogNotifier())
take_notifier(WebhookNotifier(url="https://example.invalid/hook"))
take_notifier(FakeNotifier())
print("ok")
def take_limiter(rl: RateLimiterProto) -> None: ...
def take_idempotency(store: IdempotencyStoreProto) -> None: ...
def take_cache(c: InstanceCacheProto) -> None: ...
def test_redis_implementations_conform() -> None:
"""A client at a closed port is still a Redis. Nothing here connects — no commands billed.
That is not a trick to keep the test fast; it is the module's thesis restated as a
fixture. Every one of these classes has to be constructible and callable with Redis
unreachable, because that is the state they are designed for.
"""
from datetime import UTC, datetime
settings = Settings(
# pydantic parses these strings into PostgresDsn/RedisDsn at runtime; the
# annotation names the parsed type, so the ignores sit on the arguments.
pg_dsn="postgresql://unused:unused@127.0.0.1:5432/unused", # type: ignore[arg-type]
redis_dsn="redis://127.0.0.1:1/0", # type: ignore[arg-type]
)
r = make_redis(settings)
assert r is not None
take_limiter(RateLimiter(r, limit=10, window_s=60))
take_limiter(FakeRateLimiter(10, 60, FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC))))
take_idempotency(IdempotencyStore(r))
take_idempotency(FakeIdempotencyStore())
take_cache(InstanceCache(r))
take_cache(FakeInstanceCache())
# redis_dsn=None EXPLICITLY. Omitting it does not mean "unset": pydantic-settings
# reads SVCFORGE_REDIS_DSN from the environment, so on any machine that has the real
# DSN exported this assertion sees a live Upstash client and fails — a green test that
# depends on your shell being empty is not a test.
assert make_redis(Settings(pg_dsn=settings.pg_dsn, redis_dsn=None)) is None
print("ok")
+4
View File
@@ -0,0 +1,4 @@
def test_import_core() -> None:
import svcforge_core
assert svcforge_core is not None
+42
View File
@@ -0,0 +1,42 @@
"""Unit tests for the instance state machine."""
import pytest
from svcforge_core.domain.states import LEGAL, IllegalTransition, InstanceState, transition
def test_requested_to_provisioning_is_legal() -> None:
assert transition(InstanceState.REQUESTED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING
def test_deleted_to_ready_raises() -> None:
with pytest.raises(IllegalTransition):
transition(InstanceState.DELETED, InstanceState.READY)
def test_failed_to_provisioning_is_legal_retry() -> None:
assert transition(InstanceState.FAILED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING
@pytest.mark.parametrize("state", list(InstanceState))
def test_every_state_has_a_legal_entry(state: InstanceState) -> None:
"""A new state with no LEGAL entry must fail the suite, not KeyError at runtime."""
assert state in LEGAL
assert isinstance(LEGAL[state], frozenset)
@pytest.mark.parametrize("state", list(InstanceState))
def test_every_legal_target_is_an_instance_state(state: InstanceState) -> None:
for target in LEGAL[state]:
assert isinstance(target, InstanceState)
def test_deleted_is_terminal_with_an_empty_frozenset() -> None:
assert LEGAL[InstanceState.DELETED] == frozenset()
def test_strenum_compares_equal_to_its_value() -> None:
# mypy calls this non-overlapping by declared type. That is exactly what is being
# tested: StrEnum members ARE their values at runtime, which is why psycopg can
# adapt them straight to text and model_validate round-trips them for free.
assert (InstanceState.READY == "ready") is True # type: ignore[comparison-overlap]
+144
View File
@@ -0,0 +1,144 @@
"""Unit tests for maintenance windows. Pure domain: no DB, no clock, no mocks.
`now` is a parameter everywhere in `windows.py`, which is why none of these tests
monkeypatch `datetime.now` — there is nothing to patch. That is the point of the design.
"""
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from svcforge_core.domain.windows import (
BadWindow,
MaintenanceWindow,
next_window_open,
parse_window,
schedule_upgrade_at,
)
HCM = MaintenanceWindow("0 3 * * 0", "Asia/Ho_Chi_Minh") # 03:00 every Sunday, Vietnam time
def test_next_window_open_with_naive_now_raises_value_error() -> None:
"""The one bug this module exists to prevent, caught at the boundary.
A naive datetime does not raise when you build it; it raises when you compare it,
which is inside a worker at 03:00. mypy sees `datetime` either way.
"""
with pytest.raises(ValueError, match="aware"):
next_window_open(HCM, datetime(2026, 7, 18, 20, 0)) # naive on purpose
def test_next_window_open_returns_the_hcm_sunday_expressed_in_utc() -> None:
"""Sunday 03:00 in Ho Chi Minh (UTC+7, no DST) is Saturday 20:00 UTC.
`now` here IS that instant, and the answer is that instant: the window is open right
now, so the upgrade runs now. Strictly-greater semantics would push it a full week.
"""
now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC)
assert now.weekday() == 5 # a Saturday
opens = next_window_open(HCM, now)
assert opens.tzinfo is UTC
assert opens == datetime(2026, 7, 18, 20, 0, tzinfo=UTC)
assert opens.astimezone(ZoneInfo("Asia/Ho_Chi_Minh")) == datetime(
2026, 7, 19, 3, 0, tzinfo=ZoneInfo("Asia/Ho_Chi_Minh")
)
def test_next_window_open_rolls_to_next_week_once_the_window_has_passed() -> None:
"""A second past the open and you wait for the next one. Guards the -1s inclusivity trick."""
opens = next_window_open(HCM, datetime(2026, 7, 18, 20, 0, 1, tzinfo=UTC))
assert opens == datetime(2026, 7, 25, 20, 0, tzinfo=UTC)
assert opens.tzinfo is UTC
def test_schedule_upgrade_at_with_security_returns_now_exactly() -> None:
"""A CVE with a public exploit does not wait until Sunday."""
now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC)
assert schedule_upgrade_at(HCM, security=True, now=now) == now
def test_schedule_upgrade_at_without_window_returns_now() -> None:
"""maintenance_window is null -> upgrade any time."""
now = datetime(2026, 7, 15, 9, 30, tzinfo=UTC)
assert schedule_upgrade_at(None, security=False, now=now) == now
assert next_window_open(None, now).tzinfo is UTC
def test_window_across_spring_forward_returns_one_aware_instant() -> None:
"""DST spring-forward, asserting croniter's REAL behaviour rather than trusting docs.
On 2026-03-08 America/New_York jumps 02:00 EST -> 03:00 EDT, so a `30 2 * * *` window
has no 02:30 that day. Observed: croniter does not skip the day and does not raise —
it CLAMPS to the transition instant, yielding 03:00:00-04:00 (not 03:30). The window
opens half an hour "late" in local terms, exactly once, and the following days resume
at 02:30 EDT. One instant, aware, and the caller never sees a nonexistent local time.
"""
window = MaintenanceWindow("30 2 * * *", "America/New_York")
now = datetime(2026, 3, 7, 17, 0, tzinfo=UTC) # Sat midday in New York, before the jump
opens = next_window_open(window, now)
assert opens.tzinfo is UTC
assert opens == datetime(2026, 3, 8, 7, 0, tzinfo=UTC) # == 03:00 EDT, the clamp
local = opens.astimezone(ZoneInfo("America/New_York"))
assert (local.hour, local.minute) == (3, 0)
assert local.utcoffset() == timedelta(hours=-4) # EDT: the jump has happened
# The day after, the window is back where the tenant expects it.
after = next_window_open(window, opens + timedelta(seconds=1))
assert after == datetime(2026, 3, 9, 6, 30, tzinfo=UTC) # 02:30 EDT
def test_window_across_fall_back_returns_the_first_of_the_two_local_times() -> None:
"""Fall-back makes 01:30 happen twice. Observed: croniter yields BOTH, EDT then EST.
next_window_open returns the earlier one (fold=0, -04:00). Not a bug to fix here: a
window that opens twice on one night is what the tenant's cron literally asked for.
"""
window = MaintenanceWindow("30 1 * * *", "America/New_York")
now = datetime(2026, 10, 31, 16, 0, tzinfo=UTC)
first = next_window_open(window, now)
second = next_window_open(window, first + timedelta(seconds=1))
assert first == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # 01:30 EDT
assert second == datetime(2026, 11, 1, 6, 30, tzinfo=UTC) # 01:30 EST, one hour later
assert first.tzinfo is UTC and second.tzinfo is UTC
def test_parse_window_bad_cron_raises_bad_window() -> None:
with pytest.raises(BadWindow, match="cron"):
parse_window("not a cron|Asia/Ho_Chi_Minh")
def test_parse_window_roundtrips_a_valid_spec() -> None:
assert parse_window("0 3 * * 0|Asia/Ho_Chi_Minh") == HCM
def test_parse_window_none_and_blank_mean_any_time() -> None:
assert parse_window(None) is None
assert parse_window(" ") is None
def test_parse_window_unknown_zone_raises_bad_window() -> None:
with pytest.raises(BadWindow, match="IANA"):
parse_window("0 3 * * 0|Mars/Olympus_Mons")
def test_parse_window_without_separator_raises_bad_window() -> None:
with pytest.raises(BadWindow, match="CRON"):
parse_window("0 3 * * 0")
def test_parse_window_six_field_cron_raises_bad_window() -> None:
"""croniter's is_valid() accepts a 6-field (seconds) form; the column is 5-field."""
with pytest.raises(BadWindow, match="exactly 5 fields"):
parse_window("0 0 3 * * 0|Asia/Ho_Chi_Minh")