change: Implement 2: Add the pluggable credential-free image source registry and HTTPS fetch layer
Repository Validate / repository-checks (pull_request) Successful in 1s
Repository Validate / repository-checks (pull_request) Successful in 1s
Added tools/errors.py (typed error hierarchy: ToolkitError -> SourceError -> UnknownSourceError/DuplicateSourceError/SearchRequestError/SourceAccessError with SourceUnreachableError/SourceHTTPError/SourcePayloadError/HostNotAllowedError, each with a stable code, details payload and to_dict()) and tools/sources.py (credential-free image source layer). tools/sources.py provides: an HTTPSClient restricted to the approved hosts commons.wikimedia.org, upload.wikimedia.org and api.openverse.org over HTTPS/443, sending an identifying User-Agent, with bounded exponential retry/backoff (capped) on 429/5xx and typed SourceUnreachableError/SourceHTTPError/SourcePayloadError instead of empty results; a SourceAdapter protocol; WikimediaCommonsAdapter (MediaWiki API generator=search + imageinfo) and OpenverseAdapter (/v1/images/) that pass the caller topic and result limit and normalize results into SourceCandidate records carrying source_id, file_url, reported mime type, dimensions, page URL and a byte-fetch callable; and a SourceRegistry that reports available source ids, rejects duplicate registration and raises UnknownSourceError listing the available ids. No adapter or registry path reads API keys, tokens, registrations or secrets from arguments, environment or config. Added tests/test_sources.py covering registry lookup, duplicate registration, unknown-source errors listing ids, request-URL construction with topic/limit, 429/5xx retry and exhaustion, non-retryable statuses, unreachable hosts, malformed payloads, host allowlist rejection without network contact, candidate normalization and the credential-free guarantee. Tests: prepare_environment restore of the tools component (locked env); tools.validate `python -m compileall tools tests` succeeded; tools.test `pytest` succeeded (54 passed, incl. 41 in tests/test_sources.py); tools.smoke `python -m tools.example examples/input.json` returned status ok. Progress: Implemented tools/errors.py, tools/sources.py and tests/test_sources.py for the credential-free source registry and HTTPS fetch layer. Verified with the facade commands tools.validate, tools.test (54 passed) and tools.smoke, all green on the integrated branch (target task/1-task was already at HEAD). No protected facade paths touched. Published by developer-agent through Tool Backend. Closes #4
This commit was merged in pull request #12.
This commit is contained in:
+187
@@ -0,0 +1,187 @@
|
||||
"""Typed errors raised deliberately by the toolkit.
|
||||
|
||||
The image-source layer never degrades a failed or unknown source into a silent,
|
||||
empty result. Every such situation raises a :class:`ToolkitError` subclass that
|
||||
carries a stable ``code`` string plus machine-readable ``details`` so the CLI can
|
||||
map it onto an exit status and a structured JSON envelope.
|
||||
|
||||
Hierarchy::
|
||||
|
||||
ToolkitError
|
||||
+-- SourceError
|
||||
+-- UnknownSourceError (unknown source id, lists available ids)
|
||||
+-- DuplicateSourceError (registry already holds that id)
|
||||
+-- SearchRequestError (topic/limit rejected before any request)
|
||||
+-- SourceAccessError (the source could not be used)
|
||||
+-- SourceUnreachableError (transport/DNS/timeout failure)
|
||||
+-- SourceHTTPError (non-retryable or exhausted HTTP status)
|
||||
+-- SourcePayloadError (body is not the expected JSON shape)
|
||||
+-- HostNotAllowedError (URL outside the approved hosts)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
__all__ = [
|
||||
"DuplicateSourceError",
|
||||
"HostNotAllowedError",
|
||||
"SearchRequestError",
|
||||
"SourceAccessError",
|
||||
"SourceError",
|
||||
"SourceHTTPError",
|
||||
"SourcePayloadError",
|
||||
"SourceUnreachableError",
|
||||
"ToolkitError",
|
||||
"UnknownSourceError",
|
||||
]
|
||||
|
||||
|
||||
class ToolkitError(Exception):
|
||||
"""Base class for errors the toolkit raises on purpose."""
|
||||
|
||||
code = "toolkit_error"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
self.details: dict[str, Any] = dict(details or {})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return the JSON-safe envelope fragment describing this error."""
|
||||
|
||||
payload: dict[str, Any] = {"code": self.code, "message": self.message}
|
||||
if self.details:
|
||||
payload["details"] = dict(self.details)
|
||||
return payload
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - trivial
|
||||
return self.message
|
||||
|
||||
|
||||
class SourceError(ToolkitError):
|
||||
"""Base class for every image-source problem."""
|
||||
|
||||
code = "source_error"
|
||||
|
||||
|
||||
class UnknownSourceError(SourceError):
|
||||
"""Raised when the caller asks for a source id the registry does not know."""
|
||||
|
||||
code = "source_unknown"
|
||||
|
||||
def __init__(self, source_id: str, available: Iterable[str]) -> None:
|
||||
self.source_id = str(source_id)
|
||||
self.available: tuple[str, ...] = tuple(sorted(str(item) for item in available))
|
||||
listing = ", ".join(self.available) if self.available else "<none>"
|
||||
super().__init__(
|
||||
f"unknown image source {self.source_id!r}; available sources: {listing}",
|
||||
details={"source_id": self.source_id, "available_sources": list(self.available)},
|
||||
)
|
||||
|
||||
|
||||
class DuplicateSourceError(SourceError):
|
||||
"""Raised when an adapter id is registered twice."""
|
||||
|
||||
code = "source_duplicate"
|
||||
|
||||
def __init__(self, source_id: str) -> None:
|
||||
self.source_id = str(source_id)
|
||||
super().__init__(
|
||||
f"image source {self.source_id!r} is already registered",
|
||||
details={"source_id": self.source_id},
|
||||
)
|
||||
|
||||
|
||||
class SearchRequestError(SourceError):
|
||||
"""Raised when the caller supplies a topic/limit a source cannot honour."""
|
||||
|
||||
code = "search_invalid_request"
|
||||
|
||||
def __init__(self, message: str, *, details: Mapping[str, Any] | None = None) -> None:
|
||||
super().__init__(message, details=details)
|
||||
|
||||
|
||||
class SourceAccessError(SourceError):
|
||||
"""Raised when a known source cannot be reached or refuses to answer."""
|
||||
|
||||
code = "source_access"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
url: str | None = None,
|
||||
source_id: str | None = None,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
merged: dict[str, Any] = dict(details or {})
|
||||
if url is not None:
|
||||
merged["url"] = url
|
||||
if source_id is not None:
|
||||
merged["source_id"] = source_id
|
||||
super().__init__(message, details=merged)
|
||||
self.url = url
|
||||
self.source_id = source_id
|
||||
|
||||
|
||||
class SourceUnreachableError(SourceAccessError):
|
||||
"""Raised when the transport cannot deliver a request to the host at all."""
|
||||
|
||||
code = "source_unreachable"
|
||||
|
||||
def __init__(self, url: str, *, source_id: str | None = None, reason: str | None = None) -> None:
|
||||
message = f"image source host is unreachable: {url}"
|
||||
if reason:
|
||||
message = f"{message} ({reason})"
|
||||
super().__init__(message, url=url, source_id=source_id, details={"reason": reason} if reason else None)
|
||||
|
||||
|
||||
class SourceHTTPError(SourceAccessError):
|
||||
"""Raised for an HTTP status the client cannot recover from."""
|
||||
|
||||
code = "source_http_error"
|
||||
|
||||
def __init__(self, url: str, status: int, *, source_id: str | None = None) -> None:
|
||||
super().__init__(
|
||||
f"image source returned HTTP status {status}: {url}",
|
||||
url=url,
|
||||
source_id=source_id,
|
||||
details={"status": status},
|
||||
)
|
||||
self.status = int(status)
|
||||
|
||||
|
||||
class SourcePayloadError(SourceAccessError):
|
||||
"""Raised when a source answers with something the adapter cannot read."""
|
||||
|
||||
code = "source_invalid_response"
|
||||
|
||||
def __init__(self, url: str, *, source_id: str | None = None, reason: str | None = None) -> None:
|
||||
message = f"image source returned an unusable response: {url}"
|
||||
if reason:
|
||||
message = f"{message} ({reason})"
|
||||
super().__init__(message, url=url, source_id=source_id, details={"reason": reason} if reason else None)
|
||||
|
||||
|
||||
class HostNotAllowedError(SourceAccessError):
|
||||
"""Raised when a URL leaves the approved HTTPS host allowlist."""
|
||||
|
||||
code = "source_host_not_allowed"
|
||||
|
||||
def __init__(self, url: str, *, allowed: Iterable[str] = ()) -> None:
|
||||
self.allowed: tuple[str, ...] = tuple(sorted(str(item) for item in allowed))
|
||||
super().__init__(
|
||||
f"refusing to contact {url!r}: only approved HTTPS hosts are allowed "
|
||||
f"({', '.join(self.allowed) if self.allowed else 'none configured'})",
|
||||
url=str(url),
|
||||
details={"allowed_hosts": list(self.allowed)},
|
||||
)
|
||||
Reference in New Issue
Block a user