replace argparse.Namespace plumbing with typed command inputs

This commit is contained in:
Matteo Rosati
2026-04-22 16:03:51 +02:00
parent 8ebab832d5
commit 2962a2e088
15 changed files with 560 additions and 115 deletions
+8
View File
@@ -85,6 +85,14 @@ You can also run it from the source tree without installing the tool:
uv run python -m chromy.main --help uv run python -m chromy.main --help
``` ```
## Running Tests
Run the test suite with pytest:
```bash
uv run pytest -q
```
## Commands ## Commands
```text ```text
+144 -82
View File
@@ -1,11 +1,22 @@
from __future__ import annotations from __future__ import annotations
from argparse import Namespace from argparse import Namespace
from collections.abc import Callable from collections.abc import Callable, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Generic, Protocol, TypeVar, assert_never
from chromadb.errors import InternalError, NotFoundError 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.add_data import handle_add_data
from chromy.handlers.count_collection import handle_count_collection from chromy.handlers.count_collection import handle_count_collection
from chromy.handlers.create_collection import handle_create_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 from chromy.handlers.query import handle_query
CommandHandler = Callable[[Namespace], int] CommandT = TypeVar("CommandT", bound=CommandInput)
ErrorMessageBuilder = Callable[[Namespace], str] CollectionCommandT = TypeVar("CollectionCommandT", bound="HasCollection")
CommandHandler = Callable[[CommandT], int]
ErrorMessageBuilder = Callable[[CommandT, Exception], str]
class HasCollection(Protocol):
collection: str
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CliErrorHandler: class CliErrorHandler(Generic[CommandT]):
exception_type: type[BaseException] exception_type: type[Exception]
message: ErrorMessageBuilder message: ErrorMessageBuilder[CommandT]
@dataclass(frozen=True, slots=True) def build_command_input(args: Namespace) -> CommandInput:
class CommandConfig: command = str(args.command)
handler: CommandHandler
error_handlers: tuple[CliErrorHandler, ...] = ()
match command:
COMMANDS: dict[str, CommandConfig] = { case "list-collections":
"list-collections": CommandConfig(handler=handle_list_collections), return ListCollectionsInput()
"create-collection": CommandConfig( case "create-collection":
handler=handle_create_collection, return CreateCollectionInput(collection=str(args.collection))
error_handlers=( case "delete-collection":
CliErrorHandler( return DeleteCollectionInput(collection=str(args.collection))
exception_type=InternalError, case "count":
message=lambda args: f"Collection '{args.collection}' already exists.", return CountCollectionInput(collection=str(args.collection))
), case "add-data":
), return AddDataInput(collection=str(args.collection), file=str(args.file))
), case "query":
"delete-collection": CommandConfig( return QueryInput(
handler=handle_delete_collection, collection=str(args.collection),
error_handlers=( query_text=str(args.query_text),
CliErrorHandler( )
exception_type=NotFoundError, case "delete":
message=lambda args: f"Collection '{args.collection}' does not exist.", return DeleteRecordsInput(
), collection=str(args.collection),
), where=str(args.where),
), )
"count": CommandConfig( case _:
handler=handle_count_collection, raise ValueError(f"Unknown command: {command}")
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),
),
),
),
}
def execute_command(args: Namespace) -> int: def execute_command(args: Namespace) -> int:
command = COMMANDS[args.command] command_input = build_command_input(args)
args.error_message = "An unexpected value was provided."
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: try:
return command.handler(args) return handler(command_input)
except BaseException as exc: except Exception as exc:
for error_handler in command.error_handlers: for error_handler in error_handlers:
if isinstance(exc, error_handler.exception_type): if isinstance(exc, error_handler.exception_type):
print(error_handler.message(args)) print(error_handler.message(command_input, exc))
return 1 return 1
raise 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)
+52
View File
@@ -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
)
+5 -4
View File
@@ -1,9 +1,10 @@
from argparse import Namespace from __future__ import annotations
from chromy.command_inputs import AddDataInput
from chromy.utilities import ingest_file from chromy.utilities import ingest_file
def handle_add_data(args: Namespace) -> int: def handle_add_data(command: AddDataInput) -> int:
records_added = ingest_file(args.collection, args.file) records_added = ingest_file(command.collection, command.file)
print(f"Added {records_added} records to collection '{args.collection}'.") print(f"Added {records_added} records to collection '{command.collection}'.")
return 0 return 0
+4 -3
View File
@@ -1,8 +1,9 @@
from argparse import Namespace from __future__ import annotations
from chromy.chroma_functions import count_collection from chromy.chroma_functions import count_collection
from chromy.command_inputs import CountCollectionInput
def handle_count_collection(args: Namespace) -> int: def handle_count_collection(command: CountCollectionInput) -> int:
print(count_collection(args.collection)) print(count_collection(command.collection))
return 0 return 0
+4 -3
View File
@@ -1,9 +1,10 @@
from argparse import Namespace from __future__ import annotations
from chromy.chroma_functions import create_collection from chromy.chroma_functions import create_collection
from chromy.command_inputs import CreateCollectionInput
def handle_create_collection(args: Namespace) -> int: def handle_create_collection(command: CreateCollectionInput) -> int:
collection_name = create_collection(args.collection) collection_name = create_collection(command.collection)
print(f"Created collection '{collection_name}'.") print(f"Created collection '{collection_name}'.")
return 0 return 0
+13 -15
View File
@@ -1,40 +1,38 @@
from argparse import Namespace from __future__ import annotations
from chromy.chroma_functions import delete_collection, delete_data 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]: def _parse_where_clause(where_clause: str) -> dict[str, str]:
condition, separator, value = where_clause.partition("=") condition, separator, value = where_clause.partition("=")
if separator == "": if separator == "":
raise ValueError("Invalid --where value. Expected <condition>=<value>.") raise ValueError(
"Invalid --where value. Expected <condition>=<value>.")
condition = condition.strip() condition = condition.strip()
value = value.strip() value = value.strip()
if not condition or not value: if not condition or not value:
raise ValueError("Invalid --where value. Expected <condition>=<value>.") raise ValueError(
"Invalid --where value. Expected <condition>=<value>.")
return {condition: value} return {condition: value}
def handle_delete_collection(args: Namespace) -> int: def handle_delete_collection(command: DeleteCollectionInput) -> int:
delete_collection(args.collection) delete_collection(command.collection)
print(f"Deleted collection '{args.collection}'.") print(f"Deleted collection '{command.collection}'.")
return 0 return 0
def handle_delete_records(args: Namespace) -> int: def handle_delete_records(command: DeleteRecordsInput) -> int:
try: where = _parse_where_clause(command.where)
where = _parse_where_clause(args.where) deleted = delete_data(command.collection, where)
except ValueError as exc:
args.error_message = str(exc)
raise
deleted = delete_data(args.collection, where)
condition, value = next(iter(where.items())) condition, value = next(iter(where.items()))
print( print(
f"Deleted {deleted} record(s) from collection '{args.collection}' " f"Deleted {deleted} record(s) from collection '{command.collection}' "
f"where {condition}={value}." f"where {condition}={value}."
) )
return 0 return 0
+3 -2
View File
@@ -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.chroma_functions import list_collections
from chromy.utilities import print_lines from chromy.utilities import print_lines
def handle_list_collections(_: Namespace) -> int: def handle_list_collections(_: ListCollectionsInput) -> int:
collections = list_collections() collections = list_collections()
if not collections: if not collections:
print("No collections found.") print("No collections found.")
+4 -3
View File
@@ -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 from chromy.utilities import format_query_result, print_lines, run_query
def handle_query(args: Namespace) -> int: def handle_query(command: QueryInput) -> int:
result = run_query(args.collection, args.query_text) result = run_query(command.collection, command.query_text)
print_lines(format_query_result(result)) print_lines(format_query_result(result))
return 0 return 0
@@ -1,4 +1,4 @@
# 2. Replace `argparse.Namespace` Plumbing With Typed Command Inputs # 2. Replace `argparse.Namespace` Plumbing With Typed Command Inputs [DONE]
## Summary ## 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 parser-to-command conversion tests for every command and alias.
- Add handler unit tests that construct command dataclasses directly. - Add handler unit tests that construct command dataclasses directly.
- Verify invalid delete filters still produce the same user-facing error. - 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 ## Assumptions
+4 -1
View File
@@ -25,4 +25,7 @@ chromy = "chromy.main:main"
packages = ["chromy", "chromy.handlers"] packages = ["chromy", "chromy.handlers"]
[dependency-groups] [dependency-groups]
dev = ["nuitka[onefile]>=4.0.8"] dev = [
"nuitka[onefile]>=4.0.8",
"pytest>=9.0.2",
]
+1
View File
@@ -0,0 +1 @@
"""Tests for the Chromy CLI."""
+104
View File
@@ -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 <condition>=<value>.",
)
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()
+170
View File
@@ -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 <condition>=<value>.",
):
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()
Generated
+39 -1
View File
@@ -268,6 +268,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "nuitka", extra = ["onefile"] }, { name = "nuitka", extra = ["onefile"] },
{ name = "pytest" },
] ]
[package.metadata] [package.metadata]
@@ -282,7 +283,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [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]] [[package]]
name = "click" 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" }, { 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]] [[package]]
name = "jiter" name = "jiter"
version = "0.14.0" 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" }, { 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]] [[package]]
name = "protobuf" name = "protobuf"
version = "6.33.6" 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" }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"