diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ba93610 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: +- package-ecosystem: uv + directory: / + schedule: + interval: cron + cronjob: 37 4 * * * + timezone: Europe/Warsaw + open-pull-requests-limit: 1 + versioning-strategy: increase-if-necessary + allow: + - dependency-name: costs + dependency-type: all + - dependency-name: goal + dependency-type: all + - dependency-name: pfix + dependency-type: all + - dependency-name: clickmd + dependency-type: all + - dependency-name: code2llm + dependency-type: all + groups: + internal-packages: + patterns: + - costs + - goal + - pfix + - clickmd + - code2llm + commit-message: + prefix: "chore(deps)" diff --git a/.github/internal-dependencies.json b/.github/internal-dependencies.json new file mode 100644 index 0000000..ce582b0 --- /dev/null +++ b/.github/internal-dependencies.json @@ -0,0 +1,35 @@ +{ + "schema": "goal.internal-dependencies/v1", + "packages": [ + { + "name": "costs", + "repository": "semcod/costs", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "goal", + "repository": "semcod/goal", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "pfix", + "repository": "semcod/pfix", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "clickmd", + "repository": "semcod/clickmd", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "code2llm", + "repository": "semcod/code2llm", + "registry": "pypi", + "versioning": "semver" + } + ] +} diff --git a/.github/workflows/internal-dependency-freshness.yml b/.github/workflows/internal-dependency-freshness.yml new file mode 100644 index 0000000..d64bfc8 --- /dev/null +++ b/.github/workflows/internal-dependency-freshness.yml @@ -0,0 +1,36 @@ +name: Internal dependency freshness + +on: + schedule: + - cron: "47 5 * * *" + workflow_dispatch: + pull_request: + paths: + - pyproject.toml + - uv.lock + - .github/internal-dependencies.json + - .github/workflows/internal-dependency-freshness.yml + +permissions: + contents: read + +jobs: + freshness: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Install released checker + run: python -m pip install "goal==2.2.0" + - name: Verify published stable targets + run: goal dependencies --catalog .github/internal-dependencies.json --check > dependency-freshness.json + - name: Retain freshness evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: dependency-freshness + path: dependency-freshness.json + if-no-files-found: ignore diff --git a/.github/workflows/test-locked.yml b/.github/workflows/test-locked.yml new file mode 100644 index 0000000..c42bf24 --- /dev/null +++ b/.github/workflows/test-locked.yml @@ -0,0 +1,30 @@ +name: Locked tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install uv + - name: Install locked test dependencies + run: uv sync --locked --extra dev --python "${{ matrix.python-version }}" + - name: Test supported Python versions + run: uv run --no-sync python -m pytest -q diff --git a/README.md b/README.md index 80fff9d..65f0342 100644 --- a/README.md +++ b/README.md @@ -1234,3 +1234,7 @@ _Last updated by [taskill](https://github.com/oqlos/taskill) at 2026-04-25 18:22 > Implemented a comprehensive configuration management system and CLI interface for the planfile project, including new command groups for backlog, sync, ticket, and validate operations. + +## Dependency maintenance + +See [internal dependency updates](docs/dependencies.md) for daily updates, locked tests and Python support. diff --git a/docs/dependencies.md b/docs/dependencies.md new file mode 100644 index 0000000..1c80b81 --- /dev/null +++ b/docs/dependencies.md @@ -0,0 +1,29 @@ +# Internal dependency updates + +This repository owns its dependency configuration and this guide, following the repository ownership principle in [wellmanifest/docs](https://github.com/wellmanifest/docs/blob/f64de5806577769672ebc1730d2e144b4c7671ec/README.md). The checker belongs to [semcod/goal](https://github.com/semcod/goal/blob/84f18540d14c24cc8ff5b7f202d2874344779ecc/docs/internal-dependencies.md). + +## Python and installation + +Application support remains Python >=3.10. Source inspection found no Goal imports or executable invocation in application Python code. Goal's old declarations moved to an `automation` dependency group requiring Python >=3.12, so updating release tooling does not remove Python 3.10 application support. + +```sh +uv sync --locked --extra dev --python 3.10 +uv run --no-sync python -m pytest -q +UV_PROJECT_ENVIRONMENT=.venv-automation uv sync --locked --group automation --python 3.12 +``` + +The automation command uses a separate environment. Synchronize the application environment again after merging tested dependency updates. + +## Daily checks + +[Dependabot](../.github/dependabot.yml) checks the explicit costs, Goal, pfix, clickmd and code2llm catalog daily, including weekends, and groups updates into one PR. It includes transitive packages and may widen constraints when necessary. This is a bounded catalog, not discovery of every internal package. Local/Git sources and other packages need separate review. + +[Freshness CI](../.github/workflows/internal-dependency-freshness.yml) uses released Goal 2.2.0 to compare uv.lock with the highest published stable three-part versions of the catalog packages actually used here. It has read-only repository permissions, runs daily/manually/on dependency PRs, and retains JSON evidence. Registry and resolution errors remain visible. An audit does not prove a deployed environment has installed the new versions. + +[Locked tests](../.github/workflows/test-locked.yml) install the dev extra from uv.lock and run the full project suite on Python 3.10 and 3.13. A permissive version declaration alone does not refresh the lockfile; update creation, testing, merge and installation are separate steps. + +## Delivery + +The registry targets observed on 2026-09-05 are costs 0.2.0, Goal 2.2.0, pfix 0.1.79, clickmd 1.1.15 and code2llm 0.5.176. PR and Actions checks hold the test results; the [ecosystem rollout history](https://github.com/semcod/costs/tree/main/docs/dependencies) belongs to costs. Reports and instructions are versioned in repository docs, rather than temporary machine directories. + +The Python 3.10 test run exposed imports of `datetime.UTC`, which was added after that version. Application and test code use `timezone.utc` while preserving UTC timestamps and date-based archive behavior. Project homepage/repository metadata now points to semcod/planfile. The observed PyPI release is 0.1.123 while the source project reports 0.1.125; merging this PR does not publish a new distribution or update PyPI metadata. diff --git a/planfile/__init__.py b/planfile/__init__.py index 08c23c9..d8b426a 100644 --- a/planfile/__init__.py +++ b/planfile/__init__.py @@ -12,7 +12,7 @@ __author__ = "Tom Sapletta" __email__ = "tom@sapletta.com" -from datetime import UTC, datetime, timedelta +from datetime import timezone, datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING @@ -82,7 +82,7 @@ class Planfile: @staticmethod def _utcnow() -> datetime: - return datetime.now(UTC) + return datetime.now(timezone.utc) def __init__(self, project_path: str = "."): self.store = PlanfileStore(project_path) diff --git a/planfile/api/server.py b/planfile/api/server.py index 3f4db96..3144c45 100644 --- a/planfile/api/server.py +++ b/planfile/api/server.py @@ -13,7 +13,7 @@ import time from collections import deque from contextlib import asynccontextmanager, contextmanager -from datetime import UTC, datetime +from datetime import timezone, datetime from pathlib import Path from threading import BoundedSemaphore, RLock from typing import Any, Literal @@ -932,7 +932,7 @@ def public_forensic_log( limit: int = Query(500, ge=1, le=5000), ): """Bounded PLOG/1 text projection for applications, operators and LLMs.""" - selected_day = day or datetime.now(UTC).date().isoformat() + selected_day = day or datetime.now(timezone.utc).date().isoformat() lines = get_planfile().store.forensic_log_lines( date=selected_day, ticket_id=ticket_id, @@ -960,7 +960,7 @@ def public_forensic_log_json( """Parsed PLOG/1 records with the same bounded filters as the text file.""" from planfile.core.forensic_log_dsl import parse - selected_day = day or datetime.now(UTC).date().isoformat() + selected_day = day or datetime.now(timezone.utc).date().isoformat() lines = get_planfile().store.forensic_log_lines( date=selected_day, ticket_id=ticket_id, @@ -1574,7 +1574,7 @@ async def _watch_planfile_changes(interval_seconds: float = 3.0) -> None: "type": "dashboard", "action": "watch-error", "ticket_id": "-", - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), "ticket": {"execution": {"state": "failed", "last_error": str(exc)}}, } ) @@ -1596,7 +1596,7 @@ async def _watch_planfile_changes(interval_seconds: float = 3.0) -> None: "type": "dashboard", "action": "watch-error", "ticket_id": "-", - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), "ticket": {"execution": {"state": "failed", "last_error": str(exc)}}, } _remember_event(payload) @@ -1618,7 +1618,7 @@ async def _archive_history_daily(interval_seconds: float = 300.0) -> None: """Run the idempotent history sweep once on startup and per UTC date.""" last_run_date = None while True: - today = datetime.now(UTC).date() + today = datetime.now(timezone.utc).date() if today != last_run_date: try: report = await asyncio.to_thread( @@ -1630,7 +1630,7 @@ async def _archive_history_daily(interval_seconds: float = 300.0) -> None: "type": "management.event", "action": "daily-history-error", "ticket_id": "-", - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), "source": "planfile", "tool": "planfile.api", "level": "error", @@ -1647,7 +1647,7 @@ async def _archive_history_daily(interval_seconds: float = 300.0) -> None: "type": "management.event", "action": "daily-history", "ticket_id": "-", - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), "source": "planfile", "tool": "planfile.api", "level": "info", @@ -1750,7 +1750,7 @@ async def _start_planfile_watcher() -> None: "type": "management.event", "action": "started", "ticket_id": "-", - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), "source": "planfile", "tool": "planfile.api", "queue": "koru-management", @@ -1798,7 +1798,7 @@ def list_events( @app.post("/events/test", tags=["events"]) async def create_test_event(body: TestEventRequest): """Broadcast a synthetic dashboard event without mutating a real ticket.""" - created_at = datetime.now(UTC).isoformat() + created_at = datetime.now(timezone.utc).isoformat() payload = { "type": "dashboard.test", "action": "error", @@ -1831,7 +1831,7 @@ async def ingest_management_event(body: ManagementEventRequest): "type": "management.event", "action": body.action, "ticket_id": ticket_id, - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), "source": body.source, "tool": body.tool, "queue": body.queue, @@ -3549,7 +3549,7 @@ async def _broadcast_ticket_event( "type": event_type, "action": action, "ticket_id": ticket.id if ticket is not None else ticket_id, - "created_at": datetime.now(UTC).isoformat(), + "created_at": datetime.now(timezone.utc).isoformat(), } if ticket is not None: payload["ticket"] = ticket.model_dump(mode="json", exclude_none=True) diff --git a/planfile/core/models/ticket.py b/planfile/core/models/ticket.py index e6c4c4a..7436aed 100644 --- a/planfile/core/models/ticket.py +++ b/planfile/core/models/ticket.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import timezone, datetime from typing import Any from pydantic import BaseModel, Field @@ -14,7 +14,7 @@ def _utcnow() -> datetime: - return datetime.now(UTC) + return datetime.now(timezone.utc) class TicketSource(BaseModel): diff --git a/planfile/core/operational_dsl.py b/planfile/core/operational_dsl.py index 4b63650..69c0a58 100644 --- a/planfile/core/operational_dsl.py +++ b/planfile/core/operational_dsl.py @@ -6,7 +6,7 @@ import hashlib import json import re -from datetime import UTC, datetime +from datetime import timezone, datetime from typing import Any from urllib.parse import quote, unquote @@ -57,7 +57,7 @@ def redact(value: Any, key: str = "") -> Any: def _text(value: Any, fallback: str = "-") -> str: if isinstance(value, datetime): - value = value.astimezone(UTC).isoformat() + value = value.astimezone(timezone.utc).isoformat() normalized = str(value if value is not None else "").strip() return normalized or fallback @@ -66,7 +66,7 @@ def create_event(**input: Any) -> dict[str, Any]: data = redact(input.get("data") or {}) core = { "schema": SCHEMA, - "timestamp": _text(input.get("timestamp") or datetime.now(UTC).isoformat()), + "timestamp": _text(input.get("timestamp") or datetime.now(timezone.utc).isoformat()), "kind": _text(input.get("kind"), "operation"), "source": _text(input.get("source"), "planfile"), "ticket_id": _text(input.get("ticket_id")), diff --git a/planfile/core/store.py b/planfile/core/store.py index e7b4064..2b2023f 100644 --- a/planfile/core/store.py +++ b/planfile/core/store.py @@ -7,7 +7,7 @@ import tempfile import time from contextlib import contextmanager -from datetime import UTC, datetime, timedelta +from datetime import timezone, datetime, timedelta from enum import Enum from pathlib import Path from threading import RLock @@ -144,16 +144,16 @@ def _forensic_event_date(event: dict) -> str: try: return datetime.fromisoformat(value).date().isoformat() except ValueError: - return datetime.now(UTC).date().isoformat() + return datetime.now(timezone.utc).date().isoformat() def _forensic_path_for_date(self, value: str) -> Path: - today = datetime.now(UTC).date().isoformat() + today = datetime.now(timezone.utc).date().isoformat() if value == today: return self._forensic_log_path return self._forensic_log_history_dir / f"logs-{value}.dsl.txt" def _rotate_forensic_log_unlocked(self) -> None: - today = datetime.now(UTC).date().isoformat() + today = datetime.now(timezone.utc).date().isoformat() try: recorded_date = self._forensic_log_date_path.read_text(encoding="utf-8").strip() except FileNotFoundError: @@ -227,7 +227,7 @@ def ensure_forensic_log_projection(self) -> None: ).strip() except FileNotFoundError: recorded_date = "" - if recorded_date == datetime.now(UTC).date().isoformat(): + if recorded_date == datetime.now(timezone.utc).date().isoformat(): return with self.mutation_lock(): self._ensure_forensic_log_projection_unlocked() @@ -246,7 +246,7 @@ def forensic_log_lines( from planfile.core.forensic_log_dsl import parse self.ensure_forensic_log_projection() - selected_date = date or datetime.now(UTC).date().isoformat() + selected_date = date or datetime.now(timezone.utc).date().isoformat() path = self._forensic_path_for_date(selected_date) result: deque[str] = deque(maxlen=max(1, min(int(limit), 5000))) try: @@ -273,7 +273,7 @@ def forensic_log_lines( def forensic_log_days(self) -> list[dict]: """Describe every public daily PLOG partition, newest first.""" self.ensure_forensic_log_projection() - today = datetime.now(UTC).date().isoformat() + today = datetime.now(timezone.utc).date().isoformat() paths = [(today, self._forensic_log_path)] for path in self._forensic_log_history_dir.glob("logs-*.dsl.txt"): paths.append((path.name.removeprefix("logs-").removesuffix(".dsl.txt"), path)) @@ -609,9 +609,9 @@ def _ticket_timestamp(ticket: dict) -> datetime: else: continue if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC) - return datetime.min.replace(tzinfo=UTC) + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + return datetime.min.replace(tzinfo=timezone.utc) @classmethod def _merge_sprint_snapshots(cls, current: dict, incoming: dict) -> dict: @@ -819,14 +819,14 @@ def _terminal_archive_candidates( retention only applies to capacity-driven rotation and never keeps stale work in the operational sprint indefinitely. """ - current = now or datetime.now(UTC) + current = now or datetime.now(timezone.utc) if current.tzinfo is None: - current = current.replace(tzinfo=UTC) + current = current.replace(tzinfo=timezone.utc) retention_days = config["retain_terminal_days"] if retention_days == 0: selected_ids = {ticket_id for _, ticket_id, _ in terminal} else: - cutoff_date = current.astimezone(UTC).date() - timedelta( + cutoff_date = current.astimezone(timezone.utc).date() - timedelta( days=retention_days - 1 ) selected_ids = { @@ -858,9 +858,9 @@ def _ticket_archive_timestamp(ticket: dict) -> datetime: else: continue if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC) - return datetime.now(UTC) + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + return datetime.now(timezone.utc) def archive_completed(self, *, force: bool = False) -> dict: """Move stale terminal tickets into daily history files. @@ -1630,7 +1630,7 @@ def migrate_to_sharded_yaml( } self._write_config(config) - stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ") + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") backup_dir = self.base_dir / "storage-backups" / stamp / "sprints" backup_dir.mkdir(parents=True, exist_ok=False) moved = [] @@ -1940,7 +1940,7 @@ def _build_history_entry( previous_state = cls._execution_state(previous) current_state = cls._execution_state(current) entry = { - "timestamp": datetime.now(UTC).isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "action": "update", "source": "planfile.store", "changes": changed_keys, @@ -2015,8 +2015,8 @@ def _updated_at_instant(value: object) -> datetime | None: else: return None if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC) + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) def _guard_expected_updated_at( self, @@ -2131,7 +2131,7 @@ def append_ticket_evidence( raise ValueError("evidence_idempotency_conflict") return current, False - timestamp = datetime.now(UTC).isoformat() + timestamp = datetime.now(timezone.utc).isoformat() event = { "schema": "planfile.ticket-evidence-event/v1", "timestamp": timestamp, @@ -2232,10 +2232,10 @@ def _update_ticket_unlocked( execution_update["state"] = terminal_status execution_update["assigned_to"] = None execution_update["lease_expires_at"] = None - execution_update["finished_at"] = datetime.now(UTC).isoformat() + execution_update["finished_at"] = datetime.now(timezone.utc).isoformat() serialized_updates["execution"] = execution_update tickets[ticket_id].update(serialized_updates) - tickets[ticket_id]["updated_at"] = datetime.now(UTC).isoformat() + tickets[ticket_id]["updated_at"] = datetime.now(timezone.utc).isoformat() changed_keys = sorted( key for key, value in serialized_updates.items() @@ -2305,12 +2305,12 @@ def _update_ticket_sharded_unlocked( execution_update["state"] = terminal_status execution_update["assigned_to"] = None execution_update["lease_expires_at"] = None - execution_update["finished_at"] = datetime.now(UTC).isoformat() + execution_update["finished_at"] = datetime.now(timezone.utc).isoformat() serialized_updates["execution"] = execution_update current = dict(previous) current.update(serialized_updates) - current["updated_at"] = datetime.now(UTC).isoformat() + current["updated_at"] = datetime.now(timezone.utc).isoformat() changed_keys = sorted( key for key, value in serialized_updates.items() @@ -2425,7 +2425,7 @@ def move_ticket(self, ticket_id: str, to_sprint: str) -> bool: moved_ticket = dict(previous_ticket) moved_ticket["sprint"] = to_sprint - moved_ticket["updated_at"] = datetime.now(UTC).isoformat() + moved_ticket["updated_at"] = datetime.now(timezone.utc).isoformat() history = list(moved_ticket.get("history") or []) entry = self._build_history_entry( previous_ticket, @@ -2473,7 +2473,7 @@ def move_ticket(self, ticket_id: str, to_sprint: str) -> bool: previous_ticket = dict(source_tickets[ticket_id]) moved_ticket = dict(previous_ticket) moved_ticket["sprint"] = to_sprint - moved_ticket["updated_at"] = datetime.now(UTC).isoformat() + moved_ticket["updated_at"] = datetime.now(timezone.utc).isoformat() history = list(moved_ticket.get("history") or []) entry = self._build_history_entry( previous_ticket, diff --git a/planfile/runtime_context.py b/planfile/runtime_context.py index b21e7f1..aa09887 100644 --- a/planfile/runtime_context.py +++ b/planfile/runtime_context.py @@ -4,7 +4,7 @@ import os import sys import tempfile -from datetime import UTC, datetime +from datetime import timezone, datetime from pathlib import Path from typing import Any @@ -25,7 +25,7 @@ def _now() -> str: - return datetime.now(UTC).isoformat() + return datetime.now(timezone.utc).isoformat() def _read_json(path: Path) -> Any: diff --git a/pyproject.toml b/pyproject.toml index 39eb198..ab86c52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,14 +67,13 @@ dev = [ "mypy>=1.10", "black>=23.0", "isort>=5.12", - "goal>=2.1.0", "costs>=0.1.20", "pfix>=0.1.60" ] [project.urls] -Homepage = "https://github.com/wronai/strategy" -Repository = "https://github.com/wronai/strategy" +Homepage = "https://github.com/semcod/planfile" +Repository = "https://github.com/semcod/planfile" Documentation = "https://strategy.readthedocs.io" [tool.setuptools.packages.find] @@ -173,3 +172,9 @@ max_commits = 500 # Cost thresholds for badge colors (USD) badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 } + +[tool.uv.dependency-groups.automation] +requires-python = ">=3.12" + +[dependency-groups] +automation = ["goal>=2.2.0"] diff --git a/tests/test_delivery_plan.py b/tests/test_delivery_plan.py index 592e002..e50451a 100644 --- a/tests/test_delivery_plan.py +++ b/tests/test_delivery_plan.py @@ -2,7 +2,7 @@ import hashlib import json -from datetime import UTC, datetime +from datetime import timezone, datetime import pytest from pydantic import ValidationError @@ -200,7 +200,7 @@ def test_checkpoint_and_terminal_receipt_resume_partial_completion(tmp_path) -> "phase": "testing", "head_sha": "d" * 40, "evidence_refs": ["artifact://semcod/planfile/test-report-r1"], - "recorded_at": datetime.now(UTC).isoformat(), + "recorded_at": datetime.now(timezone.utc).isoformat(), } checkpointed = backend.checkpoint_delivery_candidate(checkpoint) @@ -238,7 +238,7 @@ def test_conflicting_checkpoint_and_receipt_fail_closed(tmp_path) -> None: "phase": "testing", "head_sha": None, "evidence_refs": [], - "recorded_at": datetime.now(UTC).isoformat(), + "recorded_at": datetime.now(timezone.utc).isoformat(), } backend.checkpoint_delivery_candidate(checkpoint) with pytest.raises(DeliveryPlanError, match="delivery_plan_checkpoint_conflict"): @@ -267,7 +267,7 @@ def test_split_state_links_children_and_rewires_successor(tmp_path) -> None: "parent_candidate_key": "oversized-parent", "parent_candidate_digest": parent["candidate_digest"], "child_candidate_keys": ["bounded-child-a", "bounded-child-b"], - "recorded_at": datetime.now(UTC).isoformat(), + "recorded_at": datetime.now(timezone.utc).isoformat(), } linked = backend.record_delivery_split(split) diff --git a/tests/test_fastio_mirror_datetime.py b/tests/test_fastio_mirror_datetime.py index 3e37bc4..dc5f6de 100644 --- a/tests/test_fastio_mirror_datetime.py +++ b/tests/test_fastio_mirror_datetime.py @@ -8,7 +8,7 @@ """ import json -from datetime import UTC, date, datetime +from datetime import timezone, date, datetime import pytest @@ -55,7 +55,7 @@ def test_direct_writer_normalizes_runtime_timestamps(sprint_file): sprint_file, { "created_at": date(2026, 8, 26), - "execution": {"finished_at": datetime(2026, 8, 26, 15, tzinfo=UTC)}, + "execution": {"finished_at": datetime(2026, 8, 26, 15, tzinfo=timezone.utc)}, }, ) diff --git a/tests/test_forensic_log_dsl.py b/tests/test_forensic_log_dsl.py index dd35f50..4dde3bb 100644 --- a/tests/test_forensic_log_dsl.py +++ b/tests/test_forensic_log_dsl.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import timezone, datetime, timedelta from fastapi.testclient import TestClient @@ -83,7 +83,7 @@ def test_ticket_lifecycle_and_evidence_are_written_to_public_log(tmp_path) -> No def test_historical_events_are_partitioned_by_utc_day(tmp_path) -> None: pf = Planfile(str(tmp_path)) - yesterday = datetime.now(UTC) - timedelta(days=1) + yesterday = datetime.now(timezone.utc) - timedelta(days=1) with pf.store.mutation_lock(): pf.store._append_operational_line( operational_line( @@ -111,7 +111,7 @@ def test_historical_events_are_partitioned_by_utc_day(tmp_path) -> None: def test_first_read_on_new_utc_day_rotates_the_public_file(tmp_path) -> None: pf = Planfile(str(tmp_path)) - yesterday = datetime.now(UTC) - timedelta(days=1) + yesterday = datetime.now(timezone.utc) - timedelta(days=1) event = parse_operational( operational_line( timestamp=yesterday.isoformat(), diff --git a/tests/test_ticket_archive_force.py b/tests/test_ticket_archive_force.py index 1446876..a1dc441 100644 --- a/tests/test_ticket_archive_force.py +++ b/tests/test_ticket_archive_force.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import timezone, datetime, timedelta import yaml @@ -22,7 +22,7 @@ def test_forced_archive_applies_retention_but_never_moves_active_ticket(tmp_path } store._config_path.write_text(yaml.safe_dump(config, sort_keys=False)) - old = datetime.now(UTC) - timedelta(days=2) + old = datetime.now(timezone.utc) - timedelta(days=2) store.create_ticket(Ticket(id="PLF-001", name="Old done", status="done", created_at=old, updated_at=old)) store.create_ticket(Ticket(id="PLF-002", name="Old blocked", status="blocked", created_at=old, updated_at=old)) store.create_ticket(Ticket(id="PLF-003", name="Keep active", status="open")) diff --git a/tests/test_ticket_archiving.py b/tests/test_ticket_archiving.py index 306a6ec..8cd5d00 100644 --- a/tests/test_ticket_archiving.py +++ b/tests/test_ticket_archiving.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import timezone, datetime, timedelta import yaml @@ -26,7 +26,7 @@ def _store(tmp_path, **archive_config) -> Store: def _ticket(ticket_id: str, status: str, age_days: int = 0) -> Ticket: - timestamp = datetime(2026, 7, 20, 12, tzinfo=UTC) - timedelta(minutes=age_days) + timestamp = datetime(2026, 7, 20, 12, tzinfo=timezone.utc) - timedelta(minutes=age_days) return Ticket( id=ticket_id, name=ticket_id, @@ -181,8 +181,8 @@ def test_stale_terminal_tickets_move_below_capacity_limits(tmp_path): max_current_bytes=10_000_000, retain_terminal_days=1, ) - yesterday = datetime.now(UTC) - timedelta(days=1) - today = datetime.now(UTC) + yesterday = datetime.now(timezone.utc) - timedelta(days=1) + today = datetime.now(timezone.utc) store.create_ticket( Ticket( @@ -214,8 +214,8 @@ def test_stale_terminal_tickets_move_below_capacity_limits(tmp_path): def test_history_is_partitioned_by_terminal_completion_day(tmp_path): store = _store(tmp_path, retain_terminal_days=0) - two_days_ago = datetime.now(UTC) - timedelta(days=2) - yesterday = datetime.now(UTC) - timedelta(days=1) + two_days_ago = datetime.now(timezone.utc) - timedelta(days=2) + yesterday = datetime.now(timezone.utc) - timedelta(days=1) for ticket_id, timestamp in ( ("PLF-001", two_days_ago), @@ -238,7 +238,7 @@ def test_history_is_partitioned_by_terminal_completion_day(tmp_path): def test_zero_day_retention_moves_fresh_terminal_ticket_immediately(tmp_path): store = _store(tmp_path, retain_terminal_days=0) - now = datetime.now(UTC) + now = datetime.now(timezone.utc) store.create_ticket( Ticket( @@ -256,7 +256,7 @@ def test_zero_day_retention_moves_fresh_terminal_ticket_immediately(tmp_path): def test_active_lookup_does_not_parse_history_first(tmp_path, monkeypatch): store = _store(tmp_path, retain_terminal_days=0) - yesterday = datetime.now(UTC) - timedelta(days=1) + yesterday = datetime.now(timezone.utc) - timedelta(days=1) store.create_ticket( Ticket( id="PLF-001", diff --git a/uv.lock b/uv.lock index 490a446..aa787d0 100644 --- a/uv.lock +++ b/uv.lock @@ -630,7 +630,7 @@ wheels = [ [[package]] name = "costs" -version = "0.1.53" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, @@ -642,11 +642,12 @@ dependencies = [ { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "python-dotenv" }, { name = "tiktoken" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/02/126b813e063a47c7e7efef481ac144747f94fd05ae39ee9d5c3c6253a09f/costs-0.1.53.tar.gz", hash = "sha256:9f9f76a9e523e899b72a658c574e33145f0f25b97554c15f70dc714206d3d8ca", size = 30980, upload-time = "2026-07-02T14:11:25.407Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/6f/7f3c72d6d4b5264bd5a3447e18a3b5184bffee143f72c8d023bb759afd1a/costs-0.2.0.tar.gz", hash = "sha256:3782bae859725dae30f697b5f3e5aa5d84e06feadebc5782ae706e21d46437af", size = 44606, upload-time = "2026-09-05T15:49:55.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/0e/a6c82f642985c19f6b42a86e5aaf54086fab4ab3771d54e932ec78a7d577/costs-0.1.53-py3-none-any.whl", hash = "sha256:2795668c83a5974c66b1fb6d7831248a481e1bb009f68c4b345d74d357205b88", size = 35990, upload-time = "2026-07-02T14:11:24.146Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7e/fc91d9fdc7a1ceb9116daffab27124a54f6e8887c9d59d906df43dcbfa6a/costs-0.2.0-py3-none-any.whl", hash = "sha256:4798b8535cbbcbb354e440ebce91365f17c910414bf5831a0610998ae6e679af", size = 48625, upload-time = "2026-09-05T15:49:54.054Z" }, ] [[package]] @@ -870,7 +871,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1148,7 +1149,7 @@ wheels = [ [[package]] name = "goal" -version = "2.1.284" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1158,9 +1159,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/ed/3ef5beaa56627e95a66269adcfce7c219fff1948b93fc6f2f209fb0cea66/goal-2.1.284.tar.gz", hash = "sha256:61d47e6b68ba499fefad17b3df4bc4b6647da9db80a5dbc0918165c81d3f8ab1", size = 334328, upload-time = "2026-07-23T19:42:09.372Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/135e3f67a7324c3c49ef4246f5518b59ad90226e62c3d30ae20cba005c1d/goal-2.2.0.tar.gz", hash = "sha256:e9e2c1ac9493dae3b887b16d503f7c1f83d02ddcbdd3c5cf58d44a458bb2662f", size = 399786, upload-time = "2026-09-05T19:41:55.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/30/8155e85057ef3a8871f04db2a14b10d2216c0af68957f36f0417c2b44821/goal-2.1.284-py3-none-any.whl", hash = "sha256:d00dab399a774d07da902e1ef42ad5282e513e65410bace0c3185b555f365777", size = 309816, upload-time = "2026-07-23T19:42:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/24/69/8a4cf5710683bb777f1f6e4cd44dd3907befe97b94c6ea6e97d4305b4e44/goal-2.2.0-py3-none-any.whl", hash = "sha256:0d8f93aa317ac92a66b64b797e210a5d66044ce8db2cb05b012b03d42aced95e", size = 349709, upload-time = "2026-09-05T19:41:53.615Z" }, ] [[package]] @@ -2353,10 +2354,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2428,10 +2429,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -2510,7 +2511,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "python_full_version < '3.11' or sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2559,7 +2560,7 @@ wheels = [ [[package]] name = "planfile" -version = "0.1.124" +version = "0.1.125" source = { editable = "." } dependencies = [ { name = "filelock" }, @@ -2597,7 +2598,6 @@ dev = [ { name = "black" }, { name = "costs" }, { name = "fastapi" }, - { name = "goal" }, { name = "isort" }, { name = "mypy" }, { name = "pfix" }, @@ -2626,6 +2626,11 @@ openai = [ { name = "openai" }, ] +[package.dev-dependencies] +automation = [ + { name = "goal", marker = "python_full_version >= '3.12'" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'all'", specifier = ">=0.25" }, @@ -2636,7 +2641,6 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.111" }, { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.111" }, { name = "filelock", specifier = ">=3.0" }, - { name = "goal", marker = "extra == 'dev'", specifier = ">=2.1.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12" }, { name = "jira", marker = "extra == 'all'", specifier = ">=3.5" }, @@ -2671,6 +2675,9 @@ requires-dist = [ ] provides-extras = ["github", "jira", "gitlab", "api", "litellm", "openai", "anthropic", "llx", "all", "dev"] +[package.metadata.requires-dev] +automation = [{ name = "goal", marker = "python_full_version >= '3.12'", specifier = ">=2.2.0" }] + [[package]] name = "platformdirs" version = "4.11.0"