From 23a30805cc3e7eb7789e681af039ceb7d719c67b Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sat, 5 Sep 2026 22:45:56 +0200 Subject: [PATCH 1/6] Retain label configuration and generation in policy versions Parse and retain complete policy-state metadata in authorization and version responses, preserving old-server defaults. Validate generation as an unsigned 64-bit integer and reject Python booleans. Extend model equality and document the per-engine scope of generations. --- Changelog.md | 7 +++++++ README.md | 7 +++++++ src/treetop_client/models.py | 11 +++++++++++ tests/test_models.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/Changelog.md b/Changelog.md index 1a28d54..99bcc3e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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. + ## [0.0.12] - 2026-08-14 ### Changed diff --git a/README.md b/README.md index 9e48fd5..aa4bfe4 100644 --- a/README.md +++ b/README.md @@ -318,3 +318,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/src/treetop_client/models.py b/src/treetop_client/models.py index 87539e5..a57c5c2 100644 --- a/src/treetop_client/models.py +++ b/src/treetop_client/models.py @@ -476,8 +476,17 @@ 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 = _expect_int(self.generation, field_name="version generation") + if isinstance(generation, bool) 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: @@ -486,6 +495,8 @@ def from_api(cls, data: JsonObject) -> PolicyVersion: 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"), ) diff --git a/tests/test_models.py b/tests/test_models.py index d9bd6a8..d8fd747 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,11 +5,14 @@ from treetop_client.models import ( Action, AuthorizedResponseDetailed, + AuthorizedResponseBrief, ContextValue, Decision, Group, JsonObject, QualifiedId, + PolicyVersion, + JsonValue, Request, Resource, ResourceAttribute, @@ -223,3 +226,35 @@ 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, + }) From 1d7732e783f949380b1a24b6275a400fde33336e Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sat, 5 Sep 2026 23:03:21 +0200 Subject: [PATCH 2/6] Reuse immutable policy versions across response items Keep generation validation in the constructor and avoid repeated optional-field parsing for legacy responses. Cache at most 256 complete immutable versions, keyed by all fields and runtime types, so boolean generations cannot alias valid cached integers. Preserve subclasses by including the requested constructor type in the cache key. Add regressions for cache-key state isolation and benchmarks for complete metadata and changing generations. Same-runtime local comparisons improve version parsing from 4.93 to 3.85 microseconds and 100-item brief batch parsing from 514 to 451 microseconds versus main. --- Changelog.md | 3 +++ benchmarks/test_bench_models.py | 29 +++++++++++++++++++++++++++++ src/treetop_client/models.py | 33 ++++++++++++++++++++++++++------- tests/test_models.py | 18 ++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/Changelog.md b/Changelog.md index 99bcc3e..02d7ac8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ## [0.0.12] - 2026-08-14 diff --git a/benchmarks/test_bench_models.py b/benchmarks/test_bench_models.py index 4b5d4fa..797bc55 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 version.generation == generation diff --git a/src/treetop_client/models.py b/src/treetop_client/models.py index a57c5c2..2a17132 100644 --- a/src/treetop_client/models.py +++ b/src/treetop_client/models.py @@ -484,22 +484,41 @@ class PolicyVersion: generation: int = 0 def __post_init__(self) -> None: - generation = _expect_int(self.generation, field_name="version generation") - if isinstance(generation, bool) or not 0 <= generation <= (1 << 64) - 1: + 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), - 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_models.py b/tests/test_models.py index d8fd747..498971b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -258,3 +258,21 @@ def test_policy_version_rejects_invalid_generation(generation: JsonValue): _ = 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) From 101b45dd624fb1db152f342b1134aea1db0f5675 Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sat, 5 Sep 2026 23:14:59 +0200 Subject: [PATCH 3/6] test: support benchmark fixtures that return an earlier sample --- benchmarks/test_bench_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/test_bench_models.py b/benchmarks/test_bench_models.py index 797bc55..5255a82 100644 --- a/benchmarks/test_bench_models.py +++ b/benchmarks/test_bench_models.py @@ -144,4 +144,4 @@ def parse_next_version(): return PolicyVersion.from_api(payload) version = benchmark(parse_next_version) - assert version.generation == generation + assert 0 < version.generation <= generation From 615cbfc309cdfb849411fa163e381bd83d05e175 Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sat, 5 Sep 2026 23:44:25 +0200 Subject: [PATCH 4/6] fix: preserve independent construction of policy version subclasses --- Changelog.md | 2 ++ src/treetop_client/models.py | 8 ++++++++ tests/test_models.py | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/Changelog.md b/Changelog.md index 02d7ac8..1246b2c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ## [0.0.12] - 2026-08-14 diff --git a/src/treetop_client/models.py b/src/treetop_client/models.py index 2a17132..c2f068b 100644 --- a/src/treetop_client/models.py +++ b/src/treetop_client/models.py @@ -492,6 +492,14 @@ def __post_init__(self) -> None: 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") + # Subclasses may add mutable state or constructor behavior. + if cls is not PolicyVersion: + return cls( + hash_value, + _datetime_from_api(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"), + ) 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( diff --git a/tests/test_models.py b/tests/test_models.py index 498971b..be321e9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -276,3 +276,23 @@ def test_cached_versions_do_not_conflate_generation_types_or_state(): 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"] From c91f77c5c62f7108b81d67a018c052fd2c60ab23 Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sun, 6 Sep 2026 00:56:38 +0200 Subject: [PATCH 5/6] test: verify REST 0.0.16 and retain legacy compatibility --- .github/workflows/ci.yml | 10 ++++++++-- Changelog.md | 2 ++ README.md | 5 +++-- docker-compose.integration.yml | 2 +- tests/test_integration.py | 8 ++++++-- 5 files changed, 20 insertions(+), 7 deletions(-) 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 1246b2c..3ff832d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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, diff --git a/README.md b/README.md index aa4bfe4..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() 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/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 From 9b76ee33136d94935ee706636aa00d9b6578a164 Mon Sep 17 00:00:00 2001 From: Terje Kvernes Date: Sun, 6 Sep 2026 11:26:50 +0200 Subject: [PATCH 6/6] fix: preserve policy version subclass constructors --- Changelog.md | 2 +- src/treetop_client/models.py | 10 ++++++---- tests/test_models.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Changelog.md b/Changelog.md index 3ff832d..e7d8bc8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + retain their existing behavior, including keyword-only and legacy constructors. ## [0.0.12] - 2026-08-14 diff --git a/src/treetop_client/models.py b/src/treetop_client/models.py index c2f068b..ae01dc5 100644 --- a/src/treetop_client/models.py +++ b/src/treetop_client/models.py @@ -494,11 +494,13 @@ def from_api(cls, data: JsonObject) -> PolicyVersion: loaded_at_value = _expect_str(data.get("loaded_at"), field_name="version loaded_at") # 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_value, - _datetime_from_api(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"), + 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) diff --git a/tests/test_models.py b/tests/test_models.py index be321e9..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 @@ -296,3 +297,33 @@ def __post_init__(self) -> None: 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