From 2962a2e088fbeb6f0c52580196a8d3213eef802c Mon Sep 17 00:00:00 2001 From: Matteo Rosati Date: Wed, 22 Apr 2026 16:03:51 +0200 Subject: [PATCH] replace argparse.Namespace plumbing with typed command inputs --- README.md | 8 + chromy/cli_app.py | 226 +++++++++++------- chromy/command_inputs.py | 52 ++++ chromy/handlers/add_data.py | 9 +- chromy/handlers/count_collection.py | 7 +- chromy/handlers/create_collection.py | 7 +- chromy/handlers/delete_collection.py | 28 +-- chromy/handlers/list_collections.py | 5 +- chromy/handlers/query.py | 7 +- ...s.md => 02-_done_-typed-command-inputs.md} | 6 +- pyproject.toml | 5 +- tests/__init__.py | 1 + tests/test_cli_command_inputs.py | 104 ++++++++ tests/test_handlers.py | 170 +++++++++++++ uv.lock | 40 +++- 15 files changed, 560 insertions(+), 115 deletions(-) create mode 100644 chromy/command_inputs.py rename plans/{02-typed-command-inputs.md => 02-_done_-typed-command-inputs.md} (87%) create mode 100644 tests/__init__.py create mode 100644 tests/test_cli_command_inputs.py create mode 100644 tests/test_handlers.py diff --git a/README.md b/README.md index 4b08e28..3c71924 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,14 @@ You can also run it from the source tree without installing the tool: uv run python -m chromy.main --help ``` +## Running Tests + +Run the test suite with pytest: + +```bash +uv run pytest -q +``` + ## Commands ```text diff --git a/chromy/cli_app.py b/chromy/cli_app.py index dd86309..3ee7433 100644 --- a/chromy/cli_app.py +++ b/chromy/cli_app.py @@ -1,11 +1,22 @@ from __future__ import annotations from argparse import Namespace -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass +from typing import Generic, Protocol, TypeVar, assert_never from chromadb.errors import InternalError, NotFoundError +from chromy.command_inputs import ( + AddDataInput, + CommandInput, + CountCollectionInput, + CreateCollectionInput, + DeleteCollectionInput, + DeleteRecordsInput, + ListCollectionsInput, + QueryInput, +) from chromy.handlers.add_data import handle_add_data from chromy.handlers.count_collection import handle_count_collection from chromy.handlers.create_collection import handle_create_collection @@ -17,98 +28,149 @@ from chromy.handlers.list_collections import handle_list_collections from chromy.handlers.query import handle_query -CommandHandler = Callable[[Namespace], int] -ErrorMessageBuilder = Callable[[Namespace], str] +CommandT = TypeVar("CommandT", bound=CommandInput) +CollectionCommandT = TypeVar("CollectionCommandT", bound="HasCollection") +CommandHandler = Callable[[CommandT], int] +ErrorMessageBuilder = Callable[[CommandT, Exception], str] + + +class HasCollection(Protocol): + collection: str @dataclass(frozen=True, slots=True) -class CliErrorHandler: - exception_type: type[BaseException] - message: ErrorMessageBuilder +class CliErrorHandler(Generic[CommandT]): + exception_type: type[Exception] + message: ErrorMessageBuilder[CommandT] -@dataclass(frozen=True, slots=True) -class CommandConfig: - handler: CommandHandler - error_handlers: tuple[CliErrorHandler, ...] = () +def build_command_input(args: Namespace) -> CommandInput: + command = str(args.command) - -COMMANDS: dict[str, CommandConfig] = { - "list-collections": CommandConfig(handler=handle_list_collections), - "create-collection": CommandConfig( - handler=handle_create_collection, - error_handlers=( - CliErrorHandler( - exception_type=InternalError, - message=lambda args: f"Collection '{args.collection}' already exists.", - ), - ), - ), - "delete-collection": CommandConfig( - handler=handle_delete_collection, - error_handlers=( - CliErrorHandler( - exception_type=NotFoundError, - message=lambda args: f"Collection '{args.collection}' does not exist.", - ), - ), - ), - "count": CommandConfig( - handler=handle_count_collection, - error_handlers=( - CliErrorHandler( - exception_type=NotFoundError, - message=lambda args: f"Collection '{args.collection}' does not exist.", - ), - ), - ), - "add-data": CommandConfig( - handler=handle_add_data, - error_handlers=( - CliErrorHandler( - exception_type=NotFoundError, - message=lambda args: f"Collection '{args.collection}' does not exist.", - ), - CliErrorHandler( - exception_type=FileNotFoundError, - message=lambda args: f"The file {args.file} was not found.", - ), - ), - ), - "query": CommandConfig( - handler=handle_query, - error_handlers=( - CliErrorHandler( - exception_type=NotFoundError, - message=lambda args: f"Collection '{args.collection}' does not exist.", - ), - ), - ), - "delete": CommandConfig( - handler=handle_delete_records, - error_handlers=( - CliErrorHandler( - exception_type=NotFoundError, - message=lambda args: f"Collection '{args.collection}' does not exist.", - ), - CliErrorHandler( - exception_type=ValueError, - message=lambda args: str(args.error_message), - ), - ), - ), -} + match command: + case "list-collections": + return ListCollectionsInput() + case "create-collection": + return CreateCollectionInput(collection=str(args.collection)) + case "delete-collection": + return DeleteCollectionInput(collection=str(args.collection)) + case "count": + return CountCollectionInput(collection=str(args.collection)) + case "add-data": + return AddDataInput(collection=str(args.collection), file=str(args.file)) + case "query": + return QueryInput( + collection=str(args.collection), + query_text=str(args.query_text), + ) + case "delete": + return DeleteRecordsInput( + collection=str(args.collection), + where=str(args.where), + ) + case _: + raise ValueError(f"Unknown command: {command}") def execute_command(args: Namespace) -> int: - command = COMMANDS[args.command] - args.error_message = "An unexpected value was provided." + command_input = build_command_input(args) + match command_input: + case ListCollectionsInput(): + return _run_command(command_input, handle_list_collections) + case CreateCollectionInput(): + return _run_command( + command_input, + handle_create_collection, + ( + CliErrorHandler( + exception_type=InternalError, + message=_collection_already_exists_message, + ), + ), + ) + case DeleteCollectionInput(): + return _run_command( + command_input, + handle_delete_collection, + (_collection_not_found_handler(),), + ) + case CountCollectionInput(): + return _run_command( + command_input, + handle_count_collection, + (_collection_not_found_handler(),), + ) + case AddDataInput(): + return _run_command( + command_input, + handle_add_data, + ( + _collection_not_found_handler(), + CliErrorHandler( + exception_type=FileNotFoundError, + message=_file_not_found_message, + ), + ), + ) + case QueryInput(): + return _run_command( + command_input, + handle_query, + (_collection_not_found_handler(),), + ) + case DeleteRecordsInput(): + return _run_command( + command_input, + handle_delete_records, + ( + _collection_not_found_handler(), + CliErrorHandler( + exception_type=ValueError, + message=_exception_message, + ), + ), + ) + + assert_never(command_input) + + +def _run_command( + command_input: CommandT, + handler: CommandHandler[CommandT], + error_handlers: Sequence[CliErrorHandler[CommandT]] = (), +) -> int: try: - return command.handler(args) - except BaseException as exc: - for error_handler in command.error_handlers: + return handler(command_input) + except Exception as exc: + for error_handler in error_handlers: if isinstance(exc, error_handler.exception_type): - print(error_handler.message(args)) + print(error_handler.message(command_input, exc)) return 1 raise + + +def _collection_already_exists_message( + command: CreateCollectionInput, + _: Exception, +) -> str: + return f"Collection '{command.collection}' already exists." + + +def _collection_not_found_handler() -> CliErrorHandler[CollectionCommandT]: + return CliErrorHandler( + exception_type=NotFoundError, + message=_collection_not_found_message, + ) + + +def _collection_not_found_message(command: HasCollection, _: Exception) -> str: + return f"Collection '{command.collection}' does not exist." + + +def _file_not_found_message(command: AddDataInput, _: Exception) -> str: + return f"The file {command.file} was not found." + + +def _exception_message(_: DeleteRecordsInput, exc: Exception) -> str: + return str(exc) diff --git a/chromy/command_inputs.py b/chromy/command_inputs.py new file mode 100644 index 0000000..2a0a3f6 --- /dev/null +++ b/chromy/command_inputs.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ListCollectionsInput: + pass + + +@dataclass(frozen=True, slots=True) +class CreateCollectionInput: + collection: str + + +@dataclass(frozen=True, slots=True) +class DeleteCollectionInput: + collection: str + + +@dataclass(frozen=True, slots=True) +class CountCollectionInput: + collection: str + + +@dataclass(frozen=True, slots=True) +class AddDataInput: + collection: str + file: str + + +@dataclass(frozen=True, slots=True) +class QueryInput: + collection: str + query_text: str + + +@dataclass(frozen=True, slots=True) +class DeleteRecordsInput: + collection: str + where: str + + +CommandInput = ( + ListCollectionsInput + | CreateCollectionInput + | DeleteCollectionInput + | CountCollectionInput + | AddDataInput + | QueryInput + | DeleteRecordsInput +) diff --git a/chromy/handlers/add_data.py b/chromy/handlers/add_data.py index ec434ac..2afec79 100644 --- a/chromy/handlers/add_data.py +++ b/chromy/handlers/add_data.py @@ -1,9 +1,10 @@ -from argparse import Namespace +from __future__ import annotations +from chromy.command_inputs import AddDataInput from chromy.utilities import ingest_file -def handle_add_data(args: Namespace) -> int: - records_added = ingest_file(args.collection, args.file) - print(f"Added {records_added} records to collection '{args.collection}'.") +def handle_add_data(command: AddDataInput) -> int: + records_added = ingest_file(command.collection, command.file) + print(f"Added {records_added} records to collection '{command.collection}'.") return 0 diff --git a/chromy/handlers/count_collection.py b/chromy/handlers/count_collection.py index e34e2dd..004dc9c 100644 --- a/chromy/handlers/count_collection.py +++ b/chromy/handlers/count_collection.py @@ -1,8 +1,9 @@ -from argparse import Namespace +from __future__ import annotations from chromy.chroma_functions import count_collection +from chromy.command_inputs import CountCollectionInput -def handle_count_collection(args: Namespace) -> int: - print(count_collection(args.collection)) +def handle_count_collection(command: CountCollectionInput) -> int: + print(count_collection(command.collection)) return 0 diff --git a/chromy/handlers/create_collection.py b/chromy/handlers/create_collection.py index 9ea063b..050f7e0 100644 --- a/chromy/handlers/create_collection.py +++ b/chromy/handlers/create_collection.py @@ -1,9 +1,10 @@ -from argparse import Namespace +from __future__ import annotations from chromy.chroma_functions import create_collection +from chromy.command_inputs import CreateCollectionInput -def handle_create_collection(args: Namespace) -> int: - collection_name = create_collection(args.collection) +def handle_create_collection(command: CreateCollectionInput) -> int: + collection_name = create_collection(command.collection) print(f"Created collection '{collection_name}'.") return 0 diff --git a/chromy/handlers/delete_collection.py b/chromy/handlers/delete_collection.py index 6f9dea2..214267e 100644 --- a/chromy/handlers/delete_collection.py +++ b/chromy/handlers/delete_collection.py @@ -1,40 +1,38 @@ -from argparse import Namespace +from __future__ import annotations from chromy.chroma_functions import delete_collection, delete_data +from chromy.command_inputs import DeleteCollectionInput, DeleteRecordsInput def _parse_where_clause(where_clause: str) -> dict[str, str]: condition, separator, value = where_clause.partition("=") if separator == "": - raise ValueError("Invalid --where value. Expected =.") + raise ValueError( + "Invalid --where value. Expected =.") condition = condition.strip() value = value.strip() if not condition or not value: - raise ValueError("Invalid --where value. Expected =.") + raise ValueError( + "Invalid --where value. Expected =.") return {condition: value} -def handle_delete_collection(args: Namespace) -> int: - delete_collection(args.collection) - print(f"Deleted collection '{args.collection}'.") +def handle_delete_collection(command: DeleteCollectionInput) -> int: + delete_collection(command.collection) + print(f"Deleted collection '{command.collection}'.") return 0 -def handle_delete_records(args: Namespace) -> int: - try: - where = _parse_where_clause(args.where) - except ValueError as exc: - args.error_message = str(exc) - raise - - deleted = delete_data(args.collection, where) +def handle_delete_records(command: DeleteRecordsInput) -> int: + where = _parse_where_clause(command.where) + deleted = delete_data(command.collection, where) condition, value = next(iter(where.items())) print( - f"Deleted {deleted} record(s) from collection '{args.collection}' " + f"Deleted {deleted} record(s) from collection '{command.collection}' " f"where {condition}={value}." ) return 0 diff --git a/chromy/handlers/list_collections.py b/chromy/handlers/list_collections.py index 7b13bc5..8955953 100644 --- a/chromy/handlers/list_collections.py +++ b/chromy/handlers/list_collections.py @@ -1,10 +1,11 @@ -from argparse import Namespace +from __future__ import annotations +from chromy.command_inputs import ListCollectionsInput from chromy.chroma_functions import list_collections from chromy.utilities import print_lines -def handle_list_collections(_: Namespace) -> int: +def handle_list_collections(_: ListCollectionsInput) -> int: collections = list_collections() if not collections: print("No collections found.") diff --git a/chromy/handlers/query.py b/chromy/handlers/query.py index aa8b79c..972c502 100644 --- a/chromy/handlers/query.py +++ b/chromy/handlers/query.py @@ -1,9 +1,10 @@ -from argparse import Namespace +from __future__ import annotations +from chromy.command_inputs import QueryInput from chromy.utilities import format_query_result, print_lines, run_query -def handle_query(args: Namespace) -> int: - result = run_query(args.collection, args.query_text) +def handle_query(command: QueryInput) -> int: + result = run_query(command.collection, command.query_text) print_lines(format_query_result(result)) return 0 diff --git a/plans/02-typed-command-inputs.md b/plans/02-_done_-typed-command-inputs.md similarity index 87% rename from plans/02-typed-command-inputs.md rename to plans/02-_done_-typed-command-inputs.md index 46a91a2..703bd89 100644 --- a/plans/02-typed-command-inputs.md +++ b/plans/02-_done_-typed-command-inputs.md @@ -1,4 +1,4 @@ -# 2. Replace `argparse.Namespace` Plumbing With Typed Command Inputs +# 2. Replace `argparse.Namespace` Plumbing With Typed Command Inputs [DONE] ## Summary @@ -24,6 +24,10 @@ Stop passing mutable `argparse.Namespace` objects into handlers. Convert parsed - Add parser-to-command conversion tests for every command and alias. - Add handler unit tests that construct command dataclasses directly. - Verify invalid delete filters still produce the same user-facing error. +- Test all commands to verify they still work: + - [creating, listing, deleting] collections + - [adding, deleting] documents to a collection (use [romeo_and_juliet.txt](romeo_and_juliet.txt)) + - querying ## Assumptions diff --git a/pyproject.toml b/pyproject.toml index ee562c8..04ad3b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,4 +25,7 @@ chromy = "chromy.main:main" packages = ["chromy", "chromy.handlers"] [dependency-groups] -dev = ["nuitka[onefile]>=4.0.8"] +dev = [ + "nuitka[onefile]>=4.0.8", + "pytest>=9.0.2", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..38d2e1c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Chromy CLI.""" diff --git a/tests/test_cli_command_inputs.py b/tests/test_cli_command_inputs.py new file mode 100644 index 0000000..f1577c1 --- /dev/null +++ b/tests/test_cli_command_inputs.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import io +import unittest +from argparse import Namespace +from contextlib import redirect_stdout + +from chromy.cli_app import build_command_input, execute_command +from chromy.cli_parser import build_parser +from chromy.command_inputs import ( + AddDataInput, + CountCollectionInput, + CreateCollectionInput, + DeleteCollectionInput, + DeleteRecordsInput, + ListCollectionsInput, + QueryInput, +) + + +class BuildCommandInputTests(unittest.TestCase): + def test_parser_converts_list_collections_and_alias(self) -> None: + self.assertEqual(_parse_input( + ["list-collections"]), ListCollectionsInput()) + self.assertEqual(_parse_input(["lc"]), ListCollectionsInput()) + + def test_parser_converts_create_collection_and_alias(self) -> None: + expected = CreateCollectionInput(collection="notes") + + self.assertEqual(_parse_input( + ["create-collection", "notes"]), expected) + self.assertEqual(_parse_input(["cc", "notes"]), expected) + + def test_parser_converts_delete_collection_and_alias(self) -> None: + expected = DeleteCollectionInput(collection="notes") + + self.assertEqual(_parse_input( + ["delete-collection", "notes"]), expected) + self.assertEqual(_parse_input(["dc", "notes"]), expected) + + def test_parser_converts_count_and_alias(self) -> None: + expected = CountCollectionInput(collection="notes") + + self.assertEqual(_parse_input(["count", "notes"]), expected) + self.assertEqual(_parse_input(["co", "notes"]), expected) + + def test_parser_converts_add_data_and_alias(self) -> None: + expected = AddDataInput( + collection="notes", file="romeo_and_juliet.txt") + + self.assertEqual( + _parse_input(["add-data", "notes", "romeo_and_juliet.txt"]), + expected, + ) + self.assertEqual( + _parse_input(["ad", "notes", "romeo_and_juliet.txt"]), + expected, + ) + + def test_parser_converts_query_and_alias(self) -> None: + expected = QueryInput(collection="notes", query_text="Where is Romeo?") + + self.assertEqual( + _parse_input(["query", "notes", "Where is Romeo?"]), + expected, + ) + self.assertEqual(_parse_input( + ["q", "notes", "Where is Romeo?"]), expected) + + def test_parser_converts_delete_records_and_alias(self) -> None: + expected = DeleteRecordsInput( + collection="notes", where="file_name=play.txt") + + self.assertEqual( + _parse_input(["delete", "notes", "--where", "file_name=play.txt"]), + expected, + ) + self.assertEqual( + _parse_input(["del", "notes", "--where", "file_name=play.txt"]), + expected, + ) + + def test_invalid_delete_filter_keeps_user_facing_error(self) -> None: + args = Namespace(command="delete", collection="notes", + where="file_name") + output = io.StringIO() + + with redirect_stdout(output): + exit_code = execute_command(args) + + self.assertEqual(exit_code, 1) + self.assertEqual( + output.getvalue().strip(), + "Invalid --where value. Expected =.", + ) + self.assertFalse(hasattr(args, "error_message")) + + +def _parse_input(argv: list[str]) -> object: + return build_command_input(build_parser().parse_args(argv)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..e82243f --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import io +import unittest +from collections.abc import Callable +from contextlib import redirect_stdout +from typing import TypeVar +from unittest.mock import patch + +from chromy.command_inputs import ( + AddDataInput, + CountCollectionInput, + CreateCollectionInput, + DeleteCollectionInput, + DeleteRecordsInput, + ListCollectionsInput, + QueryInput, +) +from chromy.handlers.add_data import handle_add_data +from chromy.handlers.count_collection import handle_count_collection +from chromy.handlers.create_collection import handle_create_collection +from chromy.handlers.delete_collection import ( + handle_delete_collection, + handle_delete_records, +) +from chromy.handlers.list_collections import handle_list_collections +from chromy.handlers.query import handle_query + + +CommandT = TypeVar("CommandT") + + +class HandlerTests(unittest.TestCase): + def test_list_collections_prints_empty_message(self) -> None: + with patch("chromy.handlers.list_collections.list_collections", return_value=[]): + exit_code, output = _capture_output( + handle_list_collections, + ListCollectionsInput(), + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(output, "No collections found.\n") + + def test_list_collections_prints_collection_names(self) -> None: + with patch( + "chromy.handlers.list_collections.list_collections", + return_value=["notes", "plays"], + ): + exit_code, output = _capture_output( + handle_list_collections, + ListCollectionsInput(), + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(output, "notes\nplays\n") + + def test_create_collection_uses_typed_input(self) -> None: + with patch( + "chromy.handlers.create_collection.create_collection", + return_value="notes", + ) as create_collection: + exit_code, output = _capture_output( + handle_create_collection, + CreateCollectionInput(collection="notes"), + ) + + create_collection.assert_called_once_with("notes") + self.assertEqual(exit_code, 0) + self.assertEqual(output, "Created collection 'notes'.\n") + + def test_delete_collection_uses_typed_input(self) -> None: + with patch("chromy.handlers.delete_collection.delete_collection") as delete: + exit_code, output = _capture_output( + handle_delete_collection, + DeleteCollectionInput(collection="notes"), + ) + + delete.assert_called_once_with("notes") + self.assertEqual(exit_code, 0) + self.assertEqual(output, "Deleted collection 'notes'.\n") + + def test_count_collection_uses_typed_input(self) -> None: + with patch( + "chromy.handlers.count_collection.count_collection", + return_value=7, + ) as count: + exit_code, output = _capture_output( + handle_count_collection, + CountCollectionInput(collection="notes"), + ) + + count.assert_called_once_with("notes") + self.assertEqual(exit_code, 0) + self.assertEqual(output, "7\n") + + def test_add_data_uses_typed_input(self) -> None: + with patch( + "chromy.handlers.add_data.ingest_file", + return_value=3, + ) as ingest_file: + exit_code, output = _capture_output( + handle_add_data, + AddDataInput(collection="notes", file="romeo_and_juliet.txt"), + ) + + ingest_file.assert_called_once_with("notes", "romeo_and_juliet.txt") + self.assertEqual(exit_code, 0) + self.assertEqual(output, "Added 3 records to collection 'notes'.\n") + + def test_query_uses_typed_input(self) -> None: + query_result = {"ids": [["1"]], "documents": [["hello"]]} + with ( + patch("chromy.handlers.query.run_query", return_value=query_result) as run, + patch( + "chromy.handlers.query.format_query_result", + return_value=["Query results:", "1"], + ) as format_result, + ): + exit_code, output = _capture_output( + handle_query, + QueryInput(collection="notes", query_text="hello"), + ) + + run.assert_called_once_with("notes", "hello") + format_result.assert_called_once_with(query_result) + self.assertEqual(exit_code, 0) + self.assertEqual(output, "Query results:\n1\n") + + def test_delete_records_parses_where_filter(self) -> None: + with patch( + "chromy.handlers.delete_collection.delete_data", + return_value=2, + ) as delete_data: + exit_code, output = _capture_output( + handle_delete_records, + DeleteRecordsInput(collection="notes", + where=" file_name = play.txt "), + ) + + delete_data.assert_called_once_with("notes", {"file_name": "play.txt"}) + self.assertEqual(exit_code, 0) + self.assertEqual( + output, + "Deleted 2 record(s) from collection 'notes' where file_name=play.txt.\n", + ) + + def test_delete_records_rejects_invalid_where_filter(self) -> None: + with self.assertRaisesRegex( + ValueError, + "Invalid --where value. Expected =.", + ): + handle_delete_records( + DeleteRecordsInput(collection="notes", where="file_name") + ) + + +def _capture_output( + handler: Callable[[CommandT], int], + command: CommandT, +) -> tuple[int, str]: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = handler(command) + + return exit_code, output.getvalue() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 1f5d1d0..1ec063a 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "nuitka", extra = ["onefile"] }, + { name = "pytest" }, ] [package.metadata] @@ -282,7 +283,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "nuitka", extras = ["onefile"], specifier = ">=4.0.8" }] +dev = [ + { name = "nuitka", extras = ["onefile"], specifier = ">=4.0.8" }, + { name = "pytest", specifier = ">=9.0.2" }, +] [[package]] name = "click" @@ -559,6 +563,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jiter" version = "0.14.0" @@ -1121,6 +1134,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "protobuf" version = "6.33.6" @@ -1434,6 +1456,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"