Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .quality-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 2 additions & 165 deletions src/ableton_cli/app.py
Original file line number Diff line number Diff line change
@@ -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"]
122 changes: 122 additions & 0 deletions src/ableton_cli/app_factory.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions src/ableton_cli/bootstrap.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 1 addition & 1 deletion src/ableton_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from .app import create_app
from .app_factory import create_app

app = create_app()

Expand Down
35 changes: 35 additions & 0 deletions src/ableton_cli/platform_detection.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading