diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2becddc..4c52de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - integration: [false, true] + include: + - integration: false + - integration: true + server-version: v0.0.12 + - integration: true + server-version: v0.0.16 steps: - name: Check out code @@ -39,7 +44,8 @@ jobs: - name: Run pytest run: | if [ "${{ matrix.integration }}" = "true" ]; then - export TREETOP_REST_IMAGE=ghcr.io/treetop-policy-engine/treetop-rest:v0.0.12 + export TREETOP_REST_VERSION=${{ matrix.server-version }} + export TREETOP_REST_IMAGE=ghcr.io/treetop-policy-engine/treetop-rest:$TREETOP_REST_VERSION docker compose -f docker-compose.integration.yml pull uv run pytest -m integration else diff --git a/Changelog.md b/Changelog.md index 1a28d54..e7d8bc8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Exercise the full integration suite against REST v0.0.16 and v0.0.12, including + the label-configuration identifier from the current server. +- Retain nullable `label_set` and unsigned 64-bit `generation` in `PolicyVersion` + and authorization/version response parsing. Older servers default to `None` + and `0`; model equality now includes both state dimensions. Invalid generations, + including booleans, are rejected. +- Reuse immutable parsed policy versions across batch items with a bounded 256-entry + cache keyed by all version fields and runtime types. Avoid repeated default-field + parsing for older responses; boolean generations cannot alias cached integers. + Custom subclasses remain uncached so their constructors and independent state + retain their existing behavior, including keyword-only and legacy constructors. + ## [0.0.12] - 2026-08-14 ### Changed diff --git a/README.md b/README.md index 9e48fd5..4937cee 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Python ≥ 3.12, zero runtime deps beyond HTTPX. - **Full Async Support**: Async/await support for all API methods - **Type Safe**: Fully type-hinted dataclasses for requests and responses - **Version Tracking**: Access policy version information (hash and loaded_at timestamp) -- **Treetop REST v0.0.12**: Operational probes, generated OpenAPI, metrics, status, policy, and schema endpoints +- **Treetop REST v0.0.16**: Complete policy versions, operational probes, generated OpenAPI, metrics, + status, policy, and schema endpoints. Integration tests also retain v0.0.12 compatibility. - **Request Context**: Pass request-scoped Cedar context attributes during authorization ## Basic Usage (Single Request) @@ -254,7 +255,7 @@ status = client.status() print(status.request_context.supported) print(status.request_limits.max_batch_size) -# v0.0.12-compatible operational and discovery endpoints +# Operational and discovery endpoints supported by both tested server releases assert client.livez() assert client.readyz() openapi = client.openapi() @@ -318,3 +319,10 @@ equivalent to instruction-counted `iai-callgrind`: pull requests get stable regression comparisons, history, and profiles without relying on noisy hosted-runner wall time. Import the repository into CodSpeed once to enable result uploads; the workflow authenticates with GitHub OIDC and does not require a long-lived token. + +### Authorization state versions + +`PolicyVersion` includes `hash`, `loaded_at`, nullable `label_set`, and +`generation`. The label identifier correlates configurations across engine +replacements; generation is local to an engine instance and can restart. +Older servers that omit the new fields default to `None` and `0`. diff --git a/benchmarks/test_bench_models.py b/benchmarks/test_bench_models.py index 4b5d4fa..5255a82 100644 --- a/benchmarks/test_bench_models.py +++ b/benchmarks/test_bench_models.py @@ -19,6 +19,8 @@ AuthorizeResponseBrief, AuthorizeResponseDetailed, Metadata, + JsonObject, + PolicyVersion, StatusResponse, User, UserPolicies, @@ -116,3 +118,30 @@ def test_metadata_from_api(benchmark: BenchmarkFixture): payload = metadata_payload() metadata = benchmark(Metadata.from_api, payload) assert metadata.entries == 12 + + +def test_policy_version_with_label_metadata(benchmark: BenchmarkFixture): + payload: JsonObject = { + "hash": "policy-hash", "loaded_at": "2026-09-05T00:00:00Z", + "label_set": "labels-hash", "generation": 7, + } + version = benchmark(PolicyVersion.from_api, payload) + assert version.generation == 7 + assert version.label_set == "labels-hash" + + +def test_policy_version_with_changing_generation(benchmark: BenchmarkFixture): + payload: JsonObject = { + "hash": "policy-hash", "loaded_at": "2026-09-05T00:00:00Z", + "label_set": "labels-hash", "generation": 0, + } + generation = 0 + + def parse_next_version(): + nonlocal generation + generation += 1 + payload["generation"] = generation + return PolicyVersion.from_api(payload) + + version = benchmark(parse_next_version) + assert 0 < version.generation <= generation diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index fde4d5f..bc0ca01 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -8,7 +8,7 @@ services: command: ["/data", "--port", "18999"] integration-test-treetop-server: - image: ${TREETOP_REST_IMAGE:-ghcr.io/treetop-policy-engine/treetop-rest:v0.0.12} + image: ${TREETOP_REST_IMAGE:-ghcr.io/treetop-policy-engine/treetop-rest:v0.0.16} container_name: integration-test-treetop-server pull_policy: "always" ports: diff --git a/src/treetop_client/models.py b/src/treetop_client/models.py index 87539e5..ae01dc5 100644 --- a/src/treetop_client/models.py +++ b/src/treetop_client/models.py @@ -476,19 +476,59 @@ def from_api(cls, data: JsonObject) -> PermitPolicy: @dataclass(slots=True, frozen=True) class PolicyVersion: + """Policy and label state; generation is local to one engine instance.""" + hash: str loaded_at: datetime + label_set: str | None = None + generation: int = 0 + + def __post_init__(self) -> None: + generation = self.generation + if type(generation) is not int or not 0 <= generation <= (1 << 64) - 1: + raise ValueError("version generation must be an unsigned 64-bit integer") @classmethod def from_api(cls, data: JsonObject) -> PolicyVersion: hash_value = _expect_str(data.get("hash"), field_name="version hash") loaded_at_value = _expect_str(data.get("loaded_at"), field_name="version loaded_at") - return cls( - hash=hash_value, - loaded_at=_datetime_from_api(loaded_at_value), + # Subclasses may add mutable state or constructor behavior. + if cls is not PolicyVersion: + if "label_set" not in data and "generation" not in data: + return cls(hash=hash_value, loaded_at=_datetime_from_api(loaded_at_value)) + return cls( + hash=hash_value, + loaded_at=_datetime_from_api(loaded_at_value), + label_set=_expect_optional_str(data.get("label_set"), field_name="version label_set"), + generation=_expect_int(data.get("generation", 0), field_name="version generation"), + ) + if "label_set" not in data and "generation" not in data: + return _policy_version_from_values(cls, hash_value, loaded_at_value) + return _policy_version_from_values( + cls, + hash_value, + loaded_at_value, + _expect_optional_str(data.get("label_set"), field_name="version label_set"), + _expect_int(data.get("generation", 0), field_name="version generation"), ) +@lru_cache(maxsize=256, typed=True) +def _policy_version_from_values( + cls: type[PolicyVersion], + hash_value: str, + loaded_at: str, + label_set: str | None = None, + generation: int = 0, +) -> PolicyVersion: + """Share immutable versions across repeated batch items, keyed by every field. + + Typed keys keep booleans distinct from cached integer generations, so the + constructor always rejects them. Invalid constructions are never cached. + """ + return cls(hash_value, _datetime_from_api(loaded_at), label_set, generation) + + def _optional_policy_version(blob: JsonValue | None) -> PolicyVersion | None: if blob is None: return None diff --git a/tests/test_integration.py b/tests/test_integration.py index 086050a..20afe5d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,6 +23,7 @@ PORT = 10101 NAMESPACE = ["DNS"] +SERVER_VERSION = os.environ.get("TREETOP_REST_VERSION", "v0.0.16") def make_host_resource( @@ -182,7 +183,7 @@ def test_v0_0_11_server_surfaces(client: TreeTopClient): "title": "treetop-rest", "description": "REST server for the Treetop policy management framework", "license": {"name": "MIT", "identifier": "MIT"}, - "version": "0.0.12", + "version": SERVER_VERSION.removeprefix("v"), } assert client.status().request_limits.max_batch_size is not None assert "treetop_build_info" in client.metrics() @@ -287,11 +288,14 @@ def test_live_v0011_metadata_endpoints(client: TreeTopClient): assert client.health() is True version = client.version() - assert version.version == "v0.0.12" + assert version.version == SERVER_VERSION assert version.core.version assert version.core.cedar assert version.policies.hash assert version.policies.loaded_at is not None + if SERVER_VERSION == "v0.0.16": + assert version.policies.label_set is not None + assert len(version.policies.label_set) == 64 status = client.status() assert status.policy_configuration.allow_upload is False diff --git a/tests/test_models.py b/tests/test_models.py index d9bd6a8..3698248 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import cast import pytest @@ -5,11 +6,14 @@ from treetop_client.models import ( Action, AuthorizedResponseDetailed, + AuthorizedResponseBrief, ContextValue, Decision, Group, JsonObject, QualifiedId, + PolicyVersion, + JsonValue, Request, Resource, ResourceAttribute, @@ -223,3 +227,103 @@ def test_detailed_response_current_full_shape(): assert resp.decision == Decision.ALLOW assert resp.version_hash() == "abc123" assert resp.policies[0].literal == "permit (...);" + + +@pytest.mark.parametrize("label_set", [None, "labels-v2"]) +@pytest.mark.parametrize("generation", [0, 7, (1 << 64) - 1]) +def test_policy_version_retains_complete_state(label_set: str | None, generation: int): + wire: JsonObject = { + "hash": "abc", "loaded_at": "2026-09-05T00:00:00Z", + "label_set": label_set, "generation": generation, + } + version = PolicyVersion.from_api(wire) + assert version.label_set == label_set + assert version.generation == generation + assert version == PolicyVersion.from_api(wire) + changed: JsonObject = dict(wire, generation=(generation + 1) % (1 << 64)) + assert version != PolicyVersion.from_api(changed) + for response_type in [AuthorizedResponseBrief, AuthorizedResponseDetailed]: + response = response_type.from_api({"decision": "Deny", "policy": [], "version": wire}) + assert response.version == version + + +def test_policy_version_defaults_for_older_servers(): + version = PolicyVersion.from_api({"hash": "abc", "loaded_at": "2026-09-05T00:00:00Z"}) + assert version.label_set is None + assert version.generation == 0 + + +@pytest.mark.parametrize("generation", [-1, 1 << 64, True, False, 1.5, "1", None]) +def test_policy_version_rejects_invalid_generation(generation: JsonValue): + with pytest.raises((TypeError, ValueError), match="generation"): + _ = PolicyVersion.from_api({ + "hash": "abc", "loaded_at": "2026-09-05T00:00:00Z", "generation": generation, + }) + + +def test_cached_versions_do_not_conflate_generation_types_or_state(): + wire: JsonObject = { + "hash": "cached", "loaded_at": "2026-09-05T00:00:00Z", + "label_set": "labels-v1", "generation": 0, + } + original = PolicyVersion.from_api(wire) + assert original == PolicyVersion.from_api(dict(wire)) + for field, value in [("label_set", "labels-v2"), ("generation", 1), + ("hash", "different"), ("loaded_at", "2026-09-06T00:00:00Z")]: + changed: JsonObject = dict(wire) + changed[field] = value + assert PolicyVersion.from_api(changed) != original + for generation in [False, True]: + invalid: JsonObject = dict(wire, generation=generation) + with pytest.raises(ValueError, match="generation"): + _ = PolicyVersion.from_api(invalid) + + +@pytest.mark.parametrize("modern_metadata", [False, True]) +def test_policy_version_subclasses_are_constructed_independently(modern_metadata: bool): + constructed: list[str] = [] + + class CustomPolicyVersion(PolicyVersion): + def __post_init__(self) -> None: + super().__post_init__() + constructed.append(self.hash) + + wire: JsonObject = {"hash": "custom", "loaded_at": "2026-09-05T00:00:00Z"} + if modern_metadata: + wire.update({"label_set": "labels-v1", "generation": 7}) + first = CustomPolicyVersion.from_api(wire) + second = CustomPolicyVersion.from_api(wire) + assert isinstance(first, CustomPolicyVersion) + assert first == second + assert first is not second + assert constructed == ["custom", "custom"] + + +def test_policy_version_preserves_legacy_keyword_only_constructor(): + class LegacyVersion(PolicyVersion): + def __init__(self, *, hash: str, loaded_at: datetime): + super().__init__(hash=hash, loaded_at=loaded_at) + + wire: JsonObject = {"hash": "legacy", "loaded_at": "2026-09-05T00:00:00Z"} + first = LegacyVersion.from_api(wire) + second = LegacyVersion.from_api(wire) + assert isinstance(first, LegacyVersion) + assert first.hash == "legacy" + assert first.generation == 0 + assert first == second + assert first is not second + + +def test_policy_version_passes_modern_metadata_as_keywords(): + class KeywordVersion(PolicyVersion): + def __init__(self, *, hash: str, loaded_at: datetime, + label_set: str | None = None, generation: int = 0): + super().__init__(hash=hash, loaded_at=loaded_at, + label_set=label_set, generation=generation) + + wire: JsonObject = {"hash": "modern", "loaded_at": "2026-09-05T00:00:00Z", + "label_set": "labels", "generation": 7} + version = KeywordVersion.from_api(wire) + assert isinstance(version, KeywordVersion) + assert version.label_set == "labels" + assert version.generation == 7