From 7d8e40ba6b3571fffed86e856db587b289a097ae Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Fri, 5 Jun 2026 20:11:14 +0100 Subject: [PATCH 1/2] style: fix formatting issues --- robot/CleverActorsLib.py | 16 ++++++++++------ robot/ValidateDictLib.py | 6 ++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/robot/CleverActorsLib.py b/robot/CleverActorsLib.py index 71547a9..f615ee4 100644 --- a/robot/CleverActorsLib.py +++ b/robot/CleverActorsLib.py @@ -109,11 +109,15 @@ class CleverActorsLib: # pragma: no cover - integration test library overlay = {overlay_key: overlay_val} result = merge_configs(base, overlay) if len(result) != 2: - raise AssertionError(f"Expected result length 2, got {len(result)}: {result!r}") + raise AssertionError( + f"Expected result length 2, got {len(result)}: {result!r}" + ) if result.get(base_key) != base_val: raise AssertionError(f"Expected {base_key}={base_val!r}, got {result!r}") if result.get(overlay_key) != overlay_val: - raise AssertionError(f"Expected {overlay_key}={overlay_val!r}, got {result!r}") + raise AssertionError( + f"Expected {overlay_key}={overlay_val!r}, got {result!r}" + ) # Verify inputs not mutated if base != {base_key: base_val}: raise AssertionError(f"Base dict was mutated: {base!r}") @@ -135,7 +139,9 @@ class CleverActorsLib: # pragma: no cover - integration test library if result != {"items": [1, 2, 3, 4]}: raise AssertionError(f"Expected items=[1,2,3,4], got {result!r}") - def merge_configs_through_config_pipeline(self, base_key: str, overlay_val: str) -> None: + def merge_configs_through_config_pipeline( + self, base_key: str, overlay_val: str + ) -> None: """End-to-end test: load YAML via ConfigurationManager, merge with overlay. Loads the test_config.yaml fixture through the canonical config-loading @@ -167,9 +173,7 @@ class CleverActorsLib: # pragma: no cover - integration test library f"Base fixture key 'agents' lost during merge: {sorted(result.keys())}" ) if "cleveragents" not in result: - raise AssertionError( - f"Base fixture key 'cleveragents' lost during merge" - ) + raise AssertionError(f"Base fixture key 'cleveragents' lost during merge") def schema_validator_accepts_minimum_config(self) -> None: config = { diff --git a/robot/ValidateDictLib.py b/robot/ValidateDictLib.py index 0f6af4a..d2ee584 100644 --- a/robot/ValidateDictLib.py +++ b/robot/ValidateDictLib.py @@ -36,9 +36,7 @@ class ValidateDictLib: # pragma: no cover - integration test library "Expected ConfigurationError but validate_dict returned without error" ) - def validate_dict_returns_same_object( - self, config_dict: dict[str, Any] - ) -> None: + def validate_dict_returns_same_object(self, config_dict: dict[str, Any]) -> None: """Assert that validate_dict returns the same dict object (identity).""" result = validate_dict(config_dict, {}) if result is not config_dict: @@ -67,4 +65,4 @@ class ValidateDictLib: # pragma: no cover - integration test library raise AssertionError( f"'ConfigurationError' not found in cleveractors.__all__: " f"{cleveractors.__all__}" - ) \ No newline at end of file + ) -- 2.52.0 From 85ebe29f8e9384977a643d3977aea204f0db78e7 Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Fri, 5 Jun 2026 18:58:53 +0000 Subject: [PATCH 2/2] feat(registry): add PackageType, PackageId, PackageReference, PackageContent core types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package Registry Standard v1.0.0 (§3, §5) data types: - PackageType enum matching §3.2: ACTOR, GRAPH, STREAM, AGENT, TEMPLATE, SKILL, MCP, LSP - PackageId: frozen/hashable value object parsing pkg__<40-hex-sha1> format - PackageReference: frozen/hashable parser for REGISTRY, ID, and LOCAL reference formats - PackageContent: frozen wrapper for fetched package content with metadata - 34 Behave BDD scenarios covering valid parsing, error paths, hashability, str/repr ISSUES CLOSED: #23 --- features/registry_core_types.feature | 203 ++++++++++++ features/steps/registry_core_types_steps.py | 336 ++++++++++++++++++++ src/cleveractors/registry/__init__.py | 14 +- src/cleveractors/registry/client.py | 11 - src/cleveractors/registry/types.py | 227 +++++++++++++ 5 files changed, 779 insertions(+), 12 deletions(-) create mode 100644 features/registry_core_types.feature create mode 100644 features/steps/registry_core_types_steps.py create mode 100644 src/cleveractors/registry/types.py diff --git a/features/registry_core_types.feature b/features/registry_core_types.feature new file mode 100644 index 0000000..7df9dbd --- /dev/null +++ b/features/registry_core_types.feature @@ -0,0 +1,203 @@ +Feature: Package Registry Core Data Types + As a developer + I want PackageId, PackageReference, PackageType, and PackageContent types + So that I can work with the Package Registry Standard v1.0.0 + + Background: + Given I have imported the registry core types + + # ── PackageType ────────────────────────────────────────────────────────── + + Scenario: PackageType has all required members + Then PackageType should have member ACTOR with value "act" + And PackageType should have member GRAPH with value "grh" + And PackageType should have member STREAM with value "str" + And PackageType should have member AGENT with value "agt" + And PackageType should have member TEMPLATE with value "tpl" + And PackageType should have member SKILL with value "skl" + And PackageType should have member MCP with value "mcp" + And PackageType should have member LSP with value "lsp" + + # ── PackageId ──────────────────────────────────────────────────────────── + + Scenario: Valid PackageId from_string succeeds + Given a valid actor PackageId string "pkg_act_0123456789abcdef0123456789abcdef01234567" + When I parse it with PackageId.from_string + Then the PackageId should have type ACTOR + And the PackageId should have sha1_hex "0123456789abcdef0123456789abcdef01234567" + And the PackageId should have id_string "pkg_act_0123456789abcdef0123456789abcdef01234567" + + Scenario: Valid PackageId from_string for each type + Given a valid GRAPH PackageId string "pkg_grh_deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + When I parse it with PackageId.from_string + Then the PackageId should have type GRAPH + And the PackageId should have sha1_hex "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + + Scenario: PackageId empty string raises InvalidPackageIdError + Given an empty PackageId string + When I parse the empty string with PackageId.from_string + Then an InvalidPackageIdError should be raised with message containing "must not be empty" + + Scenario: PackageId missing pkg_ prefix raises InvalidPackageIdError + Given a PackageId string "act_0123456789abcdef0123456789abcdef01234567" missing the pkg_ prefix + When I parse it with PackageId.from_string + Then an InvalidPackageIdError should be raised with message containing "must start with 'pkg_'" + + Scenario: PackageId unknown type prefix raises InvalidPackageIdError + Given a PackageId string "pkg_xyz_0123456789abcdef0123456789abcdef01234567" with unknown type prefix + When I parse it with PackageId.from_string + Then an InvalidPackageIdError should be raised with message containing "Unknown package type prefix" + + Scenario: PackageId short SHA1 raises InvalidPackageIdError + Given a PackageId string "pkg_act_short" with invalid SHA1 + When I parse it with PackageId.from_string + Then an InvalidPackageIdError should be raised with message containing "40 hex characters" + + Scenario: PackageId uppercase SHA1 raises InvalidPackageIdError + Given a PackageId string "pkg_act_0123456789ABCDEF0123456789ABCDEF01234567" with uppercase hex + When I parse it with PackageId.from_string + Then an InvalidPackageIdError should be raised with message containing "lowercase hex characters" + + Scenario: PackageId is hashable + Given a valid actor PackageId string "pkg_act_0123456789abcdef0123456789abcdef01234567" + When I parse it with PackageId.from_string + Then the PackageId should be hashable + + Scenario: PackageId direct construction + Given I construct a PackageId directly with type GRAPH and sha1 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + Then the PackageId should have type GRAPH + And the PackageId should have id_string "pkg_grh_deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + + Scenario: PackageId direct construction with invalid SHA1 raises InvalidPackageIdError + Given I construct a PackageId directly with type ACTOR and sha1 "bad" + Then an InvalidPackageIdError should be raised with message containing "40 hex characters" + + # ── PackageReference ───────────────────────────────────────────────────── + + Scenario: Registry reference parsing + Given a package reference string "registry.cleverthis.com:acme/web_search@latest" + When I parse it with PackageReference.from_string + Then the reference_type should be REGISTRY + And the server should be "registry.cleverthis.com" + And the namespace should be "acme" + And the name should be "web_search" + And the version should be "latest" + + Scenario: Registry reference without version defaults to latest + Given a package reference string "registry.cleverthis.com:acme/web_search" + When I parse it with PackageReference.from_string + Then the version should be "latest" + + Scenario: ID reference parsing + Given a package reference string "ID:pkg_act_0123456789abcdef0123456789abcdef01234567" + When I parse it with PackageReference.from_string + Then the reference_type should be ID + And the id_string should be "pkg_act_0123456789abcdef0123456789abcdef01234567" + + Scenario: LOCAL reference parsing + Given a package reference string "local:path/to/component" + When I parse it with PackageReference.from_string + Then the reference_type should be LOCAL + And the name should be "path/to/component" + + Scenario: PackageReference round-trips original_reference + Given a package reference string "registry.cleverthis.com:acme/web_search@latest" + When I parse it with PackageReference.from_string + Then the original_reference should be "registry.cleverthis.com:acme/web_search@latest" + + Scenario: Empty reference string raises ValueError + Given an empty package reference string + When I parse the empty string with PackageReference.from_string + Then a ValueError should be raised with message containing "must not be empty" + + Scenario: Invalid registry reference without slash raises ValueError + Given a package reference string "server:missing" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "namespace/name" + + Scenario: PackageReference is hashable + Given a package reference string "registry.cleverthis.com:acme/web_search@latest" + When I parse it with PackageReference.from_string + Then the PackageReference should be hashable + + Scenario: PackageId str returns id_string + Given a valid actor PackageId string "pkg_act_0123456789abcdef0123456789abcdef01234567" + When I parse it with PackageId.from_string + Then str of the PackageId should be "pkg_act_0123456789abcdef0123456789abcdef01234567" + + Scenario: PackageId repr returns debug representation + Given a valid actor PackageId string "pkg_act_0123456789abcdef0123456789abcdef01234567" + When I parse it with PackageId.from_string + Then repr of the PackageId should be "PackageId('pkg_act_0123456789abcdef0123456789abcdef01234567')" + + Scenario: PackageReference str returns original_reference + Given a package reference string "registry.cleverthis.com:acme/web_search@latest" + When I parse it with PackageReference.from_string + Then str of the PackageReference should be "registry.cleverthis.com:acme/web_search@latest" + + Scenario: PackageReference repr returns debug representation + Given a package reference string "registry.cleverthis.com:acme/web_search@latest" + When I parse it with PackageReference.from_string + Then repr of the PackageReference should be "PackageReference('registry.cleverthis.com:acme/web_search@latest')" + + Scenario: ID reference with empty ID raises ValueError + Given a package reference string "ID:" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "ID reference must contain a Package ID" + + Scenario: ID reference with invalid PackageId raises ValueError + Given a package reference string "ID:not_a_valid_id" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "Invalid ID reference" + + Scenario: LOCAL reference with empty path raises ValueError + Given a package reference string "local:" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "must have a path after" + + Scenario: Registry reference without colon separator raises ValueError + Given a package reference string "nocolonserver" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "must be in format" + + Scenario: Registry reference with empty server raises ValueError + Given a package reference string ":ns/name" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "must include a server name" + + Scenario: Registry reference with empty version after at raises ValueError + Given a package reference string "srv:ns/name@" + When I parse it with PackageReference.from_string + Then the version should be "latest" + + Scenario: Registry reference with empty namespace raises ValueError + Given a package reference string "srv:/name" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "must include a namespace" + + Scenario: Registry reference with empty name raises ValueError + Given a package reference string "srv:ns/" + When I parse it with PackageReference.from_string + Then a ValueError should be raised with message containing "must include a name" + + # ── PackageContent ─────────────────────────────────────────────────────── + + Scenario: PackageContent wraps fetched data + Given a PackageId from string "pkg_act_0123456789abcdef0123456789abcdef01234567" + And a content dictionary with key "name" and value "my-actor" + When I create a PackageContent with the id and content + Then the PackageContent id should be "pkg_act_0123456789abcdef0123456789abcdef01234567" + And the PackageContent content should have key "name" with value "my-actor" + And the PackageContent fetched_at should be set + + Scenario: PackageContent with original_reference + Given a PackageId from string "pkg_act_0123456789abcdef0123456789abcdef01234567" + And a content dictionary with key "name" and value "my-actor" + When I create a PackageContent with id, content, and original_reference "registry.cleverthis.com:acme/actor@latest" + Then the PackageContent original_reference should be "registry.cleverthis.com:acme/actor@latest" + + Scenario: PackageContent is hashable + Given a PackageId from string "pkg_act_0123456789abcdef0123456789abcdef01234567" + And a content dictionary with key "name" and value "my-actor" + When I create a PackageContent with the id and content + Then the PackageContent should be hashable diff --git a/features/steps/registry_core_types_steps.py b/features/steps/registry_core_types_steps.py new file mode 100644 index 0000000..1e35b4c --- /dev/null +++ b/features/steps/registry_core_types_steps.py @@ -0,0 +1,336 @@ +""" +Step definitions for Package Registry Core Data Types BDD tests. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from behave import given, then, when +from behave.runner import Context + +from cleveractors.registry.exceptions import InvalidPackageIdError +from cleveractors.registry.types import ( + PackageContent, + PackageId, + PackageReference, + PackageType, + ReferenceType, +) + + +@given("I have imported the registry core types") +def step_import_registry_types(context: Context) -> None: + context.PackageType = PackageType + context.PackageId = PackageId + context.PackageReference = PackageReference + context.PackageContent = PackageContent + context.ReferenceType = ReferenceType + + +# ── PackageType steps ────────────────────────────────────────────────────── + + +@then('PackageType should have member {member} with value "{value}"') +def step_package_type_member(context: Context, member: str, value: str) -> None: + enum_member = PackageType[member] + assert enum_member.value == value, ( + f"Expected {member}.value={value!r}, got {enum_member.value!r}" + ) + + +# ── PackageId steps ──────────────────────────────────────────────────────── + + +@given('a valid {pkg_type} PackageId string "{raw_id}"') +def step_given_valid_package_id(context: Context, pkg_type: str, raw_id: str) -> None: + context.raw_id = raw_id + context.expected_type = PackageType[pkg_type.upper()] + + +@given("an empty PackageId string") +def step_given_empty_package_id(context: Context) -> None: + context.raw_id = "" + + +@given('a PackageId string "{raw_id}" missing the pkg_ prefix') +def step_given_missing_prefix(context: Context, raw_id: str) -> None: + context.raw_id = raw_id + + +@given('a PackageId string "{raw_id}" with unknown type prefix') +def step_given_unknown_prefix(context: Context, raw_id: str) -> None: + context.raw_id = raw_id + + +@given('a PackageId string "{raw_id}" with invalid SHA1') +def step_given_bad_sha1(context: Context, raw_id: str) -> None: + context.raw_id = raw_id + + +@given('a PackageId string "{raw_id}" with uppercase hex') +def step_given_uppercase_sha1(context: Context, raw_id: str) -> None: + context.raw_id = raw_id + + +@when("I parse it with PackageId.from_string") +def step_when_parse_package_id(context: Context) -> None: + context.error = None + try: + context.package_id = PackageId.from_string(context.raw_id) + except InvalidPackageIdError as exc: + context.error = exc + + +@when("I parse the empty string with PackageId.from_string") +def step_when_parse_empty_package_id(context: Context) -> None: + context.raw_id = "" + step_when_parse_package_id(context) + + +@then("the PackageId should have type {expected_type}") +def step_then_package_id_type(context: Context, expected_type: str) -> None: + expected = PackageType[expected_type] + assert context.package_id.package_type == expected, ( + f"Expected type {expected}, got {context.package_id.package_type}" + ) + + +@then('the PackageId should have sha1_hex "{expected}"') +def step_then_package_id_sha1(context: Context, expected: str) -> None: + assert context.package_id.sha1_hex == expected, ( + f"Expected sha1_hex {expected!r}, got {context.package_id.sha1_hex!r}" + ) + + +@then('the PackageId should have id_string "{expected}"') +def step_then_package_id_string(context: Context, expected: str) -> None: + assert context.package_id.id_string == expected, ( + f"Expected id_string {expected!r}, got {context.package_id.id_string!r}" + ) + + +@then('an InvalidPackageIdError should be raised with message containing "{phrase}"') +def step_then_invalid_package_id_error_message(context: Context, phrase: str) -> None: + assert context.error is not None, ( + "Expected InvalidPackageIdError but no error was raised" + ) + assert isinstance(context.error, InvalidPackageIdError), ( + f"Expected InvalidPackageIdError, got {type(context.error).__name__}" + ) + assert phrase in str(context.error), ( + f"Expected message containing {phrase!r}, got {context.error!r}" + ) + + +@then('a ValueError should be raised with message containing "{phrase}"') +def step_then_value_error_message(context: Context, phrase: str) -> None: + assert context.error is not None, "Expected ValueError but no error was raised" + assert isinstance(context.error, ValueError), ( + f"Expected ValueError, got {type(context.error).__name__}" + ) + assert phrase in str(context.error), ( + f"Expected message containing {phrase!r}, got {context.error!r}" + ) + + +@then("the PackageId should be hashable") +def step_then_package_id_hashable(context: Context) -> None: + _ = hash(context.package_id) + used_as_key = {context.package_id: "value"} + assert used_as_key[context.package_id] == "value" + + +@then('str of the PackageId should be "{expected}"') +def step_then_package_id_str(context: Context, expected: str) -> None: + assert str(context.package_id) == expected, ( + f"Expected str {expected!r}, got {str(context.package_id)!r}" + ) + + +@then('repr of the PackageId should be "{expected}"') +def step_then_package_id_repr(context: Context, expected: str) -> None: + assert repr(context.package_id) == expected, ( + f"Expected repr {expected!r}, got {repr(context.package_id)!r}" + ) + + +@given('I construct a PackageId directly with type {pkg_type} and sha1 "{sha1}"') +def step_given_direct_package_id(context: Context, pkg_type: str, sha1: str) -> None: + context.expected_type = PackageType[pkg_type] + context.error = None + try: + context.package_id = PackageId( + package_type=PackageType[pkg_type], sha1_hex=sha1 + ) + except InvalidPackageIdError as exc: + context.error = exc + + +# ── PackageReference steps ───────────────────────────────────────────────── + + +@given('a package reference string "{raw_ref}"') +def step_given_reference_string(context: Context, raw_ref: str) -> None: + context.raw_ref = raw_ref + + +@when("I parse it with PackageReference.from_string") +def step_when_parse_reference(context: Context) -> None: + context.error = None + try: + context.package_ref = PackageReference.from_string(context.raw_ref) + except ValueError as exc: + context.error = exc + + +@when("I parse the empty string with PackageReference.from_string") +def step_when_parse_empty_reference(context: Context) -> None: + context.raw_ref = "" + step_when_parse_reference(context) + + +@then("the reference_type should be {ref_type}") +def step_then_ref_type(context: Context, ref_type: str) -> None: + expected = ReferenceType[ref_type] + assert context.package_ref.reference_type == expected, ( + f"Expected {expected}, got {context.package_ref.reference_type}" + ) + + +@then('the server should be "{expected}"') +def step_then_server(context: Context, expected: str) -> None: + assert context.package_ref.server == expected, ( + f"Expected server {expected!r}, got {context.package_ref.server!r}" + ) + + +@then('the namespace should be "{expected}"') +def step_then_namespace(context: Context, expected: str) -> None: + assert context.package_ref.namespace == expected, ( + f"Expected namespace {expected!r}, got {context.package_ref.namespace!r}" + ) + + +@then('the name should be "{expected}"') +def step_then_name(context: Context, expected: str) -> None: + assert context.package_ref.name == expected, ( + f"Expected name {expected!r}, got {context.package_ref.name!r}" + ) + + +@then('the version should be "{expected}"') +def step_then_version(context: Context, expected: str) -> None: + assert context.package_ref.version == expected, ( + f"Expected version {expected!r}, got {context.package_ref.version!r}" + ) + + +@then('the id_string should be "{expected}"') +def step_then_id_string(context: Context, expected: str) -> None: + assert context.package_ref.id_string == expected, ( + f"Expected id_string {expected!r}, got {context.package_ref.id_string!r}" + ) + + +@then('the original_reference should be "{expected}"') +def step_then_original_reference(context: Context, expected: str) -> None: + assert context.package_ref.original_reference == expected, ( + f"Expected original_reference {expected!r}, " + f"got {context.package_ref.original_reference!r}" + ) + + +@then("the PackageReference should be hashable") +def step_then_reference_hashable(context: Context) -> None: + _ = hash(context.package_ref) + used_as_key = {context.package_ref: "value"} + assert used_as_key[context.package_ref] == "value" + + +@then('str of the PackageReference should be "{expected}"') +def step_then_reference_str(context: Context, expected: str) -> None: + assert str(context.package_ref) == expected, ( + f"Expected str {expected!r}, got {str(context.package_ref)!r}" + ) + + +@then('repr of the PackageReference should be "{expected}"') +def step_then_reference_repr(context: Context, expected: str) -> None: + assert repr(context.package_ref) == expected, ( + f"Expected repr {expected!r}, got {repr(context.package_ref)!r}" + ) + + +@given("an empty package reference string") +def step_given_empty_reference(context: Context) -> None: + context.raw_ref = "" + + +# ── PackageContent steps ─────────────────────────────────────────────────── + + +@given('a PackageId from string "{raw_id}"') +def step_given_package_id_from_string(context: Context, raw_id: str) -> None: + context.package_id = PackageId.from_string(raw_id) + + +@given('a content dictionary with key "{key}" and value "{value}"') +def step_given_content_dict(context: Context, key: str, value: str) -> None: + context.content_dict = {key: value} + + +@when("I create a PackageContent with the id and content") +def step_when_create_content_id_content(context: Context) -> None: + context.package_content = PackageContent( + id=context.package_id, content=context.content_dict + ) + + +@when('I create a PackageContent with id, content, and original_reference "{ref}"') +def step_when_create_content_with_ref(context: Context, ref: str) -> None: + context.package_content = PackageContent( + id=context.package_id, + content=context.content_dict, + original_reference=ref, + ) + + +@then('the PackageContent id should be "{expected}"') +def step_then_content_id(context: Context, expected: str) -> None: + assert context.package_content.id.id_string == expected, ( + f"Expected id {expected!r}, got {context.package_content.id.id_string!r}" + ) + + +@then('the PackageContent content should have key "{key}" with value "{value}"') +def step_then_content_value(context: Context, key: str, value: str) -> None: + assert context.package_content.content.get(key) == value, ( + f"Expected content[{key!r}]={value!r}, " + f"got {context.package_content.content.get(key)!r}" + ) + + +@then("the PackageContent fetched_at should be set") +def step_then_content_fetched_at(context: Context) -> None: + assert isinstance(context.package_content.fetched_at, datetime), ( + "fetched_at should be a datetime" + ) + now = datetime.now(tz=timezone.utc) + delta = now - context.package_content.fetched_at + assert delta.total_seconds() < 10, "fetched_at should be recent" + + +@then('the PackageContent original_reference should be "{expected}"') +def step_then_content_original_ref(context: Context, expected: str) -> None: + assert context.package_content.original_reference == expected, ( + f"Expected original_reference {expected!r}, " + f"got {context.package_content.original_reference!r}" + ) + + +@then("the PackageContent should be hashable") +def step_then_content_hashable(context: Context) -> None: + _ = hash(context.package_content) + used_as_key = {context.package_content: "value"} + assert used_as_key[context.package_content] == "value" diff --git a/src/cleveractors/registry/__init__.py b/src/cleveractors/registry/__init__.py index 01590ed..47a733e 100644 --- a/src/cleveractors/registry/__init__.py +++ b/src/cleveractors/registry/__init__.py @@ -1,4 +1,4 @@ -"""Package Registry client implementing the Package Registry Standard v1.0.0.""" +"""Package Registry client and core types (Package Registry Standard v1.0.0).""" from cleveractors.registry.client import RegistryClient from cleveractors.registry.exceptions import ( @@ -13,6 +13,13 @@ from cleveractors.registry.exceptions import ( ValidationError, VersionNotFoundError, ) +from cleveractors.registry.types import ( + PackageContent, + PackageId, + PackageReference, + PackageType, + ReferenceType, +) __all__ = [ "RegistryClient", @@ -26,4 +33,9 @@ __all__ = [ "ConflictError", "InternalServerError", "RegistryNetworkError", + "PackageContent", + "PackageId", + "PackageReference", + "PackageType", + "ReferenceType", ] diff --git a/src/cleveractors/registry/client.py b/src/cleveractors/registry/client.py index dbd9573..c9d2df1 100644 --- a/src/cleveractors/registry/client.py +++ b/src/cleveractors/registry/client.py @@ -25,17 +25,6 @@ from cleveractors.registry.exceptions import ( logger = logging.getLogger(__name__) -_PACKAGE_TYPE_PREFIXES: dict[str, str] = { - "actor": "pkg_act_", - "graph": "pkg_grh_", - "stream": "pkg_str_", - "agent": "pkg_agt_", - "template": "pkg_tpl_", - "skill": "pkg_skl_", - "mcp": "pkg_mcp_", - "lsp": "pkg_lsp_", -} - class RegistryClient: """Async HTTP client for the Package Registry Standard v1.0.0. diff --git a/src/cleveractors/registry/types.py b/src/cleveractors/registry/types.py new file mode 100644 index 0000000..41642f4 --- /dev/null +++ b/src/cleveractors/registry/types.py @@ -0,0 +1,227 @@ +"""Core data types for the Package Registry (Package Registry Standard v1.0.0).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any + +from cleveractors.registry.exceptions import InvalidPackageIdError + + +class PackageType(Enum): + """Package types per Package Registry Standard §3.2.""" + + ACTOR = "act" + GRAPH = "grh" + STREAM = "str" + AGENT = "agt" + TEMPLATE = "tpl" + SKILL = "skl" + MCP = "mcp" + LSP = "lsp" + + +class ReferenceType(Enum): + """Reference formats per Package Registry Standard §5.3.""" + + REGISTRY = "registry" + ID = "id" + LOCAL = "local" + + +@dataclass(frozen=True) +class PackageId: + """Content-addressed package identifier (Package Registry Standard §5.1). + + Format: ``pkg__<40-hex-sha1>``. + """ + + package_type: PackageType + sha1_hex: str + + def __post_init__(self) -> None: + if len(self.sha1_hex) != 40: + raise InvalidPackageIdError( + f"SHA1 must be exactly 40 hex characters, " + f"got {len(self.sha1_hex)}: {self.sha1_hex!r}" + ) + if not all(c in "0123456789abcdef" for c in self.sha1_hex): + raise InvalidPackageIdError( + f"SHA1 must contain only lowercase hex characters: {self.sha1_hex!r}" + ) + + @property + def id_string(self) -> str: + """The full ``pkg__`` string.""" + return f"pkg_{self.package_type.value}_{self.sha1_hex}" + + @classmethod + def from_string(cls, raw_id: str) -> PackageId: + """Parse and validate a Package ID string. + + Args: + raw_id: A string in ``pkg__<40-hex-sha1>`` format. + + Returns: + A validated PackageId instance. + + Raises: + InvalidPackageIdError: If the string does not match the required format. + """ + raw_id = raw_id.strip() + if not raw_id: + raise InvalidPackageIdError("Package ID must not be empty") + + if not raw_id.startswith("pkg_"): + raise InvalidPackageIdError( + f"Package ID must start with 'pkg_', got: {raw_id!r}" + ) + + rest = raw_id[4:] + + matched_type: PackageType | None = None + for pkg_type in PackageType: + candidate = pkg_type.value + "_" + if rest.startswith(candidate): + matched_type = pkg_type + break + + if matched_type is None: + valid_prefixes = ", ".join(t.value for t in PackageType) + raise InvalidPackageIdError( + f"Unknown package type prefix in {raw_id!r}. " + f"Valid prefixes: {valid_prefixes}" + ) + + sha1_start = len(matched_type.value) + 1 + return cls(package_type=matched_type, sha1_hex=rest[sha1_start:]) + + def __str__(self) -> str: + return self.id_string + + def __repr__(self) -> str: + return f"PackageId({self.id_string!r})" + + +@dataclass(frozen=True) +class PackageReference: + """A reference that resolves to a concrete PackageId (§5.3). + + Holds the original user-supplied reference string for debugging + alongside parsed fields. + + Formats: + - Registry: ``server:ns/name@version`` + - ID: ``ID:pkg__<40-hex-sha1>`` + - Local: ``local:`` + """ + + original_reference: str + reference_type: ReferenceType + server: str | None = None + namespace: str | None = None + name: str | None = None + version: str | None = None + id_string: str | None = None + + @classmethod + def from_string(cls, raw_ref: str) -> PackageReference: + """Parse a package reference string. + + Args: + raw_ref: A reference string in one of the three supported formats. + + Returns: + A PackageReference with parsed fields. + + Raises: + ValueError: If the reference string cannot be parsed. + """ + raw_ref = raw_ref.strip() + if not raw_ref: + raise ValueError("Package reference string must not be empty") + + if raw_ref.startswith("ID:"): + raw_id = raw_ref[3:] + if not raw_id: + raise ValueError("ID reference must contain a Package ID") + try: + PackageId.from_string(raw_id) + except (ValueError, InvalidPackageIdError) as exc: + raise ValueError(f"Invalid ID reference: {exc}") from exc + return cls( + original_reference=raw_ref, + reference_type=ReferenceType.ID, + id_string=raw_id, + ) + + if raw_ref.startswith("local:"): + local_path = raw_ref[6:] + if not local_path: + raise ValueError("LOCAL reference must have a path after 'local:'") + return cls( + original_reference=raw_ref, + reference_type=ReferenceType.LOCAL, + name=local_path, + ) + + if ":" not in raw_ref: + raise ValueError( + "Registry reference must be in format 'server:ns/name[@version]'" + ) + + server, rest = raw_ref.split(":", 1) + if not server: + raise ValueError("Registry reference must include a server name") + + if "@" in rest: + ns_name, version = rest.rsplit("@", 1) + if not version: + version = "latest" + else: + ns_name, version = rest, "latest" + + if "/" not in ns_name: + raise ValueError( + "Registry reference must include namespace/name after the server" + ) + + namespace, name = ns_name.split("/", 1) + + if not namespace: + raise ValueError("Registry reference must include a namespace") + if not name: + raise ValueError("Registry reference must include a name") + + return cls( + original_reference=raw_ref, + reference_type=ReferenceType.REGISTRY, + server=server, + namespace=namespace, + name=name, + version=version, + ) + + def __str__(self) -> str: + return self.original_reference + + def __repr__(self) -> str: + return f"PackageReference({self.original_reference!r})" + + +@dataclass(frozen=True) +class PackageContent: + """A fetched package document with metadata. + + Wraps the raw content dictionary alongside its resolved identity + and the original reference supplied by the caller. + """ + + id: PackageId + content: dict[str, Any] = field(hash=False, compare=False) + original_reference: str | None = None + fetched_at: datetime = field( + default_factory=lambda: datetime.now(tz=timezone.utc), hash=False + ) -- 2.52.0