Files
cleveractors-core/robot/ResolverLib.py
CoreRasurae 4e12902492
CI / quality (pull_request) Successful in 35s
CI / security (pull_request) Successful in 54s
CI / lint (pull_request) Successful in 58s
CI / build (pull_request) Successful in 42s
CI / typecheck (pull_request) Successful in 59s
CI / integration_tests (pull_request) Successful in 1m14s
CI / unit_tests (pull_request) Successful in 3m48s
CI / coverage (pull_request) Successful in 3m34s
CI / status-check (pull_request) Successful in 3s
CI / quality (push) Successful in 33s
CI / lint (push) Successful in 38s
CI / typecheck (push) Successful in 48s
CI / security (push) Successful in 48s
CI / build (push) Successful in 41s
CI / integration_tests (push) Successful in 1m15s
CI / unit_tests (push) Successful in 3m37s
CI / coverage (push) Successful in 3m34s
CI / status-check (push) Successful in 3s
feat(registry): implement ReferenceResolver with version alias resolution
Introduce ReferenceResolver implementing the Package Registry Standard v1.0.0
§5.3 and §4.2:

ReferenceResolver:
- parse(ref_str) → PackageReference handles all three reference schemes
  (registry, ID, local), wrapping ValueError into InvalidPackageReferenceError
- resolve(ref_str) → PackageId resolves references to concrete IDs:
  ID refs directly, registry refs via RegistryClient, local refs reserved
- Integrates with RegistryClient for remote HTTP resolution

Version resolution (§4.2):
- resolve_version(version, available_versions) resolves concrete (vX.Y.Z)
  and mutable aliases (latest, vx, x, vX.x, vX.Y.x)
- is_concrete_version() / is_version_alias() classification helpers

Integration tests: 35 Robot Framework test cases against fake registry server
with version alias resolution support

Benchmarks: 7 ASV benchmark classes covering parse, resolve, and version
resolution across small/medium/large version lists

ISSUES CLOSED: #26
2026-06-10 12:04:00 +01:00

199 lines
8.1 KiB
Python

