From d15c37cd3c0db6d4142ea61d3434aae54a1d0805 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:05:37 +0000 Subject: [PATCH 1/7] fix: add validate_extend_by_ms for spec-compliant extend_by_ms validation The spec requires extend_by_ms to be between 1 and 86400000 (1ms to 24h). Previously _build_extend_body passed ttl_ms as extend_by_ms without validation. Added validate_extend_by_ms() to _validation.py and call it in _build_extend_body. https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- runcycles/_validation.py | 6 ++++++ runcycles/lifecycle.py | 3 ++- tests/test_validation.py | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/runcycles/_validation.py b/runcycles/_validation.py index 6477c89..d88cc66 100644 --- a/runcycles/_validation.py +++ b/runcycles/_validation.py @@ -29,6 +29,12 @@ def validate_ttl_ms(ttl_ms: int) -> None: raise ValueError(f"ttl_ms must be between 1000 and 86400000, got {ttl_ms}") +def validate_extend_by_ms(extend_by_ms: int) -> None: + """Validate extend_by_ms is within allowed range (1ms to 24h).""" + if extend_by_ms < 1 or extend_by_ms > 86_400_000: + raise ValueError(f"extend_by_ms must be between 1 and 86400000, got {extend_by_ms}") + + def validate_grace_period_ms(grace_period_ms: int | None) -> None: """Validate grace period is within allowed range (0 to 60s).""" if grace_period_ms is not None and (grace_period_ms < 0 or grace_period_ms > 60_000): diff --git a/runcycles/lifecycle.py b/runcycles/lifecycle.py index febb620..ad97918 100644 --- a/runcycles/lifecycle.py +++ b/runcycles/lifecycle.py @@ -34,7 +34,7 @@ ) from runcycles.response import CyclesResponse from runcycles.retry import AsyncCommitRetryEngine, CommitRetryEngine -from runcycles._validation import validate_grace_period_ms, validate_non_negative, validate_subject, validate_ttl_ms +from runcycles._validation import validate_extend_by_ms, validate_grace_period_ms, validate_non_negative, validate_subject, validate_ttl_ms logger = logging.getLogger(__name__) @@ -144,6 +144,7 @@ def _build_release_body(reason: str) -> dict[str, Any]: def _build_extend_body(ttl_ms: int) -> dict[str, Any]: + validate_extend_by_ms(ttl_ms) return {"idempotency_key": str(uuid.uuid4()), "extend_by_ms": ttl_ms} diff --git a/tests/test_validation.py b/tests/test_validation.py index d6c9090..66c6d88 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -3,6 +3,7 @@ import pytest from runcycles._validation import ( + validate_extend_by_ms, validate_grace_period_ms, validate_non_negative, validate_reservation_id, @@ -70,6 +71,29 @@ def test_above_maximum(self) -> None: validate_ttl_ms(86_400_001) +class TestValidateExtendByMs: + def test_valid_minimum(self) -> None: + validate_extend_by_ms(1) + + def test_valid_mid_range(self) -> None: + validate_extend_by_ms(60_000) + + def test_valid_maximum(self) -> None: + validate_extend_by_ms(86_400_000) + + def test_zero_rejected(self) -> None: + with pytest.raises(ValueError, match="extend_by_ms"): + validate_extend_by_ms(0) + + def test_negative_rejected(self) -> None: + with pytest.raises(ValueError, match="extend_by_ms"): + validate_extend_by_ms(-1) + + def test_above_maximum(self) -> None: + with pytest.raises(ValueError, match="extend_by_ms"): + validate_extend_by_ms(86_400_001) + + class TestValidateGracePeriodMs: def test_none_is_valid(self) -> None: validate_grace_period_ms(None) From 048e66b0c0e8dccc2a094ef46c1451d479b11c43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:08:32 +0000 Subject: [PATCH 2/7] chore: enable ruff lint rules (E, F, I, UP) and fix violations Added [tool.ruff.lint] select = ["E", "F", "I", "UP"] to pyproject.toml. Fixed all violations: import sorting, unused imports, line length, unused variables, and UP035 (import from collections.abc). https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- examples/async_usage.py | 1 - examples/basic_usage.py | 5 ++--- examples/decorator_usage.py | 1 - pyproject.toml | 3 +++ runcycles/_validation.py | 5 ++++- runcycles/client.py | 13 ++++++++++--- runcycles/config.py | 2 +- runcycles/context.py | 2 +- runcycles/decorator.py | 7 +++++-- runcycles/lifecycle.py | 38 ++++++++++++++++++++++--------------- runcycles/response.py | 4 +++- runcycles/retry.py | 32 ++++++++++++++++++++++++------- tests/test_client.py | 3 +-- tests/test_config.py | 1 - tests/test_context.py | 2 +- tests/test_decorator.py | 37 ++++++++++++++++++++++++++++-------- tests/test_lifecycle.py | 7 +++---- tests/test_models.py | 6 ++---- tests/test_retry.py | 2 +- 19 files changed, 114 insertions(+), 57 deletions(-) diff --git a/examples/async_usage.py b/examples/async_usage.py index 9c64797..512bff4 100644 --- a/examples/async_usage.py +++ b/examples/async_usage.py @@ -10,7 +10,6 @@ get_cycles_context, ) - config = CyclesConfig( base_url="http://localhost:7878", api_key="your-api-key", diff --git a/examples/basic_usage.py b/examples/basic_usage.py index d082280..0001eeb 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -8,7 +8,6 @@ CyclesConfig, CyclesMetrics, ReservationCreateRequest, - ReleaseRequest, Subject, Unit, ) @@ -40,8 +39,8 @@ def main() -> None: reservation_id = response.get_body_attribute("reservation_id") print(f"Reserved: {reservation_id}") - # Simulate work - result = "Generated response text" + # Simulate work (result would be used in a real application) + _ = "Generated response text" # Commit actual usage commit_response = client.commit_reservation(reservation_id, CommitRequest( diff --git a/examples/decorator_usage.py b/examples/decorator_usage.py index 08a7152..9bb5d36 100644 --- a/examples/decorator_usage.py +++ b/examples/decorator_usage.py @@ -8,7 +8,6 @@ get_cycles_context, ) - config = CyclesConfig( base_url="http://localhost:7878", api_key="your-api-key", diff --git a/pyproject.toml b/pyproject.toml index fe11313..be76162 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ dev = [ target-version = "py310" line-length = 120 +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + [tool.mypy] python_version = "3.10" strict = true diff --git a/runcycles/_validation.py b/runcycles/_validation.py index d88cc66..b3301db 100644 --- a/runcycles/_validation.py +++ b/runcycles/_validation.py @@ -8,7 +8,10 @@ def validate_subject(subject: Subject | None) -> None: """Validate that a subject has at least one standard field.""" if subject is not None and not subject.has_at_least_one_standard_field(): - raise ValueError("Subject must have at least one standard field (tenant, workspace, app, workflow, agent, or toolset)") + raise ValueError( + "Subject must have at least one standard field" + " (tenant, workspace, app, workflow, agent, or toolset)" + ) def validate_reservation_id(reservation_id: str | None) -> None: diff --git a/runcycles/client.py b/runcycles/client.py index e998f2c..3cadea0 100644 --- a/runcycles/client.py +++ b/runcycles/client.py @@ -54,7 +54,10 @@ def _extract_response_headers(resp: httpx.Response) -> dict[str, str]: def _validate_balance_filters(params: dict[str, str]) -> None: """Validate that at least one subject filter is provided for balance queries.""" if not any(k in _BALANCE_FILTER_PARAMS for k in params): - raise ValueError("get_balances requires at least one subject filter (tenant, workspace, app, workflow, agent, or toolset)") + raise ValueError( + "get_balances requires at least one subject filter" + " (tenant, workspace, app, workflow, agent, or toolset)" + ) class CyclesClient: @@ -148,7 +151,9 @@ def _handle_response(resp: httpx.Response) -> CyclesResponse: error_msg = None if body and isinstance(body, dict): error_msg = body.get("message") or body.get("error") - return CyclesResponse.http_error(resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers) + return CyclesResponse.http_error( + resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers, + ) class AsyncCyclesClient: @@ -242,4 +247,6 @@ def _handle_response(resp: httpx.Response) -> CyclesResponse: error_msg = None if body and isinstance(body, dict): error_msg = body.get("message") or body.get("error") - return CyclesResponse.http_error(resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers) + return CyclesResponse.http_error( + resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers, + ) diff --git a/runcycles/config.py b/runcycles/config.py index 1eb1b18..baf8bd0 100644 --- a/runcycles/config.py +++ b/runcycles/config.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from dataclasses import dataclass, field +from dataclasses import dataclass @dataclass diff --git a/runcycles/context.py b/runcycles/context.py index 1fb5a3f..9c77e14 100644 --- a/runcycles/context.py +++ b/runcycles/context.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextvars import ContextVar -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any from runcycles.models import Amount, Balance, Caps, CyclesMetrics, Decision diff --git a/runcycles/decorator.py b/runcycles/decorator.py index a0bc441..d39dc18 100644 --- a/runcycles/decorator.py +++ b/runcycles/decorator.py @@ -4,7 +4,8 @@ import functools import inspect -from typing import Any, Callable, TypeVar +from collections.abc import Callable +from typing import Any, TypeVar from runcycles.client import AsyncCyclesClient, CyclesClient from runcycles.config import CyclesConfig @@ -31,7 +32,9 @@ def set_default_config(config: CyclesConfig) -> None: _default_config = config -def _get_effective_client(explicit_client: CyclesClient | AsyncCyclesClient | None, is_async: bool) -> CyclesClient | AsyncCyclesClient: +def _get_effective_client( + explicit_client: CyclesClient | AsyncCyclesClient | None, is_async: bool, +) -> CyclesClient | AsyncCyclesClient: global _default_client if explicit_client is not None: return explicit_client diff --git a/runcycles/lifecycle.py b/runcycles/lifecycle.py index ad97918..5323aa9 100644 --- a/runcycles/lifecycle.py +++ b/runcycles/lifecycle.py @@ -7,9 +7,17 @@ import threading import time import uuid +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable - +from typing import Any + +from runcycles._validation import ( + validate_extend_by_ms, + validate_grace_period_ms, + validate_non_negative, + validate_subject, + validate_ttl_ms, +) from runcycles.client import AsyncCyclesClient, CyclesClient from runcycles.context import CyclesContext, _clear_context, _set_context from runcycles.exceptions import ( @@ -21,20 +29,14 @@ ReservationFinalizedError, ) from runcycles.models import ( - Action, - Amount, - Caps, CyclesMetrics, Decision, DryRunResult, - ErrorCode, ReservationCreateResponse, Subject, - Unit, ) from runcycles.response import CyclesResponse from runcycles.retry import AsyncCommitRetryEngine, CommitRetryEngine -from runcycles._validation import validate_extend_by_ms, validate_grace_period_ms, validate_non_negative, validate_subject, validate_ttl_ms logger = logging.getLogger(__name__) @@ -86,7 +88,9 @@ def _evaluate_actual( raise ValueError("actual expression is required when use_estimate_if_actual_not_provided is False") -def _build_reservation_body(cfg: DecoratorConfig, estimate: int, default_subject_fields: dict[str, str | None]) -> dict[str, Any]: +def _build_reservation_body( + cfg: DecoratorConfig, estimate: int, default_subject_fields: dict[str, str | None], +) -> dict[str, Any]: """Build the reservation create request body.""" validate_non_negative(estimate, "estimate") validate_ttl_ms(cfg.ttl_ms) @@ -127,7 +131,9 @@ def _build_reservation_body(cfg: DecoratorConfig, estimate: int, default_subject return body -def _build_commit_body(actual: int, unit: str, metrics: CyclesMetrics | None, metadata: dict[str, Any] | None) -> dict[str, Any]: +def _build_commit_body( + actual: int, unit: str, metrics: CyclesMetrics | None, metadata: dict[str, Any] | None, +) -> dict[str, Any]: body: dict[str, Any] = { "idempotency_key": str(uuid.uuid4()), "actual": {"unit": unit, "amount": actual}, @@ -208,7 +214,9 @@ def _build_protocol_exception(prefix: str, response: CyclesResponse) -> CyclesPr class CyclesLifecycle: """Synchronous lifecycle orchestrator: reserve → execute → commit/release.""" - def __init__(self, client: CyclesClient, retry_engine: CommitRetryEngine, default_subject: dict[str, str | None]) -> None: + def __init__( + self, client: CyclesClient, retry_engine: CommitRetryEngine, default_subject: dict[str, str | None], + ) -> None: self._client = client self._retry_engine = retry_engine self._retry_engine.set_client(client) @@ -312,7 +320,7 @@ def execute( return result - except Exception as ex: + except Exception: logger.error("Guarded action failed, releasing: id=%s", reservation_id, exc_info=True) self._handle_release(reservation_id, "guarded_method_failed") raise @@ -390,7 +398,9 @@ def heartbeat_loop() -> None: class AsyncCyclesLifecycle: """Asynchronous lifecycle orchestrator: reserve → execute → commit/release.""" - def __init__(self, client: AsyncCyclesClient, retry_engine: AsyncCommitRetryEngine, default_subject: dict[str, str | None]) -> None: + def __init__( + self, client: AsyncCyclesClient, retry_engine: AsyncCommitRetryEngine, default_subject: dict[str, str | None], + ) -> None: self._client = client self._retry_engine = retry_engine self._retry_engine.set_client(client) @@ -407,7 +417,6 @@ async def execute( logger.debug("Estimated usage: estimate=%d", estimate) create_body = _build_reservation_body(cfg, estimate, self._default_subject) - res_t1 = time.monotonic() res_response = await self._client.create_reservation(create_body) if not res_response.is_success: @@ -421,7 +430,6 @@ async def execute( reason_code = res_result.reason_code if cfg.dry_run: - elapsed_ms = int((res_t2 - res_t1) * 1000) if decision == Decision.DENY: raise _build_protocol_exception("Dry-run denied", res_response) return DryRunResult( diff --git a/runcycles/response.py b/runcycles/response.py index 1ade1fe..aebe24f 100644 --- a/runcycles/response.py +++ b/runcycles/response.py @@ -24,7 +24,9 @@ def success(cls, status: int, body: dict[str, Any], headers: dict[str, str] | No return cls(status=status, body=body, headers=headers or {}) @classmethod - def http_error(cls, status: int, error_message: str, body: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> CyclesResponse: + def http_error( + cls, status: int, error_message: str, body: dict[str, Any] | None = None, headers: dict[str, str] | None = None, + ) -> CyclesResponse: return cls(status=status, body=body, error_message=error_message, headers=headers or {}) @classmethod diff --git a/runcycles/retry.py b/runcycles/retry.py index 9077217..621e112 100644 --- a/runcycles/retry.py +++ b/runcycles/retry.py @@ -64,7 +64,10 @@ def _retry_loop(self, pending: _PendingCommit) -> None: return response = self._client.commit_reservation(pending.reservation_id, pending.commit_body) if response.is_success: - logger.info("Commit retry succeeded: reservation_id=%s, attempt=%d", pending.reservation_id, pending.attempt) + logger.info( + "Commit retry succeeded: reservation_id=%s, attempt=%d", + pending.reservation_id, pending.attempt, + ) return elif response.is_client_error: logger.warning( @@ -78,9 +81,15 @@ def _retry_loop(self, pending: _PendingCommit) -> None: pending.reservation_id, pending.attempt, response.status, ) except Exception: - logger.exception("Commit retry error: reservation_id=%s, attempt=%d", pending.reservation_id, pending.attempt) + logger.exception( + "Commit retry error: reservation_id=%s, attempt=%d", + pending.reservation_id, pending.attempt, + ) - logger.error("Commit retry exhausted: reservation_id=%s, attempts=%d", pending.reservation_id, self._max_attempts) + logger.error( + "Commit retry exhausted: reservation_id=%s, attempts=%d", + pending.reservation_id, self._max_attempts, + ) class AsyncCommitRetryEngine: @@ -128,7 +137,10 @@ async def _retry_loop(self, pending: _PendingCommit) -> None: return response = await self._client.commit_reservation(pending.reservation_id, pending.commit_body) if response.is_success: - logger.info("Async commit retry succeeded: reservation_id=%s, attempt=%d", pending.reservation_id, pending.attempt) + logger.info( + "Async commit retry succeeded: reservation_id=%s, attempt=%d", + pending.reservation_id, pending.attempt, + ) return elif response.is_client_error: logger.warning( @@ -142,6 +154,12 @@ async def _retry_loop(self, pending: _PendingCommit) -> None: pending.reservation_id, pending.attempt, response.status, ) except Exception: - logger.exception("Async commit retry error: reservation_id=%s, attempt=%d", pending.reservation_id, pending.attempt) - - logger.error("Async commit retry exhausted: reservation_id=%s, attempts=%d", pending.reservation_id, self._max_attempts) + logger.exception( + "Async commit retry error: reservation_id=%s, attempt=%d", + pending.reservation_id, pending.attempt, + ) + + logger.error( + "Async commit retry exhausted: reservation_id=%s, attempts=%d", + pending.reservation_id, self._max_attempts, + ) diff --git a/tests/test_client.py b/tests/test_client.py index 7d53119..8e3b81f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,5 @@ """Tests for CyclesClient and AsyncCyclesClient.""" -import json import httpx import pytest @@ -13,9 +12,9 @@ CommitRequest, DecisionRequest, EventCreateRequest, + ReleaseRequest, ReservationCreateRequest, ReservationExtendRequest, - ReleaseRequest, Subject, Unit, ) diff --git a/tests/test_config.py b/tests/test_config.py index 2aa3395..2ff2589 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,5 @@ """Tests for CyclesConfig.""" -import os import pytest diff --git a/tests/test_context.py b/tests/test_context.py index 1964c85..3a84382 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,7 +1,7 @@ """Tests for CyclesContext.""" from runcycles.context import CyclesContext, _clear_context, _set_context, get_cycles_context -from runcycles.models import Amount, Caps, CyclesMetrics, Decision, Unit +from runcycles.models import Caps, CyclesMetrics, Decision class TestCyclesContext: diff --git a/tests/test_decorator.py b/tests/test_decorator.py index 620a36e..62daa65 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -6,7 +6,7 @@ from runcycles.config import CyclesConfig from runcycles.context import get_cycles_context from runcycles.decorator import cycles, set_default_client, set_default_config -from runcycles.exceptions import BudgetExceededError, CyclesProtocolError +from runcycles.exceptions import CyclesProtocolError @pytest.fixture @@ -55,7 +55,10 @@ def test_callable_estimate_and_actual(self, config: CyclesConfig, httpx_mock) -> httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_dec_2", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_dec_2", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( @@ -101,7 +104,10 @@ def test_function_exception_triggers_release(self, config: CyclesConfig, httpx_m httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_dec_3", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_dec_3", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( @@ -125,7 +131,10 @@ def test_context_cleared_after_call(self, config: CyclesConfig, httpx_mock) -> N httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_dec_4", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_dec_4", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( @@ -153,7 +162,10 @@ async def test_basic_async_lifecycle(self, config: CyclesConfig, httpx_mock) -> httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_async_1", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_async_1", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( @@ -181,7 +193,10 @@ def test_set_default_client(self, config: CyclesConfig, httpx_mock) -> None: # httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_def_1", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_def_1", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( @@ -206,7 +221,10 @@ def test_set_default_config_creates_client_lazily(self, config: CyclesConfig, ht httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_lazy_1", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_lazy_1", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( @@ -249,7 +267,10 @@ async def test_set_default_config_creates_async_client_lazily(self, config: Cycl httpx_mock.add_response( method="POST", url="http://localhost:7878/v1/reservations", - json={"decision": "ALLOW", "reservation_id": "res_lazy_a1", "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"]}, + json={ + "decision": "ALLOW", "reservation_id": "res_lazy_a1", + "expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"], + }, status_code=200, ) httpx_mock.add_response( diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index f6c450a..a839cf9 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,7 +2,7 @@ import asyncio import time -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -28,7 +28,7 @@ _evaluate_actual, _evaluate_amount, ) -from runcycles.models import CyclesMetrics, Decision +from runcycles.models import CyclesMetrics from runcycles.response import CyclesResponse from runcycles.retry import AsyncCommitRetryEngine, CommitRetryEngine @@ -179,7 +179,6 @@ def test_extracts_error_details(self) -> None: assert exc.request_id == "req-123" def test_maps_to_typed_exception(self) -> None: - from runcycles.exceptions import BudgetExceededError response = CyclesResponse.http_error( 409, @@ -593,7 +592,7 @@ def test_heartbeat_skipped_when_ttl_zero(self) -> None: mock_client.create_reservation.return_value = _allow_response() mock_client.commit_reservation.return_value = _commit_success() - cfg = _make_cfg(ttl_ms=1000) + _make_cfg(ttl_ms=1000) # We can't easily set ttl_ms=0 since validation rejects it, but we can test # the heartbeat path by calling _start_heartbeat directly import threading diff --git a/tests/test_models.py b/tests/test_models.py index a85f1c9..c8a912f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,18 +12,16 @@ CommitRequest, CyclesMetrics, Decision, - DecisionRequest, DecisionResponse, DryRunResult, ErrorCode, ErrorResponse, - EventCreateRequest, + ReleaseRequest, ReservationCreateRequest, + ReservationCreateResponse, ReservationDetail, ReservationExtendRequest, - ReservationCreateResponse, ReservationStatus, - ReleaseRequest, SignedAmount, Subject, Unit, diff --git a/tests/test_retry.py b/tests/test_retry.py index 49acbaa..80f7691 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from unittest.mock import MagicMock, AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest From 137ea6a0e05f40b4f62888d9030c07a502324310 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:09:12 +0000 Subject: [PATCH 3/7] perf: cache lifecycle instance at decoration time instead of per-call Previously, every invocation of a @cycles-decorated function created a new CommitRetryEngine and CyclesLifecycle. Now the lifecycle is lazily built on first call and cached for subsequent calls, matching the TypeScript client fix (PR#6/PR#10). Client resolution is deferred to first call to support set_default_client/set_default_config patterns. https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- runcycles/decorator.py | 65 ++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/runcycles/decorator.py b/runcycles/decorator.py index d39dc18..d5495e8 100644 --- a/runcycles/decorator.py +++ b/runcycles/decorator.py @@ -140,50 +140,53 @@ def call_llm(prompt: str, tokens: int) -> str: use_estimate_if_actual_not_provided=use_estimate_if_actual_not_provided, ) + def _build_lifecycle( + effective_client: CyclesClient | AsyncCyclesClient, + ) -> CyclesLifecycle | AsyncCyclesLifecycle: + config = effective_client._config + default_subject = { + "tenant": config.tenant, + "workspace": config.workspace, + "app": config.app, + "workflow": config.workflow, + "agent": config.agent, + "toolset": config.toolset, + } + if isinstance(effective_client, AsyncCyclesClient): + retry_engine = AsyncCommitRetryEngine(config) + return AsyncCyclesLifecycle(effective_client, retry_engine, default_subject) + retry_engine = CommitRetryEngine(config) + return CyclesLifecycle(effective_client, retry_engine, default_subject) + def decorator(fn: F) -> F: is_async = inspect.iscoroutinefunction(fn) + _cached: list[CyclesLifecycle | AsyncCyclesLifecycle | None] = [None] if is_async: @functools.wraps(fn) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: - effective_client = _get_effective_client(client, is_async=True) - if not isinstance(effective_client, AsyncCyclesClient): - raise TypeError("Async function requires an AsyncCyclesClient") - - config = effective_client._config - default_subject = { - "tenant": config.tenant, - "workspace": config.workspace, - "app": config.app, - "workflow": config.workflow, - "agent": config.agent, - "toolset": config.toolset, - } - retry_engine = AsyncCommitRetryEngine(config) - lifecycle = AsyncCyclesLifecycle(effective_client, retry_engine, default_subject) - return await lifecycle.execute(fn, args, kwargs, cfg) + lifecycle = _cached[0] + if lifecycle is None: + effective_client = _get_effective_client(client, is_async=True) + if not isinstance(effective_client, AsyncCyclesClient): + raise TypeError("Async function requires an AsyncCyclesClient") + lifecycle = _build_lifecycle(effective_client) + _cached[0] = lifecycle + return await lifecycle.execute(fn, args, kwargs, cfg) # type: ignore[union-attr] return async_wrapper # type: ignore[return-value] else: @functools.wraps(fn) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: - effective_client = _get_effective_client(client, is_async=False) - if not isinstance(effective_client, CyclesClient): - raise TypeError("Sync function requires a CyclesClient") - - config = effective_client._config - default_subject = { - "tenant": config.tenant, - "workspace": config.workspace, - "app": config.app, - "workflow": config.workflow, - "agent": config.agent, - "toolset": config.toolset, - } - retry_engine = CommitRetryEngine(config) - lifecycle = CyclesLifecycle(effective_client, retry_engine, default_subject) + lifecycle = _cached[0] + if lifecycle is None: + effective_client = _get_effective_client(client, is_async=False) + if not isinstance(effective_client, CyclesClient): + raise TypeError("Sync function requires a CyclesClient") + lifecycle = _build_lifecycle(effective_client) + _cached[0] = lifecycle return lifecycle.execute(fn, args, kwargs, cfg) return sync_wrapper # type: ignore[return-value] From 5258b8b67badf54d47ba1da6a1c6de0f496a9491 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:09:38 +0000 Subject: [PATCH 4/7] ci: add test workflow with Python 3.10/3.12 matrix, ruff, mypy, pytest The 260+ tests, strict mypy, and ruff lint rules were configured but never enforced in CI. This workflow runs on push/PR to main with a matrix across Python 3.10 (minimum) and 3.12 (current). https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..947f3d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + name: Lint & Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check . + + - name: Type check with mypy + run: mypy runcycles + + - name: Run tests + run: pytest From 6b7bfae57f85daa5881bc09401d18df26edeee38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:10:53 +0000 Subject: [PATCH 5/7] docs: add AUDIT.md for Cycles Protocol v0.1.23 conformance Maps all 9 endpoints, 6 request schemas, 10 response schemas, 5 enum types, and 8 nested object schemas to their Python implementations. All categories pass. Follows the format from cycles-spring-boot-starter. https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- AUDIT.md | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 AUDIT.md diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..dde85a5 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,169 @@ +# Cycles Protocol v0.1.23 — Client (Python) Audit + +**Date:** 2026-03-14 +**Spec:** `cycles-protocol-v0.yaml` (OpenAPI 3.1.0, v0.1.23) +**Client:** `runcycles` (Python 3.10+ / httpx / Pydantic v2) +**Server audit:** See `cycles-server/AUDIT.md` (all passing) + +--- + +## Summary + +| Category | Pass | Issues | +|----------|------|--------| +| Endpoints & HTTP Methods | 9/9 | 0 | +| Request Schemas (field names & JSON keys) | 6/6 | 0 | +| Response Schemas (field names & JSON keys) | 10/10 | 0 | +| Enum Values | 5/5 | 0 | +| Nested Object Schemas | 8/8 | 0 | +| Auth Header (X-Cycles-API-Key) | — | 0 | +| Idempotency (header ↔ body sync) | — | 0 | +| Subject Validation | — | 0 | +| Response Header Capture | — | 0 | +| Client-Side Spec Constraint Validation | — | 0 | +| Lifecycle Orchestration | — | 0 | + +**Overall: Client is protocol-conformant.** All endpoints, schemas, field names, JSON keys, and enum values match the OpenAPI spec. No open issues. + +--- + +## Audit Scope + +Compared the following across spec YAML and client Python source: +- All 9 endpoint paths, HTTP methods, and path/query parameters +- All 6 request body serializations vs spec schemas +- All 10 response model deserializations vs spec schemas +- All 5 enum types and their values +- Nested object schemas (Subject, Action, Amount, SignedAmount, Caps, CyclesMetrics, Balance, ErrorResponse) +- Auth and idempotency header handling +- Subject constraint validation (`anyOf` / at least one standard field) +- Pydantic Field constraints vs spec min/max bounds +- Lifecycle orchestration (reserve → execute → commit/release) + +--- + +## PASS — Correctly Implemented + +### Endpoints (all 9 match spec) + +| Spec Endpoint | Client Method | HTTP Method | Match | +|---|---|---|---| +| `/v1/decide` | `client.decide()` | POST | PASS | +| `/v1/reservations` (create) | `client.create_reservation()` | POST | PASS | +| `/v1/reservations` (list) | `client.list_reservations()` | GET | PASS | +| `/v1/reservations/{reservation_id}` | `client.get_reservation()` | GET | PASS | +| `/v1/reservations/{reservation_id}/commit` | `client.commit_reservation()` | POST | PASS | +| `/v1/reservations/{reservation_id}/release` | `client.release_reservation()` | POST | PASS | +| `/v1/reservations/{reservation_id}/extend` | `client.extend_reservation()` | POST | PASS | +| `/v1/balances` | `client.get_balances()` | GET | PASS | +| `/v1/events` | `client.create_event()` | POST | PASS | + +### Request Schemas (all match spec JSON keys) + +**ReservationCreateRequest** — spec required: `[idempotency_key, subject, action, estimate]` +- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `ttl_ms`, `grace_period_ms`, `overage_policy`, `dry_run`, `metadata` — all snake_case, all match spec + +**CommitRequest** — spec required: `[idempotency_key, actual]` +- Pydantic fields: `idempotency_key`, `actual`, `metrics`, `metadata` — all match spec + +**ReleaseRequest** — spec required: `[idempotency_key]` +- Pydantic fields: `idempotency_key`, `reason` — all match spec + +**DecisionRequest** — spec required: `[idempotency_key, subject, action, estimate]` +- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `metadata` — all match spec + +**EventCreateRequest** — spec required: `[idempotency_key, subject, action, actual]` +- Pydantic fields: `idempotency_key`, `subject`, `action`, `actual`, `overage_policy`, `metrics`, `client_time_ms`, `metadata` — all match spec + +**ReservationExtendRequest** — spec required: `[idempotency_key, extend_by_ms]` +- Pydantic fields: `idempotency_key`, `extend_by_ms`, `metadata` — all match spec + +### Response Schemas (all match spec JSON keys) + +| Spec Schema | Client Class | JSON Keys | Match | +|---|---|---|---| +| `ReservationCreateResponse` | `ReservationCreateResponse` | `decision`, `reservation_id`, `affected_scopes`, `expires_at_ms`, `scope_path`, `reserved`, `caps`, `reason_code`, `retry_after_ms`, `balances` | PASS | +| `CommitResponse` | `CommitResponse` | `status`, `charged`, `released`, `balances` | PASS | +| `ReleaseResponse` | `ReleaseResponse` | `status`, `released`, `balances` | PASS | +| `DecisionResponse` | `DecisionResponse` | `decision`, `caps`, `reason_code`, `retry_after_ms`, `affected_scopes` | PASS | +| `EventCreateResponse` | `EventCreateResponse` | `status`, `event_id`, `balances` | PASS | +| `ReservationExtendResponse` | `ReservationExtendResponse` | `status`, `expires_at_ms`, `balances` | PASS | +| `BalanceResponse` | `BalanceResponse` | `balances`, `has_more`, `next_cursor` | PASS | +| `ReservationDetail` | `ReservationDetail` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `committed`, `created_at_ms`, `expires_at_ms`, `finalized_at_ms`, `scope_path`, `affected_scopes`, `metadata` | PASS | +| `ReservationSummary` | `ReservationSummary` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `created_at_ms`, `expires_at_ms`, `scope_path`, `affected_scopes` | PASS | +| `ReservationListResponse` | `ReservationListResponse` | `reservations`, `has_more`, `next_cursor` | PASS | + +### Nested Object Schemas (all match) + +| Spec Schema | Client Class | JSON Keys | Match | +|---|---|---|---| +| `Subject` | `Subject` | `tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`, `dimensions` | PASS | +| `Action` | `Action` | `kind`, `name`, `tags` | PASS | +| `Amount` | `Amount` | `unit`, `amount` | PASS | +| `SignedAmount` | `SignedAmount` | `unit`, `amount` | PASS | +| `Caps` | `Caps` | `max_tokens`, `max_steps_remaining`, `tool_allowlist`, `tool_denylist`, `cooldown_ms` | PASS | +| `StandardMetrics` | `CyclesMetrics` | `tokens_input`, `tokens_output`, `latency_ms`, `model_version`, `custom` | PASS | +| `Balance` | `Balance` | `scope`, `scope_path`, `remaining`, `reserved`, `spent`, `allocated`, `debt`, `overdraft_limit`, `is_over_limit` | PASS | +| `ErrorResponse` | `ErrorResponse` | `error`, `message`, `request_id`, `details` | PASS | + +### Enum Values (all match spec) + +| Spec Enum | Client Enum | Values | Match | +|---|---|---|---| +| `DecisionEnum` | `Decision` | `ALLOW`, `ALLOW_WITH_CAPS`, `DENY` | PASS | +| `UnitEnum` | `Unit` | `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS` | PASS | +| `CommitOveragePolicy` | `CommitOveragePolicy` | `REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT` | PASS | +| `ReservationStatus` | `ReservationStatus` | `ACTIVE`, `COMMITTED`, `RELEASED`, `EXPIRED` | PASS | +| `ErrorCode` | `ErrorCode` | All 12 spec values + `UNKNOWN` (client fallback) | PASS | + +Note: Client `ErrorCode` adds `UNKNOWN` as a fallback for unrecognized server error codes. This is a client-side convenience and does not violate the spec. + +### Auth & Idempotency (correct) + +- **X-Cycles-API-Key**: Set on all requests via `httpx.Client` base headers in `CyclesClient.__init__()` (`client.py`) +- **X-Idempotency-Key**: Extracted from request body `idempotency_key` field via `_extract_idempotency_key()` and set as header in `_post()`. Header and body values always match (copied from body to header), satisfying the spec rule: "If X-Idempotency-Key header is present and body.idempotency_key is present, they MUST match." + +### Subject Validation (correct) + +- `validate_subject()` in `_validation.py` calls `Subject.has_at_least_one_standard_field()` which checks all 6 standard fields — matches spec `anyOf` constraint +- Pydantic Field constraints enforce `maxLength: 128` on all Subject fields and `maxLength: 256` on dimension values + +### Response Header Capture (correct) + +- `_extract_response_headers()` in `client.py` captures `x-request-id`, `x-ratelimit-remaining`, `x-ratelimit-reset`, `x-cycles-tenant` +- Exposed via `CyclesResponse` properties: `request_id`, `rate_limit_remaining`, `rate_limit_reset`, `cycles_tenant` + +### Client-Side Spec Constraint Validation (correct) + +All spec constraints are validated both via Pydantic Field validators (on typed request models) and via explicit validation functions (on dict-based lifecycle path): + +- `validate_non_negative()`: `Amount.amount >= 0` (spec `minimum: 0`) +- `validate_ttl_ms()`: 1000–86400000 (spec `minimum: 1000, maximum: 86400000`) +- `validate_grace_period_ms()`: 0–60000 (spec `minimum: 0, maximum: 60000`) +- `validate_extend_by_ms()`: 1–86400000 (spec `minimum: 1, maximum: 86400000`) +- Pydantic `Field(ge=1, le=86_400_000)` on `ReservationExtendRequest.extend_by_ms` +- Pydantic `Field(max_length=64)` on `Action.kind`, `Field(max_length=256)` on `Action.name` +- Pydantic `Field(min_length=1, max_length=256)` on all `idempotency_key` fields + +### Lifecycle Orchestration (correct) + +- Reserve → Execute → Commit flow with proper cleanup (release on failure) +- Heartbeat-based TTL extension at `max(ttl_ms / 2, 1000)` ms interval using `extend` endpoint +- Commit retry engine for transient failures (transport errors, 5xx) with exponential backoff +- Dry-run handling returns `DryRunResult` without executing guarded function +- `DENY` decision correctly raises typed `CyclesProtocolError` +- `ALLOW_WITH_CAPS` correctly propagates `Caps` via `CyclesContext` +- Lifecycle instance cached at decoration time (deferred client resolution on first call) +- `ContextVar`-based context propagation (safe for both sync threads and async tasks) + +### HTTP Status Code Handling (correct) + +- `is_success` correctly handles 2xx range (200 for most endpoints, 201 for events) +- Error responses parsed via `ErrorResponse.model_validate()` with `ErrorCode` mapping +- Typed exceptions: `BudgetExceededError`, `OverdraftLimitExceededError`, `DebtOutstandingError`, `ReservationExpiredError`, `ReservationFinalizedError` + +--- + +## Verdict + +The client is **fully protocol-conformant** with the Cycles Protocol v0.1.23 OpenAPI spec. All 9 endpoints, 6 request schemas, 10 response schemas, 5 enum types, and all nested object serializations match the spec exactly. JSON field names use correct snake_case throughout. Auth headers, idempotency handling, subject validation, response header capture, and spec constraint validation all follow spec normative rules. No open issues. From b84ae6f0bf26f9f53ef8c659687a10f4d192b46e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:12:18 +0000 Subject: [PATCH 6/7] fix: resolve mypy strict errors in decorator lifecycle caching Use separate typed cache cells for sync/async paths and inline the retry engine construction to avoid variable type conflicts. https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- runcycles/decorator.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/runcycles/decorator.py b/runcycles/decorator.py index d5495e8..0581673 100644 --- a/runcycles/decorator.py +++ b/runcycles/decorator.py @@ -140,11 +140,11 @@ def call_llm(prompt: str, tokens: int) -> str: use_estimate_if_actual_not_provided=use_estimate_if_actual_not_provided, ) - def _build_lifecycle( + def _build_default_subject( effective_client: CyclesClient | AsyncCyclesClient, - ) -> CyclesLifecycle | AsyncCyclesLifecycle: + ) -> dict[str, str | None]: config = effective_client._config - default_subject = { + return { "tenant": config.tenant, "workspace": config.workspace, "app": config.app, @@ -152,41 +152,41 @@ def _build_lifecycle( "agent": config.agent, "toolset": config.toolset, } - if isinstance(effective_client, AsyncCyclesClient): - retry_engine = AsyncCommitRetryEngine(config) - return AsyncCyclesLifecycle(effective_client, retry_engine, default_subject) - retry_engine = CommitRetryEngine(config) - return CyclesLifecycle(effective_client, retry_engine, default_subject) def decorator(fn: F) -> F: is_async = inspect.iscoroutinefunction(fn) - _cached: list[CyclesLifecycle | AsyncCyclesLifecycle | None] = [None] if is_async: + _cached_async: list[AsyncCyclesLifecycle | None] = [None] @functools.wraps(fn) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: - lifecycle = _cached[0] + lifecycle = _cached_async[0] if lifecycle is None: effective_client = _get_effective_client(client, is_async=True) if not isinstance(effective_client, AsyncCyclesClient): raise TypeError("Async function requires an AsyncCyclesClient") - lifecycle = _build_lifecycle(effective_client) - _cached[0] = lifecycle - return await lifecycle.execute(fn, args, kwargs, cfg) # type: ignore[union-attr] + subject = _build_default_subject(effective_client) + engine = AsyncCommitRetryEngine(effective_client._config) + lifecycle = AsyncCyclesLifecycle(effective_client, engine, subject) + _cached_async[0] = lifecycle + return await lifecycle.execute(fn, args, kwargs, cfg) return async_wrapper # type: ignore[return-value] else: + _cached_sync: list[CyclesLifecycle | None] = [None] @functools.wraps(fn) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: - lifecycle = _cached[0] + lifecycle = _cached_sync[0] if lifecycle is None: effective_client = _get_effective_client(client, is_async=False) if not isinstance(effective_client, CyclesClient): raise TypeError("Sync function requires a CyclesClient") - lifecycle = _build_lifecycle(effective_client) - _cached[0] = lifecycle + subject = _build_default_subject(effective_client) + engine = CommitRetryEngine(effective_client._config) + lifecycle = CyclesLifecycle(effective_client, engine, subject) + _cached_sync[0] = lifecycle return lifecycle.execute(fn, args, kwargs, cfg) return sync_wrapper # type: ignore[return-value] From 22f0ce5682ed8b0c640bbd2498fc9fb5b4625db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:18:40 +0000 Subject: [PATCH 7/7] fix: add missing ExtendStatus import and add Development section to README __init__.py listed ExtendStatus in __all__ but never imported it from models, causing ImportError on `from runcycles import ExtendStatus`. Added Development section to README documenting lint, type check, and test commands plus CI coverage. https://claude.ai/code/session_01HXikxwPuurjunotYpbmsE7 --- README.md | 17 +++++++++++++++++ runcycles/__init__.py | 1 + 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index 21e7985..0e53407 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,23 @@ def overdraft_func() -> str: - **Response metadata**: Access `request_id`, `rate_limit_remaining`, and `rate_limit_reset` on every response - **Environment config**: `CyclesConfig.from_env()` for 12-factor apps +## Development + +```bash +pip install -e ".[dev]" + +# Lint +ruff check . + +# Type check (strict mode) +mypy runcycles + +# Run tests +pytest +``` + +CI runs all three checks on Python 3.10 and 3.12 for every push and pull request. + ## Requirements - Python 3.10+ diff --git a/runcycles/__init__.py b/runcycles/__init__.py index 8c258ce..10c48d5 100644 --- a/runcycles/__init__.py +++ b/runcycles/__init__.py @@ -34,6 +34,7 @@ EventCreateRequest, EventCreateResponse, EventStatus, + ExtendStatus, ReleaseRequest, ReleaseResponse, ReleaseStatus,