From fb0252c15ed21ece7216ee48e99d75b349fecb22 Mon Sep 17 00:00:00 2001 From: 6uclz1 <9139177+6uclz1@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:11:29 +0900 Subject: [PATCH 1/3] reduce command validation duplication --- src/ableton_cli/commands/_validation.py | 30 +++ src/ableton_cli/commands/effect.py | 196 +++++++-------- src/ableton_cli/commands/synth.py | 196 +++++++-------- src/ableton_cli/commands/track.py | 321 ++++++++++++------------ tests/commands/test_validation.py | 100 ++++++++ 5 files changed, 478 insertions(+), 365 deletions(-) create mode 100644 tests/commands/test_validation.py diff --git a/src/ableton_cli/commands/_validation.py b/src/ableton_cli/commands/_validation.py index 61a58a8..1d70da3 100644 --- a/src/ableton_cli/commands/_validation.py +++ b/src/ableton_cli/commands/_validation.py @@ -7,6 +7,8 @@ from ..errors import AppError, ExitCode NOTE_KEYS = {"pitch", "start_time", "duration", "velocity", "mute"} +TRACK_INDEX_HINT = "Use a valid track index from 'ableton-cli tracks list'." +DEVICE_INDEX_HINT = "Use a valid device index from 'ableton-cli track info'." def invalid_argument(message: str, hint: str) -> AppError: @@ -24,6 +26,18 @@ def require_non_negative(name: str, value: int, *, hint: str) -> int: return value +def require_track_index(value: int, *, hint: str = TRACK_INDEX_HINT) -> int: + return require_non_negative("track", value, hint=hint) + + +def require_device_index(value: int, *, hint: str = DEVICE_INDEX_HINT) -> int: + return require_non_negative("device", value, hint=hint) + + +def require_parameter_index(value: int, *, hint: str) -> int: + return require_non_negative("parameter", value, hint=hint) + + def require_minus_one_or_non_negative(name: str, value: int, *, hint: str) -> int: if value < -1: raise invalid_argument(message=f"{name} must be >= -1, got {value}", hint=hint) @@ -42,6 +56,22 @@ def require_non_negative_float(name: str, value: float, *, hint: str) -> float: return value +def require_float_in_range( + name: str, + value: float, + *, + minimum: float, + maximum: float, + hint: str, +) -> float: + if value < minimum or value > maximum: + raise invalid_argument( + message=f"{name} must be between {minimum} and {maximum}, got {value}", + hint=hint, + ) + return value + + def require_non_empty_string(name: str, value: str, *, hint: str) -> str: stripped = value.strip() if not stripped: diff --git a/src/ableton_cli/commands/effect.py b/src/ableton_cli/commands/effect.py index 85f50a0..324da26 100644 --- a/src/ableton_cli/commands/effect.py +++ b/src/ableton_cli/commands/effect.py @@ -1,11 +1,18 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer from ..runtime import execute_command, get_client -from ._validation import invalid_argument, require_non_empty_string, require_non_negative +from ._validation import ( + invalid_argument, + require_device_index, + require_non_empty_string, + require_parameter_index, + require_track_index, +) _SUPPORTED_EFFECT_TYPES = ( "eq8", @@ -16,6 +23,10 @@ "utility", ) +TrackArgument = Annotated[int, typer.Argument(help="Track index (0-based)")] +DeviceArgument = Annotated[int, typer.Argument(help="Device index (0-based)")] +ParameterArgument = Annotated[int, typer.Argument(help="Parameter index (0-based)")] + effect_app = typer.Typer(help="Effect control commands", no_args_is_help=True) parameters_app = typer.Typer(help="Effect parameter listing commands", no_args_is_help=True) parameter_app = typer.Typer(help="Effect parameter write commands", no_args_is_help=True) @@ -32,6 +43,46 @@ def _normalize_effect_type(value: str) -> str: return normalized +def _require_optional_track_index(track: int | None) -> int | None: + if track is None: + return None + return require_track_index(track) + + +def _require_track_and_device_index(track: int, device: int) -> tuple[int, int]: + return ( + require_track_index(track), + require_device_index(device), + ) + + +def _require_effect_parameter_index(parameter: int) -> int: + return require_parameter_index( + parameter, + hint="Use a valid parameter index from 'ableton-cli effect parameters list'.", + ) + + +def _execute_track_device_command( + ctx: typer.Context, + *, + command: str, + track: int, + device: int, + action: Callable[[int, int], dict[str, object]], +) -> None: + def _run() -> dict[str, object]: + valid_track, valid_device = _require_track_and_device_index(track, device) + return action(valid_track, valid_device) + + execute_command( + ctx, + command=command, + args={"track": track, "device": device}, + action=_run, + ) + + @effect_app.command("find") def effect_find( ctx: typer.Context, @@ -48,14 +99,9 @@ def effect_find( ] = None, ) -> 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'.", - ) + valid_track = _require_optional_track_index(track) valid_type = _normalize_effect_type(effect_type) if effect_type is not None else None - return get_client(ctx).find_effect_devices(track=track, effect_type=valid_type) + return get_client(ctx).find_effect_devices(track=valid_track, effect_type=valid_type) execute_command( ctx, @@ -68,58 +114,36 @@ def _run() -> dict[str, object]: @parameters_app.command("list") def effect_parameters_list( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - return get_client(ctx).list_effect_parameters(track=track, device=device) - - execute_command( + _execute_track_device_command( ctx, command="effect parameters list", - args={"track": track, "device": device}, - action=_run, + track=track, + device=device, + action=lambda valid_track, valid_device: get_client(ctx).list_effect_parameters( + track=valid_track, + device=valid_device, + ), ) @parameter_app.command("set") def effect_parameter_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], - parameter: Annotated[int, typer.Argument(help="Parameter index (0-based)")], + track: TrackArgument, + device: DeviceArgument, + parameter: ParameterArgument, value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - require_non_negative( - "parameter", - parameter, - hint="Use a valid parameter index from 'ableton-cli effect parameters list'.", - ) + valid_track, valid_device = _require_track_and_device_index(track, device) + valid_parameter = _require_effect_parameter_index(parameter) return get_client(ctx).set_effect_parameter_safe( - track=track, - device=device, - parameter=parameter, + track=valid_track, + device=valid_device, + parameter=valid_parameter, value=value, ) @@ -134,27 +158,18 @@ def _run() -> dict[str, object]: @effect_app.command("observe") def effect_observe( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - return get_client(ctx).observe_effect_parameters(track=track, device=device) - - execute_command( + _execute_track_device_command( ctx, command="effect observe", - args={"track": track, "device": device}, - action=_run, + track=track, + device=device, + action=lambda valid_track, valid_device: get_client(ctx).observe_effect_parameters( + track=valid_track, + device=valid_device, + ), ) @@ -176,22 +191,13 @@ def keys(ctx: typer.Context) -> None: @standard_app.command("set") def standard_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, key: Annotated[str, typer.Argument(help="Stable effect key")], value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) + valid_track, valid_device = _require_track_and_device_index(track, device) valid_key = require_non_empty_string( "key", key, @@ -199,8 +205,8 @@ def _run() -> dict[str, object]: ) return get_client(ctx).set_standard_effect_parameter_safe( effect_type=effect_type, - track=track, - device=device, + track=valid_track, + device=valid_device, key=valid_key, value=value, ) @@ -215,31 +221,19 @@ def _run() -> dict[str, object]: @standard_app.command("observe") def standard_observe( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - return get_client(ctx).observe_standard_effect_state( - effect_type=effect_type, - track=track, - device=device, - ) - - execute_command( + _execute_track_device_command( ctx, command=f"effect {cli_name} observe", - args={"track": track, "device": device}, - action=_run, + track=track, + device=device, + action=lambda valid_track, valid_device: get_client(ctx).observe_standard_effect_state( + effect_type=effect_type, + track=valid_track, + device=valid_device, + ), ) return standard_app diff --git a/src/ableton_cli/commands/synth.py b/src/ableton_cli/commands/synth.py index aa1db01..3153552 100644 --- a/src/ableton_cli/commands/synth.py +++ b/src/ableton_cli/commands/synth.py @@ -1,14 +1,25 @@ from __future__ import annotations +from collections.abc import Callable from typing import Annotated import typer from ..runtime import execute_command, get_client -from ._validation import invalid_argument, require_non_empty_string, require_non_negative +from ._validation import ( + invalid_argument, + require_device_index, + require_non_empty_string, + require_parameter_index, + require_track_index, +) _SUPPORTED_SYNTH_TYPES = ("wavetable", "drift", "meld") +TrackArgument = Annotated[int, typer.Argument(help="Track index (0-based)")] +DeviceArgument = Annotated[int, typer.Argument(help="Device index (0-based)")] +ParameterArgument = Annotated[int, typer.Argument(help="Parameter index (0-based)")] + synth_app = typer.Typer(help="Synth control commands", no_args_is_help=True) parameters_app = typer.Typer(help="Synth parameter listing commands", no_args_is_help=True) parameter_app = typer.Typer(help="Synth parameter write commands", no_args_is_help=True) @@ -25,6 +36,46 @@ def _normalize_synth_type(value: str) -> str: return normalized +def _require_optional_track_index(track: int | None) -> int | None: + if track is None: + return None + return require_track_index(track) + + +def _require_track_and_device_index(track: int, device: int) -> tuple[int, int]: + return ( + require_track_index(track), + require_device_index(device), + ) + + +def _require_synth_parameter_index(parameter: int) -> int: + return require_parameter_index( + parameter, + hint="Use a valid parameter index from 'ableton-cli synth parameters list'.", + ) + + +def _execute_track_device_command( + ctx: typer.Context, + *, + command: str, + track: int, + device: int, + action: Callable[[int, int], dict[str, object]], +) -> None: + def _run() -> dict[str, object]: + valid_track, valid_device = _require_track_and_device_index(track, device) + return action(valid_track, valid_device) + + execute_command( + ctx, + command=command, + args={"track": track, "device": device}, + action=_run, + ) + + @synth_app.command("find") def synth_find( ctx: typer.Context, @@ -38,14 +89,9 @@ def synth_find( ] = None, ) -> 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'.", - ) + valid_track = _require_optional_track_index(track) valid_type = _normalize_synth_type(synth_type) if synth_type is not None else None - return get_client(ctx).find_synth_devices(track=track, synth_type=valid_type) + return get_client(ctx).find_synth_devices(track=valid_track, synth_type=valid_type) execute_command( ctx, @@ -58,58 +104,36 @@ def _run() -> dict[str, object]: @parameters_app.command("list") def synth_parameters_list( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - return get_client(ctx).list_synth_parameters(track=track, device=device) - - execute_command( + _execute_track_device_command( ctx, command="synth parameters list", - args={"track": track, "device": device}, - action=_run, + track=track, + device=device, + action=lambda valid_track, valid_device: get_client(ctx).list_synth_parameters( + track=valid_track, + device=valid_device, + ), ) @parameter_app.command("set") def synth_parameter_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], - parameter: Annotated[int, typer.Argument(help="Parameter index (0-based)")], + track: TrackArgument, + device: DeviceArgument, + parameter: ParameterArgument, value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - require_non_negative( - "parameter", - parameter, - hint="Use a valid parameter index from 'ableton-cli synth parameters list'.", - ) + valid_track, valid_device = _require_track_and_device_index(track, device) + valid_parameter = _require_synth_parameter_index(parameter) return get_client(ctx).set_synth_parameter_safe( - track=track, - device=device, - parameter=parameter, + track=valid_track, + device=valid_device, + parameter=valid_parameter, value=value, ) @@ -124,27 +148,18 @@ def _run() -> dict[str, object]: @synth_app.command("observe") def synth_observe( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - return get_client(ctx).observe_synth_parameters(track=track, device=device) - - execute_command( + _execute_track_device_command( ctx, command="synth observe", - args={"track": track, "device": device}, - action=_run, + track=track, + device=device, + action=lambda valid_track, valid_device: get_client(ctx).observe_synth_parameters( + track=valid_track, + device=valid_device, + ), ) @@ -166,22 +181,13 @@ def keys(ctx: typer.Context) -> None: @standard_app.command("set") def standard_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, key: Annotated[str, typer.Argument(help="Stable synth key")], value: Annotated[float, typer.Argument(help="Target parameter value")], ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) + valid_track, valid_device = _require_track_and_device_index(track, device) valid_key = require_non_empty_string( "key", key, @@ -189,8 +195,8 @@ def _run() -> dict[str, object]: ) return get_client(ctx).set_standard_synth_parameter_safe( synth_type=synth_type, - track=track, - device=device, + track=valid_track, + device=valid_device, key=valid_key, value=value, ) @@ -205,31 +211,19 @@ def _run() -> dict[str, object]: @standard_app.command("observe") def standard_observe( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - device: Annotated[int, typer.Argument(help="Device index (0-based)")], + track: TrackArgument, + device: DeviceArgument, ) -> 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( - "device", - device, - hint="Use a valid device index from 'ableton-cli track info'.", - ) - return get_client(ctx).observe_standard_synth_state( - synth_type=synth_type, - track=track, - device=device, - ) - - execute_command( + _execute_track_device_command( ctx, command=f"synth {synth_type} observe", - args={"track": track, "device": device}, - action=_run, + track=track, + device=device, + action=lambda valid_track, valid_device: get_client(ctx).observe_standard_synth_state( + synth_type=synth_type, + track=valid_track, + device=valid_device, + ), ) return standard_app diff --git a/src/ableton_cli/commands/track.py b/src/ableton_cli/commands/track.py index 30931e0..10e8594 100644 --- a/src/ableton_cli/commands/track.py +++ b/src/ableton_cli/commands/track.py @@ -1,11 +1,89 @@ from __future__ import annotations -from typing import Annotated +from collections.abc import Callable +from typing import Annotated, TypeVar import typer from ..runtime import execute_command, get_client -from ._validation import invalid_argument, require_non_empty_string, require_non_negative +from ._validation import require_float_in_range, require_non_empty_string, 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]")] + + +def _execute_track_get( + ctx: typer.Context, + *, + command: str, + track: int, + action: Callable[[int], dict[str, object]], +) -> None: + def _run() -> dict[str, object]: + valid_track = require_track_index(track) + return action(valid_track) + + execute_command( + ctx, + command=command, + args={"track": track}, + action=_run, + ) + + +def _execute_track_set( + ctx: typer.Context, + *, + command: str, + track: int, + value: TValue, + action: Callable[[int, TValue], dict[str, object]], + value_name: str = "value", + validator: Callable[[TValue], TValue] | None = None, +) -> None: + def _run() -> dict[str, object]: + valid_track = require_track_index(track) + valid_value = validator(value) if validator is not None else value + return action(valid_track, valid_value) + + execute_command( + ctx, + command=command, + args={"track": track, value_name: value}, + action=_run, + ) + + +def _require_volume_value(value: float) -> float: + return require_float_in_range( + "value", + value, + minimum=0.0, + maximum=1.0, + hint="Use a normalized volume value such as 0.75.", + ) + + +def _require_panning_value(value: float) -> float: + return require_float_in_range( + "value", + value, + minimum=-1.0, + maximum=1.0, + hint="Use a normalized panning value such as -0.25.", + ) + + +def _require_track_name(value: str) -> str: + return require_non_empty_string( + "name", + value, + hint="Pass a non-empty track name.", + ) + 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) @@ -19,273 +97,190 @@ @track_app.command("info") def track_info( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).get_track_info(track) - - execute_command( + _execute_track_get( ctx, command="track info", - args={"track": track}, - action=_run, + track=track, + action=lambda valid_track: get_client(ctx).get_track_info(valid_track), ) @volume_app.command("get") def volume_get( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, ) -> None: - def _run() -> dict[str, float | int]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_volume_get(track) - - execute_command( + _execute_track_get( ctx, command="track volume get", - args={"track": track}, - action=_run, + track=track, + action=lambda valid_track: get_client(ctx).track_volume_get(valid_track), ) @volume_app.command("set") def volume_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - value: Annotated[float, typer.Argument(help="Volume value in [0.0, 1.0]")], + track: TrackArgument, + value: VolumeValueArgument, ) -> None: - def _run() -> dict[str, float | int]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - if value < 0.0 or value > 1.0: - raise invalid_argument( - message=f"value must be between 0.0 and 1.0, got {value}", - hint="Use a normalized volume value such as 0.75.", - ) - return get_client(ctx).track_volume_set(track, value) - - execute_command( + _execute_track_set( ctx, command="track volume set", - args={"track": track, "value": value}, - action=_run, + track=track, + value=value, + validator=_require_volume_value, + action=lambda valid_track, valid_value: get_client(ctx).track_volume_set( + valid_track, + valid_value, + ), ) @name_app.command("set") def track_name_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, name: Annotated[str, typer.Argument(help="New track name")], ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - valid_name = require_non_empty_string( - "name", - name, - hint="Pass a non-empty track name.", - ) - return get_client(ctx).set_track_name(track, valid_name) - - execute_command( + _execute_track_set( ctx, command="track name set", - args={"track": track, "name": name}, - action=_run, + track=track, + value=name, + value_name="name", + validator=_require_track_name, + action=lambda valid_track, valid_name: get_client(ctx).set_track_name( + valid_track, + valid_name, + ), ) @mute_app.command("get") def mute_get( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_mute_get(track) - - execute_command( + _execute_track_get( ctx, command="track mute get", - args={"track": track}, - action=_run, + track=track, + action=lambda valid_track: get_client(ctx).track_mute_get(valid_track), ) @mute_app.command("set") def mute_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, value: Annotated[bool, typer.Argument(help="Mute value: true|false")], ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_mute_set(track, value) - - execute_command( + _execute_track_set( ctx, command="track mute set", - args={"track": track, "value": value}, - action=_run, + track=track, + value=value, + action=lambda valid_track, valid_value: get_client(ctx).track_mute_set( + valid_track, + valid_value, + ), ) @solo_app.command("get") def solo_get( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_solo_get(track) - - execute_command( + _execute_track_get( ctx, command="track solo get", - args={"track": track}, - action=_run, + track=track, + action=lambda valid_track: get_client(ctx).track_solo_get(valid_track), ) @solo_app.command("set") def solo_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, value: Annotated[bool, typer.Argument(help="Solo value: true|false")], ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_solo_set(track, value) - - execute_command( + _execute_track_set( ctx, command="track solo set", - args={"track": track, "value": value}, - action=_run, + track=track, + value=value, + action=lambda valid_track, valid_value: get_client(ctx).track_solo_set( + valid_track, + valid_value, + ), ) @arm_app.command("get") def arm_get( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_arm_get(track) - - execute_command( + _execute_track_get( ctx, command="track arm get", - args={"track": track}, - action=_run, + track=track, + action=lambda valid_track: get_client(ctx).track_arm_get(valid_track), ) @arm_app.command("set") def arm_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, value: Annotated[bool, typer.Argument(help="Arm value: true|false")], ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_arm_set(track, value) - - execute_command( + _execute_track_set( ctx, command="track arm set", - args={"track": track, "value": value}, - action=_run, + track=track, + value=value, + action=lambda valid_track, valid_value: get_client(ctx).track_arm_set( + valid_track, + valid_value, + ), ) @panning_app.command("get") def panning_get( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], + track: TrackArgument, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - return get_client(ctx).track_panning_get(track) - - execute_command( + _execute_track_get( ctx, command="track panning get", - args={"track": track}, - action=_run, + track=track, + action=lambda valid_track: get_client(ctx).track_panning_get(valid_track), ) @panning_app.command("set") def panning_set( ctx: typer.Context, - track: Annotated[int, typer.Argument(help="Track index (0-based)")], - value: Annotated[float, typer.Argument(help="Panning value in [-1.0, 1.0]")], + track: TrackArgument, + value: PanningValueArgument, ) -> None: - def _run() -> dict[str, object]: - require_non_negative( - "track", - track, - hint="Use a valid track index from 'ableton-cli tracks list'.", - ) - if value < -1.0 or value > 1.0: - raise invalid_argument( - message=f"value must be between -1.0 and 1.0, got {value}", - hint="Use a normalized panning value such as -0.25.", - ) - return get_client(ctx).track_panning_set(track, value) - - execute_command( + _execute_track_set( ctx, command="track panning set", - args={"track": track, "value": value}, - action=_run, + track=track, + value=value, + validator=_require_panning_value, + action=lambda valid_track, valid_value: get_client(ctx).track_panning_set( + valid_track, + valid_value, + ), ) diff --git a/tests/commands/test_validation.py b/tests/commands/test_validation.py new file mode 100644 index 0000000..52e5a2f --- /dev/null +++ b/tests/commands/test_validation.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest + +from ableton_cli.errors import AppError + + +def _assert_invalid_argument(exc: pytest.ExceptionInfo[AppError]) -> None: + assert exc.value.error_code == "INVALID_ARGUMENT" + + +def test_require_track_index_accepts_zero_or_positive_values() -> None: + from ableton_cli.commands._validation import require_track_index + + assert require_track_index(0) == 0 + assert require_track_index(3) == 3 + + +def test_require_track_index_rejects_negative_values() -> None: + from ableton_cli.commands._validation import require_track_index + + with pytest.raises(AppError) as exc: + require_track_index(-1) + + _assert_invalid_argument(exc) + assert exc.value.message == "track must be >= 0, got -1" + assert exc.value.hint == "Use a valid track index from 'ableton-cli tracks list'." + + +def test_require_device_index_accepts_zero_or_positive_values() -> None: + from ableton_cli.commands._validation import require_device_index + + assert require_device_index(0) == 0 + assert require_device_index(5) == 5 + + +def test_require_device_index_rejects_negative_values() -> None: + from ableton_cli.commands._validation import require_device_index + + with pytest.raises(AppError) as exc: + require_device_index(-1) + + _assert_invalid_argument(exc) + assert exc.value.message == "device must be >= 0, got -1" + assert exc.value.hint == "Use a valid device index from 'ableton-cli track info'." + + +def test_require_parameter_index_rejects_negative_values_with_custom_hint() -> None: + from ableton_cli.commands._validation import require_parameter_index + + with pytest.raises(AppError) as exc: + require_parameter_index( + -1, hint="Use a valid parameter index from 'ableton-cli synth parameters list'." + ) + + _assert_invalid_argument(exc) + assert exc.value.message == "parameter must be >= 0, got -1" + assert exc.value.hint == "Use a valid parameter index from 'ableton-cli synth parameters list'." + + +def test_require_float_in_range_accepts_boundary_values() -> None: + from ableton_cli.commands._validation import require_float_in_range + + assert ( + require_float_in_range( + "value", + 0.0, + minimum=0.0, + maximum=1.0, + hint="Use a normalized value such as 0.75.", + ) + == 0.0 + ) + assert ( + require_float_in_range( + "value", + 1.0, + minimum=0.0, + maximum=1.0, + hint="Use a normalized value such as 0.75.", + ) + == 1.0 + ) + + +def test_require_float_in_range_rejects_out_of_range_values() -> None: + from ableton_cli.commands._validation import require_float_in_range + + with pytest.raises(AppError) as exc: + require_float_in_range( + "value", + 1.2, + minimum=0.0, + maximum=1.0, + hint="Use a normalized value such as 0.75.", + ) + + _assert_invalid_argument(exc) + assert exc.value.message == "value must be between 0.0 and 1.0, got 1.2" + assert exc.value.hint == "Use a normalized value such as 0.75." From a9b6e238ec399c382d07b212db9261556328297e Mon Sep 17 00:00:00 2001 From: 6uclz1 <9139177+6uclz1@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:20:48 +0900 Subject: [PATCH 2/3] split app bootstrap and command wiring --- src/ableton_cli/app.py | 167 +------------------------- src/ableton_cli/app_factory.py | 122 +++++++++++++++++++ src/ableton_cli/bootstrap.py | 44 +++++++ src/ableton_cli/cli.py | 2 +- src/ableton_cli/platform_detection.py | 35 ++++++ src/ableton_cli/version.py | 12 ++ tests/test_app_factory.py | 2 +- tests/test_bootstrap.py | 107 +++++++++++++++++ tests/test_cli_json_output.py | 12 +- tests/test_platform_detection.py | 75 ++++++++++++ tests/test_version.py | 23 ++++ 11 files changed, 430 insertions(+), 171 deletions(-) create mode 100644 src/ableton_cli/app_factory.py create mode 100644 src/ableton_cli/bootstrap.py create mode 100644 src/ableton_cli/platform_detection.py create mode 100644 src/ableton_cli/version.py create mode 100644 tests/test_bootstrap.py create mode 100644 tests/test_platform_detection.py create mode 100644 tests/test_version.py diff --git a/src/ableton_cli/app.py b/src/ableton_cli/app.py index fc17425..0019f6e 100644 --- a/src/ableton_cli/app.py +++ b/src/ableton_cli/app.py @@ -1,168 +1,5 @@ from __future__ import annotations -import platform -from pathlib import Path -from typing import Annotated +from .app_factory import create_app -import typer - -from . import __version__ -from .commands import ( - arrangement, - batch, - browser, - clip, - device, - effect, - scenes, - session, - setup, - song, - synth, - track, - tracks, - transport, -) -from .config import resolve_settings -from .errors import AppError, ExitCode -from .logging_setup import configure_logging -from .output import OutputMode, emit_human_error, emit_json, error_payload -from .platform_paths import PlatformPaths, PosixPlatformPaths, WindowsPlatformPaths -from .runtime import RuntimeContext - -_COMMAND_MODULES = ( - setup, - batch, - song, - arrangement, - session, - scenes, - tracks, - transport, - track, - clip, - browser, - device, - synth, - effect, -) - - -def _version_callback(value: bool) -> None: - if value: - typer.echo(__version__) - raise typer.Exit(code=ExitCode.SUCCESS.value) - - -def _build_platform_paths_for_current_os() -> PlatformPaths: - detected_os = platform.system().lower() - home = Path.home() - - if detected_os == "windows": - return WindowsPlatformPaths(home=home) - if detected_os == "darwin": - return PosixPlatformPaths( - home=home, - remote_script_relative_dirs=( - ("Music", "Ableton", "User Library", "Remote Scripts"), - ("Documents", "Ableton", "User Library", "Remote Scripts"), - ), - ) - if detected_os == "linux": - return PosixPlatformPaths( - home=home, - remote_script_relative_dirs=(("Ableton", "User Library", "Remote Scripts"),), - ) - - raise AppError( - error_code="UNSUPPORTED_OS", - message=f"Unsupported operating system: {detected_os}", - hint="Use Windows, macOS, or Linux.", - exit_code=ExitCode.EXECUTION_FAILED, - ) - - -def _register_subcommands(app: typer.Typer) -> None: - for command_module in _COMMAND_MODULES: - command_module.register(app) - - -def main( - ctx: typer.Context, - host: Annotated[str | None, typer.Option("--host", help="Remote host")] = None, - port: Annotated[int | None, typer.Option("--port", help="Remote port")] = None, - timeout_ms: Annotated[ - int | None, - typer.Option("--timeout-ms", help="Request timeout in milliseconds"), - ] = None, - protocol_version: Annotated[ - int | None, - typer.Option("--protocol-version", help="Protocol version for CLI/Remote handshake"), - ] = None, - output: Annotated[ - OutputMode, - typer.Option("--output", help="Output mode", case_sensitive=False), - ] = OutputMode.HUMAN, - verbose: Annotated[bool, typer.Option("--verbose", help="Enable verbose logging")] = False, - log_file: Annotated[str | None, typer.Option("--log-file", help="Log file path")] = None, - config: Annotated[Path | None, typer.Option("--config", help="Config file path")] = None, - no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output")] = False, - quiet: Annotated[bool, typer.Option("--quiet", help="Suppress human success output")] = False, - version: Annotated[ - bool, - typer.Option( - "--version", - callback=_version_callback, - is_eager=True, - help="Show version and exit", - ), - ] = False, -) -> None: - del version - - cli_overrides = { - "host": host, - "port": port, - "timeout_ms": timeout_ms, - "log_file": log_file, - "protocol_version": protocol_version, - } - - try: - settings = resolve_settings(cli_overrides=cli_overrides, config_path=config) - platform_paths = _build_platform_paths_for_current_os() - except AppError as exc: - payload = error_payload( - command="bootstrap", - args={}, - code=exc.error_code, - message=exc.message, - hint=exc.hint, - details=exc.details or None, - ) - if output == OutputMode.JSON: - emit_json(payload) - else: - emit_human_error(exc.error_code, exc.message, exc.hint) - raise typer.Exit(code=exc.exit_code.value) from exc - - configure_logging(verbose=verbose, quiet=quiet, log_file=settings.log_file) - - ctx.obj = RuntimeContext( - settings=settings, - platform_paths=platform_paths, - output_mode=output, - quiet=quiet, - no_color=no_color, - ) - - -def create_app() -> typer.Typer: - app = typer.Typer( - help="Control and inspect Ableton Live through a local Remote Script.", - no_args_is_help=True, - add_completion=True, - ) - app.callback()(main) - _register_subcommands(app) - return app +__all__ = ["create_app"] diff --git a/src/ableton_cli/app_factory.py b/src/ableton_cli/app_factory.py new file mode 100644 index 0000000..0b87bec --- /dev/null +++ b/src/ableton_cli/app_factory.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from .bootstrap import build_runtime_context +from .commands import ( + arrangement, + batch, + browser, + clip, + device, + effect, + scenes, + session, + setup, + song, + synth, + track, + tracks, + transport, +) +from .errors import AppError +from .output import OutputMode, emit_human_error, emit_json, error_payload +from .version import version_callback + +_COMMAND_MODULES = ( + setup, + batch, + song, + arrangement, + session, + scenes, + tracks, + transport, + track, + clip, + browser, + device, + synth, + effect, +) + + +def register_commands(app: typer.Typer) -> None: + for command_module in _COMMAND_MODULES: + command_module.register(app) + + +def main( + ctx: typer.Context, + host: Annotated[str | None, typer.Option("--host", help="Remote host")] = None, + port: Annotated[int | None, typer.Option("--port", help="Remote port")] = None, + timeout_ms: Annotated[ + int | None, + typer.Option("--timeout-ms", help="Request timeout in milliseconds"), + ] = None, + protocol_version: Annotated[ + int | None, + typer.Option("--protocol-version", help="Protocol version for CLI/Remote handshake"), + ] = None, + output: Annotated[ + OutputMode, + typer.Option("--output", help="Output mode", case_sensitive=False), + ] = OutputMode.HUMAN, + verbose: Annotated[bool, typer.Option("--verbose", help="Enable verbose logging")] = False, + log_file: Annotated[str | None, typer.Option("--log-file", help="Log file path")] = None, + config: Annotated[Path | None, typer.Option("--config", help="Config file path")] = None, + no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output")] = False, + quiet: Annotated[bool, typer.Option("--quiet", help="Suppress human success output")] = False, + version: Annotated[ + bool, + typer.Option( + "--version", + callback=version_callback, + is_eager=True, + help="Show version and exit", + ), + ] = False, +) -> None: + del version + + try: + ctx.obj = build_runtime_context( + host=host, + port=port, + timeout_ms=timeout_ms, + protocol_version=protocol_version, + output=output, + verbose=verbose, + log_file=log_file, + config=config, + no_color=no_color, + quiet=quiet, + ) + except AppError as exc: + payload = error_payload( + command="bootstrap", + args={}, + code=exc.error_code, + message=exc.message, + hint=exc.hint, + details=exc.details or None, + ) + if output == OutputMode.JSON: + emit_json(payload) + else: + emit_human_error(exc.error_code, exc.message, exc.hint) + raise typer.Exit(code=exc.exit_code.value) from exc + + +def create_app() -> typer.Typer: + app = typer.Typer( + help="Control and inspect Ableton Live through a local Remote Script.", + no_args_is_help=True, + add_completion=True, + ) + app.callback()(main) + register_commands(app) + return app diff --git a/src/ableton_cli/bootstrap.py b/src/ableton_cli/bootstrap.py new file mode 100644 index 0000000..6e2040c --- /dev/null +++ b/src/ableton_cli/bootstrap.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .config import resolve_settings +from .logging_setup import configure_logging +from .output import OutputMode +from .platform_detection import build_platform_paths_for_current_os +from .runtime import RuntimeContext + + +def build_runtime_context( + *, + host: str | None, + port: int | None, + timeout_ms: int | None, + protocol_version: int | None, + output: OutputMode, + verbose: bool, + log_file: str | None, + config: Path | None, + no_color: bool, + quiet: bool, +) -> RuntimeContext: + cli_overrides: dict[str, Any] = { + "host": host, + "port": port, + "timeout_ms": timeout_ms, + "log_file": log_file, + "protocol_version": protocol_version, + } + + settings = resolve_settings(cli_overrides=cli_overrides, config_path=config) + platform_paths = build_platform_paths_for_current_os() + configure_logging(verbose=verbose, quiet=quiet, log_file=settings.log_file) + + return RuntimeContext( + settings=settings, + platform_paths=platform_paths, + output_mode=output, + quiet=quiet, + no_color=no_color, + ) diff --git a/src/ableton_cli/cli.py b/src/ableton_cli/cli.py index b9ae7df..2d2ede4 100644 --- a/src/ableton_cli/cli.py +++ b/src/ableton_cli/cli.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .app import create_app +from .app_factory import create_app app = create_app() diff --git a/src/ableton_cli/platform_detection.py b/src/ableton_cli/platform_detection.py new file mode 100644 index 0000000..2b9dc43 --- /dev/null +++ b/src/ableton_cli/platform_detection.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import platform +from pathlib import Path + +from .errors import AppError, ExitCode +from .platform_paths import PlatformPaths, PosixPlatformPaths, WindowsPlatformPaths + + +def build_platform_paths_for_current_os() -> PlatformPaths: + detected_os = platform.system().lower() + home = Path.home() + + if detected_os == "windows": + return WindowsPlatformPaths(home=home) + if detected_os == "darwin": + return PosixPlatformPaths( + home=home, + remote_script_relative_dirs=( + ("Music", "Ableton", "User Library", "Remote Scripts"), + ("Documents", "Ableton", "User Library", "Remote Scripts"), + ), + ) + if detected_os == "linux": + return PosixPlatformPaths( + home=home, + remote_script_relative_dirs=(("Ableton", "User Library", "Remote Scripts"),), + ) + + raise AppError( + error_code="UNSUPPORTED_OS", + message=f"Unsupported operating system: {detected_os}", + hint="Use Windows, macOS, or Linux.", + exit_code=ExitCode.EXECUTION_FAILED, + ) diff --git a/src/ableton_cli/version.py b/src/ableton_cli/version.py new file mode 100644 index 0000000..8a1e726 --- /dev/null +++ b/src/ableton_cli/version.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import typer + +from . import __version__ +from .errors import ExitCode + + +def version_callback(value: bool) -> None: + if value: + typer.echo(__version__) + raise typer.Exit(code=ExitCode.SUCCESS.value) diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 17b7189..928b15a 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -2,7 +2,7 @@ from typer.testing import CliRunner -import ableton_cli.app as app_module +import ableton_cli.app_factory as app_module def test_create_app_returns_new_instances() -> None: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..9858fe5 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import ableton_cli.bootstrap as bootstrap +from ableton_cli.config import Settings +from ableton_cli.errors import AppError, ExitCode +from ableton_cli.output import OutputMode +from ableton_cli.platform_paths import PosixPlatformPaths + + +def test_build_runtime_context_resolves_dependencies_and_configures_logging( + monkeypatch, tmp_path +) -> None: + settings = Settings( + host="10.0.0.20", + port=12345, + timeout_ms=2500, + log_level="INFO", + log_file="/tmp/resolved.log", + protocol_version=7, + config_path="/tmp/config.toml", + ) + platform_paths = PosixPlatformPaths( + home=Path("/home/test-user"), + remote_script_relative_dirs=(("Ableton", "User Library", "Remote Scripts"),), + ) + seen: dict[str, object] = {} + + def _resolve_settings_stub(*, cli_overrides, config_path): # noqa: ANN001, ANN202 + seen["cli_overrides"] = cli_overrides + seen["config_path"] = config_path + return settings + + monkeypatch.setattr(bootstrap, "resolve_settings", _resolve_settings_stub) + monkeypatch.setattr(bootstrap, "build_platform_paths_for_current_os", lambda: platform_paths) + monkeypatch.setattr( + bootstrap, + "configure_logging", + lambda *, verbose, quiet, log_file: seen.update( + {"verbose": verbose, "quiet": quiet, "log_file": log_file} + ), + ) + + config_path = tmp_path / "config.toml" + runtime = bootstrap.build_runtime_context( + host="127.0.0.1", + port=8765, + timeout_ms=15000, + protocol_version=11, + output=OutputMode.JSON, + verbose=True, + log_file="/tmp/cli-override.log", + config=config_path, + no_color=True, + quiet=False, + ) + + assert runtime.settings is settings + assert runtime.platform_paths is platform_paths + assert runtime.output_mode is OutputMode.JSON + assert runtime.quiet is False + assert runtime.no_color is True + assert seen["config_path"] == config_path + assert seen["cli_overrides"] == { + "host": "127.0.0.1", + "port": 8765, + "timeout_ms": 15000, + "log_file": "/tmp/cli-override.log", + "protocol_version": 11, + } + assert seen["verbose"] is True + assert seen["quiet"] is False + assert seen["log_file"] == "/tmp/resolved.log" + + +def test_build_runtime_context_propagates_bootstrap_errors(monkeypatch) -> None: + expected = AppError( + error_code="CONFIG_INVALID", + message="boom", + hint="fix", + exit_code=ExitCode.CONFIG_INVALID, + ) + + def _raise_error(*, cli_overrides, config_path): # noqa: ANN001, ANN202 + del cli_overrides, config_path + raise expected + + monkeypatch.setattr(bootstrap, "resolve_settings", _raise_error) + + with pytest.raises(AppError) as exc_info: + bootstrap.build_runtime_context( + host=None, + port=None, + timeout_ms=None, + protocol_version=None, + output=OutputMode.HUMAN, + verbose=False, + log_file=None, + config=None, + no_color=False, + quiet=False, + ) + + assert exc_info.value is expected diff --git a/tests/test_cli_json_output.py b/tests/test_cli_json_output.py index 6247964..c419433 100644 --- a/tests/test_cli_json_output.py +++ b/tests/test_cli_json_output.py @@ -51,7 +51,7 @@ def test_ping_unreachable_outputs_json_error(runner, cli_app) -> None: def test_install_remote_script_verify_includes_doctor_result(runner, cli_app, monkeypatch) -> None: - import ableton_cli.app as app_module + import ableton_cli.bootstrap as bootstrap_module from ableton_cli.commands import setup class _PlatformPathsSentinel: @@ -93,7 +93,11 @@ def _run_doctor_stub(settings, *, platform_paths) -> dict[str, object]: "run_doctor", _run_doctor_stub, ) - monkeypatch.setattr(app_module, "_build_platform_paths_for_current_os", lambda: platform_paths) + monkeypatch.setattr( + bootstrap_module, + "build_platform_paths_for_current_os", + lambda: platform_paths, + ) result = runner.invoke( cli_app, @@ -142,9 +146,9 @@ def test_install_skill_outputs_json_envelope(runner, cli_app, monkeypatch) -> No def test_bootstrap_fails_for_unsupported_os(runner, cli_app, monkeypatch) -> None: - import ableton_cli.app as app_module + import ableton_cli.platform_detection as platform_detection_module - monkeypatch.setattr(app_module.platform, "system", lambda: "Solaris") + monkeypatch.setattr(platform_detection_module.platform, "system", lambda: "Solaris") result = runner.invoke( cli_app, diff --git a/tests/test_platform_detection.py b/tests/test_platform_detection.py new file mode 100644 index 0000000..7d72e39 --- /dev/null +++ b/tests/test_platform_detection.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import ableton_cli.platform_detection as platform_detection +from ableton_cli.errors import AppError, ExitCode +from ableton_cli.platform_paths import PosixPlatformPaths, WindowsPlatformPaths + + +def test_build_platform_paths_for_windows(monkeypatch) -> None: + home = Path("/home/test-user") + + class _PathStub: + @staticmethod + def home() -> Path: + return home + + monkeypatch.setattr(platform_detection, "Path", _PathStub) + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Windows") + + result = platform_detection.build_platform_paths_for_current_os() + + assert isinstance(result, WindowsPlatformPaths) + assert result.home == home + + +def test_build_platform_paths_for_macos(monkeypatch) -> None: + home = Path("/home/test-user") + + class _PathStub: + @staticmethod + def home() -> Path: + return home + + monkeypatch.setattr(platform_detection, "Path", _PathStub) + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Darwin") + + result = platform_detection.build_platform_paths_for_current_os() + + assert isinstance(result, PosixPlatformPaths) + assert result.home == home + assert result.remote_script_relative_dirs == ( + ("Music", "Ableton", "User Library", "Remote Scripts"), + ("Documents", "Ableton", "User Library", "Remote Scripts"), + ) + + +def test_build_platform_paths_for_linux(monkeypatch) -> None: + home = Path("/home/test-user") + + class _PathStub: + @staticmethod + def home() -> Path: + return home + + monkeypatch.setattr(platform_detection, "Path", _PathStub) + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Linux") + + result = platform_detection.build_platform_paths_for_current_os() + + assert isinstance(result, PosixPlatformPaths) + assert result.home == home + assert result.remote_script_relative_dirs == (("Ableton", "User Library", "Remote Scripts"),) + + +def test_build_platform_paths_for_unsupported_os_raises_error(monkeypatch) -> None: + monkeypatch.setattr(platform_detection.platform, "system", lambda: "Solaris") + + with pytest.raises(AppError) as exc_info: + platform_detection.build_platform_paths_for_current_os() + + assert exc_info.value.error_code == "UNSUPPORTED_OS" + assert exc_info.value.exit_code == ExitCode.EXECUTION_FAILED diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..23ae725 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import pytest +import typer + +import ableton_cli.version as version_module +from ableton_cli import __version__ +from ableton_cli.errors import ExitCode + + +def test_version_callback_prints_and_exits(monkeypatch) -> None: + seen: dict[str, str] = {} + monkeypatch.setattr(version_module.typer, "echo", lambda value: seen.setdefault("value", value)) + + with pytest.raises(typer.Exit) as exc_info: + version_module.version_callback(True) + + assert seen["value"] == __version__ + assert exc_info.value.exit_code == ExitCode.SUCCESS.value + + +def test_version_callback_ignores_false() -> None: + version_module.version_callback(False) From fbb944331eeee2bea7d97a3278f00eb5b15f19ea Mon Sep 17 00:00:00 2001 From: 6uclz1 <9139177+6uclz1@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:25:44 +0900 Subject: [PATCH 3/3] fix quality-harness layer mapping for app factory --- .quality-harness.yml | 1 + tests/test_quality_harness_config.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/test_quality_harness_config.py diff --git a/.quality-harness.yml b/.quality-harness.yml index d6e78c5..1cd0438 100644 --- a/.quality-harness.yml +++ b/.quality-harness.yml @@ -42,6 +42,7 @@ layers: - name: commands include: - "src/ableton_cli/commands/**/*.py" + - "src/ableton_cli/app_factory.py" - "src/ableton_cli/app.py" - "src/ableton_cli/cli.py" - name: core diff --git a/tests/test_quality_harness_config.py b/tests/test_quality_harness_config.py new file mode 100644 index 0000000..42c975f --- /dev/null +++ b/tests/test_quality_harness_config.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from ableton_cli.quality_harness.config import load_config + +REPO_ROOT = Path(__file__).resolve().parents[1] +QUALITY_HARNESS_CONFIG = REPO_ROOT / ".quality-harness.yml" + + +def test_commands_layer_includes_app_factory() -> None: + config = load_config(QUALITY_HARNESS_CONFIG) + + commands_layer = next(rule for rule in config.layers.order if rule.name == "commands") + assert "src/ableton_cli/app_factory.py" in commands_layer.include