Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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`.
29 changes: 29 additions & 0 deletions benchmarks/test_bench_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
AuthorizeResponseBrief,
AuthorizeResponseDetailed,
Metadata,
JsonObject,
PolicyVersion,
StatusResponse,
User,
UserPolicies,
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion docker-compose.integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 43 additions & 3 deletions src/treetop_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

PORT = 10101
NAMESPACE = ["DNS"]
SERVER_VERSION = os.environ.get("TREETOP_REST_VERSION", "v0.0.16")


def make_host_resource(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
from datetime import datetime
from typing import cast

import pytest

from treetop_client.models import (
Action,
AuthorizedResponseDetailed,
AuthorizedResponseBrief,
ContextValue,
Decision,
Group,
JsonObject,
QualifiedId,
PolicyVersion,
JsonValue,
Request,
Resource,
ResourceAttribute,
Expand Down Expand Up @@ -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