Conversation
Add comprehensive integration tests for the new Typer-based CLI: - tests/cli/test_client.py: BalatroClient.call() tests - Success paths (health, gamestate) - API error handling (INVALID_STATE, BAD_REQUEST) - Connection error handling - Request ID incrementing - URL property formatting - tests/cli/test_api_cmd.py: 'balatrobot api' command tests - Happy path with various methods - Method enum validation (invalid method → exit 2) - JSON param validation (invalid JSON → exit 1) - API error formatting - Connection error formatting - Output format (indented JSON) - tests/cli/test_serve_cmd.py: 'balatrobot serve' command tests - Platform validation - Help text verification - Config.from_kwargs() env var fallback - Main app structure Infrastructure changes: - tests/cli/conftest.py: Add Balatro instance fixtures following tests/lua pattern (parallel instances, per-worker ports) - Makefile: Run CLI tests with parallel workers (-n flag)
There was a problem hiding this comment.
Pull request overview
This PR refactors the CLI from argparse to Typer and adds comprehensive integration tests for the new CLI commands. The refactoring introduces a subcommand-based interface (balatrobot serve and balatrobot api) with enhanced test coverage.
Changes:
- Replaced argparse-based CLI with Typer framework, introducing subcommands
serveandapi - Added
Config.from_kwargs()method to support the new CLI parameter handling - Implemented comprehensive integration tests with parallel test execution support
- Exported
BalatroClientandAPIErrorin the public API for programmatic use
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pyproject.toml | Added typer>=0.15 dependency |
| uv.lock | Updated lock file with typer and transitive dependencies (rich, shellingham, click) |
| src/balatrobot/init.py | Exported APIError and BalatroClient to public API |
| src/balatrobot/cli.py | Removed old argparse-based CLI implementation |
| src/balatrobot/cli/init.py | New Typer app entry point with no_args_is_help |
| src/balatrobot/cli/serve.py | New serve command with platform validation and config building |
| src/balatrobot/cli/client.py | Synchronous JSON-RPC 2.0 client for API calls |
| src/balatrobot/cli/api.py | New api command with Method enum and error handling |
| src/balatrobot/config.py | Added from_kwargs() class method for CLI parameter handling |
| tests/cli/conftest.py | Enhanced with parallel Balatro instance management and fixtures |
| tests/cli/test_serve_cmd.py | Tests for serve command options and validation |
| tests/cli/test_client.py | Integration tests for BalatroClient |
| tests/cli/test_api_cmd.py | Integration tests for api command |
| Makefile | Updated CLI tests to run with parallel workers |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
tests/cli/test_serve_cmd.py
Outdated
| """Running without args shows help (exit code 0 per Typer no_args_is_help).""" | ||
| result = runner.invoke(app, []) | ||
| # Typer no_args_is_help shows help and exits with code 0 | ||
| assert result.exit_code == 0 or result.exit_code == 2 |
There was a problem hiding this comment.
The test case test_no_args_shows_help checks for exit code 0 or 2, which is inconsistent. When Typer's no_args_is_help=True is set, running the command without arguments should consistently exit with code 0. The condition result.exit_code == 0 or result.exit_code == 2 suggests uncertainty about the behavior, but Typer with no_args_is_help=True always exits with code 0 when showing help.
| assert result.exit_code == 0 or result.exit_code == 2 | |
| assert result.exit_code == 0 |
src/balatrobot/cli/api.py
Outdated
| except Exception as e: | ||
| typer.echo(f"Error: Connection failed - {e}", err=True) | ||
| raise typer.Exit(code=1) |
There was a problem hiding this comment.
The broad exception handler catches all exceptions including system exceptions. Consider being more specific about connection-related exceptions (e.g., httpx.ConnectError, httpx.TimeoutException) to avoid masking unexpected errors or system signals like KeyboardInterrupt.
| raise APIError( | ||
| name=error["data"]["name"], | ||
| message=error["message"], | ||
| code=error["code"], |
There was a problem hiding this comment.
The error parsing assumes the error response has a nested structure error["data"]["name"], but this may fail with a KeyError if the API returns an error in a different format or if the "data" or "name" keys are missing. Consider adding defensive checks or using .get() with appropriate defaults.
| raise APIError( | |
| name=error["data"]["name"], | |
| message=error["message"], | |
| code=error["code"], | |
| error_data = error.get("data") if isinstance(error, dict) else None | |
| if isinstance(error_data, dict): | |
| name = error_data.get("name") or "UnknownError" | |
| else: | |
| name = "UnknownError" | |
| message = error.get("message", "Unknown error") | |
| code = error.get("code", -1) | |
| raise APIError( | |
| name=name, | |
| message=message, | |
| code=code, |
| name=error["data"]["name"], | ||
| message=error["message"], | ||
| code=error["code"], | ||
| ) |
There was a problem hiding this comment.
The code assumes data["result"] exists when there's no error, but if the API returns a malformed response without a "result" field, this will raise a KeyError. Consider adding validation to ensure the response contains the expected "result" field.
| ) | |
| ) | |
| if "result" not in data: | |
| raise APIError( | |
| name="InvalidResponse", | |
| message="Response missing 'result' field", | |
| code=-1, | |
| ) |
src/balatrobot/cli/serve.py
Outdated
| try: | ||
| asyncio.run(_serve(config)) | ||
| except KeyboardInterrupt: | ||
| pass |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| pass | |
| typer.echo("\nShutting down server...") | |
| raise typer.Exit(code=0) |
serve and api commands
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/balatrobot/cli/serve.py
Outdated
| async def _serve(config: Config) -> None: | ||
| """Async serve implementation.""" | ||
| async with BalatroInstance(config) as instance: | ||
| print(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") |
There was a problem hiding this comment.
The print() statement should use typer.echo() for consistency with line 100 and the rest of the CLI commands. Using typer.echo() provides better integration with the Typer framework and allows for easier testing and output control.
| print(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") | |
| typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") |
tests/cli/conftest.py
Outdated
| def _check_health(host: str, port: int, timeout: float = 2.0) -> bool: | ||
| """Sync health check.""" | ||
| try: | ||
| client = BalatroClient(host=host, port=port, timeout=timeout) | ||
| client.call("health") | ||
| return True | ||
| except Exception: | ||
| return False |
There was a problem hiding this comment.
The _check_health function is defined but never used in the codebase. Consider removing it if it's not needed, or add a comment explaining its purpose if it's intended for future use or manual debugging.
| def _check_health(host: str, port: int, timeout: float = 2.0) -> bool: | |
| """Sync health check.""" | |
| try: | |
| client = BalatroClient(host=host, port=port, timeout=timeout) | |
| client.call("health") | |
| return True | |
| except Exception: | |
| return False |
| @$(PRINT) "$(YELLOW)Running tests/cli...$(RESET)" | ||
| pytest tests/cli | ||
| @$(PRINT) "$(YELLOW)Running tests/cli with $(XDIST_WORKERS) workers...$(RESET)" | ||
| pytest -n 2 tests/cli |
There was a problem hiding this comment.
The log message says "Running tests/cli with $(XDIST_WORKERS) workers" but the actual command uses a hardcoded value of 2 workers. Consider either using the variable value in the command or updating the message to say "with 2 workers" for consistency.
| pytest -n 2 tests/cli | |
| pytest -n $(XDIST_WORKERS) tests/cli |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if worker_id == "master": | ||
| return ports[0] | ||
|
|
||
| worker_num = int(worker_id.replace("gw", "")) |
There was a problem hiding this comment.
The cli_port fixture assumes worker_num will always be within the bounds of the allocated ports array. If the number of actual xdist workers differs from the expected parallelism, this could raise an IndexError. For example, if pytest is run with -n 3 but only 2 ports are allocated, worker gw2 would try to access ports[2] which doesn't exist. Consider adding bounds checking or ensuring the port allocation matches the actual worker count.
| worker_num = int(worker_id.replace("gw", "")) | |
| worker_num = int(worker_id.replace("gw", "")) | |
| if worker_num >= len(ports): | |
| raise pytest.UsageError( | |
| f"Not enough CLI ports allocated for worker '{worker_id}'. " | |
| f"Got worker index {worker_num}, but only {len(ports)} port(s) are configured: {ports!r}" | |
| ) |
| except json.JSONDecodeError as e: | ||
| typer.echo(f"Error: Invalid JSON params - {e}", err=True) | ||
| raise typer.Exit(code=1) | ||
|
|
There was a problem hiding this comment.
The JSON params validation only checks if the string is valid JSON, but doesn't verify that the parsed result is a dictionary. If a user provides valid JSON that isn't an object (e.g., an array like '[]' or a primitive like '123'), this will be passed to the client which expects a dict. The server may reject this, but it would be better to validate early and provide a clear error message. Consider adding a check after json.loads to ensure params_dict is a dictionary.
| if not isinstance(params_dict, dict): | |
| typer.echo( | |
| "Error: Invalid JSON params - expected a JSON object (e.g. {\"key\": \"value\"})", | |
| err=True, | |
| ) | |
| raise typer.Exit(code=1) |
| from balatrobot.cli.client import APIError, BalatroClient | ||
| from balatrobot.config import Config | ||
| from balatrobot.manager import BalatroInstance | ||
|
|
||
| __version__ = "1.3.4" | ||
| __all__ = ["BalatroInstance", "Config", "__version__"] | ||
| __all__ = ["APIError", "BalatroClient", "BalatroInstance", "Config", "__version__"] |
There was a problem hiding this comment.
The BalatroClient and APIError classes are now exported in the public API (all), but there's no documentation for them in the docs directory. Since these are now part of the public API surface, they should be documented to help users understand how to use them programmatically. Consider adding a section in docs/api.md or docs/example-bot.md showing how to use the BalatroClient for programmatic API access.
Summary
Refactors the CLI from argparse to Typer and adds comprehensive integration tests.
CLI Changes
balatrobot serve- Start Balatro with BalatroBot mod (previously barebalatrobot)balatrobot api <method> [params]- Call API endpoints on a running serverUsage Examples
New Test Files
tests/cli/test_client.pyBalatroClient.call()- success, API errors, connection errorstests/cli/test_api_cmd.pyapicommand - method validation, JSON params, error formattingtests/cli/test_serve_cmd.pyservecommand - platform validation, Config.from_kwargs()Infrastructure
tests/cli/conftest.py: Balatro instance fixtures (parallel instances per worker)Makefile: CLI tests now run with-n $(XDIST_WORKERS)for parallel executionTest Results
Breaking Change
balatrobotalone now shows help. Usebalatrobot serveto start the server.