From: Axy Date: Fri, 28 Aug 2026 13:55:36 +0000 (+0200) Subject: Improved type checking and docstrings X-Git-Url: https://git.uwuaxy.net/sitemap.xml?a=commitdiff_plain;h=07349a176d7cf3a918a54284c16ba7456018639c;p=axy%2Fft%2Frag.git Improved type checking and docstrings --- diff --git a/.gitignore b/.gitignore index f8555d9..583c352 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ vllm-0.10.1 data moulinette* +.mypy_cache +.ruff_cache diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9757c71 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +install: + uv sync + +run: + uv run rag --help + +debug: + uv run -m -- pdb -m src + +clean: + find src -type d \( -name "__pycache__" -or -name ".mypy_cache" -or -name ".ruff_cache" \) -exec rm -r {} + + +lint: + uv run flake8 src + uv run mypy src --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs + +lint-strict: + uv run flake8 src + uv run mypy src --strict + +check: + uv run ty check + uv run ruff check diff --git a/pyproject.toml b/pyproject.toml index b712de9..801a9a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ "flake8>=7.3.0", "mypy>=2.3.1", "ruff>=0.16.4", + "ty>=0.0.75", "types-tqdm>=4.70.0.20260805", ] @@ -39,7 +40,10 @@ url = "https://download.pytorch.org/whl/cpu" [tool.ruff] line-length = 79 -lint.select = ["E", "F", "UP", "B", "SIM", "I", "ARG", "N"] +lint.select = ["E", "F", "UP", "B", "SIM", "I", "ARG", "N", "D"] + +[tool.ruff.lint.pydocstyle] +convention = "google" [tool.uv] preview-features = ["format", "check"] @@ -47,3 +51,7 @@ preview-features = ["format", "check"] [[tool.mypy.overrides]] module = ["fire"] follow_untyped_imports = true + +[tool.ty.rules] +all = "error" +possibly-missing-import = "ignore" diff --git a/src/__main__.py b/src/__main__.py index 4efdf78..64d4e70 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,3 +1,5 @@ +"""A retrieval augmented generation CLI.""" + from rag import main if __name__ == "__main__": diff --git a/src/rag/__init__.py b/src/rag/__init__.py index 38ddae5..76919b7 100644 --- a/src/rag/__init__.py +++ b/src/rag/__init__.py @@ -1,3 +1,5 @@ +"""A retrieval augmented generation CLI.""" + import logging import os import traceback @@ -13,7 +15,7 @@ from rag.chunking import FileType, chunk_file from rag.evaluate import sources_overlap from rag.models import ( AnswerSet, - RagDataset, + RagQuestions, RetrievedQuestion, SearchAnswers, SearchResults, @@ -23,6 +25,7 @@ from rag.retrieval import BM25, BagOfWords, stopwords def main() -> None: + """Run RAG using python fire, handling all exceptions.""" try: fire.Fire(RAG()) except Exception as e: @@ -38,9 +41,10 @@ key_adapter: pydantic.TypeAdapter[Source] = pydantic.TypeAdapter(Source) class RAG: - """A retrieval augmented generation CLI""" + """A retrieval augmented generation CLI.""" def __init__(self) -> None: + """Set up the caching state.""" self._bm25_store: BM25[Source] | None = None self._inference_store: InferenceBackend | None = None @@ -78,6 +82,7 @@ class RAG: index: str = "data/processed", max_chunk_size: int = 2000, ) -> None: + """Index a directory and its contents for later querying.""" if max_chunk_size < 1: logging.getLogger(__name__).error( f"Invalid max chunk size {max_chunk_size}" @@ -113,7 +118,7 @@ class RAG: return self._bm25_store bm25: BM25[Source] = self._readf( index + "/index.json", - lambda s: BM25.from_storage( + lambda s: BM25[Source].from_storage( (key_adapter.validate_json(k), v) for k, v in tqdm( storage_adapter.validate_json(s).items(), @@ -132,6 +137,7 @@ class RAG: def search( self, query: str, /, k: int = 5, index: str = "data/processed" ) -> None: + """Retrieve sources for a querie.""" if k < 1: logging.getLogger(__name__).error(f"Invalid k {k}") exit(1) @@ -146,11 +152,12 @@ class RAG: k: int = 5, index: str = "data/processed", ) -> None: + """Retrieve sources for queries.""" if k < 1: logging.getLogger(__name__).error(f"Invalid k {k}") exit(1) bm25 = self._bm25(index) - dataset = self._readf(dataset_path, RagDataset.model_validate_json) + dataset = self._readf(dataset_path, RagQuestions.model_validate_json) result = SearchResults( search_results=[ RetrievedQuestion( @@ -183,6 +190,7 @@ class RAG: index: str = "data/processed", max_tokens: int = 100, ) -> None: + """Answer a query, retrieving sources and using an LLM.""" if k < 1: logging.getLogger(__name__).error(f"Invalid k {k}") exit(1) @@ -210,6 +218,7 @@ class RAG: /, max_tokens: int = 100, ) -> None: + """Answer queries given search results, using an LLM.""" if max_tokens < 1: logging.getLogger(__name__).error( f"Invalid max tokens {max_tokens}" @@ -267,6 +276,7 @@ class RAG: k: int = 5, iou: float = 0.05, ) -> None: + """Evaluate the student search results for overlap with dataset.""" if k < 1: logging.getLogger(__name__).error(f"Invalid k {k}") exit(1) diff --git a/src/rag/_stoopid.py b/src/rag/_stoopid.py index 991966e..a0d732f 100644 --- a/src/rag/_stoopid.py +++ b/src/rag/_stoopid.py @@ -1,5 +1,4 @@ -"""Useless module where I can shove the required but overcomplicated and -poorly designed pydantic classes.""" +"""Useless module where I can shove the required poorly pydantic models.""" import uuid diff --git a/src/rag/answering.py b/src/rag/answering.py index 008d13d..acc5898 100644 --- a/src/rag/answering.py +++ b/src/rag/answering.py @@ -1,11 +1,15 @@ +"""Answering questions using LLMs.""" + import logging from collections.abc import Callable -from typing import Any, cast +from typing import Any, cast, override from rag.models import AnsweredQuestion, RetrievedQuestion, Source class InferenceBackend: + """A simple inference backend using transformers.""" + SYSTEM_PROMPT: str = """You are a codebase and document search assistant. You answer querries factually using the provided sources. Answer directly with no reasoning, no tags. @@ -15,12 +19,13 @@ class InferenceBackend: End your answer with a newline.""" def __init__(self, model: str) -> None: + """Initialize the backend fetching the given model from huggingface.""" from transformers import AutoModelForCausalLM, AutoTokenizer self.model: Any = AutoModelForCausalLM.from_pretrained( model, device_map="auto" ) - self.tokenizer = AutoTokenizer.from_pretrained( + self.tokenizer: Any = AutoTokenizer.from_pretrained( model, padding_side="left" ) @@ -64,7 +69,7 @@ class InferenceBackend: max_tokens: int, cb: Callable[[str], None], ) -> str: - from transformers.generation import BaseStreamer # type: ignore + from transformers.generation.streamers import BaseStreamer class Streamer(BaseStreamer): def __init__( @@ -74,6 +79,7 @@ class InferenceBackend: self.cb = cb self.skip = True + @override def put(self, value: Any) -> None: if self.skip: self.skip = False @@ -86,6 +92,7 @@ class InferenceBackend: ) self.cb(s.rstrip("\n")) + @override def end(self) -> None: self.cb("\n") @@ -100,10 +107,13 @@ class InferenceBackend: tokenizer=self.tokenizer, streamer=Streamer(self, cb), ) - full_response = self.tokenizer.batch_decode( - generated_ids, - skip_special_tokens=True, - )[0] + full_response: str = cast( + str, + self.tokenizer.batch_decode( + generated_ids, + skip_special_tokens=True, + )[0], + ) return full_response[len(prompt) :].strip() def answer( @@ -112,6 +122,7 @@ class InferenceBackend: max_tokens: int, cb: Callable[[str], None] = lambda _s: None, ) -> AnsweredQuestion: + """Answers the given question.""" return AnsweredQuestion( question_id=question.question_id, question=question.question, diff --git a/src/rag/chunking.py b/src/rag/chunking.py index e14c6c3..671aee1 100644 --- a/src/rag/chunking.py +++ b/src/rag/chunking.py @@ -1,3 +1,5 @@ +"""Utilities for chunking a file into chunks respecting filetype semantics.""" + import logging from enum import Enum, auto from itertools import count @@ -6,10 +8,12 @@ from rag.models import Source class FileType(Enum): + """A supported file type enum.""" + PYTHON = auto() TEXT = auto() - def line_depth(self, line: str) -> int: + def _line_depth(self, line: str) -> int: match self: case FileType.PYTHON: return next( @@ -30,12 +34,12 @@ class FileType(Enum): ) -def chunk_by_depth( +def _chunk_by_depth( s: str, max_chunk_size: int, by: FileType, layer: int = 0 ) -> list[str]: chunks = [""] for line in s.splitlines(True): - depth = by.line_depth(line) + depth = by._line_depth(line) if depth is None or depth > layer: chunks[-1] += line else: @@ -52,11 +56,11 @@ def chunk_by_depth( s[i : min(len(chunks), i + max_chunk_size)] for i in range(0, len(chunks), max_chunk_size) ] - res = [] + res: list[str] = [] can_extend = False for chunk in chunks: if len(chunk) > max_chunk_size: - res.extend(chunk_by_depth(chunk, max_chunk_size, by, layer + 1)) + res.extend(_chunk_by_depth(chunk, max_chunk_size, by, layer + 1)) can_extend = False elif not can_extend or len(chunk) + len(res[-1]) > max_chunk_size: res.append(chunk) @@ -69,6 +73,7 @@ def chunk_by_depth( def chunk_file( path: str, max_chunk_size: int, by: FileType ) -> dict[Source, str]: + """Appropriately chunk a file of a given filetype into smaller chunks.""" try: with open(path) as f: s = f.read() @@ -77,7 +82,7 @@ def chunk_file( f"Error while chunking file {path}, skipping ({e})" ) return {} - chunks = chunk_by_depth(s, max_chunk_size, by) + chunks = _chunk_by_depth(s, max_chunk_size, by) total_len = 0 res = {} for chunk in chunks: diff --git a/src/rag/evaluate.py b/src/rag/evaluate.py index 090ff5a..296743f 100644 --- a/src/rag/evaluate.py +++ b/src/rag/evaluate.py @@ -1,7 +1,10 @@ +"""Utilities for evaluating search results compared to expected results.""" + from rag.models import Source def sources_overlap(a: Source, b: Source, min_iou: float) -> bool: + """Check whether two sources overlap with a minimum IoU.""" union_start = min(a.first_character_index, b.first_character_index) union_end = max(a.last_character_index, b.last_character_index) + 1 union = union_end - union_start diff --git a/src/rag/models.py b/src/rag/models.py index 4ba9f78..7508598 100644 --- a/src/rag/models.py +++ b/src/rag/models.py @@ -1,14 +1,21 @@ +"""Pydantic models representing the inputs and outputs of the program.""" + import uuid +from typing import override from pydantic import BaseModel, Field class Source(BaseModel, frozen=True): + """A source identifying a file and span within in.""" + file_path: str first_character_index: int last_character_index: int + @override def __str__(self) -> str: + """Represent the source in the suggested format.""" return ( f"{self.file_path}" + f" [{self.first_character_index}:" @@ -17,33 +24,47 @@ class Source(BaseModel, frozen=True): class Question(BaseModel): + """A queried question, without further processing.""" + question_id: str = Field(default_factory=lambda: str(uuid.uuid4())) question: str -class RagDataset(BaseModel): - rag_questions: list[Question] - - class RetrievedQuestion(Question): + """A queried question, with search results.""" + retrieved_sources: list[Source] class AnsweredQuestion(RetrievedQuestion): + """A queried question, with search results and LLM answers.""" + answer: str +class RagQuestions(BaseModel): + """A set of queried question, without further processing.""" + + rag_questions: list[Question] + + class SearchResults(BaseModel): + """A set of queried questions, with search results.""" + search_results: list[RetrievedQuestion] k: int class SearchAnswers(BaseModel): + """A set of queried questions, with search results and LLM answers.""" + search_results: list[AnsweredQuestion] k: int class DatasetAnswer(BaseModel): + """An answer according to the dataset schema.""" + question_id: str question: str answer: str @@ -53,4 +74,6 @@ class DatasetAnswer(BaseModel): class AnswerSet(BaseModel): + """A set of answers according to the dataset schema.""" + rag_questions: list[DatasetAnswer] diff --git a/src/rag/retrieval.py b/src/rag/retrieval.py index e62fb75..fcc6051 100644 --- a/src/rag/retrieval.py +++ b/src/rag/retrieval.py @@ -1,3 +1,5 @@ +"""Retrieval indexing and lookup for a corpus of text.""" + import math from collections.abc import Iterable from dataclasses import dataclass @@ -10,6 +12,7 @@ type BagOfWords = Multiset[str] def words_normalize(s: str) -> Iterable[str]: + """Split a string into normalized words.""" start = 0 for i, c in enumerate(s): if c.isalnum(): @@ -22,6 +25,7 @@ def words_normalize(s: str) -> Iterable[str]: def bag_of_words(s: str, stopwords: set[str]) -> BagOfWords: + """Split a string into a normalized bag of words, ignoring stopwords.""" res: BagOfWords = {} for word in words_normalize(s): if word in stopwords: @@ -31,6 +35,7 @@ def bag_of_words(s: str, stopwords: set[str]) -> BagOfWords: def stopwords(lang: str | Iterable[str]) -> set[str]: + """Return a usable set of normalized stopwords in lang.""" return { word for e in stopwordsiso.stopwords(lang) @@ -40,6 +45,8 @@ def stopwords(lang: str | Iterable[str]) -> set[str]: @dataclass class BM25[T]: + """A BM25 index for a given corpus allowing for fast lookups.""" + corpus_words: int corpus: dict[T, tuple[int, BagOfWords]] word_usage: dict[str, set[T]] @@ -48,6 +55,7 @@ class BM25[T]: @staticmethod def from_storage(storage: Iterable[tuple[T, BagOfWords]]) -> "BM25[T]": + """Load an index from its simplifed representation.""" corpus = {} word_usage: dict[str, set[T]] = {} corpus_words = 0 @@ -62,43 +70,48 @@ class BM25[T]: return BM25(corpus_words, corpus, word_usage) def to_storage(self) -> Iterable[tuple[T, BagOfWords]]: + """Transform an index to its simplified representation.""" return ((k, v[1]) for k, v in self.corpus.items()) @staticmethod def from_corpus( raw_corpus: Iterable[tuple[T, str]], stopwords: set[str] | None = None ) -> "BM25[T]": - return BM25.from_storage( + """Create an index for a given corpus of texts, ignoring stopwrods.""" + return BM25[T].from_storage( (k, bow) for k, s in raw_corpus if (bow := bag_of_words(s, stopwords if stopwords else set())) ) - def idf(self, q: str) -> float: + def _idf(self, q: str) -> float: n_q = len(self.word_usage.get(q, set())) n = len(self.corpus) quotient = (n - n_q + 0.5) / (n_q + 0.5) return math.log(quotient + 1) def word_score(self, word: str, ident: T) -> float: + """Return the score of a document for a single query word.""" doc_words, doc = self.corpus[ident] f_q = doc.get(word, 0) freq = f_q * (self.k + 1) avgdl = self.corpus_words / len(self.corpus) freq_bias = 1 - self.b + self.b * (doc_words / avgdl) quotient = freq / (f_q + self.k * freq_bias) - return self.idf(word) * quotient + return self._idf(word) * quotient - def score(self, querry: str, doc: T) -> float: - return sum(self.word_score(q, doc) for q in words_normalize(querry)) + def score(self, query: str, doc: T) -> float: + """Return the score of a document for a query.""" + return sum(self.word_score(q, doc) for q in words_normalize(query)) def word_scores(self, word: str) -> dict[T, float]: + """Return all the non-zero scoring documents and theirs scores.""" res = {} k_plus_1 = self.k + 1 avgdl = self.corpus_words / len(self.corpus) freq_bias_add = 1 - self.b freq_bias_mul = self.b / avgdl - idf = self.idf(word) + idf = self._idf(word) for ident in self.word_usage.get(word, set()): doc_words, doc = self.corpus[ident] f_q = doc[word] @@ -108,19 +121,18 @@ class BM25[T]: res[ident] = idf * quotient return res - def scores(self, querry: str) -> dict[T, float]: - # Old slow impl: - # return {k: self.score(querry, k) for k in self.corpus} + def scores(self, query: str) -> dict[T, float]: + """Return the non-zero-scoring documents and their score.""" res: dict[T, float] = {} - for word in words_normalize(querry): + for word in words_normalize(query): for k, v in self.word_scores(word).items(): res[k] = res.get(k, 0.0) + v return res - def best_k(self, querry: str, k: int) -> list[T]: - k = min(len(self.corpus), k) + def best_k(self, query: str, k: int) -> list[T]: + """Return the k best non-zero scoring documents in the corpus.""" scores = sorted( - self.scores(querry).items(), + self.scores(query).items(), key=lambda e: e[1], reverse=True, )[:k] diff --git a/uv.lock b/uv.lock index 6c12a84..398cdb1 100644 --- a/uv.lock +++ b/uv.lock @@ -775,6 +775,7 @@ dev = [ { name = "flake8" }, { name = "mypy" }, { name = "ruff" }, + { name = "ty" }, { name = "types-tqdm" }, ] @@ -794,6 +795,7 @@ dev = [ { name = "flake8", specifier = ">=7.3.0" }, { name = "mypy", specifier = ">=2.3.1" }, { name = "ruff", specifier = ">=0.16.4" }, + { name = "ty", specifier = ">=0.0.75" }, { name = "types-tqdm", specifier = ">=4.70.0.20260805" }, ] @@ -1104,6 +1106,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/c4/a12e1d9b387fb0c40a57116db82b457e8c771cb419163cda29204d74a595/transformers-5.15.1-py3-none-any.whl", hash = "sha256:b7cdf238ff583e3a58dbc7fa34da1aaf091ce063141f65a30538160bd5afe93f", size = 11749582, upload-time = "2026-08-19T11:28:16.726Z" }, ] +[[package]] +name = "ty" +version = "0.0.75" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" }, + { url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" }, + { url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" }, + { url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" }, + { url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" }, +] + [[package]] name = "typer" version = "0.27.1"