Coverage for /home/crpier/Projects/snektest/snektest/cli.py: 42%
231 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 00:30 +0300
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 00:30 +0300
1import asyncio
2import json
3import sys
4import threading
5import traceback
6from collections.abc import Callable, Coroutine
7from dataclasses import dataclass, field
8from typing import Literal, cast
10from snektest.agent_docs import (
11 get_agent_docs,
12 get_example_source,
13 get_examples_listing,
14)
15from snektest.collection import TestsQueue, load_tests_from_filters
16from snektest.execution import run_tests
17from snektest.models import (
18 ArgsError,
19 BadRequestError,
20 CollectionError,
21 ErrorResult,
22 FailedResult,
23 FilterItem,
24 PassedResult,
25 TeardownFailure,
26 TestResult,
27 UnreachableError,
28)
29from snektest.presenter import print_error
32def _json_result_status(result: TestResult) -> str:
33 match result.result:
34 case PassedResult():
35 return "passed"
36 case FailedResult():
37 return "failed"
38 case ErrorResult():
39 return "error"
42def build_json_summary(summary: TestRunSummary) -> dict[str, object]:
43 return {
44 "passed": summary.passed,
45 "failed": summary.failed,
46 "errors": summary.errors,
47 "fixture_teardown_failed": summary.fixture_teardown_failed,
48 "session_teardown_failed": summary.session_teardown_failed,
49 "tests": [
50 {
51 "name": str(result.name),
52 "duration": result.duration,
53 "markers": list(result.markers),
54 "status": _json_result_status(result),
55 }
56 for result in summary.test_results
57 ],
58 }
61@dataclass
62class TestRunSummary:
63 """Summary of test run results."""
65 total_tests: int
66 passed: int
67 failed: int
68 errors: int
69 fixture_teardown_failed: int
70 session_teardown_failed: int
71 test_results: list[TestResult]
72 session_teardown_failures: list[TeardownFailure]
75type CliAction = Literal["agent_docs", "help", "list_examples", "show_example"]
78@dataclass(frozen=True)
79class CliOptions:
80 action: CliAction | None = None
81 capture_output: bool = True
82 example_name: str | None = None
83 json_output: bool = False
84 pdb_on_failure: bool = False
85 mark: str | None = None
88@dataclass
89class CliParseState:
90 action: CliAction | None = None
91 capture_output: bool = True
92 example_name: str | None = None
93 json_output: bool = False
94 mark: str | None = None
95 pdb_on_failure: bool = False
96 potential_filter: list[str] = field(default_factory=list[str])
99PARSE_ERROR = -1
100VALID_MARKER_VALUES = {"fast", "medium", "slow"}
102HELP_TEXT = """Usage: snektest [OPTIONS] [FILTER ...]
104Run snektest tests.
106Filters:
107 . Run tests below current directory
108 tests/test_file.py Run one test file
109 tests/test_file.py::test_name Run one test function
110 tests/test_file.py::test_name[param] Run one parameterized case
112Options:
113 -h, --help Show this help message
114 -s Disable stdout/stderr capture
115 --agent-docs Print AI-agent usage guide
116 --llms Alias for --agent-docs
117 --examples List bundled examples
118 --example NAME Print a bundled example
119 --json-output Print machine-readable JSON summary
120 --mark MARK Run tests marked fast, medium, or slow; marking tests is recommended
121 --pdb Drop into post-mortem debugger on first failure
123Example commands:
124 snektest --agent-docs
125 snektest --examples
126 snektest --example async
127 snektest examples
128 snektest example async
129 python -m snektest --agent-docs
130"""
133def _is_valid_mark_value(mark_value: str) -> bool:
134 return mark_value in VALID_MARKER_VALUES
137def _invalid_mark_message(mark_value: str) -> str:
138 allowed = ", ".join(sorted(VALID_MARKER_VALUES))
139 return f"Invalid --mark value: `{mark_value}`. Use one of: {allowed}"
142def _parse_mark_value(
143 argv: list[str], index: int, mark: str | None
144) -> tuple[str, int] | None:
145 if mark is not None:
146 print_error("Only one --mark value is supported")
147 return None
148 if index + 1 >= len(argv):
149 print_error("Missing value for --mark")
150 return None
151 mark_value = argv[index + 1]
152 if mark_value.startswith("-"):
153 print_error("Missing value for --mark")
154 return None
155 if not _is_valid_mark_value(mark_value):
156 print_error(_invalid_mark_message(mark_value))
157 return None
158 return mark_value, index + 1
161def _parse_example_value(argv: list[str], index: int) -> tuple[str, int] | None:
162 if index + 1 >= len(argv):
163 print_error("Missing value for --example")
164 return None
165 example_name = argv[index + 1]
166 if example_name.startswith("-"):
167 print_error("Missing value for --example")
168 return None
169 return example_name, index + 1
172def _set_cli_action(state: CliParseState, new_action: CliAction) -> bool:
173 if state.action is not None:
174 print_error("Only one help/docs/examples command is supported")
175 return False
176 state.action = new_action
177 return True
180def _parse_example_action(argv: list[str], index: int, state: CliParseState) -> int:
181 next_index = PARSE_ERROR
182 if _set_cli_action(state, "show_example"):
183 parsed_example = _parse_example_value(argv, index)
184 if parsed_example is not None:
185 state.example_name, next_index = parsed_example
186 return next_index
189def _parse_action_option(command: str, state: CliParseState) -> int:
190 actions: dict[str, CliAction] = {
191 "--agent-docs": "agent_docs",
192 "--examples": "list_examples",
193 "--help": "help",
194 "--llms": "agent_docs",
195 "-h": "help",
196 }
197 next_index = PARSE_ERROR
198 action = actions.get(command)
199 if action is not None and _set_cli_action(state, action):
200 next_index = 0
201 return next_index
204def _parse_option(argv: list[str], index: int, state: CliParseState) -> int:
205 command = argv[index]
206 next_index = index
207 if command in {"-h", "--agent-docs", "--examples", "--help", "--llms"}:
208 parsed_index = _parse_action_option(command, state)
209 next_index = index if parsed_index != PARSE_ERROR else PARSE_ERROR
210 elif command == "--example":
211 next_index = _parse_example_action(argv, index, state)
212 elif command == "-s":
213 state.capture_output = False
214 elif command == "--json-output":
215 state.json_output = True
216 elif command == "--pdb":
217 state.pdb_on_failure = True
218 elif command == "--mark":
219 parsed_mark = _parse_mark_value(argv, index, state.mark)
220 if parsed_mark is None:
221 next_index = PARSE_ERROR
222 else:
223 state.mark, next_index = parsed_mark
224 else:
225 print_error(f"Invalid option: `{command}`")
226 next_index = PARSE_ERROR
227 return next_index
230def _parse_positional(argv: list[str], index: int, state: CliParseState) -> int:
231 command = argv[index]
232 next_index = index
233 if command == "examples":
234 if not _set_cli_action(state, "list_examples"):
235 next_index = PARSE_ERROR
236 elif command == "example":
237 next_index = _parse_example_action(argv, index, state)
238 else:
239 state.potential_filter.append(command)
240 return next_index
243def _print_cli_action(options: CliOptions) -> int:
244 output = ""
245 if options.action == "help":
246 output = HELP_TEXT
247 elif options.action == "agent_docs":
248 output = get_agent_docs()
249 elif options.action == "list_examples":
250 output = get_examples_listing()
251 elif options.action == "show_example":
252 if options.example_name is None:
253 print_error("Missing example name")
254 return 2
255 try:
256 output = get_example_source(options.example_name)
257 except BadRequestError as e:
258 print_error(str(e))
259 return 2
260 print(output, end="")
261 return 0
264def _finish_parse_state(state: CliParseState) -> tuple[list[str], CliOptions] | int:
265 potential_filter = state.potential_filter
266 if state.action is not None and potential_filter:
267 print_error("Cannot combine help/docs/examples commands with test filters")
268 return 2
270 if state.action is None and not potential_filter:
271 potential_filter.append(".")
273 options = CliOptions(
274 action=state.action,
275 capture_output=state.capture_output,
276 example_name=state.example_name,
277 json_output=state.json_output,
278 pdb_on_failure=state.pdb_on_failure,
279 mark=state.mark,
280 )
281 return potential_filter, options
284def parse_cli_args(argv: list[str]) -> tuple[list[str], CliOptions] | int:
285 state = CliParseState()
287 index = 0
288 while index < len(argv):
289 command = argv[index]
290 if command.startswith("-"):
291 index = _parse_option(argv, index, state)
292 else:
293 index = _parse_positional(argv, index, state)
294 if index == PARSE_ERROR:
295 return 2
296 index += 1
298 return _finish_parse_state(state)
301async def _run_tests_with_producer_thread(
302 filter_items: list[FilterItem],
303 *,
304 capture_output: bool,
305 pdb_on_failure: bool,
306 mark: str | None = None,
307) -> tuple[list[TestResult], list[TeardownFailure]]:
308 queue = TestsQueue()
309 collection_exception: list[BaseException] = []
311 producer_thread = threading.Thread(
312 target=load_tests_from_filters,
313 kwargs={
314 "filter_items": filter_items,
315 "queue": queue,
316 "loop": asyncio.get_running_loop(),
317 "mark": mark,
318 "exception_holder": collection_exception,
319 },
320 )
322 producer_thread.start()
324 try:
325 test_results, session_teardown_failures = await run_tests(
326 queue=queue,
327 capture_output=capture_output,
328 pdb_on_failure=pdb_on_failure,
329 collection_failed=lambda: bool(collection_exception),
330 )
331 finally:
332 producer_thread.join()
333 if collection_exception:
334 raise collection_exception[0]
336 return test_results, session_teardown_failures
339def exit_code_from_summary(summary: TestRunSummary) -> int:
340 has_failures = (
341 summary.failed > 0
342 or summary.errors > 0
343 or summary.fixture_teardown_failed > 0
344 or summary.session_teardown_failed > 0
345 )
346 return 1 if has_failures else 0
349async def run_tests_programmatic(
350 filter_items: list[FilterItem],
351 *,
352 capture_output: bool = True,
353 pdb_on_failure: bool = False,
354 mark: str | None = None,
355) -> TestRunSummary:
356 """Run tests and return structured results instead of printing.
358 This is the programmatic API for testing snektest itself.
359 Returns structured data instead of just printing and exiting.
361 Args:
362 filter_items: List of filter items to run tests from
363 capture_output: Whether to capture test output
365 Returns:
366 TestRunSummary with test results and counts
367 """
368 if mark is not None and not _is_valid_mark_value(mark):
369 raise BadRequestError(_invalid_mark_message(mark))
371 test_results, session_teardown_failures = await _run_tests_with_producer_thread(
372 filter_items,
373 capture_output=capture_output,
374 pdb_on_failure=pdb_on_failure,
375 mark=mark,
376 )
378 return TestRunSummary(
379 total_tests=len(test_results),
380 passed=sum(1 for r in test_results if isinstance(r.result, PassedResult)),
381 failed=sum(1 for r in test_results if isinstance(r.result, FailedResult)),
382 errors=sum(1 for r in test_results if isinstance(r.result, ErrorResult)),
383 fixture_teardown_failed=sum(
384 1 for r in test_results if r.fixture_teardown_failures
385 ),
386 session_teardown_failed=len(session_teardown_failures),
387 test_results=test_results,
388 session_teardown_failures=session_teardown_failures,
389 )
392async def run_script(
393 argv: list[str] | None = None,
394 *,
395 run_tests_programmatic_fn: Callable[..., Coroutine[object, object, object]]
396 | None = None,
397) -> int:
398 """Parse arguments and run tests."""
399 parsed = parse_cli_args(sys.argv[1:] if argv is None else argv)
400 if isinstance(parsed, int):
401 return parsed
403 potential_filter, options = parsed
405 if options.action is not None:
406 return _print_cli_action(options)
408 try:
409 filter_items = [FilterItem(item) for item in potential_filter]
410 except ArgsError as e:
411 print_error(str(e))
412 return 2
414 runner = run_tests_programmatic_fn or run_tests_programmatic
415 try:
416 summary = cast(
417 "TestRunSummary",
418 await runner(
419 filter_items,
420 capture_output=options.capture_output,
421 pdb_on_failure=options.pdb_on_failure,
422 mark=options.mark,
423 ),
424 )
425 except asyncio.CancelledError:
426 return 2
428 if options.json_output:
429 print(json.dumps(build_json_summary(summary)))
431 return exit_code_from_summary(summary)
434def main() -> None:
435 """Main entry point for the CLI."""
436 async_runner = cast("Callable[[object], int]", asyncio.run)
437 sys.exit(main_inner(async_runner=async_runner))
440def main_inner(
441 *,
442 async_runner: Callable[[object], int],
443 argv: list[str] | None = None,
444) -> int:
445 try:
446 coroutine = run_script(argv)
447 return async_runner(coroutine)
448 except CollectionError as e:
449 formatted = "".join(traceback.format_exception(e)).rstrip()
450 print_error(f"Collection error:\n{formatted}")
451 return 2
452 except BadRequestError as e:
453 print_error(f"Bad request error: {e}")
454 return 2
455 except UnreachableError as e:
456 print_error(f"Internal error: {e}")
457 return 2
458 except KeyboardInterrupt:
459 print_error("Interrupted by user")
460 return 2
461 except Exception as e:
462 print_error(f"Unexpected error: {e}")
463 return 1
466if __name__ == "__main__":
467 main()