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"