From 9f80ff0339ff0898777a9df7497b652cf07f041c Mon Sep 17 00:00:00 2001 From: 6uclz1 <9139177+6uclz1@users.noreply.github.com> Date: Wed, 4 Mar 2026 01:43:58 +0900 Subject: [PATCH 1/2] split command adapters and enforce public contract checks --- CONTRIBUTING.md | 18 + .../AbletonCliRemote/command_backend.py | 6 + .../command_backend_contract.py | 23 +- .../command_backend_registry.py | 4 +- .../live_backend_parts/base.py | 15 +- remote_script/AbletonCliRemote/server.py | 14 +- src/ableton_cli/capabilities.py | 12 +- src/ableton_cli/client/_client_core.py | 65 +-- src/ableton_cli/client/backends.py | 84 +++ src/ableton_cli/client/protocol.py | 20 +- src/ableton_cli/client/transport.py | 42 +- .../commands/_arrangement_clip_commands.py | 223 +++++++ .../commands/_arrangement_notes_commands.py | 298 ++++++++++ .../commands/_arrangement_record_commands.py | 39 ++ .../commands/_arrangement_session_commands.py | 49 ++ .../commands/_arrangement_shared.py | 43 ++ .../commands/_arrangement_specs.py | 5 + .../commands/_client_command_runner.py | 70 +++ .../commands/_track_arm_commands.py | 50 ++ .../commands/_track_info_commands.py | 30 + .../commands/_track_mute_commands.py | 50 ++ .../commands/_track_name_commands.py | 36 ++ .../commands/_track_panning_commands.py | 51 ++ src/ableton_cli/commands/_track_shared.py | 17 + .../commands/_track_solo_commands.py | 50 ++ src/ableton_cli/commands/_track_specs.py | 22 + .../commands/_track_volume_commands.py | 51 ++ src/ableton_cli/commands/_validation.py | 4 +- src/ableton_cli/commands/arrangement.py | 548 ++---------------- src/ableton_cli/commands/batch.py | 14 +- src/ableton_cli/commands/browser.py | 259 ++++++--- src/ableton_cli/commands/device.py | 62 +- src/ableton_cli/commands/effect.py | 211 +++++-- src/ableton_cli/commands/scenes.py | 121 +++- src/ableton_cli/commands/session.py | 97 +++- src/ableton_cli/commands/setup.py | 8 +- src/ableton_cli/commands/song.py | 89 ++- src/ableton_cli/commands/track.py | 267 +++------ src/ableton_cli/commands/tracks.py | 94 ++- src/ableton_cli/commands/transport.py | 129 ++++- src/ableton_cli/config.py | 22 +- src/ableton_cli/contract_checks.py | 39 ++ src/ableton_cli/contracts/__init__.py | 10 +- src/ableton_cli/contracts/public_snapshot.py | 67 +++ src/ableton_cli/contracts/registry.py | 14 +- src/ableton_cli/dev_checks.py | 1 + src/ableton_cli/errors.py | 105 +++- src/ableton_cli/installer.py | 12 +- src/ableton_cli/platform_detection.py | 4 +- src/ableton_cli/runtime.py | 9 +- .../test_arrangement_command_adapter.py | 98 ++++ .../commands/test_arrangement_module_split.py | 26 + .../commands/test_browser_command_adapter.py | 37 ++ tests/commands/test_client_command_runner.py | 107 ++++ tests/commands/test_device_command_adapter.py | 53 ++ tests/commands/test_effect_command_adapter.py | 76 +++ tests/commands/test_scenes_command_adapter.py | 75 +++ .../commands/test_session_command_adapter.py | 36 ++ tests/commands/test_song_command_adapter.py | 36 ++ tests/commands/test_track_command_adapter.py | 76 +++ tests/commands/test_track_module_split.py | 19 + tests/commands/test_tracks_command_adapter.py | 37 ++ .../test_transport_command_adapter.py | 36 ++ tests/snapshots/public_contract_snapshot.json | 273 +++++++++ tests/test_app_factory.py | 9 + tests/test_client_backends.py | 72 +++ tests/test_contract_checks.py | 43 ++ tests/test_contracts.py | 2 + tests/test_dev_checks.py | 8 +- tests/test_errors.py | 37 ++ tests/test_exit_codes.py | 27 +- tests/test_public_contract_snapshot.py | 17 + tools/update_public_contract_snapshot.py | 19 + 73 files changed, 3666 insertions(+), 1126 deletions(-) create mode 100644 src/ableton_cli/client/backends.py create mode 100644 src/ableton_cli/commands/_arrangement_clip_commands.py create mode 100644 src/ableton_cli/commands/_arrangement_notes_commands.py create mode 100644 src/ableton_cli/commands/_arrangement_record_commands.py create mode 100644 src/ableton_cli/commands/_arrangement_session_commands.py create mode 100644 src/ableton_cli/commands/_arrangement_shared.py create mode 100644 src/ableton_cli/commands/_arrangement_specs.py create mode 100644 src/ableton_cli/commands/_client_command_runner.py create mode 100644 src/ableton_cli/commands/_track_arm_commands.py create mode 100644 src/ableton_cli/commands/_track_info_commands.py create mode 100644 src/ableton_cli/commands/_track_mute_commands.py create mode 100644 src/ableton_cli/commands/_track_name_commands.py create mode 100644 src/ableton_cli/commands/_track_panning_commands.py create mode 100644 src/ableton_cli/commands/_track_shared.py create mode 100644 src/ableton_cli/commands/_track_solo_commands.py create mode 100644 src/ableton_cli/commands/_track_specs.py create mode 100644 src/ableton_cli/commands/_track_volume_commands.py create mode 100644 src/ableton_cli/contract_checks.py create mode 100644 src/ableton_cli/contracts/public_snapshot.py create mode 100644 tests/commands/test_arrangement_command_adapter.py create mode 100644 tests/commands/test_arrangement_module_split.py create mode 100644 tests/commands/test_browser_command_adapter.py create mode 100644 tests/commands/test_client_command_runner.py create mode 100644 tests/commands/test_device_command_adapter.py create mode 100644 tests/commands/test_session_command_adapter.py create mode 100644 tests/commands/test_song_command_adapter.py create mode 100644 tests/commands/test_track_module_split.py create mode 100644 tests/commands/test_tracks_command_adapter.py create mode 100644 tests/commands/test_transport_command_adapter.py create mode 100644 tests/snapshots/public_contract_snapshot.json create mode 100644 tests/test_client_backends.py create mode 100644 tests/test_contract_checks.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_public_contract_snapshot.py create mode 100644 tools/update_public_contract_snapshot.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5ad3d5..ef5f8a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,24 @@ uv run ableton-cli --help uv run ableton-cli --version ``` +## Public Contract Boundary + +The following are treated as public/stable contracts: + +- README command surface (`song`, `transport`, `track`, `tracks`, `clip`, `arrangement`, `browser`, `device`, `synth`, `effect`, `batch`, `setup`) +- JSON output envelope shape (`ok`, `command`, `args`, `result`, `error`) +- fixed CLI exit codes and documented error codes +- TCP JSONL protocol request/response envelope and protocol versioning rules +- `tests/snapshots/public_contract_snapshot.json` + +Before merge, regenerate snapshot only when you intentionally change a public contract: + +```bash +uv run python tools/update_public_contract_snapshot.py +``` + +Internal modules (for example, `src/ableton_cli/commands/_*.py` and quality harness internals) can be refactored freely as long as public contracts and tests stay green. + ## Commit Hook (Ruff) Enable repository-managed git hooks to run Ruff on every commit: diff --git a/remote_script/AbletonCliRemote/command_backend.py b/remote_script/AbletonCliRemote/command_backend.py index eaf796c..73a198c 100644 --- a/remote_script/AbletonCliRemote/command_backend.py +++ b/remote_script/AbletonCliRemote/command_backend.py @@ -15,6 +15,9 @@ REMOTE_SCRIPT_VERSION, CommandBackend, CommandError, + RemoteErrorCode, + RemoteErrorReason, + details_with_reason, ) from .command_backend_registry import dispatch_command @@ -33,5 +36,8 @@ "NOTE_VELOCITY_MAX", "CommandError", "CommandBackend", + "RemoteErrorCode", + "RemoteErrorReason", + "details_with_reason", "dispatch_command", ] diff --git a/remote_script/AbletonCliRemote/command_backend_contract.py b/remote_script/AbletonCliRemote/command_backend_contract.py index 6381ecf..a262112 100644 --- a/remote_script/AbletonCliRemote/command_backend_contract.py +++ b/remote_script/AbletonCliRemote/command_backend_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum from typing import Any, Protocol PROTOCOL_VERSION = 2 @@ -18,9 +19,29 @@ NOTE_KEYS = frozenset({"pitch", "start_time", "duration", "velocity", "mute"}) +class RemoteErrorCode(str, Enum): + INVALID_ARGUMENT = "INVALID_ARGUMENT" + PROTOCOL_VERSION_MISMATCH = "PROTOCOL_VERSION_MISMATCH" + TIMEOUT = "TIMEOUT" + REMOTE_BUSY = "REMOTE_BUSY" + BATCH_STEP_FAILED = "BATCH_STEP_FAILED" + INTERNAL_ERROR = "INTERNAL_ERROR" + + +class RemoteErrorReason(str, Enum): + NOT_SUPPORTED_BY_LIVE_API = "not_supported_by_live_api" + + +def details_with_reason(reason: RemoteErrorReason, /, **details: Any) -> dict[str, Any]: + return { + "reason": reason.value, + **details, + } + + @dataclass(slots=True) class CommandError(Exception): - code: str + code: RemoteErrorCode | str message: str hint: str | None = None details: dict[str, Any] | None = None diff --git a/remote_script/AbletonCliRemote/command_backend_registry.py b/remote_script/AbletonCliRemote/command_backend_registry.py index 639c947..965b44c 100644 --- a/remote_script/AbletonCliRemote/command_backend_registry.py +++ b/remote_script/AbletonCliRemote/command_backend_registry.py @@ -4,7 +4,7 @@ from collections.abc import Callable from typing import Any -from .command_backend_contract import CommandBackend, CommandError +from .command_backend_contract import CommandBackend, CommandError, RemoteErrorCode from .command_backend_handlers_batch import make_execute_batch_handler from .command_backend_handlers_browser import BROWSER_HANDLERS from .command_backend_handlers_devices import DEVICE_HANDLERS @@ -48,7 +48,7 @@ def dispatch_command(backend: CommandBackend, name: str, args: dict[str, Any]) - handler = _HANDLERS.get(name) if handler is None: raise CommandError( - code="INVALID_ARGUMENT", + code=RemoteErrorCode.INVALID_ARGUMENT, message=f"Unknown command: {name}", hint="Use a supported command name.", ) diff --git a/remote_script/AbletonCliRemote/live_backend_parts/base.py b/remote_script/AbletonCliRemote/live_backend_parts/base.py index 72da89a..6607b39 100644 --- a/remote_script/AbletonCliRemote/live_backend_parts/base.py +++ b/remote_script/AbletonCliRemote/live_backend_parts/base.py @@ -2,7 +2,14 @@ from typing import Any -from ..command_backend import PROTOCOL_VERSION, REMOTE_SCRIPT_VERSION, CommandError +from ..command_backend import ( + PROTOCOL_VERSION, + REMOTE_SCRIPT_VERSION, + CommandError, + RemoteErrorCode, + RemoteErrorReason, + details_with_reason, +) from ..effect_specs import ( SUPPORTED_EFFECT_TYPES, canonicalize_effect_type, @@ -18,15 +25,15 @@ def _invalid_argument(message: str, hint: str) -> CommandError: - return CommandError(code="INVALID_ARGUMENT", message=message, hint=hint) + return CommandError(code=RemoteErrorCode.INVALID_ARGUMENT, message=message, hint=hint) def _not_supported_by_live_api(message: str, hint: str) -> CommandError: return CommandError( - code="INVALID_ARGUMENT", + code=RemoteErrorCode.INVALID_ARGUMENT, message=message, hint=hint, - details={"reason": "not_supported_by_live_api"}, + details=details_with_reason(RemoteErrorReason.NOT_SUPPORTED_BY_LIVE_API), ) diff --git a/remote_script/AbletonCliRemote/server.py b/remote_script/AbletonCliRemote/server.py index 92840ea..c27d81b 100644 --- a/remote_script/AbletonCliRemote/server.py +++ b/remote_script/AbletonCliRemote/server.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any -from .command_backend import PROTOCOL_VERSION +from .command_backend import PROTOCOL_VERSION, RemoteErrorCode @dataclass(slots=True) @@ -74,7 +74,7 @@ def handle(self) -> None: self._write_response( _error( request_id="unknown", - code="PROTOCOL_VERSION_MISMATCH", + code=RemoteErrorCode.PROTOCOL_VERSION_MISMATCH.value, message="Invalid JSON payload", hint="Send one JSON object per line.", ) @@ -86,14 +86,14 @@ def handle(self) -> None: try: if payload.get("type") != "command": raise CommandExecutionError( - code="INVALID_ARGUMENT", + code=RemoteErrorCode.INVALID_ARGUMENT.value, message="type must be 'command'", hint="Use the CLI protocol request shape.", ) protocol_version = payload.get("protocol_version") if protocol_version != PROTOCOL_VERSION: raise CommandExecutionError( - code="PROTOCOL_VERSION_MISMATCH", + code=RemoteErrorCode.PROTOCOL_VERSION_MISMATCH.value, message=( "Protocol version mismatch " f"(remote={PROTOCOL_VERSION}, request={protocol_version})" @@ -108,14 +108,14 @@ def handle(self) -> None: args = payload.get("args", {}) if not isinstance(args, dict): raise CommandExecutionError( - code="INVALID_ARGUMENT", + code=RemoteErrorCode.INVALID_ARGUMENT.value, message="args must be an object", hint="Pass a JSON object for args.", ) meta = payload.get("meta", {}) if not isinstance(meta, dict): raise CommandExecutionError( - code="INVALID_ARGUMENT", + code=RemoteErrorCode.INVALID_ARGUMENT.value, message="meta must be an object", hint="Pass a JSON object for meta.", ) @@ -137,7 +137,7 @@ def handle(self) -> None: except Exception as exc: # noqa: BLE001 response = _error( request_id=request_id, - code="INTERNAL_ERROR", + code=RemoteErrorCode.INTERNAL_ERROR.value, message=f"Remote internal error: {exc}", hint="Check Remote Script logs.", ) diff --git a/src/ableton_cli/capabilities.py b/src/ableton_cli/capabilities.py index 7325dfc..bff1f6d 100644 --- a/src/ableton_cli/capabilities.py +++ b/src/ableton_cli/capabilities.py @@ -3,7 +3,7 @@ import hashlib from typing import Any -from .errors import AppError, ExitCode +from .errors import AppError, ErrorCode, ExitCode _REQUIRED_REMOTE_COMMANDS = frozenset( { @@ -165,7 +165,7 @@ def parse_supported_commands(ping_payload: dict[str, Any]) -> set[str]: raw_commands = ping_payload.get("supported_commands") if not isinstance(raw_commands, list): raise AppError( - error_code="REMOTE_SCRIPT_INCOMPATIBLE", + error_code=ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE, message="Remote Script ping payload is missing supported_commands", hint="Reinstall Remote Script and restart Ableton Live.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -175,7 +175,7 @@ def parse_supported_commands(ping_payload: dict[str, Any]) -> set[str]: for index, value in enumerate(raw_commands): if not isinstance(value, str) or not value.strip(): raise AppError( - error_code="REMOTE_SCRIPT_INCOMPATIBLE", + error_code=ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE, message=f"supported_commands[{index}] must be a non-empty string", hint="Reinstall Remote Script and restart Ableton Live.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -184,7 +184,7 @@ def parse_supported_commands(ping_payload: dict[str, Any]) -> set[str]: if not commands: raise AppError( - error_code="REMOTE_SCRIPT_INCOMPATIBLE", + error_code=ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE, message="Remote Script reported no supported commands", hint="Reinstall Remote Script and restart Ableton Live.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -193,7 +193,7 @@ def parse_supported_commands(ping_payload: dict[str, Any]) -> set[str]: remote_hash = ping_payload.get("command_set_hash") if not isinstance(remote_hash, str) or not remote_hash.strip(): raise AppError( - error_code="REMOTE_SCRIPT_INCOMPATIBLE", + error_code=ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE, message="Remote Script ping payload is missing command_set_hash", hint="Reinstall Remote Script and restart Ableton Live.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -201,7 +201,7 @@ def parse_supported_commands(ping_payload: dict[str, Any]) -> set[str]: expected_hash = compute_command_set_hash(commands) if remote_hash != expected_hash: raise AppError( - error_code="REMOTE_SCRIPT_INCOMPATIBLE", + error_code=ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE, message="Remote Script command_set_hash does not match supported_commands", hint="Reinstall Remote Script and restart Ableton Live.", exit_code=ExitCode.PROTOCOL_MISMATCH, diff --git a/src/ableton_cli/client/_client_core.py b/src/ableton_cli/client/_client_core.py index 08b8cd7..e0ef00c 100644 --- a/src/ableton_cli/client/_client_core.py +++ b/src/ableton_cli/client/_client_core.py @@ -4,9 +4,8 @@ from ..capabilities import read_only_remote_commands from ..config import Settings -from ..errors import AppError, ExitCode, remote_error_to_app_error -from .protocol import make_request, parse_response -from .transport import RecordingTransport, ReplayTransport, TcpJsonlTransport +from ..errors import AppError, ErrorCode, ExitCode +from .backends import ClientBackend, LiveBackendClient, RecordingClient, ReplayClient class _AbletonClientCore: @@ -23,61 +22,43 @@ def __init__( self._read_only_commands = read_only_remote_commands() if record_path is not None and replay_path is not None: raise AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message="--record and --replay cannot be used together", hint="Choose exactly one of --record or --replay.", exit_code=ExitCode.INVALID_ARGUMENT, ) - base_transport = TcpJsonlTransport( - host=settings.host, - port=settings.port, - timeout_ms=settings.timeout_ms, + self._backend = self._build_backend( + settings=settings, + record_path=record_path, + replay_path=replay_path, ) + # Keep direct transport access available for tests and fixtures. + self.transport = self._backend.transport + + @staticmethod + def _build_backend( + *, + settings: Settings, + record_path: str | None, + replay_path: str | None, + ) -> ClientBackend: if replay_path is not None: - self.transport = ReplayTransport(path=replay_path) - elif record_path is not None: - self.transport = RecordingTransport(inner=base_transport, path=record_path) - else: - self.transport = base_transport + return ReplayClient(settings, path=replay_path) + if record_path is not None: + return RecordingClient(settings, path=record_path) + return LiveBackendClient(settings) def _dispatch(self, name: str, args: dict[str, Any]) -> dict[str, Any]: if self.read_only and name not in self._read_only_commands: raise AppError( - error_code="READ_ONLY_VIOLATION", + error_code=ErrorCode.READ_ONLY_VIOLATION, message=f"Command '{name}' is blocked in read-only mode", hint="Run without --read-only to execute write commands.", exit_code=ExitCode.EXECUTION_FAILED, details={"command": name}, ) - - request = make_request( - name=name, - args=args, - protocol_version=self.settings.protocol_version, - meta={"request_timeout_ms": self.settings.timeout_ms}, - ) - raw_response = self.transport.send(request.to_dict()) - response = parse_response( - payload=raw_response, - expected_request_id=request.request_id, - expected_protocol=self.settings.protocol_version, - ) - - if response.ok: - if response.result is None: - return {} - return response.result - - if response.error is None: - raise AppError( - error_code="INTERNAL_ERROR", - message="Remote command failed without structured error payload", - hint="Update Remote Script error handling.", - exit_code=ExitCode.EXECUTION_FAILED, - ) - - raise remote_error_to_app_error(response.error) + return self._backend.dispatch(name, args) def _call(self, name: str, args: dict[str, Any] | None = None) -> dict[str, Any]: payload = {} if args is None else dict(args) diff --git a/src/ableton_cli/client/backends.py b/src/ableton_cli/client/backends.py new file mode 100644 index 0000000..d468668 --- /dev/null +++ b/src/ableton_cli/client/backends.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any, Protocol + +from ..config import Settings +from ..errors import AppError, ErrorCode, ExitCode, remote_error_to_app_error +from .protocol import make_request, parse_response +from .transport import JsonTransport, RecordingTransport, ReplayTransport, TcpJsonlTransport + + +class ClientBackend(Protocol): + transport: JsonTransport + + def dispatch(self, name: str, args: dict[str, Any]) -> dict[str, Any]: ... + + +class _TransportBackend: + def __init__(self, settings: Settings, transport: JsonTransport) -> None: + self._settings = settings + self.transport = transport + + def dispatch(self, name: str, args: dict[str, Any]) -> dict[str, Any]: + request = make_request( + name=name, + args=args, + protocol_version=self._settings.protocol_version, + meta={"request_timeout_ms": self._settings.timeout_ms}, + ) + raw_response = self.transport.send(request.to_dict()) + response = parse_response( + payload=raw_response, + expected_request_id=request.request_id, + expected_protocol=self._settings.protocol_version, + ) + + if response.ok: + if response.result is None: + return {} + return response.result + + if response.error is None: + raise AppError( + error_code=ErrorCode.INTERNAL_ERROR, + message="Remote command failed without structured error payload", + hint="Update Remote Script error handling.", + exit_code=ExitCode.EXECUTION_FAILED, + ) + + raise remote_error_to_app_error(response.error) + + +class LiveBackendClient(_TransportBackend): + def __init__(self, settings: Settings) -> None: + super().__init__( + settings, + TcpJsonlTransport( + host=settings.host, + port=settings.port, + timeout_ms=settings.timeout_ms, + ), + ) + + +class RecordingClient(_TransportBackend): + def __init__(self, settings: Settings, *, path: str) -> None: + super().__init__( + settings, + RecordingTransport( + inner=TcpJsonlTransport( + host=settings.host, + port=settings.port, + timeout_ms=settings.timeout_ms, + ), + path=path, + ), + ) + + +class ReplayClient(_TransportBackend): + def __init__(self, settings: Settings, *, path: str) -> None: + super().__init__( + settings, + ReplayTransport(path=path), + ) diff --git a/src/ableton_cli/client/protocol.py b/src/ableton_cli/client/protocol.py index 8ba5063..2bd53d1 100644 --- a/src/ableton_cli/client/protocol.py +++ b/src/ableton_cli/client/protocol.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ExitCode @dataclass(slots=True) @@ -39,7 +39,7 @@ class Response: REQUIRED_RESPONSE_KEYS = {"ok", "request_id", "protocol_version"} -def _raise_protocol_error(error_code: str, message: str, hint: str) -> None: +def _raise_protocol_error(error_code: ErrorCode, message: str, hint: str) -> None: raise AppError( error_code=error_code, message=message, @@ -70,7 +70,7 @@ def parse_response( missing = REQUIRED_RESPONSE_KEYS.difference(payload) if missing: _raise_protocol_error( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message=f"Invalid response payload, missing keys: {sorted(missing)}", hint="Ensure the Remote Script protocol implementation matches the CLI.", ) @@ -78,7 +78,7 @@ def parse_response( response_protocol = payload.get("protocol_version") if not isinstance(response_protocol, int): _raise_protocol_error( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="protocol_version must be an integer", hint=( "Set matching protocol versions on both sides " @@ -87,7 +87,7 @@ def parse_response( ) if response_protocol != expected_protocol: _raise_protocol_error( - error_code="PROTOCOL_VERSION_MISMATCH", + error_code=ErrorCode.PROTOCOL_VERSION_MISMATCH, message=( f"Protocol version mismatch (cli={expected_protocol}, remote={response_protocol})" ), @@ -100,7 +100,7 @@ def parse_response( request_id = payload.get("request_id") if request_id != expected_request_id: _raise_protocol_error( - error_code="PROTOCOL_REQUEST_ID_MISMATCH", + error_code=ErrorCode.PROTOCOL_REQUEST_ID_MISMATCH, message=(f"request_id mismatch (expected={expected_request_id}, actual={request_id})"), hint="Check request routing in the Remote Script server.", ) @@ -108,7 +108,7 @@ def parse_response( ok = payload.get("ok") if not isinstance(ok, bool): _raise_protocol_error( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="'ok' must be a boolean in response payload", hint="Update Remote Script response format.", ) @@ -116,7 +116,7 @@ def parse_response( result = payload.get("result") if result is not None and not isinstance(result, dict): _raise_protocol_error( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="'result' must be an object when provided", hint="Return JSON object for result payloads.", ) @@ -124,14 +124,14 @@ def parse_response( error = payload.get("error") if error is not None and not isinstance(error, dict): _raise_protocol_error( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="'error' must be an object when provided", hint="Return structured error payload with code/message.", ) if isinstance(error, dict) and "details" in error and error["details"] is not None: if not isinstance(error["details"], dict): _raise_protocol_error( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="'error.details' must be an object when provided", hint="Return structured error details as a JSON object.", ) diff --git a/src/ableton_cli/client/transport.py b/src/ableton_cli/client/transport.py index fb0d218..4f892fe 100644 --- a/src/ableton_cli/client/transport.py +++ b/src/ableton_cli/client/transport.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, Protocol -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ExitCode class JsonTransport(Protocol): @@ -30,21 +30,21 @@ def send(self, payload: dict[str, Any]) -> dict[str, Any]: raw = file_obj.readline() except TimeoutError as exc: raise AppError( - error_code="TIMEOUT", + error_code=ErrorCode.TIMEOUT, message=f"Timed out while communicating with {self.host}:{self.port}", hint="Increase --timeout-ms or verify Ableton Remote Script responsiveness.", exit_code=ExitCode.TIMEOUT, ) from exc except ConnectionRefusedError as exc: raise AppError( - error_code="ABLETON_NOT_REACHABLE", + error_code=ErrorCode.ABLETON_NOT_REACHABLE, message=f"Unable to connect to {self.host}:{self.port}", hint="Start Ableton Live and enable the Remote Script.", exit_code=ExitCode.ABLETON_NOT_CONNECTED, ) from exc except OSError as exc: raise AppError( - error_code="ABLETON_NOT_REACHABLE", + error_code=ErrorCode.ABLETON_NOT_REACHABLE, message=f"Network error while connecting to {self.host}:{self.port}", hint="Check host/port and confirm the Remote Script is running.", exit_code=ExitCode.ABLETON_NOT_CONNECTED, @@ -52,7 +52,7 @@ def send(self, payload: dict[str, Any]) -> dict[str, Any]: if not raw: raise AppError( - error_code="PROTOCOL_VERSION_MISMATCH", + error_code=ErrorCode.PROTOCOL_VERSION_MISMATCH, message="Remote endpoint closed connection without response", hint="Ensure the Remote Script returns one JSON line per request.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -62,7 +62,7 @@ def send(self, payload: dict[str, Any]) -> dict[str, Any]: decoded = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AppError( - error_code="PROTOCOL_VERSION_MISMATCH", + error_code=ErrorCode.PROTOCOL_VERSION_MISMATCH, message="Received malformed JSON from Remote Script", hint="Check protocol implementation in Remote Script.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -70,7 +70,7 @@ def send(self, payload: dict[str, Any]) -> dict[str, Any]: if not isinstance(decoded, dict): raise AppError( - error_code="PROTOCOL_VERSION_MISMATCH", + error_code=ErrorCode.PROTOCOL_VERSION_MISMATCH, message="Response must be a JSON object", hint="Return object payloads from Remote Script.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -122,7 +122,7 @@ def __init__(self, *, path: str) -> None: self.path = Path(path) if not self.path.exists(): raise AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message=f"Replay file does not exist: {self.path}", hint="Provide an existing JSONL path to --replay.", exit_code=ExitCode.INVALID_ARGUMENT, @@ -137,7 +137,7 @@ def __init__(self, *, path: str) -> None: entry = json.loads(raw_line) except json.JSONDecodeError as exc: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message=f"Replay file line {line_number} is not valid JSON", hint="Fix JSONL formatting in replay file.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -145,7 +145,7 @@ def __init__(self, *, path: str) -> None: if not isinstance(entry, dict): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message=f"Replay file line {line_number} must be an object", hint="Use object-per-line JSONL format.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -153,7 +153,7 @@ def __init__(self, *, path: str) -> None: request = entry.get("request") if not isinstance(request, dict): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message=f"Replay file line {line_number}.request must be an object", hint="Each replay entry requires a request object.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -167,14 +167,14 @@ def _request_key(request: dict[str, Any]) -> str: args = request.get("args", {}) if not isinstance(name, str) or not name: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay request.name must be a non-empty string", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, ) if not isinstance(args, dict): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay request.args must be an object", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -186,7 +186,7 @@ def _request_key(request: dict[str, Any]) -> str: def _raise_replay_error(payload: Any) -> None: if not isinstance(payload, dict): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay error payload must be an object", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -195,7 +195,7 @@ def _raise_replay_error(payload: Any) -> None: code_value = payload.get("error_code", payload.get("code")) if not isinstance(code_value, str) or not code_value: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay error payload is missing error_code", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -203,7 +203,7 @@ def _raise_replay_error(payload: Any) -> None: message = payload.get("message") if not isinstance(message, str) or not message: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay error payload is missing message", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -211,7 +211,7 @@ def _raise_replay_error(payload: Any) -> None: hint = payload.get("hint") if hint is not None and not isinstance(hint, str): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay error payload hint must be a string or null", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -221,7 +221,7 @@ def _raise_replay_error(payload: Any) -> None: exit_code = ExitCode(int(raw_exit_code)) except (TypeError, ValueError) as exc: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay error payload exit_code is invalid", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -229,7 +229,7 @@ def _raise_replay_error(payload: Any) -> None: details = payload.get("details", {}) if details is not None and not isinstance(details, dict): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay error payload details must be an object or null", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -247,7 +247,7 @@ def send(self, payload: dict[str, Any]) -> dict[str, Any]: bucket = self._entries_by_key.get(key) if not bucket: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay fixture does not contain a matching request", hint="Record fixtures with --record for the exact name+args sequence.", exit_code=ExitCode.PROTOCOL_MISMATCH, @@ -262,7 +262,7 @@ def send(self, payload: dict[str, Any]) -> dict[str, Any]: response = entry.get("response") if not isinstance(response, dict): raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message="Replay entry response must be an object", hint="Record new replay fixtures with --record.", exit_code=ExitCode.PROTOCOL_MISMATCH, diff --git a/src/ableton_cli/commands/_arrangement_clip_commands.py b/src/ableton_cli/commands/_arrangement_clip_commands.py new file mode 100644 index 0000000..567faa2 --- /dev/null +++ b/src/ableton_cli/commands/_arrangement_clip_commands.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._arrangement_shared import require_arrangement_clip_index, require_track_index +from ._arrangement_specs import ArrangementCommandSpec +from ._validation import ( + invalid_argument, + parse_notes_input, + require_absolute_path, + require_non_negative_float, + require_positive_float, +) + +CLIP_CREATE_SPEC = ArrangementCommandSpec( + command_name="arrangement clip create", + client_method="arrangement_clip_create", +) + +CLIP_LIST_SPEC = ArrangementCommandSpec( + command_name="arrangement clip list", + client_method="arrangement_clip_list", +) + +CLIP_DELETE_SPEC = ArrangementCommandSpec( + command_name="arrangement clip delete", + client_method="arrangement_clip_delete", +) + + +def register_commands( + clip_app: typer.Typer, + *, + run_client_command_spec: Callable[..., None], +) -> None: + @clip_app.command("create") + def arrangement_clip_create( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + start: Annotated[ + float, + typer.Option( + "--start", + help="Arrangement start time in beats", + ), + ], + length: Annotated[ + float, + typer.Option( + "--length", + help="Arrangement clip length in beats", + ), + ], + audio_path: Annotated[ + str | None, + typer.Option( + "--audio-path", + help="Absolute audio file path for audio tracks", + ), + ] = None, + notes_json: Annotated[ + str | None, + typer.Option("--notes-json", help="JSON array of note objects for MIDI clips"), + ] = None, + notes_file: Annotated[ + str | None, + typer.Option( + "--notes-file", + help="Path to JSON file containing note array for MIDI clips", + ), + ] = None, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_start = require_non_negative_float( + "start", + start, + hint="Use a non-negative --start value in beats.", + ) + valid_length = require_positive_float( + "length", + length, + hint="Use a positive --length value in beats.", + ) + normalized_audio_path = ( + require_absolute_path( + "audio_path", + audio_path, + hint="Pass an absolute file path for --audio-path.", + ) + if audio_path is not None + else None + ) + notes = ( + parse_notes_input(notes_json=notes_json, notes_file=notes_file) + if notes_json is not None or notes_file is not None + else None + ) + if normalized_audio_path is not None and notes is not None: + raise invalid_argument( + message="--audio-path and --notes-json/--notes-file are mutually exclusive", + hint="Use notes options for MIDI clips or --audio-path for audio clips.", + ) + return { + "track": valid_track, + "start_time": valid_start, + "length": valid_length, + "audio_path": normalized_audio_path, + "notes": notes, + } + + run_client_command_spec( + ctx, + spec=CLIP_CREATE_SPEC, + args={ + "track": track, + "start_time": start, + "length": length, + "audio_path": audio_path, + "notes": notes_json is not None or notes_file is not None, + }, + method_kwargs=_method_kwargs, + ) + + @clip_app.command("list") + def arrangement_clip_list( + ctx: typer.Context, + track: Annotated[ + int | None, + typer.Option( + "--track", + help="Optional track index filter (0-based)", + ), + ] = None, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) if track is not None else None + return {"track": valid_track} + + run_client_command_spec( + ctx, + spec=CLIP_LIST_SPEC, + args={"track": track}, + method_kwargs=_method_kwargs, + ) + + @clip_app.command("delete") + def arrangement_clip_delete( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + index: Annotated[ + int | None, + typer.Argument(help="Arrangement clip index to delete", show_default=False), + ] = None, + start: Annotated[ + float | None, + typer.Option("--start", help="Range start beat (inclusive)"), + ] = None, + end: Annotated[ + float | None, + typer.Option("--end", help="Range end beat (exclusive)"), + ] = None, + all_: Annotated[ + bool, + typer.Option("--all", help="Delete all arrangement clips on the track"), + ] = False, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) if index is not None else None + has_range_value = start is not None or end is not None + if has_range_value and (start is None or end is None): + raise invalid_argument( + message="--start and --end must be provided together for range delete mode", + hint="Use both --start and --end, or use index/--all mode.", + ) + mode_count = int(valid_index is not None) + int(has_range_value) + int(all_) + if mode_count != 1: + raise invalid_argument( + message="Exactly one delete mode must be selected: index, range, or --all", + hint="Use one of: | --start/--end | --all.", + ) + + valid_start = None + valid_end = None + if has_range_value: + assert start is not None + assert end is not None + valid_start = require_non_negative_float( + "start", + start, + hint="Use a non-negative --start value in beats.", + ) + valid_end = require_non_negative_float( + "end", + end, + hint="Use a non-negative --end value in beats.", + ) + if valid_end <= valid_start: + raise invalid_argument( + message=( + f"end must be greater than start (start={valid_start}, end={valid_end})" + ), + hint="Use a valid [start, end) range with end > start.", + ) + + return { + "track": valid_track, + "index": valid_index, + "start": valid_start, + "end": valid_end, + "delete_all": all_, + } + + run_client_command_spec( + ctx, + spec=CLIP_DELETE_SPEC, + args={"track": track, "index": index, "start": start, "end": end, "all": all_}, + method_kwargs=_method_kwargs, + ) diff --git a/src/ableton_cli/commands/_arrangement_notes_commands.py b/src/ableton_cli/commands/_arrangement_notes_commands.py new file mode 100644 index 0000000..f7657a0 --- /dev/null +++ b/src/ableton_cli/commands/_arrangement_notes_commands.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._arrangement_shared import ( + filters_payload, + require_arrangement_clip_index, + require_track_index, +) +from ._arrangement_specs import ArrangementCommandSpec +from ._validation import ( + invalid_argument, + parse_notes_input, + require_non_empty_string, + resolve_uri_or_path_target, + validate_clip_note_filters, +) + +NOTES_ADD_SPEC = ArrangementCommandSpec( + command_name="arrangement clip notes add", + client_method="arrangement_clip_notes_add", +) + +NOTES_GET_SPEC = ArrangementCommandSpec( + command_name="arrangement clip notes get", + client_method="arrangement_clip_notes_get", +) + +NOTES_CLEAR_SPEC = ArrangementCommandSpec( + command_name="arrangement clip notes clear", + client_method="arrangement_clip_notes_clear", +) + +NOTES_REPLACE_SPEC = ArrangementCommandSpec( + command_name="arrangement clip notes replace", + client_method="arrangement_clip_notes_replace", +) + +NOTES_IMPORT_BROWSER_SPEC = ArrangementCommandSpec( + command_name="arrangement clip notes import-browser", + client_method="arrangement_clip_notes_import_browser", +) + + +def register_commands( + notes_app: typer.Typer, + *, + run_client_command_spec: Callable[..., None], +) -> None: + @notes_app.command("add") + def arrangement_clip_notes_add( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], + notes_json: Annotated[ + str | None, + typer.Option("--notes-json", help="JSON array of note objects"), + ] = None, + notes_file: Annotated[ + str | None, + typer.Option("--notes-file", help="Path to JSON file containing note array"), + ] = None, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) + notes = parse_notes_input(notes_json=notes_json, notes_file=notes_file) + return { + "track": valid_track, + "index": valid_index, + "notes": notes, + } + + run_client_command_spec( + ctx, + spec=NOTES_ADD_SPEC, + args={"track": track, "index": index}, + method_kwargs=_method_kwargs, + ) + + @notes_app.command("get") + def arrangement_clip_notes_get( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], + start_time: Annotated[ + float | None, + typer.Option("--start-time", help="Inclusive start time filter in beats"), + ] = None, + end_time: Annotated[ + float | None, + typer.Option("--end-time", help="Exclusive end time filter in beats"), + ] = None, + pitch: Annotated[ + int | None, + typer.Option("--pitch", help="Exact MIDI pitch filter"), + ] = None, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) + filters = validate_clip_note_filters( + start_time=start_time, + end_time=end_time, + pitch=pitch, + ) + return { + "track": valid_track, + "index": valid_index, + "start_time": filters["start_time"], + "end_time": filters["end_time"], + "pitch": filters["pitch"], + } + + run_client_command_spec( + ctx, + spec=NOTES_GET_SPEC, + args=filters_payload( + track=track, + index=index, + start_time=start_time, + end_time=end_time, + pitch=pitch, + ), + method_kwargs=_method_kwargs, + ) + + @notes_app.command("clear") + def arrangement_clip_notes_clear( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], + start_time: Annotated[ + float | None, + typer.Option("--start-time", help="Inclusive start time filter in beats"), + ] = None, + end_time: Annotated[ + float | None, + typer.Option("--end-time", help="Exclusive end time filter in beats"), + ] = None, + pitch: Annotated[ + int | None, + typer.Option("--pitch", help="Exact MIDI pitch filter"), + ] = None, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) + filters = validate_clip_note_filters( + start_time=start_time, + end_time=end_time, + pitch=pitch, + ) + return { + "track": valid_track, + "index": valid_index, + "start_time": filters["start_time"], + "end_time": filters["end_time"], + "pitch": filters["pitch"], + } + + run_client_command_spec( + ctx, + spec=NOTES_CLEAR_SPEC, + args=filters_payload( + track=track, + index=index, + start_time=start_time, + end_time=end_time, + pitch=pitch, + ), + method_kwargs=_method_kwargs, + ) + + @notes_app.command("replace") + def arrangement_clip_notes_replace( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], + notes_json: Annotated[ + str | None, + typer.Option("--notes-json", help="JSON array of note objects"), + ] = None, + notes_file: Annotated[ + str | None, + typer.Option("--notes-file", help="Path to JSON file containing note array"), + ] = None, + start_time: Annotated[ + float | None, + typer.Option("--start-time", help="Inclusive start time filter in beats"), + ] = None, + end_time: Annotated[ + float | None, + typer.Option("--end-time", help="Exclusive end time filter in beats"), + ] = None, + pitch: Annotated[ + int | None, + typer.Option("--pitch", help="Exact MIDI pitch filter"), + ] = None, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) + notes = parse_notes_input(notes_json=notes_json, notes_file=notes_file) + filters = validate_clip_note_filters( + start_time=start_time, + end_time=end_time, + pitch=pitch, + ) + return { + "track": valid_track, + "index": valid_index, + "notes": notes, + "start_time": filters["start_time"], + "end_time": filters["end_time"], + "pitch": filters["pitch"], + } + + run_client_command_spec( + ctx, + spec=NOTES_REPLACE_SPEC, + args=filters_payload( + track=track, + index=index, + start_time=start_time, + end_time=end_time, + pitch=pitch, + ), + method_kwargs=_method_kwargs, + ) + + @notes_app.command("import-browser") + def arrangement_clip_notes_import_browser( + ctx: typer.Context, + track: Annotated[int, typer.Argument(help="Track index (0-based)")], + index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], + target: Annotated[str, typer.Argument(help="Browser target (URI or path to .alc)")], + mode: Annotated[ + str, typer.Option("--mode", help="Note import mode: replace|append") + ] = "replace", + import_length: Annotated[ + bool, + typer.Option( + "--import-length/--no-import-length", + help="Copy source clip length into the destination arrangement clip", + ), + ] = False, + import_groove: Annotated[ + bool, + typer.Option( + "--import-groove/--no-import-groove", + help="Copy source clip groove settings into the destination arrangement clip", + ), + ] = False, + ) -> None: + def _method_kwargs() -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) + valid_mode = require_non_empty_string( + "mode", + mode, + hint="Use --mode replace or append.", + ).lower() + if valid_mode not in {"replace", "append"}: + raise invalid_argument( + message=f"mode must be one of replace/append, got {mode}", + hint="Use --mode replace or append.", + ) + target_uri, target_path = resolve_uri_or_path_target( + target=target, + hint="Use a browser path or URI for a .alc MIDI clip item.", + ) + return { + "track": valid_track, + "index": valid_index, + "target_uri": target_uri, + "target_path": target_path, + "mode": valid_mode, + "import_length": import_length, + "import_groove": import_groove, + } + + run_client_command_spec( + ctx, + spec=NOTES_IMPORT_BROWSER_SPEC, + args={ + "track": track, + "index": index, + "target": target, + "mode": mode, + "import_length": import_length, + "import_groove": import_groove, + }, + method_kwargs=_method_kwargs, + ) diff --git a/src/ableton_cli/commands/_arrangement_record_commands.py b/src/ableton_cli/commands/_arrangement_record_commands.py new file mode 100644 index 0000000..a711803 --- /dev/null +++ b/src/ableton_cli/commands/_arrangement_record_commands.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import Callable + +import typer + +from ._arrangement_specs import ArrangementCommandSpec + +RECORD_START_SPEC = ArrangementCommandSpec( + command_name="arrangement record start", + client_method="arrangement_record_start", +) + +RECORD_STOP_SPEC = ArrangementCommandSpec( + command_name="arrangement record stop", + client_method="arrangement_record_stop", +) + + +def register_commands( + record_app: typer.Typer, + *, + run_client_command_spec: Callable[..., None], +) -> None: + @record_app.command("start") + def arrangement_record_start(ctx: typer.Context) -> None: + run_client_command_spec( + ctx, + spec=RECORD_START_SPEC, + args={}, + ) + + @record_app.command("stop") + def arrangement_record_stop(ctx: typer.Context) -> None: + run_client_command_spec( + ctx, + spec=RECORD_STOP_SPEC, + args={}, + ) diff --git a/src/ableton_cli/commands/_arrangement_session_commands.py b/src/ableton_cli/commands/_arrangement_session_commands.py new file mode 100644 index 0000000..c6a3a10 --- /dev/null +++ b/src/ableton_cli/commands/_arrangement_session_commands.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._arrangement_specs import ArrangementCommandSpec +from ._validation import require_non_empty_string +from .clip._parsers import parse_arrangement_scene_durations + +FROM_SESSION_SPEC = ArrangementCommandSpec( + command_name="arrangement from-session", + client_method="arrangement_from_session", +) + + +def register_commands( + arrangement_app: typer.Typer, + *, + run_client_command_spec: Callable[..., None], +) -> None: + @arrangement_app.command("from-session") + def arrangement_from_session( + ctx: typer.Context, + scenes: Annotated[ + str, + typer.Option( + "--scenes", + help="Scene duration map as CSV (scene_index:duration_beats,...)", + ), + ], + ) -> None: + def _method_kwargs() -> dict[str, object]: + parsed = parse_arrangement_scene_durations(scenes) + return {"scenes": parsed} + + run_client_command_spec( + ctx, + spec=FROM_SESSION_SPEC, + args={ + "scenes": require_non_empty_string( + "scenes", + scenes, + hint="Use --scenes 0:24,1:48.", + ) + }, + method_kwargs=_method_kwargs, + ) diff --git a/src/ableton_cli/commands/_arrangement_shared.py b/src/ableton_cli/commands/_arrangement_shared.py new file mode 100644 index 0000000..4929a35 --- /dev/null +++ b/src/ableton_cli/commands/_arrangement_shared.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any + +from ._validation import require_non_negative + +TRACK_HINT = "Use a valid track index from 'ableton-cli tracks list'." +ARRANGEMENT_CLIP_INDEX_HINT = ( + "Use a valid arrangement clip index from 'ableton-cli arrangement clip list'." +) + + +def require_track_index(track: int) -> int: + return require_non_negative( + "track", + track, + hint=TRACK_HINT, + ) + + +def require_arrangement_clip_index(index: int, *, name: str = "index") -> int: + return require_non_negative( + name, + index, + hint=ARRANGEMENT_CLIP_INDEX_HINT, + ) + + +def filters_payload( + *, + track: int, + index: int, + start_time: float | None, + end_time: float | None, + pitch: int | None, +) -> dict[str, Any]: + return { + "track": track, + "index": index, + "start_time": start_time, + "end_time": end_time, + "pitch": pitch, + } diff --git a/src/ableton_cli/commands/_arrangement_specs.py b/src/ableton_cli/commands/_arrangement_specs.py new file mode 100644 index 0000000..681b271 --- /dev/null +++ b/src/ableton_cli/commands/_arrangement_specs.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from ._client_command_runner import CommandSpec + +ArrangementCommandSpec = CommandSpec diff --git a/src/ableton_cli/commands/_client_command_runner.py b/src/ableton_cli/commands/_client_command_runner.py new file mode 100644 index 0000000..ef67b6e --- /dev/null +++ b/src/ableton_cli/commands/_client_command_runner.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Protocol, cast + +import typer + +MethodKwargs = dict[str, object] | Callable[[], dict[str, object]] | None +ClientAction = Callable[[object], dict[str, object]] +GetClientFn = Callable[[typer.Context], object] +ExecuteCommandFn = Callable[..., None] + + +class ClientCommandSpec(Protocol): + command_name: str + client_method: str + + +@dataclass(frozen=True) +class CommandSpec: + command_name: str + client_method: str + + +def _resolve_method_kwargs(method_kwargs: MethodKwargs) -> dict[str, object]: + if callable(method_kwargs): + return method_kwargs() + if method_kwargs is None: + return {} + return method_kwargs + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: ClientAction, + get_client_fn: GetClientFn, + execute_command_fn: ExecuteCommandFn, +) -> None: + execute_command_fn( + ctx, + command=command_name, + args=args, + action=lambda: fn(get_client_fn(ctx)), + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: ClientCommandSpec, + args: dict[str, object], + get_client_fn: GetClientFn, + execute_command_fn: ExecuteCommandFn, + method_kwargs: MethodKwargs = None, +) -> None: + run_client_command( + ctx, + command_name=spec.command_name, + args=args, + fn=lambda client: cast( + dict[str, object], + getattr(client, spec.client_method)(**_resolve_method_kwargs(method_kwargs)), + ), + get_client_fn=get_client_fn, + execute_command_fn=execute_command_fn, + ) diff --git a/src/ableton_cli/commands/_track_arm_commands.py b/src/ableton_cli/commands/_track_arm_commands.py new file mode 100644 index 0000000..a7e1e0e --- /dev/null +++ b/src/ableton_cli/commands/_track_arm_commands.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._track_shared import TrackArgument +from ._track_specs import TrackCommandSpec, TrackValueCommandSpec + +ARM_GET_SPEC = TrackCommandSpec( + command_name="track arm get", + client_method="track_arm_get", +) + +ARM_SET_SPEC = TrackValueCommandSpec[bool]( + command_name="track arm set", + client_method="track_arm_set", +) + + +def register_commands( + arm_app: typer.Typer, + *, + run_track_command_spec: Callable[..., None], + run_track_value_command_spec: Callable[..., None], +) -> None: + @arm_app.command("get") + def arm_get( + ctx: typer.Context, + track: TrackArgument, + ) -> None: + run_track_command_spec( + ctx, + spec=ARM_GET_SPEC, + track=track, + ) + + @arm_app.command("set") + def arm_set( + ctx: typer.Context, + track: TrackArgument, + value: Annotated[bool, typer.Argument(help="Arm value: true|false")], + ) -> None: + run_track_value_command_spec( + ctx, + spec=ARM_SET_SPEC, + track=track, + value=value, + ) diff --git a/src/ableton_cli/commands/_track_info_commands.py b/src/ableton_cli/commands/_track_info_commands.py new file mode 100644 index 0000000..03f29e1 --- /dev/null +++ b/src/ableton_cli/commands/_track_info_commands.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable + +import typer + +from ._track_shared import TrackArgument +from ._track_specs import TrackCommandSpec + +TRACK_INFO_SPEC = TrackCommandSpec( + command_name="track info", + client_method="get_track_info", +) + + +def register_commands( + track_app: typer.Typer, + *, + run_track_command_spec: Callable[..., None], +) -> None: + @track_app.command("info") + def track_info( + ctx: typer.Context, + track: TrackArgument, + ) -> None: + run_track_command_spec( + ctx, + spec=TRACK_INFO_SPEC, + track=track, + ) diff --git a/src/ableton_cli/commands/_track_mute_commands.py b/src/ableton_cli/commands/_track_mute_commands.py new file mode 100644 index 0000000..1544ae1 --- /dev/null +++ b/src/ableton_cli/commands/_track_mute_commands.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._track_shared import TrackArgument +from ._track_specs import TrackCommandSpec, TrackValueCommandSpec + +MUTE_GET_SPEC = TrackCommandSpec( + command_name="track mute get", + client_method="track_mute_get", +) + +MUTE_SET_SPEC = TrackValueCommandSpec[bool]( + command_name="track mute set", + client_method="track_mute_set", +) + + +def register_commands( + mute_app: typer.Typer, + *, + run_track_command_spec: Callable[..., None], + run_track_value_command_spec: Callable[..., None], +) -> None: + @mute_app.command("get") + def mute_get( + ctx: typer.Context, + track: TrackArgument, + ) -> None: + run_track_command_spec( + ctx, + spec=MUTE_GET_SPEC, + track=track, + ) + + @mute_app.command("set") + def mute_set( + ctx: typer.Context, + track: TrackArgument, + value: Annotated[bool, typer.Argument(help="Mute value: true|false")], + ) -> None: + run_track_value_command_spec( + ctx, + spec=MUTE_SET_SPEC, + track=track, + value=value, + ) diff --git a/src/ableton_cli/commands/_track_name_commands.py b/src/ableton_cli/commands/_track_name_commands.py new file mode 100644 index 0000000..dcc34b8 --- /dev/null +++ b/src/ableton_cli/commands/_track_name_commands.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._track_shared import TrackArgument +from ._track_specs import TrackValueCommandSpec +from ._validation import require_track_and_name + +NAME_SET_SPEC = TrackValueCommandSpec[str]( + command_name="track name set", + client_method="set_track_name", + value_name="name", + validators=(require_track_and_name,), +) + + +def register_commands( + name_app: typer.Typer, + *, + run_track_value_command_spec: Callable[..., None], +) -> None: + @name_app.command("set") + def track_name_set( + ctx: typer.Context, + track: TrackArgument, + name: Annotated[str, typer.Argument(help="New track name")], + ) -> None: + run_track_value_command_spec( + ctx, + spec=NAME_SET_SPEC, + track=track, + value=name, + ) diff --git a/src/ableton_cli/commands/_track_panning_commands.py b/src/ableton_cli/commands/_track_panning_commands.py new file mode 100644 index 0000000..5ede03b --- /dev/null +++ b/src/ableton_cli/commands/_track_panning_commands.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from collections.abc import Callable + +import typer + +from ._track_shared import PanningValueArgument, TrackArgument +from ._track_specs import TrackCommandSpec, TrackValueCommandSpec +from ._validation import require_track_and_pan + +PANNING_GET_SPEC = TrackCommandSpec( + command_name="track panning get", + client_method="track_panning_get", +) + +PANNING_SET_SPEC = TrackValueCommandSpec[float]( + command_name="track panning set", + client_method="track_panning_set", + validators=(require_track_and_pan,), +) + + +def register_commands( + panning_app: typer.Typer, + *, + run_track_command_spec: Callable[..., None], + run_track_value_command_spec: Callable[..., None], +) -> None: + @panning_app.command("get") + def panning_get( + ctx: typer.Context, + track: TrackArgument, + ) -> None: + run_track_command_spec( + ctx, + spec=PANNING_GET_SPEC, + track=track, + ) + + @panning_app.command("set") + def panning_set( + ctx: typer.Context, + track: TrackArgument, + value: PanningValueArgument, + ) -> None: + run_track_value_command_spec( + ctx, + spec=PANNING_SET_SPEC, + track=track, + value=value, + ) diff --git a/src/ableton_cli/commands/_track_shared.py b/src/ableton_cli/commands/_track_shared.py new file mode 100644 index 0000000..09e0d41 --- /dev/null +++ b/src/ableton_cli/commands/_track_shared.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated, TypeVar + +import typer + +TValue = TypeVar("TValue") + +TrackArgument = Annotated[int, typer.Argument(help="Track index (0-based)")] +VolumeValueArgument = Annotated[float, typer.Argument(help="Volume value in [0.0, 1.0]")] +PanningValueArgument = Annotated[float, typer.Argument(help="Panning value in [-1.0, 1.0]")] + +TrackValidator = Callable[[int], int] +TrackValueValidator = Callable[[int, TValue], tuple[int, TValue]] +TrackAction = Callable[[object, int], dict[str, object]] +TrackValueAction = Callable[[object, int, TValue], dict[str, object]] diff --git a/src/ableton_cli/commands/_track_solo_commands.py b/src/ableton_cli/commands/_track_solo_commands.py new file mode 100644 index 0000000..92ff3c4 --- /dev/null +++ b/src/ableton_cli/commands/_track_solo_commands.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Annotated + +import typer + +from ._track_shared import TrackArgument +from ._track_specs import TrackCommandSpec, TrackValueCommandSpec + +SOLO_GET_SPEC = TrackCommandSpec( + command_name="track solo get", + client_method="track_solo_get", +) + +SOLO_SET_SPEC = TrackValueCommandSpec[bool]( + command_name="track solo set", + client_method="track_solo_set", +) + + +def register_commands( + solo_app: typer.Typer, + *, + run_track_command_spec: Callable[..., None], + run_track_value_command_spec: Callable[..., None], +) -> None: + @solo_app.command("get") + def solo_get( + ctx: typer.Context, + track: TrackArgument, + ) -> None: + run_track_command_spec( + ctx, + spec=SOLO_GET_SPEC, + track=track, + ) + + @solo_app.command("set") + def solo_set( + ctx: typer.Context, + track: TrackArgument, + value: Annotated[bool, typer.Argument(help="Solo value: true|false")], + ) -> None: + run_track_value_command_spec( + ctx, + spec=SOLO_SET_SPEC, + track=track, + value=value, + ) diff --git a/src/ableton_cli/commands/_track_specs.py b/src/ableton_cli/commands/_track_specs.py new file mode 100644 index 0000000..6e13c13 --- /dev/null +++ b/src/ableton_cli/commands/_track_specs.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Generic + +from ._track_shared import TrackValidator, TrackValueValidator, TValue + + +@dataclass(frozen=True) +class TrackCommandSpec: + command_name: str + client_method: str + validators: Sequence[TrackValidator] | None = None + + +@dataclass(frozen=True) +class TrackValueCommandSpec(Generic[TValue]): + command_name: str + client_method: str + value_name: str = "value" + validators: Sequence[TrackValueValidator[TValue]] | None = None diff --git a/src/ableton_cli/commands/_track_volume_commands.py b/src/ableton_cli/commands/_track_volume_commands.py new file mode 100644 index 0000000..48b5ec6 --- /dev/null +++ b/src/ableton_cli/commands/_track_volume_commands.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from collections.abc import Callable + +import typer + +from ._track_shared import TrackArgument, VolumeValueArgument +from ._track_specs import TrackCommandSpec, TrackValueCommandSpec +from ._validation import require_track_and_volume + +VOLUME_GET_SPEC = TrackCommandSpec( + command_name="track volume get", + client_method="track_volume_get", +) + +VOLUME_SET_SPEC = TrackValueCommandSpec[float]( + command_name="track volume set", + client_method="track_volume_set", + validators=(require_track_and_volume,), +) + + +def register_commands( + volume_app: typer.Typer, + *, + run_track_command_spec: Callable[..., None], + run_track_value_command_spec: Callable[..., None], +) -> None: + @volume_app.command("get") + def volume_get( + ctx: typer.Context, + track: TrackArgument, + ) -> None: + run_track_command_spec( + ctx, + spec=VOLUME_GET_SPEC, + track=track, + ) + + @volume_app.command("set") + def volume_set( + ctx: typer.Context, + track: TrackArgument, + value: VolumeValueArgument, + ) -> None: + run_track_value_command_spec( + ctx, + spec=VOLUME_SET_SPEC, + track=track, + value=value, + ) diff --git a/src/ableton_cli/commands/_validation.py b/src/ableton_cli/commands/_validation.py index beceed8..52f01ff 100644 --- a/src/ableton_cli/commands/_validation.py +++ b/src/ableton_cli/commands/_validation.py @@ -4,7 +4,7 @@ from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any, TypeVar -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ExitCode NOTE_KEYS = {"pitch", "start_time", "duration", "velocity", "mute"} TRACK_INDEX_HINT = "Use a valid track index from 'ableton-cli tracks list'." @@ -23,7 +23,7 @@ def invalid_argument(message: str, hint: str) -> AppError: return AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message=message, hint=hint, exit_code=ExitCode.INVALID_ARGUMENT, diff --git a/src/ableton_cli/commands/arrangement.py b/src/ableton_cli/commands/arrangement.py index 586697a..c8476e0 100644 --- a/src/ableton_cli/commands/arrangement.py +++ b/src/ableton_cli/commands/arrangement.py @@ -1,22 +1,17 @@ from __future__ import annotations -from typing import Annotated +from collections.abc import Callable import typer from ..runtime import execute_command, get_client -from ._validation import ( - invalid_argument, - parse_notes_input, - require_absolute_path, - require_non_empty_string, - require_non_negative, - require_non_negative_float, - require_positive_float, - resolve_uri_or_path_target, - validate_clip_note_filters, -) -from .clip._parsers import parse_arrangement_scene_durations +from ._arrangement_clip_commands import register_commands as register_clip_commands +from ._arrangement_notes_commands import register_commands as register_notes_commands +from ._arrangement_record_commands import register_commands as register_record_commands +from ._arrangement_session_commands import register_commands as register_session_commands +from ._arrangement_specs import ArrangementCommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared arrangement_app = typer.Typer(help="Arrangement commands", no_args_is_help=True) record_app = typer.Typer(help="Arrangement recording commands", no_args_is_help=True) @@ -24,519 +19,44 @@ notes_app = typer.Typer(help="Arrangement clip note commands", no_args_is_help=True) -@record_app.command("start") -def arrangement_record_start(ctx: typer.Context) -> None: - execute_command( - ctx, - command="arrangement record start", - args={}, - action=lambda: get_client(ctx).arrangement_record_start(), - ) - - -@record_app.command("stop") -def arrangement_record_stop(ctx: typer.Context) -> None: - execute_command( - ctx, - command="arrangement record stop", - args={}, - action=lambda: get_client(ctx).arrangement_record_stop(), - ) - - -@clip_app.command("create") -def arrangement_clip_create( - ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - start: Annotated[ - float, - typer.Option( - "--start", - help="Arrangement start time in beats", - ), - ], - length: Annotated[ - float, - typer.Option( - "--length", - help="Arrangement clip length in beats", - ), - ], - audio_path: Annotated[ - str | None, - typer.Option( - "--audio-path", - help="Absolute audio file path for audio tracks", - ), - ] = None, - notes_json: Annotated[ - str | None, - typer.Option("--notes-json", help="JSON array of note objects for MIDI clips"), - ] = None, - notes_file: Annotated[ - str | None, - typer.Option("--notes-file", help="Path to JSON file containing note array for MIDI clips"), - ] = None, -) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - require_non_negative_float( - "start", - start, - hint="Use a non-negative --start value in beats.", - ) - require_positive_float( - "length", - length, - hint="Use a positive --length value in beats.", - ) - normalized_audio_path = ( - require_absolute_path( - "audio_path", - audio_path, - hint="Pass an absolute file path for --audio-path.", - ) - if audio_path is not None - else None - ) - notes = ( - parse_notes_input(notes_json=notes_json, notes_file=notes_file) - if notes_json is not None or notes_file is not None - else None - ) - if normalized_audio_path is not None and notes is not None: - raise invalid_argument( - message="--audio-path and --notes-json/--notes-file are mutually exclusive", - hint="Use notes options for MIDI clips or --audio-path for audio clips.", - ) - return get_client(ctx).arrangement_clip_create( - track=track, - start_time=start, - length=length, - audio_path=normalized_audio_path, - notes=notes, - ) - - execute_command( - ctx, - command="arrangement clip create", - args={ - "track": track, - "start_time": start, - "length": length, - "audio_path": audio_path, - "notes": notes_json is not None or notes_file is not None, - }, - action=_run, - ) - - -@clip_app.command("list") -def arrangement_clip_list( +def run_client_command( ctx: typer.Context, - track: Annotated[ - int | None, - typer.Option( - "--track", - help="Optional track index filter (0-based)", - ), - ] = None, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], ) -> None: - def _run() -> dict[str, object]: - if track is not None: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).arrangement_clip_list(track=track) - - execute_command( + run_client_command_shared( ctx, - command="arrangement clip list", - args={"track": track}, - action=_run, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, ) -@notes_app.command("add") -def arrangement_clip_notes_add( +def run_client_command_spec( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], - notes_json: Annotated[ - str | None, - typer.Option("--notes-json", help="JSON array of note objects"), - ] = None, - notes_file: Annotated[ - str | None, - typer.Option("--notes-file", help="Path to JSON file containing note array"), - ] = None, + *, + spec: ArrangementCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, ) -> None: - def _run() -> dict[str, object]: - valid_track = require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_index = require_non_negative( - "index", - index, - hint="Use a valid arrangement clip index from 'ableton-cli arrangement clip list'.", - ) - notes = parse_notes_input(notes_json=notes_json, notes_file=notes_file) - return get_client(ctx).arrangement_clip_notes_add( - track=valid_track, - index=valid_index, - notes=notes, - ) - - execute_command( + run_client_command_spec_shared( ctx, - command="arrangement clip notes add", - args={"track": track, "index": index}, - action=_run, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, ) -@notes_app.command("get") -def arrangement_clip_notes_get( - ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], - start_time: Annotated[ - float | None, - typer.Option("--start-time", help="Inclusive start time filter in beats"), - ] = None, - end_time: Annotated[ - float | None, - typer.Option("--end-time", help="Exclusive end time filter in beats"), - ] = None, - pitch: Annotated[int | None, typer.Option("--pitch", help="Exact MIDI pitch filter")] = None, -) -> None: - def _run() -> dict[str, object]: - valid_track = require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_index = require_non_negative( - "index", - index, - hint="Use a valid arrangement clip index from 'ableton-cli arrangement clip list'.", - ) - filters = validate_clip_note_filters( - start_time=start_time, - end_time=end_time, - pitch=pitch, - ) - return get_client(ctx).arrangement_clip_notes_get( - track=valid_track, - index=valid_index, - start_time=filters["start_time"], - end_time=filters["end_time"], - pitch=filters["pitch"], - ) - - execute_command( - ctx, - command="arrangement clip notes get", - args={"track": track, "index": index, "start_time": start_time, "end_time": end_time}, - action=_run, - ) - - -@notes_app.command("clear") -def arrangement_clip_notes_clear( - ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], - start_time: Annotated[ - float | None, - typer.Option("--start-time", help="Inclusive start time filter in beats"), - ] = None, - end_time: Annotated[ - float | None, - typer.Option("--end-time", help="Exclusive end time filter in beats"), - ] = None, - pitch: Annotated[int | None, typer.Option("--pitch", help="Exact MIDI pitch filter")] = None, -) -> None: - def _run() -> dict[str, object]: - valid_track = require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_index = require_non_negative( - "index", - index, - hint="Use a valid arrangement clip index from 'ableton-cli arrangement clip list'.", - ) - filters = validate_clip_note_filters( - start_time=start_time, - end_time=end_time, - pitch=pitch, - ) - return get_client(ctx).arrangement_clip_notes_clear( - track=valid_track, - index=valid_index, - start_time=filters["start_time"], - end_time=filters["end_time"], - pitch=filters["pitch"], - ) - - execute_command( - ctx, - command="arrangement clip notes clear", - args={"track": track, "index": index, "start_time": start_time, "end_time": end_time}, - action=_run, - ) - - -@notes_app.command("replace") -def arrangement_clip_notes_replace( - ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], - notes_json: Annotated[ - str | None, - typer.Option("--notes-json", help="JSON array of note objects"), - ] = None, - notes_file: Annotated[ - str | None, - typer.Option("--notes-file", help="Path to JSON file containing note array"), - ] = None, - start_time: Annotated[ - float | None, - typer.Option("--start-time", help="Inclusive start time filter in beats"), - ] = None, - end_time: Annotated[ - float | None, - typer.Option("--end-time", help="Exclusive end time filter in beats"), - ] = None, - pitch: Annotated[int | None, typer.Option("--pitch", help="Exact MIDI pitch filter")] = None, -) -> None: - def _run() -> dict[str, object]: - valid_track = require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_index = require_non_negative( - "index", - index, - hint="Use a valid arrangement clip index from 'ableton-cli arrangement clip list'.", - ) - notes = parse_notes_input(notes_json=notes_json, notes_file=notes_file) - filters = validate_clip_note_filters( - start_time=start_time, - end_time=end_time, - pitch=pitch, - ) - return get_client(ctx).arrangement_clip_notes_replace( - track=valid_track, - index=valid_index, - notes=notes, - start_time=filters["start_time"], - end_time=filters["end_time"], - pitch=filters["pitch"], - ) - - execute_command( - ctx, - command="arrangement clip notes replace", - args={"track": track, "index": index, "start_time": start_time, "end_time": end_time}, - action=_run, - ) - - -@notes_app.command("import-browser") -def arrangement_clip_notes_import_browser( - ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - index: Annotated[int, typer.Argument(help="Arrangement clip index from list output")], - target: Annotated[str, typer.Argument(help="Browser target (URI or path to .alc)")], - mode: Annotated[ - str, typer.Option("--mode", help="Note import mode: replace|append") - ] = "replace", - import_length: Annotated[ - bool, - typer.Option( - "--import-length/--no-import-length", - help="Copy source clip length into the destination arrangement clip", - ), - ] = False, - import_groove: Annotated[ - bool, - typer.Option( - "--import-groove/--no-import-groove", - help="Copy source clip groove settings into the destination arrangement clip", - ), - ] = False, -) -> None: - def _run() -> dict[str, object]: - valid_track = require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_index = require_non_negative( - "index", - index, - hint="Use a valid arrangement clip index from 'ableton-cli arrangement clip list'.", - ) - valid_mode = require_non_empty_string( - "mode", - mode, - hint="Use --mode replace or append.", - ).lower() - if valid_mode not in {"replace", "append"}: - raise invalid_argument( - message=f"mode must be one of replace/append, got {mode}", - hint="Use --mode replace or append.", - ) - target_uri, target_path = resolve_uri_or_path_target( - target=target, - hint="Use a browser path or URI for a .alc MIDI clip item.", - ) - return get_client(ctx).arrangement_clip_notes_import_browser( - track=valid_track, - index=valid_index, - target_uri=target_uri, - target_path=target_path, - mode=valid_mode, - import_length=import_length, - import_groove=import_groove, - ) - - execute_command( - ctx, - command="arrangement clip notes import-browser", - args={ - "track": track, - "index": index, - "target": target, - "mode": mode, - "import_length": import_length, - "import_groove": import_groove, - }, - action=_run, - ) - - -@clip_app.command("delete") -def arrangement_clip_delete( - ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - index: Annotated[ - int | None, - typer.Argument(help="Arrangement clip index to delete", show_default=False), - ] = None, - start: Annotated[ - float | None, - typer.Option("--start", help="Range start beat (inclusive)"), - ] = None, - end: Annotated[ - float | None, - typer.Option("--end", help="Range end beat (exclusive)"), - ] = None, - all_: Annotated[ - bool, - typer.Option("--all", help="Delete all arrangement clips on the track"), - ] = False, -) -> None: - def _run() -> dict[str, object]: - valid_track = require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_index = ( - require_non_negative( - "index", - index, - hint="Use a valid arrangement clip index from 'ableton-cli arrangement clip list'.", - ) - if index is not None - else None - ) - has_range_value = start is not None or end is not None - if has_range_value and (start is None or end is None): - raise invalid_argument( - message="--start and --end must be provided together for range delete mode", - hint="Use both --start and --end, or use index/--all mode.", - ) - mode_count = int(valid_index is not None) + int(has_range_value) + int(all_) - if mode_count != 1: - raise invalid_argument( - message="Exactly one delete mode must be selected: index, range, or --all", - hint="Use one of: | --start/--end | --all.", - ) - - valid_start = None - valid_end = None - if has_range_value: - assert start is not None - assert end is not None - valid_start = require_non_negative_float( - "start", - start, - hint="Use a non-negative --start value in beats.", - ) - valid_end = require_non_negative_float( - "end", - end, - hint="Use a non-negative --end value in beats.", - ) - if valid_end <= valid_start: - raise invalid_argument( - message=( - f"end must be greater than start (start={valid_start}, end={valid_end})" - ), - hint="Use a valid [start, end) range with end > start.", - ) - - return get_client(ctx).arrangement_clip_delete( - track=valid_track, - index=valid_index, - start=valid_start, - end=valid_end, - delete_all=all_, - ) - - execute_command( - ctx, - command="arrangement clip delete", - args={"track": track, "index": index, "start": start, "end": end, "all": all_}, - action=_run, - ) - - -@arrangement_app.command("from-session") -def arrangement_from_session( - ctx: typer.Context, - scenes: Annotated[ - str, - typer.Option( - "--scenes", - help="Scene duration map as CSV (scene_index:duration_beats,...)", - ), - ], -) -> None: - def _run() -> dict[str, object]: - parsed = parse_arrangement_scene_durations(scenes) - return get_client(ctx).arrangement_from_session(parsed) - - execute_command( - ctx, - command="arrangement from-session", - args={"scenes": require_non_empty_string("scenes", scenes, hint="Use --scenes 0:24,1:48.")}, - action=_run, - ) +register_record_commands(record_app, run_client_command_spec=run_client_command_spec) +register_clip_commands(clip_app, run_client_command_spec=run_client_command_spec) +register_notes_commands(notes_app, run_client_command_spec=run_client_command_spec) +register_session_commands(arrangement_app, run_client_command_spec=run_client_command_spec) arrangement_app.add_typer(record_app, name="record") diff --git a/src/ableton_cli/commands/batch.py b/src/ableton_cli/commands/batch.py index 2220f0b..b8f4c0d 100644 --- a/src/ableton_cli/commands/batch.py +++ b/src/ableton_cli/commands/batch.py @@ -9,7 +9,7 @@ import typer from ..capabilities import parse_supported_commands, required_remote_commands -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ExitCode from ..runtime import execute_command, get_client, get_runtime from ._validation import invalid_argument, require_non_empty_string @@ -344,7 +344,7 @@ def _raise_assert_failure( actual: Any = None, ) -> None: raise AppError( - error_code="BATCH_ASSERT_FAILED", + error_code=ErrorCode.BATCH_ASSERT_FAILED, message=f"Batch assert failed at step {step_index}", hint="Fix batch assert conditions or preceding step behavior.", exit_code=ExitCode.EXECUTION_FAILED, @@ -411,7 +411,7 @@ def _run_preflight( supported_commands = parse_supported_commands(ping_result) except AppError as exc: raise AppError( - error_code="BATCH_PREFLIGHT_FAILED", + error_code=ErrorCode.BATCH_PREFLIGHT_FAILED, message="Batch preflight failed while validating ping/capabilities", hint=exc.hint or "Fix protocol/capability mismatch before retrying batch.", exit_code=ExitCode.EXECUTION_FAILED, @@ -425,7 +425,7 @@ def _run_preflight( remote_protocol = ping_result.get("protocol_version") if remote_protocol != expected_protocol: raise AppError( - error_code="BATCH_PREFLIGHT_FAILED", + error_code=ErrorCode.BATCH_PREFLIGHT_FAILED, message="Batch preflight protocol_version mismatch", hint="Align CLI protocol version and Remote Script protocol version.", exit_code=ExitCode.EXECUTION_FAILED, @@ -439,7 +439,7 @@ def _run_preflight( remote_hash = ping_result.get("command_set_hash") if expected_hash is not None and remote_hash != expected_hash: raise AppError( - error_code="BATCH_PREFLIGHT_FAILED", + error_code=ErrorCode.BATCH_PREFLIGHT_FAILED, message="Batch preflight command_set_hash mismatch", hint="Update Remote Script or batch preflight command_set_hash.", exit_code=ExitCode.EXECUTION_FAILED, @@ -457,7 +457,7 @@ def _run_preflight( missing = sorted(required.difference(supported_commands)) if missing: raise AppError( - error_code="BATCH_PREFLIGHT_FAILED", + error_code=ErrorCode.BATCH_PREFLIGHT_FAILED, message="Batch preflight detected missing required commands", hint="Reinstall Remote Script and restart Ableton Live.", exit_code=ExitCode.EXECUTION_FAILED, @@ -503,7 +503,7 @@ def _execute_step( raise if attempt >= max_attempts: raise AppError( - error_code="BATCH_RETRY_EXHAUSTED", + error_code=ErrorCode.BATCH_RETRY_EXHAUSTED, message=f"Retry exhausted for step {step_index}", hint="Increase retry.max_attempts or fix underlying command errors.", exit_code=ExitCode.EXECUTION_FAILED, diff --git a/src/ableton_cli/commands/browser.py b/src/ableton_cli/commands/browser.py index f9c3b8a..e76936d 100644 --- a/src/ableton_cli/commands/browser.py +++ b/src/ableton_cli/commands/browser.py @@ -1,10 +1,14 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer from ..runtime import execute_command, get_client +from ._client_command_runner import CommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import ( invalid_argument, require_non_empty_string, @@ -15,6 +19,100 @@ browser_app = typer.Typer(help="Ableton browser commands", no_args_is_help=True) +BrowserCommandSpec = CommandSpec + + +BROWSER_TREE_SPEC = BrowserCommandSpec( + command_name="browser tree", + client_method="get_browser_tree", +) +BROWSER_ITEMS_AT_PATH_SPEC = BrowserCommandSpec( + command_name="browser items-at-path", + client_method="get_browser_items_at_path", +) +BROWSER_ITEM_SPEC = BrowserCommandSpec( + command_name="browser item", + client_method="get_browser_item", +) +BROWSER_CATEGORIES_SPEC = BrowserCommandSpec( + command_name="browser categories", + client_method="get_browser_categories", +) +BROWSER_ITEMS_SPEC = BrowserCommandSpec( + command_name="browser items", + client_method="get_browser_items", +) +BROWSER_SEARCH_SPEC = BrowserCommandSpec( + command_name="browser search", + client_method="search_browser_items", +) +BROWSER_LOAD_SPEC = BrowserCommandSpec( + command_name="browser load", + client_method="load_instrument_or_effect", +) +BROWSER_LOAD_DRUM_KIT_SPEC = BrowserCommandSpec( + command_name="browser load-drum-kit", + client_method="load_drum_kit", +) + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: BrowserCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def _validate_item_type(item_type: str) -> str: + if item_type not in {"all", "folder", "device", "loadable"}: + raise invalid_argument( + message=f"item_type must be one of all/folder/device/loadable, got {item_type}", + hint="Use one of: all, folder, device, loadable.", + ) + return item_type + + +def _validate_paging(*, limit: int, offset: int) -> tuple[int, int]: + if limit <= 0: + raise invalid_argument( + message=f"limit must be > 0, got {limit}", + hint="Use a positive limit value.", + ) + if offset < 0: + raise invalid_argument( + message=f"offset must be >= 0, got {offset}", + hint="Use a non-negative offset value.", + ) + return limit, offset + + @browser_app.command("tree") def browser_tree( ctx: typer.Context, @@ -23,11 +121,11 @@ def browser_tree( typer.Argument(help="Category type, e.g. all/instruments/sounds/drums/audio_effects."), ] = "all", ) -> None: - execute_command( + run_client_command_spec( ctx, - command="browser tree", + spec=BROWSER_TREE_SPEC, args={"category_type": category_type}, - action=lambda: get_client(ctx).get_browser_tree(category_type), + method_kwargs={"category_type": category_type}, ) @@ -36,15 +134,15 @@ def browser_items_at_path( ctx: typer.Context, path: Annotated[str, typer.Argument(help="Browser path, e.g. drums/Kits")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_path = require_non_empty_string("path", path, hint="Pass a non-empty browser path.") - return get_client(ctx).get_browser_items_at_path(valid_path) + return {"path": valid_path} - execute_command( + run_client_command_spec( ctx, - command="browser items-at-path", + spec=BROWSER_ITEMS_AT_PATH_SPEC, args={"path": path}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -53,15 +151,15 @@ def browser_item( ctx: typer.Context, target: Annotated[str, typer.Argument(help="Browser target (URI or path)")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_uri, valid_path = resolve_uri_or_path_target(target=target) - return get_client(ctx).get_browser_item(uri=valid_uri, path=valid_path) + return {"uri": valid_uri, "path": valid_path} - execute_command( + run_client_command_spec( ctx, - command="browser item", + spec=BROWSER_ITEM_SPEC, args={"target": target}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -70,11 +168,11 @@ def browser_categories( ctx: typer.Context, category_type: Annotated[str, typer.Argument(help="Category filter")] = "all", ) -> None: - execute_command( + run_client_command_spec( ctx, - command="browser categories", + spec=BROWSER_CATEGORIES_SPEC, args={"category_type": category_type}, - action=lambda: get_client(ctx).get_browser_categories(category_type), + method_kwargs={"category_type": category_type}, ) @@ -89,30 +187,22 @@ def browser_items( limit: Annotated[int, typer.Option("--limit", help="Maximum number of items")] = 100, offset: Annotated[int, typer.Option("--offset", help="Pagination offset")] = 0, ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_path = require_non_empty_string("path", path, hint="Pass a non-empty browser path.") - if item_type not in {"all", "folder", "device", "loadable"}: - raise invalid_argument( - message=f"item_type must be one of all/folder/device/loadable, got {item_type}", - hint="Use one of: all, folder, device, loadable.", - ) - if limit <= 0: - raise invalid_argument( - message=f"limit must be > 0, got {limit}", - hint="Use a positive limit value.", - ) - if offset < 0: - raise invalid_argument( - message=f"offset must be >= 0, got {offset}", - hint="Use a non-negative offset value.", - ) - return get_client(ctx).get_browser_items(valid_path, item_type, limit, offset) + valid_item_type = _validate_item_type(item_type) + valid_limit, valid_offset = _validate_paging(limit=limit, offset=offset) + return { + "path": valid_path, + "item_type": valid_item_type, + "limit": valid_limit, + "offset": valid_offset, + } - execute_command( + run_client_command_spec( ctx, - command="browser items", + spec=BROWSER_ITEMS_SPEC, args={"path": path, "item_type": item_type, "limit": limit, "offset": offset}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -136,41 +226,28 @@ def browser_search( typer.Option("--case-sensitive", help="Use case-sensitive matching"), ] = False, ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_query = require_non_empty_string("query", query, hint="Pass a non-empty query.") valid_path = ( require_non_empty_string("path", path, hint="Pass a non-empty browser path.") if path is not None else None ) - if item_type not in {"all", "folder", "device", "loadable"}: - raise invalid_argument( - message=f"item_type must be one of all/folder/device/loadable, got {item_type}", - hint="Use one of: all, folder, device, loadable.", - ) - if limit <= 0: - raise invalid_argument( - message=f"limit must be > 0, got {limit}", - hint="Use a positive limit value.", - ) - if offset < 0: - raise invalid_argument( - message=f"offset must be >= 0, got {offset}", - hint="Use a non-negative offset value.", - ) - return get_client(ctx).search_browser_items( - query=valid_query, - path=valid_path, - item_type=item_type, - limit=limit, - offset=offset, - exact=exact, - case_sensitive=case_sensitive, - ) + valid_item_type = _validate_item_type(item_type) + valid_limit, valid_offset = _validate_paging(limit=limit, offset=offset) + return { + "query": valid_query, + "path": valid_path, + "item_type": valid_item_type, + "limit": valid_limit, + "offset": valid_offset, + "exact": exact, + "case_sensitive": case_sensitive, + } - execute_command( + run_client_command_spec( ctx, - command="browser search", + spec=BROWSER_SEARCH_SPEC, args={ "query": query, "path": path, @@ -180,7 +257,7 @@ def _run() -> dict[str, object]: "exact": exact, "case_sensitive": case_sensitive, }, - action=_run, + method_kwargs=_method_kwargs, ) @@ -226,8 +303,8 @@ def browser_load( ), ] = False, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( + def _method_kwargs() -> dict[str, object]: + valid_track = require_non_negative( "track", track, hint="Use a valid track index from 'ableton-cli tracks list'.", @@ -273,21 +350,21 @@ def _run() -> dict[str, object]: hint="Use --notes-mode replace or append when importing clip length/groove.", ) valid_uri, valid_path = resolve_uri_or_path_target(target=target) - return get_client(ctx).load_instrument_or_effect( - track, - uri=valid_uri, - path=valid_path, - target_track_mode=valid_mode, - clip_slot=valid_clip_slot, - notes_mode=valid_notes_mode, - preserve_track_name=preserve_track_name, - import_length=import_length, - import_groove=import_groove, - ) + return { + "track": valid_track, + "uri": valid_uri, + "path": valid_path, + "target_track_mode": valid_mode, + "clip_slot": valid_clip_slot, + "notes_mode": valid_notes_mode, + "preserve_track_name": preserve_track_name, + "import_length": import_length, + "import_groove": import_groove, + } - execute_command( + run_client_command_spec( ctx, - command="browser load", + spec=BROWSER_LOAD_SPEC, args={ "track": track, "target": target, @@ -298,7 +375,7 @@ def _run() -> dict[str, object]: "import_length": import_length, "import_groove": import_groove, }, - action=_run, + method_kwargs=_method_kwargs, ) @@ -313,8 +390,8 @@ def browser_load_drum_kit( typer.Option("--kit-path", help="Browser path for drum kit item"), ] = None, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( + def _method_kwargs() -> dict[str, object]: + valid_track = require_non_negative( "track", track, hint="Use a valid track index from 'ableton-cli tracks list'.", @@ -344,18 +421,18 @@ def _run() -> dict[str, object]: if kit_path is not None else None ) - return get_client(ctx).load_drum_kit( - track=track, - rack_uri=valid_rack_uri, - kit_uri=valid_kit_uri, - kit_path=valid_kit_path, - ) + return { + "track": valid_track, + "rack_uri": valid_rack_uri, + "kit_uri": valid_kit_uri, + "kit_path": valid_kit_path, + } - execute_command( + run_client_command_spec( ctx, - command="browser load-drum-kit", + spec=BROWSER_LOAD_DRUM_KIT_SPEC, args={"track": track, "rack_uri": rack_uri, "kit_uri": kit_uri, "kit_path": kit_path}, - action=_run, + method_kwargs=_method_kwargs, ) diff --git a/src/ableton_cli/commands/device.py b/src/ableton_cli/commands/device.py index 9323923..d08bf1c 100644 --- a/src/ableton_cli/commands/device.py +++ b/src/ableton_cli/commands/device.py @@ -1,16 +1,63 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer from ..runtime import execute_command, get_client +from ._client_command_runner import CommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import require_non_negative device_app = typer.Typer(help="Device commands", no_args_is_help=True) parameter_app = typer.Typer(help="Device parameter commands", no_args_is_help=True) +DeviceCommandSpec = CommandSpec + + +DEVICE_PARAMETER_SET_SPEC = DeviceCommandSpec( + command_name="device parameter set", + client_method="set_device_parameter", +) + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: DeviceCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + @parameter_app.command("set") def set_device_parameter( ctx: typer.Context, @@ -19,7 +66,7 @@ def set_device_parameter( parameter: Annotated[int, typer.Argument(help="Parameter index (0-based)")], value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: require_non_negative( "track", track, @@ -35,18 +82,23 @@ def _run() -> dict[str, object]: parameter, hint="Use a valid parameter index from 'ableton-cli track info'.", ) - return get_client(ctx).set_device_parameter(track, device, parameter, value) + return { + "track": track, + "device": device, + "parameter": parameter, + "value": value, + } - execute_command( + run_client_command_spec( ctx, - command="device parameter set", + spec=DEVICE_PARAMETER_SET_SPEC, args={ "track": track, "device": device, "parameter": parameter, "value": value, }, - action=_run, + method_kwargs=_method_kwargs, ) diff --git a/src/ableton_cli/commands/effect.py b/src/ableton_cli/commands/effect.py index c38b65d..32dffdc 100644 --- a/src/ableton_cli/commands/effect.py +++ b/src/ableton_cli/commands/effect.py @@ -1,11 +1,14 @@ from __future__ import annotations from collections.abc import Callable, Sequence -from typing import Annotated +from dataclasses import dataclass +from typing import Annotated, cast import typer from ..runtime import execute_command, get_client +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import ( invalid_argument, require_non_empty_string, @@ -35,6 +38,37 @@ TrackDeviceAction = Callable[[object, int, int], dict[str, object]] +@dataclass(frozen=True) +class EffectCommandSpec: + command_name: str + client_method: str + + +@dataclass(frozen=True) +class TrackDeviceCommandSpec: + command_name: str + client_method: str + validators: Sequence[TrackDeviceValidator] | None = None + + +EFFECT_FIND_SPEC = EffectCommandSpec( + command_name="effect find", + client_method="find_effect_devices", +) +EFFECT_PARAMETERS_LIST_SPEC = TrackDeviceCommandSpec( + command_name="effect parameters list", + client_method="list_effect_parameters", +) +EFFECT_PARAMETER_SET_SPEC = EffectCommandSpec( + command_name="effect parameter set", + client_method="set_effect_parameter_safe", +) +EFFECT_OBSERVE_SPEC = TrackDeviceCommandSpec( + command_name="effect observe", + client_method="observe_effect_parameters", +) + + def _normalize_effect_type(value: str) -> str: parsed = require_non_empty_string("effect_type", value, hint="Pass a non-empty effect type.") normalized = parsed.lower() @@ -80,6 +114,74 @@ def _run() -> dict[str, object]: ) +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: EffectCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_track_device_command_spec( + ctx: typer.Context, + *, + spec: TrackDeviceCommandSpec, + track: int, + device: int, + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + def _resolve_method_kwargs() -> dict[str, object]: + if callable(method_kwargs): + return method_kwargs() + if method_kwargs is None: + return {} + return method_kwargs + + run_track_device_command( + ctx, + command_name=spec.command_name, + track=track, + device=device, + validators=spec.validators, + fn=lambda client, valid_track, valid_device: cast( + dict[str, object], + getattr(client, spec.client_method)( + **{ + "track": valid_track, + "device": valid_device, + **_resolve_method_kwargs(), + } + ), + ), + ) + + @effect_app.command("find") def effect_find( ctx: typer.Context, @@ -95,17 +197,16 @@ def effect_find( ), ] = None, ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_track = require_optional_track_index(track) valid_type = _normalize_effect_type(effect_type) if effect_type is not None else None - client = get_client(ctx) - return client.find_effect_devices(track=valid_track, effect_type=valid_type) + return {"track": valid_track, "effect_type": valid_type} - execute_command( + run_client_command_spec( ctx, - command="effect find", + spec=EFFECT_FIND_SPEC, args={"track": track, "effect_type": effect_type}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -115,15 +216,11 @@ def effect_parameters_list( track: TrackArgument, device: DeviceArgument, ) -> None: - run_track_device_command( + run_track_device_command_spec( ctx, - command_name="effect parameters list", + spec=EFFECT_PARAMETERS_LIST_SPEC, track=track, device=device, - fn=lambda client, valid_track, valid_device: client.list_effect_parameters( - track=valid_track, - device=valid_device, - ), ) @@ -135,22 +232,21 @@ def effect_parameter_set( parameter: ParameterArgument, value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_track, valid_device = require_track_and_device(track, device) valid_parameter = _require_effect_parameter_index(parameter) - client = get_client(ctx) - return client.set_effect_parameter_safe( - track=valid_track, - device=valid_device, - parameter=valid_parameter, - value=value, - ) - - execute_command( + return { + "track": valid_track, + "device": valid_device, + "parameter": valid_parameter, + "value": value, + } + + run_client_command_spec( ctx, - command="effect parameter set", + spec=EFFECT_PARAMETER_SET_SPEC, args={"track": track, "device": device, "parameter": parameter, "value": value}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -160,15 +256,11 @@ def effect_observe( track: TrackArgument, device: DeviceArgument, ) -> None: - run_track_device_command( + run_track_device_command_spec( ctx, - command_name="effect observe", + spec=EFFECT_OBSERVE_SPEC, track=track, device=device, - fn=lambda client, valid_track, valid_device: client.observe_effect_parameters( - track=valid_track, - device=valid_device, - ), ) @@ -177,18 +269,26 @@ def _build_standard_effect_app(effect_type: str, cli_name: str) -> typer.Typer: help=f"{cli_name.title()} effect wrapper commands", no_args_is_help=True, ) + keys_spec = EffectCommandSpec( + command_name=f"effect {cli_name} keys", + client_method="list_standard_effect_keys", + ) + set_spec = EffectCommandSpec( + command_name=f"effect {cli_name} set", + client_method="set_standard_effect_parameter_safe", + ) + observe_spec = TrackDeviceCommandSpec( + command_name=f"effect {cli_name} observe", + client_method="observe_standard_effect_state", + ) @standard_app.command("keys") def keys(ctx: typer.Context) -> None: - def _run() -> dict[str, object]: - client = get_client(ctx) - return client.list_standard_effect_keys(effect_type) - - execute_command( + run_client_command_spec( ctx, - command=f"effect {cli_name} keys", + spec=keys_spec, args={}, - action=_run, + method_kwargs={"effect_type": effect_type}, ) @standard_app.command("set") @@ -199,27 +299,26 @@ def standard_set( key: Annotated[str, typer.Argument(help="Stable effect key")], value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_track, valid_device = require_track_and_device(track, device) valid_key = require_non_empty_string( "key", key, hint="Pass a non-empty stable effect key.", ) - client = get_client(ctx) - return client.set_standard_effect_parameter_safe( - effect_type=effect_type, - track=valid_track, - device=valid_device, - key=valid_key, - value=value, - ) - - execute_command( + return { + "effect_type": effect_type, + "track": valid_track, + "device": valid_device, + "key": valid_key, + "value": value, + } + + run_client_command_spec( ctx, - command=f"effect {cli_name} set", + spec=set_spec, args={"track": track, "device": device, "key": key, "value": value}, - action=_run, + method_kwargs=_method_kwargs, ) @standard_app.command("observe") @@ -228,16 +327,12 @@ def standard_observe( track: TrackArgument, device: DeviceArgument, ) -> None: - run_track_device_command( + run_track_device_command_spec( ctx, - command_name=f"effect {cli_name} observe", + spec=observe_spec, track=track, device=device, - fn=lambda client, valid_track, valid_device: client.observe_standard_effect_state( - effect_type=effect_type, - track=valid_track, - device=valid_device, - ), + method_kwargs={"effect_type": effect_type}, ) return standard_app diff --git a/src/ableton_cli/commands/scenes.py b/src/ableton_cli/commands/scenes.py index 6d46d6d..d5985fd 100644 --- a/src/ableton_cli/commands/scenes.py +++ b/src/ableton_cli/commands/scenes.py @@ -1,7 +1,8 @@ from __future__ import annotations from collections.abc import Callable, Sequence -from typing import Annotated, TypeVar +from dataclasses import dataclass +from typing import Annotated, Generic, TypeVar, cast import typer @@ -28,6 +29,28 @@ SceneMoveAction = Callable[[object, int, int], dict[str, object]] +@dataclass(frozen=True) +class SceneCommandSpec: + command_name: str + client_method: str + validators: Sequence[SceneValidator] | None = None + + +@dataclass(frozen=True) +class SceneValueCommandSpec(Generic[TValue]): + command_name: str + client_method: str + value_name: str = "value" + validators: Sequence[SceneValueValidator[TValue]] | None = None + + +@dataclass(frozen=True) +class SceneMoveCommandSpec: + command_name: str + client_method: str + validators: Sequence[SceneMoveValidator] | None = None + + def run_scene_command( ctx: typer.Context, *, @@ -108,6 +131,83 @@ def _run() -> dict[str, object]: ) +def run_scene_command_spec( + ctx: typer.Context, + *, + spec: SceneCommandSpec, + scene: int, +) -> None: + run_scene_command( + ctx, + command_name=spec.command_name, + scene=scene, + validators=spec.validators, + fn=lambda client, valid_scene: cast( + dict[str, object], + getattr(client, spec.client_method)(valid_scene), + ), + ) + + +def run_scene_value_command_spec( + ctx: typer.Context, + *, + spec: SceneValueCommandSpec[TValue], + scene: int, + value: TValue, +) -> None: + run_scene_value_command( + ctx, + command_name=spec.command_name, + scene=scene, + value=value, + value_name=spec.value_name, + validators=spec.validators, + fn=lambda client, valid_scene, valid_value: cast( + dict[str, object], + getattr(client, spec.client_method)(valid_scene, valid_value), + ), + ) + + +def run_scene_move_command_spec( + ctx: typer.Context, + *, + spec: SceneMoveCommandSpec, + from_scene: int, + to_scene: int, +) -> None: + run_scene_move_command( + ctx, + command_name=spec.command_name, + from_scene=from_scene, + to_scene=to_scene, + validators=spec.validators, + fn=lambda client, valid_from_scene, valid_to_scene: cast( + dict[str, object], + getattr(client, spec.client_method)(valid_from_scene, valid_to_scene), + ), + ) + + +SCENE_NAME_SET_SPEC = SceneValueCommandSpec[str]( + command_name="scenes name set", + client_method="set_scene_name", + value_name="name", + validators=(require_scene_and_name,), +) + +SCENE_FIRE_SPEC = SceneCommandSpec( + command_name="scenes fire", + client_method="fire_scene", +) + +SCENE_MOVE_SPEC = SceneMoveCommandSpec( + command_name="scenes move", + client_method="scenes_move", +) + + @scenes_app.command("list") def scenes_list(ctx: typer.Context) -> None: def _run() -> dict[str, object]: @@ -152,14 +252,11 @@ def scenes_name_set( scene: Annotated[int, typer.Argument(help="Scene index (0-based)")], name: Annotated[str, typer.Argument(help="New scene name")], ) -> None: - run_scene_value_command( + run_scene_value_command_spec( ctx, - command_name="scenes name set", + spec=SCENE_NAME_SET_SPEC, scene=scene, value=name, - value_name="name", - validators=[require_scene_and_name], - fn=lambda client, valid_scene, valid_name: client.set_scene_name(valid_scene, valid_name), ) @@ -168,11 +265,10 @@ def scenes_fire( ctx: typer.Context, scene: Annotated[int, typer.Argument(help="Scene index (0-based)")], ) -> None: - run_scene_command( + run_scene_command_spec( ctx, - command_name="scenes fire", + spec=SCENE_FIRE_SPEC, scene=scene, - fn=lambda client, valid_scene: client.fire_scene(valid_scene), ) @@ -182,14 +278,11 @@ def scenes_move( from_scene: Annotated[int, typer.Argument(help="Source scene index (0-based)")], to_scene: Annotated[int, typer.Argument(help="Destination scene index (0-based)")], ) -> None: - run_scene_move_command( + run_scene_move_command_spec( ctx, - command_name="scenes move", + spec=SCENE_MOVE_SPEC, from_scene=from_scene, to_scene=to_scene, - fn=lambda client, valid_from_scene, valid_to_scene: client.scenes_move( - valid_from_scene, valid_to_scene - ), ) diff --git a/src/ableton_cli/commands/session.py b/src/ableton_cli/commands/session.py index 56c9fb0..116a220 100644 --- a/src/ableton_cli/commands/session.py +++ b/src/ableton_cli/commands/session.py @@ -1,34 +1,87 @@ from __future__ import annotations import json +from collections.abc import Callable from pathlib import Path import typer from ..runtime import execute_command, get_client from ..session_diff import compute_session_diff +from ._client_command_runner import CommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import invalid_argument session_app = typer.Typer(help="Session information commands", no_args_is_help=True) +SessionCommandSpec = CommandSpec + + +SESSION_INFO_SPEC = SessionCommandSpec( + command_name="session info", + client_method="get_session_info", +) +SESSION_SNAPSHOT_SPEC = SessionCommandSpec( + command_name="session snapshot", + client_method="session_snapshot", +) +SESSION_STOP_ALL_CLIPS_SPEC = SessionCommandSpec( + command_name="session stop-all-clips", + client_method="stop_all_clips", +) + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: SessionCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + @session_app.command("info") def session_info(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="session info", + spec=SESSION_INFO_SPEC, args={}, - action=lambda: get_client(ctx).get_session_info(), ) @session_app.command("snapshot") def session_snapshot(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="session snapshot", + spec=SESSION_SNAPSHOT_SPEC, args={}, - action=lambda: get_client(ctx).session_snapshot(), ) @@ -56,37 +109,37 @@ def _load_snapshot(path: str, *, source_name: str) -> dict[str, object]: return payload +def _session_diff_result(*, from_path: str, to_path: str) -> dict[str, object]: + from_snapshot = _load_snapshot(from_path, source_name="--from") + to_snapshot = _load_snapshot(to_path, source_name="--to") + result = compute_session_diff(from_snapshot, to_snapshot) + return { + "from_path": str(Path(from_path)), + "to_path": str(Path(to_path)), + **result, + } + + @session_app.command("diff") def session_diff( ctx: typer.Context, from_path: str = typer.Option(..., "--from"), to_path: str = typer.Option(..., "--to"), ) -> None: # noqa: E501 - def _run() -> dict[str, object]: - from_snapshot = _load_snapshot(from_path, source_name="--from") - to_snapshot = _load_snapshot(to_path, source_name="--to") - result = compute_session_diff(from_snapshot, to_snapshot) - return { - "from_path": str(Path(from_path)), - "to_path": str(Path(to_path)), - **result, - } - - execute_command( + run_client_command( ctx, - command="session diff", + command_name="session diff", args={"from": from_path, "to": to_path}, - action=_run, + fn=lambda _client: _session_diff_result(from_path=from_path, to_path=to_path), ) @session_app.command("stop-all-clips") def session_stop_all_clips(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="session stop-all-clips", + spec=SESSION_STOP_ALL_CLIPS_SPEC, args={}, - action=lambda: get_client(ctx).stop_all_clips(), ) diff --git a/src/ableton_cli/commands/setup.py b/src/ableton_cli/commands/setup.py index 8c289f5..51c175b 100644 --- a/src/ableton_cli/commands/setup.py +++ b/src/ableton_cli/commands/setup.py @@ -9,7 +9,7 @@ from ..completion import completion_help from ..config import default_config_path, init_config_file, update_config_value from ..doctor import run_doctor -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ExitCode from ..installer import install_remote_script, install_skill from ..runtime import execute_command, get_client, get_runtime from ._validation import invalid_argument, require_non_empty_string @@ -277,14 +277,14 @@ def wait_ready( def _run() -> dict[str, object]: if max_wait_ms <= 0: raise AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message=f"max_wait_ms must be > 0, got {max_wait_ms}", hint="Use a positive --max-wait-ms value.", exit_code=ExitCode.INVALID_ARGUMENT, ) if interval_ms <= 0: raise AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message=f"interval_ms must be > 0, got {interval_ms}", hint="Use a positive --interval-ms value.", exit_code=ExitCode.INVALID_ARGUMENT, @@ -314,7 +314,7 @@ def _run() -> dict[str, object]: f"{round(elapsed_ms, 3)}ms" ) raise AppError( - error_code="TIMEOUT", + error_code=ErrorCode.TIMEOUT, message=timeout_message, hint=exc.hint or "Start Ableton Live and enable the Remote Script, then retry.", diff --git a/src/ableton_cli/commands/song.py b/src/ableton_cli/commands/song.py index f3de9d1..621cdd1 100644 --- a/src/ableton_cli/commands/song.py +++ b/src/ableton_cli/commands/song.py @@ -1,33 +1,90 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer from ..runtime import execute_command, get_client +from ._client_command_runner import CommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import require_non_empty_string song_app = typer.Typer(help="Song and session information", no_args_is_help=True) song_export_app = typer.Typer(help="Song export commands", no_args_is_help=True) +SongCommandSpec = CommandSpec + + +SONG_INFO_SPEC = SongCommandSpec( + command_name="song info", + client_method="song_info", +) +SONG_NEW_SPEC = SongCommandSpec( + command_name="song new", + client_method="song_new", +) +SONG_SAVE_SPEC = SongCommandSpec( + command_name="song save", + client_method="song_save", +) +SONG_EXPORT_AUDIO_SPEC = SongCommandSpec( + command_name="song export audio", + client_method="song_export_audio", +) + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: SongCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + @song_app.command("info") def song_info(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="song info", + spec=SONG_INFO_SPEC, args={}, - action=lambda: get_client(ctx).song_info(), ) @song_app.command("new") def song_new(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="song new", + spec=SONG_NEW_SPEC, args={}, - action=lambda: get_client(ctx).song_new(), ) @@ -36,19 +93,19 @@ def song_save( ctx: typer.Context, path: Annotated[str, typer.Option("--path", help="Destination .als path")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_path = require_non_empty_string( "path", path, hint="Pass a non-empty --path for the destination .als file.", ) - return get_client(ctx).song_save(valid_path) + return {"path": valid_path} - execute_command( + run_client_command_spec( ctx, - command="song save", + spec=SONG_SAVE_SPEC, args={"path": path}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -57,19 +114,19 @@ def song_export_audio( ctx: typer.Context, path: Annotated[str, typer.Option("--path", help="Destination audio path (for example .wav)")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_path = require_non_empty_string( "path", path, hint="Pass a non-empty --path for exported audio.", ) - return get_client(ctx).song_export_audio(valid_path) + return {"path": valid_path} - execute_command( + run_client_command_spec( ctx, - command="song export audio", + spec=SONG_EXPORT_AUDIO_SPEC, args={"path": path}, - action=_run, + method_kwargs=_method_kwargs, ) diff --git a/src/ableton_cli/commands/track.py b/src/ableton_cli/commands/track.py index af5ae07..57cfd39 100644 --- a/src/ableton_cli/commands/track.py +++ b/src/ableton_cli/commands/track.py @@ -1,30 +1,31 @@ from __future__ import annotations -from collections.abc import Callable, Sequence -from typing import Annotated, TypeVar +from collections.abc import Sequence +from typing import cast import typer from ..runtime import execute_command, get_client +from ._track_arm_commands import register_commands as register_arm_commands +from ._track_info_commands import register_commands as register_info_commands +from ._track_mute_commands import register_commands as register_mute_commands +from ._track_name_commands import register_commands as register_name_commands +from ._track_panning_commands import register_commands as register_panning_commands +from ._track_shared import ( + TrackAction, + TrackValidator, + TrackValueAction, + TrackValueValidator, + TValue, +) +from ._track_solo_commands import register_commands as register_solo_commands +from ._track_specs import TrackCommandSpec, TrackValueCommandSpec +from ._track_volume_commands import register_commands as register_volume_commands from ._validation import ( - require_track_and_name, - require_track_and_pan, require_track_and_value, - require_track_and_volume, require_track_index, ) -TValue = TypeVar("TValue") - -TrackArgument = Annotated[int, typer.Argument(help="Track index (0-based)")] -VolumeValueArgument = Annotated[float, typer.Argument(help="Volume value in [0.0, 1.0]")] -PanningValueArgument = Annotated[float, typer.Argument(help="Panning value in [-1.0, 1.0]")] - -TrackValidator = Callable[[int], int] -TrackValueValidator = Callable[[int, TValue], tuple[int, TValue]] -TrackAction = Callable[[object, int], dict[str, object]] -TrackValueAction = Callable[[object, int, TValue], dict[str, object]] - def run_track_command( ctx: typer.Context, @@ -79,203 +80,83 @@ def _run() -> dict[str, object]: ) -track_app = typer.Typer(help="Single-track commands", no_args_is_help=True) -volume_app = typer.Typer(help="Track volume commands", no_args_is_help=True) -name_app = typer.Typer(help="Track naming commands", no_args_is_help=True) -mute_app = typer.Typer(help="Track mute commands", no_args_is_help=True) -solo_app = typer.Typer(help="Track solo commands", no_args_is_help=True) -arm_app = typer.Typer(help="Track arm commands", no_args_is_help=True) -panning_app = typer.Typer(help="Track panning commands", no_args_is_help=True) - - -@track_app.command("info") -def track_info( - ctx: typer.Context, - track: TrackArgument, -) -> None: - run_track_command( - ctx, - command_name="track info", - track=track, - fn=lambda client, valid_track: client.get_track_info(valid_track), - ) - - -@volume_app.command("get") -def volume_get( - ctx: typer.Context, - track: TrackArgument, -) -> None: - run_track_command( - ctx, - command_name="track volume get", - track=track, - fn=lambda client, valid_track: client.track_volume_get(valid_track), - ) - - -@volume_app.command("set") -def volume_set( - ctx: typer.Context, - track: TrackArgument, - value: VolumeValueArgument, -) -> None: - run_track_value_command( - ctx, - command_name="track volume set", - track=track, - value=value, - validators=[require_track_and_volume], - fn=lambda client, valid_track, valid_value: client.track_volume_set( - valid_track, - valid_value, - ), - ) - - -@name_app.command("set") -def track_name_set( - ctx: typer.Context, - track: TrackArgument, - name: Annotated[str, typer.Argument(help="New track name")], -) -> None: - run_track_value_command( - ctx, - command_name="track name set", - track=track, - value=name, - value_name="name", - validators=[require_track_and_name], - fn=lambda client, valid_track, valid_name: client.set_track_name( - valid_track, - valid_name, - ), - ) - - -@mute_app.command("get") -def mute_get( - ctx: typer.Context, - track: TrackArgument, -) -> None: - run_track_command( - ctx, - command_name="track mute get", - track=track, - fn=lambda client, valid_track: client.track_mute_get(valid_track), - ) - - -@mute_app.command("set") -def mute_set( - ctx: typer.Context, - track: TrackArgument, - value: Annotated[bool, typer.Argument(help="Mute value: true|false")], -) -> None: - run_track_value_command( - ctx, - command_name="track mute set", - track=track, - value=value, - fn=lambda client, valid_track, valid_value: client.track_mute_set( - valid_track, - valid_value, - ), - ) - - -@solo_app.command("get") -def solo_get( +def run_track_command_spec( ctx: typer.Context, - track: TrackArgument, + *, + spec: TrackCommandSpec, + track: int, ) -> None: run_track_command( ctx, - command_name="track solo get", + command_name=spec.command_name, track=track, - fn=lambda client, valid_track: client.track_solo_get(valid_track), - ) - - -@solo_app.command("set") -def solo_set( - ctx: typer.Context, - track: TrackArgument, - value: Annotated[bool, typer.Argument(help="Solo value: true|false")], -) -> None: - run_track_value_command( - ctx, - command_name="track solo set", - track=track, - value=value, - fn=lambda client, valid_track, valid_value: client.track_solo_set( - valid_track, - valid_value, + validators=spec.validators, + fn=lambda client, valid_track: cast( + dict[str, object], + getattr(client, spec.client_method)(valid_track), ), ) -@arm_app.command("get") -def arm_get( - ctx: typer.Context, - track: TrackArgument, -) -> None: - run_track_command( - ctx, - command_name="track arm get", - track=track, - fn=lambda client, valid_track: client.track_arm_get(valid_track), - ) - - -@arm_app.command("set") -def arm_set( +def run_track_value_command_spec( ctx: typer.Context, - track: TrackArgument, - value: Annotated[bool, typer.Argument(help="Arm value: true|false")], + *, + spec: TrackValueCommandSpec[TValue], + track: int, + value: TValue, ) -> None: run_track_value_command( ctx, - command_name="track arm set", + command_name=spec.command_name, track=track, value=value, - fn=lambda client, valid_track, valid_value: client.track_arm_set( - valid_track, - valid_value, + value_name=spec.value_name, + validators=spec.validators, + fn=lambda client, valid_track, valid_value: cast( + dict[str, object], + getattr(client, spec.client_method)(valid_track, valid_value), ), ) -@panning_app.command("get") -def panning_get( - ctx: typer.Context, - track: TrackArgument, -) -> None: - run_track_command( - ctx, - command_name="track panning get", - track=track, - fn=lambda client, valid_track: client.track_panning_get(valid_track), - ) - +track_app = typer.Typer(help="Single-track commands", no_args_is_help=True) +volume_app = typer.Typer(help="Track volume commands", no_args_is_help=True) +name_app = typer.Typer(help="Track naming commands", no_args_is_help=True) +mute_app = typer.Typer(help="Track mute commands", no_args_is_help=True) +solo_app = typer.Typer(help="Track solo commands", no_args_is_help=True) +arm_app = typer.Typer(help="Track arm commands", no_args_is_help=True) +panning_app = typer.Typer(help="Track panning commands", no_args_is_help=True) -@panning_app.command("set") -def panning_set( - ctx: typer.Context, - track: TrackArgument, - value: PanningValueArgument, -) -> None: - run_track_value_command( - ctx, - command_name="track panning set", - track=track, - value=value, - validators=[require_track_and_pan], - fn=lambda client, valid_track, valid_value: client.track_panning_set( - valid_track, - valid_value, - ), - ) +register_info_commands(track_app, run_track_command_spec=run_track_command_spec) +register_volume_commands( + volume_app, + run_track_command_spec=run_track_command_spec, + run_track_value_command_spec=run_track_value_command_spec, +) +register_name_commands( + name_app, + run_track_value_command_spec=run_track_value_command_spec, +) +register_mute_commands( + mute_app, + run_track_command_spec=run_track_command_spec, + run_track_value_command_spec=run_track_value_command_spec, +) +register_solo_commands( + solo_app, + run_track_command_spec=run_track_command_spec, + run_track_value_command_spec=run_track_value_command_spec, +) +register_arm_commands( + arm_app, + run_track_command_spec=run_track_command_spec, + run_track_value_command_spec=run_track_value_command_spec, +) +register_panning_commands( + panning_app, + run_track_command_spec=run_track_command_spec, + run_track_value_command_spec=run_track_value_command_spec, +) track_app.add_typer(volume_app, name="volume") diff --git a/src/ableton_cli/commands/tracks.py b/src/ableton_cli/commands/tracks.py index 5430814..661944a 100644 --- a/src/ableton_cli/commands/tracks.py +++ b/src/ableton_cli/commands/tracks.py @@ -1,23 +1,81 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer from ..runtime import execute_command, get_client +from ._client_command_runner import CommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import require_minus_one_or_non_negative, require_non_negative tracks_app = typer.Typer(help="Track collection commands", no_args_is_help=True) create_app = typer.Typer(help="Track creation commands", no_args_is_help=True) +TracksCommandSpec = CommandSpec + + +TRACKS_LIST_SPEC = TracksCommandSpec( + command_name="tracks list", + client_method="tracks_list", +) +TRACKS_CREATE_MIDI_SPEC = TracksCommandSpec( + command_name="tracks create midi", + client_method="create_midi_track", +) +TRACKS_CREATE_AUDIO_SPEC = TracksCommandSpec( + command_name="tracks create audio", + client_method="create_audio_track", +) +TRACKS_DELETE_SPEC = TracksCommandSpec( + command_name="tracks delete", + client_method="tracks_delete", +) + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: TracksCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + @tracks_app.command("list") def tracks_list(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="tracks list", + spec=TRACKS_LIST_SPEC, args={}, - action=lambda: get_client(ctx).tracks_list(), ) @@ -32,19 +90,19 @@ def create_midi_track( ), ] = -1, ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: require_minus_one_or_non_negative( "index", index, hint="Use -1 for append or a non-negative insertion index.", ) - return get_client(ctx).create_midi_track(index) + return {"index": index} - execute_command( + run_client_command_spec( ctx, - command="tracks create midi", + spec=TRACKS_CREATE_MIDI_SPEC, args={"index": index}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -59,19 +117,19 @@ def create_audio_track( ), ] = -1, ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: require_minus_one_or_non_negative( "index", index, hint="Use -1 for append or a non-negative insertion index.", ) - return get_client(ctx).create_audio_track(index) + return {"index": index} - execute_command( + run_client_command_spec( ctx, - command="tracks create audio", + spec=TRACKS_CREATE_AUDIO_SPEC, args={"index": index}, - action=_run, + method_kwargs=_method_kwargs, ) @@ -83,15 +141,15 @@ def delete_track( ctx: typer.Context, track: Annotated[int, typer.Argument(help="Track index (0-based)")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: require_non_negative("track", track, hint="Use a valid track index from 'tracks list'.") - return get_client(ctx).tracks_delete(track) + return {"track": track} - execute_command( + run_client_command_spec( ctx, - command="tracks delete", + spec=TRACKS_DELETE_SPEC, args={"track": track}, - action=_run, + method_kwargs=_method_kwargs, ) diff --git a/src/ableton_cli/commands/transport.py b/src/ableton_cli/commands/transport.py index bf22a2f..b37a733 100644 --- a/src/ableton_cli/commands/transport.py +++ b/src/ableton_cli/commands/transport.py @@ -1,11 +1,15 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ExitCode from ..runtime import execute_command, get_client +from ._client_command_runner import CommandSpec +from ._client_command_runner import run_client_command as run_client_command_shared +from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import require_non_negative_float transport_app = typer.Typer(help="Transport control commands", no_args_is_help=True) @@ -13,43 +17,110 @@ position_app = typer.Typer(help="Playhead position controls", no_args_is_help=True) +TransportCommandSpec = CommandSpec + + +TRANSPORT_PLAY_SPEC = TransportCommandSpec( + command_name="transport play", + client_method="transport_play", +) +TRANSPORT_STOP_SPEC = TransportCommandSpec( + command_name="transport stop", + client_method="transport_stop", +) +TRANSPORT_TOGGLE_SPEC = TransportCommandSpec( + command_name="transport toggle", + client_method="transport_toggle", +) +TRANSPORT_TEMPO_GET_SPEC = TransportCommandSpec( + command_name="transport tempo get", + client_method="transport_tempo_get", +) +TRANSPORT_TEMPO_SET_SPEC = TransportCommandSpec( + command_name="transport tempo set", + client_method="transport_tempo_set", +) +TRANSPORT_POSITION_GET_SPEC = TransportCommandSpec( + command_name="transport position get", + client_method="transport_position_get", +) +TRANSPORT_POSITION_SET_SPEC = TransportCommandSpec( + command_name="transport position set", + client_method="transport_position_set", +) +TRANSPORT_REWIND_SPEC = TransportCommandSpec( + command_name="transport rewind", + client_method="transport_rewind", +) + + +def run_client_command( + ctx: typer.Context, + *, + command_name: str, + args: dict[str, object], + fn: Callable[[object], dict[str, object]], +) -> None: + run_client_command_shared( + ctx, + command_name=command_name, + args=args, + fn=fn, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + +def run_client_command_spec( + ctx: typer.Context, + *, + spec: TransportCommandSpec, + args: dict[str, object], + method_kwargs: dict[str, object] | Callable[[], dict[str, object]] | None = None, +) -> None: + run_client_command_spec_shared( + ctx, + spec=spec, + args=args, + method_kwargs=method_kwargs, + get_client_fn=get_client, + execute_command_fn=execute_command, + ) + + @transport_app.command("play") def transport_play(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="transport play", + spec=TRANSPORT_PLAY_SPEC, args={}, - action=lambda: get_client(ctx).transport_play(), ) @transport_app.command("stop") def transport_stop(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="transport stop", + spec=TRANSPORT_STOP_SPEC, args={}, - action=lambda: get_client(ctx).transport_stop(), ) @transport_app.command("toggle") def transport_toggle(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="transport toggle", + spec=TRANSPORT_TOGGLE_SPEC, args={}, - action=lambda: get_client(ctx).transport_toggle(), ) @tempo_app.command("get") def tempo_get(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="transport tempo get", + spec=TRANSPORT_TEMPO_GET_SPEC, args={}, - action=lambda: get_client(ctx).transport_tempo_get(), ) @@ -58,31 +129,30 @@ def tempo_set( ctx: typer.Context, bpm: Annotated[float, typer.Argument(help="Target BPM. Allowed range: 20.0 to 999.0")], ) -> None: - def _run() -> dict[str, float]: + def _method_kwargs() -> dict[str, object]: if bpm < 20.0 or bpm > 999.0: raise AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message=f"bpm must be between 20.0 and 999.0, got {bpm}", hint="Use a valid tempo value such as 120.", exit_code=ExitCode.INVALID_ARGUMENT, ) - return get_client(ctx).transport_tempo_set(bpm) + return {"bpm": bpm} - execute_command( + run_client_command_spec( ctx, - command="transport tempo set", + spec=TRANSPORT_TEMPO_SET_SPEC, args={"bpm": bpm}, - action=_run, + method_kwargs=_method_kwargs, ) @position_app.command("get") def transport_position_get(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="transport position get", + spec=TRANSPORT_POSITION_GET_SPEC, args={}, - action=lambda: get_client(ctx).transport_position_get(), ) @@ -91,29 +161,28 @@ def transport_position_set( ctx: typer.Context, beats: Annotated[float, typer.Argument(help="Target beat position (>= 0)")], ) -> None: - def _run() -> dict[str, object]: + def _method_kwargs() -> dict[str, object]: valid_beats = require_non_negative_float( "beats", beats, hint="Use a non-negative beat position such as 0 or 32.", ) - return get_client(ctx).transport_position_set(valid_beats) + return {"beats": valid_beats} - execute_command( + run_client_command_spec( ctx, - command="transport position set", + spec=TRANSPORT_POSITION_SET_SPEC, args={"beats": beats}, - action=_run, + method_kwargs=_method_kwargs, ) @transport_app.command("rewind") def transport_rewind(ctx: typer.Context) -> None: - execute_command( + run_client_command_spec( ctx, - command="transport rewind", + spec=TRANSPORT_REWIND_SPEC, args={}, - action=lambda: get_client(ctx).transport_rewind(), ) diff --git a/src/ableton_cli/config.py b/src/ableton_cli/config.py index 2faeefe..2096897 100644 --- a/src/ableton_cli/config.py +++ b/src/ableton_cli/config.py @@ -9,7 +9,7 @@ import tomli from platformdirs import user_config_dir -from .errors import AppError, ExitCode +from .errors import AppError, ErrorCode, ExitCode ENV_PREFIX = "ABLETON_CLI_" @@ -53,7 +53,7 @@ def _normalize_int(name: str, value: Any) -> int: return int(value) except (TypeError, ValueError) as exc: raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"Invalid integer for '{name}': {value}", hint="Fix the config value or pass a valid CLI option.", exit_code=ExitCode.CONFIG_INVALID, @@ -63,7 +63,7 @@ def _normalize_int(name: str, value: Any) -> int: def _normalize_str(name: str, value: Any) -> str: if not isinstance(value, str): raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"Invalid string for '{name}': {value}", hint="Fix the config value or pass a valid CLI option.", exit_code=ExitCode.CONFIG_INVALID, @@ -79,7 +79,7 @@ def _load_file_values(path: Path) -> dict[str, Any]: raw = tomli.loads(path.read_text(encoding="utf-8")) except (tomli.TOMLDecodeError, OSError) as exc: raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"Failed to read config file: {path}", hint="Check the TOML syntax and file permissions.", exit_code=ExitCode.CONFIG_INVALID, @@ -87,7 +87,7 @@ def _load_file_values(path: Path) -> dict[str, Any]: if not isinstance(raw, dict): raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"Config file root must be a table: {path}", hint="Use key/value pairs at the top level.", exit_code=ExitCode.CONFIG_INVALID, @@ -122,28 +122,28 @@ def _settings_from_merged(merged: dict[str, Any], *, resolved_path: Path) -> Set if not host: raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message="host must not be empty", hint="Set host to a reachable IP or hostname.", exit_code=ExitCode.CONFIG_INVALID, ) if not (1 <= port <= 65535): raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"port out of range: {port}", hint="Use a port between 1 and 65535.", exit_code=ExitCode.CONFIG_INVALID, ) if timeout_ms <= 0: raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"timeout_ms must be positive: {timeout_ms}", hint="Use a positive timeout in milliseconds.", exit_code=ExitCode.CONFIG_INVALID, ) if protocol_version <= 0: raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"protocol_version must be positive: {protocol_version}", hint="Set protocol_version to 2.", exit_code=ExitCode.CONFIG_INVALID, @@ -170,7 +170,7 @@ def _serialize_toml_value(value: Any) -> str: if isinstance(value, str): return json.dumps(value, ensure_ascii=False) raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"Unsupported value type for config serialization: {type(value).__name__}", hint="Use scalar TOML values for config entries.", exit_code=ExitCode.CONFIG_INVALID, @@ -250,7 +250,7 @@ def update_config_value(path: Path, *, key: str, value: Any) -> dict[str, Any]: if key not in CONFIG_SET_KEYS: allowed = ", ".join(sorted(CONFIG_SET_KEYS)) raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"Unsupported config key for updates: {key}", hint=f"Use one of: {allowed}", exit_code=ExitCode.CONFIG_INVALID, diff --git a/src/ableton_cli/contract_checks.py b/src/ableton_cli/contract_checks.py new file mode 100644 index 0000000..35ac71f --- /dev/null +++ b/src/ableton_cli/contract_checks.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .contracts import build_public_contract_snapshot + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _default_snapshot_path() -> Path: + return _repo_root() / "tests" / "snapshots" / "public_contract_snapshot.json" + + +def _load_snapshot(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def ensure_public_contract_snapshot_is_current(snapshot_path: Path | None = None) -> None: + target = _default_snapshot_path() if snapshot_path is None else snapshot_path + expected = _load_snapshot(target) + actual = build_public_contract_snapshot() + if actual != expected: + raise RuntimeError( + "Public contract snapshot is out of date. " + "Run 'uv run python tools/update_public_contract_snapshot.py' and commit the result." + ) + + +def main() -> int: + ensure_public_contract_snapshot_is_current() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ableton_cli/contracts/__init__.py b/src/ableton_cli/contracts/__init__.py index fe952dd..d8e5d42 100644 --- a/src/ableton_cli/contracts/__init__.py +++ b/src/ableton_cli/contracts/__init__.py @@ -1,3 +1,9 @@ -from .registry import validate_command_contract +from .public_snapshot import PUBLIC_CONTRACT_SCHEMA_VERSION, build_public_contract_snapshot +from .registry import get_registered_contracts, validate_command_contract -__all__ = ["validate_command_contract"] +__all__ = [ + "PUBLIC_CONTRACT_SCHEMA_VERSION", + "build_public_contract_snapshot", + "get_registered_contracts", + "validate_command_contract", +] diff --git a/src/ableton_cli/contracts/public_snapshot.py b/src/ableton_cli/contracts/public_snapshot.py new file mode 100644 index 0000000..bf5d446 --- /dev/null +++ b/src/ableton_cli/contracts/public_snapshot.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from ..client.protocol import REQUIRED_RESPONSE_KEYS, make_request +from ..errors import ErrorCode, ErrorDetailReason, details_with_reason +from ..output import error_payload, success_payload +from .registry import get_registered_contracts + +PUBLIC_CONTRACT_SCHEMA_VERSION = 1 + + +def build_public_contract_snapshot() -> dict[str, Any]: + protocol_request = make_request( + name="example_command", + args={"example": True}, + protocol_version=2, + meta={"request_timeout_ms": 15000}, + ).to_dict() + protocol_request["request_id"] = "" + + return { + "schema_version": PUBLIC_CONTRACT_SCHEMA_VERSION, + "json_output_envelope": { + "success": success_payload( + command="example command", + args={"example": True}, + result={"status": "ok"}, + ), + "error": error_payload( + command="example command", + args={"example": True}, + code=ErrorCode.INVALID_ARGUMENT.value, + message="Example failure", + hint="Resolve example failure.", + details=details_with_reason(ErrorDetailReason.NOT_SUPPORTED_BY_LIVE_API), + ), + }, + "errors": { + "codes": sorted(code.value for code in ErrorCode), + "detail_reasons": sorted(reason.value for reason in ErrorDetailReason), + }, + "protocol": { + "request": protocol_request, + "response_required_keys": sorted(REQUIRED_RESPONSE_KEYS), + "response_success": { + "ok": True, + "request_id": "", + "protocol_version": 2, + "result": {"status": "ok"}, + "error": None, + }, + "response_error": { + "ok": False, + "request_id": "", + "protocol_version": 2, + "result": None, + "error": { + "code": ErrorCode.INVALID_ARGUMENT.value, + "message": "Example failure", + "hint": "Resolve example failure.", + "details": details_with_reason(ErrorDetailReason.NOT_SUPPORTED_BY_LIVE_API), + }, + }, + }, + "command_contracts": get_registered_contracts(), + } diff --git a/src/ableton_cli/contracts/registry.py b/src/ableton_cli/contracts/registry.py index a423de4..11ddffe 100644 --- a/src/ableton_cli/contracts/registry.py +++ b/src/ableton_cli/contracts/registry.py @@ -1,8 +1,9 @@ from __future__ import annotations +from copy import deepcopy from typing import Any -from ..errors import AppError, ExitCode +from ..errors import AppError, ErrorCode, ErrorDetailReason, ExitCode, details_with_reason from .schema import ContractValidationError, validate_value _CONTRACTS: dict[str, dict[str, dict[str, Any]]] = { @@ -88,6 +89,10 @@ } +def get_registered_contracts() -> dict[str, dict[str, dict[str, Any]]]: + return deepcopy(_CONTRACTS) + + def validate_command_contract(*, command: str, args: dict[str, Any], result: Any) -> None: contract = _CONTRACTS.get(command) if contract is None: @@ -98,13 +103,16 @@ def validate_command_contract(*, command: str, args: dict[str, Any], result: Any validate_value(contract["result"], result, path="result") except ContractValidationError as exc: raise AppError( - error_code="PROTOCOL_INVALID_RESPONSE", + error_code=ErrorCode.PROTOCOL_INVALID_RESPONSE, message=f"Contract validation failed for '{command}': {exc.path} {exc.message}", hint="Fix the command contract or result payload shape.", exit_code=ExitCode.PROTOCOL_MISMATCH, details={ "command": command, "path": exc.path, - "reason": exc.message, + **details_with_reason( + ErrorDetailReason.CONTRACT_VALIDATION_FAILED, + validation_message=exc.message, + ), }, ) from exc diff --git a/src/ableton_cli/dev_checks.py b/src/ableton_cli/dev_checks.py index bbe8bef..6e5d93f 100644 --- a/src/ableton_cli/dev_checks.py +++ b/src/ableton_cli/dev_checks.py @@ -12,6 +12,7 @@ ("uv", "run", "ruff", "check", "."), ("uv", "run", "ruff", "format", "--check", "."), ("uv", "run", "python", "tools/generate_skill_docs.py", "--check"), + ("uv", "run", "python", "-m", "ableton_cli.contract_checks"), ("uv", "run", "pytest"), ) PYTEST_COMMAND_PREFIX = ("uv", "run", "pytest") diff --git a/src/ableton_cli/errors.py b/src/ableton_cli/errors.py index 914fc70..0fa0771 100644 --- a/src/ableton_cli/errors.py +++ b/src/ableton_cli/errors.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import IntEnum +from enum import Enum, IntEnum from typing import Any @@ -17,9 +17,51 @@ class ExitCode(IntEnum): INTERNAL_ERROR = 99 +class ErrorCode(str, Enum): + INVALID_ARGUMENT = "INVALID_ARGUMENT" + CONFIG_INVALID = "CONFIG_INVALID" + ABLETON_NOT_REACHABLE = "ABLETON_NOT_REACHABLE" + REMOTE_SCRIPT_NOT_INSTALLED = "REMOTE_SCRIPT_NOT_INSTALLED" + REMOTE_SCRIPT_INCOMPATIBLE = "REMOTE_SCRIPT_INCOMPATIBLE" + PROTOCOL_VERSION_MISMATCH = "PROTOCOL_VERSION_MISMATCH" + PROTOCOL_INVALID_RESPONSE = "PROTOCOL_INVALID_RESPONSE" + PROTOCOL_REQUEST_ID_MISMATCH = "PROTOCOL_REQUEST_ID_MISMATCH" + TIMEOUT = "TIMEOUT" + BATCH_STEP_FAILED = "BATCH_STEP_FAILED" + REMOTE_BUSY = "REMOTE_BUSY" + READ_ONLY_VIOLATION = "READ_ONLY_VIOLATION" + BATCH_PREFLIGHT_FAILED = "BATCH_PREFLIGHT_FAILED" + BATCH_ASSERT_FAILED = "BATCH_ASSERT_FAILED" + BATCH_RETRY_EXHAUSTED = "BATCH_RETRY_EXHAUSTED" + INSTALL_TARGET_NOT_FOUND = "INSTALL_TARGET_NOT_FOUND" + SKILL_SOURCE_NOT_FOUND = "SKILL_SOURCE_NOT_FOUND" + UNSUPPORTED_OS = "UNSUPPORTED_OS" + INTERNAL_ERROR = "INTERNAL_ERROR" + + +class ErrorDetailReason(str, Enum): + NOT_SUPPORTED_BY_LIVE_API = "not_supported_by_live_api" + CONTRACT_VALIDATION_FAILED = "contract_validation_failed" + + +def _error_code_value(error_code: ErrorCode | str) -> str: + return error_code.value if isinstance(error_code, ErrorCode) else error_code + + +def details_with_reason( + reason: ErrorDetailReason, + /, + **details: Any, +) -> dict[str, Any]: + return { + "reason": reason.value, + **details, + } + + @dataclass(slots=True) class AppError(Exception): - error_code: str + error_code: ErrorCode | str message: str hint: str | None = None exit_code: ExitCode = ExitCode.INTERNAL_ERROR @@ -27,40 +69,51 @@ class AppError(Exception): def to_payload(self) -> dict[str, Any]: return { - "code": self.error_code, + "code": _error_code_value(self.error_code), "message": self.message, "hint": self.hint, "details": self.details or None, } -REMOTE_ERROR_TO_EXIT_CODE: dict[str, ExitCode] = { - "INVALID_ARGUMENT": ExitCode.INVALID_ARGUMENT, - "CONFIG_INVALID": ExitCode.CONFIG_INVALID, - "ABLETON_NOT_REACHABLE": ExitCode.ABLETON_NOT_CONNECTED, - "REMOTE_SCRIPT_NOT_INSTALLED": ExitCode.REMOTE_SCRIPT_NOT_DETECTED, - "REMOTE_SCRIPT_INCOMPATIBLE": ExitCode.PROTOCOL_MISMATCH, - "PROTOCOL_VERSION_MISMATCH": ExitCode.PROTOCOL_MISMATCH, - "PROTOCOL_INVALID_RESPONSE": ExitCode.PROTOCOL_MISMATCH, - "PROTOCOL_REQUEST_ID_MISMATCH": ExitCode.PROTOCOL_MISMATCH, - "TIMEOUT": ExitCode.TIMEOUT, - "BATCH_STEP_FAILED": ExitCode.EXECUTION_FAILED, - "REMOTE_BUSY": ExitCode.EXECUTION_FAILED, - "READ_ONLY_VIOLATION": ExitCode.EXECUTION_FAILED, - "BATCH_PREFLIGHT_FAILED": ExitCode.EXECUTION_FAILED, - "BATCH_ASSERT_FAILED": ExitCode.EXECUTION_FAILED, - "BATCH_RETRY_EXHAUSTED": ExitCode.EXECUTION_FAILED, - "INSTALL_TARGET_NOT_FOUND": ExitCode.EXECUTION_FAILED, - "INTERNAL_ERROR": ExitCode.INTERNAL_ERROR, +REMOTE_ERROR_TO_EXIT_CODE: dict[ErrorCode, ExitCode] = { + ErrorCode.INVALID_ARGUMENT: ExitCode.INVALID_ARGUMENT, + ErrorCode.CONFIG_INVALID: ExitCode.CONFIG_INVALID, + ErrorCode.ABLETON_NOT_REACHABLE: ExitCode.ABLETON_NOT_CONNECTED, + ErrorCode.REMOTE_SCRIPT_NOT_INSTALLED: ExitCode.REMOTE_SCRIPT_NOT_DETECTED, + ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE: ExitCode.PROTOCOL_MISMATCH, + ErrorCode.PROTOCOL_VERSION_MISMATCH: ExitCode.PROTOCOL_MISMATCH, + ErrorCode.PROTOCOL_INVALID_RESPONSE: ExitCode.PROTOCOL_MISMATCH, + ErrorCode.PROTOCOL_REQUEST_ID_MISMATCH: ExitCode.PROTOCOL_MISMATCH, + ErrorCode.TIMEOUT: ExitCode.TIMEOUT, + ErrorCode.BATCH_STEP_FAILED: ExitCode.EXECUTION_FAILED, + ErrorCode.REMOTE_BUSY: ExitCode.EXECUTION_FAILED, + ErrorCode.READ_ONLY_VIOLATION: ExitCode.EXECUTION_FAILED, + ErrorCode.BATCH_PREFLIGHT_FAILED: ExitCode.EXECUTION_FAILED, + ErrorCode.BATCH_ASSERT_FAILED: ExitCode.EXECUTION_FAILED, + ErrorCode.BATCH_RETRY_EXHAUSTED: ExitCode.EXECUTION_FAILED, + ErrorCode.INSTALL_TARGET_NOT_FOUND: ExitCode.EXECUTION_FAILED, + ErrorCode.SKILL_SOURCE_NOT_FOUND: ExitCode.EXECUTION_FAILED, + ErrorCode.UNSUPPORTED_OS: ExitCode.EXECUTION_FAILED, + ErrorCode.INTERNAL_ERROR: ExitCode.INTERNAL_ERROR, } -def exit_code_from_error_code(error_code: str) -> ExitCode: - return REMOTE_ERROR_TO_EXIT_CODE.get(error_code, ExitCode.EXECUTION_FAILED) +def exit_code_from_error_code(error_code: ErrorCode | str) -> ExitCode: + normalized = _error_code_value(error_code) + for code, exit_code in REMOTE_ERROR_TO_EXIT_CODE.items(): + if code.value == normalized: + return exit_code + return ExitCode.EXECUTION_FAILED def remote_error_to_app_error(error: dict[str, Any]) -> AppError: - code = str(error.get("code", "INTERNAL_ERROR")) + code = str(error.get("code", ErrorCode.INTERNAL_ERROR.value)) + normalized_code: ErrorCode | str + try: + normalized_code = ErrorCode(code) + except ValueError: + normalized_code = code message = str(error.get("message", "Remote command failed")) hint = error.get("hint") if hint is not None: @@ -70,9 +123,9 @@ def remote_error_to_app_error(error: dict[str, Any]) -> AppError: if isinstance(details, dict): sanitized_details = details return AppError( - error_code=code, + error_code=normalized_code, message=message, hint=hint, - exit_code=exit_code_from_error_code(code), + exit_code=exit_code_from_error_code(normalized_code), details={"remote": error, **sanitized_details}, ) diff --git a/src/ableton_cli/installer.py b/src/ableton_cli/installer.py index bf987c6..01c90ab 100644 --- a/src/ableton_cli/installer.py +++ b/src/ableton_cli/installer.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from .errors import AppError, ExitCode +from .errors import AppError, ErrorCode, ExitCode from .platform_paths import PlatformPaths REMOTE_SCRIPT_DIR_NAME = "AbletonCliRemote" @@ -30,7 +30,7 @@ def _select_target_roots(candidates: list[Path]) -> list[Path]: joined = "\n".join(str(candidate) for candidate in candidates) raise AppError( - error_code="INSTALL_TARGET_NOT_FOUND", + error_code=ErrorCode.INSTALL_TARGET_NOT_FOUND, message="Could not locate Ableton Remote Scripts directory", hint=f"Create one of these directories or set up Ableton User Library:\n{joined}", exit_code=ExitCode.EXECUTION_FAILED, @@ -48,7 +48,7 @@ def install_remote_script( source = remote_script_source_dir() if not source.exists(): raise AppError( - error_code="REMOTE_SCRIPT_NOT_INSTALLED", + error_code=ErrorCode.REMOTE_SCRIPT_NOT_INSTALLED, message=f"Remote Script source not found: {source}", hint="Reinstall ableton-cli package including remote_script assets.", exit_code=ExitCode.REMOTE_SCRIPT_NOT_DETECTED, @@ -100,7 +100,7 @@ def _resolve_codex_home(codex_home: Path | None) -> Path: raw_value = os.environ.get(CODEX_HOME_ENV_VAR) if raw_value is None or not raw_value.strip(): raise AppError( - error_code="CONFIG_INVALID", + error_code=ErrorCode.CONFIG_INVALID, message=f"{CODEX_HOME_ENV_VAR} is not set", hint=f"Set {CODEX_HOME_ENV_VAR} before running install-skill.", exit_code=ExitCode.CONFIG_INVALID, @@ -126,7 +126,7 @@ def _resolve_skill_home( if target == "claude": return _resolve_claude_home(claude_home=claude_home, platform_paths=platform_paths) raise AppError( - error_code="INVALID_ARGUMENT", + error_code=ErrorCode.INVALID_ARGUMENT, message=f"target must be one of: codex, claude (got {target!r})", hint="Use --target codex or --target claude.", exit_code=ExitCode.INVALID_ARGUMENT, @@ -147,7 +147,7 @@ def install_skill( source = skill_source_file() if not source.exists(): raise AppError( - error_code="SKILL_SOURCE_NOT_FOUND", + error_code=ErrorCode.SKILL_SOURCE_NOT_FOUND, message=f"Skill source not found: {source}", hint="Reinstall ableton-cli package including skills assets.", exit_code=ExitCode.EXECUTION_FAILED, diff --git a/src/ableton_cli/platform_detection.py b/src/ableton_cli/platform_detection.py index 2b9dc43..64ade31 100644 --- a/src/ableton_cli/platform_detection.py +++ b/src/ableton_cli/platform_detection.py @@ -3,7 +3,7 @@ import platform from pathlib import Path -from .errors import AppError, ExitCode +from .errors import AppError, ErrorCode, ExitCode from .platform_paths import PlatformPaths, PosixPlatformPaths, WindowsPlatformPaths @@ -28,7 +28,7 @@ def build_platform_paths_for_current_os() -> PlatformPaths: ) raise AppError( - error_code="UNSUPPORTED_OS", + error_code=ErrorCode.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/runtime.py b/src/ableton_cli/runtime.py index 39206cb..67d9efa 100644 --- a/src/ableton_cli/runtime.py +++ b/src/ableton_cli/runtime.py @@ -11,7 +11,7 @@ from .compact import compact_payload from .config import Settings from .contracts import validate_command_contract -from .errors import AppError, ExitCode +from .errors import AppError, ErrorCode, ExitCode from .output import ( OutputMode, emit_human_error, @@ -90,10 +90,11 @@ def execute_command( except typer.Exit: raise except AppError as exc: + serialized_error = exc.to_payload() payload = error_payload( command=command, args=args, - code=exc.error_code, + code=serialized_error["code"], message=exc.message, hint=exc.hint, details=exc.details or None, @@ -101,11 +102,11 @@ def execute_command( if runtime.output_mode == OutputMode.JSON: emit_json(payload) else: - emit_human_error(exc.error_code, exc.message, exc.hint) + emit_human_error(serialized_error["code"], exc.message, exc.hint) raise typer.Exit(exc.exit_code.value) from exc except Exception as exc: # noqa: BLE001 logger.exception("Unhandled command failure") - code = "INTERNAL_ERROR" + code = ErrorCode.INTERNAL_ERROR.value message = "Unexpected internal error" hint = "Run with --verbose and check stderr/log-file for details." payload = error_payload(command=command, args=args, code=code, message=message, hint=hint) diff --git a/tests/commands/test_arrangement_command_adapter.py b/tests/commands/test_arrangement_command_adapter.py new file mode 100644 index 0000000..c6aaff0 --- /dev/null +++ b/tests/commands/test_arrangement_command_adapter.py @@ -0,0 +1,98 @@ +from __future__ import annotations + + +def test_run_client_command_spec_dispatches_zero_arg_method(monkeypatch) -> None: + from ableton_cli.commands import arrangement + + captured: dict[str, object] = {} + + class _Client: + def arrangement_record_start(self): # noqa: ANN201 + return {"recording": True} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(arrangement, "get_client", _get_client) + monkeypatch.setattr(arrangement, "execute_command", _execute_command) + + arrangement.run_client_command_spec( + ctx=object(), + spec=arrangement.ArrangementCommandSpec( + command_name="arrangement record start", + client_method="arrangement_record_start", + ), + args={}, + ) + + assert captured["command"] == "arrangement record start" + assert captured["args"] == {} + assert captured["result"] == {"recording": True} + + +def test_run_client_command_spec_passes_method_kwargs(monkeypatch) -> None: + from ableton_cli.commands import arrangement + + captured: dict[str, object] = {} + + class _Client: + def arrangement_clip_notes_get( + self, + *, + track: int, + index: int, + start_time: float | None, + end_time: float | None, + pitch: int | None, + ): # noqa: ANN201 + return { + "track": track, + "index": index, + "start_time": start_time, + "end_time": end_time, + "pitch": pitch, + } + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(arrangement, "get_client", _get_client) + monkeypatch.setattr(arrangement, "execute_command", _execute_command) + + arrangement.run_client_command_spec( + ctx=object(), + spec=arrangement.ArrangementCommandSpec( + command_name="arrangement clip notes get", + client_method="arrangement_clip_notes_get", + ), + args={"track": 1, "index": 0}, + method_kwargs={ + "track": 1, + "index": 0, + "start_time": 0.0, + "end_time": 4.0, + "pitch": 60, + }, + ) + + assert captured["command"] == "arrangement clip notes get" + assert captured["args"] == {"track": 1, "index": 0} + assert captured["result"] == { + "track": 1, + "index": 0, + "start_time": 0.0, + "end_time": 4.0, + "pitch": 60, + } diff --git a/tests/commands/test_arrangement_module_split.py b/tests/commands/test_arrangement_module_split.py new file mode 100644 index 0000000..5b67dac --- /dev/null +++ b/tests/commands/test_arrangement_module_split.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import importlib + + +def test_arrangement_domain_modules_exist() -> None: + module_names = ( + "ableton_cli.commands._arrangement_record_commands", + "ableton_cli.commands._arrangement_clip_commands", + "ableton_cli.commands._arrangement_notes_commands", + "ableton_cli.commands._arrangement_session_commands", + ) + + for module_name in module_names: + module = importlib.import_module(module_name) + assert hasattr(module, "register_commands") + + +def test_arrangement_clip_and_session_modules_expose_specs() -> None: + clip_module = importlib.import_module("ableton_cli.commands._arrangement_clip_commands") + session_module = importlib.import_module("ableton_cli.commands._arrangement_session_commands") + + assert hasattr(clip_module, "CLIP_CREATE_SPEC") + assert hasattr(clip_module, "CLIP_LIST_SPEC") + assert hasattr(clip_module, "CLIP_DELETE_SPEC") + assert hasattr(session_module, "FROM_SESSION_SPEC") diff --git a/tests/commands/test_browser_command_adapter.py b/tests/commands/test_browser_command_adapter.py new file mode 100644 index 0000000..290bb8b --- /dev/null +++ b/tests/commands/test_browser_command_adapter.py @@ -0,0 +1,37 @@ +from __future__ import annotations + + +def test_browser_run_client_command_spec_dispatches_kwargs(monkeypatch) -> None: + from ableton_cli.commands import browser + + captured: dict[str, object] = {} + + class _Client: + def get_browser_tree(self, category_type: str): # noqa: ANN201 + return {"category_type": category_type} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(browser, "get_client", _get_client) + monkeypatch.setattr(browser, "execute_command", _execute_command) + + browser.run_client_command_spec( + ctx=object(), + spec=browser.BrowserCommandSpec( + command_name="browser tree", + client_method="get_browser_tree", + ), + args={"category_type": "drums"}, + method_kwargs={"category_type": "drums"}, + ) + + assert captured["command"] == "browser tree" + assert captured["args"] == {"category_type": "drums"} + assert captured["result"] == {"category_type": "drums"} diff --git a/tests/commands/test_client_command_runner.py b/tests/commands/test_client_command_runner.py new file mode 100644 index 0000000..ec4faca --- /dev/null +++ b/tests/commands/test_client_command_runner.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class _Spec: + command_name: str + client_method: str + + +def test_run_client_command_dispatches_execute_command_with_client_action() -> None: + from ableton_cli.commands._client_command_runner import run_client_command + + captured: dict[str, object] = {} + + class _Client: + pass + + client = _Client() + + def _get_client(_ctx): # noqa: ANN202 + captured["get_client_called"] = True + return client + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + run_client_command( + ctx=object(), + command_name="demo command", + args={"value": 1}, + fn=lambda resolved_client: {"same_client": resolved_client is client}, + get_client_fn=_get_client, + execute_command_fn=_execute_command, + ) + + assert captured["command"] == "demo command" + assert captured["args"] == {"value": 1} + assert captured["get_client_called"] is True + assert captured["result"] == {"same_client": True} + + +def test_run_client_command_spec_dispatches_method_with_resolved_kwargs_callable() -> None: + from ableton_cli.commands._client_command_runner import run_client_command_spec + + captured: dict[str, object] = {} + + class _Client: + def ping(self, *, value: int): # noqa: ANN201 + return {"value": value} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + run_client_command_spec( + ctx=object(), + spec=_Spec(command_name="demo ping", client_method="ping"), + args={"value": 3}, + method_kwargs=lambda: {"value": 7}, + get_client_fn=_get_client, + execute_command_fn=_execute_command, + ) + + assert captured["command"] == "demo ping" + assert captured["args"] == {"value": 3} + assert captured["result"] == {"value": 7} + + +def test_run_client_command_spec_uses_empty_kwargs_when_not_provided() -> None: + from ableton_cli.commands._client_command_runner import run_client_command_spec + + captured: dict[str, object] = {} + + class _Client: + def version(self): # noqa: ANN201 + return {"version": "1.0.0"} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + run_client_command_spec( + ctx=object(), + spec=_Spec(command_name="demo version", client_method="version"), + args={}, + get_client_fn=_get_client, + execute_command_fn=_execute_command, + ) + + assert captured["command"] == "demo version" + assert captured["args"] == {} + assert captured["result"] == {"version": "1.0.0"} diff --git a/tests/commands/test_device_command_adapter.py b/tests/commands/test_device_command_adapter.py new file mode 100644 index 0000000..f74c135 --- /dev/null +++ b/tests/commands/test_device_command_adapter.py @@ -0,0 +1,53 @@ +from __future__ import annotations + + +def test_device_run_client_command_spec_passes_method_kwargs(monkeypatch) -> None: + from ableton_cli.commands import device + + captured: dict[str, object] = {} + + class _Client: + def set_device_parameter( # noqa: ANN201 + self, + track: int, + device: int, + parameter: int, + value: float, + ): + return { + "track": track, + "device": device, + "parameter": parameter, + "value": value, + } + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(device, "get_client", _get_client) + monkeypatch.setattr(device, "execute_command", _execute_command) + + device.run_client_command_spec( + ctx=object(), + spec=device.DeviceCommandSpec( + command_name="device parameter set", + client_method="set_device_parameter", + ), + args={"track": 1, "device": 2, "parameter": 3, "value": 0.5}, + method_kwargs={ + "track": 1, + "device": 2, + "parameter": 3, + "value": 0.5, + }, + ) + + assert captured["command"] == "device parameter set" + assert captured["args"] == {"track": 1, "device": 2, "parameter": 3, "value": 0.5} + assert captured["result"] == {"track": 1, "device": 2, "parameter": 3, "value": 0.5} diff --git a/tests/commands/test_effect_command_adapter.py b/tests/commands/test_effect_command_adapter.py index 8aba656..44c7dd8 100644 --- a/tests/commands/test_effect_command_adapter.py +++ b/tests/commands/test_effect_command_adapter.py @@ -71,3 +71,79 @@ def _validator(track_index: int, device_index: int) -> tuple[int, int]: assert captured["command"] == "effect test" assert captured["args"] == {"track": 0, "device": 1} assert captured["result"] == {"same_client": True, "track": 2, "device": 3} + + +def test_run_track_device_command_spec_dispatches_client_method(monkeypatch) -> None: + from ableton_cli.commands import effect + + captured: dict[str, object] = {} + + class _Client: + def list_effect_parameters(self, track: int, device: int): # noqa: ANN201 + return {"track": track, "device": device, "parameters": []} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(effect, "get_client", _get_client) + monkeypatch.setattr(effect, "execute_command", _execute_command) + + effect.run_track_device_command_spec( + ctx=object(), + spec=effect.TrackDeviceCommandSpec( + command_name="effect parameters list", + client_method="list_effect_parameters", + ), + track=3, + device=2, + ) + + assert captured["command"] == "effect parameters list" + assert captured["args"] == {"track": 3, "device": 2} + assert captured["result"] == {"track": 3, "device": 2, "parameters": []} + + +def test_run_client_command_spec_dispatches_method(monkeypatch) -> None: + from ableton_cli.commands import effect + + captured: dict[str, object] = {} + + class _Client: + def find_effect_devices( # noqa: ANN201 + self, + track: int | None, + effect_type: str | None, + ): + return {"track": track, "effect_type": effect_type} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(effect, "get_client", _get_client) + monkeypatch.setattr(effect, "execute_command", _execute_command) + + effect.run_client_command_spec( + ctx=object(), + spec=effect.EffectCommandSpec( + command_name="effect find", + client_method="find_effect_devices", + ), + args={"track": 0, "effect_type": "eq8"}, + method_kwargs={"track": 0, "effect_type": "eq8"}, + ) + + assert captured["command"] == "effect find" + assert captured["args"] == {"track": 0, "effect_type": "eq8"} + assert captured["result"] == {"track": 0, "effect_type": "eq8"} diff --git a/tests/commands/test_scenes_command_adapter.py b/tests/commands/test_scenes_command_adapter.py index 7778417..f61bcea 100644 --- a/tests/commands/test_scenes_command_adapter.py +++ b/tests/commands/test_scenes_command_adapter.py @@ -100,3 +100,78 @@ def _execute_command(_ctx, *, command, args, action, human_formatter=None): # n assert exc.value.message == "from must be >= 0, got -1" assert get_client_calls["count"] == 0 + + +def test_run_scene_command_spec_dispatches_client_method(monkeypatch) -> None: + from ableton_cli.commands import scenes + + captured: dict[str, object] = {} + + class _Client: + def fire_scene(self, scene_index: int): # noqa: ANN201 + return {"scene": scene_index, "fired": True} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(scenes, "get_client", _get_client) + monkeypatch.setattr(scenes, "execute_command", _execute_command) + + scenes.run_scene_command_spec( + ctx=object(), + spec=scenes.SceneCommandSpec( + command_name="scenes fire", + client_method="fire_scene", + ), + scene=4, + ) + + assert captured["command"] == "scenes fire" + assert captured["args"] == {"scene": 4} + assert captured["result"] == {"scene": 4, "fired": True} + + +def test_run_scene_move_command_spec_applies_validators(monkeypatch) -> None: + from ableton_cli.commands import scenes + + captured: dict[str, object] = {} + + class _Client: + def scenes_move(self, from_scene: int, to_scene: int): # noqa: ANN201 + return {"from": from_scene, "to": to_scene} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + def _validator(from_scene: int, to_scene: int) -> tuple[int, int]: + return from_scene + 1, to_scene + 2 + + monkeypatch.setattr(scenes, "get_client", _get_client) + monkeypatch.setattr(scenes, "execute_command", _execute_command) + + scenes.run_scene_move_command_spec( + ctx=object(), + spec=scenes.SceneMoveCommandSpec( + command_name="scenes move", + client_method="scenes_move", + validators=(_validator,), + ), + from_scene=1, + to_scene=3, + ) + + assert captured["command"] == "scenes move" + assert captured["args"] == {"from": 1, "to": 3} + assert captured["result"] == {"from": 2, "to": 5} diff --git a/tests/commands/test_session_command_adapter.py b/tests/commands/test_session_command_adapter.py new file mode 100644 index 0000000..06ba462 --- /dev/null +++ b/tests/commands/test_session_command_adapter.py @@ -0,0 +1,36 @@ +from __future__ import annotations + + +def test_session_run_client_command_spec_passes_method_kwargs(monkeypatch) -> None: + from ableton_cli.commands import session + + captured: dict[str, object] = {} + + class _Client: + def stop_all_clips(self): # noqa: ANN201 + return {"stopped": True} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(session, "get_client", _get_client) + monkeypatch.setattr(session, "execute_command", _execute_command) + + session.run_client_command_spec( + ctx=object(), + spec=session.SessionCommandSpec( + command_name="session stop-all-clips", + client_method="stop_all_clips", + ), + args={}, + ) + + assert captured["command"] == "session stop-all-clips" + assert captured["args"] == {} + assert captured["result"] == {"stopped": True} diff --git a/tests/commands/test_song_command_adapter.py b/tests/commands/test_song_command_adapter.py new file mode 100644 index 0000000..74bdf8e --- /dev/null +++ b/tests/commands/test_song_command_adapter.py @@ -0,0 +1,36 @@ +from __future__ import annotations + + +def test_song_run_client_command_spec_dispatches_method(monkeypatch) -> None: + from ableton_cli.commands import song + + captured: dict[str, object] = {} + + class _Client: + def song_new(self): # noqa: ANN201 + return {"created": True} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(song, "get_client", _get_client) + monkeypatch.setattr(song, "execute_command", _execute_command) + + song.run_client_command_spec( + ctx=object(), + spec=song.SongCommandSpec( + command_name="song new", + client_method="song_new", + ), + args={}, + ) + + assert captured["command"] == "song new" + assert captured["args"] == {} + assert captured["result"] == {"created": True} diff --git a/tests/commands/test_track_command_adapter.py b/tests/commands/test_track_command_adapter.py index d81ff86..ed8fcfa 100644 --- a/tests/commands/test_track_command_adapter.py +++ b/tests/commands/test_track_command_adapter.py @@ -99,3 +99,79 @@ def _execute_command(_ctx, *, command, args, action, human_formatter=None): # n assert exc.value.message == "track must be >= 0, got -1" assert get_client_calls["count"] == 0 + + +def test_run_track_command_spec_dispatches_client_method(monkeypatch) -> None: + from ableton_cli.commands import track + + captured: dict[str, object] = {} + + class _Client: + def track_mute_get(self, track_index: int): # noqa: ANN201 + return {"track": track_index, "mute": True} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(track, "get_client", _get_client) + monkeypatch.setattr(track, "execute_command", _execute_command) + + track.run_track_command_spec( + ctx=object(), + spec=track.TrackCommandSpec( + command_name="track mute get", + client_method="track_mute_get", + ), + track=3, + ) + + assert captured["command"] == "track mute get" + assert captured["args"] == {"track": 3} + assert captured["result"] == {"track": 3, "mute": True} + + +def test_run_track_value_command_spec_uses_value_name_and_validators(monkeypatch) -> None: + from ableton_cli.commands import track + + captured: dict[str, object] = {} + + class _Client: + def set_track_name(self, track_index: int, name: str): # noqa: ANN201 + return {"track": track_index, "name": name} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + def _validator(track_index: int, value: str) -> tuple[int, str]: + return track_index + 1, value.strip() + + monkeypatch.setattr(track, "get_client", _get_client) + monkeypatch.setattr(track, "execute_command", _execute_command) + + track.run_track_value_command_spec( + ctx=object(), + spec=track.TrackValueCommandSpec[str]( + command_name="track name set", + client_method="set_track_name", + value_name="name", + validators=(_validator,), + ), + track=0, + value=" Bass ", + ) + + assert captured["command"] == "track name set" + assert captured["args"] == {"track": 0, "name": " Bass "} + assert captured["result"] == {"track": 1, "name": "Bass"} diff --git a/tests/commands/test_track_module_split.py b/tests/commands/test_track_module_split.py new file mode 100644 index 0000000..e3e61f5 --- /dev/null +++ b/tests/commands/test_track_module_split.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import importlib + + +def test_track_domain_modules_exist() -> None: + module_names = ( + "ableton_cli.commands._track_info_commands", + "ableton_cli.commands._track_volume_commands", + "ableton_cli.commands._track_name_commands", + "ableton_cli.commands._track_mute_commands", + "ableton_cli.commands._track_solo_commands", + "ableton_cli.commands._track_arm_commands", + "ableton_cli.commands._track_panning_commands", + ) + + for module_name in module_names: + module = importlib.import_module(module_name) + assert hasattr(module, "register_commands") diff --git a/tests/commands/test_tracks_command_adapter.py b/tests/commands/test_tracks_command_adapter.py new file mode 100644 index 0000000..8307e3f --- /dev/null +++ b/tests/commands/test_tracks_command_adapter.py @@ -0,0 +1,37 @@ +from __future__ import annotations + + +def test_tracks_run_client_command_spec_passes_method_kwargs(monkeypatch) -> None: + from ableton_cli.commands import tracks + + captured: dict[str, object] = {} + + class _Client: + def create_audio_track(self, index: int): # noqa: ANN201 + return {"index": index, "kind": "audio"} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(tracks, "get_client", _get_client) + monkeypatch.setattr(tracks, "execute_command", _execute_command) + + tracks.run_client_command_spec( + ctx=object(), + spec=tracks.TracksCommandSpec( + command_name="tracks create audio", + client_method="create_audio_track", + ), + args={"index": -1}, + method_kwargs={"index": 3}, + ) + + assert captured["command"] == "tracks create audio" + assert captured["args"] == {"index": -1} + assert captured["result"] == {"index": 3, "kind": "audio"} diff --git a/tests/commands/test_transport_command_adapter.py b/tests/commands/test_transport_command_adapter.py new file mode 100644 index 0000000..7fa05ff --- /dev/null +++ b/tests/commands/test_transport_command_adapter.py @@ -0,0 +1,36 @@ +from __future__ import annotations + + +def test_transport_run_client_command_spec_dispatches_method(monkeypatch) -> None: + from ableton_cli.commands import transport + + captured: dict[str, object] = {} + + class _Client: + def transport_play(self): # noqa: ANN201 + return {"playing": True} + + def _get_client(_ctx): # noqa: ANN202 + return _Client() + + def _execute_command(_ctx, *, command, args, action, human_formatter=None): # noqa: ANN202 + del human_formatter + captured["command"] = command + captured["args"] = args + captured["result"] = action() + + monkeypatch.setattr(transport, "get_client", _get_client) + monkeypatch.setattr(transport, "execute_command", _execute_command) + + transport.run_client_command_spec( + ctx=object(), + spec=transport.TransportCommandSpec( + command_name="transport play", + client_method="transport_play", + ), + args={}, + ) + + assert captured["command"] == "transport play" + assert captured["args"] == {} + assert captured["result"] == {"playing": True} diff --git a/tests/snapshots/public_contract_snapshot.json b/tests/snapshots/public_contract_snapshot.json new file mode 100644 index 0000000..2ed4c7e --- /dev/null +++ b/tests/snapshots/public_contract_snapshot.json @@ -0,0 +1,273 @@ +{ + "command_contracts": { + "doctor": { + "args": { + "additional_properties": false, + "type": "object" + }, + "result": { + "properties": { + "checks": { + "type": "array" + }, + "summary": { + "properties": { + "fail": { + "type": "integer" + }, + "pass": { + "type": "integer" + }, + "warn": { + "type": "integer" + } + }, + "required": [ + "pass", + "warn", + "fail" + ], + "type": "object" + } + }, + "required": [ + "summary", + "checks" + ], + "type": "object" + } + }, + "ping": { + "args": { + "additional_properties": false, + "type": "object" + }, + "result": { + "properties": { + "api_support": { + "type": [ + "object", + "null" + ] + }, + "command_set_hash": { + "type": [ + "string", + "null" + ] + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol_version": { + "type": [ + "integer", + "null" + ] + }, + "remote_script_version": { + "type": [ + "string", + "null" + ] + }, + "rtt_ms": { + "type": "number" + }, + "supported_commands": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "host", + "port", + "rtt_ms" + ], + "type": "object" + } + }, + "session diff": { + "args": { + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "result": { + "properties": { + "added": { + "type": "object" + }, + "changed": { + "type": "object" + }, + "from_path": { + "type": "string" + }, + "removed": { + "type": "object" + }, + "to_path": { + "type": "string" + } + }, + "required": [ + "from_path", + "to_path", + "added", + "removed", + "changed" + ], + "type": "object" + } + }, + "song info": { + "args": { + "additional_properties": false, + "type": "object" + }, + "result": { + "properties": { + "tempo": { + "type": "number" + } + }, + "type": "object" + } + }, + "tracks list": { + "args": { + "additional_properties": false, + "type": "object" + }, + "result": { + "properties": { + "tracks": { + "type": "array" + } + }, + "required": [ + "tracks" + ], + "type": "object" + } + } + }, + "errors": { + "codes": [ + "ABLETON_NOT_REACHABLE", + "BATCH_ASSERT_FAILED", + "BATCH_PREFLIGHT_FAILED", + "BATCH_RETRY_EXHAUSTED", + "BATCH_STEP_FAILED", + "CONFIG_INVALID", + "INSTALL_TARGET_NOT_FOUND", + "INTERNAL_ERROR", + "INVALID_ARGUMENT", + "PROTOCOL_INVALID_RESPONSE", + "PROTOCOL_REQUEST_ID_MISMATCH", + "PROTOCOL_VERSION_MISMATCH", + "READ_ONLY_VIOLATION", + "REMOTE_BUSY", + "REMOTE_SCRIPT_INCOMPATIBLE", + "REMOTE_SCRIPT_NOT_INSTALLED", + "SKILL_SOURCE_NOT_FOUND", + "TIMEOUT", + "UNSUPPORTED_OS" + ], + "detail_reasons": [ + "contract_validation_failed", + "not_supported_by_live_api" + ] + }, + "json_output_envelope": { + "error": { + "args": { + "example": true + }, + "command": "example command", + "error": { + "code": "INVALID_ARGUMENT", + "details": { + "reason": "not_supported_by_live_api" + }, + "hint": "Resolve example failure.", + "message": "Example failure" + }, + "ok": false, + "result": null + }, + "success": { + "args": { + "example": true + }, + "command": "example command", + "error": null, + "ok": true, + "result": { + "status": "ok" + } + } + }, + "protocol": { + "request": { + "args": { + "example": true + }, + "meta": { + "request_timeout_ms": 15000 + }, + "name": "example_command", + "protocol_version": 2, + "request_id": "", + "type": "command" + }, + "response_error": { + "error": { + "code": "INVALID_ARGUMENT", + "details": { + "reason": "not_supported_by_live_api" + }, + "hint": "Resolve example failure.", + "message": "Example failure" + }, + "ok": false, + "protocol_version": 2, + "request_id": "", + "result": null + }, + "response_required_keys": [ + "ok", + "protocol_version", + "request_id" + ], + "response_success": { + "error": null, + "ok": true, + "protocol_version": 2, + "request_id": "", + "result": { + "status": "ok" + } + } + }, + "schema_version": 1 +} diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 928b15a..b25f1a5 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -33,3 +33,12 @@ def wrapped_register(app) -> None: # noqa: ANN001 assert first.exit_code == 0 assert second.exit_code == 0 assert register_calls == 1 + + +def test_public_command_registry_excludes_internal_modules() -> None: + module_names = [ + command_module.__name__.rsplit(".", maxsplit=1)[-1] + for command_module in app_module._COMMAND_MODULES + ] + + assert all(not module_name.startswith("_") for module_name in module_names) diff --git a/tests/test_client_backends.py b/tests/test_client_backends.py new file mode 100644 index 0000000..aac1534 --- /dev/null +++ b/tests/test_client_backends.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from ableton_cli.client.ableton_client import AbletonClient +from ableton_cli.client.backends import LiveBackendClient, RecordingClient, ReplayClient +from ableton_cli.config import Settings +from ableton_cli.errors import AppError, ErrorCode + + +def _settings() -> Settings: + return Settings( + host="127.0.0.1", + port=8765, + timeout_ms=15000, + log_level="INFO", + log_file=None, + protocol_version=2, + config_path="/tmp/ableton-cli-test.toml", + ) + + +def test_client_uses_live_backend_by_default() -> None: + client = AbletonClient(_settings()) + assert isinstance(client._backend, LiveBackendClient) + + +def test_client_uses_recording_backend_with_record_path(tmp_path: Path) -> None: + client = AbletonClient(_settings(), record_path=str(tmp_path / "record.jsonl")) + assert isinstance(client._backend, RecordingClient) + + +def test_client_uses_replay_backend_with_replay_path(tmp_path: Path) -> None: + replay_path = tmp_path / "replay.jsonl" + replay_path.write_text("", encoding="utf-8") + + client = AbletonClient(_settings(), replay_path=str(replay_path)) + assert isinstance(client._backend, ReplayClient) + + +def test_client_dispatch_uses_backend(monkeypatch: pytest.MonkeyPatch) -> None: + client = AbletonClient(_settings()) + captured: dict[str, Any] = {} + + def _dispatch(name: str, args: dict[str, Any]) -> dict[str, Any]: + captured["name"] = name + captured["args"] = args + return {"tempo": 123.0} + + monkeypatch.setattr(client._backend, "dispatch", _dispatch) + + result = client.song_info() + + assert result == {"tempo": 123.0} + assert captured == {"name": "song_info", "args": {}} + + +def test_client_read_only_stops_dispatch_before_backend(monkeypatch: pytest.MonkeyPatch) -> None: + client = AbletonClient(_settings(), read_only=True) + + def _dispatch(_name: str, _args: dict[str, Any]) -> dict[str, Any]: + raise AssertionError("backend dispatch must not run for blocked write commands") + + monkeypatch.setattr(client._backend, "dispatch", _dispatch) + + with pytest.raises(AppError) as exc_info: + client.track_volume_set(0, 0.5) + + assert exc_info.value.error_code == ErrorCode.READ_ONLY_VIOLATION diff --git a/tests/test_contract_checks.py b/tests/test_contract_checks.py new file mode 100644 index 0000000..77c944b --- /dev/null +++ b/tests/test_contract_checks.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ableton_cli import contract_checks + + +def _write_snapshot(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def test_ensure_public_contract_snapshot_is_current_passes_when_equal( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + snapshot_path = tmp_path / "snapshot.json" + payload = {"schema_version": 1, "contracts": {"song info": {}}} + _write_snapshot(snapshot_path, payload) + + monkeypatch.setattr(contract_checks, "build_public_contract_snapshot", lambda: payload) + + contract_checks.ensure_public_contract_snapshot_is_current(snapshot_path) + + +def test_ensure_public_contract_snapshot_is_current_raises_when_different( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + snapshot_path = tmp_path / "snapshot.json" + _write_snapshot(snapshot_path, {"schema_version": 1, "contracts": {}}) + monkeypatch.setattr( + contract_checks, + "build_public_contract_snapshot", + lambda: {"schema_version": 1, "contracts": {"song info": {}}}, + ) + + with pytest.raises(RuntimeError) as exc_info: + contract_checks.ensure_public_contract_snapshot_is_current(snapshot_path) + + assert "Public contract snapshot is out of date." in str(exc_info.value) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 8059546..ce4e676 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -35,6 +35,8 @@ def song_info(self): # noqa: ANN201 payload = json.loads(result.stdout) assert payload["ok"] is False assert payload["error"]["code"] == "PROTOCOL_INVALID_RESPONSE" + assert payload["error"]["details"]["reason"] == "contract_validation_failed" + assert isinstance(payload["error"]["details"]["validation_message"], str) def test_tracks_list_contract_rejects_non_array_tracks(runner, cli_app, monkeypatch) -> None: diff --git a/tests/test_dev_checks.py b/tests/test_dev_checks.py index 05c9d09..3ff88be 100644 --- a/tests/test_dev_checks.py +++ b/tests/test_dev_checks.py @@ -25,7 +25,7 @@ def _run(command: tuple[str, ...], check: bool) -> SimpleNamespace: # noqa: ANN def test_run_default_checks_runs_all_commands_and_returns_failure(monkeypatch) -> None: commands: list[tuple[str, ...]] = [] - exits = [0, 1, 0, 0] + exits = [0, 1, 0, 0, 0] def _run(command: tuple[str, ...], check: bool) -> SimpleNamespace: # noqa: ANN202 assert check is False @@ -61,7 +61,7 @@ def _run(command: tuple[str, ...], check: bool) -> SimpleNamespace: # noqa: ANN def test_main_writes_report_json(monkeypatch, tmp_path: Path) -> None: - exits = [0, 0, 0, 1] + exits = [0, 0, 0, 0, 1] def _run(command: tuple[str, ...], check: bool) -> SimpleNamespace: # noqa: ANN202 assert check is False @@ -85,8 +85,8 @@ def _run(command: tuple[str, ...], check: bool) -> SimpleNamespace: # noqa: ANN assert report["schema_version"] == 1 assert report["status"] == "fail" assert report["exit_code"] == 1 - assert len(report["commands"]) == 4 - assert report["commands"][3]["command"] == [ + assert len(report["commands"]) == 5 + assert report["commands"][4]["command"] == [ "uv", "run", "pytest", diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..5751626 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from ableton_cli.errors import ( + AppError, + ErrorCode, + ErrorDetailReason, + ExitCode, + details_with_reason, +) + + +def test_app_error_payload_uses_string_error_code_for_enum() -> None: + error = AppError( + error_code=ErrorCode.TIMEOUT, + message="timed out", + hint="retry", + exit_code=ExitCode.TIMEOUT, + ) + + payload = error.to_payload() + + assert payload["code"] == "TIMEOUT" + assert payload["message"] == "timed out" + assert payload["hint"] == "retry" + assert payload["details"] is None + + +def test_details_with_reason_normalizes_reason_field() -> None: + details = details_with_reason( + ErrorDetailReason.NOT_SUPPORTED_BY_LIVE_API, + operation="song_save", + ) + + assert details == { + "reason": "not_supported_by_live_api", + "operation": "song_save", + } diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py index fb8a8b2..057a794 100644 --- a/tests/test_exit_codes.py +++ b/tests/test_exit_codes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ableton_cli.errors import ExitCode, exit_code_from_error_code +from ableton_cli.errors import ErrorCode, ErrorDetailReason, ExitCode, exit_code_from_error_code def test_exit_code_values_are_fixed() -> None: @@ -36,3 +36,28 @@ def test_remote_error_to_exit_code_mapping() -> None: assert exit_code_from_error_code("BATCH_RETRY_EXHAUSTED") == ExitCode.EXECUTION_FAILED assert exit_code_from_error_code("INTERNAL_ERROR") == ExitCode.INTERNAL_ERROR assert exit_code_from_error_code("UNKNOWN") == ExitCode.EXECUTION_FAILED + + +def test_error_code_enum_values_are_stable() -> None: + assert ErrorCode.INVALID_ARGUMENT.value == "INVALID_ARGUMENT" + assert ErrorCode.CONFIG_INVALID.value == "CONFIG_INVALID" + assert ErrorCode.ABLETON_NOT_REACHABLE.value == "ABLETON_NOT_REACHABLE" + assert ErrorCode.REMOTE_SCRIPT_NOT_INSTALLED.value == "REMOTE_SCRIPT_NOT_INSTALLED" + assert ErrorCode.REMOTE_SCRIPT_INCOMPATIBLE.value == "REMOTE_SCRIPT_INCOMPATIBLE" + assert ErrorCode.PROTOCOL_VERSION_MISMATCH.value == "PROTOCOL_VERSION_MISMATCH" + assert ErrorCode.PROTOCOL_INVALID_RESPONSE.value == "PROTOCOL_INVALID_RESPONSE" + assert ErrorCode.PROTOCOL_REQUEST_ID_MISMATCH.value == "PROTOCOL_REQUEST_ID_MISMATCH" + assert ErrorCode.TIMEOUT.value == "TIMEOUT" + assert ErrorCode.BATCH_STEP_FAILED.value == "BATCH_STEP_FAILED" + assert ErrorCode.REMOTE_BUSY.value == "REMOTE_BUSY" + assert ErrorCode.READ_ONLY_VIOLATION.value == "READ_ONLY_VIOLATION" + assert ErrorCode.BATCH_PREFLIGHT_FAILED.value == "BATCH_PREFLIGHT_FAILED" + assert ErrorCode.BATCH_ASSERT_FAILED.value == "BATCH_ASSERT_FAILED" + assert ErrorCode.BATCH_RETRY_EXHAUSTED.value == "BATCH_RETRY_EXHAUSTED" + assert ErrorCode.INSTALL_TARGET_NOT_FOUND.value == "INSTALL_TARGET_NOT_FOUND" + assert ErrorCode.INTERNAL_ERROR.value == "INTERNAL_ERROR" + + +def test_error_detail_reason_values_are_stable() -> None: + assert ErrorDetailReason.NOT_SUPPORTED_BY_LIVE_API.value == "not_supported_by_live_api" + assert ErrorDetailReason.CONTRACT_VALIDATION_FAILED.value == "contract_validation_failed" diff --git a/tests/test_public_contract_snapshot.py b/tests/test_public_contract_snapshot.py new file mode 100644 index 0000000..5cc4d05 --- /dev/null +++ b/tests/test_public_contract_snapshot.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def _load_snapshot(path: Path) -> dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_public_contract_snapshot_matches_expected() -> None: + from ableton_cli.contracts.public_snapshot import build_public_contract_snapshot + + expected_path = Path(__file__).resolve().parent / "snapshots" / "public_contract_snapshot.json" + expected = _load_snapshot(expected_path) + + assert build_public_contract_snapshot() == expected diff --git a/tools/update_public_contract_snapshot.py b/tools/update_public_contract_snapshot.py new file mode 100644 index 0000000..d71a868 --- /dev/null +++ b/tools/update_public_contract_snapshot.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from ableton_cli.contracts import build_public_contract_snapshot + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[1] + snapshot_path = repo_root / "tests" / "snapshots" / "public_contract_snapshot.json" + payload = build_public_contract_snapshot() + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + snapshot_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From fbd517278b414e0ab5a582838478d0a04bdba076 Mon Sep 17 00:00:00 2001 From: 6uclz1 <9139177+6uclz1@users.noreply.github.com> Date: Wed, 4 Mar 2026 01:54:47 +0900 Subject: [PATCH 2/2] fix quality harness threshold regressions --- .../commands/_arrangement_notes_commands.py | 151 +++++++++++------- src/ableton_cli/commands/arrangement.py | 18 --- src/ableton_cli/commands/browser.py | 18 --- 3 files changed, 96 insertions(+), 91 deletions(-) diff --git a/src/ableton_cli/commands/_arrangement_notes_commands.py b/src/ableton_cli/commands/_arrangement_notes_commands.py index f7657a0..380441f 100644 --- a/src/ableton_cli/commands/_arrangement_notes_commands.py +++ b/src/ableton_cli/commands/_arrangement_notes_commands.py @@ -45,10 +45,51 @@ ) -def register_commands( +RunClientCommandSpec = Callable[..., None] + + +def _note_filter_method_kwargs( + *, + track: int, + index: int, + start_time: float | None, + end_time: float | None, + pitch: int | None, +) -> dict[str, object]: + valid_track = require_track_index(track) + valid_index = require_arrangement_clip_index(index) + filters = validate_clip_note_filters( + start_time=start_time, + end_time=end_time, + pitch=pitch, + ) + return { + "track": valid_track, + "index": valid_index, + "start_time": filters["start_time"], + "end_time": filters["end_time"], + "pitch": filters["pitch"], + } + + +def _validate_import_mode(mode: str) -> str: + valid_mode = require_non_empty_string( + "mode", + mode, + hint="Use --mode replace or append.", + ).lower() + if valid_mode not in {"replace", "append"}: + raise invalid_argument( + message=f"mode must be one of replace/append, got {mode}", + hint="Use --mode replace or append.", + ) + return valid_mode + + +def _register_add_command( notes_app: typer.Typer, *, - run_client_command_spec: Callable[..., None], + run_client_command_spec: RunClientCommandSpec, ) -> None: @notes_app.command("add") def arrangement_clip_notes_add( @@ -65,13 +106,10 @@ def arrangement_clip_notes_add( ] = None, ) -> None: def _method_kwargs() -> dict[str, object]: - valid_track = require_track_index(track) - valid_index = require_arrangement_clip_index(index) - notes = parse_notes_input(notes_json=notes_json, notes_file=notes_file) return { - "track": valid_track, - "index": valid_index, - "notes": notes, + "track": require_track_index(track), + "index": require_arrangement_clip_index(index), + "notes": parse_notes_input(notes_json=notes_json, notes_file=notes_file), } run_client_command_spec( @@ -81,6 +119,12 @@ def _method_kwargs() -> dict[str, object]: method_kwargs=_method_kwargs, ) + +def _register_get_command( + notes_app: typer.Typer, + *, + run_client_command_spec: RunClientCommandSpec, +) -> None: @notes_app.command("get") def arrangement_clip_notes_get( ctx: typer.Context, @@ -100,20 +144,13 @@ def arrangement_clip_notes_get( ] = None, ) -> None: def _method_kwargs() -> dict[str, object]: - valid_track = require_track_index(track) - valid_index = require_arrangement_clip_index(index) - filters = validate_clip_note_filters( + return _note_filter_method_kwargs( + track=track, + index=index, start_time=start_time, end_time=end_time, pitch=pitch, ) - return { - "track": valid_track, - "index": valid_index, - "start_time": filters["start_time"], - "end_time": filters["end_time"], - "pitch": filters["pitch"], - } run_client_command_spec( ctx, @@ -128,6 +165,12 @@ def _method_kwargs() -> dict[str, object]: method_kwargs=_method_kwargs, ) + +def _register_clear_command( + notes_app: typer.Typer, + *, + run_client_command_spec: RunClientCommandSpec, +) -> None: @notes_app.command("clear") def arrangement_clip_notes_clear( ctx: typer.Context, @@ -147,20 +190,13 @@ def arrangement_clip_notes_clear( ] = None, ) -> None: def _method_kwargs() -> dict[str, object]: - valid_track = require_track_index(track) - valid_index = require_arrangement_clip_index(index) - filters = validate_clip_note_filters( + return _note_filter_method_kwargs( + track=track, + index=index, start_time=start_time, end_time=end_time, pitch=pitch, ) - return { - "track": valid_track, - "index": valid_index, - "start_time": filters["start_time"], - "end_time": filters["end_time"], - "pitch": filters["pitch"], - } run_client_command_spec( ctx, @@ -175,6 +211,12 @@ def _method_kwargs() -> dict[str, object]: method_kwargs=_method_kwargs, ) + +def _register_replace_command( + notes_app: typer.Typer, + *, + run_client_command_spec: RunClientCommandSpec, +) -> None: @notes_app.command("replace") def arrangement_clip_notes_replace( ctx: typer.Context, @@ -202,22 +244,15 @@ def arrangement_clip_notes_replace( ] = None, ) -> None: def _method_kwargs() -> dict[str, object]: - valid_track = require_track_index(track) - valid_index = require_arrangement_clip_index(index) - notes = parse_notes_input(notes_json=notes_json, notes_file=notes_file) - filters = validate_clip_note_filters( + payload = _note_filter_method_kwargs( + track=track, + index=index, start_time=start_time, end_time=end_time, pitch=pitch, ) - return { - "track": valid_track, - "index": valid_index, - "notes": notes, - "start_time": filters["start_time"], - "end_time": filters["end_time"], - "pitch": filters["pitch"], - } + payload["notes"] = parse_notes_input(notes_json=notes_json, notes_file=notes_file) + return payload run_client_command_spec( ctx, @@ -232,6 +267,12 @@ def _method_kwargs() -> dict[str, object]: method_kwargs=_method_kwargs, ) + +def _register_import_browser_command( + notes_app: typer.Typer, + *, + run_client_command_spec: RunClientCommandSpec, +) -> None: @notes_app.command("import-browser") def arrangement_clip_notes_import_browser( ctx: typer.Context, @@ -257,28 +298,16 @@ def arrangement_clip_notes_import_browser( ] = False, ) -> None: def _method_kwargs() -> dict[str, object]: - valid_track = require_track_index(track) - valid_index = require_arrangement_clip_index(index) - valid_mode = require_non_empty_string( - "mode", - mode, - hint="Use --mode replace or append.", - ).lower() - if valid_mode not in {"replace", "append"}: - raise invalid_argument( - message=f"mode must be one of replace/append, got {mode}", - hint="Use --mode replace or append.", - ) target_uri, target_path = resolve_uri_or_path_target( target=target, hint="Use a browser path or URI for a .alc MIDI clip item.", ) return { - "track": valid_track, - "index": valid_index, + "track": require_track_index(track), + "index": require_arrangement_clip_index(index), "target_uri": target_uri, "target_path": target_path, - "mode": valid_mode, + "mode": _validate_import_mode(mode), "import_length": import_length, "import_groove": import_groove, } @@ -296,3 +325,15 @@ def _method_kwargs() -> dict[str, object]: }, method_kwargs=_method_kwargs, ) + + +def register_commands( + notes_app: typer.Typer, + *, + run_client_command_spec: RunClientCommandSpec, +) -> None: + _register_add_command(notes_app, run_client_command_spec=run_client_command_spec) + _register_get_command(notes_app, run_client_command_spec=run_client_command_spec) + _register_clear_command(notes_app, run_client_command_spec=run_client_command_spec) + _register_replace_command(notes_app, run_client_command_spec=run_client_command_spec) + _register_import_browser_command(notes_app, run_client_command_spec=run_client_command_spec) diff --git a/src/ableton_cli/commands/arrangement.py b/src/ableton_cli/commands/arrangement.py index c8476e0..3b0c287 100644 --- a/src/ableton_cli/commands/arrangement.py +++ b/src/ableton_cli/commands/arrangement.py @@ -10,7 +10,6 @@ from ._arrangement_record_commands import register_commands as register_record_commands from ._arrangement_session_commands import register_commands as register_session_commands from ._arrangement_specs import ArrangementCommandSpec -from ._client_command_runner import run_client_command as run_client_command_shared from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared arrangement_app = typer.Typer(help="Arrangement commands", no_args_is_help=True) @@ -19,23 +18,6 @@ notes_app = typer.Typer(help="Arrangement clip note commands", no_args_is_help=True) -def run_client_command( - ctx: typer.Context, - *, - command_name: str, - args: dict[str, object], - fn: Callable[[object], dict[str, object]], -) -> None: - run_client_command_shared( - ctx, - command_name=command_name, - args=args, - fn=fn, - get_client_fn=get_client, - execute_command_fn=execute_command, - ) - - def run_client_command_spec( ctx: typer.Context, *, diff --git a/src/ableton_cli/commands/browser.py b/src/ableton_cli/commands/browser.py index e76936d..855d1c3 100644 --- a/src/ableton_cli/commands/browser.py +++ b/src/ableton_cli/commands/browser.py @@ -7,7 +7,6 @@ from ..runtime import execute_command, get_client from ._client_command_runner import CommandSpec -from ._client_command_runner import run_client_command as run_client_command_shared from ._client_command_runner import run_client_command_spec as run_client_command_spec_shared from ._validation import ( invalid_argument, @@ -56,23 +55,6 @@ ) -def run_client_command( - ctx: typer.Context, - *, - command_name: str, - args: dict[str, object], - fn: Callable[[object], dict[str, object]], -) -> None: - run_client_command_shared( - ctx, - command_name=command_name, - args=args, - fn=fn, - get_client_fn=get_client, - execute_command_fn=execute_command, - ) - - def run_client_command_spec( ctx: typer.Context, *,