feat(registry): add PackageType, PackageId, PackageReference, PackageContent core types #31

Merged
CoreRasurae merged 2 commits from feature/m1-registry-core-types into master 2026-06-06 18:32:47 +00:00
7 changed files with 791 additions and 22 deletions
+203
View File
@@ -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
+336
View File
@@ -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"
+10 -6
View File
@@ -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 = {
+2 -4
View File
@@ -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__}"
)
)
+13 -1
View File
@@ -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",
]
-11
View File
@@ -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.
+227
View File
@@ -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_<type>_<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_<type>_<sha1>`` 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_<type>_<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_<type>_<40-hex-sha1>``
- Local: ``local:<path>``
"""
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
)