feat(core): scaffold core structure and api typing
This commit is contained in:
1 parent
5ead2cacfe
commit
b64a72da29
42 files changed
+880
-7
No files matched your search
+9
-5
@@ -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
@@ -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",
|
||||
]
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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__}"
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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: ...
|
||||
@@ -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.
@@ -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, ...] = ()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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.
@@ -0,0 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Budget:
|
||||
first_byte: float
|
||||
overall: float
|
||||
@@ -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
|
||||
@@ -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]: ...
|
||||
@@ -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]: ...
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user