replace argparse.Namespace plumbing with typed command inputs
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Chromy CLI."""
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user