feat(core): scaffold core structure and api typing

This commit is contained in:
hh committed 2026-09-13 14:34:12 +02:00
1 parent 5ead2cacfe
commit b64a72da29
42 files changed
+880 -7

No files matched your search

+9 -5
View File
@@ -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"
+48 -2
View File
@@ -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",
]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+49
View File
@@ -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
+87
View File
@@ -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)
+25
View File
@@ -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__}"
+60
View File
@@ -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
View File
Whitespace-only changes.
+14
View File
@@ -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
Whitespace-only changes.
@@ -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
)
@@ -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: ...
+43
View File
@@ -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
Whitespace-only changes.
+40
View File
@@ -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, ...] = ()
+43
View File
@@ -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
+26
View File
@@ -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
Whitespace-only changes.
@@ -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
@@ -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
+9
View File
@@ -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
+13
View File
@@ -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
+14
View File
@@ -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
+17
View File
@@ -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
Whitespace-only changes.
+20
View File
@@ -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
Whitespace-only changes.
@@ -0,0 +1,2 @@
class Reagent:
__slots__ = ()
@@ -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
@@ -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"]
@@ -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"]
@@ -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)
@@ -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
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Budget:
first_byte: float
overall: float
+12
View File
@@ -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
+20
View File
@@ -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]: ...
+30
View File
@@ -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: ...
@@ -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]: ...
+40
View File
@@ -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",
]
Generated
+72
View File
@@ -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" },
]