feat(registry): implement RegistryError hierarchy with typed exceptions #43
@@ -11,10 +11,14 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm
|
||||
|
||||
- **Registry Resolution Bugfixes (PR Review rui.hu)**: Fixed `TemplateRegistry._instantiate_from_registry_ref` hardcoding `package_type="template"` instead of threading the template type, which broke all non-template registry resolutions. `_try_parse_registry_ref` now filters to `ReferenceType.REGISTRY` only, preserving the "local templates unaffected" acceptance criterion. `_original_reference` in resolved results now stores the verbatim original reference string, not the cache-key with type suffix. `application.py` template-type mapping extended to cover all 8 types (previously only AGENT/GRAPH/STREAM — the other 5 silently misclassified as STREAM). Duplicated `_instantiate_from_registry_ref`/`_try_parse_registry_ref` logic extracted into shared `_resolve_registry_ref` helper in `base.py`. `RegistryClient` now URL-encodes namespace/name path components to prevent injection. HTTPS enforcement with `allow_insecure` flag per spec §12.2. `ReferenceResolver.close_all()` closes clients under the async lock to prevent concurrent client leak. Async lock lazily initialised to avoid deprecation warning. Added `plural_name` property to `TemplateType`.
|
||||
|
||||
- **Registry Error Hierarchy Fixes (PR Review rui.hu Round 5)**: Fixed `details` dropping in non-5xx fallback path of `_request()` — `details` extracted from structured error bodies are now propagated to `RegistryError` for all unrecognised error types. Added `asyncio.Lock` to `RegistryClient._get_client()` to match the project-standardised check-and-create pattern used by `ReferenceResolver._get_client()`. Fixed a no-op BDD scenario ("404 without error type in body falls back to PackageNotFoundError") that had no `Then` step. Fixed HTTPS logging step definitions that constructed a new `RegistryClient` internally, defeating test isolation. Enhanced the "Client with API key" scenario to verify the `Authorization` header is present. Added end-to-end BDD scenario for `details` propagation from structured error bodies. Added `quote(package_id)` URL-encoding to `get_package()`. Replaced misleading `if exc.request is not None:` with `if getattr(exc, "_request", None) is not None:`. Simplified `RegistryNetworkError.__str__` using parts list + join. Replaced deprecated `asyncio.get_event_loop().run_until_complete()` with `asyncio.run()`. Tightened `_CLASS_MAP` type annotation to `dict[str, type[CleverAgentsException]]`. Added comment to `typing.cast(int, code)` explaining intentional non-int test.
|
||||
|
||||
|
||||
- **Registry Reference Resolution**: Fixed `ReferenceResolver._resolve_registry_async` passing the package name instead of the type code to `RegistryClient.resolve_package`, which would construct invalid API paths and break all registry integrations. Added `package_type` parameter to `resolve()` and `aresolve()`, with automatic mapping from `TemplateType` values to `PackageType` codes. Replaced `ValueError` with `TemplateError` for all registry fetch failure paths per acceptance criteria. Cache and client-pool operations now use `asyncio.Lock` in async coroutines (`aresolve`, `_resolve_registry_async`, `_get_client`, `close_all`) to prevent event-loop blocking, while retaining `threading.Lock` for the synchronous `resolve()` path. Use bounded `OrderedDict` with LRU eviction and consistently return deep copies to prevent cache poisoning. Fixed `_get_client()` race condition by protecting the full check-and-create under a single lock acquisition. Ad-hoc `ReferenceResolver` instances created during `_instantiate_from_registry_ref` are now properly closed in both success and error paths. Registry reference detection now uses proper `PackageReference.from_string()` parsing instead of a fragile `":" in template_name` check, and returns PackageReference for all reference types (LOCAL, ID, REGISTRY) enabling resolution of local and ID references through `instantiate_from_config` and `EnhancedTemplateRegistry.instantiate()`. Template `params` from registry-based configs are now applied to resolved content instead of being silently discarded. Added async `aresolve()` entry point to prevent `RuntimeError` when called from running event loops; `resolve()` now detects running loops and directs callers to `aresolve()`. Bare `except Exception: pass` blocks replaced with debug-level logging.
|
||||
|
||||
### Added
|
||||
|
||||
- **Registry Error Hierarchy** (`cleveractors.registry.exceptions`): Typed exception hierarchy per Package Registry Standard §13.2. `RegistryError(CleverAgentsException)` base carries `message`, optional `details: dict`, and optional `original_reference: str`; `__str__` includes the reference when present. Nine leaf exceptions: `PackageNotFoundError` (404), `InvalidPackageIdError` (400), `InvalidPackageReferenceError` (400), `VersionNotFoundError` (404), `ValidationError` (400), `AuthenticationRequiredError` (401), `AccessDeniedError` (403), `ConflictError` (409), and `RegistryNetworkError` (5xx / connection / timeout) which additionally carries `status_code` and `url`. `exception_for_status()` maps HTTP codes to typed exceptions; `_ERROR_TYPE_MAP` enables error-type parsing from structured JSON error bodies. 23 Behave BDD scenarios and 12 Robot Framework integration tests; exceptions.py achieves 100% coverage.
|
||||
- **Structured `ExecutionError` fields + 5-limit enforcement in `PureLangGraph`** (`cleveractors.core.exceptions.ExecutionError`, `cleveractors.langgraph.pure_graph.PureLangGraph`): `ExecutionError` gains `kind` (categorical: `depth`, `model_calls`, `tool_calls`, `timeout`, `cost`) and `reason` (sub-code: `budget_exhausted` or `missing_pricing_entry`) fields, both defaulting to `""` for backward compatibility with existing `raise ExecutionError(msg)` call sites. `PureLangGraph` now accepts `limits` and `pricing` constructor arguments. When `limits["max_depth"]` is supplied, a depth breach raises `ExecutionError(kind="depth")` instead of silently returning the current message. `max_model_calls` is checked before each AGENT-type node; `max_tool_calls` before each TOOL-type node. `execute()` wraps `_execute_from_node()` in `asyncio.wait_for()` when `limits["timeout_ms"]` is set, mapping `asyncio.TimeoutError` to `ExecutionError(kind="timeout")`. After each LLM node, cost is computed from the supplied `pricing` table (rates are USD per million tokens per ADR-2029); breach raises `ExecutionError(kind="cost", reason="budget_exhausted")`; a missing provider or model entry raises `ExecutionError(kind="cost", reason="missing_pricing_entry")`. `runtime_dispatch._execute_graph()` now passes `executor.limits` and `executor.pricing` to `PureLangGraph`. `ExecutionError` is exported from `cleveractors.__init__` and `__all__`. (ADR-2029, issue #15)
|
||||
- **`create_executor()` router-facing API** (`cleveractors.create_executor`): New module-level factory function that constructs an `Executor` wrapping `PureLangGraph` and `AgentFactory`. Accepts `config_dict` (validated actor configuration), `credentials` (per-provider credential dict for per-request injection), `limits` (execution budget), and `pricing` (per-model cost table). `executor.execute(message)` runs the actor graph and returns an `ActorResult(response, prompt_tokens, completion_tokens, nodes)`. All four execution paths are supported — `llm`, `graph`, `tool`, and `multi_actor`. Credentials are passed to `AgentFactory` and never injected into the stored `config_dict` (ADR-2026 AC8). Exported from `cleveractors.__init__` and `__all__` (ADR-2024, ADR-2026, ADR-2029).
|
||||
- **`ActorResult` and `NodeUsage` types** (`cleveractors.result`): Canonical dataclasses for the router-facing result API, now defined in `cleveractors.result` (ADR-2027). `ActorResult` carries the response string, aggregated `prompt_tokens`/`completion_tokens`, a non-empty `nodes: list[NodeUsage]` breakdown for per-model billing, and an optional opaque `state` blob for stateless graph resumption (ADR-2026). Re-exported from `cleveractors.runtime` for backward compatibility.
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
# The Package Registry Standard
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Status:** Normative
|
||||
|
||||
---
|
||||
|
||||
## 0. Document Conventions
|
||||
|
||||
This document uses the following conventions to specify normative requirements:
|
||||
|
||||
- **MUST**, **REQUIRED**, **SHALL** indicate an absolute requirement.
|
||||
- **MUST NOT**, **SHALL NOT** indicate an absolute prohibition.
|
||||
- **SHOULD**, **RECOMMENDED** indicate that there may exist valid reasons in particular circumstances to ignore a particular requirement, but the full implications must be understood and carefully weighed.
|
||||
- **SHOULD NOT**, **NOT RECOMMENDED** indicate that there may exist valid reasons in particular circumstances when the particular behavior is acceptable, but the full implications should be understood and the case carefully weighed.
|
||||
- **MAY**, **OPTIONAL** indicate that an item is truly optional.
|
||||
|
||||
Code and configuration examples are presented in YAML 1.2 syntax. Internal identifiers, types, and values are presented in `monospaced font`.
|
||||
|
||||
A "compliant implementation" is any software that provides package registry services in conformance with the requirements of this document.
|
||||
|
||||
The terms "package", "registry", "client", and "server" are used throughout this document to refer to packages, package registries, registry clients, and registry servers respectively.
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### 1.1 Purpose
|
||||
|
||||
This document defines the **Package Registry Standard**: a protocol and data model for storing, retrieving, and managing versioned packages of AI components. A Package Registry provides a centralized system for discovering, sharing, and distributing reusable AI building blocks including actors, graphs, agents, templates, skills, and other component types.
|
||||
|
||||
### 1.2 Scope
|
||||
|
||||
This standard normatively defines:
|
||||
|
||||
1. The structural grammar and syntax of package identifiers.
|
||||
2. The complete set of allowed package types and their content-addressed storage format.
|
||||
3. The versioning scheme for packages including semantic versioning and alias resolution.
|
||||
4. The reference formats for declaring dependencies between packages.
|
||||
5. The canonicalization process for ensuring content-addressable uniqueness.
|
||||
6. The API endpoints and request/response formats for registry operations.
|
||||
7. The authentication and authorization mechanisms.
|
||||
8. The discovery and browsing capabilities.
|
||||
9. The package metadata schema.
|
||||
10. The caching and assembly mechanisms for composite packages.
|
||||
|
||||
This standard does **NOT** define:
|
||||
|
||||
1. Specific programming-language bindings or SDKs.
|
||||
2. Internal storage implementations or database schemas.
|
||||
3. User interface designs or web application layouts.
|
||||
4. Package build tools or publishing workflows beyond the API requirements.
|
||||
5. Specific security implementations beyond authentication requirements.
|
||||
|
||||
### 1.3 Conformance
|
||||
|
||||
A compliant implementation MUST:
|
||||
|
||||
1. Accept any well-formed package that conforms to this standard and reject any document that does not.
|
||||
2. Implement all required API endpoints with the specified request/response formats.
|
||||
3. Support at least all defined package types as defined in this document.
|
||||
4. Apply the canonicalization rules in §5 and signal errors for invalid packages.
|
||||
5. Support the version resolution mechanism described in §4.2.
|
||||
6. Treat all reserved identifiers (§7) appropriately.
|
||||
|
||||
A compliant implementation MAY:
|
||||
|
||||
1. Provide additional package types beyond those defined here, provided they do not conflict with the names defined in this standard.
|
||||
2. Implement additional API endpoints for enhanced functionality.
|
||||
3. Support alternative authentication mechanisms beyond those required.
|
||||
4. Optimize storage or retrieval where the optimization is not observable through the defined API.
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Package | A content-addressed, versioned unit of AI components conforming to a specific type schema. |
|
||||
| Registry | A service implementing this standard that stores and serves packages. |
|
||||
| Client | Software that interacts with a registry to publish or retrieve packages. |
|
||||
| Server | The registry implementation providing the API endpoints. |
|
||||
| Package ID | A globally unique identifier for a package in the format `pkg_<type>_<40-hex-sha1>`. |
|
||||
| Reference | A string that identifies a package, which may be resolved to a concrete Package ID. |
|
||||
| Version | A semantic version string or alias that identifies a specific package revision. |
|
||||
| Alias | A mutable reference that resolves to a concrete version (e.g., `latest`, `v1.x`). |
|
||||
| Canonical Form | The normalized, content-addressed representation of a package used for SHA1 computation. |
|
||||
| Assembly | The process of resolving and combining dependencies into a complete package document. |
|
||||
| Namespace | A logical grouping of packages under a shared ownership context. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Document Structure
|
||||
|
||||
### 3.1 Package Format
|
||||
|
||||
A package MUST be a valid YAML 1.2 document encoded in UTF-8. The top-level YAML value MUST be a mapping (dictionary). Documents that are not mappings, or that fail to parse as YAML, MUST be rejected with an error.
|
||||
|
||||
### 3.2 Package Types
|
||||
|
||||
The following table enumerates all permitted package types. Each type defines the structure and semantics of packages of that kind:
|
||||
|
||||
| Type | Prefix | Description |
|
||||
|------|--------|-------------|
|
||||
| `actor` | `pkg_act_` | Actor configuration documents conforming to the Actor Configuration Standard. |
|
||||
| `graph` | `pkg_grh_` | Graph route definitions that can be used as components in larger systems. |
|
||||
| `stream` | `pkg_str_` | Stream route definitions for reactive processing pipelines. |
|
||||
| `agent` | `pkg_agt_` | Agent definitions that can be referenced in actor configurations. |
|
||||
| `template` | `pkg_tpl_` | Reusable template definitions for actors, graphs, streams, or agents. |
|
||||
| `skill` | `pkg_skl_` | Skill packages defining capabilities for AI agents. |
|
||||
| `mcp` | `pkg_mcp_` | MCP (Model Connector Protocol) server definitions. |
|
||||
| `lsp` | `pkg_lsp_` | LSP (Language Server Protocol) service definitions. |
|
||||
|
||||
### 3.3 Minimum Valid Package
|
||||
|
||||
The minimum valid package MUST contain at least:
|
||||
|
||||
1. All required fields for its package type as defined by the respective standards.
|
||||
2. A name field identifying the package within its namespace.
|
||||
3. A description field explaining the package's purpose.
|
||||
|
||||
---
|
||||
|
||||
## 4. Versioning Scheme
|
||||
|
||||
### 4.1 Version Formats and Semantics
|
||||
|
||||
| Format | Type | Behavior | Example |
|
||||
|--------|------|----------|---------|
|
||||
| `vX.Y.Z` | Immutable Concrete | Always points to same SHA1 | `v3.2.1` → always pkg_act_a1b2c3... |
|
||||
| `vX.Y.x` | Mutable Range Alias | Points to latest Z in X.Y.* series | `v3.2.x` → points to latest v3.2.Z |
|
||||
| `vX.x` | Mutable Major Alias | Points to latest version with major X | `v3.x` → points to latest v3.*.* |
|
||||
| `vx` | Mutable Global Alias | Synonymous with `latest` | `vx` → points to globally latest version |
|
||||
| `latest` | Mutable Global Alias | Synonymous with `vx` | `latest` → points to globally latest version |
|
||||
|
||||
### 4.2 Version Resolution Logic
|
||||
|
||||
When resolving a version reference:
|
||||
|
||||
1. If the version is immutable (concrete), resolve directly to the corresponding package ID.
|
||||
2. If the version is mutable (alias):
|
||||
- Retrieve all published versions for the package namespace/name.
|
||||
- Filter to only concrete versions that match the alias pattern.
|
||||
- Sort matching versions semantically (newest first).
|
||||
- Return the latest/best matching version.
|
||||
|
||||
---
|
||||
|
||||
## 5. Package Identifiers
|
||||
|
||||
### 5.1 Package ID Format
|
||||
|
||||
Every package is identified by a globally unique identifier in the format:
|
||||
|
||||
```
|
||||
pkg_<type>_<40-hex-sha1>
|
||||
```
|
||||
|
||||
Where:
|
||||
- `pkg_` is a fixed prefix
|
||||
- `<type>` is one of the defined package type prefixes (§3.2)
|
||||
- `<40-hex-sha1>` is exactly 40 lowercase hexadecimal characters representing a SHA1 hash
|
||||
|
||||
### 5.2 Package ID Generation
|
||||
|
||||
A Package ID MUST be generated by:
|
||||
|
||||
1. Canonicalizing the package content according to §6.
|
||||
2. Computing the SHA1 hash of the canonical form.
|
||||
3. Formatting as `pkg_<type>_<sha1>` where type is determined by the package content.
|
||||
|
||||
### 5.3 Reference Formats
|
||||
|
||||
Packages are referenced using the following formats:
|
||||
|
||||
```yaml
|
||||
# Registry reference with version - external package server
|
||||
mcp_server: "registry.cleverthis.com:acme/web_search@latest"
|
||||
skill_ref: "skills.example.com:code_analysis@2.3.1"
|
||||
|
||||
# SHA1 reference - direct ID reference
|
||||
template_ref: "ID:pkg_tpl_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
|
||||
# All references can omit version (defaults to @latest)
|
||||
agent_no_version: "registry.cleverthis.com:simple/agent" # Same as @latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Canonicalization
|
||||
|
||||
### 6.1 Canonical Form Requirements
|
||||
|
||||
Every package MUST have a canonical form that:
|
||||
|
||||
1. Is deterministically generated from the package content.
|
||||
2. Uses UTF-8 encoding with NFC normalization for all string values.
|
||||
3. Sorts dictionary keys lexicographically.
|
||||
4. Removes lifecycle fields that record when content was authored (e.g., `version`, `release_date`).
|
||||
5. Resolves all internal references to SHA1 IDs.
|
||||
|
||||
### 6.2 Canonicalization Process
|
||||
|
||||
The canonicalization process MUST:
|
||||
|
||||
1. Parse the package YAML into a structured representation.
|
||||
2. Normalize string values using Unicode NFC normalization.
|
||||
3. Sort dictionary keys lexicographically.
|
||||
4. Remove lifecycle fields.
|
||||
5. Resolve internal references to concrete SHA1 IDs.
|
||||
6. Serialize to RFC-8785 canonical JSON format.
|
||||
|
||||
### 6.3 Dependency Resolution
|
||||
|
||||
During canonicalization, all references within the package MUST be resolved:
|
||||
|
||||
1. References to other packages in the same bundle are resolved first.
|
||||
2. External registry references are fetched and resolved to their concrete Package IDs.
|
||||
3. All resolved dependencies are recorded in the canonical form's metadata.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reserved Names
|
||||
|
||||
The following names are reserved by this standard and MUST NOT be used for user-defined package types or identifiers:
|
||||
|
||||
### 7.1 Type Prefixes
|
||||
|
||||
All prefixes defined in §3.2 are reserved for their specified semantics.
|
||||
|
||||
### 7.2 Version Aliases
|
||||
|
||||
The version aliases `latest`, `vx`, and patterns ending in `.x` are reserved for the standard version resolution mechanism.
|
||||
|
||||
### 7.3 Reference Schemes
|
||||
|
||||
The reference schemes `ID:`, `registry:`, and `local:` are reserved for their specified semantics.
|
||||
|
||||
---
|
||||
|
||||
## 8. API Specification
|
||||
|
||||
### 8.1 Overview
|
||||
|
||||
A compliant registry implementation MUST provide the following HTTP API endpoints:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/packages/{package_id}` | GET | Retrieve raw package content by ID |
|
||||
| `/{package_type}/{namespace}/{name}` | GET | Resolve a package by type, namespace, name, and version |
|
||||
| `/publish` | POST | Publish a new package version |
|
||||
| `/browse` | GET | Browse published packages with optional filters |
|
||||
| `/.well-known/cleverthis-packages` | GET | Registry discovery endpoint |
|
||||
|
||||
### 8.2 Package Retrieval
|
||||
|
||||
#### 8.2.1 Get Package by ID
|
||||
|
||||
```
|
||||
GET /packages/{package_id}
|
||||
```
|
||||
|
||||
Returns the raw package content for the specified package ID.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"content": "<YAML string>"
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.2.2 Resolve Package
|
||||
|
||||
```
|
||||
GET /{package_type}/{namespace}/{name}?version={version}
|
||||
```
|
||||
|
||||
Resolves a package reference to its concrete Package ID.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef",
|
||||
"type": "actor"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Publishing
|
||||
|
||||
#### 8.3.1 Publish Package
|
||||
|
||||
```
|
||||
POST /publish
|
||||
```
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef",
|
||||
"namespace": "example",
|
||||
"name": "my-actor",
|
||||
"version": "v1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"version_id": "pver_...",
|
||||
"message": "Published successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 Discovery
|
||||
|
||||
#### 8.4.1 Browse Packages
|
||||
|
||||
```
|
||||
GET /browse?type={type}&namespace={namespace}
|
||||
```
|
||||
|
||||
Returns a list of published packages matching the filters.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"id": "pkg_act_...",
|
||||
"type": "actor",
|
||||
"name": "my-actor",
|
||||
"description": "An example actor package",
|
||||
"namespace": "example",
|
||||
"version_count": 3,
|
||||
"created_at": "2026-05-25T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.4.2 Registry Discovery
|
||||
|
||||
```
|
||||
GET /.well-known/cleverthis-packages
|
||||
```
|
||||
|
||||
Returns metadata about the registry implementation.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"name": "CleverThis Package Registry",
|
||||
"version": "1.0.0",
|
||||
"supported_types": ["actor", "graph", "stream", "agent", "template", "skill", "mcp", "lsp"],
|
||||
"authentication": ["anonymous", "api_key"],
|
||||
"features": ["semantic_versioning", "mutable_aliases"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Authentication and Authorization
|
||||
|
||||
### 9.1 Anonymous Access
|
||||
|
||||
Registries MUST support anonymous read access to public packages.
|
||||
|
||||
### 9.2 Authenticated Access
|
||||
|
||||
For publishing or accessing private packages, registries MAY require authentication using:
|
||||
|
||||
1. API key tokens passed in the `Authorization` header.
|
||||
2. OAuth2 bearer tokens.
|
||||
3. Other mutually agreed-upon authentication mechanisms.
|
||||
|
||||
### 9.3 Authorization Model
|
||||
|
||||
Registries MUST enforce the following authorization rules:
|
||||
|
||||
1. Only authenticated users MAY publish packages.
|
||||
2. Package owners control publishing permissions for their namespaces.
|
||||
3. Private packages are only accessible to authorized users.
|
||||
4. Public packages are readable by all users (anonymous or authenticated).
|
||||
|
||||
---
|
||||
|
||||
## 10. Caching and Assembly
|
||||
|
||||
### 10.1 Content-Addressed Storage
|
||||
|
||||
Packages MUST be stored using content-addressed storage where:
|
||||
|
||||
1. The key is the full Package ID.
|
||||
2. The value is the canonical YAML representation.
|
||||
3. Storage is immutable - once written, content cannot change.
|
||||
|
||||
### 10.2 Assembly Cache
|
||||
|
||||
For packages with dependencies, registries SHOULD maintain an assembly cache that:
|
||||
|
||||
1. Stores fully resolved package documents with dependencies inlined.
|
||||
2. Uses LRU eviction policies to manage storage space.
|
||||
3. Invalidates cached assemblies when dependencies change.
|
||||
|
||||
### 10.3 Client-Side Caching
|
||||
|
||||
Clients SHOULD implement caching mechanisms that:
|
||||
|
||||
1. Store previously retrieved packages locally.
|
||||
2. Validate cached content using Package IDs.
|
||||
3. Refresh cached content based on TTL or version updates.
|
||||
|
||||
---
|
||||
|
||||
## 11. Validation Rules
|
||||
|
||||
The following rules MUST be enforced for package operations:
|
||||
|
||||
### 11.1 Package Validation
|
||||
|
||||
1. The package document MUST parse as valid YAML 1.2.
|
||||
2. The top-level document MUST be a mapping.
|
||||
3. The package MUST conform to the structural requirements of its declared type.
|
||||
4. All references MUST resolve to valid Package IDs.
|
||||
5. The canonical form MUST produce a consistent SHA1 hash.
|
||||
|
||||
### 11.2 Publishing Validation
|
||||
|
||||
1. Only authenticated users MAY publish packages.
|
||||
2. Package IDs MUST be unique - duplicates SHALL be rejected.
|
||||
3. Version strings MUST conform to the defined format.
|
||||
4. Namespace ownership MUST be verified for publishing.
|
||||
5. Dependencies MUST exist in the registry or be published simultaneously.
|
||||
|
||||
### 11.3 Reference Validation
|
||||
|
||||
1. All package references MUST resolve to existing packages.
|
||||
2. Circular dependencies MUST be detected and rejected.
|
||||
3. Version constraints on dependencies MUST be satisfiable.
|
||||
|
||||
---
|
||||
|
||||
## 12. Security Model
|
||||
|
||||
### 12.1 Package Integrity
|
||||
|
||||
All packages MUST be content-addressed to ensure:
|
||||
|
||||
1. Tamper evidence - any change to package content changes its ID.
|
||||
2. Deduplication - identical packages resolve to the same ID.
|
||||
3. Immutability - published packages cannot be altered.
|
||||
|
||||
### 12.2 Transport Security
|
||||
|
||||
All communication with registry servers MUST use HTTPS in production environments.
|
||||
|
||||
### 12.3 Access Control
|
||||
|
||||
Registries MUST implement access control that:
|
||||
|
||||
1. Separates public and private packages.
|
||||
2. Controls publishing permissions by namespace ownership.
|
||||
3. Supports authentication for protected operations.
|
||||
4. Logs security-relevant events for audit purposes.
|
||||
|
||||
---
|
||||
|
||||
## 13. Error Handling
|
||||
|
||||
### 13.1 Error Response Format
|
||||
|
||||
All error responses MUST follow this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "<error_type>",
|
||||
"message": "<human_readable_message>",
|
||||
"details": { /* optional additional details */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 13.2 Defined Error Types
|
||||
|
||||
| Error Type | HTTP Status | Description |
|
||||
|------------|-------------|-------------|
|
||||
| `PackageNotFound` | 404 | The requested package does not exist |
|
||||
| `InvalidPackageId` | 400 | The provided Package ID format is invalid |
|
||||
| `VersionNotFound` | 404 | The requested version does not exist |
|
||||
| `ValidationError` | 400 | The package failed validation |
|
||||
| `AuthenticationRequired` | 401 | Authentication is required for this operation |
|
||||
| `AccessDenied` | 403 | Access to the requested resource is denied |
|
||||
| `Conflict` | 409 | The request conflicts with existing data |
|
||||
| `InternalServerError` | 500 | An unexpected error occurred server-side |
|
||||
|
||||
---
|
||||
|
||||
## 14. Compliance Test Vectors
|
||||
|
||||
A compliant implementation SHOULD be tested against (at minimum) the following classes of test vectors:
|
||||
|
||||
1. **Package ID Generation**: Valid and invalid Package ID formats.
|
||||
2. **Canonicalization**: Various package types with different content.
|
||||
3. **Version Resolution**: Concrete versions and mutable aliases.
|
||||
4. **Reference Resolution**: Different reference formats and edge cases.
|
||||
5. **API Operations**: All required endpoints with valid and invalid inputs.
|
||||
6. **Authentication**: Access control for public/private packages.
|
||||
7. **Error Conditions**: Proper error responses for all failure modes.
|
||||
8. **Caching**: Cache behavior for updated and unchanged packages.
|
||||
|
||||
---
|
||||
|
||||
## 15. Versioning of This Standard
|
||||
|
||||
This document is **Version 1.0.0** of the Package Registry Standard. Future revisions:
|
||||
|
||||
- A **patch** version (1.0.x) corrects errors and clarifies semantics without changing required behavior.
|
||||
- A **minor** version (1.x.0) adds new optional features without breaking conformance of existing implementations.
|
||||
- A **major** version (x.0.0) introduces breaking changes; non-backward-compatible.
|
||||
|
||||
Conformance MUST be declared against a specific version of this standard. Implementations MAY support multiple major versions concurrently.
|
||||
@@ -0,0 +1,183 @@
|
||||
Feature: Registry Exception Hierarchy
|
||||
As a developer using the Package Registry Standard v1.0.0
|
||||
I want a typed exception hierarchy per §13.2
|
||||
So that I can handle each error type appropriately
|
||||
|
||||
Background:
|
||||
Given I have imported the registry exception module
|
||||
|
||||
# ── RegistryError construction ────────────────────────────────────────
|
||||
|
||||
Scenario: RegistryError with message only
|
||||
When I raise a plain RegistryError with text "something went wrong"
|
||||
Then the error message should be "something went wrong"
|
||||
And the error should have no details
|
||||
And the error should have no original_reference
|
||||
|
||||
Scenario: RegistryError with message and details
|
||||
When I raise a RegistryError text "invalid input" and detail {"field": "name"}
|
||||
Then the error message should be "invalid input"
|
||||
And the error details should equal {"field": "name"}
|
||||
And the error should have no original_reference
|
||||
|
||||
Scenario: RegistryError with message and original_reference
|
||||
When I raise a RegistryError text "not found" and reference "pkg_act_unknown"
|
||||
Then the error message should be "not found"
|
||||
And the error original_reference should be "pkg_act_unknown"
|
||||
And str of the error should be "not found [ref: pkg_act_unknown]"
|
||||
|
||||
Scenario: RegistryError with message, details, and original_reference
|
||||
When I raise a RegistryError text "conflict" detail {"dup": true} and reference "pkg_act_abc"
|
||||
Then the error message should be "conflict"
|
||||
And the error details should equal {"dup": true}
|
||||
And the error original_reference should be "pkg_act_abc"
|
||||
And str of the error should be "conflict [ref: pkg_act_abc]"
|
||||
|
||||
# ── __str__ without original_reference ────────────────────────────────
|
||||
|
||||
Scenario: RegistryError __str__ is just the message when no reference
|
||||
When I raise a plain RegistryError with text "plain error"
|
||||
Then str of the error should be "plain error"
|
||||
|
||||
# ── RegistryNetworkError specific fields ──────────────────────────────
|
||||
|
||||
Scenario: RegistryNetworkError carries status_code and url
|
||||
When I raise a simple network error message "server error" status 500 url "https://registry.example.com/packages/pkg_act_test"
|
||||
Then the error message should be "server error"
|
||||
And the error status_code should be 500
|
||||
And the error url should be "https://registry.example.com/packages/pkg_act_test"
|
||||
And str of the error should be "server error [status: 500] [url: https://registry.example.com/packages/pkg_act_test]"
|
||||
|
||||
Scenario: RegistryNetworkError with all fields
|
||||
When I raise a fully detailed network error message "timeout" detail {"retries": 3} ref "pkg_act_x" status None url "https://bad.example.com"
|
||||
Then the error message should be "timeout"
|
||||
And the error details should equal {"retries": 3}
|
||||
And the error original_reference should be "pkg_act_x"
|
||||
And the error status_code should be None
|
||||
And the error url should be "https://bad.example.com"
|
||||
And str of the error should be "timeout [ref: pkg_act_x] [url: https://bad.example.com]"
|
||||
|
||||
# ── Typed subclass construction ──────────────────────────────────────
|
||||
|
||||
Scenario: PackageNotFoundError construction
|
||||
When I raise a PackageNotFoundError with message "pkg not found"
|
||||
Then the error message should be "pkg not found"
|
||||
And the error should be an instance of PackageNotFoundError
|
||||
|
||||
Scenario: InvalidPackageIdError construction
|
||||
When I raise an InvalidPackageIdError with message "bad id"
|
||||
Then the error message should be "bad id"
|
||||
And the error should be an instance of InvalidPackageIdError
|
||||
|
||||
Scenario: InvalidPackageReferenceError construction
|
||||
When I raise an InvalidPackageReferenceError with message "bad ref"
|
||||
Then the error message should be "bad ref"
|
||||
And the error should be an instance of InvalidPackageReferenceError
|
||||
|
||||
Scenario: VersionNotFoundError construction
|
||||
When I raise a VersionNotFoundError with message "version missing"
|
||||
Then the error message should be "version missing"
|
||||
And the error should be an instance of VersionNotFoundError
|
||||
|
||||
Scenario: ValidationError construction
|
||||
When I raise a ValidationError with message "invalid"
|
||||
Then the error message should be "invalid"
|
||||
And the error should be an instance of ValidationError
|
||||
|
||||
Scenario: AuthenticationRequiredError construction
|
||||
When I raise an AuthenticationRequiredError with message "auth needed"
|
||||
Then the error message should be "auth needed"
|
||||
And the error should be an instance of AuthenticationRequiredError
|
||||
|
||||
Scenario: AccessDeniedError construction
|
||||
When I raise an AccessDeniedError with message "no access"
|
||||
Then the error message should be "no access"
|
||||
And the error should be an instance of AccessDeniedError
|
||||
|
||||
Scenario: ConflictError construction
|
||||
When I raise a ConflictError with message "duplicate"
|
||||
Then the error message should be "duplicate"
|
||||
And the error should be an instance of ConflictError
|
||||
|
||||
# ── HTTP status code mapping (§13.2) ─────────────────────────────────
|
||||
|
||||
Scenario: 400 maps to InvalidPackageIdError
|
||||
When I call exception_for_status with code 400 and message "bad request"
|
||||
Then the result should be an instance of InvalidPackageIdError
|
||||
And the error message should be "bad request"
|
||||
|
||||
Scenario: 401 maps to AuthenticationRequiredError
|
||||
When I call exception_for_status with code 401 and message "auth required"
|
||||
Then the result should be an instance of AuthenticationRequiredError
|
||||
And the error message should be "auth required"
|
||||
|
||||
Scenario: 403 maps to AccessDeniedError
|
||||
When I call exception_for_status with code 403 and message "denied"
|
||||
Then the result should be an instance of AccessDeniedError
|
||||
And the error message should be "denied"
|
||||
|
||||
Scenario: 404 maps to PackageNotFoundError
|
||||
When I call exception_for_status with code 404 and message "gone"
|
||||
Then the result should be an instance of PackageNotFoundError
|
||||
And the error message should be "gone"
|
||||
|
||||
Scenario: 409 maps to ConflictError
|
||||
When I call exception_for_status with code 409 and message "conflict"
|
||||
Then the result should be an instance of ConflictError
|
||||
And the error message should be "conflict"
|
||||
|
||||
Scenario: 500 maps to RegistryNetworkError
|
||||
When I call exception_for_status with code 500 and message "server error"
|
||||
Then the result should be an instance of RegistryNetworkError
|
||||
And the error message should be "server error"
|
||||
And the error status_code should be 500
|
||||
|
||||
Scenario: 503 maps to RegistryNetworkError
|
||||
When I call exception_for_status with code 503 and message "unavailable"
|
||||
Then the result should be an instance of RegistryNetworkError
|
||||
And the error message should be "unavailable"
|
||||
And the error status_code should be 503
|
||||
|
||||
Scenario: non-integer status_code falls back to RegistryError
|
||||
When I call exception_for_status with non-integer code "not-a-number" and message "bad status"
|
||||
Then the result should be an instance of RegistryError
|
||||
And the error message should be "bad status"
|
||||
|
||||
Scenario: Unknown status falls back to RegistryError
|
||||
When I call exception_for_status with code 418 and message "teapot"
|
||||
Then the result should be an instance of RegistryError
|
||||
And the error message should be "teapot"
|
||||
|
||||
# ── Inheritance chain ─────────────────────────────────────────────────
|
||||
|
||||
Scenario: All typed errors inherit from RegistryError
|
||||
When I check that PackageNotFoundError is a subclass of RegistryError
|
||||
And I check that InvalidPackageIdError is a subclass of RegistryError
|
||||
And I check that InvalidPackageReferenceError is a subclass of RegistryError
|
||||
And I check that VersionNotFoundError is a subclass of RegistryError
|
||||
And I check that ValidationError is a subclass of RegistryError
|
||||
And I check that AuthenticationRequiredError is a subclass of RegistryError
|
||||
And I check that AccessDeniedError is a subclass of RegistryError
|
||||
And I check that ConflictError is a subclass of RegistryError
|
||||
And I check that RegistryNetworkError is a subclass of RegistryError
|
||||
Then all 9 checks should pass
|
||||
|
||||
Scenario: RegistryError inherits from CleverAgentsException
|
||||
When I check that RegistryError is a subclass of CleverAgentsException
|
||||
Then all 1 checks should pass
|
||||
|
||||
# ── Error type map completeness ──────────────────────────────────────
|
||||
|
||||
Scenario: _ERROR_TYPE_MAP contains all error type keys
|
||||
When I inspect the _ERROR_TYPE_MAP
|
||||
Then the map should contain key "PackageNotFound"
|
||||
And the map should contain key "InvalidPackageId"
|
||||
And the map should contain key "InvalidPackageReference"
|
||||
And the map should contain key "VersionNotFound"
|
||||
And the map should contain key "ValidationError"
|
||||
And the map should contain key "AuthenticationRequired"
|
||||
And the map should contain key "AccessDenied"
|
||||
And the map should contain key "Conflict"
|
||||
And the map should contain key "InternalServerError"
|
||||
And the value for key "InternalServerError" should be RegistryNetworkError
|
||||
And the map should have exactly 9 keys
|
||||
@@ -104,9 +104,17 @@ Feature: Registry HTTP Client
|
||||
When the mock server raises a timeout error
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then a RegistryNetworkError should be raised
|
||||
And the raised error should have url None
|
||||
|
||||
# ── Error mapping (§13.2) ─────────────────────────────────────────────
|
||||
|
||||
Scenario: Response with null message preserves default HTTP status message
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 500 with body '{"message": null}'
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then a RegistryNetworkError should be raised
|
||||
And the error message should be "HTTP 500"
|
||||
|
||||
Scenario: 401 response raises AuthenticationRequiredError
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 401 with body '{"message": "Authentication required"}'
|
||||
@@ -125,17 +133,21 @@ Feature: Registry HTTP Client
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then a ConflictError should be raised
|
||||
|
||||
Scenario: 500 response raises InternalServerError
|
||||
Scenario: 500 response raises RegistryNetworkError
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 500 with body '{"message": "Internal error"}'
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then an InternalServerError should be raised
|
||||
Then a RegistryNetworkError should be raised
|
||||
And the raised error should have status_code 500
|
||||
And the raised error should have url set
|
||||
|
||||
Scenario: 500 range response raises InternalServerError
|
||||
Scenario: 503 response raises RegistryNetworkError
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 503 with body '{"message": "Service unavailable"}'
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then an InternalServerError should be raised
|
||||
Then a RegistryNetworkError should be raised
|
||||
And the raised error should have status_code 503
|
||||
And the raised error should have url set
|
||||
|
||||
Scenario: Unknown error status falls back to RegistryError
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
@@ -157,6 +169,13 @@ Feature: Registry HTTP Client
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then a VersionNotFoundError should be raised
|
||||
|
||||
Scenario: Error response with details propagates details to exception
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 404 with body '{"error": {"type": "PackageNotFound", "message": "not found", "details": {"package_id": "pkg_abc"}}}'
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then a PackageNotFoundError should be raised
|
||||
And the error details should equal {"package_id": "pkg_abc"}
|
||||
|
||||
Scenario: 400 with ValidationError error type in body raises ValidationError
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 400 with body '{"error": {"type": "ValidationError", "message": "Package validation failed"}}'
|
||||
@@ -166,12 +185,15 @@ Feature: Registry HTTP Client
|
||||
Scenario: 404 without error type in body falls back to PackageNotFoundError
|
||||
When I create a RegistryClient pointed at "https://registry.example.com"
|
||||
When the mock server returns status 404 with body '{"message": "Package not found"}'
|
||||
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
|
||||
Then a PackageNotFoundError should be raised
|
||||
|
||||
Scenario: Client with API key includes authorization header
|
||||
When I create an authenticated RegistryClient pointed at "https://registry.example.com" with API key "my-api-key"
|
||||
When the mock server returns status 200 with body '{"content": "ok"}'
|
||||
When I call get_package with API key client and package_id "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
|
||||
When I call get_package with API key client and package_id "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" capturing headers
|
||||
Then the registry result body equals '{"content": "ok"}'
|
||||
And the Authorization header should be "Bearer my-api-key"
|
||||
|
||||
# ── All package type prefixes ────────────────────────────────────────
|
||||
|
||||
@@ -202,13 +224,13 @@ Feature: Registry HTTP Client
|
||||
# ── HTTPS enforcement (spec §12.2) ────────────────────────────────────
|
||||
|
||||
Scenario: Client warns when HTTP is used with an API key
|
||||
When I create a RegistryClient with base URL "http://registry.example.com" and API key "token123"
|
||||
Then the client should log a warning about cleartext credentials
|
||||
When I create a RegistryClient with base URL "http://registry.example.com" and API key "token123" and capture logs
|
||||
Then a cleartext credentials warning was logged
|
||||
|
||||
Scenario: Client logs info when HTTP is used without an API key
|
||||
When I create a RegistryClient pointed at "http://registry.example.com"
|
||||
Then the client should log info about HTTPS requirement
|
||||
When I create a RegistryClient pointed at "http://registry.example.com" and capture logs
|
||||
Then an HTTPS requirement info message was logged
|
||||
|
||||
Scenario: Client with allow_insecure suppresses HTTP warnings
|
||||
When I create an insecure RegistryClient pointed at "http://registry.example.com"
|
||||
Then the client should not log HTTPS warnings
|
||||
When I create an insecure RegistryClient pointed at "http://registry.example.com" and capture logs
|
||||
Then no HTTPS warnings were logged
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Step definitions for Registry Exception Hierarchy BDD tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveractors.core.exceptions import CleverAgentsException
|
||||
from cleveractors.registry.exceptions import (
|
||||
_ERROR_TYPE_MAP,
|
||||
AccessDeniedError,
|
||||
AuthenticationRequiredError,
|
||||
ConflictError,
|
||||
InvalidPackageIdError,
|
||||
InvalidPackageReferenceError,
|
||||
PackageNotFoundError,
|
||||
RegistryError,
|
||||
RegistryNetworkError,
|
||||
ValidationError,
|
||||
VersionNotFoundError,
|
||||
exception_for_status,
|
||||
)
|
||||
|
||||
_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
|
||||
"CleverAgentsException": CleverAgentsException,
|
||||
"RegistryError": RegistryError,
|
||||
"PackageNotFoundError": PackageNotFoundError,
|
||||
"InvalidPackageIdError": InvalidPackageIdError,
|
||||
"InvalidPackageReferenceError": InvalidPackageReferenceError,
|
||||
"VersionNotFoundError": VersionNotFoundError,
|
||||
"ValidationError": ValidationError,
|
||||
"AuthenticationRequiredError": AuthenticationRequiredError,
|
||||
"AccessDeniedError": AccessDeniedError,
|
||||
"ConflictError": ConflictError,
|
||||
"RegistryNetworkError": RegistryNetworkError,
|
||||
}
|
||||
|
||||
|
||||
@given("I have imported the registry exception module")
|
||||
def step_import_exception_module(context: Context) -> None:
|
||||
context._checks_passed = 0
|
||||
|
||||
|
||||
# ── Construction steps ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I raise a plain RegistryError with text "{message}"')
|
||||
def step_raise_registry_error_message(context: Context, message: str) -> None:
|
||||
context.error = RegistryError(message)
|
||||
|
||||
|
||||
@when('I raise a RegistryError text "{message}" and detail {details_json}')
|
||||
def step_raise_registry_error_message_details(
|
||||
context: Context, message: str, details_json: str
|
||||
) -> None:
|
||||
context.error = RegistryError(message, details=json.loads(details_json))
|
||||
|
||||
|
||||
@when('I raise a RegistryError text "{message}" and reference "{ref}"')
|
||||
def step_raise_registry_error_message_ref(
|
||||
context: Context, message: str, ref: str
|
||||
) -> None:
|
||||
context.error = RegistryError(message, original_reference=ref)
|
||||
|
||||
|
||||
@when(
|
||||
'I raise a RegistryError text "{message}" detail {details_json} and reference "{ref}"'
|
||||
)
|
||||
def step_raise_registry_error_full(
|
||||
context: Context, message: str, details_json: str, ref: str
|
||||
) -> None:
|
||||
context.error = RegistryError(
|
||||
message, details=json.loads(details_json), original_reference=ref
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I raise a simple network error message "{message}" status {status_code} url "{url}"'
|
||||
)
|
||||
def step_raise_network_error_basic(
|
||||
context: Context, message: str, status_code: str, url: str
|
||||
) -> None:
|
||||
sc = int(status_code)
|
||||
context.error = RegistryNetworkError(message, status_code=sc, url=url)
|
||||
|
||||
|
||||
@when(
|
||||
'I raise a fully detailed network error message "{message}" detail {details_json} ref "{ref}" status {status_code} url "{url}"'
|
||||
)
|
||||
def step_raise_network_error_full(
|
||||
context: Context,
|
||||
message: str,
|
||||
details_json: str,
|
||||
ref: str,
|
||||
status_code: str,
|
||||
url: str,
|
||||
) -> None:
|
||||
sc = None if status_code == "None" else int(status_code)
|
||||
context.error = RegistryNetworkError(
|
||||
message,
|
||||
details=json.loads(details_json),
|
||||
original_reference=ref,
|
||||
status_code=sc,
|
||||
url=url,
|
||||
)
|
||||
|
||||
|
||||
# ── Assertion steps ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then('the error message should be "{expected}"')
|
||||
def step_error_message(context: Context, expected: str) -> None:
|
||||
assert context.error.message == expected, (
|
||||
f"Expected message {expected!r}, got {context.error.message!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the error should have no details")
|
||||
def step_error_no_details(context: Context) -> None:
|
||||
assert context.error.details is None, (
|
||||
f"Expected details=None, got {context.error.details!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the error should have no original_reference")
|
||||
def step_error_no_ref(context: Context) -> None:
|
||||
assert context.error.original_reference is None, (
|
||||
f"Expected original_reference=None, got {context.error.original_reference!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the error details should equal {expected_json}")
|
||||
def step_error_details(context: Context, expected_json: str) -> None:
|
||||
expected = json.loads(expected_json)
|
||||
assert context.error.details == expected, (
|
||||
f"Expected details {expected!r}, got {context.error.details!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the error original_reference should be "{expected}"')
|
||||
def step_error_ref(context: Context, expected: str) -> None:
|
||||
assert context.error.original_reference == expected, (
|
||||
f"Expected original_reference {expected!r}, "
|
||||
f"got {context.error.original_reference!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('str of the error should be "{expected}"')
|
||||
def step_error_str(context: Context, expected: str) -> None:
|
||||
assert str(context.error) == expected, (
|
||||
f"Expected str {expected!r}, got {str(context.error)!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the error status_code should be {expected}")
|
||||
def step_error_status_code(context: Context, expected: str) -> None:
|
||||
exp = None if expected == "None" else int(expected)
|
||||
assert context.error.status_code == exp, (
|
||||
f"Expected status_code {exp!r}, got {context.error.status_code!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the error url should be "{expected}"')
|
||||
def step_error_url(context: Context, expected: str) -> None:
|
||||
assert context.error.url == expected, (
|
||||
f"Expected url {expected!r}, got {context.error.url!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── exception_for_status steps ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I call exception_for_status with code {code:d} and message "{message}"')
|
||||
def step_call_exception_for_status(context: Context, code: int, message: str) -> None:
|
||||
context.result = exception_for_status(code, message)
|
||||
|
||||
|
||||
@when(
|
||||
'I call exception_for_status with non-integer code "{code}" and message "{message}"'
|
||||
)
|
||||
def step_call_exception_for_status_non_int(
|
||||
context: Context, code: str, message: str
|
||||
) -> None:
|
||||
# Intentionally non-int to exercise the isinstance guard in exception_for_status
|
||||
context.result = exception_for_status(cast(int, code), message)
|
||||
|
||||
|
||||
@then("the result should be an instance of {cls_name}")
|
||||
def step_result_instance_of(context: Context, cls_name: str) -> None:
|
||||
expected_cls = _CLASS_MAP[cls_name]
|
||||
assert isinstance(context.result, expected_cls), (
|
||||
f"Expected isinstance of {expected_cls.__name__}, "
|
||||
f"got {type(context.result).__name__}"
|
||||
)
|
||||
context.error = context.result
|
||||
|
||||
|
||||
# ── Inheritance chain steps ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I check that {child} is a subclass of {parent}")
|
||||
def step_check_subclass(context: Context, child: str, parent: str) -> None:
|
||||
child_cls = _CLASS_MAP[child]
|
||||
parent_cls = _CLASS_MAP[parent]
|
||||
assert issubclass(child_cls, parent_cls), (
|
||||
f"{child_cls.__name__} is not a subclass of {parent_cls.__name__}"
|
||||
)
|
||||
context._checks_passed += 1
|
||||
|
||||
|
||||
@then("all {count:d} checks should pass")
|
||||
def step_all_checks_pass(context: Context, count: int) -> None:
|
||||
assert context._checks_passed == count, (
|
||||
f"Expected {count} inheritance checks, got {context._checks_passed}"
|
||||
)
|
||||
|
||||
|
||||
# ── Error type map steps ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I inspect the _ERROR_TYPE_MAP")
|
||||
def step_inspect_map(context: Context) -> None:
|
||||
context._error_map = _ERROR_TYPE_MAP
|
||||
|
||||
|
||||
@then('the map should contain key "{key}"')
|
||||
def step_map_has_key(context: Context, key: str) -> None:
|
||||
assert key in context._error_map, f"Expected _ERROR_TYPE_MAP to contain key {key!r}"
|
||||
|
||||
|
||||
@then("the map should have exactly {count:d} keys")
|
||||
def step_map_cardinality(context: Context, count: int) -> None:
|
||||
actual = len(context._error_map)
|
||||
assert actual == count, (
|
||||
f"Expected _ERROR_TYPE_MAP to have {count} keys, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
@then('the value for key "{key}" should be {value_cls}')
|
||||
def step_map_value(context: Context, key: str, value_cls: str) -> None:
|
||||
expected = _CLASS_MAP[value_cls]
|
||||
assert context._error_map[key] is expected, (
|
||||
f"Expected _ERROR_TYPE_MAP[{key!r}] = {expected.__name__}, "
|
||||
f"got {context._error_map[key].__name__}"
|
||||
)
|
||||
|
||||
|
||||
# ── Typed subclass construction steps ────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I raise a PackageNotFoundError with message "{message}"')
|
||||
def step_raise_package_not_found_error(context: Context, message: str) -> None:
|
||||
context.error = PackageNotFoundError(message)
|
||||
|
||||
|
||||
@when('I raise an InvalidPackageIdError with message "{message}"')
|
||||
def step_raise_invalid_id_error(context: Context, message: str) -> None:
|
||||
context.error = InvalidPackageIdError(message)
|
||||
|
||||
|
||||
@when('I raise an InvalidPackageReferenceError with message "{message}"')
|
||||
def step_raise_invalid_ref_error(context: Context, message: str) -> None:
|
||||
context.error = InvalidPackageReferenceError(message)
|
||||
|
||||
|
||||
@when('I raise a VersionNotFoundError with message "{message}"')
|
||||
def step_raise_version_not_found_error(context: Context, message: str) -> None:
|
||||
context.error = VersionNotFoundError(message)
|
||||
|
||||
|
||||
@when('I raise a ValidationError with message "{message}"')
|
||||
def step_raise_validation_error(context: Context, message: str) -> None:
|
||||
context.error = ValidationError(message)
|
||||
|
||||
|
||||
@when('I raise an AuthenticationRequiredError with message "{message}"')
|
||||
def step_raise_auth_required_error(context: Context, message: str) -> None:
|
||||
context.error = AuthenticationRequiredError(message)
|
||||
|
||||
|
||||
@when('I raise an AccessDeniedError with message "{message}"')
|
||||
def step_raise_access_denied_error(context: Context, message: str) -> None:
|
||||
context.error = AccessDeniedError(message)
|
||||
|
||||
|
||||
@when('I raise a ConflictError with message "{message}"')
|
||||
def step_raise_conflict_error(context: Context, message: str) -> None:
|
||||
context.error = ConflictError(message)
|
||||
|
||||
|
||||
@then("the error should be an instance of {cls_name}")
|
||||
def step_error_is_instance(context: Context, cls_name: str) -> None:
|
||||
assert isinstance(context.error, _CLASS_MAP[cls_name]), (
|
||||
f"Expected {cls_name}, got {type(context.error).__name__}"
|
||||
)
|
||||
@@ -16,8 +16,8 @@ from cleveractors.registry.exceptions import (
|
||||
AccessDeniedError,
|
||||
AuthenticationRequiredError,
|
||||
ConflictError,
|
||||
InternalServerError,
|
||||
InvalidPackageIdError,
|
||||
InvalidPackageReferenceError,
|
||||
PackageNotFoundError,
|
||||
RegistryError,
|
||||
RegistryNetworkError,
|
||||
@@ -164,7 +164,7 @@ def step_call_get_package(context: Any, package_id: str) -> None:
|
||||
context.client._client = context._mock_client
|
||||
context.result = await context.client.get_package(package_id)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when('I try to call get_package with package_id "{package_id}"')
|
||||
@@ -176,7 +176,7 @@ def step_try_call_get_package(context: Any, package_id: str) -> None:
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@then("the registry result body equals '{body}'")
|
||||
@@ -205,6 +205,36 @@ def step_check_network_error(context: Any) -> None:
|
||||
assert isinstance(context.error, RegistryNetworkError)
|
||||
|
||||
|
||||
@then("the raised error should have status_code {expected:d}")
|
||||
def step_check_error_status_code(context: Any, expected: int) -> None:
|
||||
assert context.error is not None
|
||||
assert context.error.status_code == expected, (
|
||||
f"Expected status_code {expected}, got {context.error.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@then('the raised error should have url "{expected}"')
|
||||
def step_check_error_url(context: Any, expected: str) -> None:
|
||||
assert context.error is not None
|
||||
assert context.error.url == expected, (
|
||||
f"Expected url {expected!r}, got {context.error.url!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the raised error should have url set")
|
||||
def step_check_error_url_set(context: Any) -> None:
|
||||
assert context.error is not None
|
||||
assert context.error.url is not None, "Expected url to be set, got None"
|
||||
|
||||
|
||||
@then("the raised error should have url None")
|
||||
def step_check_error_url_none(context: Any) -> None:
|
||||
assert context.error is not None
|
||||
assert context.error.url is None, (
|
||||
f"Expected url to be None, got {context.error.url!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── resolve_package (§8.2.2) ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -220,7 +250,7 @@ def step_call_resolve_with_version(
|
||||
pkg_type, ns, name, version
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when(
|
||||
@@ -235,7 +265,7 @@ def step_call_resolve_and_version(
|
||||
pkg_type, ns, name, version
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when(
|
||||
@@ -248,7 +278,7 @@ def step_call_resolve_no_version(
|
||||
context.client._client = context._mock_client
|
||||
context.result = await context.client.resolve_package(pkg_type, ns, name)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when(
|
||||
@@ -264,7 +294,7 @@ def step_try_call_resolve(
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@then('the resolved package_id should be "{expected}"')
|
||||
@@ -289,7 +319,7 @@ def step_call_browse_no_filters(context: Any) -> None:
|
||||
context.client._client = context._mock_client
|
||||
context.result = await context.client.browse()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when('I call browse with type "{pkg_type}"')
|
||||
@@ -298,7 +328,7 @@ def step_call_browse_with_type(context: Any, pkg_type: str) -> None:
|
||||
context.client._client = context._mock_client
|
||||
context.result = await context.client.browse(package_type=pkg_type)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when('I call browse with namespace "{ns}"')
|
||||
@@ -307,7 +337,7 @@ def step_call_browse_with_namespace(context: Any, ns: str) -> None:
|
||||
context.client._client = context._mock_client
|
||||
context.result = await context.client.browse(namespace=ns)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@then("the result count should be {expected:d}")
|
||||
@@ -325,7 +355,7 @@ def step_call_discover(context: Any) -> None:
|
||||
context.client._client = context._mock_client
|
||||
context.result = await context.client.discover()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@then('the result should have key "{key}"')
|
||||
@@ -345,7 +375,7 @@ def step_use_context_manager(context: Any) -> None:
|
||||
context.ctx_client = client
|
||||
context.ctx_was_open = not client._client.is_closed
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_use())
|
||||
asyncio.run(_use())
|
||||
|
||||
|
||||
@then("the client should be closed after the context block")
|
||||
@@ -384,12 +414,6 @@ def step_check_conflict(context: Any) -> None:
|
||||
assert isinstance(context.error, ConflictError)
|
||||
|
||||
|
||||
@then("an InternalServerError should be raised")
|
||||
def step_check_internal_error(context: Any) -> None:
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, InternalServerError)
|
||||
|
||||
|
||||
@then("a RegistryError should be raised")
|
||||
def step_check_registry_error(context: Any) -> None:
|
||||
assert context.error is not None
|
||||
@@ -400,9 +424,12 @@ def step_check_registry_error(context: Any) -> None:
|
||||
AuthenticationRequiredError,
|
||||
AccessDeniedError,
|
||||
ConflictError,
|
||||
InternalServerError,
|
||||
RegistryNetworkError,
|
||||
InvalidPackageIdError,
|
||||
PackageNotFoundError,
|
||||
VersionNotFoundError,
|
||||
InvalidPackageReferenceError,
|
||||
ValidationError,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -419,53 +446,74 @@ def step_check_validation_error(context: Any) -> None:
|
||||
assert isinstance(context.error, ValidationError)
|
||||
|
||||
|
||||
@when('I call get_package with API key client and package_id "{package_id}"')
|
||||
def step_call_get_package_with_key(context: Any, package_id: str) -> None:
|
||||
@when(
|
||||
'I call get_package with API key client and package_id "{package_id}" capturing headers'
|
||||
)
|
||||
def step_call_get_package_with_key_capture_headers(
|
||||
context: Any, package_id: str
|
||||
) -> None:
|
||||
async def _call() -> None:
|
||||
with patch("cleveractors.registry.client.httpx.AsyncClient") as mock_cls:
|
||||
mock_cls.return_value = context._mock_client
|
||||
context.result = await context.client.get_package(package_id)
|
||||
context._captured_client_kwargs = mock_cls.call_args
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_call())
|
||||
asyncio.run(_call())
|
||||
|
||||
|
||||
@when('I create an insecure RegistryClient pointed at "{base_url}"')
|
||||
def step_create_insecure_client(context: Any, base_url: str) -> None:
|
||||
from cleveractors.registry.client import RegistryClient
|
||||
|
||||
context.client = RegistryClient(base_url=base_url, allow_insecure=True)
|
||||
@then('the Authorization header should be "{expected}"')
|
||||
def step_check_authorization_header(context: Any, expected: str) -> None:
|
||||
headers = context._captured_client_kwargs[1].get("headers", {})
|
||||
actual = headers.get("Authorization", "")
|
||||
assert actual == expected, (
|
||||
f"Expected Authorization header {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the client should log a warning about cleartext credentials")
|
||||
def step_client_logs_cleartext_warning(context: Any) -> None:
|
||||
from cleveractors.registry.client import RegistryClient
|
||||
|
||||
@when(
|
||||
'I create a RegistryClient with base URL "{base_url}" and API key "{api_key}" and capture logs'
|
||||
)
|
||||
def step_create_client_with_key_and_capture_logs(
|
||||
context: Any, base_url: str, api_key: str
|
||||
) -> None:
|
||||
with patch("cleveractors.registry.client.logger.warning") as mock_warn:
|
||||
RegistryClient(base_url="http://example.com", api_key="test-key")
|
||||
mock_warn.assert_called_once()
|
||||
assert "cleartext" in mock_warn.call_args[0][0].lower()
|
||||
context.client = RegistryClient(base_url=base_url, api_key=api_key)
|
||||
context._log_warn_mock = mock_warn
|
||||
|
||||
|
||||
@then("the client should log info about HTTPS requirement")
|
||||
def step_client_logs_https_info(context: Any) -> None:
|
||||
from cleveractors.registry.client import RegistryClient
|
||||
|
||||
@when('I create a RegistryClient pointed at "{base_url}" and capture logs')
|
||||
def step_create_client_and_capture_logs(context: Any, base_url: str) -> None:
|
||||
with patch("cleveractors.registry.client.logger.info") as mock_info:
|
||||
RegistryClient(base_url="http://example.com")
|
||||
mock_info.assert_called_once()
|
||||
assert "https" in mock_info.call_args[0][0].lower()
|
||||
context.client = RegistryClient(base_url=base_url)
|
||||
context._log_info_mock = mock_info
|
||||
|
||||
|
||||
@then("the client should not log HTTPS warnings")
|
||||
def step_client_no_https_warnings(context: Any) -> None:
|
||||
from cleveractors.registry.client import RegistryClient
|
||||
|
||||
@when('I create an insecure RegistryClient pointed at "{base_url}" and capture logs')
|
||||
def step_create_insecure_client_and_capture_logs(context: Any, base_url: str) -> None:
|
||||
with patch("cleveractors.registry.client.logger.warning") as mock_warn:
|
||||
with patch("cleveractors.registry.client.logger.info") as mock_info:
|
||||
RegistryClient(base_url="http://example.com", allow_insecure=True)
|
||||
for call_args in mock_warn.call_args_list:
|
||||
msg = call_args[0][0].lower()
|
||||
assert "cleartext" not in msg
|
||||
for call_args in mock_info.call_args_list:
|
||||
msg = call_args[0][0].lower()
|
||||
assert "https" not in msg or "production" not in msg
|
||||
context.client = RegistryClient(base_url=base_url, allow_insecure=True)
|
||||
context._log_warn_mock = mock_warn
|
||||
context._log_info_mock = mock_info
|
||||
|
||||
|
||||
@then("a cleartext credentials warning was logged")
|
||||
def step_assert_cleartext_warning(context: Any) -> None:
|
||||
context._log_warn_mock.assert_called_once()
|
||||
assert "cleartext" in context._log_warn_mock.call_args[0][0].lower()
|
||||
|
||||
|
||||
@then("an HTTPS requirement info message was logged")
|
||||
def step_assert_https_info(context: Any) -> None:
|
||||
context._log_info_mock.assert_called_once()
|
||||
assert "https" in context._log_info_mock.call_args[0][0].lower()
|
||||
|
||||
|
||||
@then("no HTTPS warnings were logged")
|
||||
def step_assert_no_https_warnings(context: Any) -> None:
|
||||
for call_args in context._log_warn_mock.call_args_list:
|
||||
msg = call_args[0][0].lower()
|
||||
assert "cleartext" not in msg
|
||||
for call_args in context._log_info_mock.call_args_list:
|
||||
msg = call_args[0][0].lower()
|
||||
assert "https" not in msg or "production" not in msg
|
||||
|
||||
+49
-12
@@ -30,6 +30,19 @@ from cleveractors.core.exceptions import (
|
||||
ConfigurationError,
|
||||
)
|
||||
from cleveractors.core.progress import ProgressBarManager
|
||||
from cleveractors.registry.exceptions import (
|
||||
AccessDeniedError,
|
||||
AuthenticationRequiredError,
|
||||
ConflictError,
|
||||
InvalidPackageIdError,
|
||||
InvalidPackageReferenceError,
|
||||
PackageNotFoundError,
|
||||
RegistryError,
|
||||
RegistryNetworkError,
|
||||
ValidationError,
|
||||
VersionNotFoundError,
|
||||
exception_for_status,
|
||||
)
|
||||
from cleveractors.templates.enhanced_registry import EnhancedTemplateRegistry
|
||||
from cleveractors.templates.registry import TemplateRegistry
|
||||
from cleveractors.templates.renderer import TemplateEngine, TemplateRenderer
|
||||
@@ -43,6 +56,23 @@ from cleveractors.registry.reference_resolver import PackageContentResolver
|
||||
from cleveractors.registry.types import PackageReference
|
||||
|
||||
|
||||
_EXCEPTION_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
|
||||
"CleverAgentsException": CleverAgentsException,
|
||||
"ConfigurationError": ConfigurationError,
|
||||
"AgentCreationError": AgentCreationError,
|
||||
"RegistryError": RegistryError,
|
||||
"PackageNotFoundError": PackageNotFoundError,
|
||||
"InvalidPackageIdError": InvalidPackageIdError,
|
||||
"InvalidPackageReferenceError": InvalidPackageReferenceError,
|
||||
"VersionNotFoundError": VersionNotFoundError,
|
||||
"ValidationError": ValidationError,
|
||||
"AuthenticationRequiredError": AuthenticationRequiredError,
|
||||
"AccessDeniedError": AccessDeniedError,
|
||||
"ConflictError": ConflictError,
|
||||
"RegistryNetworkError": RegistryNetworkError,
|
||||
}
|
||||
|
||||
|
||||
class CleverActorsLib: # pragma: no cover - integration test library
|
||||
"""Keyword library for cleveractors Robot Framework integration tests.
|
||||
|
||||
@@ -362,25 +392,32 @@ class CleverActorsLib: # pragma: no cover - integration test library
|
||||
# ── exceptions ────────────────────────────────────────────────────
|
||||
|
||||
def exception_is_subclass_of(self, child: str, parent: str) -> None:
|
||||
exceptions = {
|
||||
"CleverAgentsException": CleverAgentsException,
|
||||
"ConfigurationError": ConfigurationError,
|
||||
"AgentCreationError": AgentCreationError,
|
||||
}
|
||||
child_cls = exceptions[child]
|
||||
parent_cls = exceptions[parent]
|
||||
child_cls = _EXCEPTION_CLASS_MAP[child]
|
||||
parent_cls = _EXCEPTION_CLASS_MAP[parent]
|
||||
if not issubclass(child_cls, parent_cls):
|
||||
raise AssertionError(f"{child} is not a subclass of {parent}")
|
||||
|
||||
def exception_is_instance_cleveragents_exception(self, exc_name: str) -> None:
|
||||
mapping = {
|
||||
"ConfigurationError": ConfigurationError,
|
||||
"AgentCreationError": AgentCreationError,
|
||||
}
|
||||
cls = mapping[exc_name]
|
||||
cls = _EXCEPTION_CLASS_MAP[exc_name]
|
||||
if not issubclass(cls, CleverAgentsException):
|
||||
raise AssertionError(f"{exc_name} is not a CleverAgentsException")
|
||||
|
||||
def exception_for_status_returns(self, code: str, expected_class: str) -> None:
|
||||
result = exception_for_status(int(code), f"status {code}")
|
||||
expected = _EXCEPTION_CLASS_MAP[expected_class]
|
||||
if not isinstance(result, expected):
|
||||
raise AssertionError(
|
||||
f"Expected {expected_class} for code {code}, "
|
||||
f"got {type(result).__name__}"
|
||||
)
|
||||
if (
|
||||
getattr(result, "status_code", None) is not None
|
||||
and str(result.status_code) != code
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Expected status_code {code}, got {result.status_code}"
|
||||
)
|
||||
|
||||
# ── progress bar ──────────────────────────────────────────────────
|
||||
|
||||
def create_progress_bar(self) -> None:
|
||||
|
||||
@@ -19,3 +19,121 @@ ConfigurationError Is Instance Of CleverAgentsException
|
||||
AgentCreationError Is Instance Of CleverAgentsException
|
||||
[Documentation] Verify isinstance check for agent error
|
||||
Exception Is Instance Cleveragents Exception AgentCreationError
|
||||
|
||||
# ── Registry exception hierarchy (§13.2) ─────────────────────────────────
|
||||
|
||||
RegistryError Is Subclass Of CleverAgentsException
|
||||
[Documentation] RegistryError extends CleverAgentsException
|
||||
Exception Is Subclass Of RegistryError CleverAgentsException
|
||||
|
||||
PackageNotFoundError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 404 extends RegistryError
|
||||
Exception Is Subclass Of PackageNotFoundError RegistryError
|
||||
|
||||
InvalidPackageIdError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 400 extends RegistryError
|
||||
Exception Is Subclass Of InvalidPackageIdError RegistryError
|
||||
|
||||
InvalidPackageReferenceError Is Subclass Of RegistryError
|
||||
[Documentation] Custom 400 extends RegistryError
|
||||
Exception Is Subclass Of InvalidPackageReferenceError RegistryError
|
||||
|
||||
VersionNotFoundError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 404 extends RegistryError
|
||||
Exception Is Subclass Of VersionNotFoundError RegistryError
|
||||
|
||||
ValidationError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 400 extends RegistryError
|
||||
Exception Is Subclass Of ValidationError RegistryError
|
||||
|
||||
AuthenticationRequiredError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 401 extends RegistryError
|
||||
Exception Is Subclass Of AuthenticationRequiredError RegistryError
|
||||
|
||||
AccessDeniedError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 403 extends RegistryError
|
||||
Exception Is Subclass Of AccessDeniedError RegistryError
|
||||
|
||||
ConflictError Is Subclass Of RegistryError
|
||||
[Documentation] Typed 409 extends RegistryError
|
||||
Exception Is Subclass Of ConflictError RegistryError
|
||||
|
||||
RegistryNetworkError Is Subclass Of RegistryError
|
||||
[Documentation] 5xx and transport extends RegistryError
|
||||
Exception Is Subclass Of RegistryNetworkError RegistryError
|
||||
|
||||
# ── RegistryError isinstance checks ──────────────────────────────────────
|
||||
|
||||
RegistryError Is Instance Of CleverAgentsException
|
||||
[Documentation] RegistryError is a CleverAgentsException
|
||||
Exception Is Instance Cleveragents Exception RegistryError
|
||||
|
||||
PackageNotFoundError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed errors inherit base class
|
||||
Exception Is Instance Cleveragents Exception PackageNotFoundError
|
||||
|
||||
RegistryNetworkError Is Instance Of CleverAgentsException
|
||||
[Documentation] NetworkError inherits base class
|
||||
Exception Is Instance Cleveragents Exception RegistryNetworkError
|
||||
|
||||
InvalidPackageIdError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed errors inherit base class
|
||||
Exception Is Instance Cleveragents Exception InvalidPackageIdError
|
||||
|
||||
InvalidPackageReferenceError Is Instance Of CleverAgentsException
|
||||
[Documentation] Custom reference error inherits base class
|
||||
Exception Is Instance Cleveragents Exception InvalidPackageReferenceError
|
||||
|
||||
VersionNotFoundError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed 404 inherits base class
|
||||
Exception Is Instance Cleveragents Exception VersionNotFoundError
|
||||
|
||||
ValidationError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed 400 inherits base class
|
||||
Exception Is Instance Cleveragents Exception ValidationError
|
||||
|
||||
AuthenticationRequiredError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed 401 inherits base class
|
||||
Exception Is Instance Cleveragents Exception AuthenticationRequiredError
|
||||
|
||||
AccessDeniedError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed 403 inherits base class
|
||||
Exception Is Instance Cleveragents Exception AccessDeniedError
|
||||
|
||||
ConflictError Is Instance Of CleverAgentsException
|
||||
[Documentation] Typed 409 inherits base class
|
||||
Exception Is Instance Cleveragents Exception ConflictError
|
||||
|
||||
# ── exception_for_status routing (§13.2) ─────────────────────────────────
|
||||
|
||||
Exception For Status 400 Returns InvalidPackageIdError
|
||||
[Documentation] 400 status code maps to InvalidPackageIdError
|
||||
Exception For Status Returns 400 InvalidPackageIdError
|
||||
|
||||
Exception For Status 401 Returns AuthenticationRequiredError
|
||||
[Documentation] 401 status code maps to AuthenticationRequiredError
|
||||
Exception For Status Returns 401 AuthenticationRequiredError
|
||||
|
||||
Exception For Status 403 Returns AccessDeniedError
|
||||
[Documentation] 403 status code maps to AccessDeniedError
|
||||
Exception For Status Returns 403 AccessDeniedError
|
||||
|
||||
Exception For Status 404 Returns PackageNotFoundError
|
||||
[Documentation] 404 status code maps to PackageNotFoundError
|
||||
Exception For Status Returns 404 PackageNotFoundError
|
||||
|
||||
Exception For Status 409 Returns ConflictError
|
||||
[Documentation] 409 status code maps to ConflictError
|
||||
Exception For Status Returns 409 ConflictError
|
||||
|
||||
Exception For Status 500 Returns RegistryNetworkError
|
||||
[Documentation] 500 status code maps to RegistryNetworkError
|
||||
Exception For Status Returns 500 RegistryNetworkError
|
||||
|
||||
Exception For Status 503 Returns RegistryNetworkError
|
||||
[Documentation] 503 status code maps to RegistryNetworkError
|
||||
Exception For Status Returns 503 RegistryNetworkError
|
||||
|
||||
Exception For Status 418 Returns RegistryError
|
||||
[Documentation] Unknown 4xx status code falls back to RegistryError
|
||||
Exception For Status Returns 418 RegistryError
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ ${EXPECTED_VERSION} 2.1.0
|
||||
Package Version Is Correct
|
||||
[Documentation] Verify __version__ matches expected value
|
||||
${result}= Run Process python -c import cleveractors; print(cleveractors.__version__)
|
||||
... timeout=30s
|
||||
... timeout=60s
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ${EXPECTED_VERSION}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from cleveractors.registry.exceptions import (
|
||||
AccessDeniedError,
|
||||
AuthenticationRequiredError,
|
||||
ConflictError,
|
||||
InternalServerError,
|
||||
InvalidPackageIdError,
|
||||
InvalidPackageReferenceError,
|
||||
PackageNotFoundError,
|
||||
@@ -15,6 +14,7 @@ from cleveractors.registry.exceptions import (
|
||||
RegistryNetworkError,
|
||||
ValidationError,
|
||||
VersionNotFoundError,
|
||||
exception_for_status,
|
||||
)
|
||||
from cleveractors.registry.reference_resolver import PackageContentResolver
|
||||
from cleveractors.registry.resolver import (
|
||||
@@ -38,7 +38,6 @@ __all__ = [
|
||||
"CacheStats",
|
||||
"Canonicalizer",
|
||||
"ConflictError",
|
||||
"InternalServerError",
|
||||
"InvalidPackageIdError",
|
||||
"InvalidPackageReferenceError",
|
||||
"PackageContent",
|
||||
@@ -55,6 +54,7 @@ __all__ = [
|
||||
"RegistryNetworkError",
|
||||
"ValidationError",
|
||||
"VersionNotFoundError",
|
||||
"exception_for_status",
|
||||
"is_concrete_version",
|
||||
"is_version_alias",
|
||||
"resolve_version",
|
||||
|
||||
@@ -12,6 +12,7 @@ and optional API key authentication (§9.2).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -52,6 +53,7 @@ class RegistryClient:
|
||||
self.api_key: Optional[str] = api_key
|
||||
self.timeout: float = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._async_lock: Optional[asyncio.Lock] = None
|
||||
if not allow_insecure and not self.base_url.startswith("https://"):
|
||||
if api_key is not None:
|
||||
logger.warning(
|
||||
@@ -66,17 +68,23 @@ class RegistryClient:
|
||||
"in production (§12.2)."
|
||||
)
|
||||
|
||||
def _get_async_lock(self) -> asyncio.Lock:
|
||||
if self._async_lock is None:
|
||||
self._async_lock = asyncio.Lock()
|
||||
return self._async_lock
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
headers: dict[str, str] = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
)
|
||||
return self._client
|
||||
async with self._get_async_lock():
|
||||
if self._client is None or self._client.is_closed:
|
||||
headers: dict[str, str] = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def _request(self, path: str, params: Optional[dict[str, str]] = None) -> Any:
|
||||
client = await self._get_client()
|
||||
@@ -86,25 +94,50 @@ class RegistryClient:
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
url = (
|
||||
str(exc.request.url)
|
||||
if getattr(exc, "_request", None) is not None
|
||||
else None
|
||||
)
|
||||
msg = f"HTTP {status}"
|
||||
error_type: Optional[str] = None
|
||||
details: Optional[dict[str, Any]] = None
|
||||
try:
|
||||
body = exc.response.json()
|
||||
if isinstance(body, dict):
|
||||
error_block = body.get("error")
|
||||
if isinstance(error_block, dict):
|
||||
error_type = error_block.get("type")
|
||||
if "message" in error_block:
|
||||
msg = error_block["message"]
|
||||
elif "message" in body:
|
||||
new_msg = error_block.get("message")
|
||||
if new_msg is not None:
|
||||
msg = new_msg
|
||||
raw_details = error_block.get("details")
|
||||
if isinstance(raw_details, dict):
|
||||
details = raw_details
|
||||
elif "message" in body and body["message"] is not None:
|
||||
msg = body["message"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if error_type and error_type in _ERROR_TYPE_MAP:
|
||||
raise _ERROR_TYPE_MAP[error_type](msg) from exc
|
||||
raise exception_for_status(status, msg) from exc
|
||||
if isinstance(error_type, str) and error_type in _ERROR_TYPE_MAP:
|
||||
exc_cls = _ERROR_TYPE_MAP[error_type]
|
||||
if exc_cls is RegistryNetworkError:
|
||||
raise RegistryNetworkError(
|
||||
msg, details=details, status_code=status, url=url
|
||||
) from exc
|
||||
raise exc_cls(msg, details=details) from exc
|
||||
if 500 <= status < 600:
|
||||
raise RegistryNetworkError(
|
||||
msg, details=details, status_code=status, url=url
|
||||
) from exc
|
||||
raise exception_for_status(status, msg, details=details) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise RegistryNetworkError(str(exc)) from exc
|
||||
url = None
|
||||
try:
|
||||
if getattr(exc, "_request", None) is not None:
|
||||
url = str(exc.request.url)
|
||||
except RuntimeError:
|
||||
pass
|
||||
raise RegistryNetworkError(str(exc), url=url) from exc
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
@@ -137,7 +170,8 @@ class RegistryClient:
|
||||
InvalidPackageIdError: If the Package ID format is invalid.
|
||||
RegistryNetworkError: For connection errors and timeouts.
|
||||
"""
|
||||
return await self._request(f"/packages/{package_id}")
|
||||
encoded_id = quote(package_id, safe="")
|
||||
return await self._request(f"/packages/{encoded_id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# §8.2.2 GET /{package_type}/{namespace}/{name}?version={version}
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
"""Typed exceptions for the Package Registry client.
|
||||
|
||||
Maps the 8 error types defined in the Package Registry Standard §13.2
|
||||
to Python exception classes, plus a network error type for transport failures.
|
||||
Maps the error types defined in the Package Registry Standard §13.2
|
||||
to Python exception classes. The spec defines 8 standard error types;
|
||||
``InvalidPackageReferenceError`` (project-specific) and
|
||||
``RegistryNetworkError`` (transport/5xx) bring the total to 9 typed
|
||||
subclasses of ``RegistryError``.
|
||||
|
||||
All errors carry a human-readable ``message``, optional ``details``
|
||||
dict, and optional ``original_reference`` string (e.g. the raw
|
||||
package ID or URL that triggered the error). ``RegistryNetworkError``
|
||||
additionally carries ``status_code`` and ``url`` for transport failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from cleveractors.core.exceptions import CleverAgentsException
|
||||
|
||||
|
||||
class RegistryError(CleverAgentsException):
|
||||
"""Base exception for all registry-related errors."""
|
||||
"""Base exception for all registry-related errors.
|
||||
|
||||
Attributes:
|
||||
message: Human-readable description of the error.
|
||||
details: Optional dictionary with supplementary information.
|
||||
original_reference: Optional string that triggered the error
|
||||
(e.g. a malformed package ID, a URL, or a version alias).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
original_reference: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message: str = message
|
||||
self.details: dict[str, Any] | None = (
|
||||
dict(details) if details is not None else None # shallow copy
|
||||
)
|
||||
self.original_reference: str | None = original_reference
|
||||
|
||||
def __str__(self) -> str:
|
||||
base = self.message
|
||||
if self.original_reference is not None:
|
||||
base = f"{base} [ref: {self.original_reference}]"
|
||||
return base
|
||||
|
||||
|
||||
class PackageNotFoundError(RegistryError):
|
||||
@@ -19,6 +58,10 @@ class InvalidPackageIdError(RegistryError):
|
||||
"""The provided Package ID format is invalid (HTTP 400)."""
|
||||
|
||||
|
||||
class InvalidPackageReferenceError(RegistryError):
|
||||
"""The provided package reference string could not be parsed or resolved (HTTP 400)."""
|
||||
|
||||
|
||||
class VersionNotFoundError(RegistryError):
|
||||
"""The requested version does not exist (HTTP 404)."""
|
||||
|
||||
@@ -39,42 +82,91 @@ class ConflictError(RegistryError):
|
||||
"""The request conflicts with existing data (HTTP 409)."""
|
||||
|
||||
|
||||
class InternalServerError(RegistryError):
|
||||
"""An unexpected error occurred server-side (HTTP 500)."""
|
||||
|
||||
|
||||
class RegistryNetworkError(RegistryError):
|
||||
"""Connection errors, timeouts, or other transport-level failures."""
|
||||
"""Connection errors, timeouts, or server-side failures (HTTP 5xx).
|
||||
|
||||
Attributes:
|
||||
status_code: The HTTP status code when originating from a
|
||||
server response. May be ``None`` for pure transport errors
|
||||
(timeout, connection refused, DNS failure, etc.).
|
||||
url: The URL that was being accessed when the error occurred.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
original_reference: str | None = None,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
url: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
message, details=details, original_reference=original_reference
|
||||
)
|
||||
self.status_code: int | None = status_code
|
||||
self.url: str | None = url
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts: list[str] = [super().__str__()]
|
||||
if self.status_code is not None:
|
||||
parts.append(f"[status: {self.status_code}]")
|
||||
if self.url is not None:
|
||||
parts.append(f"[url: {self.url}]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
class InvalidPackageReferenceError(RegistryError):
|
||||
"""The provided package reference string could not be parsed or resolved."""
|
||||
# Maps server-side error-type strings to exception classes per
|
||||
# the Package Registry Standard §13.2, plus the project-specific
|
||||
# InvalidPackageReference type which the server may return.
|
||||
_ERROR_TYPE_MAP: MappingProxyType[str, type[RegistryError]] = MappingProxyType(
|
||||
{
|
||||
"PackageNotFound": PackageNotFoundError,
|
||||
"InvalidPackageId": InvalidPackageIdError,
|
||||
"InvalidPackageReference": InvalidPackageReferenceError,
|
||||
"VersionNotFound": VersionNotFoundError,
|
||||
"ValidationError": ValidationError,
|
||||
"AuthenticationRequired": AuthenticationRequiredError,
|
||||
"AccessDenied": AccessDeniedError,
|
||||
"Conflict": ConflictError,
|
||||
"InternalServerError": RegistryNetworkError,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_ERROR_TYPE_MAP: dict[str, type[RegistryError]] = {
|
||||
"PackageNotFound": PackageNotFoundError,
|
||||
"InvalidPackageId": InvalidPackageIdError,
|
||||
"VersionNotFound": VersionNotFoundError,
|
||||
"ValidationError": ValidationError,
|
||||
"AuthenticationRequired": AuthenticationRequiredError,
|
||||
"AccessDenied": AccessDeniedError,
|
||||
"Conflict": ConflictError,
|
||||
"InternalServerError": InternalServerError,
|
||||
}
|
||||
def exception_for_status(
|
||||
status_code: int, message: str, details: dict[str, Any] | None = None
|
||||
) -> RegistryError:
|
||||
"""Return an appropriate :class:`RegistryError` for an HTTP status code.
|
||||
|
||||
Mapping follows the Package Registry Standard §13.2:
|
||||
* 400 → :class:`InvalidPackageIdError`
|
||||
* 401 → :class:`AuthenticationRequiredError`
|
||||
* 403 → :class:`AccessDeniedError`
|
||||
* 404 → :class:`PackageNotFoundError`
|
||||
* 409 → :class:`ConflictError`
|
||||
* 5xx → :class:`RegistryNetworkError`
|
||||
|
||||
def exception_for_status(status_code: int, message: str) -> RegistryError:
|
||||
"""Return an appropriate RegistryError for an HTTP status code."""
|
||||
All other status codes fall back to a plain :class:`RegistryError`.
|
||||
|
||||
Args:
|
||||
status_code: The HTTP status code.
|
||||
message: Human-readable error message.
|
||||
details: Optional dictionary with supplementary error information
|
||||
from a structured error response body (§13.1).
|
||||
"""
|
||||
if not isinstance(status_code, int):
|
||||
return RegistryError(message)
|
||||
if status_code == 400:
|
||||
return InvalidPackageIdError(message)
|
||||
return InvalidPackageIdError(message, details=details)
|
||||
if status_code == 401:
|
||||
return AuthenticationRequiredError(message)
|
||||
return AuthenticationRequiredError(message, details=details)
|
||||
if status_code == 403:
|
||||
return AccessDeniedError(message)
|
||||
return AccessDeniedError(message, details=details)
|
||||
if status_code == 404:
|
||||
return PackageNotFoundError(message)
|
||||
return PackageNotFoundError(message, details=details)
|
||||
if status_code == 409:
|
||||
return ConflictError(message)
|
||||
return ConflictError(message, details=details)
|
||||
if 500 <= status_code < 600:
|
||||
return InternalServerError(message)
|
||||
return RegistryError(message)
|
||||
return RegistryNetworkError(message, details=details, status_code=status_code)
|
||||
return RegistryError(message, details=details)
|
||||
|
||||
Reference in New Issue
Block a user