V1 CLI Architecture#
This directory contains the modularized CLI implementation for Haniwers v1.
Structure#
cli/
├── __init__.py # Package exports
├── main.py # Entry point with global options
├── config.py # Configuration management commands
├── port.py # Port search command
├── daq.py # Data acquisition command
├── threshold.py # Threshold setting command
├── scan.py # Threshold scanning command
├── fitter.py # Threshold scanning command (planned)
├── mock.py # Mocker management command
└── README.md # This file
Adding New Commands#
Follow these established patterns to add new commands:
Pattern 1: Single Command Module#
For commands with a single action (like daq, scan):
1. Create the command module (cli/new_command.py):
"""New command implementation."""
import typer
from typer import Context
from pathlib import Path
from haniwers.v1.config.loader import ConfigLoader
def new_command(ctx: Context):
"""Brief description of what this command does.
Longer description with details about the command's purpose
and behavior.
"""
config_path = ctx.obj.get("config_path")
typer.echo(f"[v1] NEW_COMMAND start with config: {config_path}")
# Load configuration
try:
loader = ConfigLoader(config_path)
cfg = loader.config
except Exception as e:
msg = f"[ERROR] Failed to load config: {e}"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
# Command logic here
typer.echo("[v1] NEW_COMMAND complete")
2. Register in main.py:
# Add import
from haniwers.v1.cli import config, daq, scan, new_command
# Add registration (after existing commands)
app.command()(new_command.new_command)
3. Create tests (tests/v1/unit/cli/new_command/test_new_command.py):
"""Unit tests for new_command.
What is new_command?
Brief description of the command's purpose.
What are we testing?
- Success cases: normal operation
- Failure cases: error handling
- Edge cases: boundary conditions
Coverage Target: 80%+
"""
import pytest
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from haniwers.v1.cli.main import app
runner = CliRunner()
class TestNewCommand:
"""Test new_command success cases."""
@patch("haniwers.v1.cli.new_command.ConfigLoader")
def test_new_command_success(self, mock_loader, tmp_path, mock_config):
"""Test new_command executes successfully."""
# Arrange
mock_loader.return_value.config = mock_config
config_path = tmp_path / "test.toml"
# Act
result = runner.invoke(app, ["new-command", "--config", str(config_path)])
# Assert
assert result.exit_code == 0
assert "[v1] NEW_COMMAND start" in result.output
mock_loader.assert_called_once_with(config_path)
class TestNewCommandFailures:
"""Test new_command failure cases."""
def test_new_command_missing_config(self, tmp_path):
"""Test handling of missing configuration file."""
# Arrange
missing_file = tmp_path / "does_not_exist.toml"
# Act
result = runner.invoke(app, ["new-command", "--config", str(missing_file)])
# Assert
assert result.exit_code == 1
assert "[ERROR]" in result.output
@pytest.fixture
def mock_config():
"""Create mock configuration for testing."""
config = MagicMock()
# Configure mock attributes as needed
return config
Pattern 2: Multi-Command Module#
For commands with subcommands (like config show, config init):
1. Create the command module (cli/new_group.py):
"""New command group with subcommands."""
import typer
# Create Typer app for this command group
app = typer.Typer()
@app.command()
def subcommand1(ctx: typer.Context):
"""First subcommand description."""
config_path = ctx.obj.get("config_path")
typer.echo(f"[v1] Subcommand1 with config: {config_path}")
@app.command()
def subcommand2(ctx: typer.Context):
"""Second subcommand description."""
config_path = ctx.obj.get("config_path")
typer.echo(f"[v1] Subcommand2 with config: {config_path}")
2. Register in main.py:
# Add import
from haniwers.v1.cli import config, daq, scan, new_group
# Add registration
app.add_typer(new_group.app, name="new-group")
3. Create tests (one file per subcommand):
tests/v1/unit/cli/new_group/test_subcommand1.pytests/v1/unit/cli/new_group/test_subcommand2.py
Testing Guidelines#
Test Organization#
One test file per function/command
Structure: Success Cases → Failure Cases → Edge Cases
Coverage target: 80%+ for all modules
Test Documentation#
Each test file must include:
"""Unit tests for command_name.
What is command_name?
Brief description of what the command does.
What are we testing?
- List of test categories
- Key scenarios covered
- Important edge cases
Coverage Target: 80%+
"""
Mocking Strategy#
Mock external dependencies:
ConfigLoader,run_session, etc.Mock time-dependent functions:
datetime.now()Use
unittest.mock.patchfor isolationCreate fixtures for common mock configurations
Running Tests#
# Run all CLI tests
poetry run pytest tests/v1/unit/cli/
# Run specific command tests
poetry run pytest tests/v1/unit/cli/new_command/
# Run with coverage
poetry run pytest tests/v1/unit/cli/ --cov=haniwers.v1.cli --cov-report=term
Code Quality Standards#
Formatting#
# Format code
poetry run ruff format src/haniwers/v1/cli/
# Check formatting
poetry run ruff format --check src/haniwers/v1/cli/
Linting#
# Run linter
poetry run ruff check src/haniwers/v1/cli/
# Fix auto-fixable issues
poetry run ruff check --fix src/haniwers/v1/cli/
Pre-commit Hooks#
# Run all pre-commit hooks
poetry run pre-commit run --all-files
Architecture Patterns#
Global Options (main.py)#
--env: Environment file path (default: .env.haniwers)--config: Configuration file path (default: hnw.toml)Stored in
ctx.objdictionary for command access
Configuration Loading#
All commands should:
Get config path from
ctx.objUse
ConfigLoaderto load and validate configurationHandle loading errors with helpful messages
Exit with code 1 on errors
Error Handling#
try:
loader = ConfigLoader(config_path)
cfg = loader.config
except Exception as e:
msg = f"[ERROR] Failed to load config: {e}"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
Output Conventions#
Success messages:
typer.echo("[v1] MESSAGE")Error messages:
typer.echo("[ERROR] MESSAGE", err=True)Progress updates:
typer.echo("[v1] STEP description")
Command Options with ConfigLoader Precedence#
When a command has options that can come from both CLI arguments and configuration files, follow this simple rule:
CLI Options > ConfigLoader values > Hardcoded defaults
The easiest way: Load config from files, then override with CLI values in one place (the command function). This keeps precedence logic simple and clear.
The Simple Pattern#
For commands with config loading (like scan serial):
Step 1: Set CLI option defaults to None
def serial(
channel: int = typer.Option(None, "--channel", min=1, max=3),
center: int = typer.Option(None, "--center", min=1, max=1023),
nsteps: int = typer.Option(None, "--nsteps", min=1),
duration: int = typer.Option(None, "--duration", min=1),
port: str = typer.Option(None, "--port"),
config_path: Path = typer.Option(None, "--config"),
) -> None:
Why? None means “user didn’t specify this”. Non-None means “user specified this value”.
Step 2: Load config and override with CLI values
try:
# Load from config file (or use defaults if no file)
# ConfigLoader accepts "config_path=None"
loader = ConfigLoader(config_path)
cfg = loader.config
# Override config values if user specified them
# IMPORTANT: Use 'is not None' not truthiness (to handle 0 as valid)
if nsteps is not None:
cfg.scan.nsteps = nsteps
if duration is not None:
cfg.scan.duration = duration
if port is not None:
cfg.device.port = port
# Now cfg has the final merged values
# Run your scan logic with cfg
except FileNotFoundError as e:
typer.echo(f"[ERROR] Config file not found: {e}", err=True)
raise typer.Exit(code=1)
except Exception as e:
typer.echo(f"[ERROR] {e}", err=True)
raise typer.Exit(code=1)
Step 3: Use the normalized config
After overrides, cfg has all final values. Pass it to your scan functions without worrying about None values.
Key Rules#
Always use
is not None- Never useif nsteps:because 0 is validif nsteps is not None: # ✓ Correct if nsteps: # ✗ Wrong - fails for 0
Override only specified channels - In multi-channel mode, only update channels the user specified
# If user specified --centers "1:280;3:320", only update ch1 and ch3 # Leave ch2 at its config value
One place for all precedence logic - All “CLI vs config” decisions in the command function, nowhere else
Testing Precedence Rules#
For comprehensive testing of CLI option precedence:
class TestSerialOptionPrecedence:
"""Test CLI > ConfigLoader > Hardcoded Default precedence."""
@patch("haniwers.v1.cli.scan.ConfigLoader")
def test_cli_option_overrides_config(self, mock_loader):
"""CLI option takes precedence over config file."""
# Arrange
mock_cfg = MagicMock()
mock_cfg.scan.nsteps = 20 # Config has 20
mock_loader.return_value.config = mock_cfg
# Act: run with --nsteps 30
result = runner.invoke(app, [
"scan", "serial",
"--channel", "1", "--center", "280",
"--nsteps", "30"
])
# Assert: should use 30 (CLI value)
assert mock_cfg.scan.nsteps == 30
@patch("haniwers.v1.cli.scan.ConfigLoader")
def test_config_value_used_when_no_cli(self, mock_loader):
"""ConfigLoader value used when CLI option not specified."""
# Arrange
mock_cfg = MagicMock()
mock_cfg.scan.nsteps = 20 # Config has 20
mock_loader.return_value.config = mock_cfg
# Act: run without --nsteps
result = runner.invoke(app, [
"scan", "serial",
"--channel", "1", "--center", "280"
])
# Assert: should still be 20 (config value, unchanged)
assert mock_cfg.scan.nsteps == 20
def test_hardcoded_default_when_no_cli_or_config(self):
"""Hardcoded default used when both CLI and config missing."""
# If config file doesn't exist, ConfigLoader uses internal defaults
# Verify that command still runs with hardcoded defaults as fallback
pass
Configuration Modes#
When a command supports multiple modes, detect at the CLI layer:
Mode 1: Single-channel CLI:
--channel 1 --center 280 --nsteps 10Mode 2: Multi-channel CLI:
--centers "1:280;2:300;3:320" --nsteps 10Mode 3: Config-only:
--config config.toml(no CLI options)Mode 4: Config + Port override:
--config config.toml --port /dev/ttyUSB0
All modes use the same precedence: CLI values override config values.
Parsing Multi-Value Options#
For options that accept multiple delimited values (like --centers "1:300;2:400;3:500"), create a reusable helper function to parse and validate:
Pattern: Delimiter-separated channel:value pairs
def parse_channel_options(option_str: str) -> dict[str, int]:
"""Parse 'channel:value' pairs separated by semicolons.
Generic parser for any delimited channel option. Can be used for multiple
CLI options with the same "channel:value;channel:value" format.
Input format: "1:300;2:400;3:500"
Output: {"ch1": 300, "ch2": 400, "ch3": 500}
Raises:
ValueError: If format is invalid (wrong delimiter or bad channel/value)
"""
if not option_str:
return {}
pairs: dict[str, int] = {}
for item in option_str.split(";"):
try:
channel_str, value_str = item.split(":")
channel = int(channel_str)
value = int(value_str)
# Validate channel range (1-3 for OSECHI detector)
if not (1 <= channel <= 3):
raise ValueError(f"Channel {channel} out of range 1-3")
pairs[f"ch{channel}"] = value
except ValueError as e:
raise ValueError(f"Invalid option format: {e}") from e
return pairs
Usage in command (for centers):
# In serial() command function:
if centers is not None:
try:
channel_centers = parse_channel_options(centers)
# Apply to config
for ch_key, center_value in channel_centers.items():
cfg.sensors[ch_key].center = center_value
except ValueError as e:
typer.echo(f"[ERROR] {e}", err=True)
raise typer.Exit(code=1)
Key points:
Generic parsing function - Reusable for any “channel:value” delimited option, not just centers
Separate parsing from application - Parse first, validate, then apply to config
Clear error messages - Tell user exactly what went wrong with their input
Normalize output - Return a consistent data structure (dict with “ch1”, “ch2” keys)
Validate ranges - Check channel bounds (1-3) during parsing, validate specific values in caller
Real-World Example: scan serial command#
See src/haniwers/v1/cli/scan.py for the simplified implementation:
ConfigLoader conditional loading (loads config if needed, handles errors)
Direct config override: CLI values override cfg attributes directly
Selective sensor updates (only specified channels)
All precedence logic in one place (the
serial()function)
File Changes Required#
When adding a new command, you need to modify:
Create:
src/haniwers/v1/cli/new_command.py(command implementation)Modify:
src/haniwers/v1/cli/main.py(2 lines: import + registration)Create:
tests/v1/unit/cli/new_command/(test directory)Create:
tests/v1/unit/cli/new_command/test_*.py(test files)
That’s it! Just 2 lines changed in main.py for each new command.
Examples#
Minimal Single Command#
# cli/hello.py
def hello(ctx: typer.Context):
"""Say hello."""
typer.echo("[v1] Hello, World!")
# main.py additions
from haniwers.v1.cli import hello
app.command()(hello.hello)
Command with Configuration#
# cli/process.py
def process(ctx: typer.Context):
"""Process data with configuration."""
config_path = ctx.obj.get("config_path")
loader = ConfigLoader(config_path)
cfg = loader.config
# Use configuration
typer.echo(f"[v1] Processing with workspace: {cfg.daq.workspace}")
# main.py additions
from haniwers.v1.cli import process
app.command()(process.process)
Next Steps#
After implementing your command:
Run tests:
poetry run pytest tests/v1/unit/cli/your_command/Check coverage: Ensure 80%+ coverage
Format code:
poetry run ruff formatRun linting:
poetry run ruff checkRun pre-commit:
poetry run pre-commit run --all-filesUpdate documentation if needed
Create commit with conventional commit format