"""Robot Framework keyword library for ReferenceResolver integration tests.
Provides keywords for testing the ReferenceResolver against a fake
registry server (started by the test suite).
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from cleveractors.registry.client import RegistryClient
from cleveractors.registry.exceptions import InvalidPackageReferenceError
from cleveractors.registry.resolver import (
ReferenceResolver,
is_concrete_version,
is_version_alias,
resolve_version,
)
from cleveractors.registry.types import PackageId, PackageReference, ReferenceType
ROBOT_LIBRARY_SCOPE = "TEST SUITE" # pragma: no cover - integration test library
class ResolverLib: # pragma: no cover - integration test library
"""Keyword library for ReferenceResolver Robot Framework integration tests."""
def __init__(self) -> None:
self._resolver: ReferenceResolver | None = None
self._client: RegistryClient | None = None
self._parsed_ref: PackageReference | None = None
self._resolved_id: PackageId | None = None
self._result: str | bool | None = None
self._error: Exception | None = None
# ── setup / teardown ────────────────────────────────────────────────
def create_resolver_without_client(self) -> None:
self._resolver = ReferenceResolver()
def create_resolver_with_client(self, base_url: str) -> None:
self._client = RegistryClient(base_url=base_url)
self._resolver = ReferenceResolver(client=self._client)
def close_resolver_client(self) -> None:
async def _close() -> None:
if self._client is not None:
await self._client.close()
self._run(_close)
# ── parse ───────────────────────────────────────────────────────────
def parse_reference(self, ref_str: str) -> None:
self._error = None
try:
self._parsed_ref = ReferenceResolver.parse(ref_str)
except InvalidPackageReferenceError as exc:
self._error = exc
def parsed_reference_type_should_be(self, expected: str) -> None:
if self._parsed_ref is None:
raise AssertionError("No parsed reference available")
actual = self._parsed_ref.reference_type.value
if actual != expected:
raise AssertionError(
f"Expected reference_type {expected!r}, got {actual!r}"
)
def parsed_server_should_be(self, expected: str) -> None:
actual = self._parsed_ref.server
if actual != expected:
raise AssertionError(f"Expected server {expected!r}, got {actual!r}")
def parsed_namespace_should_be(self, expected: str) -> None:
actual = self._parsed_ref.namespace
if actual != expected:
raise AssertionError(f"Expected namespace {expected!r}, got {actual!r}")
def parsed_name_should_be(self, expected: str) -> None:
actual = self._parsed_ref.name
if actual != expected:
raise AssertionError(f"Expected name {expected!r}, got {actual!r}")
def parsed_version_should_be(self, expected: str) -> None:
actual = self._parsed_ref.version
if actual != expected:
raise AssertionError(f"Expected version {expected!r}, got {actual!r}")
def parsed_id_string_should_be(self, expected: str) -> None:
actual = self._parsed_ref.id_string
if actual != expected:
raise AssertionError(f"Expected id_string {expected!r}, got {actual!r}")
def parsed_original_reference_should_be(self, expected: str) -> None:
actual = self._parsed_ref.original_reference
if actual != expected:
raise AssertionError(
f"Expected original_reference {expected!r}, got {actual!r}"
)
def parsed_server_should_be_none(self) -> None:
if self._parsed_ref.server is not None:
raise AssertionError(
f"Expected server None, got {self._parsed_ref.server!r}"
)
def parsed_namespace_should_be_none(self) -> None:
if self._parsed_ref.namespace is not None:
raise AssertionError(
f"Expected namespace None, got {self._parsed_ref.namespace!r}"
)
def parse_should_have_raised(self, phrase: str) -> None:
if self._error is None:
raise AssertionError("Expected error but none was raised")
if phrase not in str(self._error):
raise AssertionError(
f"Expected error containing {phrase!r}, got {self._error!r}"
)
# ── resolve ─────────────────────────────────────────────────────────
def resolve_reference(self, ref_str: str) -> None:
self._error = None
async def _resolve() -> None:
try:
self._resolved_id = await self._resolver.resolve(ref_str)
except InvalidPackageReferenceError as exc:
self._error = exc
self._run(_resolve)
def resolved_package_id_should_be(self, expected: str) -> None:
if self._resolved_id is None:
raise AssertionError("No resolved package ID available")
actual = self._resolved_id.id_string
if actual != expected:
raise AssertionError(f"Expected resolved id {expected!r}, got {actual!r}")
def resolve_should_have_raised(self, phrase: str) -> None:
if self._error is None:
raise AssertionError("Expected error but none was raised")
if phrase not in str(self._error):
raise AssertionError(
f"Expected error containing {phrase!r}, got {self._error!r}"
)
# ── version resolution ──────────────────────────────────────────────
def resolve_version_against(self, version: str, versions_csv: str) -> None:
self._error = None
available = (
[v.strip() for v in versions_csv.split(",") if v.strip()]
if versions_csv.strip()
else []
)
try:
self._result = resolve_version(version, available)
except InvalidPackageReferenceError as exc:
self._error = exc
def resolved_version_should_be(self, expected: str) -> None:
if self._result != expected:
raise AssertionError(
f"Expected resolved version {expected!r}, got {self._result!r}"
)
def resolve_version_should_have_raised(self, phrase: str) -> None:
if self._error is None:
raise AssertionError("Expected error but none was raised")
if phrase not in str(self._error):
raise AssertionError(
f"Expected error containing {phrase!r}, got {self._error!r}"
)
# ── version classification ──────────────────────────────────────────
def check_is_concrete_version(self, version: str) -> None:
self._result = is_concrete_version(version)
def check_is_version_alias(self, version: str) -> None:
self._result = is_version_alias(version)
def classification_result_should_be_true(self) -> None:
if self._result is not True:
raise AssertionError(f"Expected True, got {self._result!r}")
def classification_result_should_be_false(self) -> None:
if self._result is not False:
raise AssertionError(f"Expected False, got {self._result!r}")
# ── async runner ────────────────────────────────────────────────────
@staticmethod
def _run(coro_fn: Callable[[], Awaitable[None]], timeout: float = 30.0) -> None:
asyncio.run(asyncio.wait_for(coro_fn(), timeout=timeout))