diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 0000000..ced3476
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,3 @@
+{
+ "contextFileName": "AGENTS.md"
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c3d8f02..b9fc0ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -208,4 +208,5 @@ __marimo__/
private_key*.pem
-devel/
\ No newline at end of file
+devel/
+data/
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7a5448b..0e3d6ae 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,9 +14,3 @@ repos:
language: system
types_or: [python, pyi]
require_serial: true
-
- - id: pyrefly-check
- name: Pyrefly (type checking)
- entry: pyrefly check
- language: system
- pass_filenames: false
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c9a1ea4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,194 @@
+# Agent Guidelines for apkit
+
+This file provides guidelines for AI agents working on the apkit codebase.
+
+## Project Overview
+
+apkit is a modern, fast toolkit for building ActivityPub-based applications with Python. It uses FastAPI for the server, supports async HTTP clients, and handles ActivityPub models, HTTP signatures, and Fediverse protocols.
+
+## Build, Test, and Lint Commands
+
+### Package Manager
+This project uses `uv` as the package manager.
+
+### Running Tests
+```bash
+# Run all tests
+uv run pytest
+
+# Run tests with coverage
+uv run pytest --cov=src/apkit
+
+# Run a single test
+uv run pytest tests/path/to/test_file.py::test_function_name
+
+# Run tests for a specific module
+uv run pytest tests/client/
+```
+
+### Linting and Formatting
+```bash
+# Check all files with ruff
+uv run ruff check .
+
+# Check and auto-fix issues
+uv run ruff check --fix .
+
+# Format all files
+uv run ruff format .
+
+# Type checking with pyrefly
+uv run pyrefly check .
+```
+
+### Pre-commit Hooks
+```bash
+# Install pre-commit hooks
+pre-commit install
+
+# Run all hooks manually
+pre-commit run --all-files
+```
+
+## Code Style Guidelines
+
+### Imports
+1. **Standard library** imports first (e.g., `import json`, `from typing import ...`)
+2. **Third-party** imports second (e.g., `import apmodel`, `from fastapi import ...`)
+3. **Local/apkit** imports last (e.g., `from ..types import ActorKey`)
+4. Use **absolute imports** for external dependencies, **relative imports** for internal modules
+5. Sort imports with `collections.abc` before `typing`
+
+Example:
+```python
+import json
+import re
+from collections.abc import Iterable, Mapping
+from typing import Any, Dict, List, Optional, TypeVar
+
+import aiohttp
+import httpx
+from apmodel.types import ActivityPubModel
+
+from ..types import ActorKey
+from .models import Resource
+```
+
+### Formatting
+- **Line length**: 88 characters (Black-compatible)
+- **Indent**: 4 spaces
+- **Quotes**: Double quotes for strings
+- Follow **ruff** configuration in `pyproject.toml`
+
+### Type Hints
+- **Always use type hints** for function parameters and return types
+- Use `from typing import ...` imports at the top
+- Use `ParamSpec` and `TypeVar` for generic types
+- For Python 3.10+, use `X | Y` syntax instead of `Optional` or `Union` where appropriate
+
+Example:
+```python
+from typing import Optional, TypeVar
+
+T = TypeVar("T")
+
+def fetch(url: str, headers: Optional[dict] = None) -> dict | None:
+ ...
+```
+
+### Naming Conventions
+- **Classes**: `PascalCase` (e.g., `ActivityPubClient`, `WebfingerResult`)
+- **Functions/Methods**: `snake_case` (e.g., `fetch_actor`, `build_webfinger_url`)
+- **Constants**: `SCREAMING_SNAKE_CASE`
+- **Private methods/vars**: Prefix with underscore (e.g., `__fetch_actor`, `_client`)
+- **Type variables**: Single uppercase letter (e.g., `T`, `P`, `R`)
+
+### Data Classes
+- Use `@dataclass(frozen=True)` for immutable models
+- Use regular `@dataclass` for mutable response wrappers
+- Document classes and methods with docstrings
+
+Example:
+```python
+@dataclass(frozen=True)
+class Link:
+ """Represents a link in a WebFinger response."""
+ rel: str
+ type: str | None
+ href: str | None
+```
+
+### Error Handling
+- Use **specific exceptions** (e.g., `ValueError`, `TypeError`)
+- Raise with descriptive messages
+- Use custom exceptions in `exceptions.py` for domain-specific errors
+- Use `match` statements for pattern matching (Python 3.11+)
+
+Example:
+```python
+match headers:
+ case Mapping() as m:
+ items = m.items()
+ case None:
+ items = []
+ case _:
+ raise TypeError(f"Unsupported header type: {type(headers)}")
+```
+
+### Testing
+- Use **pytest** for testing
+- Write **descriptive test names** (e.g., `test_build_webfinger_url`)
+- Use pytest classes for grouping related tests (e.g., `class TestResource:`)
+- Mock external dependencies when appropriate
+
+### Project Structure
+```
+src/apkit/
+├── __init__.py # Package exports
+├── _version.py # Version info (auto-generated)
+├── abc/ # Abstract base classes
+├── cache.py # Caching utilities
+├── client/ # HTTP client implementation
+│ ├── __init__.py
+│ ├── base/ # Base context managers
+│ ├── client.py # Main ActivityPubClient
+│ ├── exceptions.py # Client exceptions
+│ ├── models.py # Data models
+│ └── types.py # Type definitions
+├── config.py # Configuration
+├── helper/ # Helper utilities
+├── kv/ # Key-value store implementations
+├── models/ # ActivityPub model exports
+├── nodeinfo/ # NodeInfo implementation
+├── server/ # FastAPI server components
+│ ├── app.py # ActivityPubServer
+│ ├── routes/ # Route handlers
+│ ├── responses.py # Response classes
+│ └── types.py # Server types
+└── types.py # Common types
+```
+
+## Important Notes
+
+- Python **3.11+** is required
+- **Type hints are mandatory** for all new code
+- Follow the **KISS principle** - Keep It Simple, Stupid
+- **Conventional Commits** for commit messages (e.g., `feat:`, `fix:`, `docs:`)
+- The codebase is **not stable** - API changes may break backward compatibility
+
+## Dependencies
+
+Key external dependencies:
+- `apmodel>=0.5.1` - ActivityPub models
+- `apsig>=0.6.0` - HTTP signatures
+- `fastapi>=0.116.1` - Web framework (optional, server extra)
+- `aiohttp>=3.13.3` - Async HTTP client
+- `httpx>=0.28.1` - Sync HTTP client
+
+## Before Submitting
+
+1. Run `uv run ruff check --fix .` to auto-fix linting issues
+2. Run `uv run ruff format .` to format code
+3. Run `uv run pyrefly check .` to verify type hints
+4. Run `uv run pytest` to ensure all tests pass
+5. Ensure imports are organized correctly
diff --git a/FEDERATION.md b/FEDERATION.md
index 1c195af..bf56637 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -4,9 +4,10 @@
- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
- [WebFinger](https://webfinger.net/)
-- [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
+- [draft-cavage-http-signatures-12](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
- [Linked Data Signatures 1.0](https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/)
- [NodeInfo](https://nodeinfo.diaspora.software/)
+- [RFC 9421: HTTP Message Signatures](https://datatracker.ietf.org/doc/html/rfc9421)
## Supported FEPs
diff --git a/examples/send_message.py b/examples/send_message.py
index fc405a3..5f55d1d 100644
--- a/examples/send_message.py
+++ b/examples/send_message.py
@@ -117,10 +117,9 @@ async def send_note(recepient: str) -> None:
cc=["https://www.w3.org/ns/activitystreams#Public"],
tag=[
Mention(
- href=target_actor.url,
- name=f"@{target_actor.preferred_username}"
+ href=target_actor.url, name=f"@{target_actor.preferred_username}"
)
- ]
+ ],
)
# Create activity
diff --git a/pyproject.toml b/pyproject.toml
index c3d86ba..bb4b42c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,10 @@ server = [
"fastapi>=0.116.1",
"uvicorn>=0.35.0",
]
+speed = [
+ "google-re2>=1.1.20251105",
+ "lxml>=6.0.2"
+]
[tool.uv]
default-groups = "all"
@@ -51,13 +55,16 @@ version-file = "src/apkit/_version.py"
[dependency-groups]
dev = [
+ "aioresponses>=0.7.8",
"coverage>=7.10.7",
"pytest>=8.4.1",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
+ "respx>=0.22.0",
"pyrefly>=0.46.0",
"ruff>=0.14.10",
- "prek>=0.3.3"
+ "prek>=0.3.3",
+ "pre-commit>=4.5.1",
]
docs = [
"mkdocs>=1.6.1",
@@ -113,12 +120,17 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.pyrefly]
project-includes = [
- "src/**/*.py*",
- "tests/**/*.py*",
+ "src/**/*.py",
+ "tests/**/*.py",
]
project-excludes = [
- "scripts/**/*.py"
+ "scripts/**/*.py",
+ "**/.*",
+ "**/*venv/**",
]
+search-path = ["src"]
+ignore-errors-in-generated-code = true
+
[tool.ruff.format]
quote-style = "double"
diff --git a/src/apkit/_version.py b/src/apkit/_version.py
index f3c1bc9..6fe97bb 100644
--- a/src/apkit/_version.py
+++ b/src/apkit/_version.py
@@ -28,7 +28,7 @@
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
-__version__ = version = '0.3.8.post1.dev10+g892881c3c'
-__version_tuple__ = version_tuple = (0, 3, 8, 'post1', 'dev10', 'g892881c3c')
+__version__ = version = '0.3.8.post1.dev52+gd0f42cdc1'
+__version_tuple__ = version_tuple = (0, 3, 8, 'post1', 'dev52', 'gd0f42cdc1')
__commit_id__ = commit_id = None
diff --git a/src/apkit/client/__init__.py b/src/apkit/client/__init__.py
index d15e502..870ad04 100644
--- a/src/apkit/client/__init__.py
+++ b/src/apkit/client/__init__.py
@@ -1,5 +1,11 @@
-from .models import WebfingerResult
-from .models import Resource as WebfingerResource
+from .client import ActivityPubClient
from .models import Link as WebfingerLink
+from .models import Resource as WebfingerResource
+from .models import WebfingerResult
-__all__ = ["WebfingerResult", "WebfingerResource", "WebfingerLink"]
+__all__ = [
+ "ActivityPubClient",
+ "WebfingerResult",
+ "WebfingerResource",
+ "WebfingerLink",
+]
diff --git a/src/apkit/client/_common.py b/src/apkit/client/_common.py
index 88cd344..0e2f99e 100644
--- a/src/apkit/client/_common.py
+++ b/src/apkit/client/_common.py
@@ -2,14 +2,15 @@
import json
import urllib.parse
import warnings
-from collections.abc import Mapping
+from collections.abc import Iterable, Mapping
from typing import (
Any,
+ Callable,
Dict,
- Iterable,
List,
Optional,
Tuple,
+ TypeVar,
Union,
)
@@ -18,18 +19,25 @@
from apmodel.types import ActivityPubModel
from apsig import draft
from apsig.rfc9421 import RFC9421Signer
-from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
+from typing_extensions import ParamSpec
+
+from apkit.types import ActorKey
-from ..types import ActorKey
from .models import Resource, WebfingerResult
+P = ParamSpec("P")
+R = TypeVar("R")
+
def reconstruct_headers(
headers: Any,
user_agent: str,
json: Optional[dict | ActivityPubModel | Any] = None,
) -> Dict[str, str]:
- processed_headers: Dict[str, Any] = {}
+ final_headers: Dict[str, str] = {}
+ key_map: Dict[str, str] = {}
match headers:
case Mapping() as m:
@@ -42,43 +50,57 @@ def reconstruct_headers(
raise TypeError(f"Unsupported header type: {type(headers)}")
for k, v in items:
- key_str = str(k)
- key_lower = key_str.lower()
+ k_str = str(k)
+ k_lower = k_str.lower()
+ if k_lower not in key_map:
+ key_map[k_lower] = k_str
+ final_headers[k_str] = str(v)
+
+ if "user-agent" not in key_map:
+ final_headers["User-Agent"] = user_agent
+ key_map["user-agent"] = "User-Agent"
+
+ if json and "content-type" not in key_map:
+ match json:
+ case ActivityPubModel():
+ final_headers["Content-Type"] = (
+ "application/activity+json; charset=UTF-8"
+ )
+ case dict():
+ final_headers["Content-Type"] = "application/json"
- if key_lower not in processed_headers:
- processed_headers[key_lower] = v
- processed_headers[f"{key_lower}_original_key"] = key_str
+ return final_headers
- if "user-agent" not in processed_headers:
- processed_headers["user-agent"] = user_agent
- processed_headers["user-agent_original_key"] = "User-Agent"
- if json:
- if isinstance(json, ActivityPubModel):
- if "content-type" not in processed_headers:
- processed_headers["content-type"] = (
- "application/activity+json; charset=UTF-8"
- )
- processed_headers["content-type_original_key"] = "Content-Type"
- elif isinstance(json, dict):
- if "content-type" not in processed_headers:
- processed_headers["content-type"] = "application/json"
- processed_headers["content-type_original_key"] = "Content-Type"
+def build_webfinger_url(host: str, resource: Resource) -> str:
+ """Builds a WebFinger URL."""
+ return f"https://{host}/.well-known/webfinger?resource={resource}"
- final_headers: Dict[str, str] = {}
- for key_lower, value in processed_headers.items():
- if key_lower.endswith("_original_key"):
- continue
- original_key = processed_headers.get(f"{key_lower}_original_key")
+def validate_webfinger_result(
+ result: WebfingerResult, expected_subject: Resource
+) -> None:
+ """Validates the subject in a WebfingerResult."""
+ if result.subject != expected_subject:
+ raise ValueError(
+ f"Mismatched subject in response. Expected {expected_subject}, got {result.subject}"
+ )
+
- if original_key:
- final_headers[original_key] = str(value)
- else:
- standard_key = key_lower.replace("-", " ").title().replace(" ", "-")
- final_headers[standard_key] = str(value)
+def _is_expected_content_type(actual_ctype: str, expected_ctype_prefix: str) -> bool:
+ mime_type = actual_ctype.split(";")[0].strip().lower()
- return final_headers
+ if mime_type == "application/json":
+ return True
+ if mime_type.endswith("+json"):
+ return True
+
+ if expected_ctype_prefix and mime_type.startswith(
+ expected_ctype_prefix.split(";")[0].lower()
+ ):
+ return True
+
+ return False
def sign_request(
@@ -92,7 +114,7 @@ def sign_request(
# "fep8b32",
],
as_dict: bool = False,
-) -> Tuple[Optional[Union[bytes, dict]], dict]:
+) -> Tuple[Optional[bytes | dict], dict]:
if isinstance(body, ActivityPubModel):
body = apmodel.to_dict(body)
@@ -102,7 +124,7 @@ def sign_request(
signed_rfc9421 = False
for signature in signatures:
- if isinstance(signature.private_key, rsa.RSAPrivateKey):
+ if isinstance(signature.private_key, RSAPrivateKey):
if "rfc9421" in sign_with and not signed_rfc9421:
if "draft-cavage" in sign_with:
warnings.warn(
@@ -155,7 +177,7 @@ def sign_request(
private_key=signature.private_key,
)
signed_rsa2017 = True
- elif isinstance(signature.private_key, ed25519.Ed25519PrivateKey):
+ elif isinstance(signature.private_key, Ed25519PrivateKey):
if "fep8b32" in sign_with and body and not signed_fep8b32:
now = (
datetime.datetime.now().isoformat(sep="T", timespec="seconds") + "Z"
@@ -180,32 +202,8 @@ def sign_request(
return body, headers
-def build_webfinger_url(host: str, resource: Resource) -> str:
- """Builds a WebFinger URL."""
- return f"https://{host}/.well-known/webfinger?resource={resource}"
-
-
-def validate_webfinger_result(
- result: WebfingerResult, expected_subject: Resource
-) -> None:
- """Validates the subject in a WebfingerResult."""
- if result.subject != expected_subject:
- raise ValueError(
- f"Mismatched subject in response. Expected {expected_subject}, got {result.subject}"
- )
+def delegate_target(func: Callable[P, R]) -> Callable[P, R]:
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ return func(*args, **kwargs)
-
-def _is_expected_content_type(actual_ctype: str, expected_ctype_prefix: str) -> bool:
- mime_type = actual_ctype.split(";")[0].strip().lower()
-
- if mime_type == "application/json":
- return True
- if mime_type.endswith("+json"):
- return True
-
- if expected_ctype_prefix and mime_type.startswith(
- expected_ctype_prefix.split(";")[0].lower()
- ):
- return True
-
- return False
+ return wrapper
diff --git a/src/apkit/client/asyncio/__init__.py b/src/apkit/client/asyncio/__init__.py
deleted file mode 100644
index 7035c00..0000000
--- a/src/apkit/client/asyncio/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .client import ActivityPubClient
-
-__all__ = ["ActivityPubClient"]
diff --git a/src/apkit/client/asyncio/actor.py b/src/apkit/client/asyncio/actor.py
deleted file mode 100644
index 0396110..0000000
--- a/src/apkit/client/asyncio/actor.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from typing import TYPE_CHECKING
-
-from apmodel.types import ActivityPubModel
-
-from .. import _common, models
-
-if TYPE_CHECKING:
- from .client import ActivityPubClient
-
-
-class ActorFetcher:
- def __init__(self, client: "ActivityPubClient"):
- self.__client: "ActivityPubClient" = client
-
- async def resolve(
- self,
- username: str,
- host: str,
- headers: dict = {"Accept": "application/jrd+json"},
- ) -> models.WebfingerResult:
- """Resolves an actor's profile from a remote server asynchronously."""
- headers = _common.reconstruct_headers(headers, self.__client.user_agent)
- resource = models.Resource(username=username, host=host)
- url = _common.build_webfinger_url(host=host, resource=resource)
-
- async with self.__client.get(url, headers=headers) as resp:
- if resp.ok:
- data = await resp.json()
- result = models.WebfingerResult.from_dict(data)
- _common.validate_webfinger_result(result, resource)
- return result
- else:
- raise ValueError(f"Failed to resolve Actor: {url}")
-
- async def fetch(
- self, url: str, headers: dict = {"Accept": "application/activity+json"}
- ) -> ActivityPubModel | dict | list | str | None:
- headers = _common.reconstruct_headers(
- headers if headers else {}, self.__client.user_agent
- )
- async with self.__client.get(url, headers=headers) as resp:
- if resp.ok:
- data = await resp.parse()
- return data
- else:
- raise ValueError(f"Failed to resolve Actor: {url}")
diff --git a/src/apkit/client/asyncio/client.py b/src/apkit/client/asyncio/client.py
deleted file mode 100644
index 621126f..0000000
--- a/src/apkit/client/asyncio/client.py
+++ /dev/null
@@ -1,311 +0,0 @@
-import asyncio
-import json
-import warnings
-from ssl import SSLContext
-from typing import (
- Any,
- Awaitable,
- Callable,
- Iterable,
- List,
- Mapping,
- Optional,
- Sequence,
- Type,
- Union,
-)
-
-import aiohttp
-from aiohttp.abc import AbstractCookieJar
-from aiohttp.client import _CharsetResolver
-from aiohttp.helpers import _SENTINEL, sentinel
-from aiohttp.http_writer import (
- HttpVersion as HttpVersion,
-)
-from aiohttp.http_writer import (
- HttpVersion10 as HttpVersion10,
-)
-from aiohttp.http_writer import (
- HttpVersion11 as HttpVersion11,
-)
-from aiohttp.http_writer import (
- StreamWriter as StreamWriter,
-)
-from aiohttp.typedefs import JSONEncoder, LooseCookies, LooseHeaders, StrOrURL
-from apmodel.types import ActivityPubModel
-from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
-from yarl import URL, Query
-
-from ..._version import __version__
-from ...types import ActorKey
-from .._common import reconstruct_headers, sign_request
-from .actor import ActorFetcher
-from .types import ActivityPubClientResponse, _RequestContextManager
-
-
-class ActivityPubClient(aiohttp.ClientSession):
- def __init__(
- self,
- base_url: Optional[StrOrURL] = None,
- *,
- connector: Optional[aiohttp.BaseConnector] = None,
- loop: Optional[asyncio.AbstractEventLoop] = None,
- cookies: Optional[LooseCookies] = None,
- headers: Optional[LooseHeaders] = None,
- proxy: Optional[StrOrURL] = None,
- proxy_auth: Optional[aiohttp.BasicAuth] = None,
- skip_auto_headers: Optional[Iterable[str]] = None,
- auth: Optional[aiohttp.BasicAuth] = None,
- json_serialize: JSONEncoder = json.dumps,
- request_class: Type[aiohttp.ClientRequest] = aiohttp.ClientRequest,
- response_class: Type[aiohttp.ClientResponse] = ActivityPubClientResponse,
- ws_response_class: Type[
- aiohttp.ClientWebSocketResponse
- ] = aiohttp.ClientWebSocketResponse,
- version: HttpVersion = aiohttp.http.HttpVersion11,
- cookie_jar: Optional[AbstractCookieJar] = None,
- connector_owner: bool = True,
- raise_for_status: Union[
- bool, Callable[[aiohttp.ClientResponse], Awaitable[None]]
- ] = False,
- read_timeout: Union[float, _SENTINEL] = sentinel,
- conn_timeout: Optional[float] = None,
- timeout: Union[object, aiohttp.ClientTimeout] = sentinel,
- auto_decompress: bool = True,
- trust_env: bool = False,
- requote_redirect_url: bool = True,
- trace_configs: Optional[List[aiohttp.TraceConfig]] = None,
- read_bufsize: int = 2**16,
- max_line_size: int = 8190,
- max_field_size: int = 8190,
- fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
- middlewares: Sequence[aiohttp.ClientMiddlewareType] = (),
- ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel,
- user_agent: str = f"apkit/{__version__}",
- ) -> None:
- self.user_agent = user_agent
- self.actor: ActorFetcher = ActorFetcher(self)
- super().__init__(
- base_url,
- connector=connector,
- loop=loop,
- cookies=cookies,
- headers=headers,
- proxy=proxy,
- proxy_auth=proxy_auth,
- skip_auto_headers=skip_auto_headers,
- auth=auth,
- json_serialize=json_serialize,
- request_class=request_class,
- response_class=response_class,
- ws_response_class=ws_response_class,
- version=version,
- cookie_jar=cookie_jar,
- connector_owner=connector_owner,
- raise_for_status=raise_for_status,
- read_timeout=read_timeout,
- conn_timeout=conn_timeout,
- timeout=timeout,
- auto_decompress=auto_decompress,
- trust_env=trust_env,
- requote_redirect_url=requote_redirect_url,
- trace_configs=trace_configs,
- read_bufsize=read_bufsize,
- max_line_size=max_line_size,
- max_field_size=max_field_size,
- fallback_charset_resolver=fallback_charset_resolver,
- middlewares=middlewares,
- ssl_shutdown_timeout=ssl_shutdown_timeout,
- )
-
- async def __aenter__(self) -> "ActivityPubClient":
- return self
-
- async def _request(
- self,
- method: str,
- str_or_url: StrOrURL,
- *,
- params: Query = None,
- data: Any = None,
- json: Any = None,
- cookies: Optional[LooseCookies] = None,
- headers: Optional[LooseHeaders] = None,
- skip_auto_headers: Optional[Iterable[str]] = None,
- auth: Optional[aiohttp.BasicAuth] = None,
- allow_redirects: bool = True,
- max_redirects: int = 10,
- compress: Union[str, bool, None] = None,
- chunked: Optional[bool] = None,
- expect100: bool = False,
- raise_for_status: Union[
- None, bool, Callable[[aiohttp.ClientResponse], Awaitable[None]]
- ] = None,
- read_until_eof: bool = True,
- proxy: Optional[StrOrURL] = None,
- proxy_auth: Optional[aiohttp.BasicAuth] = None,
- timeout: Union[aiohttp.ClientTimeout, _SENTINEL] = sentinel,
- verify_ssl: Optional[bool] = None,
- fingerprint: Optional[bytes] = None,
- ssl_context: Optional[SSLContext] = None,
- ssl: Union[SSLContext, bool, aiohttp.Fingerprint] = True,
- server_hostname: Optional[str] = None,
- proxy_headers: Optional[LooseHeaders] = None,
- trace_request_ctx: Optional[Mapping[str, Any]] = None,
- read_bufsize: Optional[int] = None,
- auto_decompress: Optional[bool] = None,
- max_line_size: Optional[int] = None,
- max_field_size: Optional[int] = None,
- middlewares: Optional[Sequence[aiohttp.ClientMiddlewareType]] = None,
- signatures: List[ActorKey] = [],
- sign_with: List[str] = [
- "draft-cavage",
- "rsa2017",
- "fep8b32",
- ],
- ) -> ActivityPubClientResponse:
- headers = reconstruct_headers(headers if headers else {}, self.user_agent, json)
- if signatures != [] and sign_with:
- j, headers = await asyncio.to_thread(
- sign_request,
- str(str_or_url),
- headers=headers,
- signatures=signatures,
- body=json,
- sign_with=sign_with,
- as_dict=True,
- )
- if j and not isinstance(j, bytes):
- json = j
-
- # pyrefly: ignore
- return await super()._request(
- method,
- str_or_url,
- params=params,
- data=data,
- json=json,
- cookies=cookies,
- headers=headers,
- skip_auto_headers=skip_auto_headers,
- auth=auth,
- allow_redirects=allow_redirects,
- max_redirects=max_redirects,
- compress=compress,
- chunked=chunked,
- expect100=expect100,
- raise_for_status=raise_for_status,
- read_until_eof=read_until_eof,
- proxy=proxy,
- proxy_auth=proxy_auth,
- timeout=timeout,
- verify_ssl=verify_ssl,
- fingerprint=fingerprint,
- ssl_context=ssl_context,
- ssl=ssl,
- server_hostname=server_hostname,
- proxy_headers=proxy_headers,
- trace_request_ctx=trace_request_ctx,
- read_bufsize=read_bufsize,
- auto_decompress=auto_decompress,
- max_line_size=max_line_size,
- max_field_size=max_field_size,
- middlewares=middlewares,
- )
-
- def get( # pyrefly: ignore[bad-override]
- self,
- url: str | URL,
- *,
- allow_redirects: bool = True,
- headers: Optional[LooseHeaders] = None,
- signatures: List[ActorKey] = [],
- sign_with: Optional[List[str]] = None,
- # deprecated
- key_id: Optional[str] = None,
- signature: Optional[Union[rsa.RSAPrivateKey, ed25519.Ed25519PrivateKey]] = None,
- **kwargs: Any,
- ) -> _RequestContextManager:
- if key_id or signature:
- warnings.warn(
- "key_id and signature are deprecated. Use signatures and sign_with instead.",
- DeprecationWarning,
- stacklevel=2,
- )
-
- if not signatures and signature and key_id:
- signatures = [ActorKey(key_id=key_id, private_key=signature)]
-
- final_sign_with: Optional[List[str]] = sign_with
- if final_sign_with is None:
- if signatures:
- final_sign_with = ["draft-cavage"]
- else:
- final_sign_with = []
- final_sign_with.extend(["draft-cavage"])
-
- return _RequestContextManager(
- self._request(
- aiohttp.hdrs.METH_GET,
- url,
- allow_redirects=allow_redirects,
- headers=headers,
- signatures=signatures,
- sign_with=final_sign_with,
- **kwargs,
- )
- )
-
- def post( # pyrefly: ignore
- self,
- url: str | URL,
- *,
- json: Union[dict, ActivityPubModel] = {},
- headers: Optional[LooseHeaders] = None,
- signatures: List[ActorKey] = [],
- sign_with: Optional[List[str]] = [
- "draft-cavage",
- "rsa2017",
- "fep8b32",
- ], # TODO: "draft-cavage", "rsa2017", "fep8b32"
- # deprecated
- key_id: Optional[str] = None,
- signature: Optional[Union[rsa.RSAPrivateKey, ed25519.Ed25519PrivateKey]] = None,
- sign_http: bool = True,
- sign_ld: bool = False,
- **kwargs: Any,
- ) -> _RequestContextManager:
- if key_id or signature:
- warnings.warn(
- "key_id and signature are deprecated. Use signatures and sign_with instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- if not (key_id and signature):
- raise ValueError("key_id and signature must be provided together")
-
- if not signatures and signature and key_id:
- signatures = [ActorKey(key_id=key_id, private_key=signature)]
-
- final_sign_with: Optional[List[str]] = sign_with
- if final_sign_with is None:
- if signatures:
- final_sign_with = ["draft-cavage", "rsa2017", "fep8b32"]
- else:
- final_sign_with = []
- if sign_http:
- final_sign_with.extend(["draft-cavage", "fep8b32"])
- if sign_ld:
- final_sign_with.append("rsa2017")
-
- return _RequestContextManager(
- self._request(
- aiohttp.hdrs.METH_POST,
- url,
- json=json,
- headers=headers,
- signatures=signatures,
- sign_with=final_sign_with,
- **kwargs,
- )
- )
diff --git a/src/apkit/client/asyncio/types.py b/src/apkit/client/asyncio/types.py
deleted file mode 100644
index f09668c..0000000
--- a/src/apkit/client/asyncio/types.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import asyncio
-from typing import Any, Coroutine, Generator, Optional, TypeVar
-
-import apmodel
-from aiohttp.client import ClientResponse as _ClientResponse
-from aiohttp.client import ClientWebSocketResponse
-from aiohttp.client import (
- _BaseRequestContextManager as _BaseRequestContextManagerOriginal,
-)
-from aiohttp.typedefs import DEFAULT_JSON_DECODER, JSONDecoder
-from apmodel.types import ActivityPubModel
-
-
-class ActivityPubClientResponse(_ClientResponse):
- async def parse(
- self,
- *,
- encoding: Optional[str] = None,
- loads: JSONDecoder = DEFAULT_JSON_DECODER,
- content_type: Optional[str] = "application/json",
- ) -> Optional[dict | str | list | ActivityPubModel]:
- """Read the response body as an ActivityPub model."""
- json = await self.json(
- encoding=encoding, loads=loads, content_type=content_type
- )
- return apmodel.load(json)
-
-
-_RetType = TypeVar("_RetType", ActivityPubClientResponse, ClientWebSocketResponse)
-
-
-class _BaseRequestContextManager(_BaseRequestContextManagerOriginal[_RetType]):
- def __init__(self, coro: Coroutine[asyncio.Future[Any], None, _RetType]) -> None:
- super().__init__(coro)
-
- def __await__(self) -> Generator[Any, None, _RetType]:
- return super().__await__()
-
- def __iter__(self) -> Generator[Any, None, _RetType]:
- return super().__iter__()
-
- async def __aenter__(self) -> _RetType:
- return await super().__aenter__()
-
-
-_RequestContextManager = _BaseRequestContextManager[ActivityPubClientResponse]
diff --git a/src/apkit/client/base/__init__.py b/src/apkit/client/base/__init__.py
new file mode 100644
index 0000000..b0fc0f5
--- /dev/null
+++ b/src/apkit/client/base/__init__.py
@@ -0,0 +1,3 @@
+from .context import BaseReqContextManagerDef, BaseReqContextManagerImpl, SignMethod
+
+__all__ = ["BaseReqContextManagerDef", "BaseReqContextManagerImpl", "SignMethod"]
diff --git a/src/apkit/client/base/context.py b/src/apkit/client/base/context.py
new file mode 100644
index 0000000..188a476
--- /dev/null
+++ b/src/apkit/client/base/context.py
@@ -0,0 +1,204 @@
+import datetime
+import json
+import urllib.parse
+import warnings
+from typing import (
+ Any,
+ Dict,
+ List,
+ Literal,
+ Optional,
+ Protocol,
+ Set,
+ Tuple,
+ TypeVar,
+ Union,
+ cast,
+ get_args,
+)
+
+import aiohttp
+import apmodel
+import apsig
+import httpx
+from apmodel.types import ActivityPubModel
+from apsig import draft
+from apsig.rfc9421 import RFC9421Signer
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
+
+from ..._version import __version__
+from ...types import ActorKey
+from .._common import reconstruct_headers
+from ..types import UnifiedResponse, UnifiedResponseAsync
+
+T = TypeVar("T", httpx.Response, aiohttp.ClientResponse, covariant=True)
+
+SignMethod = Literal["draft-cavage", "rfc9421", "rsa2017", "fep8b32"]
+
+SIGN_METHOD_OPTIONS = get_args(SignMethod)
+ALLOWED_SIGN_METHODS = frozenset(SIGN_METHOD_OPTIONS)
+MAX_ALLOWED = len(SIGN_METHOD_OPTIONS)
+EXCLUSIVE_SET = {"draft-9421": frozenset({"rfc9421", "draft-cavage"})}
+
+
+class BaseReqContextManagerDef(Protocol[T]):
+ async def __aenter__(self) -> UnifiedResponseAsync: ...
+
+ async def __aexit__(self, *args: Any) -> None: ...
+
+ def __enter__(self) -> UnifiedResponse: ...
+
+ def __exit__(self, *args: Any) -> None: ...
+
+
+class BaseReqContextManagerImpl:
+ def __init__(
+ self,
+ client: Optional[httpx.Client],
+ client_async: Optional[aiohttp.ClientSession],
+ url: str,
+ user_agent: str = f"apkit/{__version__}",
+ headers: Optional[Dict[str, str]] = None,
+ allow_redirect: bool = True,
+ max_redirects: int = 10,
+ sign_as: Optional[List[ActorKey]] = None,
+ sign_with: Optional[List[SignMethod]] = None,
+ **kwargs: Any,
+ ):
+ self._client = client
+ self._client_async = client_async
+ self._url = url
+ self._user_agent = user_agent
+ self._headers = headers
+ self._allow_redirect = allow_redirect
+ self._max_redirects = max_redirects
+ self._sign_as = set(sign_as) if sign_as else set()
+
+ self._used: bool = False
+ self._resp: Optional[Union[httpx.Response, aiohttp.ClientResponse]] = None
+ self._body: Optional[Union[ActivityPubModel, Dict[str, Any], bytes]] = None
+ self._kwargs = kwargs
+ self.__validate_sign_with(sign_with=sign_with)
+
+ def __validate_sign_with(self, sign_with: Optional[List[SignMethod]]):
+ raw_input = (
+ sign_with if sign_with is not None else [cast(SignMethod, "draft-cavage")]
+ )
+ filtered_set: Set[SignMethod] = set(raw_input) & cast(
+ Set[SignMethod], ALLOWED_SIGN_METHODS
+ )
+
+ conflict_draft = EXCLUSIVE_SET["draft-9421"]
+ if conflict_draft.issubset(filtered_set):
+ filtered_set -= conflict_draft
+ warnings.warn(
+ "RFC9421 and draft-cavage are exclusive. Both were removed for safety.",
+ UserWarning,
+ )
+
+ self._sign_with: Set[SignMethod] = filtered_set
+
+ def _reconstruct_headers(
+ self,
+ body: Optional[Union[ActivityPubModel, Dict[str, Any], bytes]] = None,
+ ) -> Dict[str, str]:
+ return reconstruct_headers(self._headers, self._user_agent, body)
+
+ def _sign_request(
+ self, headers: Dict[str, str], as_dict: bool = False
+ ) -> Tuple[Optional[Union[bytes, Dict[str, Any]]], Dict[str, str]]:
+ if not self._sign_as:
+ return None, headers
+
+ sign_with = self._sign_with
+ url = self._url
+
+ parsed_url = urllib.parse.urlparse(url)
+ hostname = parsed_url.hostname or ""
+ path = parsed_url.path or "/"
+
+ done = {m: False for m in ["cavage", "rsa2017", "fep8b32", "rfc9421"]}
+
+ is_rfc = "rfc9421" in sign_with
+ is_cavage = "draft-cavage" in sign_with
+ if is_rfc and is_cavage:
+ warnings.warn(
+ "Draft and RFC9421 Signing is exclusive. Legacy Draft mode prioritized.",
+ UserWarning,
+ )
+ is_rfc = False
+
+ if isinstance(self._body, ActivityPubModel):
+ body_dict = apmodel.to_dict(self._body)
+ elif isinstance(self._body, bytes):
+ body_dict = json.loads(self._body)
+ else:
+ body_dict = self._body or {}
+
+ if isinstance(self._body, bytes):
+ body_bytes = self._body
+ else:
+ body_bytes = json.dumps(body_dict, ensure_ascii=False).encode("utf-8")
+
+ for actor in self._sign_as:
+ priv_key = actor.private_key
+ key_id = actor.key_id
+ if isinstance(priv_key, RSAPrivateKey):
+ if is_rfc and not done["rfc9421"]:
+ rfc_signer = RFC9421Signer(priv_key, key_id)
+ headers = rfc_signer.sign(
+ headers=dict(headers),
+ method="POST",
+ host=hostname,
+ path=path,
+ body=body_bytes,
+ )
+ done["rfc9421"] = True
+
+ if is_cavage and not done["cavage"]:
+ signer = draft.Signer(
+ headers=dict(headers),
+ method="POST",
+ url=url,
+ key_id=key_id,
+ private_key=priv_key,
+ body=body_bytes,
+ )
+ headers = signer.sign()
+ done["cavage"] = True
+
+ if "rsa2017" in sign_with and body_dict and not done["rsa2017"]:
+ ld_signer = apsig.LDSignature()
+ body_dict = ld_signer.sign(
+ doc=body_dict,
+ creator=key_id,
+ private_key=priv_key,
+ )
+ done["rsa2017"] = True
+
+ elif isinstance(priv_key, Ed25519PrivateKey):
+ if "fep8b32" in sign_with and body_dict and not done["fep8b32"]:
+ now = (
+ datetime.datetime.now(datetime.timezone.utc)
+ .isoformat(timespec="seconds")
+ .replace("+00:00", "Z")
+ )
+ fep_signer = apsig.ProofSigner(private_key=priv_key)
+ body_dict = fep_signer.sign(
+ unsecured_document=body_dict,
+ options={
+ "type": "DataIntegrityProof",
+ "cryptosuite": "eddsa-jcs-2022",
+ "proofPurpose": "assertionMethod",
+ "verificationMethod": key_id,
+ "created": now,
+ },
+ )
+ done["fep8b32"] = True
+
+ final_body = body_dict
+ if not as_dict and isinstance(body_dict, dict):
+ final_body = json.dumps(body_dict, ensure_ascii=False).encode("utf-8")
+
+ return final_body, headers
diff --git a/src/apkit/client/client.py b/src/apkit/client/client.py
new file mode 100644
index 0000000..d100197
--- /dev/null
+++ b/src/apkit/client/client.py
@@ -0,0 +1,81 @@
+from typing import Any, Dict, List, Optional
+
+import aiohttp
+import httpx
+from apmodel.types import ActivityPubModel
+
+from .._version import __version__
+from ..types import ActorKey
+from .base.context import SignMethod
+from .methods.get import GetReqContextManager
+from .methods.post import PostReqContextManager
+
+
+class ActivityPubClient:
+ def __init__(self, user_agent: str = f"apkit/{__version__}"):
+ self.__user_agent = user_agent
+
+ self.__aiohttp: Optional[aiohttp.ClientSession] = None
+ self.__httpx: Optional[httpx.Client] = None
+
+ def get(
+ self,
+ url: str,
+ headers: Optional[Dict[str, str]] = None,
+ allow_redirect: bool = True,
+ max_redirects: int = 10,
+ sign_as: Optional[List[ActorKey]] = None,
+ sign_with: Optional[List[SignMethod]] = None,
+ ) -> GetReqContextManager:
+ return GetReqContextManager(
+ self.__httpx,
+ self.__aiohttp,
+ url,
+ self.__user_agent,
+ headers,
+ allow_redirect,
+ max_redirects,
+ sign_as,
+ sign_with,
+ )
+
+ def post(
+ self,
+ url: str,
+ headers: Optional[Dict[str, str]] = None,
+ json: Optional[ActivityPubModel | dict[str, Any]] = None,
+ allow_redirect: bool = True,
+ max_redirects: int = 10,
+ sign_as: Optional[List[ActorKey]] = None,
+ sign_with: Optional[List[SignMethod]] = None,
+ ) -> PostReqContextManager:
+ return PostReqContextManager(
+ self.__httpx,
+ self.__aiohttp,
+ url,
+ self.__user_agent,
+ headers,
+ json,
+ allow_redirect,
+ max_redirects,
+ sign_as,
+ sign_with,
+ )
+
+ async def __aenter__(self):
+ if self.__aiohttp is None or self.__aiohttp.closed:
+ self.__aiohttp = aiohttp.ClientSession()
+ return self
+
+ async def __aexit__(self, *args):
+ if self.__aiohttp and not self.__aiohttp.closed:
+ await self.__aiohttp.close()
+
+ def __enter__(self):
+ if not self.__httpx:
+ self.__httpx = httpx.Client()
+ return self
+
+ def __exit__(self, *args):
+ if self.__httpx:
+ self.__httpx.close()
diff --git a/src/apkit/client/methods/get.py b/src/apkit/client/methods/get.py
new file mode 100644
index 0000000..0baed44
--- /dev/null
+++ b/src/apkit/client/methods/get.py
@@ -0,0 +1,73 @@
+import asyncio
+from typing import Any
+
+import aiohttp
+
+from ..base.context import (
+ BaseReqContextManagerDef,
+ BaseReqContextManagerImpl,
+)
+from ..types import T, UnifiedResponse, UnifiedResponseAsync
+
+
+class GetReqContextManager(BaseReqContextManagerImpl, BaseReqContextManagerDef[T]):
+ async def __aenter__(self) -> UnifiedResponseAsync:
+ if self._used:
+ raise RuntimeError(
+ f"{self.__class__.__name__} instance cannot be reused. "
+ "Each request requires a new context manager instance."
+ )
+ if not self._client_async or self._client_async.closed:
+ raise RuntimeError(
+ "The async client session is not initialized or has been closed. "
+ "Ensure you are using 'async with ActivityPubClient()'."
+ )
+ headers = self._reconstruct_headers(self._body)
+ _, headers = await asyncio.to_thread(
+ self._sign_request, headers=headers, as_dict=True
+ )
+ self._resp = await self._client_async.get(
+ self._url,
+ headers=headers,
+ allow_redirects=self._allow_redirect,
+ max_redirects=self._max_redirects,
+ **self._kwargs,
+ )
+ return UnifiedResponseAsync(self._resp)
+
+ async def __aexit__(self, *args: Any) -> None:
+ if (
+ self._resp
+ and isinstance(self._resp, aiohttp.ClientResponse)
+ and not self._resp.closed
+ ):
+ self._resp.close()
+ self._resp = None
+ self._used = True
+
+ def __enter__(self) -> UnifiedResponse:
+ if self._used:
+ raise RuntimeError(
+ f"{self.__class__.__name__} instance cannot be reused. "
+ "Each request requires a new context manager instance."
+ )
+ if not self._client:
+ raise RuntimeError(
+ "The client session is not initialized or has been closed. "
+ "Ensure you are using 'with ActivityPubClient()'."
+ )
+ headers = self._reconstruct_headers(self._body)
+ _, headers = self._sign_request(headers=headers, as_dict=False)
+ self._resp = self._client.get(
+ self._url,
+ headers=headers,
+ follow_redirects=self._allow_redirect,
+ **self._kwargs,
+ )
+ return UnifiedResponse(self._resp)
+
+ def __exit__(self, *args: Any) -> None:
+ if self._resp:
+ self._resp.close()
+ self._resp = None
+ self._used = True
diff --git a/src/apkit/client/methods/post.py b/src/apkit/client/methods/post.py
new file mode 100644
index 0000000..4c434cd
--- /dev/null
+++ b/src/apkit/client/methods/post.py
@@ -0,0 +1,119 @@
+import asyncio
+from typing import Any, Dict, List, Optional
+
+import aiohttp
+import httpx
+from apmodel.types import ActivityPubModel
+
+from ..._version import __version__
+from ...types import ActorKey
+from ..base.context import (
+ BaseReqContextManagerDef,
+ BaseReqContextManagerImpl,
+ SignMethod,
+)
+from ..types import T, UnifiedResponse, UnifiedResponseAsync
+
+
+class PostReqContextManager(BaseReqContextManagerImpl, BaseReqContextManagerDef[T]):
+ def __init__(
+ self,
+ client: Optional[httpx.Client],
+ client_async: Optional[aiohttp.ClientSession],
+ url: str,
+ user_agent: str = f"apkit/{__version__}",
+ headers: Optional[Dict[str, str]] = None,
+ json: Optional[ActivityPubModel | Dict[str, Any]] = None,
+ allow_redirect: bool = True,
+ max_redirects: int = 10,
+ sign_as: Optional[List[ActorKey]] = None,
+ sign_with: Optional[List[SignMethod]] = None,
+ **kwargs: Any,
+ ):
+ super().__init__(
+ client,
+ client_async,
+ url,
+ user_agent,
+ headers,
+ allow_redirect,
+ max_redirects,
+ sign_as,
+ sign_with,
+ **kwargs,
+ )
+ self._body = json
+
+ async def __aenter__(self) -> UnifiedResponseAsync:
+ if self._used:
+ raise RuntimeError(
+ f"{self.__class__.__name__} instance cannot be reused. "
+ "Each request requires a new context manager instance."
+ )
+ if not self._client_async or self._client_async.closed:
+ raise RuntimeError(
+ "The async client session is not initialized or has been closed. "
+ "Ensure you are using 'async with ActivityPubClient()'."
+ )
+ args: Dict[str, Any] = {}
+ headers = self._reconstruct_headers(self._body)
+ body, headers = await asyncio.to_thread(
+ self._sign_request, headers=headers, as_dict=True
+ )
+ if body:
+ if isinstance(body, dict):
+ args["json"] = body
+ else:
+ args["data"] = body
+ self._resp = await self._client_async.post(
+ self._url,
+ headers=headers,
+ allow_redirects=self._allow_redirect,
+ **self._kwargs | args,
+ )
+ return UnifiedResponseAsync(self._resp)
+
+ async def __aexit__(self, *args: Any) -> None:
+ if (
+ self._resp
+ and isinstance(self._resp, aiohttp.ClientResponse)
+ and not self._resp.closed
+ ):
+ self._resp.close()
+ self._resp = None
+ self._used = True
+
+ def __enter__(self) -> UnifiedResponse:
+ if self._used:
+ raise RuntimeError(
+ f"{self.__class__.__name__} instance cannot be reused. "
+ "Each request requires a new context manager instance."
+ )
+ if not self._client:
+ raise RuntimeError(
+ "The client session is not initialized or has been closed. "
+ "Ensure you are using 'with ActivityPubClient()'."
+ )
+ args: Dict[str, Any] = {}
+ headers = self._reconstruct_headers(self._body)
+ body, headers = self._sign_request(
+ headers=headers, as_dict=not isinstance(self._body, bytes)
+ )
+ if body:
+ if isinstance(body, dict):
+ args["json"] = body
+ else:
+ args["data"] = body
+ self._resp = self._client.post(
+ self._url,
+ headers=headers,
+ follow_redirects=self._allow_redirect,
+ **self._kwargs | args,
+ )
+ return UnifiedResponse(self._resp)
+
+ def __exit__(self, *args: Any) -> None:
+ if self._resp:
+ self._resp.close()
+ self._resp = None
+ self._used = True
diff --git a/src/apkit/client/models.py b/src/apkit/client/models.py
index 294c9f6..547386b 100644
--- a/src/apkit/client/models.py
+++ b/src/apkit/client/models.py
@@ -1,101 +1,239 @@
-import re
-from dataclasses import dataclass
-from typing import Any, Dict, List, Optional, Union
+import re as standard_re
+from collections import defaultdict
+from typing import (
+ Any,
+ Dict,
+ List,
+ Optional,
+ ParamSpec,
+ TypeVar,
+ Union,
+)
+
+try:
+ import re2 as re
+except ImportError:
+ re = standard_re
+
+try:
+ import lxml.etree as lxml_etree
+
+ HAS_LXML = True
+except ImportError:
+ lxml_etree = None
+ HAS_LXML = False
+
+from xml.etree import ElementTree as std_etree # noqa: N813
+
+P = ParamSpec("P")
+R = TypeVar("R")
+
+_ACCT_RE = re.compile(r"^([^@]+)@([^@]+)$")
-@dataclass(frozen=True)
class Resource:
"""Represents a WebFinger resource."""
- username: str
- host: str
- url: Optional[str] = None
+ __slots__ = ("_username", "_host", "_url")
+
+ def __init__(self, username: str, host: str, url: Optional[str] = None):
+ self._username = username
+ self._host = host
+ self._url = url
def __str__(self) -> str:
- return f"acct:{self.username}@{self.host}"
+ return f"acct:{self._username}@{self._host}"
+
+ @property
+ def username(self) -> str:
+ return self._username
+
+ @property
+ def host(self) -> str:
+ return self._host
+
+ @property
+ def url(self) -> Optional[str]:
+ return self._url
@classmethod
def parse(cls, resource_str: str) -> "Resource":
- """Parses a resource string (e.g., 'acct:user@example.com')."""
+ orig = resource_str
if resource_str.startswith("acct:"):
resource_str = resource_str[5:]
- match = re.match(r"^([^@]+)@([^@]+)$", resource_str)
+ if "@" in resource_str:
+ parts = resource_str.split("@")
+ if len(parts) == 2:
+ return cls(username=parts[0], host=parts[1], url=None)
+
+ match = _ACCT_RE.match(resource_str)
if not match:
- return cls(username="", host="", url=resource_str)
- # raise ValueError(f"Invalid resource format: {resource_str}")
+ return cls(username="", host="", url=orig)
+
+ u, h = match.groups()
+ if isinstance(u, str) and isinstance(h, str):
+ return cls(username=u, host=h, url=None)
- username, host = match.groups()
- return cls(username=username, host=host, url=None)
+ return cls(username=str(u or ""), host=str(h or ""), url=None)
def export(self) -> str:
- return f"acct:{self.username}@{self.host}"
+ return f"acct:{self._username}@{self._host}"
-@dataclass(frozen=True)
class Link:
"""Represents a link in a WebFinger response."""
- rel: str
- type: str | None
- href: str | None
+ __slots__ = ("_rel", "_type", "_href")
+
+ def __init__(self, rel: str, type: Optional[str], href: Optional[str]):
+ self._rel = rel
+ self._type = type
+ self._href = href
+
+ @property
+ def rel(self) -> str:
+ return self._rel
+
+ @property
+ def type(self) -> Optional[str]:
+ return self._type
+
+ @property
+ def href(self) -> Optional[str]:
+ return self._href
def to_json(self) -> dict:
- return {"rel": self.rel, "type": self.type, "href": self.href}
+ return {"rel": self._rel, "type": self._type, "href": self._href}
-@dataclass(frozen=True)
class WebfingerResult:
- """Represents a parsed WebFinger response."""
+ __slots__ = ("_subject", "_links", "_type_map")
+
+ def __init__(
+ self,
+ subject: Resource,
+ links: List[Link],
+ ):
+ self._subject = subject
+ self._links = links
+
+ type_map = defaultdict(list)
+ for item in links:
+ l_type = item.type
+ if l_type:
+ type_map[l_type].append(item)
+
+ self._type_map: Dict[str, List[Link]] = dict(type_map)
+
+ @property
+ def subject(self) -> Resource:
+ return self._subject
- subject: Resource
- links: List[Link]
+ @property
+ def links(self) -> List[Link]:
+ return self._links
def to_json(self) -> dict:
- links = []
- for link in self.links:
- links.append(link.to_json())
+ return {
+ "subject": self._subject.export(),
+ "links": [link.to_json() for link in self._links],
+ }
+
+ def to_xml(self, encoding: str = "UTF-8") -> bytes:
+ ns = "http://docs.oasis-open.org/ns/xri/xrd-1.0"
+
+ if HAS_LXML and lxml_etree is not None:
+ root = lxml_etree.Element(f"{{{ns}}}XRD", nsmap={None: ns})
+ subject = lxml_etree.SubElement(root, f"{{{ns}}}Subject")
+ subject.text = self._subject.export()
+
+ for link in self._links:
+ l_elem = lxml_etree.SubElement(root, f"{{{ns}}}Link")
+ if link.rel:
+ l_elem.set("rel", link.rel)
+ if link.type:
+ l_elem.set("type", link.type)
+ if link.href:
+ l_elem.set("href", link.href)
+
+ return lxml_etree.tostring(
+ root, encoding=encoding, xml_declaration=True, pretty_print=True
+ )
+ else:
+ std_etree.register_namespace("", ns)
+ root = std_etree.Element(f"{{{ns}}}XRD")
+
+ subject = std_etree.SubElement(root, f"{{{ns}}}Subject")
+ subject.text = self._subject.export()
+
+ for link in self._links:
+ l_elem = std_etree.SubElement(root, f"{{{ns}}}Link")
+ if link.rel:
+ l_elem.set("rel", link.rel)
+ if link.type:
+ l_elem.set("type", link.type)
+ if link.href:
+ l_elem.set("href", link.href)
- return {"subject": self.subject.export(), "links": links}
+ return std_etree.tostring(root, encoding=encoding, xml_declaration=True)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "WebfingerResult":
- """Parses a dictionary into a WebfingerResult object."""
subject_str = data.get("subject")
if not subject_str:
raise ValueError("Missing 'subject' in WebFinger response")
- subject = Resource.parse(subject_str)
-
links_data = data.get("links", [])
- links = [
- Link(
- rel=link_data.get("rel"),
- type=link_data.get("type"),
- href=link_data.get("href"),
+ links = []
+
+ append_link = links.append
+
+ for item in links_data:
+ link = Link(
+ rel=str(item.get("rel", "")),
+ type=item.get("type"),
+ href=item.get("href"),
)
- for link_data in links_data
- ]
+ append_link(link)
- return cls(subject=subject, links=links)
+ return cls(Resource.parse(subject_str), links)
- def get(self, link_type: str) -> Union[Link, List[Link], None]:
- """
- Gets links by their 'type' attribute.
+ @classmethod
+ def from_xml(cls, xml_data: Union[str, bytes]) -> "WebfingerResult":
+ """Parses an XRD (XML) string/bytes into a WebfingerResult."""
+ if isinstance(xml_data, str):
+ xml_data = xml_data.encode("utf-8")
- Args:
- link_type: The 'type' of the link to find.
+ ns = {"xrd": "http://docs.oasis-open.org/ns/xri/xrd-1.0"}
- Returns:
- A single Link object if exactly one is found.
- A list of Link objects if multiple are found.
- None if no matching links are found.
- """
- found_links = [link for link in self.links if link.type == link_type]
+ if HAS_LXML and lxml_etree is not None:
+ parser = lxml_etree.XMLParser(recover=True, no_network=True)
+ root = lxml_etree.fromstring(xml_data, parser=parser)
- if not found_links:
- return None
- elif len(found_links) == 1:
- return found_links[0]
+ subject_nodes = root.xpath("xrd:Subject/text()", namespaces=ns)
+ subject_str = str(subject_nodes[0]) if subject_nodes else ""
+ link_nodes = root.xpath("xrd:Link", namespaces=ns)
else:
- return found_links
+ root = std_etree.fromstring(xml_data)
+ subject_node = root.find("xrd:Subject", ns)
+ subject_str = (subject_node.text if subject_node is not None else "") or ""
+ link_nodes = root.findall("xrd:Link", ns)
+
+ if not subject_str:
+ raise ValueError("Missing 'Subject' in XRD response")
+
+ links: List[Link] = []
+ for node in link_nodes:
+ rel = node.get("rel", "")
+ l_type = node.get("type")
+ href = node.get("href") or node.get("template")
+ links.append(Link(rel=rel, type=l_type, href=href))
+
+ return cls(Resource.parse(subject_str), links)
+
+ def get(self, link_type: str) -> Union[Link, List[Link], None]:
+ found = self._type_map.get(link_type)
+ if found is None:
+ return None
+ return found[0] if len(found) == 1 else found
diff --git a/src/apkit/client/sync/__init__.py b/src/apkit/client/sync/__init__.py
deleted file mode 100644
index 7035c00..0000000
--- a/src/apkit/client/sync/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .client import ActivityPubClient
-
-__all__ = ["ActivityPubClient"]
diff --git a/src/apkit/client/sync/actor.py b/src/apkit/client/sync/actor.py
deleted file mode 100644
index c800f22..0000000
--- a/src/apkit/client/sync/actor.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from typing import TYPE_CHECKING
-
-from apmodel.types import ActivityPubModel
-
-from .. import _common, models
-
-if TYPE_CHECKING:
- from .client import ActivityPubClient
-
-
-class ActorFetcher:
- def __init__(self, client: "ActivityPubClient"):
- self.__client: "ActivityPubClient" = client
-
- def resolve(
- self,
- username: str,
- host: str,
- headers: dict = {"Accept": "application/jrd+json"},
- ) -> models.WebfingerResult:
- """Resolves an actor's profile from a remote server."""
- headers = _common.reconstruct_headers(headers, self.__client.user_agent)
- resource = models.Resource(username=username, host=host)
- url = _common.build_webfinger_url(host=host, resource=resource)
-
- resp = self.__client.get(url, headers=headers)
- if resp.ok:
- data = resp.json()
- result = models.WebfingerResult.from_dict(data)
- _common.validate_webfinger_result(result, resource)
- return result
- else:
- raise ValueError(f"Failed to resolve Actor: {url}")
-
- def fetch(
- self, url: str, headers: dict = {"Accept": "application/activity+json"}
- ) -> ActivityPubModel | dict | list | str | None:
- headers = _common.reconstruct_headers(headers, self.__client.user_agent)
- resp = self.__client.get(
- url,
- headers=headers,
- )
- if resp.ok:
- data = resp.parse()
- return data
- else:
- raise ValueError(f"Failed to resolve Actor: {url}")
diff --git a/src/apkit/client/sync/client.py b/src/apkit/client/sync/client.py
deleted file mode 100644
index c8ee954..0000000
--- a/src/apkit/client/sync/client.py
+++ /dev/null
@@ -1,153 +0,0 @@
-import json
-import typing
-
-import apmodel
-import httpcore
-from apmodel.types import ActivityPubModel
-from typing_extensions import Optional
-
-from ..._version import __version__
-from ...types import ActorKey
-from .._common import reconstruct_headers, sign_request
-from .actor import ActorFetcher
-from .exceptions import TooManyRedirectsError
-from .types import Response
-
-
-class ActivityPubClient:
- def __init__(self, user_agent: str = f"apkit/{__version__}") -> None:
- self.user_agent = user_agent
- self.actor: ActorFetcher = ActorFetcher(self)
-
- self.__http: Optional[httpcore.ConnectionPool] = None
-
- def __enter__(self) -> "ActivityPubClient":
- self.__http = httpcore.ConnectionPool()
- return self
-
- def __exit__(self, *args) -> None:
- if self.__http:
- self.__http.close()
-
- def __transform_to_bytes(
- self, content: bytes | str | dict | ActivityPubModel
- ) -> bytes:
- match content:
- case bytes():
- return content
- case str():
- return content.encode("utf-8")
- case dict():
- return json.dumps(content, ensure_ascii=False).encode("utf-8")
- case ActivityPubModel() as model:
- return json.dumps(apmodel.to_dict(model), ensure_ascii=False).encode(
- "utf-8"
- )
- case _:
- raise TypeError(f"Unsupported type: {type(content)}")
-
- def request(
- self,
- method: str,
- url: httpcore.URL | str,
- headers: dict = {},
- content: str | dict | ActivityPubModel | bytes | None = None,
- allow_redirect: bool = True,
- max_redirects: int = 5,
- signatures: typing.List[ActorKey] = [],
- sign_with: typing.List[str] = ["draft-cavage", "rsa2017", "fep8b32"],
- ) -> Response:
- if not self.__http:
- raise NotImplementedError
- headers = reconstruct_headers(headers, self.user_agent, content)
- if content is not None:
- content = self.__transform_to_bytes(content)
- if signatures != []:
- content, headers = sign_request(
- url=bytes(url).decode("ascii")
- if isinstance(url, httpcore.URL)
- else url,
- headers=headers,
- signatures=signatures,
- body=content,
- sign_with=sign_with,
- as_dict=False,
- )
- if not isinstance(content, bytes):
- raise ValueError
- response = self.__http.request(
- method=method.upper(),
- url=url,
- headers=[
- (k.encode("utf-8"), v.encode("utf-8")) for k, v in headers.items()
- ],
- content=content,
- )
- if allow_redirect:
- if response.status in [301, 307, 308]:
- for _ in range(max_redirects):
- location = (
- {
- key.decode("utf-8"): value.decode("utf-8")
- for key, value in response.headers
- }
- ).get("Location")
- if not location:
- break
- response = self.__http.request(
- method=method.upper(),
- url=location,
- headers=[
- (k.encode("utf-8"), v.encode("utf-8"))
- for k, v in headers.items()
- ],
- content=content,
- )
- if response.status not in [301, 307, 308]:
- return Response(response)
- raise TooManyRedirectsError
- return Response(response)
-
- def post(
- self,
- url: httpcore.URL | str,
- headers: dict = {},
- body: dict | str | bytes | None = None,
- allow_redirect: bool = True,
- max_redirects: int = 5,
- signatures: typing.List[ActorKey] = [],
- sign_with: typing.List[str] = ["draft-cavage", "rsa2017", "fep8b32"],
- ) -> Response:
- if body is not None:
- body = self.__transform_to_bytes(body)
- resp = self.request(
- "POST",
- url=url,
- headers=headers,
- content=body,
- allow_redirect=allow_redirect,
- max_redirects=max_redirects,
- signatures=signatures,
- sign_with=sign_with,
- )
- return resp
-
- def get(
- self,
- url: httpcore.URL | str,
- headers: dict = {},
- allow_redirect: bool = True,
- max_redirects: int = 5,
- signatures: typing.List[ActorKey] = [],
- sign_with: typing.List[str] = ["draft-cavage", "rsa2017", "fep8b32"],
- ) -> Response:
- resp = self.request(
- "GET",
- url=url,
- headers=headers,
- allow_redirect=allow_redirect,
- max_redirects=max_redirects,
- signatures=signatures,
- sign_with=sign_with,
- )
- return resp
diff --git a/src/apkit/client/sync/exceptions.py b/src/apkit/client/sync/exceptions.py
deleted file mode 100644
index a7b5882..0000000
--- a/src/apkit/client/sync/exceptions.py
+++ /dev/null
@@ -1,13 +0,0 @@
-class ContentTypeError(Exception):
- def __init__(self, message: str, status: int, headers: dict):
- super().__init__(message)
- self.status = status
- self.headers = headers
-
-
-class TooManyRedirectsError(Exception):
- pass
-
-
-class NotImplementedWarning(Warning):
- pass
diff --git a/src/apkit/client/sync/types.py b/src/apkit/client/sync/types.py
deleted file mode 100644
index 16d89f3..0000000
--- a/src/apkit/client/sync/types.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import json
-from email.message import Message
-from typing import Any, Callable
-
-import apmodel
-import charset_normalizer as chardet
-import httpcore
-from apmodel.types import ActivityPubModel
-from typing_extensions import Optional
-
-from .._common import _is_expected_content_type
-from .exceptions import ContentTypeError
-
-JSONDecoder = Callable[[str], Any]
-DEFAULT_JSON_DECODER = json.loads
-
-
-class Response:
- def __init__(self, response: httpcore.Response) -> None:
- self.__response = response
-
- def _get_encoding_from_header(self) -> Optional[str]:
- ctype_value = self.headers.get("Content-Type")
-
- if not ctype_value:
- return None
-
- try:
- msg = Message()
- msg["Content-Type"] = ctype_value
-
- charset = msg.get_param("charset")
-
- if isinstance(charset, str):
- return charset.lower()
-
- return None
-
- except Exception:
- return None
-
- @property
- def ok(self) -> bool:
- return self.status >= 200 and self.status <= 299
-
- @property
- def status(self) -> int:
- return self.__response.status
-
- @property
- def body(self) -> bytes:
- return self.__response.content
-
- @property
- def headers(self) -> dict[str, str]:
- return {
- key.decode("utf-8"): value.decode("utf-8")
- for key, value in self.__response.headers
- }
-
- def json(
- self,
- *,
- encoding: Optional[str] = None,
- loads: Callable[[str], Any] = DEFAULT_JSON_DECODER,
- content_type: Optional[str] = "application/json",
- ) -> Any:
- """Read and decodes JSON response."""
- if content_type:
- ctype = self.headers.get("Content-Type", "").lower()
-
- if not _is_expected_content_type(ctype, content_type):
- raise ContentTypeError(
- message=(
- "Attempt to decode JSON with unexpected mimetype: %s" % ctype
- ),
- status=self.status,
- headers=self.headers,
- )
-
- if self.body is None:
- return None
-
- stripped = self.body.strip()
- if not stripped:
- return None
-
- if encoding is None:
- header_encoding = self._get_encoding_from_header()
-
- if header_encoding:
- encoding = header_encoding
- else:
- best_match = chardet.from_bytes(stripped).best()
- if best_match:
- encoding = best_match.encoding
- else:
- encoding = "utf-8"
-
- return loads(stripped.decode(encoding))
-
- def parse(
- self,
- *,
- encoding: Optional[str] = None,
- loads: JSONDecoder = DEFAULT_JSON_DECODER,
- ) -> Optional[dict | str | list | ActivityPubModel]:
- json = self.json(encoding=encoding, loads=loads)
- return apmodel.load(json)
diff --git a/src/apkit/client/types.py b/src/apkit/client/types.py
new file mode 100644
index 0000000..2e5f57e
--- /dev/null
+++ b/src/apkit/client/types.py
@@ -0,0 +1,97 @@
+from typing import Any, Awaitable, Optional, Protocol, TypeVar
+
+import aiohttp
+import apmodel
+import httpx
+from apmodel.types import ActivityPubModel
+
+T = TypeVar("T", httpx.Response, aiohttp.ClientResponse, covariant=True)
+RT = TypeVar("RT", dict[str, Any], Awaitable[dict[str, Any]])
+PT = TypeVar("PT", ActivityPubModel, Awaitable[ActivityPubModel])
+
+
+class Response(Protocol[T, RT, PT]):
+ @property
+ def status(self) -> int: ...
+
+ @property
+ def raw(self) -> T: ...
+
+ def json(self, **kwargs) -> RT: ...
+
+ def parse(self, **kwargs) -> PT: ...
+
+
+class UnifiedResponse(Response[httpx.Response, dict[str, Any], ActivityPubModel]):
+ def __init__(self, native_response: httpx.Response):
+ self._raw = native_response
+
+ @property
+ def raw(self) -> httpx.Response:
+ return self._raw
+
+ @property
+ def headers(self) -> dict:
+ return dict(self._raw.headers)
+
+ @property
+ def status(self) -> int:
+ return self._raw.status_code
+
+ def parse(self, **kwargs) -> ActivityPubModel:
+ """Read the response body as an ActivityPub model."""
+ json = self.json(**kwargs)
+ obj = apmodel.load(json)
+ if isinstance(obj, ActivityPubModel):
+ return obj
+ raise ValueError("failed to parse json")
+
+ def json(self, **kwargs) -> dict:
+ return self._raw.json()
+
+
+class UnifiedResponseAsync(
+ Response[
+ aiohttp.ClientResponse, Awaitable[dict[str, Any]], Awaitable[ActivityPubModel]
+ ]
+):
+ def __init__(self, native_response: aiohttp.ClientResponse):
+ self._raw = native_response
+
+ @property
+ def raw(self) -> aiohttp.ClientResponse:
+ return self._raw
+
+ @property
+ def headers(self) -> dict:
+ return dict(self._raw.headers)
+
+ @property
+ def status(self) -> int:
+ return self._raw.status
+
+ async def json(
+ self,
+ encoding: Optional[str] = None,
+ content_type: Optional[str] = "application/json",
+ **kwargs,
+ ) -> dict:
+ try:
+ return await self._raw.json(
+ encoding=encoding, content_type=content_type, **kwargs
+ )
+ finally:
+ await self._raw.release()
+
+ async def parse(
+ self,
+ encoding: Optional[str] = None,
+ content_type: Optional[str] = "application/json",
+ **kwargs,
+ ) -> ActivityPubModel:
+ """Read the response body as an ActivityPub model."""
+ json = await self.json(encoding=encoding, content_type=content_type, **kwargs)
+ obj = apmodel.load(json)
+ if isinstance(obj, ActivityPubModel):
+ return obj
+ raise ValueError("failed to parse json")
diff --git a/src/apkit/helper/host_meta.py b/src/apkit/helper/host_meta.py
new file mode 100644
index 0000000..4d4322d
--- /dev/null
+++ b/src/apkit/helper/host_meta.py
@@ -0,0 +1,162 @@
+import json
+import xml.etree.ElementTree
+from collections import defaultdict
+from typing import Dict, List, NamedTuple, Optional, Union
+
+HAS_LXML = False
+try:
+ from lxml import etree as _lxml
+
+ lxml_etree = _lxml
+ HAS_LXML = True
+except ImportError:
+ pass
+
+
+class HostMetaLink(NamedTuple):
+ rel: str
+ type: Optional[str]
+ href: Optional[str]
+ template: Optional[str]
+
+
+class HostMeta:
+ __slots__ = ("links", "_rel_map")
+
+ def __init__(self, links: List[HostMetaLink]):
+ self.links = links
+ rel_map: Dict[str, List[HostMetaLink]] = defaultdict(list)
+ for link in links:
+ rel_map[link.rel].append(link)
+ self._rel_map = dict(rel_map)
+
+ @classmethod
+ def from_json(cls, json_data: Union[str, bytes]) -> "HostMeta":
+ data = json.loads(json_data)
+ links_data = data.get("links", [])
+
+ links = [
+ HostMetaLink(
+ rel=i.get("rel", ""),
+ type=i.get("type"),
+ href=i.get("href"),
+ template=i.get("template"),
+ )
+ for i in links_data
+ ]
+ return cls(links)
+
+ @classmethod
+ def from_xml(cls, xml_data: Union[str, bytes]) -> "HostMeta":
+ if isinstance(xml_data, str):
+ xml_data = xml_data.encode("utf-8")
+
+ if HAS_LXML and lxml_etree is not None:
+ return cls._parse_with_lxml(xml_data)
+ else:
+ return cls._parse_with_std_etree(xml_data)
+
+ @classmethod
+ def _parse_with_lxml(cls, xml_data: bytes) -> "HostMeta":
+ assert lxml_etree is not None
+
+ ns = {"xrd": "http://docs.oasis-open.org/ns/xri/xrd-1.0"}
+ parser = lxml_etree.XMLParser(recover=True, no_network=True)
+ root = lxml_etree.fromstring(xml_data, parser=parser)
+
+ nodes = root.xpath("xrd:Link", namespaces=ns)
+
+ links = [
+ HostMetaLink(
+ rel=str(n.get("rel", "")),
+ type=n.get("type"),
+ href=n.get("href"),
+ template=n.get("template"),
+ )
+ for n in nodes
+ ]
+ return cls(links)
+
+ @classmethod
+ def _parse_with_std_etree(cls, xml_data: bytes) -> "HostMeta":
+ ns = {"xrd": "http://docs.oasis-open.org/ns/xri/xrd-1.0"}
+ root = xml.etree.ElementTree.fromstring(xml_data)
+
+ nodes = root.findall("xrd:Link", ns)
+
+ links = [
+ HostMetaLink(
+ rel=n.get("rel", ""),
+ type=n.get("type"),
+ href=n.get("href"),
+ template=n.get("template"),
+ )
+ for n in nodes
+ ]
+ return cls(links)
+
+ def to_json(self, indent: Optional[int] = None) -> str:
+ data = {
+ "links": [
+ {k: v for k, v in link._asdict().items() if v is not None}
+ for link in self.links
+ ]
+ }
+ return json.dumps(data, indent=indent, ensure_ascii=False)
+
+ def to_xml(self) -> str:
+ if HAS_LXML and lxml_etree is not None:
+ return self._to_xml_with_lxml()
+ return self._to_xml_with_std_etree()
+
+ def _to_xml_with_lxml(self) -> str:
+ if lxml_etree is None:
+ raise RuntimeError("lxml is not available even though HAS_LXML is True.")
+ nsmap = {None: "http://docs.oasis-open.org/ns/xri/xrd-1.0"}
+ root = lxml_etree.Element("XRD", nsmap=nsmap)
+
+ for link in self.links:
+ attrs = {k: v for k, v in link._asdict().items() if v is not None}
+ lxml_etree.SubElement(root, "Link", attrs)
+
+ return lxml_etree.tostring(
+ root, encoding="UTF-8", xml_declaration=True, pretty_print=True
+ ).decode("utf-8")
+
+ def _to_xml_with_std_etree(self) -> str:
+ xml.etree.ElementTree.register_namespace(
+ "", "http://docs.oasis-open.org/ns/xri/xrd-1.0"
+ )
+ root = xml.etree.ElementTree.Element(
+ "{http://docs.oasis-open.org/ns/xri/xrd-1.0}XRD"
+ )
+
+ for link in self.links:
+ attrs = {k: v for k, v in link._asdict().items() if v is not None}
+ xml.etree.ElementTree.SubElement(
+ root, "{http://docs.oasis-open.org/ns/xri/xrd-1.0}Link", attrs
+ )
+
+ return xml.etree.ElementTree.tostring(root, encoding="unicode")
+
+ def find_link(self, rel: str) -> Optional[HostMetaLink]:
+ return next((i for i in self.links if i.rel == rel), None)
+
+ def get(self, rel: str) -> Union[HostMetaLink, List[HostMetaLink], None]:
+ found = self._rel_map.get(rel)
+
+ if not found:
+ return None
+
+ if len(found) == 1:
+ return found[0]
+
+ return found
+
+ def get_all(self, rel: str) -> List[HostMetaLink]:
+ return self._rel_map.get(rel, [])
+
+ @property
+ def lrdd(self) -> Optional[HostMetaLink]:
+ found = self._rel_map.get("lrdd")
+ return found[0] if found else None
diff --git a/src/apkit/helper/inbox.py b/src/apkit/helper/inbox.py
index fd93428..fa26d89 100644
--- a/src/apkit/helper/inbox.py
+++ b/src/apkit/helper/inbox.py
@@ -22,7 +22,7 @@
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
-from ..client.asyncio.client import ActivityPubClient
+from ..client.client import ActivityPubClient
from ..config import AppConfig
@@ -35,9 +35,11 @@ async def __fetch_actor(self, activity: Activity) -> Optional[Actor]:
async with ActivityPubClient() as client:
match activity.actor:
case str() as url:
- actor = await client.actor.fetch(url=url)
+ # TODO: fix later
+ actor = await client.actor.fetch(url=url) # pyrefly: ignore
case Link(href=str() as url):
- actor = await client.actor.fetch(url=url)
+ # TODO: fix later
+ actor = await client.actor.fetch(url=url) # pyrefly: ignore
case Actor() as actor_obj:
actor = actor_obj
case _:
diff --git a/src/apkit/nodeinfo/builder.py b/src/apkit/nodeinfo/builder.py
index 5d43cca..1a90a1a 100644
--- a/src/apkit/nodeinfo/builder.py
+++ b/src/apkit/nodeinfo/builder.py
@@ -1,10 +1,10 @@
from typing import Literal, Optional, Union
from apmodel.nodeinfo.nodeinfo import (
- Nodeinfo, # noqa: F401
+ Nodeinfo,
)
from apmodel.nodeinfo.nodeinfo import (
- NodeinfoInbound as Inbound, # noqa: F401
+ NodeinfoInbound as Inbound,
)
from apmodel.nodeinfo.nodeinfo import (
NodeinfoOutbound as Outbound, # noqa: F401
diff --git a/src/apkit/server/types.py b/src/apkit/server/types.py
index b1d0343..209fe8e 100644
--- a/src/apkit/server/types.py
+++ b/src/apkit/server/types.py
@@ -5,10 +5,9 @@
from apmodel.types import ActivityPubModel
from apmodel.vocab.activity import Accept, Reject
from apmodel.vocab.actor import Actor, ActorEndpoints
-from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import Request
-from ..client.asyncio.client import ActivityPubClient
+from ..client.client import ActivityPubClient
from ..types import ActorKey, Outbox # noqa: F401
if TYPE_CHECKING:
@@ -39,15 +38,8 @@ async def send(
if not isinstance(inbox, str):
raise ValueError(f"Unsupported Inbox Type: {inbox}")
- for key in keys:
- if isinstance(key.private_key, rsa.RSAPrivateKey):
- priv_key = key.private_key
- key_id = key.key_id
- break
if priv_key and key_id and inbox:
- async with client.post(
- inbox, key_id=key_id, signature=priv_key, json=activity
- ) as _:
+ async with client.post(inbox, sign_as=keys, json=activity) as _:
return None
else:
pass
diff --git a/src/apkit/types.py b/src/apkit/types.py
index 11cdba0..11e56ce 100644
--- a/src/apkit/types.py
+++ b/src/apkit/types.py
@@ -7,7 +7,7 @@ class Outbox:
pass
-@dataclass
+@dataclass(frozen=True)
class ActorKey:
key_id: str
private_key: rsa.RSAPrivateKey | ed25519.Ed25519PrivateKey
diff --git a/tests/client/test_models.py b/tests/client/test_models.py
index 6c0d31a..3a1e887 100644
--- a/tests/client/test_models.py
+++ b/tests/client/test_models.py
@@ -2,6 +2,23 @@
from dataclasses import FrozenInstanceError
from apkit.client.models import Resource, Link, WebfingerResult
+@pytest.fixture
+def xrd_valid():
+ return """
+
+ acct:alice@example.com
+ https://example.com/alice
+
+
+
+ """
+
+@pytest.fixture
+def xrd_invalid():
+ return """
+
+
+ """
class TestResource:
"""Test cases for the Resource class."""
@@ -61,7 +78,7 @@ def test_parse_invalid_resource_string(self):
def test_resource_immutability(self):
"""Test that Resource is immutable (frozen dataclass)."""
resource = Resource(username="alice", host="example.com", url=None)
- with pytest.raises(FrozenInstanceError):
+ with pytest.raises(AttributeError):
resource.username = "eve" # pyrefly: ignore
@@ -101,7 +118,7 @@ def test_link_to_json_with_none_values(self):
def test_link_immutability(self):
"""Test that Link is immutable (frozen dataclass)."""
link = Link(rel="profile", type="text/html", href="https://example.com/profile")
- with pytest.raises(FrozenInstanceError):
+ with pytest.raises(AttributeError):
link.rel = "self" # pyrefly: ignore
@@ -267,9 +284,57 @@ def test_webfinger_result_immutability(self):
]
result = WebfingerResult(subject=subject, links=links)
- with pytest.raises(FrozenInstanceError):
+ with pytest.raises(AttributeError):
result.subject = Resource(username="bob", host="example.com", url=None) # pyrefly: ignore
+ def test_webfinger_result_from_xml(self, xrd_valid):
+ """Test creating WebfingerResult from XML (XRD)."""
+ result = WebfingerResult.from_xml(xrd_valid)
+
+ assert result.subject.username == "alice"
+ assert result.subject.host == "example.com"
+ assert len(result.links) == 3
+
+ ap_link = result.get("application/activity+json")
+ assert isinstance(ap_link, Link)
+ assert ap_link.href == "https://example.com/alice"
+
+ sub_links = [l for l in result.links if "subscribe" in l.rel]
+ assert len(sub_links) == 1
+ assert sub_links[0].href == "https://example.com/ostatus_subscribe?acct={uri}"
+
+ def test_webfinger_result_from_xml_missing_subject(self, xrd_invalid):
+ """Test from_xml with missing subject raises ValueError."""
+ with pytest.raises(ValueError, match="Missing 'Subject'"):
+ WebfingerResult.from_xml(xrd_invalid)
+
+ def test_webfinger_result_from_xml_bytes(self, xrd_valid):
+ """Test from_xml with bytes input (common in HTTP responses)."""
+ result = WebfingerResult.from_xml(xrd_valid.encode("utf-8"))
+ assert result.subject.username == "alice"
+
+ def test_webfinger_result_immutability(self):
+ """Test that WebfingerResult is immutable (__slots__ protection)."""
+ subject = Resource(username="alice", host="example.com", url=None)
+ result = WebfingerResult(subject=subject, links=[])
+
+ with pytest.raises(AttributeError):
+ result.subject = Resource(username="bob", host="example.com", url=None) # type: ignore
+
+ def test_webfinger_result_get_consistency(self, xrd_valid):
+ """Test that get() returns correct types regardless of parse source."""
+ result = WebfingerResult.from_xml(xrd_valid)
+
+ found = result.get("application/activity+json")
+ assert isinstance(found, Link)
+
+ assert result.get("image/png") is None
+
+ def test_xml_output_roundtrip(self):
+ result = WebfingerResult.from_dict({"subject": "acct:alice@example.com", "links": []})
+ xml_data = result.to_xml()
+ reparsed = WebfingerResult.from_xml(xml_data)
+ assert str(reparsed.subject) == str(result.subject)
def test_integration():
"""Integration test covering the full workflow."""
@@ -315,3 +380,22 @@ def test_integration():
html_links = reconstructed.get("text/html")
assert isinstance(html_links, list)
assert len(html_links) == 2
+
+def test_full_roundtrip_integration(xrd_valid):
+ from_xml = WebfingerResult.from_xml(xrd_valid)
+ json_data = from_xml.to_json()
+ reconstructed = WebfingerResult.from_dict(json_data)
+
+ assert str(reconstructed.subject) == str(from_xml.subject)
+ assert len(reconstructed.links) == len(from_xml.links)
+
+ def get_first_href(result: WebfingerResult, l_type: str) -> str:
+ res = result.get(l_type)
+ if isinstance(res, list):
+ return res[0].href or ""
+ if res is not None:
+ return res.href or ""
+ return ""
+
+ assert get_first_href(reconstructed, "text/html") == get_first_href(from_xml, "text/html")
+ assert get_first_href(reconstructed, "application/activity+json") == get_first_href(from_xml, "application/activity+json")
diff --git a/tests/client/test_request.py b/tests/client/test_request.py
new file mode 100644
index 0000000..8a6ff19
--- /dev/null
+++ b/tests/client/test_request.py
@@ -0,0 +1,75 @@
+import re
+import pytest
+import respx
+from aioresponses import aioresponses
+from httpx import Response
+from apkit.client import ActivityPubClient
+
+@pytest.fixture(scope="session")
+def shared_data():
+ data = {
+ "actor_head": {
+ "Content-Type": "application/activity+json",
+ "vary": "Accept-Encoding, Accept"
+ },
+ "actor_json": {
+ "@context": ["https://www.w3.org/ns/activitystreams"],
+ "id": "https://example.com/actor",
+ "type": "Application",
+ "preferredUsername": "actor",
+ "inbox": "https://example.com/actor/inbox"
+ }
+ }
+ return data
+
+@respx.mock
+def test_get_sync_success(shared_data):
+ url = "https://example.com/actor"
+ respx.get(url).mock(return_value=Response(
+ 200,
+ json=shared_data["actor_json"],
+ headers=shared_data["actor_head"]
+ ))
+
+ with ActivityPubClient() as client:
+ with client.get(url) as resp:
+ data = resp.json()
+ assert data["type"] == "Application"
+ assert resp.status == 200
+
+@respx.mock
+def test_sync_reused_error():
+ url = "https://example.com"
+ respx.get(url).mock(return_value=Response(200))
+ with ActivityPubClient() as client:
+ req = client.get(url)
+ with req:
+ pass
+ with pytest.raises(RuntimeError, match="instance cannot be reused"):
+ with req:
+ pass
+
+@pytest.mark.asyncio
+async def test_get_async_success(shared_data):
+ url = "https://example.com/actor"
+ with aioresponses() as m:
+ m.get(
+ url,
+ payload=shared_data["actor_json"],
+ headers=shared_data["actor_head"],
+ status=200
+ )
+
+ async with ActivityPubClient() as client:
+ async with client.get(url) as resp:
+ data = await resp.json()
+ assert data["type"] == "Application"
+ assert resp.status == 200
+
+@pytest.mark.asyncio
+async def test_async_uninitialized_error():
+ client = ActivityPubClient()
+ expected_msg = re.escape("The async client session is not initialized or has been closed. Ensure you are using 'async with ActivityPubClient()'.")
+ with pytest.raises(RuntimeError, match=expected_msg):
+ async with client.get("https://example.com"):
+ pass
\ No newline at end of file
diff --git a/tests/test_host_meta.py b/tests/test_host_meta.py
new file mode 100644
index 0000000..96909bd
--- /dev/null
+++ b/tests/test_host_meta.py
@@ -0,0 +1,146 @@
+import json
+
+import pytest
+from apkit.helper.host_meta import HostMeta, HostMetaLink, HAS_LXML
+
+VALID_XRD = """
+
+
+
+
+
+"""
+
+VALID_JRD = """
+{
+ "links": [
+ {
+ "rel": "lrdd",
+ "type": "application/jrd+json",
+ "template": "https://example.com/.well-known/webfinger?resource={uri}"
+ }
+ ]
+}
+"""
+
+@pytest.fixture
+def host_meta():
+ return HostMeta.from_xml(VALID_XRD)
+
+class TestHostMeta:
+ def test_from_xml_basic(self, host_meta: HostMeta):
+ assert len(host_meta.links) == 3
+ assert host_meta.links[0].rel == "lrdd"
+
+ def test_get_single(self, host_meta: HostMeta):
+ link = host_meta.get("lrdd")
+ assert isinstance(link, HostMetaLink)
+ assert link.template == "https://example.com/.well-known/webfinger?resource={uri}"
+
+ def test_get_multiple(self, host_meta: HostMeta):
+ links = host_meta.get("http://spec.example.net/rel/1")
+ assert isinstance(links, list)
+ assert len(links) == 2
+ assert links[0].href == "https://example.com/1"
+ assert links[1].href == "https://example.com/2"
+
+ def test_get_none(self, host_meta: HostMeta):
+ assert host_meta.get("undefined") is None
+
+ def test_get_all(self, host_meta: HostMeta):
+ assert len(host_meta.get_all("lrdd")) == 1
+ assert len(host_meta.get_all("http://spec.example.net/rel/1")) == 2
+ assert host_meta.get_all("undefined") == []
+
+ def test_find_link(self, host_meta: HostMeta):
+ link = host_meta.find_link("http://spec.example.net/rel/1")
+ assert link is not None
+ assert link.href == "https://example.com/1"
+
+ @pytest.mark.parametrize("parser_method", [
+ "_parse_with_std_etree",
+ pytest.param("_parse_with_lxml", marks=pytest.mark.skipif(not HAS_LXML, reason="lxml not installed"))
+ ])
+ def test_parsers_directly(self, parser_method):
+ method = getattr(HostMeta, parser_method)
+ instance = method(VALID_XRD.encode("utf-8"))
+ assert len(instance.links) == 3
+ assert instance.links[0].rel == "lrdd"
+
+ def test_malformed_xml(self):
+ bad_xml = ""
+ with pytest.raises(Exception):
+ HostMeta._parse_with_std_etree(bad_xml.encode("utf-8"))
+
+ def test_from_json_basic(self):
+ hm = HostMeta.from_json(VALID_JRD)
+ assert len(hm.links) == 1
+
+ link = hm.get("lrdd")
+ assert isinstance(link, HostMetaLink)
+ assert link.rel == "lrdd"
+ assert link.template == "https://example.com/.well-known/webfinger?resource={uri}"
+ assert link.type == "application/jrd+json"
+
+ def test_from_json_empty_links(self):
+ empty_jrd = '{"links": []}'
+ hm = HostMeta.from_json(empty_jrd)
+ assert hm.links == []
+ assert hm.get("lrdd") is None
+
+ def test_compatibility(self):
+ xml_data = """
+
+ """
+
+ hm_xml = HostMeta.from_xml(xml_data)
+ hm_json = HostMeta.from_json(VALID_JRD)
+
+ assert type(hm_xml.links[0]) is type(hm_json.links[0])
+ assert isinstance(hm_xml.links[0], HostMetaLink)
+
+ def test_invalid_json_raises(self):
+ with pytest.raises(json.JSONDecodeError):
+ HostMeta.from_json("{ invalid json }")
+
+ def test_to_json(self, host_meta: HostMeta):
+ json_str = host_meta.to_json()
+ new_meta = HostMeta.from_json(json_str)
+
+ assert len(new_meta.links) == len(host_meta.links)
+ assert new_meta.links[0].rel == host_meta.links[0].rel
+ assert "links" in json.loads(json_str)
+
+ @pytest.mark.parametrize("use_lxml", [
+ pytest.param(True, marks=pytest.mark.skipif(not HAS_LXML, reason="lxml missing")),
+ False
+ ])
+ def test_to_xml_roundtrip(self, host_meta: HostMeta, use_lxml):
+ if use_lxml:
+ xml_str = host_meta._to_xml_with_lxml()
+ else:
+ xml_str = host_meta._to_xml_with_std_etree()
+
+ new_meta = HostMeta.from_xml(xml_str)
+ new_lrdd = new_meta.find_link("lrdd")
+ orig_lrdd = host_meta.find_link("lrdd")
+
+ assert new_lrdd is not None
+ assert orig_lrdd is not None
+ assert new_lrdd.template == orig_lrdd.template
+ assert len(new_meta.links) == len(host_meta.links)
+
+ def test_to_json_excludes_none(self):
+ link = HostMetaLink(rel="test", type=None, href="http://ex.com", template=None)
+ hm = HostMeta([link])
+ data = json.loads(hm.to_json())
+
+ assert "rel" in data["links"][0]
+ assert "type" not in data["links"][0]
+ assert "template" not in data["links"][0]
+
+ def test_get_single_link(self, host_meta: HostMeta):
+ link = host_meta.get("lrdd")
+
+ assert isinstance(link, HostMetaLink)
+ assert link.template is not None
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index 39d458f..b9a0be0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -113,6 +113,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
]
+[[package]]
+name = "aioresponses"
+version = "0.7.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" },
+]
+
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -179,15 +192,22 @@ server = [
{ name = "fastapi" },
{ name = "uvicorn" },
]
+speed = [
+ { name = "google-re2" },
+ { name = "lxml" },
+]
[package.dev-dependencies]
dev = [
+ { name = "aioresponses" },
{ name = "coverage" },
+ { name = "pre-commit" },
{ name = "prek" },
{ name = "pyrefly" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
+ { name = "respx" },
{ name = "ruff" },
]
docs = [
@@ -202,23 +222,28 @@ requires-dist = [
{ name = "apsig", specifier = ">=0.6.0" },
{ name = "charset-normalizer", specifier = ">=3.4.3" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.116.1" },
+ { name = "google-re2", marker = "extra == 'speed'", specifier = ">=1.1.20251105" },
{ name = "httpcore", extras = ["http2", "socks"], specifier = ">=1.0.9" },
{ name = "httpx", specifier = ">=0.28.1" },
+ { name = "lxml", marker = "extra == 'speed'", specifier = ">=6.0.2" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.4" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "types-requests", specifier = ">=2.32.4.20250913" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.35.0" },
]
-provides-extras = ["redis", "server"]
+provides-extras = ["redis", "server", "speed"]
[package.metadata.requires-dev]
dev = [
+ { name = "aioresponses", specifier = ">=0.7.8" },
{ name = "coverage", specifier = ">=7.10.7" },
+ { name = "pre-commit", specifier = ">=4.5.1" },
{ name = "prek", specifier = ">=0.3.3" },
{ name = "pyrefly", specifier = ">=0.46.0" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
+ { name = "respx", specifier = ">=0.22.0" },
{ name = "ruff", specifier = ">=0.14.10" },
]
docs = [
@@ -415,6 +440,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
+[[package]]
+name = "cfgv"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -663,6 +697,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
]
+[[package]]
+name = "distlib"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.128.0"
@@ -678,6 +721,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
+[[package]]
+name = "filelock"
+version = "3.24.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
+]
+
[[package]]
name = "frozendict"
version = "2.4.7"
@@ -804,6 +856,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
]
+[[package]]
+name = "google-re2"
+version = "1.1.20251105"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/4d/203a08dab1bdb5c83b46dd424c01a789ecb5a37dbc80f33d016bd116a9d7/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:329efa209ea7baa44f0facf0402fa34e655dc97fdeb10d0b83fc06354f5575fd", size = 483717, upload-time = "2025-11-05T14:57:04.808Z" },
+ { url = "https://files.pythonhosted.org/packages/78/88/466026b43ff5c7d740f5ede090992ec63b60d1810ab14fe35dfc00677e0a/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:aa2ad5f6f48921ec137a7b7f1b1da903ddef8627a2dc30bc878a9a69d9925719", size = 515547, upload-time = "2025-11-05T14:57:06.013Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6a/c6c9fdb00c98990e4f7a6cd650e209d7b5d2754ca0404b72c69ac9909a69/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ac1cb2526cc88f050a0661fc7245ad009ee454bddc541b2e653f1d007585000d", size = 485396, upload-time = "2025-11-05T14:57:07.592Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/f6/529c44f607c47f96cfa29c1fe3a690fe75b2fdb48e9b0d6b54e5f0a75e59/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:50c7205182ad66c23c07abe8072f720ca2f7d595b61e28fd9b63623614f9afd6", size = 517150, upload-time = "2025-11-05T14:57:09.376Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d2/ccc07860e31ab81965c63f9ed4eb69ea0d3449a9b4e1610f71883694bbe8/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:4cb5acee61e35772503b8b1db3c592a46b8e6a9bc0ab54d7d6233654ea2bf93d", size = 482807, upload-time = "2025-11-05T14:57:11.057Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/43/5fb20d16664457f61670bdd95f39039d43ee8b7732511c688e2f322a4317/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1617097d63620c2d46bdfc0e48f24f66cd341664fc75718636d234f67473fe7f", size = 508839, upload-time = "2025-11-05T14:57:12.338Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/f2/6e470338271e164dd3c5e508876f99aec3ed23bf419c7d54a5672fd5b05f/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a5610b26742b90cb1d64ead2b16fe0e3bd7e67add03fd3779cd1b85e401661", size = 573718, upload-time = "2025-11-05T14:57:13.635Z" },
+ { url = "https://files.pythonhosted.org/packages/91/21/4566fc344c21cf3c49082d13ddab785994b5e3b8b7fd4631242538f698a2/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03156291269f145eccddff63118f2df02d395792f51fc039f09955818943815a", size = 590749, upload-time = "2025-11-05T14:57:14.864Z" },
+ { url = "https://files.pythonhosted.org/packages/94/19/5981fb798bb8d08933b815b1fd9e55d179c380b9d8c21a49197b9b7c5967/google_re2-1.1.20251105-1-cp311-cp311-win32.whl", hash = "sha256:54f51762b51dc238eceddf49b56cc2b64594fe72d9328c1c39d615aa990e1f87", size = 434066, upload-time = "2025-11-05T14:57:16.22Z" },
+ { url = "https://files.pythonhosted.org/packages/49/e5/f83053a36cfc4762d843748e4f7a9c1141937dcf74cd6fc3f4598292dda3/google_re2-1.1.20251105-1-cp311-cp311-win_amd64.whl", hash = "sha256:f5f856ff5036a8f22b3bad57f376d4e3b97b59b64f311bdb1f83c8dabded2492", size = 491025, upload-time = "2025-11-05T14:57:17.746Z" },
+ { url = "https://files.pythonhosted.org/packages/56/be/4315c3b38f42f9a2888fa76260545c98547502f1c35aa63a672d39011b2e/google_re2-1.1.20251105-1-cp311-cp311-win_arm64.whl", hash = "sha256:913864f97de4151eaa8bb7746ca230fd193656501e07fb658ce2cd46d4f6efcc", size = 642194, upload-time = "2025-11-05T14:57:19.374Z" },
+ { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" },
+ { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" },
+ { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" },
+ { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b9/c441722196598fc3de0f654606ad9975a968c71dc27f516b5a4c9ebb94fd/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:9f3cf610e857a7d6f02916cf2b7fc159a5429b8bcb23164500d46e5e233f2924", size = 485549, upload-time = "2025-11-05T14:57:36.939Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/87/cf588255e5ada1dfb555cc96de35be78438bb0b6faba64df5fe91cecc224/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a21c2807bf4d5d00f206a4ecb3b043aad674e28c451b697b740280f608872078", size = 518840, upload-time = "2025-11-05T14:57:38.115Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/39/da66e4ca9be0c51546efc6fb39cf1683c4be8245d8199cb54a9808e8d5fa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8314144eefeee7b88b742081c2038418f677e63901039ca9dbfbc0c5bb6d2911", size = 487037, upload-time = "2025-11-05T14:57:39.467Z" },
+ { url = "https://files.pythonhosted.org/packages/75/dd/24ba65692dd58dca6ff178428551f4e9b776d1489a1251f5c8539e598baa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:28a46be978e53c772139d0f5c9ba69f53563fcdd4225407e4d34d51208b828f1", size = 520285, upload-time = "2025-11-05T14:57:40.666Z" },
+ { url = "https://files.pythonhosted.org/packages/61/12/cfdbb92bed24af6474970a75a26145c424f98cfbcc633fdd185985f0efe0/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e", size = 482981, upload-time = "2025-11-05T14:57:41.928Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bf/5fc32ded9279e69a87b88d7261e7e77e2e26325d4e27ca1303a3215e430a/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1920b15dc9b1bdfeca5aa2c60900373c6f27cd1056d53cd299456ea5540a6fff", size = 510366, upload-time = "2025-11-05T14:57:43.21Z" },
+ { url = "https://files.pythonhosted.org/packages/71/71/f927ddc7aef1b8d7ccc8a649c335d311f29f3dea658209e30e37720e4891/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87", size = 572390, upload-time = "2025-11-05T14:57:44.422Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/8c/23075e589038284c9487f41cde531d35873f9da622fb4ac7d1d97bd9086e/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b", size = 591386, upload-time = "2025-11-05T14:57:45.713Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/7f/858453ef689f6b9895cd02b466836a9d1a6e4ba535d1a275b01bf73baa1d/google_re2-1.1.20251105-1-cp313-cp313-win32.whl", hash = "sha256:67c5c73d7ebcf3f0e0a3b528b41bd8c6c04900f1598aebf05bbdf15a06cf5f9a", size = 433807, upload-time = "2025-11-05T14:57:46.92Z" },
+ { url = "https://files.pythonhosted.org/packages/08/24/6ea87fe682e115ffd296e91eb5c5a266349d1ee8414ce8ece3f99ec1ac84/google_re2-1.1.20251105-1-cp313-cp313-win_amd64.whl", hash = "sha256:0bcba63ad3ea8926fb0c71bb5044e33d405bb9395f5b5444393cd5f28f0bf6d3", size = 491734, upload-time = "2025-11-05T14:57:48.304Z" },
+ { url = "https://files.pythonhosted.org/packages/34/85/32ba71b06f3cf5f9856ae95b3d6463b971742453631a5ae2c5be338ea377/google_re2-1.1.20251105-1-cp313-cp313-win_arm64.whl", hash = "sha256:64ee189ea857f2126c5e42073cfa9b03e9f4cbaf073edbedb575059074841aa0", size = 642654, upload-time = "2025-11-05T14:57:49.602Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/7f/7eb238bdcd06182b5f427afd305cf413b7cf4ea71047308bbf35912cf923/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:cc151cf6a585d9ebe711da32b23683fcff40f78db8c8587c7f4b209ef4658809", size = 484719, upload-time = "2025-11-05T14:57:51.326Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/62/eed28eab67f939f4b9383c47b1db11638ade6ac30785c15cb960de85ba43/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:7e2186d2c90488c1e11895343941f35ca2f58e9ba6c6b034fd531abe22ef77cc", size = 517698, upload-time = "2025-11-05T14:57:52.597Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/16/a1e6768513f788bf9c67a1cfe379ef34a793983eee46e4b653e42b558b78/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:41be22359c3dceb582937739b4365dd8e279de24ad0a5b10e653503abaff2ed7", size = 486421, upload-time = "2025-11-05T14:57:53.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/fc/7a97ffd36d451e5a8bfaff2f9022b14807795d588f98227ff96e8da99856/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f3168d7bbac247c862ea85b2f3c011d3a04bedcb6892b37f14d488f4133b206e", size = 519037, upload-time = "2025-11-05T14:57:55.078Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ee/8b6f7d94bb689dafdf60de8dd8f8f6296ad40d4d15c933fcda4da7a3a06b/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:79ce664038194a31bbcf422137f9607ae3d9946a5cff98cf0efbeb7f9411e64b", size = 483373, upload-time = "2025-11-05T14:57:56.297Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a6/16a09e03d1de128f821869e4252688c21319f5017d9209f4d0e71ea5c951/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:0476b07421b8882b279d5ceb5b760c15c62d581ded95274697fc1227e3869ee6", size = 510167, upload-time = "2025-11-05T14:57:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/9d/213dce5de401527369fb5af11096b18c06001d9eb71f3318fe5eba1ec706/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85feec3161ffdc12f6b144e37a2f91f80b771c72ffadde60191e89a49f6d7e81", size = 573176, upload-time = "2025-11-05T14:57:59.211Z" },
+ { url = "https://files.pythonhosted.org/packages/03/be/a8def96aa4a80b233e105767d22e3de961dcde5a04f0a05cb4f3ddb4df78/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7bfaa2cf55daf0c5c650e68526bb20b61e37d7f3ae53f6893013acc1c91c116", size = 591483, upload-time = "2025-11-05T14:58:00.416Z" },
+ { url = "https://files.pythonhosted.org/packages/14/ea/144bbc4b9359da89aec07b4c2a91a6bfe7119914885386577c665b07bb01/google_re2-1.1.20251105-1-cp314-cp314-win32.whl", hash = "sha256:214c1accdc60fff9ce1bf812b157147ca361844f496ed9e0d5f357b0e562ced8", size = 433773, upload-time = "2025-11-05T14:58:01.594Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b3/74e301211699f1b650ba7690a3e4e52146ac4266fcd62f3ea0a945b9eda4/google_re2-1.1.20251105-1-cp314-cp314-win_amd64.whl", hash = "sha256:6d4d5fdadd329a2ed193463899d00ef2fd126172f36a4c01c9def271f19801b6", size = 491893, upload-time = "2025-11-05T14:58:02.969Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d1/4adcfcb9c95e3d064c9f7aaf6cb3a4fc842d86115014b9d4094db4d465b5/google_re2-1.1.20251105-1-cp314-cp314-win_arm64.whl", hash = "sha256:1d27f3a2a947ec1f721d0f14f661108acfd4f4d34f357ce28db951cc036656e5", size = 643093, upload-time = "2025-11-05T14:58:05.761Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -892,6 +996,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
+[[package]]
+name = "identify"
+version = "2.6.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
+]
+
[[package]]
name = "idna"
version = "3.11"
@@ -1339,6 +1452,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/9b/c21a9c1d5ea4847989f1eb00e3147e38e79aaea7c4b4d1cbd4f1afae9740/multiformats_config-0.3.1-py3-none-any.whl", hash = "sha256:dec4c9d42ed0d9305889b67440f72e8e8d74b82b80abd7219667764b5b0a8e1d", size = 17153, upload-time = "2023-12-18T21:35:21.171Z" },
]
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
[[package]]
name = "packaging"
version = "26.0"
@@ -1384,6 +1506,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pre-commit"
+version = "4.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
+]
+
[[package]]
name = "prek"
version = "0.3.3"
@@ -1846,6 +1984,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
+[[package]]
+name = "respx"
+version = "0.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
+]
+
[[package]]
name = "ruff"
version = "0.14.14"
@@ -2021,6 +2171,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
+[[package]]
+name = "virtualenv"
+version = "20.38.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" },
+]
+
[[package]]
name = "watchdog"
version = "6.0.0"