docs(registry): add MkDocs API documentation and usage examples for Package Registry Client #52
@@ -0,0 +1,248 @@
|
||||
# RegistryCache
|
||||
|
||||
Transparent client-side LRU cache with TTL expiration and SHA-1 tamper detection,
|
||||
implementing [Package Registry Standard v1.0.0](../actor-registry-standard.md) §10.3.
|
||||
|
||||
```python
|
||||
from cleveractors.registry import RegistryCache, CacheFactory, CacheStats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`RegistryCache` wraps a `RegistryClient` and adds:
|
||||
|
||||
| Feature | Mechanism |
|
||||
|---------|-----------|
|
||||
| **LRU eviction** | Least-recently-used entry evicted when `max_size` exceeded |
|
||||
| **TTL expiration** | Entries older than `ttl` seconds are re-fetched |
|
||||
| **SHA-1 tamper detection** | Cached content hash is validated against stored PackageId |
|
||||
| **Singleflight coalescing** | Concurrent cold-key requests share a single upstream fetch |
|
||||
| **Thread safety** | All operations protected by `asyncio.Lock` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```python
|
||||
RegistryCache(
|
||||
client: RegistryClient,
|
||||
*,
|
||||
max_size: int = 256,
|
||||
ttl: float = 300.0,
|
||||
validate_content: bool = True,
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `client` | `RegistryClient` | *required* | The upstream client to cache responses for |
|
||||
| `max_size` | `int` | `256` | Maximum number of cached entries before LRU eviction |
|
||||
| `ttl` | `float` | `300.0` | Time-to-live in seconds before re-fetch |
|
||||
| `validate_content` | `bool` | `True` | Whether to validate cached content by recomputing SHA-1 |
|
||||
|
||||
---
|
||||
|
||||
## CacheStats
|
||||
|
||||
Observable cache statistics exposed via `RegistryCache.stats`:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CacheStats:
|
||||
hits: int = 0
|
||||
misses: int = 0
|
||||
evictions: int = 0
|
||||
|
||||
def record_hit(self) -> None: ...
|
||||
def record_miss(self) -> None: ...
|
||||
def record_eviction(self) -> None: ...
|
||||
def reset(self) -> None: ...
|
||||
```
|
||||
|
||||
```python
|
||||
cache = RegistryCache(client, max_size=512, ttl=600.0)
|
||||
# ... perform operations ...
|
||||
print(f"hits={cache.stats.hits} misses={cache.stats.misses} evictions={cache.stats.evictions}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Methods
|
||||
|
||||
### get_package
|
||||
|
||||
```python
|
||||
async def get_package(self, package_id: str) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Retrieve package content, using cache when available and valid.
|
||||
|
||||
Cache-hit flow:
|
||||
1. Entry found in cache and within TTL → validate SHA-1 (CPU-bound, outside lock).
|
||||
2. Hash matches → return cached content.
|
||||
3. Hash mismatch → evict (tampered), re-fetch from upstream.
|
||||
|
||||
Cache-miss flow:
|
||||
1. Entry missing, expired, or tampered.
|
||||
2. Concurrent callers for the same key share a single upstream fetch (singleflight).
|
||||
3. Fetch from upstream, populate cache, return.
|
||||
|
||||
### resolve_package
|
||||
|
||||
```python
|
||||
async def resolve_package(
|
||||
self,
|
||||
package_type: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
version: str = "latest",
|
||||
) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Mirrors `RegistryClient.resolve_package` with transparent caching.
|
||||
Maintains a separate resolve-store keyed by `(type, namespace, name, version)`.
|
||||
|
||||
### put
|
||||
|
||||
```python
|
||||
async def put(self, package_id: str, content: dict[str, Any]) -> None
|
||||
```
|
||||
|
||||
Insert content directly into the cache without an upstream fetch.
|
||||
Useful when content was obtained through another channel (e.g. `resolve_package`)
|
||||
and you want to warm the cache for subsequent `get_package` hits.
|
||||
|
||||
### invalidate
|
||||
|
||||
```python
|
||||
async def invalidate(self, package_id: str) -> bool
|
||||
```
|
||||
|
||||
Remove a specific entry from the cache. Returns `True` if the entry was present.
|
||||
|
||||
```python
|
||||
removed = await cache.invalidate("pkg_act_...")
|
||||
print(f"Cache entry removed: {removed}")
|
||||
```
|
||||
|
||||
### clear
|
||||
|
||||
```python
|
||||
async def clear(self) -> None
|
||||
```
|
||||
|
||||
Clear all entries from both stores (package content and resolution) and reset
|
||||
statistics.
|
||||
|
||||
### close
|
||||
|
||||
```python
|
||||
async def close(self) -> None
|
||||
```
|
||||
|
||||
Clear caches, reset statistics, and close the underlying `RegistryClient`.
|
||||
If you need post-mortem statistics, snapshot `cache.stats` before calling `close()`.
|
||||
|
||||
### `__contains__`
|
||||
|
||||
```python
|
||||
def __contains__(self, package_id: str) -> bool
|
||||
```
|
||||
|
||||
Raw membership test; returns `True` if the key is present (does **not** check TTL).
|
||||
|
||||
```python
|
||||
if "pkg_act_..." in cache:
|
||||
print("Entry exists in cache (may be expired)")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Context Manager
|
||||
|
||||
```python
|
||||
async with RegistryCache(client, max_size=512) as cache:
|
||||
content = await cache.get_package("pkg_act_...")
|
||||
print(f"Final stats: hits={cache.stats.hits}")
|
||||
# cache.close() called automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CacheFactory
|
||||
|
||||
Encapsulates cache configuration so consumers depend on the factory
|
||||
abstraction rather than constructing `RegistryCache` directly.
|
||||
|
||||
```python
|
||||
factory = CacheFactory(max_size=1000, ttl=900.0, validate_content=True)
|
||||
cache = factory.create(client)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `max_size` | `int` | `256` | Maximum cache entries |
|
||||
| `ttl` | `float` | `300.0` | TTL in seconds |
|
||||
| `validate_content` | `bool` | `True` | Enable SHA-1 validation |
|
||||
|
||||
---
|
||||
|
||||
## Real-Life Example: Email Categorization with Caching
|
||||
|
||||
A categorization system that resolves templates once, caches them, and
|
||||
detects tampered cache entries:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from cleveractors.registry import (
|
||||
RegistryClient,
|
||||
RegistryCache,
|
||||
CacheStats,
|
||||
PackageId,
|
||||
)
|
||||
|
||||
|
||||
async def categorize_emails() -> None:
|
||||
client = RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="categorizer-key",
|
||||
)
|
||||
cache = RegistryCache(client, max_size=256, ttl=600.0)
|
||||
|
||||
CATEGORY_TEMPLATES = [
|
||||
"pkg_skl_aaa1111111111111111111111111111111111",
|
||||
"pkg_skl_bbb2222222222222222222222222222222222",
|
||||
"pkg_skl_ccc3333333333333333333333333333333333",
|
||||
]
|
||||
|
||||
templates: dict[str, dict] = {}
|
||||
|
||||
for pkg_id in CATEGORY_TEMPLATES:
|
||||
try:
|
||||
content = await cache.get_package(pkg_id)
|
||||
templates[pkg_id] = content
|
||||
print(f"Loaded template: {content.get('name', pkg_id)}")
|
||||
except Exception as exc:
|
||||
print(f"Failed to load {pkg_id}: {exc}")
|
||||
|
||||
# Warm the cache explicitly via put (content from resolve)
|
||||
resolved = await cache.resolve_package(
|
||||
package_type="skill",
|
||||
namespace="acme",
|
||||
name="priority-classifier",
|
||||
version="v2.0.0",
|
||||
)
|
||||
await cache.put(resolved["package_id"], {"name": "priority-classifier"})
|
||||
|
||||
# Check cache statistics
|
||||
print(f"Cache stats: {cache.stats}")
|
||||
print(f"Template count: {len(templates)}")
|
||||
|
||||
await cache.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(categorize_emails())
|
||||
```
|
||||
@@ -0,0 +1,207 @@
|
||||
# Canonicalizer
|
||||
|
||||
Deterministic RFC-8785 canonical JSON for content-addressed SHA-1 hashing,
|
||||
implementing [Package Registry Standard v1.0.0](../actor-registry-standard.md) §6.
|
||||
|
||||
```python
|
||||
from cleveractors.registry import Canonicalizer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The Canonicalizer produces a **deterministic, reproducible** JSON representation
|
||||
of a package document so that identical content always produces the same SHA-1
|
||||
hash — and therefore the same Package ID. This enables content-addressed
|
||||
storage, deduplication, and tamper detection.
|
||||
|
||||
!!! warning "SHA-1 is for content-addressing only"
|
||||
SHA-1 is used **solely** for content-addressing per §6. It is **not**
|
||||
suitable for cryptographic integrity verification. Use a modern hash
|
||||
(SHA-256, SHA-3) when cryptographic guarantees are required.
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```python
|
||||
Canonicalizer(
|
||||
*,
|
||||
reference_resolver: Callable[[str], str] | None = None,
|
||||
max_depth: int = 100,
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `reference_resolver` | `Callable \| None` | `None` | Optional callback to resolve `ID:pkg_...` and `local:...` references before canonicalization |
|
||||
| `max_depth` | `int` | `100` | Maximum nesting depth; documents exceeding this raise `ValueError` |
|
||||
|
||||
When `reference_resolver` is configured, the canonicalizer walks the content
|
||||
and replaces reference strings with their resolved concrete forms before
|
||||
computing the canonical representation.
|
||||
|
||||
---
|
||||
|
||||
## Methods
|
||||
|
||||
### canonicalize
|
||||
|
||||
```python
|
||||
def canonicalize(self, content: dict[str, Any]) -> str
|
||||
```
|
||||
|
||||
Produce deterministic RFC-8785 canonical JSON from a package content dict.
|
||||
|
||||
The canonical form is produced by a 5-step pipeline (§6.2):
|
||||
|
||||
1. **Reference resolution** (when a resolver callback is configured): resolve all
|
||||
`ID:pkg_...` and `local:...` references to concrete forms.
|
||||
2. **NFC normalization**: every string value is normalised to Unicode NFC form.
|
||||
3. **Dictionary key sorting**: keys are sorted lexicographically.
|
||||
4. **Lifecycle field stripping**: `version` and `release_date` keys are removed.
|
||||
5. **RFC-8785 serialization**: sorted keys, UTF-8, no insignificant whitespace,
|
||||
no solidus escaping, `NaN`/`Infinity` rejected.
|
||||
|
||||
```python
|
||||
canon = Canonicalizer()
|
||||
json_str = canon.canonicalize({
|
||||
"name": "my-actor",
|
||||
"version": "v1.0.0", # stripped
|
||||
"release_date": "2025-01-01", # stripped
|
||||
"agents": {
|
||||
"chat": {"type": "llm"}
|
||||
},
|
||||
})
|
||||
print(json_str)
|
||||
# {"agents":{"chat":{"type":"llm"}},"name":"my-actor"}
|
||||
```
|
||||
|
||||
### compute_package_id
|
||||
|
||||
```python
|
||||
def compute_package_id(
|
||||
self, content: dict[str, Any], package_type: PackageType
|
||||
) -> PackageId
|
||||
```
|
||||
|
||||
Compute a content-addressed PackageId from content.
|
||||
|
||||
1. Canonicalize the content.
|
||||
2. Compute SHA-1 of the canonical JSON.
|
||||
3. Format as `pkg_<type>_<40-hex-sha1>`.
|
||||
|
||||
```python
|
||||
from cleveractors.registry import Canonicalizer, PackageType, PackageId
|
||||
|
||||
canon = Canonicalizer()
|
||||
pid = canon.compute_package_id(
|
||||
content={"name": "hello-world", "agents": {"echo": {"type": "tool"}}},
|
||||
package_type=PackageType.ACTOR,
|
||||
)
|
||||
print(pid.id_string)
|
||||
# pkg_act_<40-hex-sha1>
|
||||
```
|
||||
|
||||
### resolve_references
|
||||
|
||||
```python
|
||||
def resolve_references(
|
||||
self, content: dict[str, Any]
|
||||
) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Walk a content dict and resolve all internal `ID:pkg_...` and `local:...`
|
||||
references via the configured resolver callback. Returns a new dict with all
|
||||
references replaced by their resolved values.
|
||||
|
||||
---
|
||||
|
||||
## Transformation Details
|
||||
|
||||
### Lifecycle Fields
|
||||
|
||||
The following keys are stripped during canonicalization because they record
|
||||
*when* content was authored, not *what* the content is:
|
||||
|
||||
- `version` — the version string assigned at publish time
|
||||
- `release_date` — the timestamp of publication
|
||||
|
||||
These fields are intentionally excluded so that publishing the same content
|
||||
under a different version does not produce a different Package ID.
|
||||
|
||||
### NFC Normalization
|
||||
|
||||
All string values are normalized to Unicode Normalization Form C (NFC).
|
||||
This ensures that visually identical strings with different Unicode
|
||||
representations (e.g. composed vs. decomposed characters) produce the same
|
||||
canonical form.
|
||||
|
||||
### Float Handling
|
||||
|
||||
IEEE 754 negative zero (`-0.0`) is normalized to `0.0` so that mathematically
|
||||
equivalent values produce identical hashes. `NaN` and `Infinity` are rejected
|
||||
by the RFC-8785 serializer (`allow_nan=False`).
|
||||
|
||||
### Max Depth Protection
|
||||
|
||||
Documents exceeding `max_depth` nesting raise `ValueError`. The default limit
|
||||
of 100 protects against stack overflow on deeply nested or malicious documents.
|
||||
|
||||
---
|
||||
|
||||
## Real-Life Example: CI/CD Actor Verification
|
||||
|
||||
Verifying that an actor configuration has not drifted between CI/CD environments
|
||||
by comparing content-addressed hashes:
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
from cleveractors.registry import Canonicalizer, PackageType
|
||||
|
||||
def verify_actor_drift(
|
||||
staging_config: dict,
|
||||
production_config: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Compare actor configs across environments using content-addressed hashes.
|
||||
Returns True if identical, False if drift detected.
|
||||
"""
|
||||
canon = Canonicalizer()
|
||||
|
||||
staging_json = canon.canonicalize(staging_config)
|
||||
prod_json = canon.canonicalize(production_config)
|
||||
|
||||
staging_hash = hashlib.sha1(
|
||||
staging_json.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()
|
||||
prod_hash = hashlib.sha1(
|
||||
prod_json.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()
|
||||
|
||||
if staging_hash != prod_hash:
|
||||
pid_staging = canon.compute_package_id(
|
||||
staging_config, PackageType.ACTOR
|
||||
)
|
||||
pid_prod = canon.compute_package_id(
|
||||
production_config, PackageType.ACTOR
|
||||
)
|
||||
print(
|
||||
f"DRIFT DETECTED:\n"
|
||||
f" staging: {pid_staging.id_string}\n"
|
||||
f" production: {pid_prod.id_string}"
|
||||
)
|
||||
return False
|
||||
|
||||
print("No drift detected — configurations are identical.")
|
||||
return True
|
||||
```
|
||||
|
||||
This pattern is particularly useful in CI/CD pipelines where actor
|
||||
configurations are deployed across multiple environments and must remain
|
||||
consistent:
|
||||
- Compute the canonical hash of the config committed to the repository.
|
||||
- After deployment, fetch the live config from each environment.
|
||||
- Recompute and compare hashes to detect unauthorized modifications or
|
||||
configuration drift.
|
||||
@@ -0,0 +1,263 @@
|
||||
# RegistryClient
|
||||
|
||||
Async HTTP client implementing all 4 API endpoints of the
|
||||
[Package Registry Standard v1.0.0](../actor-registry-standard.md) §8.
|
||||
|
||||
```python
|
||||
from cleveractors.registry import RegistryClient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```python
|
||||
RegistryClient(
|
||||
base_url: str,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
allow_insecure: bool = False,
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `base_url` | `str` | *required* | Registry server base URL (e.g. `"https://registry.example.com"`) |
|
||||
| `api_key` | `str \| None` | `None` | Optional API key for authenticated operations (§9.2) |
|
||||
| `timeout` | `float` | `30.0` | Request timeout in seconds |
|
||||
| `allow_insecure` | `bool` | `False` | Suppress the HTTP-without-TLS security warning |
|
||||
|
||||
The constructor emits a warning when an API key is used over HTTP without
|
||||
`allow_insecure=True`, because the standard requires HTTPS in production (§12.2).
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
The client implements all 4 endpoints defined by the standard:
|
||||
|
||||
| Method | Endpoint | Standard Ref |
|
||||
|--------|----------|-------------|
|
||||
| `get_package(package_id)` | `GET /packages/{id}` | §8.2.1 |
|
||||
| `resolve_package(type, ns, name, version)` | `GET /{type}/{ns}/{name}?version={v}` | §8.2.2 |
|
||||
| `browse(type, namespace)` | `GET /browse` | §8.4.1 |
|
||||
| `discover()` | `GET /.well-known/cleverthis-packages` | §8.4.2 |
|
||||
|
||||
All methods are `async` and return dictionaries. They map HTTP errors to typed
|
||||
exceptions from the [exception hierarchy](exceptions.md).
|
||||
|
||||
---
|
||||
|
||||
### get_package
|
||||
|
||||
```python
|
||||
async def get_package(self, package_id: str) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Retrieve raw package content by its globally unique Package ID.
|
||||
|
||||
```python
|
||||
content = await client.get_package(
|
||||
"pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
)
|
||||
print(content["name"]) # e.g. "order-validator"
|
||||
```
|
||||
|
||||
Raises: `PackageNotFoundError`, `InvalidPackageIdError`, `RegistryNetworkError`.
|
||||
|
||||
---
|
||||
|
||||
### resolve_package
|
||||
|
||||
```python
|
||||
async def resolve_package(
|
||||
self,
|
||||
package_type: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
version: Optional[str] = None,
|
||||
) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Resolve a named package reference to its concrete Package ID. Performs version
|
||||
alias resolution according to §4.2.
|
||||
|
||||
```python
|
||||
# Resolve with a concrete version
|
||||
result = await client.resolve_package(
|
||||
package_type="actor",
|
||||
namespace="acme",
|
||||
name="order-validator",
|
||||
version="v1.0.0",
|
||||
)
|
||||
print(result["package_id"]) # "pkg_act_..."
|
||||
|
||||
# Resolve with the `latest` alias (default when version is None)
|
||||
latest = await client.resolve_package(
|
||||
package_type="skill",
|
||||
namespace="acme",
|
||||
name="code-analysis",
|
||||
)
|
||||
print(latest["package_id"])
|
||||
```
|
||||
|
||||
Returns a dict with keys `package_id` (str) and `type` (str).
|
||||
|
||||
Raises: `PackageNotFoundError`, `VersionNotFoundError`, `RegistryNetworkError`.
|
||||
|
||||
---
|
||||
|
||||
### browse
|
||||
|
||||
```python
|
||||
async def browse(
|
||||
self,
|
||||
package_type: Optional[str] = None,
|
||||
namespace: Optional[str] = None,
|
||||
) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Browse published packages with optional type and namespace filters.
|
||||
|
||||
```python
|
||||
# List all actor packages
|
||||
results = await client.browse(package_type="actor")
|
||||
for pkg in results["packages"]:
|
||||
print(f"{pkg['name']} ({pkg['namespace']}) — {pkg['description']}")
|
||||
|
||||
# List all packages in a namespace
|
||||
results = await client.browse(namespace="acme")
|
||||
print(f"Found {results['count']} packages in acme namespace")
|
||||
```
|
||||
|
||||
Returns a dict with keys `packages` (list of package summaries) and `count` (int).
|
||||
|
||||
Raises: `RegistryNetworkError`.
|
||||
|
||||
---
|
||||
|
||||
### discover
|
||||
|
||||
```python
|
||||
async def discover(self) -> dict[str, Any]
|
||||
```
|
||||
|
||||
Retrieve registry metadata via the well-known discovery endpoint (§8.4.2).
|
||||
|
||||
```python
|
||||
meta = await client.discover()
|
||||
print(meta["name"]) # "CleverThis Package Registry"
|
||||
print(meta["version"]) # "1.0.0"
|
||||
print(meta["supported_types"]) # ["actor", "graph", "stream", ...]
|
||||
print(meta["authentication"]) # ["anonymous", "api_key"]
|
||||
```
|
||||
|
||||
Raises: `RegistryNetworkError`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The client supports two authentication modes defined in §9:
|
||||
|
||||
| Mode | How to use | When |
|
||||
|------|-----------|------|
|
||||
| **Anonymous** | Omit `api_key` | Reading public packages |
|
||||
| **API Key** | Pass `api_key="..."` | Publishing or accessing private packages |
|
||||
|
||||
The API key is sent as a `Bearer` token in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <api_key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Mapping
|
||||
|
||||
HTTP errors from the server are automatically converted to typed Python
|
||||
exceptions. See [exceptions.md](exceptions.md) for the complete mapping.
|
||||
|
||||
| HTTP Status | Exception |
|
||||
|-------------|-----------|
|
||||
| 400 | `InvalidPackageIdError` |
|
||||
| 401 | `AuthenticationRequiredError` |
|
||||
| 403 | `AccessDeniedError` |
|
||||
| 404 | `PackageNotFoundError` |
|
||||
| 409 | `ConflictError` |
|
||||
| 5xx | `RegistryNetworkError` |
|
||||
|
||||
Server-side structured error responses (§13.1) are parsed and the `type`
|
||||
field is matched against the error-type-to-exception map, allowing the
|
||||
server to indicate specific error conditions (e.g. `VersionNotFound`)
|
||||
even when the HTTP status code is less specific.
|
||||
|
||||
---
|
||||
|
||||
## Async Context Manager
|
||||
|
||||
`RegistryClient` supports async context management for automatic cleanup:
|
||||
|
||||
```python
|
||||
async with RegistryClient(base_url="https://registry.example.com") as client:
|
||||
content = await client.get_package("pkg_act_...")
|
||||
# client.close() is called automatically on exit
|
||||
```
|
||||
|
||||
Alternatively, call `close()` explicitly:
|
||||
|
||||
```python
|
||||
client = RegistryClient(base_url="https://registry.example.com")
|
||||
try:
|
||||
content = await client.get_package("pkg_act_...")
|
||||
finally:
|
||||
await client.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-Life Example: Supplier Agent Registration
|
||||
|
||||
Registering a supplier agent that validates part numbers in purchase orders
|
||||
by pulling an order-validation template from the package registry:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from cleveractors.registry import RegistryClient, RegistryCache, CacheFactory
|
||||
|
||||
|
||||
async def register_supplier_agent() -> dict[str, Any]:
|
||||
client = RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="supplier-api-key",
|
||||
)
|
||||
cache = RegistryCache(client, max_size=256, ttl=300.0)
|
||||
|
||||
# 1. Discover registry capabilities
|
||||
meta = await client.discover()
|
||||
if "actor" not in meta.get("supported_types", []):
|
||||
raise RuntimeError("Registry does not support actor packages")
|
||||
|
||||
# 2. Resolve the order-validator template to its concrete ID
|
||||
resolved = await cache.resolve_package(
|
||||
package_type="actor",
|
||||
namespace="acme",
|
||||
name="order-validator",
|
||||
version="v1.0.0",
|
||||
)
|
||||
package_id = resolved["package_id"]
|
||||
|
||||
# 3. Fetch the full template content
|
||||
content = await cache.get_package(package_id)
|
||||
print(f"Loaded template: {content.get('name', package_id)}")
|
||||
print(f"Stats — hits: {cache.stats.hits}, misses: {cache.stats.misses}")
|
||||
|
||||
await cache.close()
|
||||
return content
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(register_supplier_agent())
|
||||
print(f"Agent config: {list(result.keys())}")
|
||||
```
|
||||
@@ -0,0 +1,286 @@
|
||||
# Exceptions
|
||||
|
||||
Typed exception hierarchy for the Package Registry Client, mapping server-side
|
||||
error types from [Package Registry Standard v1.0.0](../actor-registry-standard.md) §13.2
|
||||
to Python exception classes.
|
||||
|
||||
```python
|
||||
from cleveractors.registry.exceptions import RegistryError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Class Hierarchy
|
||||
|
||||
```
|
||||
CleverAgentsException
|
||||
└── RegistryError # Base — all registry errors
|
||||
├── PackageNotFoundError # 404 — package does not exist
|
||||
├── InvalidPackageIdError # 400 / local — bad Package ID format
|
||||
├── InvalidPackageReferenceError # 400 / local — bad reference string
|
||||
│ └── CircularLocalReferenceError # §11.3.2 — always fatal
|
||||
├── VersionNotFoundError # 404 — version/alias cannot be resolved
|
||||
├── ValidationError # 400 — package failed validation
|
||||
├── AuthenticationRequiredError # 401 — auth needed
|
||||
├── AccessDeniedError # 403 — insufficient permissions
|
||||
├── ConflictError # 409 — duplicate or conflicting data
|
||||
└── RegistryNetworkError # 5xx + transport — connection/timeout/5xx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RegistryError (base)
|
||||
|
||||
```python
|
||||
class RegistryError(CleverAgentsException):
|
||||
message: str # Human-readable description
|
||||
details: dict[str, Any] | None # Optional supplementary info from server
|
||||
original_reference: str | None # The string that triggered the error
|
||||
```
|
||||
|
||||
All subclasses inherit `message`, `details`, and `original_reference`.
|
||||
|
||||
---
|
||||
|
||||
## Exception Reference
|
||||
|
||||
### PackageNotFoundError
|
||||
|
||||
The requested package does not exist in the registry (HTTP 404, type `"PackageNotFound"`).
|
||||
|
||||
```python
|
||||
from cleveractors.registry.exceptions import PackageNotFoundError
|
||||
|
||||
try:
|
||||
content = await client.get_package("pkg_act_nonexistent")
|
||||
except PackageNotFoundError as exc:
|
||||
print(f"Package not found: {exc.message}")
|
||||
```
|
||||
|
||||
### InvalidPackageIdError
|
||||
|
||||
The provided Package ID format is invalid (HTTP 400, type `"InvalidPackageId"`).
|
||||
|
||||
Raised locally by `PackageId.from_string()` and `PackageId.__post_init__()` when the
|
||||
ID does not match the `pkg_<type>_<40-hex-sha1>` format:
|
||||
|
||||
```python
|
||||
from cleveractors.registry.exceptions import InvalidPackageIdError
|
||||
|
||||
try:
|
||||
pid = PackageId.from_string("bad-format")
|
||||
except InvalidPackageIdError as exc:
|
||||
print(f"Bad ID: {exc.message}")
|
||||
```
|
||||
|
||||
### InvalidPackageReferenceError
|
||||
|
||||
The provided package reference string could not be parsed or resolved (HTTP 400,
|
||||
type `"InvalidPackageReference"`).
|
||||
|
||||
Raised by `ReferenceResolver.parse()` and `ReferenceResolver.resolve()` when
|
||||
the reference string is malformed, the referenced resource cannot be found, or
|
||||
the required backend (client or local store) is not configured.
|
||||
|
||||
### CircularLocalReferenceError
|
||||
|
||||
A circular `local:` reference chain was detected (§11.3.2).
|
||||
|
||||
**Always** raised regardless of lenient-mode settings, because cycles represent a
|
||||
correctness violation, not a graceful-degradation scenario. This is a subclass of
|
||||
`InvalidPackageReferenceError` — catch the parent class to handle both cycles
|
||||
and other reference failures uniformly.
|
||||
|
||||
### VersionNotFoundError
|
||||
|
||||
The requested version or version alias does not exist (HTTP 404, type `"VersionNotFound"`).
|
||||
|
||||
```python
|
||||
from cleveractors.registry.exceptions import VersionNotFoundError
|
||||
|
||||
try:
|
||||
result = await client.resolve_package(
|
||||
package_type="actor",
|
||||
namespace="acme",
|
||||
name="my-agent",
|
||||
version="v99.0.0", # does not exist
|
||||
)
|
||||
except VersionNotFoundError as exc:
|
||||
print(f"Version not found: {exc.message}")
|
||||
```
|
||||
|
||||
### ValidationError
|
||||
|
||||
The package failed server-side validation (HTTP 400, type `"ValidationError"`).
|
||||
|
||||
### AuthenticationRequiredError
|
||||
|
||||
Authentication is required for this operation (HTTP 401, type `"AuthenticationRequired"`).
|
||||
|
||||
### AccessDeniedError
|
||||
|
||||
Access to the requested resource is denied (HTTP 403, type `"AccessDenied"`).
|
||||
Authenticated, but the credential lacks the required permission.
|
||||
|
||||
### ConflictError
|
||||
|
||||
The request conflicts with existing data (HTTP 409, type `"Conflict"`).
|
||||
Typically raised when attempting to publish a duplicate Package ID.
|
||||
|
||||
### RegistryNetworkError
|
||||
|
||||
Connection errors, timeouts, DNS failures, or HTTP 5xx server errors.
|
||||
|
||||
```python
|
||||
class RegistryNetworkError(RegistryError):
|
||||
status_code: int | None # HTTP status for server errors
|
||||
url: str | None # The URL being accessed
|
||||
```
|
||||
|
||||
```python
|
||||
from cleveractors.registry.exceptions import RegistryNetworkError
|
||||
|
||||
try:
|
||||
content = await client.get_package("pkg_act_...")
|
||||
except RegistryNetworkError as exc:
|
||||
print(f"Network error: {exc.message}")
|
||||
print(f"Status: {exc.status_code}, URL: {exc.url}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Status Mapping
|
||||
|
||||
The `exception_for_status` function maps HTTP status codes to exception classes:
|
||||
|
||||
```python
|
||||
from cleveractors.registry.exceptions import exception_for_status
|
||||
|
||||
exc = exception_for_status(404, "Package not found")
|
||||
print(type(exc).__name__) # "PackageNotFoundError"
|
||||
```
|
||||
|
||||
| Status | Exception |
|
||||
|--------|-----------|
|
||||
| 400 | `InvalidPackageIdError` |
|
||||
| 401 | `AuthenticationRequiredError` |
|
||||
| 403 | `AccessDeniedError` |
|
||||
| 404 | `PackageNotFoundError` |
|
||||
| 409 | `ConflictError` |
|
||||
| 500–599 | `RegistryNetworkError` |
|
||||
| *other* | `RegistryError` |
|
||||
|
||||
---
|
||||
|
||||
## Server Error Type to Exception Map
|
||||
|
||||
The internal `_ERROR_TYPE_MAP` also maps server-side error type strings
|
||||
(from structured error responses §13.1) to exception classes for automatic
|
||||
conversion:
|
||||
|
||||
```python
|
||||
_ERROR_TYPE_MAP = {
|
||||
"PackageNotFound": PackageNotFoundError,
|
||||
"InvalidPackageId": InvalidPackageIdError,
|
||||
"InvalidPackageReference": InvalidPackageReferenceError,
|
||||
"VersionNotFound": VersionNotFoundError,
|
||||
"ValidationError": ValidationError,
|
||||
"AuthenticationRequired": AuthenticationRequiredError,
|
||||
"AccessDenied": AccessDeniedError,
|
||||
"Conflict": ConflictError,
|
||||
"InternalServerError": RegistryNetworkError,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-Life Example: Email Categorization System
|
||||
|
||||
Catching and handling specific exception types in a system that resolves
|
||||
categorization templates from a registry:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from cleveractors.registry import RegistryClient
|
||||
from cleveractors.registry.exceptions import (
|
||||
AccessDeniedError,
|
||||
AuthenticationRequiredError,
|
||||
PackageNotFoundError,
|
||||
RegistryError,
|
||||
RegistryNetworkError,
|
||||
VersionNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
async def load_categorization_templates(
|
||||
client: RegistryClient,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Load email categorization templates from the registry.
|
||||
Handles specific error types with appropriate recovery strategies.
|
||||
"""
|
||||
templates = []
|
||||
template_refs = [
|
||||
("skill", "acme", "email-categorizer", "v1.x"),
|
||||
("skill", "acme", "spam-detector", "latest"),
|
||||
("skill", "acme", "priority-classifier", "v2.0.0"),
|
||||
]
|
||||
|
||||
for pkg_type, ns, name, version in template_refs:
|
||||
try:
|
||||
result = await client.resolve_package(
|
||||
package_type=pkg_type,
|
||||
namespace=ns,
|
||||
name=name,
|
||||
version=version,
|
||||
)
|
||||
content = await client.get_package(result["package_id"])
|
||||
templates.append(content)
|
||||
|
||||
except PackageNotFoundError:
|
||||
print(f"Template {ns}/{name} not registered — skipping")
|
||||
continue
|
||||
|
||||
except VersionNotFoundError:
|
||||
print(
|
||||
f"Version {version} of {ns}/{name} not found — "
|
||||
f"trying latest..."
|
||||
)
|
||||
try:
|
||||
result = await client.resolve_package(
|
||||
package_type=pkg_type, namespace=ns, name=name
|
||||
)
|
||||
content = await client.get_package(result["package_id"])
|
||||
templates.append(content)
|
||||
except RegistryError as fallback_exc:
|
||||
print(f"Fallback also failed: {fallback_exc}")
|
||||
|
||||
except AuthenticationRequiredError:
|
||||
print("API key expired or missing — cannot proceed")
|
||||
break
|
||||
|
||||
except AccessDeniedError:
|
||||
print(f"Access denied to {ns}/{name} — check permissions")
|
||||
continue
|
||||
|
||||
except RegistryNetworkError as exc:
|
||||
print(
|
||||
f"Network failure (status={exc.status_code}) — "
|
||||
f"will retry on next cycle"
|
||||
)
|
||||
continue
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
async def main() -> None:
|
||||
async with RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="service-api-key",
|
||||
) as client:
|
||||
templates = await load_categorization_templates(client)
|
||||
print(f"Loaded {len(templates)} templates")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Package Registry Client
|
||||
|
||||
**CleverActors Package Registry Subsystem — Client-Side Implementation of the Package Registry Standard v1.0.0**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Package Registry Client implements the [Package Registry Standard v1.0.0](../actor-registry-standard.md)
|
||||
on the client side. It provides a complete, async-first library for discovering, resolving,
|
||||
fetching, caching, and validating versioned packages of AI components — actors, graphs,
|
||||
agents, templates, skills, MCP servers, and LSP services — from any compliant registry
|
||||
server.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/cleveractors/registry/
|
||||
├── __init__.py Public re-exports
|
||||
├── types.py Core data types (PackageType, PackageId, PackageReference, PackageContent)
|
||||
├── exceptions.py Typed exception hierarchy (RegistryError + 9 subclasses)
|
||||
├── client.py Async HTTP client (RegistryClient) — all 4 API endpoints
|
||||
├── canonical.py Canonicalizer — RFC-8785 canonical JSON + SHA-1 hashing
|
||||
├── resolver.py ReferenceResolver — parse and resolve all 3 reference schemes
|
||||
├── reference_resolver.py PackageContentResolver — multi-server client pool with caching
|
||||
├── cache.py RegistryCache — LRU + TTL + tamper detection with singleflight
|
||||
└── local_store.py LocalPackageStore — resolve `local:<path>` references from disk
|
||||
```
|
||||
|
||||
### Module Relationships
|
||||
|
||||
```
|
||||
PackageReference ──► ReferenceResolver ──► PackageId
|
||||
│ │
|
||||
│ ▼
|
||||
│ RegistryClient (HTTP)
|
||||
│ │
|
||||
│ ▼
|
||||
└──────────► PackageContentResolver
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
▼ ▼ ▼
|
||||
RegistryCache Canonicalizer LocalPackageStore
|
||||
```
|
||||
|
||||
## Module Map
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| [Types](types.md) | `PackageType`, `PackageId`, `PackageReference`, `PackageContent` — frozen dataclasses and enums |
|
||||
| [Client](client.md) | `RegistryClient` — async HTTP client implementing all 4 endpoints (§8) |
|
||||
| [Canonicalizer](canonical.md) | `Canonicalizer` — deterministic RFC-8785 JSON, NFC normalization, SHA-1 hashing |
|
||||
| [Resolver](resolver.md) | `ReferenceResolver` — parse all 3 reference schemes, resolve version aliases |
|
||||
| [Exceptions](exceptions.md) | `RegistryError` hierarchy — 9 typed exceptions with HTTP status mapping |
|
||||
| [Cache](cache.md) | `RegistryCache` — LRU eviction, TTL expiration, SHA-1 tamper detection, singleflight |
|
||||
| [Integration](integration.md) | End-to-end workflows combining multiple modules |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from cleveractors.registry import (
|
||||
RegistryClient,
|
||||
RegistryCache,
|
||||
CacheFactory,
|
||||
ReferenceResolver,
|
||||
PackageReference,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. Create an authenticated client for your registry server
|
||||
client = RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="your-api-key",
|
||||
)
|
||||
|
||||
# 2. Wrap it with transparent caching (optional but recommended)
|
||||
cache = RegistryCache(client, max_size=512, ttl=600.0)
|
||||
|
||||
# 3. Fetch a package by its content-addressed ID
|
||||
content = await cache.get_package(
|
||||
"pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
)
|
||||
print(f"Package name: {content['name']}")
|
||||
|
||||
# 4. Resolve a named package with version
|
||||
resolved = await cache.resolve_package(
|
||||
package_type="actor",
|
||||
namespace="acme",
|
||||
name="order-validator",
|
||||
version="v1.0.0",
|
||||
)
|
||||
print(f"Resolved to: {resolved['package_id']}")
|
||||
|
||||
# 5. Parse and resolve references with the resolver
|
||||
resolver = ReferenceResolver(client=client)
|
||||
ref = resolver.parse("registry.example.com:acme/order-validator@latest")
|
||||
package_id = await resolver.resolve(ref.original_reference)
|
||||
print(f"Resolved ID: {package_id}")
|
||||
|
||||
await cache.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
@@ -0,0 +1,466 @@
|
||||
# Integration Examples
|
||||
|
||||
End-to-end workflows that combine multiple modules from the Package Registry
|
||||
subsystem. Each example is a complete, copy-paste-runnable script (aside from
|
||||
the registry server URL, which must be configured for your environment).
|
||||
|
||||
---
|
||||
|
||||
## 1. Electronic Component Ordering Pipeline
|
||||
|
||||
Registers a supplier agent that validates part numbers in purchase orders
|
||||
by resolving an order-validator template from the package registry at startup.
|
||||
|
||||
This workflow demonstrates the full pipeline: **parse reference → resolve
|
||||
(version alias) → fetch → cache → instantiate agent**.
|
||||
|
||||
```python
|
||||
"""
|
||||
Electronic component ordering pipeline.
|
||||
|
||||
Resolves an order-validator template from the registry, fetches its
|
||||
full content, and produces a supplier agent configuration. Illustrates
|
||||
the complete client → cache → resolver pipeline.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from cleveractors.registry import (
|
||||
CacheFactory,
|
||||
PackageContentResolver,
|
||||
RegistryCache,
|
||||
RegistryClient,
|
||||
ReferenceResolver,
|
||||
)
|
||||
from cleveractors.registry.exceptions import (
|
||||
InvalidPackageReferenceError,
|
||||
PackageNotFoundError,
|
||||
RegistryError,
|
||||
RegistryNetworkError,
|
||||
)
|
||||
from cleveractors.registry.types import PackageReference, ReferenceType
|
||||
|
||||
|
||||
async def order_pipeline() -> dict[str, Any]:
|
||||
client = RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="supplier-api-key",
|
||||
)
|
||||
|
||||
# Transparent content-level cache per §10.3
|
||||
cache_factory = CacheFactory(max_size=512, ttl=300.0)
|
||||
cache = cache_factory.create(client)
|
||||
|
||||
# Resolver for parsing and resolving references
|
||||
resolver = ReferenceResolver(client=client)
|
||||
|
||||
# 1. Parse the reference string
|
||||
ref_str = "registry.example.com:acme/order-validator@v1.x"
|
||||
ref: PackageReference = resolver.parse(ref_str)
|
||||
|
||||
if ref.reference_type != ReferenceType.REGISTRY:
|
||||
raise RuntimeError(f"Expected registry reference, got {ref.reference_type}")
|
||||
|
||||
print(f"[1] Parsed reference: {ref.namespace}/{ref.name}@{ref.version}")
|
||||
|
||||
# 2. Resolve the version alias to a concrete Package ID
|
||||
resolved = await cache.resolve_package(
|
||||
package_type="actor",
|
||||
namespace=ref.namespace,
|
||||
name=ref.name,
|
||||
version=ref.version,
|
||||
)
|
||||
package_id = resolved["package_id"]
|
||||
print(f"[2] Resolved {ref.version} → {package_id}")
|
||||
|
||||
# 3. Fetch full package content (cache hit if already fetched)
|
||||
content = await cache.get_package(package_id)
|
||||
print(f"[3] Fetched content: {content.get('name', package_id)}")
|
||||
|
||||
# 4. Build supplier agent config from the template
|
||||
agent_config = {
|
||||
"type": "supplier",
|
||||
"template_package_id": package_id,
|
||||
"validators": content.get("validators", []),
|
||||
"cache_stats": {
|
||||
"hits": cache.stats.hits,
|
||||
"misses": cache.stats.misses,
|
||||
"evictions": cache.stats.evictions,
|
||||
},
|
||||
}
|
||||
print(f"[4] Agent configured with {len(agent_config['validators'])} validators")
|
||||
print(f" Cache: hits={cache.stats.hits} misses={cache.stats.misses}")
|
||||
|
||||
await cache.close()
|
||||
return agent_config
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = asyncio.run(order_pipeline())
|
||||
print(f"\nDone. Supplier agent config: {list(config.keys())}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Email Categorization System
|
||||
|
||||
Resolves categorization templates from a registry, fetches tool packages for
|
||||
domain-specific agents, caches for repeated use, and gracefully handles
|
||||
missing or inaccessible packages.
|
||||
|
||||
```python
|
||||
"""
|
||||
Email categorization system.
|
||||
|
||||
Resolves multiple skill packages from the registry, loads tool packages
|
||||
for domain-specific agents, and builds a categorisation pipeline. Uses
|
||||
the content resolver for multi-server client pooling and transparent
|
||||
two-tier caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from cleveractors.registry import (
|
||||
CacheFactory,
|
||||
PackageContentResolver,
|
||||
RegistryClient,
|
||||
)
|
||||
from cleveractors.registry.exceptions import (
|
||||
AccessDeniedError,
|
||||
AuthenticationRequiredError,
|
||||
PackageNotFoundError,
|
||||
RegistryNetworkError,
|
||||
VersionNotFoundError,
|
||||
)
|
||||
from cleveractors.registry.types import PackageReference
|
||||
|
||||
|
||||
CATEGORY_TEMPLATES = [
|
||||
("skill", "acme", "email-categorizer", "v1.x"),
|
||||
("skill", "acme", "spam-detector", "latest"),
|
||||
("skill", "acme", "priority-classifier", "v2.0.0"),
|
||||
("actor", "acme", "response-generator", "v1.0.0"),
|
||||
]
|
||||
|
||||
TOOL_PACKAGES = [
|
||||
"pkg_skl_aaa1111111111111111111111111111111111",
|
||||
"pkg_skl_bbb2222222222222222222222222222222222",
|
||||
]
|
||||
|
||||
|
||||
async def build_categorization_system() -> dict[str, Any]:
|
||||
client = RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="categorizer-key",
|
||||
)
|
||||
|
||||
cache_factory = CacheFactory(max_size=256, ttl=600.0)
|
||||
resolver = PackageContentResolver(
|
||||
api_key="categorizer-key",
|
||||
cache_factory=cache_factory,
|
||||
)
|
||||
|
||||
# Manually pre-warm the resolver's client pool
|
||||
resolver.clients["https://registry.example.com"] = client
|
||||
|
||||
# Phase 1: Resolve template packages
|
||||
template_contents: dict[str, dict] = {}
|
||||
failed_templates: list[str] = []
|
||||
|
||||
for pkg_type, ns, name, version in CATEGORY_TEMPLATES:
|
||||
ref = PackageReference.from_string(
|
||||
f"registry.example.com:{ns}/{name}@{version}"
|
||||
)
|
||||
try:
|
||||
content = await resolver.aresolve(ref, package_type=pkg_type)
|
||||
if content is not None:
|
||||
template_contents[f"{ns}/{name}"] = content
|
||||
print(
|
||||
f" ✓ {ns}/{name}@{version} → "
|
||||
f"{content.get('_package_id', 'unknown')}"
|
||||
)
|
||||
else:
|
||||
failed_templates.append(f"{ns}/{name}")
|
||||
except (PackageNotFoundError, VersionNotFoundError) as exc:
|
||||
print(f" ✗ {ns}/{name}@{version}: {exc}")
|
||||
failed_templates.append(f"{ns}/{name}")
|
||||
except AccessDeniedError:
|
||||
print(f" ✗ {ns}/{name}@{version}: access denied")
|
||||
failed_templates.append(f"{ns}/{name}")
|
||||
|
||||
# Phase 2: Load tool packages from cache (second fetch hits cache)
|
||||
print("\nLoading tool packages...")
|
||||
tool_contents: dict[str, dict] = {}
|
||||
for pkg_id in TOOL_PACKAGES:
|
||||
try:
|
||||
content = await resolver.aresolve(
|
||||
PackageReference.from_string(f"ID:{pkg_id}"),
|
||||
package_type="skill",
|
||||
)
|
||||
if content is not None:
|
||||
tool_contents[pkg_id] = content
|
||||
print(f" ✓ {pkg_id}")
|
||||
except Exception as exc:
|
||||
print(f" ✗ {pkg_id}: {exc}")
|
||||
|
||||
# Phase 3: Aggregate cache statistics
|
||||
stats = resolver.total_stats
|
||||
print(
|
||||
f"\nCache summary: hits={stats.hits} misses={stats.misses}"
|
||||
f" evictions={stats.evictions}"
|
||||
)
|
||||
|
||||
await resolver.close_all()
|
||||
return {
|
||||
"templates_loaded": len(template_contents),
|
||||
"tools_loaded": len(tool_contents),
|
||||
"failed_templates": failed_templates,
|
||||
"cache_stats": {
|
||||
"hits": stats.hits,
|
||||
"misses": stats.misses,
|
||||
"evictions": stats.evictions,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(build_categorization_system())
|
||||
print(f"\nDone: {result}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CI/CD Pipeline Actor Verification
|
||||
|
||||
Canonicalizes an actor configuration, compares hashes across environments,
|
||||
and detects configuration drift. Demonstrates the canonicalization pipeline
|
||||
and content validation workflow.
|
||||
|
||||
```python
|
||||
"""
|
||||
CI/CD pipeline actor verification.
|
||||
|
||||
Compares actor configurations across staging and production environments
|
||||
using content-addressed SHA-1 hashes. Detects drift, verifies integrity
|
||||
of cached templates, and reports mismatches with full Package IDs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from cleveractors.registry import (
|
||||
Canonicalizer,
|
||||
PackageType,
|
||||
RegistryCache,
|
||||
RegistryClient,
|
||||
)
|
||||
from cleveractors.registry.types import PackageId
|
||||
|
||||
|
||||
def canonical_hash(content: dict[str, Any]) -> str:
|
||||
"""Compute the canonical SHA-1 hash of a content dict."""
|
||||
canon = Canonicalizer()
|
||||
canonical = canon.canonicalize(content)
|
||||
return hashlib.sha1(
|
||||
canonical.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()
|
||||
|
||||
|
||||
async def verify_deployment(
|
||||
staging_url: str,
|
||||
production_url: str,
|
||||
package_id_str: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Verify that a deployed actor config is identical across environments.
|
||||
|
||||
Args:
|
||||
staging_url: URL of the staging registry.
|
||||
production_url: URL of the production registry.
|
||||
package_id_str: The Package ID to verify.
|
||||
"""
|
||||
pid = PackageId.from_string(package_id_str)
|
||||
result: dict[str, Any] = {"package_id": pid.id_string}
|
||||
|
||||
# Fetch from both environments
|
||||
async with (
|
||||
RegistryClient(base_url=staging_url) as staging_client,
|
||||
RegistryClient(base_url=production_url) as prod_client,
|
||||
):
|
||||
staging_cache = RegistryCache(staging_client, max_size=1, ttl=60.0)
|
||||
prod_cache = RegistryCache(prod_client, max_size=1, ttl=60.0)
|
||||
|
||||
staging_content = await staging_cache.get_package(package_id_str)
|
||||
prod_content = await prod_cache.get_package(package_id_str)
|
||||
|
||||
result["staging_content_name"] = staging_content.get("name")
|
||||
result["prod_content_name"] = prod_content.get("name")
|
||||
|
||||
# Compute canonical hashes
|
||||
staging_hash = canonical_hash(staging_content)
|
||||
prod_hash = canonical_hash(prod_content)
|
||||
result["staging_hash"] = staging_hash
|
||||
result["prod_hash"] = prod_hash
|
||||
|
||||
# Verify canonical hash matches the ID
|
||||
canon = Canonicalizer()
|
||||
computed_id = canon.compute_package_id(staging_content, pid.package_type)
|
||||
result["hash_matches_id"] = computed_id.sha1_hex == pid.sha1_hex
|
||||
|
||||
# Compare environments
|
||||
if staging_hash != prod_hash:
|
||||
result["drift_detected"] = True
|
||||
result["staging_canonical_id"] = computed_id.id_string
|
||||
|
||||
computed_prod = canon.compute_package_id(
|
||||
prod_content, pid.package_type
|
||||
)
|
||||
result["prod_canonical_id"] = computed_prod.id_string
|
||||
|
||||
print(
|
||||
f"DRIFT DETECTED for {package_id_str}\n"
|
||||
f" Staging hash: {staging_hash}\n"
|
||||
f" Production hash: {prod_hash}"
|
||||
)
|
||||
else:
|
||||
result["drift_detected"] = False
|
||||
print(f"No drift — {pid.id_string} matches across environments")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
PKG_ID = "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
|
||||
result = await verify_deployment(
|
||||
staging_url="https://staging-registry.example.com",
|
||||
production_url="https://registry.example.com",
|
||||
package_id_str=PKG_ID,
|
||||
)
|
||||
|
||||
print(f"\nVerification result:")
|
||||
for key, value in result.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Multi-Tenant SaaS Provisioning
|
||||
|
||||
Resolves agent templates per tenant namespace, validates content integrity
|
||||
via SHA-1, detects tampered cache entries, and provisions tenant-specific
|
||||
agent configurations.
|
||||
|
||||
```python
|
||||
"""
|
||||
Multi-tenant SaaS provisioning.
|
||||
|
||||
Provisions agent configurations for multiple tenants by resolving
|
||||
tenant-specific templates from the registry. Validates content integrity
|
||||
and detects tampered or stale cache entries.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from cleveractors.registry import (
|
||||
CacheFactory,
|
||||
Canonicalizer,
|
||||
PackageContentResolver,
|
||||
RegistryClient,
|
||||
PackageType,
|
||||
)
|
||||
from cleveractors.registry.exceptions import (
|
||||
RegistryError,
|
||||
RegistryNetworkError,
|
||||
)
|
||||
from cleveractors.registry.types import PackageReference
|
||||
|
||||
|
||||
TENANTS = {
|
||||
"tenant-alpha": "registry.example.com:alpha/workflow-agent@v2.x",
|
||||
"tenant-beta": "registry.example.com:beta/workflow-agent@v1.x",
|
||||
"tenant-gamma": "registry.example.com:gamma/workflow-agent@latest",
|
||||
}
|
||||
|
||||
|
||||
async def provision_tenants() -> dict[str, dict[str, Any]]:
|
||||
client = RegistryClient(
|
||||
base_url="https://registry.example.com",
|
||||
api_key="provisioning-key",
|
||||
)
|
||||
cache_factory = CacheFactory(max_size=512, ttl=300.0)
|
||||
resolver = PackageContentResolver(
|
||||
api_key="provisioning-key",
|
||||
cache_factory=cache_factory,
|
||||
)
|
||||
resolver.clients["https://registry.example.com"] = client
|
||||
canon = Canonicalizer()
|
||||
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for tenant_id, ref_str in TENANTS.items():
|
||||
print(f"\nProvisioning {tenant_id}...")
|
||||
ref = PackageReference.from_string(ref_str)
|
||||
|
||||
try:
|
||||
# Resolve via the two-tier cache
|
||||
content = await resolver.aresolve(ref, package_type="actor")
|
||||
if content is None:
|
||||
print(f" ✗ Failed to resolve {ref_str}")
|
||||
results[tenant_id] = {"status": "failed", "reason": "not_found"}
|
||||
continue
|
||||
|
||||
package_id_str = content.get("_package_id", "unknown")
|
||||
|
||||
# Validate content integrity
|
||||
canon.compute_package_id(
|
||||
content, package_type=PackageType.ACTOR
|
||||
)
|
||||
|
||||
results[tenant_id] = {
|
||||
"status": "provisioned",
|
||||
"package_id": package_id_str,
|
||||
"namespace": ref.namespace,
|
||||
"template_name": content.get("name", "unknown"),
|
||||
}
|
||||
print(f" ✓ Resolved to {package_id_str}")
|
||||
|
||||
except RegistryNetworkError as exc:
|
||||
print(f" ✗ Network error: {exc}")
|
||||
results[tenant_id] = {
|
||||
"status": "failed",
|
||||
"reason": f"network: {exc.status_code}",
|
||||
}
|
||||
except RegistryError as exc:
|
||||
print(f" ✗ Registry error: {exc}")
|
||||
results[tenant_id] = {
|
||||
"status": "failed",
|
||||
"reason": type(exc).__name__,
|
||||
}
|
||||
|
||||
# Report aggregated cache statistics
|
||||
stats = resolver.total_stats
|
||||
print(
|
||||
f"\nAggregated cache: hits={stats.hits} "
|
||||
f"misses={stats.misses} evictions={stats.evictions}"
|
||||
)
|
||||
|
||||
await resolver.close_all()
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = asyncio.run(provision_tenants())
|
||||
provisioned = sum(
|
||||
1 for r in results.values() if r["status"] == "provisioned"
|
||||
)
|
||||
print(f"\nProvisioned {provisioned}/{len(results)} tenants")
|
||||
```
|
||||
@@ -0,0 +1,238 @@
|
||||
# ReferenceResolver
|
||||
|
||||
Parses and resolves package references in all 3 supported schemes, implementing
|
||||
[Package Registry Standard v1.0.0](../actor-registry-standard.md) §5.3 and §4.2.
|
||||
|
||||
```python
|
||||
from cleveractors.registry import ReferenceResolver
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```python
|
||||
ReferenceResolver(
|
||||
client: RegistryClient | None = None,
|
||||
local_store: LocalPackageStore | None = None,
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `client` | `RegistryClient \| None` | `None` | Client for resolving `registry:` references |
|
||||
| `local_store` | `LocalPackageStore \| None` | `None` | Store for resolving `local:` references |
|
||||
|
||||
Both are optional. Resolution fails with `InvalidPackageReferenceError` if you
|
||||
attempt to resolve a reference type without the corresponding backend configured.
|
||||
|
||||
---
|
||||
|
||||
## Reference Schemes
|
||||
|
||||
Three reference schemes are supported per §5.3:
|
||||
|
||||
| Scheme | Format | Backend Required |
|
||||
|--------|--------|-----------------|
|
||||
| **Registry** | `server:namespace/name@version` | `RegistryClient` |
|
||||
| **ID** | `ID:pkg_<type>_<40-hex-sha1>` | None (inline parsing) |
|
||||
| **Local** | `local:<path>` | `LocalPackageStore` |
|
||||
|
||||
---
|
||||
|
||||
## Static Methods
|
||||
|
||||
### parse
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def parse(ref_str: str) -> PackageReference
|
||||
```
|
||||
|
||||
Parse a reference string into a `PackageReference`. Delegates to
|
||||
`PackageReference.from_string` and wraps `ValueError` into
|
||||
`InvalidPackageReferenceError`.
|
||||
|
||||
```python
|
||||
ref = ReferenceResolver.parse("registry.example.com:acme/agent@v1.0.0")
|
||||
print(ref.reference_type) # ReferenceType.REGISTRY
|
||||
print(ref.namespace) # "acme"
|
||||
```
|
||||
|
||||
### resolve_version
|
||||
|
||||
```python
|
||||
def resolve_version(
|
||||
version: str,
|
||||
available_versions: list[str],
|
||||
) -> str
|
||||
```
|
||||
|
||||
Resolve a version string (concrete or alias) against available versions per §4.2.
|
||||
|
||||
Static helper; does not require a `ReferenceResolver` instance.
|
||||
|
||||
| Version Format | Behavior |
|
||||
|----------------|----------|
|
||||
| `vX.Y.Z` | Concrete: matched directly |
|
||||
| `latest`, `vx`, `x` | Global alias: resolves to newest concrete version |
|
||||
| `vX.x` | Major alias: resolves to newest with major X |
|
||||
| `vX.Y.x` | Minor alias: resolves to newest Z in X.Y series |
|
||||
|
||||
```python
|
||||
from cleveractors.registry import resolve_version
|
||||
|
||||
available = ["v1.0.0", "v1.0.1", "v1.1.0", "v2.0.0"]
|
||||
|
||||
print(resolve_version("v1.0.0", available)) # "v1.0.0"
|
||||
print(resolve_version("latest", available)) # "v2.0.0"
|
||||
print(resolve_version("v1.x", available)) # "v1.1.0"
|
||||
print(resolve_version("v1.0.x", available)) # "v1.0.1"
|
||||
```
|
||||
|
||||
### is_concrete_version / is_version_alias
|
||||
|
||||
```python
|
||||
def is_concrete_version(version: str) -> bool
|
||||
def is_version_alias(version: str) -> bool
|
||||
```
|
||||
|
||||
```python
|
||||
print(is_concrete_version("v1.2.3")) # True
|
||||
print(is_concrete_version("latest")) # False
|
||||
print(is_version_alias("latest")) # True
|
||||
print(is_version_alias("v3.x")) # True
|
||||
print(is_version_alias("v1.2.3")) # False
|
||||
print(is_version_alias("unknown")) # False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Methods
|
||||
|
||||
### resolve
|
||||
|
||||
```python
|
||||
async def resolve(
|
||||
self,
|
||||
ref_str: str,
|
||||
package_type: str = "actor",
|
||||
) -> PackageId
|
||||
```
|
||||
|
||||
Parse and resolve a reference string to a concrete `PackageId`.
|
||||
|
||||
Resolution strategy by reference type:
|
||||
|
||||
| Type | Strategy |
|
||||
|------|----------|
|
||||
| **ID** | Parse the Package ID string directly — no network call |
|
||||
| **Local** | Resolve via `LocalPackageStore` from the filesystem |
|
||||
| **Registry** | Query the configured `RegistryClient` to resolve by name/namespace/version |
|
||||
|
||||
```python
|
||||
from cleveractors.registry import RegistryClient, ReferenceResolver
|
||||
|
||||
client = RegistryClient(base_url="https://registry.example.com")
|
||||
resolver = ReferenceResolver(client=client)
|
||||
|
||||
# ID reference — resolved inline, no network call
|
||||
pid = await resolver.resolve(
|
||||
"ID:pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
)
|
||||
print(pid.sha1_hex)
|
||||
|
||||
# Registry reference — queries the server
|
||||
pid = await resolver.resolve(
|
||||
"registry.example.com:acme/web-search@v1.0.0",
|
||||
package_type="skill",
|
||||
)
|
||||
print(pid.id_string)
|
||||
|
||||
await resolver.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Context Manager
|
||||
|
||||
```python
|
||||
async with ReferenceResolver(client=client) as resolver:
|
||||
pid = await resolver.resolve("ID:pkg_act_...")
|
||||
# resolver.close() called automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-Life Example: Multi-Tenant SaaS Provisioning
|
||||
|
||||
Resolving agent templates per tenant namespace, detecting and rejecting
|
||||
malformed references:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from cleveractors.registry import (
|
||||
RegistryClient,
|
||||
ReferenceResolver,
|
||||
)
|
||||
from cleveractors.registry.exceptions import (
|
||||
InvalidPackageReferenceError,
|
||||
PackageNotFoundError,
|
||||
)
|
||||
from cleveractors.registry.types import PackageReference, ReferenceType
|
||||
|
||||
|
||||
TENANT_AGENTS = {
|
||||
"tenant-alpha": "registry.example.com:alpha/categorization-agent@v2.x",
|
||||
"tenant-beta": "registry.example.com:beta/categorization-agent@v1.x",
|
||||
}
|
||||
|
||||
|
||||
async def provision_tenant(tenant_id: str) -> None:
|
||||
ref_str = TENANT_AGENTS.get(tenant_id)
|
||||
if ref_str is None:
|
||||
raise ValueError(f"Unknown tenant: {tenant_id}")
|
||||
|
||||
async with RegistryClient(
|
||||
base_url="https://registry.example.com"
|
||||
) as client:
|
||||
resolver = ReferenceResolver(client=client)
|
||||
|
||||
# Step 1: Parse — validates reference format
|
||||
try:
|
||||
ref = resolver.parse(ref_str)
|
||||
except InvalidPackageReferenceError as exc:
|
||||
print(f"Invalid reference for {tenant_id}: {exc}")
|
||||
return
|
||||
|
||||
# Step 2: Verify it is a registry reference (not ID, not local)
|
||||
if ref.reference_type != ReferenceType.REGISTRY:
|
||||
print(
|
||||
f"Expected registry reference for {tenant_id}, "
|
||||
f"got {ref.reference_type.value}"
|
||||
)
|
||||
return
|
||||
|
||||
# Step 3: Resolve — queries registry and handles version alias
|
||||
try:
|
||||
package_id = await resolver.resolve(
|
||||
ref_str, package_type="actor"
|
||||
)
|
||||
except PackageNotFoundError as exc:
|
||||
print(f"Package not found for {tenant_id}: {exc}")
|
||||
return
|
||||
except InvalidPackageReferenceError as exc:
|
||||
print(
|
||||
f"Resolution failed for {tenant_id}: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[{tenant_id}] Resolved {ref.namespace}/{ref.name}"
|
||||
f"@{ref.version} → {package_id.id_string}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(provision_tenant("tenant-alpha"))
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# Types
|
||||
|
||||
Core immutable data types for the Package Registry Client. All types derive
|
||||
from the [Package Registry Standard v1.0.0](../actor-registry-standard.md)
|
||||
and are implemented as frozen dataclasses and enums with strict validation
|
||||
at construction time.
|
||||
|
||||
---
|
||||
|
||||
## PackageType
|
||||
|
||||
```python
|
||||
from cleveractors.registry import PackageType
|
||||
```
|
||||
|
||||
An enum of all 8 package types defined in the standard §3.2. Each member
|
||||
carries a string prefix used in Package ID construction.
|
||||
|
||||
| Member | Prefix | Description |
|
||||
|--------|--------|-------------|
|
||||
| `PackageType.ACTOR` | `"act"` | Actor configuration documents |
|
||||
| `PackageType.GRAPH` | `"grh"` | Graph route definitions |
|
||||
| `PackageType.STREAM` | `"str"` | Stream route definitions |
|
||||
| `PackageType.AGENT` | `"agt"` | Agent definitions |
|
||||
| `PackageType.TEMPLATE` | `"tpl"` | Reusable component templates |
|
||||
| `PackageType.SKILL` | `"skl"` | Skill packages |
|
||||
| `PackageType.MCP` | `"mcp"` | MCP server definitions |
|
||||
| `PackageType.LSP` | `"lsp"` | LSP service definitions |
|
||||
|
||||
Usage:
|
||||
|
||||
```python
|
||||
pkg_type = PackageType.ACTOR
|
||||
print(pkg_type.value) # "act"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ReferenceType
|
||||
|
||||
```python
|
||||
from cleveractors.registry import ReferenceType
|
||||
```
|
||||
|
||||
An enum of the three reference schemes defined in the standard §5.3.
|
||||
|
||||
| Member | Value | Reference Format |
|
||||
|--------|-------|-----------------|
|
||||
| `ReferenceType.REGISTRY` | `"registry"` | `server:namespace/name@version` |
|
||||
| `ReferenceType.ID` | `"id"` | `ID:pkg_<type>_<40-hex-sha1>` |
|
||||
| `ReferenceType.LOCAL` | `"local"` | `local:<path>` |
|
||||
|
||||
---
|
||||
|
||||
## PackageId
|
||||
|
||||
```python
|
||||
from cleveractors.registry import PackageId
|
||||
```
|
||||
|
||||
A frozen, content-addressed package identifier (§5.1). Formatted as:
|
||||
|
||||
```
|
||||
pkg_<type>_<40-hex-sha1>
|
||||
```
|
||||
|
||||
The SHA-1 digest is computed from the canonical form of the package content (§6).
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `package_type` | `PackageType` | The package's type |
|
||||
| `sha1_hex` | `str` | The 40-character lowercase hex SHA-1 digest |
|
||||
|
||||
| Method / Property | Returns | Description |
|
||||
|-------------------|---------|-------------|
|
||||
| `id_string` | `str` | The full `pkg_<type>_<sha1>` string |
|
||||
| `from_string(raw_id)` | `PackageId` | Parse and validate a Package ID string |
|
||||
| `__str__()` | `str` | Same as `id_string` |
|
||||
|
||||
`PackageId` is immutable — once constructed, neither `package_type` nor
|
||||
`sha1_hex` can change. Validation runs at construction time:
|
||||
|
||||
- SHA-1 must be exactly 40 lowercase hex characters.
|
||||
- The type prefix must match a known `PackageType`.
|
||||
- Empty strings are rejected.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from cleveractors.registry import PackageId, PackageType
|
||||
from cleveractors.registry.exceptions import InvalidPackageIdError
|
||||
|
||||
# Construct directly
|
||||
pid = PackageId(
|
||||
package_type=PackageType.ACTOR,
|
||||
sha1_hex="a1b2c3d4e5f67890abcdef1234567890abcdef",
|
||||
)
|
||||
print(pid.id_string)
|
||||
# pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef
|
||||
|
||||
# Parse from a string
|
||||
pid2 = PackageId.from_string(
|
||||
"pkg_tpl_b1a2c3d4e5f67890abcdef1234567890fedcba"
|
||||
)
|
||||
print(pid2.package_type) # PackageType.TEMPLATE
|
||||
print(pid2.sha1_hex) # "b1a2c3d4e5f67890abcdef1234567890fedcba"
|
||||
|
||||
# Validation rejects malformed IDs
|
||||
try:
|
||||
PackageId.from_string("pkg_act_bad")
|
||||
except InvalidPackageIdError as exc:
|
||||
print(f"Rejected: {exc}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PackageReference
|
||||
|
||||
```python
|
||||
from cleveractors.registry import PackageReference
|
||||
```
|
||||
|
||||
A frozen, parsed representation of a reference string (§5.3). Holds the
|
||||
original string for debugging alongside typed, parsed fields.
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `original_reference` | `str` | The verbatim reference string |
|
||||
| `reference_type` | `ReferenceType` | Which scheme this reference uses |
|
||||
| `server` | `str \| None` | Registry server hostname (REGISTRY only) |
|
||||
| `namespace` | `str \| None` | Package namespace (REGISTRY only) |
|
||||
| `name` | `str \| None` | Package name (REGISTRY) or local path (LOCAL) |
|
||||
| `version` | `str \| None` | Version string or alias; defaults to `"latest"` |
|
||||
| `id_string` | `str \| None` | Raw Package ID (ID references only) |
|
||||
| `package_type` | `str \| None` | Inferred package type prefix |
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `from_string(raw_ref)` | `PackageReference` | Parse and validate a reference string |
|
||||
|
||||
Three reference formats are supported:
|
||||
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| Registry | `"registry.cleverthis.com:acme/web_search@latest"` |
|
||||
| ID | `"ID:pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"` |
|
||||
| Local | `"local:path/to/package.yaml"` |
|
||||
|
||||
Version defaults to `"latest"` when omitted from registry references.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from cleveractors.registry import PackageReference, ReferenceType
|
||||
|
||||
# Parse a registry reference
|
||||
ref = PackageReference.from_string(
|
||||
"registry.cleverthis.com:acme/order-validator@v1.0.0"
|
||||
)
|
||||
print(ref.server) # "registry.cleverthis.com"
|
||||
print(ref.namespace) # "acme"
|
||||
print(ref.name) # "order-validator"
|
||||
print(ref.version) # "v1.0.0"
|
||||
|
||||
# Parse an ID reference
|
||||
id_ref = PackageReference.from_string(
|
||||
"ID:pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
)
|
||||
print(id_ref.reference_type) # ReferenceType.ID
|
||||
print(id_ref.id_string) # "pkg_act_..."
|
||||
|
||||
# Parse a local reference
|
||||
local_ref = PackageReference.from_string("local:agents/validator.yaml")
|
||||
print(local_ref.reference_type) # ReferenceType.LOCAL
|
||||
print(local_ref.name) # "agents/validator.yaml"
|
||||
|
||||
# Version defaults to "latest"
|
||||
bare_ref = PackageReference.from_string(
|
||||
"registry.example.com:acme/my-agent"
|
||||
)
|
||||
print(bare_ref.version) # "latest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PackageContent
|
||||
|
||||
```python
|
||||
from cleveractors.registry import PackageContent
|
||||
```
|
||||
|
||||
A frozen wrapper for fetched package content alongside its resolved identity.
|
||||
Returned by `RegistryCache` and `PackageContentResolver`.
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `PackageId` | The content-addressed identity |
|
||||
| `content` | `dict[str, Any]` | The parsed package content (not used for hashing/equality) |
|
||||
| `original_reference` | `str \| None` | The reference string supplied by the caller |
|
||||
| `fetched_at` | `datetime` | UTC timestamp of when the content was retrieved (not used for hashing/equality) |
|
||||
|
||||
`PackageContent` is frozen. The `content` and `fetched_at` fields are excluded
|
||||
from hash and equality comparisons so that two `PackageContent` objects with
|
||||
the same `id` and `original_reference` compare as equal regardless of when
|
||||
they were fetched.
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from cleveractors.registry import PackageContent, PackageId, PackageType
|
||||
|
||||
pid = PackageId(package_type=PackageType.ACTOR, sha1_hex="a" * 40)
|
||||
pkg = PackageContent(
|
||||
id=pid,
|
||||
content={"name": "my-actor", "agents": {}},
|
||||
original_reference="registry.example.com:acme/my-actor@v1.0.0",
|
||||
)
|
||||
print(pkg.id) # PackageId('pkg_act_aaa...')
|
||||
print(pkg.content["name"]) # "my-actor"
|
||||
print(pkg.original_reference) # "registry.example.com:acme/my-actor@v1.0.0"
|
||||
```
|
||||
@@ -7,6 +7,15 @@ edit_uri: src/branch/master/docs
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Package Registry:
|
||||
- Overview: registry/index.md
|
||||
- Types: registry/types.md
|
||||
- RegistryClient: registry/client.md
|
||||
- Canonicalizer: registry/canonical.md
|
||||
- ReferenceResolver: registry/resolver.md
|
||||
- Exceptions: registry/exceptions.md
|
||||
- RegistryCache: registry/cache.md
|
||||
- Integration Examples: registry/integration.md
|
||||
- Development:
|
||||
- Quality Automation: development/quality-automation.md
|
||||
|
||||
|
||||
Reference in New Issue
Block a user