diff --git a/.quality-harness.yml b/.quality-harness.yml index d6e78c5..1cd0438 100644 --- a/.quality-harness.yml +++ b/.quality-harness.yml @@ -42,6 +42,7 @@ layers: - name: commands include: - "src/ableton_cli/commands/**/*.py" + - "src/ableton_cli/app_factory.py" - "src/ableton_cli/app.py" - "src/ableton_cli/cli.py" - name: core diff --git a/src/ableton_cli/app.py b/src/ableton_cli/app.py index fc17425..0019f6e 100644 --- a/src/ableton_cli/app.py +++ b/src/ableton_cli/app.py @@ -1,168 +1,5 @@ from __future__ import annotations -import platform -from pathlib import Path -from typing import Annotated +from .app_factory import create_app -import typer - -from . import __version__ -from .commands import ( - arrangement, - batch, - browser, - clip, - device, - effect, - scenes, - session, - setup, - song, - synth, - track, - tracks, - transport, -) -from .config import resolve_settings -from .errors import AppError, ExitCode -from .logging_setup import configure_logging -from .output import OutputMode, emit_human_error, emit_json, error_payload -from .platform_paths import PlatformPaths, PosixPlatformPaths, WindowsPlatformPaths -from .runtime import RuntimeContext - -_COMMAND_MODULES = ( - setup, - batch, - song, - arrangement, - session, - scenes, - tracks, - transport, - track, - clip, - browser, - device, - synth, - effect, -) - - -def _version_callback(value: bool) -> None: - if value: - typer.echo(__version__) - raise typer.Exit(code=ExitCode.SUCCESS.value) - - -def _build_platform_paths_for_current_os() -> PlatformPaths: - detected_os = platform.system().lower() - home = Path.home() - - if detected_os == "windows": - return WindowsPlatformPaths(home=home) - if detected_os == "darwin": - return PosixPlatformPaths( - home=home, - remote_script_relative_dirs=( - ("Music", "Ableton", "User Library", "Remote Scripts"), - ("Documents", "Ableton", "User Library", "Remote Scripts"), - ), - ) - if detected_os == "linux": - return PosixPlatformPaths( - home=home, - remote_script_relative_dirs=(("Ableton", "User Library", "Remote Scripts"),), - ) - - raise AppError( - error_code="UNSUPPORTED_OS", - message=f"Unsupported operating system: {detected_os}", - hint="Use Windows, macOS, or Linux.", - exit_code=ExitCode.EXECUTION_FAILED, - ) - - -def _register_subcommands(app: typer.Typer) -> None: - for command_module in _COMMAND_MODULES: - command_module.register(app) - - -def main( - ctx: typer.Context, - host: Annotated[str | None, typer.Option("--host", help="Remote host")] = None, - port: Annotated[int | None, typer.Option("--port", help="Remote port")] = None, - timeout_ms: Annotated[ - int | None, - typer.Option("--timeout-ms", help="Request timeout in milliseconds"), - ] = None, - protocol_version: Annotated[ - int | None, - typer.Option("--protocol-version", help="Protocol version for CLI/Remote handshake"), - ] = None, - output: Annotated[ - OutputMode, - typer.Option("--output", help="Output mode", case_sensitive=False), - ] = OutputMode.HUMAN, - verbose: Annotated[bool, typer.Option("--verbose", help="Enable verbose logging")] = False, - log_file: Annotated[str | None, typer.Option("--log-file", help="Log file path")] = None, - config: Annotated[Path | None, typer.Option("--config", help="Config file path")] = None, - no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output")] = False, - quiet: Annotated[bool, typer.Option("--quiet", help="Suppress human success output")] = False, - version: Annotated[ - bool, - typer.Option( - "--version", - callback=_version_callback, - is_eager=True, - help="Show version and exit", - ), - ] = False, -) -> None: - del version - - cli_overrides = { - "host": host, - "port": port, - "timeout_ms": timeout_ms, - "log_file": log_file, - "protocol_version": protocol_version, - } - - try: - settings = resolve_settings(cli_overrides=cli_overrides, config_path=config) - platform_paths = _build_platform_paths_for_current_os() - except AppError as exc: - payload = error_payload( - command="bootstrap", - args={}, - code=exc.error_code, - message=exc.message, - hint=exc.hint, - details=exc.details or None, - ) - if output == OutputMode.JSON: - emit_json(payload) - else: - emit_human_error(exc.error_code, exc.message, exc.hint) - raise typer.Exit(code=exc.exit_code.value) from exc - - configure_logging(verbose=verbose, quiet=quiet, log_file=settings.log_file) - - ctx.obj = RuntimeContext( - settings=settings, - platform_paths=platform_paths, - output_mode=output, - quiet=quiet, - no_color=no_color, - ) - - -def create_app() -> typer.Typer: - app = typer.Typer( - help="Control and inspect Ableton Live through a local Remote Script.", - no_args_is_help=True, - add_completion=True, - ) - app.callback()(main) - _register_subcommands(app) - return app +__all__ = ["create_app"] diff --git a/src/ableton_cli/app_factory.py b/src/ableton_cli/app_factory.py new file mode 100644 index 0000000..0b87bec --- /dev/null +++ b/src/ableton_cli/app_factory.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from .bootstrap import build_runtime_context +from .commands import ( + arrangement, + batch, + browser, + clip, + device, + effect, + scenes, + session, + setup, + song, + synth, + track, + tracks, + transport, +) +from .errors import AppError +from .output import OutputMode, emit_human_error, emit_json, error_payload +from .version import version_callback + +_COMMAND_MODULES = ( + setup, + batch, + song, + arrangement, + session, + scenes, + tracks, + transport, + track, + clip, + browser, + device, + synth, + effect, +) + + +def register_commands(app: typer.Typer) -> None: + for command_module in _COMMAND_MODULES: + command_module.register(app) + + +def main( + ctx: typer.Context, + host: Annotated[str | None, typer.Option("--host", help="Remote host")] = None, + port: Annotated[int | None, typer.Option("--port", help="Remote port")] = None, + timeout_ms: Annotated[ + int | None, + typer.Option("--timeout-ms", help="Request timeout in milliseconds"), + ] = None, + protocol_version: Annotated[ + int | None, + typer.Option("--protocol-version", help="Protocol version for CLI/Remote handshake"), + ] = None, + output: Annotated[ + OutputMode, + typer.Option("--output", help="Output mode", case_sensitive=False), + ] = OutputMode.HUMAN, + verbose: Annotated[bool, typer.Option("--verbose", help="Enable verbose logging")] = False, + log_file: Annotated[str | None, typer.Option("--log-file", help="Log file path")] = None, + config: Annotated[Path | None, typer.Option("--config", help="Config file path")] = None, + no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output")] = False, + quiet: Annotated[bool, typer.Option("--quiet", help="Suppress human success output")] = False, + version: Annotated[ + bool, + typer.Option( + "--version", + callback=version_callback, + is_eager=True, + help="Show version and exit", + ), + ] = False, +) -> None: + del version + + try: + ctx.obj = build_runtime_context( + host=host, + port=port, + timeout_ms=timeout_ms, + protocol_version=protocol_version, + output=output, + verbose=verbose, + log_file=log_file, + config=config, + no_color=no_color, + quiet=quiet, + ) + except AppError as exc: + payload = error_payload( + command="bootstrap", + args={}, + code=exc.error_code, + message=exc.message, + hint=exc.hint, + details=exc.details or None, + ) + if output == OutputMode.JSON: + emit_json(payload) + else: + emit_human_error(exc.error_code, exc.message, exc.hint) + raise typer.Exit(code=exc.exit_code.value) from exc + + +def create_app() -> typer.Typer: + app = typer.Typer( + help="Control and inspect Ableton Live through a local Remote Script.", + no_args_is_help=True, + add_completion=True, + ) + app.callback()(main) + register_commands(app) + return app diff --git a/src/ableton_cli/bootstrap.py b/src/ableton_cli/bootstrap.py new file mode 100644 index 0000000..6e2040c --- /dev/null +++ b/src/ableton_cli/bootstrap.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .config import resolve_settings +from .logging_setup import configure_logging +from .output import OutputMode +from .platform_detection import build_platform_paths_for_current_os +from .runtime import RuntimeContext + + +def build_runtime_context( + *, + host: str | None, + port: int | None, + timeout_ms: int | None, + protocol_version: int | None, + output: OutputMode, + verbose: bool, + log_file: str | None, + config: Path | None, + no_color: bool, + quiet: bool, +) -> RuntimeContext: + cli_overrides: dict[str, Any] = { + "host": host, + "port": port, + "timeout_ms": timeout_ms, + "log_file": log_file, + "protocol_version": protocol_version, + } + + settings = resolve_settings(cli_overrides=cli_overrides, config_path=config) + platform_paths = build_platform_paths_for_current_os() + configure_logging(verbose=verbose, quiet=quiet, log_file=settings.log_file) + + return RuntimeContext( + settings=settings, + platform_paths=platform_paths, + output_mode=output, + quiet=quiet, + no_color=no_color, + ) diff --git a/src/ableton_cli/cli.py b/src/ableton_cli/cli.py index b9ae7df..2d2ede4 100644 --- a/src/ableton_cli/cli.py +++ b/src/ableton_cli/cli.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .app import create_app +from .app_factory import create_app app = create_app() diff --git a/src/ableton_cli/platform_detection.py b/src/ableton_cli/platform_detection.py new file mode 100644 index 0000000..2b9dc43 --- /dev/null +++ b/src/ableton_cli/platform_detection.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import platform +from pathlib import Path + +from .errors import AppError, ExitCode +from .platform_paths import PlatformPaths, PosixPlatformPaths, WindowsPlatformPaths + + +def build_platform_paths_for_current_os() -> PlatformPaths: + detected_os = platform.system().lower() + home = Path.home() + + if detected_os == "windows": + return WindowsPlatformPaths(home=home) + if detected_os == "darwin": + return PosixPlatformPaths( + home=home, + remote_script_relative_dirs=( + ("Music", "Ableton", "User Library", "Remote Scripts"), + ("Documents", "Ableton", "User Library", "Remote Scripts"), + ), + ) + if detected_os == "linux": + return PosixPlatformPaths( + home=home, + remote_script_relative_dirs=(("Ableton", "User Library", "Remote Scripts"),), + ) + + raise AppError( + error_code="UNSUPPORTED_OS", + message=f"Unsupported operating system: {detected_os}", + hint="Use Windows, macOS, or Linux.", + exit_code=ExitCode.EXECUTION_FAILED, + ) diff --git a/src/ableton_cli/version.py b/src/ableton_cli/version.py new file mode 100644 index 0000000..8a1e726 --- /dev/null +++ b/src/ableton_cli/version.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import typer + +from . import __version__ +from .errors import ExitCode + + +def version_callback(value: bool) -> None: + if value: + typer.echo(__version__) + raise typer.Exit(code=ExitCode.SUCCESS.value) diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 17b7189..928b15a 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -2,7 +2,7 @@ from typer.testing import CliRunner -import ableton_cli.app as app_module +import ableton_cli.app_factory as app_module def test_create_app_returns_new_instances() -> None: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..9858fe5 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import ableton_cli.bootstrap as bootstrap +from ableton_cli.config import Settings +from ableton_cli.errors import AppError, ExitCode +from ableton_cli.output import OutputMode +from ableton_cli.platform_paths import PosixPlatformPaths + + +def test_build_runtime_context_resolves_dependencies_and_configures_logging( + monkeypatch, tmp_path +) -> None: + settings = Settings( + host="10.0.0.20", + port=12345, + timeout_ms=2500, + log_level="INFO", + log_file="/tmp/resolved.log", + protocol_version=7, + config_path="/tmp/config.toml", + ) + platform_paths = PosixPlatformPaths( + home=Path("/home/test-user"), + remote_script_relative_dirs=(("Ableton", "User Library", "Remote Scripts"),), + ) + seen: dict[str, object] = {} + + def _resolve_settings_stub(*, cli_overrides, config_path): # noqa: ANN001, ANN202 + seen["cli_overrides"] = cli_overrides + seen["config_path"] = config_path + return settings + + monkeypatch.setattr(bootstrap, "resolve_settings", _resolve_settings_stub) + monkeypatch.setattr(bootstrap, "build_platform_paths_for_current_os", lambda: platform_paths) + monkeypatch.setattr( + bootstrap, + "configure_logging", + lambda *, verbose, quiet, log_file: seen.update( + {"verbose": verbose, "quiet": quiet, "log_file": log_file} + ), + ) + + config_path = tmp_path / "config.toml" + runtime = bootstrap.build_runtime_context( + host="127.0.0.1", + port=8765, + timeout_ms=15000, + protocol_version=11, + output=OutputMode.JSON, + verbose=True, + log_file="/tmp/cli-override.log", + config=config_path, + no_color=True, + quiet=False, + ) + + assert runtime.settings is settings + assert runtime.platform_paths is platform_paths + assert runtime.output_mode is OutputMode.JSON + assert runtime.quiet is False + assert runtime.no_color is True + assert seen["config_path"] == config_path + assert seen["cli_overrides"] == { + "host": "127.0.0.1", + "port": 8765, + "timeout_ms": 15000, + "log_file": "/tmp/cli-override.log", + "protocol_version": 11, + } + assert seen["verbose"] is True + assert seen["quiet"] is False + assert seen["log_file"] == "/tmp/resolved.log" + + +def test_build_runtime_context_propagates_bootstrap_errors(monkeypatch) -> None: + expected = AppError( + error_code="CONFIG_INVALID", + message="boom", + hint="fix", + exit_code=ExitCode.CONFIG_INVALID, + ) + + def _raise_error(*, cli_overrides, config_path): # noqa: ANN001, ANN202 + del cli_overrides, config_path + raise expected + + monkeypatch.setattr(bootstrap, "resolve_settings", _raise_error) + + with pytest.raises(AppError) as exc_info: + bootstrap.build_runtime_context( + host=None, + port=None, + timeout_ms=None, + protocol_version=None, + output=OutputMode.HUMAN, + verbose=False, + log_file=None, + config=None, + no_color=False, + quiet=False, + ) + + assert exc_info.value is expected diff --git a/tests/test_cli_json_output.py b/tests/test_cli_json_output.py index 6247964..c419433 100644 --- a/tests/test_cli_json_output.py +++ b/tests/test_cli_json_output.py @@ -51,7 +51,7 @@ def test_ping_unreachable_outputs_json_error(runner, cli_app) -> None: def test_install_remote_script_verify_includes_doctor_result(runner, cli_app, monkeypatch) -> None: - import ableton_cli.app as app_module + import ableton_cli.bootstrap as bootstrap_module from ableton_cli.commands import setup class _PlatformPathsSentinel: @@ -93,7 +93,11 @@ def _run_doctor_stub(settings, *, platform_paths) -> dict[str, object]: "run_doctor", _run_doctor_stub, ) - monkeypatch.setattr(app_module, "_build_platform_paths_for_current_os", lambda: platform_paths) + monkeypatch.setattr( + bootstrap_module, + "build_platform_paths_for_current_os", + lambda: platform_paths, + ) result = runner.invoke( cli_app, @@ -142,9 +146,9 @@ def test_install_skill_outputs_json_envelope(runner, cli_app, monkeypatch) -> No def test_bootstrap_fails_for_unsupported_os(runner, cli_app, monkeypatch) -> None: - import ableton_cli.app as app_module + import ableton_cli.platform_detection as platform_detection_module - monkeypatch.setattr(app_module.platform, "system", lambda: "Solaris") + monkeypatch.setattr(platform_detection_module.platform, "system", lambda: "Solaris") result = runner.invoke( cli_app, diff --git a/tests/test_platform_detection.py b/tests/test_platform_detection.py new file mode 100644 index 0000000..7d72e39 --- /dev/null +++ b/tests/test_platform_detection.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import ableton_cli.platform_detection as platform_detection +from ableton_cli.errors import AppError, ExitCode +from ableton_cli.platform_paths import PosixPlatformPaths, WindowsPlatformPaths + + +def test_build_platform_paths_for_windows(monkeypatch) -> None: + home = Path("/home/test-user") + + class _PathStub: + @staticmethod + def home() -> Path: + return home + + monkeypatch.setattr(platform_detection, "Path", _PathStub) + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Windows") + + result = platform_detection.build_platform_paths_for_current_os() + + assert isinstance(result, WindowsPlatformPaths) + assert result.home == home + + +def test_build_platform_paths_for_macos(monkeypatch) -> None: + home = Path("/home/test-user") + + class _PathStub: + @staticmethod + def home() -> Path: + return home + + monkeypatch.setattr(platform_detection, "Path", _PathStub) + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Darwin") + + result = platform_detection.build_platform_paths_for_current_os() + + assert isinstance(result, PosixPlatformPaths) + assert result.home == home + assert result.remote_script_relative_dirs == ( + ("Music", "Ableton", "User Library", "Remote Scripts"), + ("Documents", "Ableton", "User Library", "Remote Scripts"), + ) + + +def test_build_platform_paths_for_linux(monkeypatch) -> None: + home = Path("/home/test-user") + + class _PathStub: + @staticmethod + def home() -> Path: + return home + + monkeypatch.setattr(platform_detection, "Path", _PathStub) + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Linux") + + result = platform_detection.build_platform_paths_for_current_os() + + assert isinstance(result, PosixPlatformPaths) + assert result.home == home + assert result.remote_script_relative_dirs == (("Ableton", "User Library", "Remote Scripts"),) + + +def test_build_platform_paths_for_unsupported_os_raises_error(monkeypatch) -> None: + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Solaris") + + with pytest.raises(AppError) as exc_info: + platform_detection.build_platform_paths_for_current_os() + + assert exc_info.value.error_code == "UNSUPPORTED_OS" + assert exc_info.value.exit_code == ExitCode.EXECUTION_FAILED diff --git a/tests/test_quality_harness_config.py b/tests/test_quality_harness_config.py new file mode 100644 index 0000000..42c975f --- /dev/null +++ b/tests/test_quality_harness_config.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from ableton_cli.quality_harness.config import load_config + +REPO_ROOT = Path(__file__).resolve().parents[1] +QUALITY_HARNESS_CONFIG = REPO_ROOT / ".quality-harness.yml" + + +def test_commands_layer_includes_app_factory() -> None: + config = load_config(QUALITY_HARNESS_CONFIG) + + commands_layer = next(rule for rule in config.layers.order if rule.name == "commands") + assert "src/ableton_cli/app_factory.py" in commands_layer.include diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..23ae725 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import pytest +import typer + +import ableton_cli.version as version_module +from ableton_cli import __version__ +from ableton_cli.errors import ExitCode + + +def test_version_callback_prints_and_exits(monkeypatch) -> None: + seen: dict[str, str] = {} + monkeypatch.setattr(version_module.typer, "echo", lambda value: seen.setdefault("value", value)) + + with pytest.raises(typer.Exit) as exc_info: + version_module.version_callback(True) + + assert seen["value"] == __version__ + assert exc_info.value.exit_code == ExitCode.SUCCESS.value + + +def test_version_callback_ignores_false() -> None: + version_module.version_callback(False)