diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index effb02d..9bd084c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,10 @@ jobs: with: python-version: "3.11" - name: Install ruff - run: pip install "ruff>=0.5" + # Floor, not a pin: the rule set is fixed by [tool.ruff.lint].select + # in pyproject.toml, so a newer ruff cannot switch rules on by itself. + # The floor only guarantees every selected code is known. + run: pip install "ruff>=0.15" - name: Run ruff run: ruff check . diff --git a/adapters/claude/hooks/tests/validate_examples.py b/adapters/claude/hooks/tests/validate_examples.py index 67adb4a..eb1bf69 100644 --- a/adapters/claude/hooks/tests/validate_examples.py +++ b/adapters/claude/hooks/tests/validate_examples.py @@ -6,9 +6,10 @@ Exit 0 = all pass. Exit 1 = failures. """ import sys -import yaml from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent PASS = 0 diff --git a/pyproject.toml b/pyproject.toml index 8807967..769c832 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,41 @@ context_lifecycle = ["py.typed"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] + +[tool.ruff] +# Without this block the rule set is whatever the installed ruff defaults to, +# which is how 201 findings appeared here without a line of code changing: +# 0.16 widened its defaults, CI installed it unpinned, and rules the project +# never opted into red-walled a green repo. `select` below is the project's +# intent, stated once, so a future release cannot decide it for us. +target-version = "py311" # matches requires-python; UP rules key off this +line-length = 100 + +[tool.ruff.lint] +select = [ + # Ruff's own documented defaults — this repo has always passed these. + "E4", "E7", "E9", "F", + # Adopted deliberately: each is mechanical, auto-fixable, and was cleared + # in the same commit that selected it. + "I", # import sorting + "UP017", # datetime.timezone.utc -> datetime.UTC + "UP035", # deprecated import + "UP037", # quoted annotation no longer needed + "UP045", # Optional[X] -> X | None + "RUF022", # __all__ sorted + "RUF023", # __slots__ sorted + "FURB188", # manual slice -> removeprefix/removesuffix + "PLR0402", # import x.y as y -> from x import y +] +# Deliberately NOT selected, rather than silently absent. Each is a real signal +# but needs judgement per site, and adopting one means fixing every hit in the +# same pass — not sprinkling suppressions: +# BLE001 blind except — many here are best-effort discovery, already +# annotated with a reason at the site +# PLC0415 import not at top — several are deliberate lazy imports +# B008 call in argument default — a false positive for Typer, whose +# entire CLI surface is built from `typer.Option(...)` defaults +# E402 import not at top — tests/conftest.py imports after the venv guard +# on purpose, so the guard can fail before anything else loads +# S110/S112, S603, S606, DTZ*, PLW1510, TRY004, RUF059, SIM102/103, C408, +# RUF012, FURB162, ISC004, PIE810 diff --git a/src/context_lifecycle/__init__.py b/src/context_lifecycle/__init__.py index 0b3a40f..8151e63 100644 --- a/src/context_lifecycle/__init__.py +++ b/src/context_lifecycle/__init__.py @@ -3,11 +3,11 @@ """ContextLifecycle — cognition lifecycle schemas, I/O, policy enforcement.""" from context_lifecycle.errors import ( - CLError, - AnchorMissing, - AnchorInvalid, AmbiguousAnchor, + AnchorInvalid, + AnchorMissing, BoundaryViolation, + CLError, ManifestNotFound, SessionNotStarted, ) @@ -21,16 +21,16 @@ __version__ = "0.3.0" __all__ = [ - "__version__", - "CLError", - "AnchorMissing", - "AnchorInvalid", "AmbiguousAnchor", + "AnchorInvalid", + "AnchorMissing", "BoundaryViolation", + "CLError", + "HydratedContext", "ManifestNotFound", "SessionNotStarted", - "HydratedContext", - "hydrate", + "__version__", "capture", + "hydrate", "peek", ] diff --git a/src/context_lifecycle/cli/ledger.py b/src/context_lifecycle/cli/ledger.py index 5cad6ba..f604303 100644 --- a/src/context_lifecycle/cli/ledger.py +++ b/src/context_lifecycle/cli/ledger.py @@ -22,10 +22,13 @@ import typer from context_lifecycle.ledger import LedgerUnavailable, capture -from context_lifecycle.ledger.check import DEFAULT_MAX_AGE_DAYS, check as run_check +from context_lifecycle.ledger.check import DEFAULT_MAX_AGE_DAYS +from context_lifecycle.ledger.check import check as run_check from context_lifecycle.ledger.observe import ( DEFAULT_MIN_COUNT, DEFAULT_WINDOW_DAYS, +) +from context_lifecycle.ledger.observe import ( observe as run_observe, ) from context_lifecycle.ledger.promote import promote as run_promote diff --git a/src/context_lifecycle/cli/loop.py b/src/context_lifecycle/cli/loop.py index 360c49b..af89a26 100644 --- a/src/context_lifecycle/cli/loop.py +++ b/src/context_lifecycle/cli/loop.py @@ -21,7 +21,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -203,7 +203,7 @@ def signal_cmd( cfg.state_path.mkdir(parents=True, exist_ok=True) payload = { "task": task, - "injected_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "injected_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "injected_by": "cl loop signal", "consumed": False, } diff --git a/src/context_lifecycle/cli/reconcile.py b/src/context_lifecycle/cli/reconcile.py index 834bcc2..ac4659e 100644 --- a/src/context_lifecycle/cli/reconcile.py +++ b/src/context_lifecycle/cli/reconcile.py @@ -21,6 +21,7 @@ render_index, ) from context_lifecycle.reconcile.lock import PruneLockHeld +from context_lifecycle.reconcile.privacy import PrivateArchiveUnavailable from context_lifecycle.reconcile.prune import ( DEFAULT_RECENT_N, PruneRefused, @@ -28,7 +29,6 @@ build_plan, format_plan, ) -from context_lifecycle.reconcile.privacy import PrivateArchiveUnavailable app = typer.Typer(no_args_is_help=True, add_completion=False) diff --git a/src/context_lifecycle/cli/session.py b/src/context_lifecycle/cli/session.py index 1c86252..d0dde66 100644 --- a/src/context_lifecycle/cli/session.py +++ b/src/context_lifecycle/cli/session.py @@ -16,7 +16,6 @@ import os import shutil from pathlib import Path -from typing import Optional import typer @@ -28,11 +27,15 @@ ) from context_lifecycle.session.anchor import ( ENV_VAR as ANCHOR_ENV, +) +from context_lifecycle.session.anchor import ( resolve_anchor_arg, validate_anchor, ) from context_lifecycle.session.ids import ( ENV_VAR as SESSION_ENV, +) +from context_lifecycle.session.ids import ( generate_session_id, is_valid_session_id, ) @@ -50,7 +53,7 @@ @app.command("start") def start( - manifest: Optional[str] = typer.Argument( + manifest: str | None = typer.Argument( None, help="Manifest name or absolute path. If omitted, P2 will infer via RepoGraph.", ), @@ -182,7 +185,7 @@ def end( @app.command("prune") def prune( - manifest: Optional[str] = typer.Argument( + manifest: str | None = typer.Argument( None, help=f"Anchor manifest name or path (default: ${ANCHOR_ENV}).", ), diff --git a/src/context_lifecycle/context_engine/attribution.py b/src/context_lifecycle/context_engine/attribution.py index e174481..6046b97 100644 --- a/src/context_lifecycle/context_engine/attribution.py +++ b/src/context_lifecycle/context_engine/attribution.py @@ -53,10 +53,10 @@ import re import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Callable # The verbatim sentinel cold.py stores when CI cannot be resolved (matches # ci_status.UNKNOWN; duplicated here so the planner loads standalone). @@ -287,7 +287,7 @@ def _matches_any_glob(changed: tuple[str, ...], globs: tuple[str, ...], cold) -> means precisely what "this item surfaces for that path" means. """ for raw in changed: - target = raw[2:] if raw.startswith("./") else raw + target = raw.removeprefix("./") for glob in globs: if cold._glob_to_regex(glob).match(target): return True @@ -357,7 +357,7 @@ def plan_attribution( # sort last and are rejected per-commit below. def _commit_key(c: CitedCommit) -> tuple: dt = _as_dt(c.author_date) - return (dt is None, dt or datetime.max.replace(tzinfo=timezone.utc), c.sha) + return (dt is None, dt or datetime.max.replace(tzinfo=UTC), c.sha) commits.sort(key=_commit_key) diff --git a/src/context_lifecycle/context_engine/attribution_apply.py b/src/context_lifecycle/context_engine/attribution_apply.py index 5cc3046..ce0b3f8 100644 --- a/src/context_lifecycle/context_engine/attribution_apply.py +++ b/src/context_lifecycle/context_engine/attribution_apply.py @@ -60,9 +60,9 @@ import re import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass, field, replace from pathlib import Path -from typing import Callable # The verbatim sentinel cold.py stores when CI cannot be resolved (matches # ci_status.UNKNOWN / attribution.UNKNOWN; duplicated so this module loads diff --git a/src/context_lifecycle/context_engine/cold.py b/src/context_lifecycle/context_engine/cold.py index caa23ea..bbaf46f 100644 --- a/src/context_lifecycle/context_engine/cold.py +++ b/src/context_lifecycle/context_engine/cold.py @@ -298,8 +298,7 @@ def surface_cold(target: str, knowledge_dir: Path, max_items: int) -> list[str]: (spec §1). """ try: - if target.startswith("./"): - target = target[2:] + target = target.removeprefix("./") matched: list[tuple[str, str]] = [] # (topic, line) for item in load_index(knowledge_dir): if item.tier != "cold": diff --git a/src/context_lifecycle/context_engine/route.py b/src/context_lifecycle/context_engine/route.py index bfc911e..4789389 100644 --- a/src/context_lifecycle/context_engine/route.py +++ b/src/context_lifecycle/context_engine/route.py @@ -24,8 +24,8 @@ import json import re import sys -from datetime import datetime from dataclasses import dataclass +from datetime import datetime from pathlib import Path import yaml @@ -207,8 +207,7 @@ def select_docs_split( """ # Strip only a leading literal "./" prefix — NOT a character set, which # would mangle dot-prefixed paths like ".github/...". - if target.startswith("./"): - target = target[2:] + target = target.removeprefix("./") best: dict[str, int] = {} order: list[str] = [] for route in routes: diff --git a/src/context_lifecycle/hooks/__init__.py b/src/context_lifecycle/hooks/__init__.py index 155c5b7..8954df8 100644 --- a/src/context_lifecycle/hooks/__init__.py +++ b/src/context_lifecycle/hooks/__init__.py @@ -3,21 +3,21 @@ """Hook decision functions — pure logic over loaded state.""" from context_lifecycle.hooks.decisions import ( - Decision, Allow, Block, - Warn, + Decision, DecisionResult, + Warn, ) from context_lifecycle.hooks.pre_tool_use import evaluate_pre_tool_use from context_lifecycle.hooks.stop import evaluate_stop __all__ = [ - "Decision", "Allow", "Block", - "Warn", + "Decision", "DecisionResult", + "Warn", "evaluate_pre_tool_use", "evaluate_stop", ] diff --git a/src/context_lifecycle/hooks/decisions.py b/src/context_lifecycle/hooks/decisions.py index 4ba274b..d9d5359 100644 --- a/src/context_lifecycle/hooks/decisions.py +++ b/src/context_lifecycle/hooks/decisions.py @@ -31,14 +31,14 @@ class DecisionResult: reason: str = "" warnings: list[Warn] = field(default_factory=list) - def block(self, reason: str) -> "DecisionResult": + def block(self, reason: str) -> DecisionResult: # First block wins (matches bash's `exit 2` short-circuit). if self.decision is Decision.ALLOW: self.decision = Decision.BLOCK self.reason = reason return self - def warn(self, reason: str) -> "DecisionResult": + def warn(self, reason: str) -> DecisionResult: self.warnings.append(Warn(reason)) return self diff --git a/src/context_lifecycle/hooks/pre_tool_use.py b/src/context_lifecycle/hooks/pre_tool_use.py index 1636bf5..94df1a0 100644 --- a/src/context_lifecycle/hooks/pre_tool_use.py +++ b/src/context_lifecycle/hooks/pre_tool_use.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -28,7 +28,7 @@ class HookInput: tool_input: dict[str, Any] @classmethod - def from_payload(cls, payload: dict[str, Any]) -> "HookInput": + def from_payload(cls, payload: dict[str, Any]) -> HookInput: return cls( tool_name=str(payload.get("tool_name", "")), tool_input=payload.get("tool_input") or {}, @@ -89,7 +89,7 @@ def evaluate_pre_tool_use( ) -> DecisionResult: """Pure decision function. Returns a DecisionResult; CLI maps it to exit codes.""" result = Allow() - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) # --- require_capsule --- if config.guard.require_capsule: diff --git a/src/context_lifecycle/io/__init__.py b/src/context_lifecycle/io/__init__.py index 871d06d..a4e0f81 100644 --- a/src/context_lifecycle/io/__init__.py +++ b/src/context_lifecycle/io/__init__.py @@ -2,6 +2,6 @@ # Copyright (C) 2026 ProtocolWarden """YAML I/O helpers.""" -from context_lifecycle.io.yaml_io import load_yaml, dump_yaml, load_yaml_safe +from context_lifecycle.io.yaml_io import dump_yaml, load_yaml, load_yaml_safe -__all__ = ["load_yaml", "dump_yaml", "load_yaml_safe"] +__all__ = ["dump_yaml", "load_yaml", "load_yaml_safe"] diff --git a/src/context_lifecycle/ledger/check.py b/src/context_lifecycle/ledger/check.py index bda77c5..787da3c 100644 --- a/src/context_lifecycle/ledger/check.py +++ b/src/context_lifecycle/ledger/check.py @@ -30,7 +30,7 @@ class StaleCandidate: """A candidate older than the threshold. Plain value object.""" - __slots__ = ("date", "signal", "context", "age_days") + __slots__ = ("age_days", "context", "date", "signal") def __init__(self, date: str, signal: str, context: str, age_days: int) -> None: self.date = date diff --git a/src/context_lifecycle/ledger/observe.py b/src/context_lifecycle/ledger/observe.py index 29c4f2d..1696db2 100644 --- a/src/context_lifecycle/ledger/observe.py +++ b/src/context_lifecycle/ledger/observe.py @@ -38,7 +38,7 @@ class RecurringSignal: """A signal recurring across candidates within the window. Value object.""" - __slots__ = ("signal", "count", "latest_date", "contexts") + __slots__ = ("contexts", "count", "latest_date", "signal") def __init__( self, signal: str, count: int, latest_date: str, contexts: list[str] diff --git a/src/context_lifecycle/lifecycle.py b/src/context_lifecycle/lifecycle.py index ca1cce2..83f2510 100644 --- a/src/context_lifecycle/lifecycle.py +++ b/src/context_lifecycle/lifecycle.py @@ -25,10 +25,11 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Iterable +from typing import Any from context_lifecycle.errors import BoundaryViolation from context_lifecycle.io.yaml_io import dump_yaml, load_yaml_safe @@ -36,7 +37,6 @@ from context_lifecycle.session.ids import require_session_env from context_lifecycle.session.paths import SessionPaths - # --------------------------------------------------------------------------- # Public dataclass # --------------------------------------------------------------------------- @@ -223,7 +223,7 @@ def hydrate(lineage_id: str, work_item: dict[str, Any]) -> HydratedContext: "lineage_id": lineage_id, "session_id": paths.session_id, "status": "fresh", - "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "created_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "work_item": dict(work_item) if isinstance(work_item, dict) else work_item, } fresh = True @@ -256,7 +256,7 @@ def capture(lineage_id: str, result: dict[str, Any]) -> None: if kind == "capsule": target = _active_file(paths, lineage_id) elif kind == "checkpoint": - ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%SZ") ckpt_id = result.get("checkpoint_id") or ts target = paths.checkpoints / f"{ckpt_id}.yaml" else: # handoff @@ -272,7 +272,7 @@ def capture(lineage_id: str, result: dict[str, Any]) -> None: payload.setdefault("session_id", paths.session_id) payload.setdefault( "captured_at", - datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), ) dump_yaml(target, payload) diff --git a/src/context_lifecycle/models/__init__.py b/src/context_lifecycle/models/__init__.py index 92d1310..0badd1b 100644 --- a/src/context_lifecycle/models/__init__.py +++ b/src/context_lifecycle/models/__init__.py @@ -3,19 +3,19 @@ """Pydantic v2 models mirroring the YAML schemas in .context/schemas/.""" from context_lifecycle.models.capsule import InvestigationCapsule -from context_lifecycle.models.checkpoint import LoopCheckpoint, ContextRisk, Orchestrator -from context_lifecycle.models.handoff import WorkerHandoff, WorkerScope, Lease -from context_lifecycle.models.config import GuardConfig, LoopConfig, CLConfig +from context_lifecycle.models.checkpoint import ContextRisk, LoopCheckpoint, Orchestrator +from context_lifecycle.models.config import CLConfig, GuardConfig, LoopConfig +from context_lifecycle.models.handoff import Lease, WorkerHandoff, WorkerScope __all__ = [ + "CLConfig", + "ContextRisk", + "GuardConfig", "InvestigationCapsule", + "Lease", "LoopCheckpoint", - "ContextRisk", + "LoopConfig", "Orchestrator", "WorkerHandoff", "WorkerScope", - "Lease", - "GuardConfig", - "LoopConfig", - "CLConfig", ] diff --git a/src/context_lifecycle/models/config.py b/src/context_lifecycle/models/config.py index abdbdc7..fabc34f 100644 --- a/src/context_lifecycle/models/config.py +++ b/src/context_lifecycle/models/config.py @@ -32,7 +32,7 @@ class CLConfig(BaseModel): loop: LoopConfig = Field(default_factory=LoopConfig) @classmethod - def from_file(cls, path: Path) -> "CLConfig": + def from_file(cls, path: Path) -> CLConfig: """Load from YAML file. Returns defaults if file missing or malformed.""" from context_lifecycle.io.yaml_io import load_yaml_safe diff --git a/src/context_lifecycle/models/handoff.py b/src/context_lifecycle/models/handoff.py index 80bdd0a..ef2be5e 100644 --- a/src/context_lifecycle/models/handoff.py +++ b/src/context_lifecycle/models/handoff.py @@ -4,7 +4,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from pydantic import BaseModel, ConfigDict, Field @@ -61,12 +61,12 @@ def is_lease_expired(self, now: datetime | None = None) -> bool: ts = self.effective_expires_at if not ts: return False - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) try: # Accept "Z" suffix and offset-aware ISO formats parsed = datetime.fromisoformat(ts.replace("Z", "+00:00")) except ValueError: return False if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) + parsed = parsed.replace(tzinfo=UTC) return now > parsed diff --git a/src/context_lifecycle/pseudo_operator/engine.py b/src/context_lifecycle/pseudo_operator/engine.py index 6af4956..9cc7c64 100644 --- a/src/context_lifecycle/pseudo_operator/engine.py +++ b/src/context_lifecycle/pseudo_operator/engine.py @@ -26,17 +26,17 @@ import socket import sys import time -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from .config import BackendSpec, PseudoOperatorConfig -from .sessions import SessionMixin, _command_available, _resolve_command # noqa: F401 from .limits import ( GLOBAL_CLAUDE_LIMIT_RE, RATE_LIMIT_BUFFER, classify_limit_kind, parse_rate_limit_reset, ) +from .sessions import SessionMixin, _command_available, _resolve_command # noqa: F401 logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ def _log(self, msg: str) -> None: @staticmethod def _ts() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") # ── locking (atomic + hostname-aware, Track A7 semantics) ───────────── def _lock_payload(self) -> str: @@ -148,11 +148,11 @@ def _backend_available(self, name: str, cooldowns: dict[str, datetime | None]) - spec = self._backends[name] until = cooldowns.get(name) return _command_available(self._cli_for(spec)) and ( - until is None or datetime.now(timezone.utc) >= until + until is None or datetime.now(UTC) >= until ) def _clear_expired_cooldowns(self, cooldowns: dict[str, datetime | None]) -> None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for backend, until in list(cooldowns.items()): if until is not None and now >= until: cooldowns[backend] = None @@ -179,7 +179,7 @@ def _next_runnable_fallback( return None def _next_backend_reset(self, cooldowns: dict[str, datetime | None]) -> datetime | None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) future = [dt for dt in cooldowns.values() if dt is not None and dt > now] return min(future) if future else None @@ -189,7 +189,7 @@ def _sleep_until_backend_reset(self, cooldowns: dict[str, datetime | None]) -> b return False delay = max( 60, - int((reset_dt - datetime.now(timezone.utc)).total_seconds()) + RATE_LIMIT_BUFFER, + int((reset_dt - datetime.now(UTC)).total_seconds()) + RATE_LIMIT_BUFFER, ) self._log( f"All available backends are cooling down until " diff --git a/src/context_lifecycle/pseudo_operator/limits.py b/src/context_lifecycle/pseudo_operator/limits.py index fd47d3a..a627b1d 100644 --- a/src/context_lifecycle/pseudo_operator/limits.py +++ b/src/context_lifecycle/pseudo_operator/limits.py @@ -12,7 +12,7 @@ import logging import re -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -95,7 +95,7 @@ def parse_rate_limit_reset( # reset is next year. if reset_local <= now_local: reset_local = reset_local.replace(year=reset_local.year + 1) - return reset_local.astimezone(timezone.utc), text + return reset_local.astimezone(UTC), text m = _TIMEZONE_RESET_RE.search(text) if m: @@ -113,13 +113,13 @@ def parse_rate_limit_reset( ) if reset_local <= now_local: reset_local += timedelta(days=1) - return reset_local.astimezone(timezone.utc), text + return reset_local.astimezone(UTC), text m = _ISO_RESET_RE.search(text) if m: return ( datetime.fromisoformat(m.group(1).replace("Z", "+00:00")).astimezone( - timezone.utc + UTC ), text, ) @@ -132,7 +132,7 @@ def parse_rate_limit_reset( seconds=int(m.group("seconds") or 0), ) if delta.total_seconds() > 0: - return datetime.now(timezone.utc) + delta, text + return datetime.now(UTC) + delta, text if _LIMIT_SIGNAL_RE.search(text): logger.info( diff --git a/src/context_lifecycle/pseudo_operator/sessions.py b/src/context_lifecycle/pseudo_operator/sessions.py index 32d7ca4..181f9cd 100644 --- a/src/context_lifecycle/pseudo_operator/sessions.py +++ b/src/context_lifecycle/pseudo_operator/sessions.py @@ -15,9 +15,10 @@ import re import shutil import subprocess -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path + def _resolve_command(command: str) -> str | None: resolved = shutil.which(command) if resolved is not None: @@ -94,8 +95,7 @@ def _env_file_vars_literal(env_file: Path) -> dict[str, str]: result: dict[str, str] = {} for line in env_file.read_text(encoding="utf-8").splitlines(): line = line.strip() - if line.startswith("export "): - line = line[len("export "):] + line = line.removeprefix("export ") if line and not line.startswith("#") and "=" in line: k, _, v = line.partition("=") result[k.strip()] = v.strip().strip("'\"") @@ -141,7 +141,7 @@ def _seed_cooldowns(self, cooldowns: dict[str, datetime | None]) -> None: except json.JSONDecodeError: self._log("seed_cooldowns hook produced non-JSON output — ignored.") return - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for backend, iso in seeded.items(): if backend not in cooldowns or not iso: continue @@ -168,7 +168,7 @@ def _budget_guard(self, cooldowns: dict[str, datetime | None]) -> None: except json.JSONDecodeError: self._log("budget_guard hook produced non-JSON output — ignored.") return - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for backend, iso in horizons.items(): if backend not in cooldowns or not iso: continue @@ -345,7 +345,7 @@ def _session_command(self, name: str, prompt: str) -> list[str]: ] def _session_log_path(self, iteration: int, name: str) -> Path: - ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") return self.cfg.session_log_dir / f"iter_{iteration:04d}_{ts}_{name}.log" def run_session( diff --git a/src/context_lifecycle/session/__init__.py b/src/context_lifecycle/session/__init__.py index e30b4e9..82dd3d0 100644 --- a/src/context_lifecycle/session/__init__.py +++ b/src/context_lifecycle/session/__init__.py @@ -3,30 +3,30 @@ """Session anchor + id + path helpers.""" from context_lifecycle.session.anchor import ( - resolve_anchor_arg, require_anchor_env, + resolve_anchor_arg, validate_anchor, ) from context_lifecycle.session.ids import generate_session_id, require_session_env from context_lifecycle.session.paths import ( SessionPaths, - session_root, active_dir, + archived_root, checkpoints_dir, handoffs_dir, - archived_root, + session_root, ) __all__ = [ - "resolve_anchor_arg", - "require_anchor_env", - "validate_anchor", - "generate_session_id", - "require_session_env", "SessionPaths", - "session_root", "active_dir", + "archived_root", "checkpoints_dir", + "generate_session_id", "handoffs_dir", - "archived_root", + "require_anchor_env", + "require_session_env", + "resolve_anchor_arg", + "session_root", + "validate_anchor", ] diff --git a/src/context_lifecycle/session/ids.py b/src/context_lifecycle/session/ids.py index 4cf1c25..8524b5f 100644 --- a/src/context_lifecycle/session/ids.py +++ b/src/context_lifecycle/session/ids.py @@ -10,7 +10,7 @@ import os import re import secrets -from datetime import datetime, timezone +from datetime import UTC, datetime from context_lifecycle.errors import SessionNotStarted @@ -20,7 +20,7 @@ def generate_session_id(now: datetime | None = None) -> str: """Generate a fresh session id of the locked format.""" - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) date = now.strftime("%Y-%m-%d") # 4 hex chars from secrets — collision-safe enough for per-day session ids. rand = secrets.token_hex(2) diff --git a/tests/conftest.py b/tests/conftest.py index 61ca0b7..a8dd857 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import os import sys -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -106,7 +106,7 @@ def valid_handoff_data() -> dict: }, "lease": { "max_subagents": 2, - "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)) + "expires_at": (datetime.now(UTC) + timedelta(hours=1)) .strftime("%Y-%m-%dT%H:%M:%SZ"), }, } diff --git a/tests/test_consolidate.py b/tests/test_consolidate.py index 7229a9d..b4ce897 100644 --- a/tests/test_consolidate.py +++ b/tests/test_consolidate.py @@ -22,7 +22,6 @@ import sys from pathlib import Path - _ENGINE_DIR = Path(__file__).resolve().parent.parent / "src" / "context_lifecycle" / "context_engine" _REPO_ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_engine.py b/tests/test_engine.py index 44eab85..886794e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -8,7 +8,7 @@ import json import os import socket -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -17,8 +17,8 @@ PseudoOperatorEngine, load_pseudo_operator_config, ) -from context_lifecycle.pseudo_operator.config import PseudoOperatorConfig from context_lifecycle.pseudo_operator import engine as engine_mod +from context_lifecycle.pseudo_operator.config import PseudoOperatorConfig assert engine_mod is not None # T6: module import reference @@ -261,7 +261,7 @@ def test_global_claude_limit_cools_all_claude_backends(tmp_path: Path): def test_seed_cooldowns_hook(tmp_path: Path): - future = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(UTC) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") script = tmp_path / "seed.py" script.write_text(f"import json; print(json.dumps({{'claude': '{future}', 'codex': None}}))") eng = PseudoOperatorEngine( @@ -274,7 +274,7 @@ def test_seed_cooldowns_hook(tmp_path: Path): def test_budget_guard_hook_extends_cooldown(tmp_path: Path): - future = (datetime.now(timezone.utc) + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(UTC) + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") script = tmp_path / "guard.py" script.write_text( f"import json; print(json.dumps({{'claude': '{future}', 'opus': '{future}', 'codex': None}}))" @@ -288,11 +288,11 @@ def test_budget_guard_hook_extends_cooldown(tmp_path: Path): def test_budget_guard_never_shortens_a_real_cooldown(tmp_path: Path): """A cheap budget estimate must not mask a longer real limit reset.""" - near = (datetime.now(timezone.utc) + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + near = (datetime.now(UTC) + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ") script = tmp_path / "guard.py" script.write_text(f"import json; print(json.dumps({{'claude': '{near}'}}))") eng = PseudoOperatorEngine(_cfg(tmp_path, hooks={"budget_guard": ["python3", str(script)]})) - real_reset = datetime.now(timezone.utc) + timedelta(hours=3) + real_reset = datetime.now(UTC) + timedelta(hours=3) cooldowns = {"claude": real_reset, "opus": None, "codex": None} eng._budget_guard(cooldowns) assert cooldowns["claude"] == real_reset @@ -302,7 +302,7 @@ def test_budget_guard_null_output_leaves_cooldowns_alone(tmp_path: Path): script = tmp_path / "guard.py" script.write_text("import json; print(json.dumps({'claude': None, 'opus': None}))") eng = PseudoOperatorEngine(_cfg(tmp_path, hooks={"budget_guard": ["python3", str(script)]})) - real_reset = datetime.now(timezone.utc) + timedelta(hours=1) + real_reset = datetime.now(UTC) + timedelta(hours=1) cooldowns = {"claude": real_reset, "opus": None, "codex": None} eng._budget_guard(cooldowns) assert cooldowns["claude"] == real_reset and cooldowns["opus"] is None diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 4c47110..3316103 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -4,19 +4,19 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from context_lifecycle.models.handoff import WorkerHandoff def test_handoff_lease_not_expired(): - future = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(UTC) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") h = WorkerHandoff.model_validate({"lease": {"expires_at": future}}) assert h.is_lease_expired() is False def test_handoff_lease_expired(): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") h = WorkerHandoff.model_validate({"lease": {"expires_at": past}}) assert h.is_lease_expired() is True @@ -28,7 +28,7 @@ def test_handoff_lease_unset_means_not_expired(): def test_handoff_top_level_expires_at_compat(): """The bash hook read top-level `expires_at`; allow it via extra='allow'.""" - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") h = WorkerHandoff.model_validate({"expires_at": past}) assert h.is_lease_expired() is True diff --git a/tests/test_ids.py b/tests/test_ids.py index a0d6196..60c7f13 100644 --- a/tests/test_ids.py +++ b/tests/test_ids.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 ProtocolWarden from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -16,7 +16,7 @@ def test_generate_session_id_format(): - sid = generate_session_id(now=datetime(2026, 5, 22, tzinfo=timezone.utc)) + sid = generate_session_id(now=datetime(2026, 5, 22, tzinfo=UTC)) assert sid.startswith("s-2026-05-22-") assert is_valid_session_id(sid) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 47b9fe2..273f868 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -21,7 +21,6 @@ ) from context_lifecycle.session.paths import SessionPaths - # --------------------------------------------------------------------------- # Helpers / fixtures # --------------------------------------------------------------------------- diff --git a/tests/test_limits.py b/tests/test_limits.py index 0332a37..d0643e6 100644 --- a/tests/test_limits.py +++ b/tests/test_limits.py @@ -4,7 +4,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from context_lifecycle.pseudo_operator import limits @@ -23,14 +23,14 @@ def test_parse_relative_reset(tmp_path: Path): log.write_text("Rate limit reached. Try again in 2h 30m.") reset, text = parse_rate_limit_reset(log) assert reset is not None - assert timedelta(hours=2) < (reset - datetime.now(timezone.utc)) <= timedelta(hours=2, minutes=31) + assert timedelta(hours=2) < (reset - datetime.now(UTC)) <= timedelta(hours=2, minutes=31) def test_parse_iso_reset(tmp_path: Path): log = tmp_path / "s.log" log.write_text("usage limit — resets at 2027-01-02T03:04Z") reset, _ = parse_rate_limit_reset(log) - assert reset == datetime(2027, 1, 2, 3, 4, tzinfo=timezone.utc) + assert reset == datetime(2027, 1, 2, 3, 4, tzinfo=UTC) def test_no_limit_signal_returns_none(tmp_path: Path): diff --git a/tests/test_paths.py b/tests/test_paths.py index 68c9e0d..8a11ac3 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -2,7 +2,6 @@ # Copyright (C) 2026 ProtocolWarden from __future__ import annotations - from context_lifecycle.session.paths import SessionPaths, archived_root diff --git a/tests/test_pre_tool_use.py b/tests/test_pre_tool_use.py index 19a4153..752f837 100644 --- a/tests/test_pre_tool_use.py +++ b/tests/test_pre_tool_use.py @@ -4,8 +4,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone - +from datetime import UTC, datetime, timedelta from context_lifecycle.hooks.decisions import Decision from context_lifecycle.hooks.pre_tool_use import HookInput, evaluate_pre_tool_use @@ -48,7 +47,7 @@ def test_require_capsule_valid_allows(paths, require_capsule_config, write_yaml_ # --- lease expiry --- def test_lease_expired_blocks(paths, default_config, write_yaml_helper): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") write_yaml_helper(paths.handoffs / "h.yaml", {"lease": {"expires_at": past}}) r = evaluate_pre_tool_use(paths=paths, config=default_config, hook_input=_hook_input()) assert r.is_block @@ -62,7 +61,7 @@ def test_lease_future_allows(paths, default_config, write_yaml_helper, valid_han def test_lease_enforce_disabled(paths, write_yaml_helper): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") write_yaml_helper(paths.handoffs / "h.yaml", {"lease": {"expires_at": past}}) cfg = CLConfig.model_validate({"guard": {"enforce_lease": False}}) r = evaluate_pre_tool_use(paths=paths, config=cfg, hook_input=_hook_input()) diff --git a/tests/test_retention.py b/tests/test_retention.py index 59be281..fa863dd 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -232,6 +232,7 @@ def test_auto_gc_full_lifecycle_move_then_delete(tmp_path: Path): def test_maybe_auto_gc_throttles_and_logs(tmp_path: Path): from datetime import datetime, timedelta + from context_lifecycle.session.retention import maybe_auto_gc anchor = _anchor(tmp_path) _mk_session(anchor, "s-2026-05-01-aaaa") @@ -250,6 +251,7 @@ def test_maybe_auto_gc_throttles_and_logs(tmp_path: Path): def test_manual_prune_skips_gc_state_dir(tmp_path: Path): from datetime import datetime + from context_lifecycle.session.retention import maybe_auto_gc anchor = _anchor(tmp_path) _mk_session(anchor, "s-2026-05-01-aaaa") @@ -351,7 +353,8 @@ def test_auto_gc_corrupt_stamp_is_fail_safe(tmp_path: Path): def test_throttle_is_one_attempt_per_window_even_after_failure(tmp_path: Path, monkeypatch): """The stamp advances on attempt, not success — documented semantics.""" from datetime import datetime, timedelta - import context_lifecycle.session.retention as retention + + from context_lifecycle.session import retention anchor = _anchor(tmp_path) _mk_session(anchor, "s-2026-05-01-aaaa") now = datetime(2026, 6, 6, 12, 0, 0) diff --git a/tests/test_signing.py b/tests/test_signing.py index 55a181d..a317b89 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -21,9 +21,9 @@ from typer.testing import CliRunner from context_lifecycle.cli import loop as loop_cli +from context_lifecycle.cli.loop import sign_config_cmd, verify_config_cmd from context_lifecycle.pseudo_operator import signing from context_lifecycle.pseudo_operator.config import load_verified_config -from context_lifecycle.cli.loop import sign_config_cmd, verify_config_cmd from context_lifecycle.pseudo_operator.signing import ( VerifyResult, canonical_bytes,