From b64a72da29ee20edad13dc4f3e3f662ab7f765f6 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 13 Sep 2026 14:34:12 +0200 Subject: [PATCH] feat(core): scaffold core structure and api typing --- pyproject.toml | 14 +-- src/aderarr/__init__.py | 50 ++++++++++- src/aderarr/core/__init__.py | 0 src/aderarr/core/lab/__init__.py | 0 src/aderarr/core/lab/budget.py | 49 +++++++++++ src/aderarr/core/lab/cartel.py | 87 +++++++++++++++++++ src/aderarr/core/lab/keys.py | 25 ++++++ src/aderarr/core/lab/products.py | 60 +++++++++++++ src/aderarr/core/proto/__init__.py | 0 src/aderarr/core/proto/dealer.py | 14 +++ .../core/proto/deliverables/__init__.py | 0 .../core/proto/deliverables/outcome.py | 62 +++++++++++++ src/aderarr/core/proto/deliverables/stream.py | 11 +++ src/aderarr/core/proto/errors.py | 43 +++++++++ src/aderarr/core/proto/models/__init__.py | 0 src/aderarr/core/proto/models/catalog.py | 40 +++++++++ src/aderarr/core/proto/models/media.py | 43 +++++++++ src/aderarr/core/proto/models/secret.py | 26 ++++++ src/aderarr/core/proto/products/__init__.py | 0 src/aderarr/core/proto/products/catalog.py | 21 +++++ src/aderarr/core/proto/products/lyrics.py | 9 ++ src/aderarr/core/proto/products/mark.py | 9 ++ src/aderarr/core/proto/products/match.py | 13 +++ src/aderarr/core/proto/products/media.py | 14 +++ src/aderarr/core/proto/products/search.py | 17 ++++ src/aderarr/core/proto/recipe/__init__.py | 0 src/aderarr/core/proto/recipe/mark.py | 20 +++++ .../core/proto/recipe/reagents/__init__.py | 0 .../core/proto/recipe/reagents/base.py | 2 + .../core/proto/recipe/reagents/http.py | 17 ++++ .../core/proto/recipe/reagents/human.py | 10 +++ .../core/proto/recipe/reagents/session.py | 9 ++ .../core/proto/recipe/reagents/transform.py | 11 +++ src/aderarr/core/proto/recipe/terminals.py | 17 ++++ src/aderarr/core/proto/supply/__init__.py | 0 src/aderarr/core/proto/supply/budget.py | 7 ++ src/aderarr/core/proto/supply/identity.py | 12 +++ src/aderarr/core/proto/supply/store.py | 20 +++++ src/aderarr/core/proto/supply/supplier.py | 30 +++++++ src/aderarr/core/proto/supply/transformer.py | 13 +++ src/aderarr/plugins/__init__.py | 40 +++++++++ uv.lock | 72 +++++++++++++++ 42 files changed, 880 insertions(+), 7 deletions(-) create mode 100644 src/aderarr/core/__init__.py create mode 100644 src/aderarr/core/lab/__init__.py create mode 100644 src/aderarr/core/lab/budget.py create mode 100644 src/aderarr/core/lab/cartel.py create mode 100644 src/aderarr/core/lab/keys.py create mode 100644 src/aderarr/core/lab/products.py create mode 100644 src/aderarr/core/proto/__init__.py create mode 100644 src/aderarr/core/proto/dealer.py create mode 100644 src/aderarr/core/proto/deliverables/__init__.py create mode 100644 src/aderarr/core/proto/deliverables/outcome.py create mode 100644 src/aderarr/core/proto/deliverables/stream.py create mode 100644 src/aderarr/core/proto/errors.py create mode 100644 src/aderarr/core/proto/models/__init__.py create mode 100644 src/aderarr/core/proto/models/catalog.py create mode 100644 src/aderarr/core/proto/models/media.py create mode 100644 src/aderarr/core/proto/models/secret.py create mode 100644 src/aderarr/core/proto/products/__init__.py create mode 100644 src/aderarr/core/proto/products/catalog.py create mode 100644 src/aderarr/core/proto/products/lyrics.py create mode 100644 src/aderarr/core/proto/products/mark.py create mode 100644 src/aderarr/core/proto/products/match.py create mode 100644 src/aderarr/core/proto/products/media.py create mode 100644 src/aderarr/core/proto/products/search.py create mode 100644 src/aderarr/core/proto/recipe/__init__.py create mode 100644 src/aderarr/core/proto/recipe/mark.py create mode 100644 src/aderarr/core/proto/recipe/reagents/__init__.py create mode 100644 src/aderarr/core/proto/recipe/reagents/base.py create mode 100644 src/aderarr/core/proto/recipe/reagents/http.py create mode 100644 src/aderarr/core/proto/recipe/reagents/human.py create mode 100644 src/aderarr/core/proto/recipe/reagents/session.py create mode 100644 src/aderarr/core/proto/recipe/reagents/transform.py create mode 100644 src/aderarr/core/proto/recipe/terminals.py create mode 100644 src/aderarr/core/proto/supply/__init__.py create mode 100644 src/aderarr/core/proto/supply/budget.py create mode 100644 src/aderarr/core/proto/supply/identity.py create mode 100644 src/aderarr/core/proto/supply/store.py create mode 100644 src/aderarr/core/proto/supply/supplier.py create mode 100644 src/aderarr/core/proto/supply/transformer.py create mode 100644 src/aderarr/plugins/__init__.py create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index 5dbc2e4..1b5f827 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ authors = [ requires-python = ">=3.13" dependencies = [] +[dependency-groups] +dev = [ + "ruff>=0.16.7", + "ty>=0.0.80", +] + [build-system] requires = ["uv_build>=0.11.14,<0.12.0"] build-backend = "uv_build" @@ -34,8 +40,6 @@ skip-magic-trailing-comma = true [tool.ruff.lint.isort] split-on-trailing-comma = false -[dependency-groups] -dev = [ - "ruff>=0.15.20", - "ty>=0.0.55", -] +[tool.pyright] +venvPath = "." +venv = ".venv" diff --git a/src/aderarr/__init__.py b/src/aderarr/__init__.py index 177bcb8..61e2230 100644 --- a/src/aderarr/__init__.py +++ b/src/aderarr/__init__.py @@ -1,2 +1,48 @@ -def main() -> None: - print("Hello from aderarr!") +from aderarr.core.lab.budget import deadline +from aderarr.core.lab.cartel import Capability, Cartel, DealerInfo +from aderarr.core.proto.deliverables.outcome import ( + Degraded, + Expired, + Fatal, + NeedsAuth, + Ok, + Outcome, + RateLimited, + Streaming, + Unresolved, +) +from aderarr.core.proto.deliverables.stream import Stream +from aderarr.core.proto.errors import AderarrError, ContractError, NoBudgetError +from aderarr.core.proto.models.catalog import Artist, LyricLine, Lyrics, Release, Track +from aderarr.core.proto.models.media import MediaInfo, Quality +from aderarr.core.proto.models.secret import Secret +from aderarr.core.proto.supply.identity import Identity + +__all__ = [ + "AderarrError", + "Artist", + "Capability", + "Cartel", + "ContractError", + "DealerInfo", + "Degraded", + "Expired", + "Fatal", + "Identity", + "LyricLine", + "Lyrics", + "MediaInfo", + "NeedsAuth", + "NoBudgetError", + "Ok", + "Outcome", + "Quality", + "RateLimited", + "Release", + "Secret", + "Stream", + "Streaming", + "Track", + "Unresolved", + "deadline", +] diff --git a/src/aderarr/core/__init__.py b/src/aderarr/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/lab/__init__.py b/src/aderarr/core/lab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/lab/budget.py b/src/aderarr/core/lab/budget.py new file mode 100644 index 0000000..6f16846 --- /dev/null +++ b/src/aderarr/core/lab/budget.py @@ -0,0 +1,49 @@ +import asyncio +from collections.abc import AsyncGenerator, Generator +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar + +from aderarr.core.proto.errors import NoBudgetError +from aderarr.core.proto.supply.budget import Budget + +_budget: ContextVar[Budget | None] = ContextVar("aderarr_budget", default=None) + + +def current() -> Budget: + budget = _budget.get() + if budget is None: + raise NoBudgetError + return budget + + +def remaining_first_byte() -> float: + return current().first_byte - asyncio.get_running_loop().time() + + +def remaining_overall() -> float: + return current().overall - asyncio.get_running_loop().time() + + +@contextmanager +def deadline(*, first_byte: float, overall: float) -> Generator[Budget]: + now = asyncio.get_running_loop().time() + budget = Budget(first_byte=now + first_byte, overall=now + overall) + outer = _budget.get() + if outer is not None: + budget = Budget( + first_byte=min(budget.first_byte, outer.first_byte), + overall=min(budget.overall, outer.overall), + ) + token = _budget.set(budget) + try: + yield budget + finally: + _budget.reset(token) + + +@asynccontextmanager +async def within(cap: float) -> AsyncGenerator[None]: + budget = current() + now = asyncio.get_running_loop().time() + async with asyncio.timeout_at(min(budget.first_byte, budget.overall, now + cap)): + yield diff --git a/src/aderarr/core/lab/cartel.py b/src/aderarr/core/lab/cartel.py new file mode 100644 index 0000000..06a8937 --- /dev/null +++ b/src/aderarr/core/lab/cartel.py @@ -0,0 +1,87 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from aderarr.core.lab.products import RecipeMeta, collect_recipes +from aderarr.core.proto.dealer import Dealer +from aderarr.core.proto.errors import ContractError +from aderarr.core.proto.recipe.reagents.base import Reagent +from aderarr.core.proto.recipe.reagents.human import AskHuman +from aderarr.core.proto.recipe.reagents.session import Invalidate +from aderarr.core.proto.recipe.reagents.transform import Transform +from aderarr.core.proto.supply.supplier import Supplier +from aderarr.core.proto.supply.transformer import Transformer + +_BUILTIN: frozenset[type[Reagent]] = frozenset({AskHuman, Invalidate}) + + +@dataclass(frozen=True, slots=True) +class Capability: + resolvable: bool + missing: tuple[type[Reagent], ...] + + +@dataclass(frozen=True, slots=True) +class DealerInfo: + dealer: Dealer + recipes: Mapping[str, RecipeMeta] + + +class Cartel: + def __init__(self) -> None: + self._dealers: dict[str, DealerInfo] = {} + self._suppliers: dict[type[Reagent], Supplier[Any]] = {} + self._transformers: dict[str, Transformer] = {} + self._capabilities: dict[tuple[str, str], Capability] | None = None + + def register_dealer(self, dealer: Dealer) -> None: + dealer_id = getattr(dealer, "id", None) + if not isinstance(dealer_id, str) or not dealer_id: + msg = f"{type(dealer).__qualname__} has no id" + raise ContractError(msg) + if dealer_id in self._dealers: + msg = f"dealer {dealer_id} is already registered" + raise ContractError(msg) + self._dealers[dealer_id] = DealerInfo(dealer, collect_recipes(type(dealer))) + self._capabilities = None + + def register_supplier(self, supplier: Supplier[Any]) -> None: + if supplier.reagent in self._suppliers: + msg = f"supplier for {supplier.reagent.__qualname__} is already registered" + raise ContractError(msg) + self._suppliers[supplier.reagent] = supplier + self._capabilities = None + + def register_transformer(self, transformer: Transformer) -> None: + if transformer.transform_kind in self._transformers: + msg = f"transformer for {transformer.transform_kind} is already registered" + raise ContractError(msg) + self._transformers[transformer.transform_kind] = transformer + self._capabilities = None + + def dealers(self) -> Sequence[DealerInfo]: + return tuple(self._dealers.values()) + + def capabilities(self) -> Mapping[tuple[str, str], Capability]: + if self._capabilities is None: + self._capabilities = { + (dealer_id, product): self._capability(meta) + for dealer_id, info in self._dealers.items() + for product, meta in info.recipes.items() + } + return self._capabilities + + def resolve(self, reagent: Reagent) -> Supplier[Any] | None: + return self._suppliers.get(type(reagent)) + + def resolve_transform(self, transform: Transform) -> Transformer | None: + return self._transformers.get(transform.kind) + + def _capability(self, meta: RecipeMeta) -> Capability: + missing = tuple(r for r in meta.reagents if not self._available(r)) + return Capability(resolvable=not missing, missing=missing) + + def _available(self, reagent: type[Reagent]) -> bool: + if reagent in _BUILTIN or reagent in self._suppliers: + return True + return reagent is Transform and bool(self._transformers) diff --git a/src/aderarr/core/lab/keys.py b/src/aderarr/core/lab/keys.py new file mode 100644 index 0000000..a0f8cda --- /dev/null +++ b/src/aderarr/core/lab/keys.py @@ -0,0 +1,25 @@ +from aderarr.core.proto.recipe.reagents.base import Reagent + + +def creds(provider: str, identity: str) -> str: + return f"creds:{provider}:{identity}" + + +def session(provider: str, identity: str) -> str: + return f"session:{provider}:{identity}" + + +def auth(provider: str, identity: str) -> str: + return f"auth:{provider}:{identity}" + + +def rate_limit(provider: str, identity: str, window: int) -> str: + return f"rl:{provider}:{identity}:{window}" + + +def health(provider: str, identity: str) -> str: + return f"health:{provider}:{identity}" + + +def cost(reagent: type[Reagent]) -> str: + return f"cost:{reagent.__module__}.{reagent.__qualname__}" diff --git a/src/aderarr/core/lab/products.py b/src/aderarr/core/lab/products.py new file mode 100644 index 0000000..b161ea1 --- /dev/null +++ b/src/aderarr/core/lab/products.py @@ -0,0 +1,60 @@ +import inspect +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from typing import Any + +from aderarr.core.proto.errors import ContractError +from aderarr.core.proto.products.mark import PRODUCT_MARK +from aderarr.core.proto.recipe.mark import RECIPE_MARK +from aderarr.core.proto.recipe.reagents.base import Reagent + + +@dataclass(frozen=True, slots=True) +class RecipeMeta: + product: str + method: str + reagents: tuple[type[Reagent], ...] + + +def _marks(obj: object) -> Mapping[str, Any]: + return getattr(obj, "__dict__", {}) + + +def _members(cls: type) -> Iterator[tuple[str, object]]: + for base in cls.__mro__: + yield from vars(base).items() + + +def _declared_products(cls: type) -> dict[str, object]: + declared: dict[str, object] = {} + for name, attr in _members(cls): + if ( + _marks(attr).get(PRODUCT_MARK) + and declared.setdefault(name, attr) is not attr + ): + msg = f"{cls.__qualname__}: product {name} is declared twice" + raise ContractError(msg) + return declared + + +def collect_recipes(cls: type) -> Mapping[str, RecipeMeta]: + declared = _declared_products(cls) + for name, attr in _members(cls): + if RECIPE_MARK in _marks(attr) and name not in declared: + msg = f"{cls.__qualname__}.{name}: recipe is not a product" + raise ContractError(msg) + + recipes: dict[str, RecipeMeta] = {} + for name, declaration in declared.items(): + impl = getattr(cls, name) + if impl is declaration: + continue + reagents = _marks(impl).get(RECIPE_MARK) + if reagents is None or not inspect.isasyncgenfunction(impl): + msg = ( + f"{cls.__qualname__}.{name}: product needs an async generator + @recipe" + ) + raise ContractError(msg) + product_name = getattr(declaration, "__qualname__", name) + recipes[product_name] = RecipeMeta(product_name, name, reagents) + return recipes diff --git a/src/aderarr/core/proto/__init__.py b/src/aderarr/core/proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/dealer.py b/src/aderarr/core/proto/dealer.py new file mode 100644 index 0000000..68d03db --- /dev/null +++ b/src/aderarr/core/proto/dealer.py @@ -0,0 +1,14 @@ +from typing import ClassVar + +from aderarr.core.proto.products.catalog import CatalogProducts +from aderarr.core.proto.products.lyrics import LyricsProducts +from aderarr.core.proto.products.match import MatchProducts +from aderarr.core.proto.products.media import MediaProducts +from aderarr.core.proto.products.search import SearchProducts + + +class Dealer( + SearchProducts, CatalogProducts, MatchProducts, LyricsProducts, MediaProducts +): + id: ClassVar[str] + base_url: ClassVar[str | None] = None diff --git a/src/aderarr/core/proto/deliverables/__init__.py b/src/aderarr/core/proto/deliverables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/deliverables/outcome.py b/src/aderarr/core/proto/deliverables/outcome.py new file mode 100644 index 0000000..6b26c46 --- /dev/null +++ b/src/aderarr/core/proto/deliverables/outcome.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass +from typing import Any + +from aderarr.core.proto.deliverables.stream import Stream +from aderarr.core.proto.models.media import Quality +from aderarr.core.proto.recipe.reagents.base import Reagent +from aderarr.core.proto.recipe.reagents.human import AskHuman + + +@dataclass(frozen=True, slots=True) +class Ok[T]: + value: T + + +@dataclass(frozen=True, slots=True) +class Streaming: + stream: Stream + + +@dataclass(frozen=True, slots=True) +class Degraded[T]: + outcome: Ok[T] | Streaming + why: str + got: Quality + wanted: Quality + + +@dataclass(frozen=True, slots=True) +class RateLimited: + retry_after: float + + +@dataclass(frozen=True, slots=True) +class NeedsAuth: + reagent: AskHuman | None + + +@dataclass(frozen=True, slots=True) +class Unresolved: + missing: tuple[type[Reagent], ...] + + +@dataclass(frozen=True, slots=True) +class Expired: + partial: Any = None + + +@dataclass(frozen=True, slots=True) +class Fatal: + error: Exception + + +type Outcome[T] = ( + Ok[T] + | Streaming + | Degraded[T] + | RateLimited + | NeedsAuth + | Unresolved + | Expired + | Fatal +) diff --git a/src/aderarr/core/proto/deliverables/stream.py b/src/aderarr/core/proto/deliverables/stream.py new file mode 100644 index 0000000..12e42cd --- /dev/null +++ b/src/aderarr/core/proto/deliverables/stream.py @@ -0,0 +1,11 @@ +from collections.abc import AsyncIterator +from typing import Protocol + +from aderarr.core.proto.models.media import MediaInfo + + +class Stream(AsyncIterator[bytes], Protocol): + @property + def media(self) -> MediaInfo | None: ... + + async def aclose(self) -> None: ... diff --git a/src/aderarr/core/proto/errors.py b/src/aderarr/core/proto/errors.py new file mode 100644 index 0000000..f0e2917 --- /dev/null +++ b/src/aderarr/core/proto/errors.py @@ -0,0 +1,43 @@ +from aderarr.core.proto.recipe.reagents.base import Reagent + + +class AderarrError(Exception): + pass + + +class CoreError(AderarrError): + pass + + +class UnresolvableError(CoreError): + def __init__(self, missing: tuple[type[Reagent], ...]) -> None: + super().__init__(missing) + self.missing = missing + + +class DeadlineExceededError(CoreError): + pass + + +class RateLimitedError(CoreError): + def __init__(self, retry_after: float) -> None: + super().__init__(retry_after) + self.retry_after = retry_after + + +class TransientError(AderarrError): + def __init__(self, retry_after: float | None = None) -> None: + super().__init__(retry_after) + self.retry_after = retry_after + + +class SemanticError(AderarrError): + pass + + +class ContractError(AderarrError): + pass + + +class NoBudgetError(AderarrError): + pass diff --git a/src/aderarr/core/proto/models/__init__.py b/src/aderarr/core/proto/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/models/catalog.py b/src/aderarr/core/proto/models/catalog.py new file mode 100644 index 0000000..9b61873 --- /dev/null +++ b/src/aderarr/core/proto/models/catalog.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Artist: + provider: str + id: str + name: str + + +@dataclass(frozen=True, slots=True) +class Track: + provider: str + id: str + title: str + artists: tuple[Artist, ...] + duration_ms: int | None = None + isrc: str | None = None + + +@dataclass(frozen=True, slots=True) +class Release: + provider: str + id: str + title: str + artists: tuple[Artist, ...] + duration_ms: int | None = None + upc: str | None = None + + +@dataclass(frozen=True, slots=True) +class LyricLine: + start_ms: int + text: str + + +@dataclass(frozen=True, slots=True) +class Lyrics: + text: str + synced: tuple[LyricLine, ...] = () diff --git a/src/aderarr/core/proto/models/media.py b/src/aderarr/core/proto/models/media.py new file mode 100644 index 0000000..b97b8f2 --- /dev/null +++ b/src/aderarr/core/proto/models/media.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Quality: + container: str + codec: str + lossless: bool + bitrate: int | None = None + bit_depth: int | None = None + sample_rate: int | None = None + + def _rank(self) -> tuple[bool, int, int]: + if self.lossless: + return True, self.bit_depth or 0, self.sample_rate or 0 + return False, self.bitrate or 0, 0 + + def __lt__(self, other: object) -> bool: + if not isinstance(other, Quality): + return NotImplemented + return self._rank() < other._rank() + + def __le__(self, other: object) -> bool: + if not isinstance(other, Quality): + return NotImplemented + return self._rank() <= other._rank() + + def __gt__(self, other: object) -> bool: + if not isinstance(other, Quality): + return NotImplemented + return self._rank() > other._rank() + + def __ge__(self, other: object) -> bool: + if not isinstance(other, Quality): + return NotImplemented + return self._rank() >= other._rank() + + +@dataclass(frozen=True, slots=True) +class MediaInfo: + quality: Quality + size: int | None = None + duration_ms: int | None = None diff --git a/src/aderarr/core/proto/models/secret.py b/src/aderarr/core/proto/models/secret.py new file mode 100644 index 0000000..5bc89c7 --- /dev/null +++ b/src/aderarr/core/proto/models/secret.py @@ -0,0 +1,26 @@ +import hmac + + +def _raw(value: str | bytes) -> bytes: + return value.encode() if isinstance(value, str) else value + + +class Secret[T: str | bytes]: + __slots__ = ("_value",) + + def __init__(self, value: T) -> None: + self._value = value + + def get(self) -> T: + return self._value + + def __repr__(self) -> str: + return "Secret(***)" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, type(self)): + return NotImplemented + return hmac.compare_digest(_raw(self._value), _raw(other.get())) + + def __hash__(self) -> int: + raise TypeError diff --git a/src/aderarr/core/proto/products/__init__.py b/src/aderarr/core/proto/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/products/catalog.py b/src/aderarr/core/proto/products/catalog.py new file mode 100644 index 0000000..11949f8 --- /dev/null +++ b/src/aderarr/core/proto/products/catalog.py @@ -0,0 +1,21 @@ +from aderarr.core.proto.models.catalog import Artist, Release, Track +from aderarr.core.proto.products.mark import product +from aderarr.core.proto.recipe.mark import Recipe + + +class CatalogProducts: + @product + def resolve(self, url: str) -> Recipe[Track | Release | Artist]: + raise NotImplementedError + + @product + def release_tracks(self, release: Release) -> Recipe[tuple[Track, ...]]: + raise NotImplementedError + + @product + def artist_releases(self, artist: Artist) -> Recipe[tuple[Release, ...]]: + raise NotImplementedError + + @product + def metadata(self, track: Track) -> Recipe[Track]: + raise NotImplementedError diff --git a/src/aderarr/core/proto/products/lyrics.py b/src/aderarr/core/proto/products/lyrics.py new file mode 100644 index 0000000..ee944be --- /dev/null +++ b/src/aderarr/core/proto/products/lyrics.py @@ -0,0 +1,9 @@ +from aderarr.core.proto.models.catalog import Lyrics, Track +from aderarr.core.proto.products.mark import product +from aderarr.core.proto.recipe.mark import Recipe + + +class LyricsProducts: + @product + def lyrics(self, track: Track) -> Recipe[Lyrics]: + raise NotImplementedError diff --git a/src/aderarr/core/proto/products/mark.py b/src/aderarr/core/proto/products/mark.py new file mode 100644 index 0000000..634f1c3 --- /dev/null +++ b/src/aderarr/core/proto/products/mark.py @@ -0,0 +1,9 @@ +from collections.abc import Callable +from typing import Any + +PRODUCT_MARK = "__aderarr_product__" + + +def product[F: Callable[..., Any]](method: F) -> F: + vars(method)[PRODUCT_MARK] = True + return method diff --git a/src/aderarr/core/proto/products/match.py b/src/aderarr/core/proto/products/match.py new file mode 100644 index 0000000..3f9f5c9 --- /dev/null +++ b/src/aderarr/core/proto/products/match.py @@ -0,0 +1,13 @@ +from aderarr.core.proto.models.catalog import Release, Track +from aderarr.core.proto.products.mark import product +from aderarr.core.proto.recipe.mark import Recipe + + +class MatchProducts: + @product + def match_track(self, track: Track) -> Recipe[Track]: + raise NotImplementedError + + @product + def match_release(self, release: Release) -> Recipe[Release]: + raise NotImplementedError diff --git a/src/aderarr/core/proto/products/media.py b/src/aderarr/core/proto/products/media.py new file mode 100644 index 0000000..3cd4e9b --- /dev/null +++ b/src/aderarr/core/proto/products/media.py @@ -0,0 +1,14 @@ +from aderarr.core.proto.models.catalog import Release, Track +from aderarr.core.proto.models.media import Quality +from aderarr.core.proto.products.mark import product +from aderarr.core.proto.recipe.mark import Delivery + + +class MediaProducts: + @product + def cover(self, release: Release) -> Delivery: + raise NotImplementedError + + @product + def download(self, track: Track, *, quality: Quality | None = None) -> Delivery: + raise NotImplementedError diff --git a/src/aderarr/core/proto/products/search.py b/src/aderarr/core/proto/products/search.py new file mode 100644 index 0000000..bcb32da --- /dev/null +++ b/src/aderarr/core/proto/products/search.py @@ -0,0 +1,17 @@ +from aderarr.core.proto.models.catalog import Artist, Release, Track +from aderarr.core.proto.products.mark import product +from aderarr.core.proto.recipe.mark import Recipe + + +class SearchProducts: + @product + def search_tracks(self, query: str) -> Recipe[tuple[Track, ...]]: + raise NotImplementedError + + @product + def search_releases(self, query: str) -> Recipe[tuple[Release, ...]]: + raise NotImplementedError + + @product + def search_artists(self, query: str) -> Recipe[tuple[Artist, ...]]: + raise NotImplementedError diff --git a/src/aderarr/core/proto/recipe/__init__.py b/src/aderarr/core/proto/recipe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/recipe/mark.py b/src/aderarr/core/proto/recipe/mark.py new file mode 100644 index 0000000..3f26d90 --- /dev/null +++ b/src/aderarr/core/proto/recipe/mark.py @@ -0,0 +1,20 @@ +from collections.abc import AsyncGenerator, Callable +from typing import Any + +from aderarr.core.proto.recipe.reagents.base import Reagent +from aderarr.core.proto.recipe.terminals import Deliver, Done + +type Recipe[T] = AsyncGenerator[Reagent | Done[T], Any] +type Delivery = AsyncGenerator[Reagent | Deliver, Any] + +RECIPE_MARK = "__aderarr_recipe__" + + +def recipe[F: Callable[..., Any]]( + *, reagents: tuple[type[Reagent], ...] +) -> Callable[[F], F]: + def mark(method: F) -> F: + vars(method)[RECIPE_MARK] = reagents + return method + + return mark diff --git a/src/aderarr/core/proto/recipe/reagents/__init__.py b/src/aderarr/core/proto/recipe/reagents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/recipe/reagents/base.py b/src/aderarr/core/proto/recipe/reagents/base.py new file mode 100644 index 0000000..e4fe26f --- /dev/null +++ b/src/aderarr/core/proto/recipe/reagents/base.py @@ -0,0 +1,2 @@ +class Reagent: + __slots__ = () diff --git a/src/aderarr/core/proto/recipe/reagents/http.py b/src/aderarr/core/proto/recipe/reagents/http.py new file mode 100644 index 0000000..6426a09 --- /dev/null +++ b/src/aderarr/core/proto/recipe/reagents/http.py @@ -0,0 +1,17 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from aderarr.core.proto.recipe.reagents.base import Reagent + + +@dataclass(frozen=True, slots=True) +class Http(Reagent): + method: str + url: str + params: Mapping[str, str] | None = field(default=None, repr=False) + headers: Mapping[str, str] | None = field(default=None, repr=False) + json: Any = field(default=None, repr=False) + data: bytes | Mapping[str, str] | None = field(default=None, repr=False) + expect: tuple[int, ...] = (200,) + stream: bool = False diff --git a/src/aderarr/core/proto/recipe/reagents/human.py b/src/aderarr/core/proto/recipe/reagents/human.py new file mode 100644 index 0000000..84927d8 --- /dev/null +++ b/src/aderarr/core/proto/recipe/reagents/human.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Literal + +from aderarr.core.proto.recipe.reagents.base import Reagent + + +@dataclass(frozen=True, slots=True) +class AskHuman(Reagent): + prompt: str + kind: Literal["otp", "captcha", "url_callback", "text"] diff --git a/src/aderarr/core/proto/recipe/reagents/session.py b/src/aderarr/core/proto/recipe/reagents/session.py new file mode 100644 index 0000000..f2b396a --- /dev/null +++ b/src/aderarr/core/proto/recipe/reagents/session.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Literal + +from aderarr.core.proto.recipe.reagents.base import Reagent + + +@dataclass(frozen=True, slots=True) +class Invalidate(Reagent): + what: Literal["session"] diff --git a/src/aderarr/core/proto/recipe/reagents/transform.py b/src/aderarr/core/proto/recipe/reagents/transform.py new file mode 100644 index 0000000..dc4e5ee --- /dev/null +++ b/src/aderarr/core/proto/recipe/reagents/transform.py @@ -0,0 +1,11 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from aderarr.core.proto.recipe.reagents.base import Reagent + + +@dataclass(frozen=True, slots=True) +class Transform(Reagent): + kind: str + params: Mapping[str, Any] = field(repr=False) diff --git a/src/aderarr/core/proto/recipe/terminals.py b/src/aderarr/core/proto/recipe/terminals.py new file mode 100644 index 0000000..02bfef3 --- /dev/null +++ b/src/aderarr/core/proto/recipe/terminals.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + +from aderarr.core.proto.models.media import MediaInfo +from aderarr.core.proto.recipe.reagents.base import Reagent +from aderarr.core.proto.recipe.reagents.transform import Transform + + +@dataclass(frozen=True, slots=True) +class Done[T]: + value: T + + +@dataclass(frozen=True, slots=True) +class Deliver: + source: str | Reagent + transforms: tuple[Transform, ...] = () + media: MediaInfo | None = None diff --git a/src/aderarr/core/proto/supply/__init__.py b/src/aderarr/core/proto/supply/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aderarr/core/proto/supply/budget.py b/src/aderarr/core/proto/supply/budget.py new file mode 100644 index 0000000..0ab2f03 --- /dev/null +++ b/src/aderarr/core/proto/supply/budget.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Budget: + first_byte: float + overall: float diff --git a/src/aderarr/core/proto/supply/identity.py b/src/aderarr/core/proto/supply/identity.py new file mode 100644 index 0000000..b5e2c11 --- /dev/null +++ b/src/aderarr/core/proto/supply/identity.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from aderarr.core.proto.models.secret import Secret + + +@dataclass(frozen=True, slots=True) +class Identity: + id: str + provider_id: str + impersonate: str + user_agent: str + proxy: Secret[str] | None = None diff --git a/src/aderarr/core/proto/supply/store.py b/src/aderarr/core/proto/supply/store.py new file mode 100644 index 0000000..5f8d304 --- /dev/null +++ b/src/aderarr/core/proto/supply/store.py @@ -0,0 +1,20 @@ +from contextlib import AbstractAsyncContextManager +from typing import Protocol + + +class Store(Protocol): + async def get(self, ns: str, key: str) -> bytes | None: ... + + async def set( + self, ns: str, key: str, value: bytes, *, ttl: float | None = None + ) -> None: ... + + async def delete(self, ns: str, key: str) -> None: ... + + async def incr( + self, ns: str, key: str, delta: int = 1, *, ttl: float | None = None + ) -> int: ... + + def lock( + self, ns: str, key: str, *, ttl: float + ) -> AbstractAsyncContextManager[bool]: ... diff --git a/src/aderarr/core/proto/supply/supplier.py b/src/aderarr/core/proto/supply/supplier.py new file mode 100644 index 0000000..4ada1a2 --- /dev/null +++ b/src/aderarr/core/proto/supply/supplier.py @@ -0,0 +1,30 @@ +import logging +from dataclasses import dataclass +from typing import Literal, Protocol + +from aderarr.core.proto.recipe.reagents.base import Reagent +from aderarr.core.proto.supply.budget import Budget +from aderarr.core.proto.supply.identity import Identity +from aderarr.core.proto.supply.store import Store + + +@dataclass(frozen=True, slots=True) +class ExecCtx: + identity: Identity + budget: Budget + store: Store + namespace: str + logger: logging.Logger + + +class Supplier[R: Reagent](Protocol): + @property + def reagent(self) -> type[R]: ... + + @property + def execution(self) -> Literal["inproc", "subprocess", "remote"]: ... + + @property + def cap(self) -> float: ... + + async def perform(self, reagent: R, ctx: ExecCtx) -> object: ... diff --git a/src/aderarr/core/proto/supply/transformer.py b/src/aderarr/core/proto/supply/transformer.py new file mode 100644 index 0000000..59a1f7b --- /dev/null +++ b/src/aderarr/core/proto/supply/transformer.py @@ -0,0 +1,13 @@ +from collections.abc import AsyncIterator +from typing import Protocol + +from aderarr.core.proto.recipe.reagents.transform import Transform + + +class Transformer(Protocol): + @property + def transform_kind(self) -> str: ... + + def wrap( + self, chunks: AsyncIterator[bytes], transform: Transform + ) -> AsyncIterator[bytes]: ... diff --git a/src/aderarr/plugins/__init__.py b/src/aderarr/plugins/__init__.py new file mode 100644 index 0000000..3a37273 --- /dev/null +++ b/src/aderarr/plugins/__init__.py @@ -0,0 +1,40 @@ +from aderarr.core.proto.dealer import Dealer +from aderarr.core.proto.deliverables.stream import Stream +from aderarr.core.proto.errors import SemanticError, TransientError +from aderarr.core.proto.products.mark import product +from aderarr.core.proto.recipe.mark import Delivery, Recipe, recipe +from aderarr.core.proto.recipe.reagents.base import Reagent +from aderarr.core.proto.recipe.reagents.http import Http +from aderarr.core.proto.recipe.reagents.human import AskHuman +from aderarr.core.proto.recipe.reagents.session import Invalidate +from aderarr.core.proto.recipe.reagents.transform import Transform +from aderarr.core.proto.recipe.terminals import Deliver, Done +from aderarr.core.proto.supply.budget import Budget +from aderarr.core.proto.supply.identity import Identity +from aderarr.core.proto.supply.store import Store +from aderarr.core.proto.supply.supplier import ExecCtx, Supplier +from aderarr.core.proto.supply.transformer import Transformer + +__all__ = [ + "AskHuman", + "Budget", + "Dealer", + "Deliver", + "Delivery", + "Done", + "ExecCtx", + "Http", + "Identity", + "Invalidate", + "Reagent", + "Recipe", + "SemanticError", + "Store", + "Stream", + "Supplier", + "Transform", + "Transformer", + "TransientError", + "product", + "recipe", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2a94e46 --- /dev/null +++ b/uv.lock @@ -0,0 +1,72 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aderarr" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "ruff", specifier = ">=0.16.7" }, + { name = "ty", specifier = ">=0.0.80" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "ty" +version = "0.0.80" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, + { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, + { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, +]