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
17 changes: 13 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.12", "3.13", "3.14"]
exclude:
# 3.14 is pre-release; skip Windows until wheels are widely available
- os: windows-latest
python-version: "3.14"

steps:
- uses: actions/checkout@v4
Expand All @@ -65,6 +69,8 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
# Allow pre-release builds for 3.14
allow-prereleases: true

- name: Install FFmpeg (Linux)
if: runner.os == 'Linux'
Expand All @@ -83,13 +89,12 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests
- name: Run unit + e2e tests
run: pytest tests/ -v --cov=music_cli --cov-report=xml --cov-report=term-missing
continue-on-error: true

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest'
if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
with:
files: ./coverage.xml
fail_ci_if_error: false
Expand Down Expand Up @@ -136,3 +141,7 @@ jobs:

- name: Run pre-commit
uses: pre-commit/[email protected]
env:
# pytest-e2e uses language: system and requires the project venv.
# Tests are run by the dedicated test matrix job — skip here.
SKIP: pytest-e2e
18 changes: 17 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ repos:

# Python linting and formatting with Ruff (fast, replaces black/flake8/isort/etc)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.4
rev: v0.11.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
Expand All @@ -45,6 +45,22 @@ repos:
args: [-c, pyproject.toml, -r, music_cli]
additional_dependencies: ["bandit[toml]"]

# Fast e2e smoke tests — run only tests that don't require a live daemon.
# These complete in a few seconds and catch CLI wiring regressions early.
- repo: local
hooks:
- id: pytest-e2e
name: pytest e2e (fast, no daemon)
language: system
# Run only test_e2e.py; skip the slow ai/youtube/daemon-integration
# tests that require external processes.
entry: python -m pytest tests/test_e2e.py -x -q --no-header --tb=short
types: [python]
pass_filenames: false
always_run: false
# Only trigger when CLI source or e2e tests are modified
files: ^(music_cli/cli\.py|music_cli/config\.py|tests/test_e2e\.py)$

# CI configuration
ci:
autofix_commit_msg: "style: auto-fix pre-commit hooks"
Expand Down
59 changes: 31 additions & 28 deletions music_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sys
import threading
import time
from pathlib import Path

import click

Expand Down Expand Up @@ -48,13 +49,13 @@ def get_random_quote() -> str:

# Mapping from unicode symbol to plain-text fallback
_ICON_FALLBACKS: dict[str, str] = {
"\u25b6": "[playing]", # ▶
"\u23f8": "[paused]", # ⏸
"\u23f9": "[stopped]", # ⏹
"\u23ed": "[skip]", # ⏭
"\u274c": "[error]", # ❌
"\u23f3": "[loading]", # ⏳
"\u26a0": "[warning]", # ⚠
"\u25b6": "[playing]", # ▶
"\u23f8": "[paused]", # ⏸
"\u23f9": "[stopped]", # ⏹
"\u23ed": "[skip]", # ⏭
"\u274c": "[error]", # ❌
"\u23f3": "[loading]", # ⏳
"\u26a0": "[warning]", # ⚠
}


Expand Down Expand Up @@ -216,18 +217,21 @@ def format_usage(self, ctx, formatter):
super().format_usage(ctx, formatter)


def _register_alias(group: AliasedGroup, alias: str, target: str) -> None:
def _register_alias(group: click.Group, alias: str, target: str) -> None:
"""Register a hidden alias on an AliasedGroup."""
group.add_alias(alias, target)
cast_group: AliasedGroup = group # type: ignore[assignment]
cast_group.add_alias(alias, target)


@click.group(
cls=AliasedGroup,
invoke_without_command=True,
context_settings=dict(help_option_names=["-h", "--help"]),
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.version_option(__version__)
@click.option("--no-color", is_flag=True, default=False, help="Disable emoji/unicode symbols in output")
@click.option(
"--no-color", is_flag=True, default=False, help="Disable emoji/unicode symbols in output"
)
@click.pass_context
def main(ctx, no_color):
"""mc: A command-line music player for coders.
Expand Down Expand Up @@ -263,13 +267,11 @@ def _detect_play_mode(source_arg):
return "context", None

# 1. Existing file path
if os.path.exists(source_arg):
if Path(source_arg).exists():
return "local", source_arg

# 2. YouTube URL
yt_pattern = re.compile(
r"(youtube\.com/watch|youtu\.be/|youtube\.com/playlist)", re.IGNORECASE
)
yt_pattern = re.compile(r"(youtube\.com/watch|youtu\.be/|youtube\.com/playlist)", re.IGNORECASE)
if yt_pattern.search(source_arg):
return "youtube", source_arg

Expand All @@ -296,7 +298,9 @@ def _detect_play_mode(source_arg):
default=None,
help="Playback mode (usually auto-detected from SOURCE)",
)
@click.option("--source", "-s", "source_flag", default=None, help="Source file/URL/station name (legacy flag)")
@click.option(
"--source", "-s", "source_flag", default=None, help="Source file/URL/station name (legacy flag)"
)
@click.option(
"--mood",
"-M",
Expand Down Expand Up @@ -344,14 +348,14 @@ def play(source, mode, source_flag, mood, auto, duration, index):
# Task 2.2: Deprecate play -m ai
if mode == "ai":
click.echo(
f"{icon(chr(0x26a0))} Deprecated: use 'mc ai play' instead. This will be removed in v1.0.",
f"{icon(chr(0x26A0))} Deprecated: use 'mc ai play' instead. This will be removed in v1.0.",
err=True,
)

# Task 2.3: Deprecate play -m history
if mode == "history":
click.echo(
f"{icon(chr(0x26a0))} Deprecated: use 'mc history play N' instead. This will be removed in v1.0.",
f"{icon(chr(0x26A0))} Deprecated: use 'mc history play N' instead. This will be removed in v1.0.",
err=True,
)

Expand Down Expand Up @@ -391,7 +395,7 @@ def play(source, mode, source_flag, mood, auto, duration, index):
title = track.get("title", track.get("source", "Unknown"))
source_type = track.get("source_type", "unknown")

click.echo(f"{icon(chr(0x25b6))} Playing: {title} [{source_type}]")
click.echo(f"{icon(chr(0x25B6))} Playing: {title} [{source_type}]")
if auto:
click.echo(" Auto-play enabled (shuffle mode)")

Expand All @@ -412,7 +416,7 @@ def stop():
if "error" in response:
click.echo(f"Error: {response['error']}", err=True)
else:
click.echo(f"{icon(chr(0x23f9))} Stopped")
click.echo(f"{icon(chr(0x23F9))} Stopped")
except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
Expand All @@ -428,7 +432,7 @@ def pause():
if "error" in response:
click.echo(f"Error: {response['error']}", err=True)
else:
click.echo(f"{icon(chr(0x23f8))} Paused")
click.echo(f"{icon(chr(0x23F8))} Paused")
except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
Expand All @@ -444,7 +448,7 @@ def resume():
if "error" in response:
click.echo(f"Error: {response['error']}", err=True)
else:
click.echo(f"{icon(chr(0x25b6), '[resumed]')} Resumed")
click.echo(f"{icon(chr(0x25B6), '[resumed]')} Resumed")
except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
Expand Down Expand Up @@ -513,7 +517,7 @@ def next_track():
if "error" in response:
click.echo(f"Error: {response['error']}", err=True)
else:
click.echo(f"{icon(chr(0x23ed))} Skipped to next track")
click.echo(f"{icon(chr(0x23ED))} Skipped to next track")
except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
Expand Down Expand Up @@ -643,7 +647,7 @@ def radios_play(number):
click.echo(f"Error: {response['error']}", err=True)
sys.exit(1)

click.echo(f"{icon(chr(0x25b6))} Playing: {name}")
click.echo(f"{icon(chr(0x25B6))} Playing: {name}")

except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
Expand Down Expand Up @@ -777,7 +781,7 @@ def history_play(number):

track = response.get("track", {})
title = track.get("title", track.get("source", "Unknown"))
click.echo(f"{icon(chr(0x25b6))} Replaying: {title}")
click.echo(f"{icon(chr(0x25B6))} Replaying: {title}")

except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
Expand Down Expand Up @@ -897,7 +901,7 @@ def list_moods(mood_name):
sys.exit(1)
track = response.get("track", {})
title = track.get("title", track.get("source", "Unknown"))
click.echo(f"{icon(chr(0x25b6))} Playing mood '{mood_name}': {title}")
click.echo(f"{icon(chr(0x25B6))} Playing mood '{mood_name}': {title}")
except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
Expand Down Expand Up @@ -1445,7 +1449,7 @@ def youtube_play(number):

track = response.get("track", {})
title = track.get("title", "Unknown")
click.echo(f"{icon(chr(0x25b6))} Playing: {title}")
click.echo(f"{icon(chr(0x25B6))} Playing: {title}")

except ConnectionError as e:
click.echo(f"Error: {e}", err=True)
Expand Down Expand Up @@ -1608,6 +1612,5 @@ def update_radios_legacy(ctx):
_register_alias(ai_group, "models", "model")
_register_alias(ai_models_group, "set-default", "default")


if __name__ == "__main__":
main()
10 changes: 5 additions & 5 deletions music_cli/hf_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
HF_HUB_AVAILABLE = True
except ImportError:
HF_HUB_AVAILABLE = False
scan_cache_dir = None
snapshot_download = None
HfHubHTTPError = Exception
scan_cache_dir = None # type: ignore[assignment] # noqa: F811
snapshot_download = None # type: ignore[assignment] # noqa: F811
HfHubHTTPError = Exception # type: ignore[misc,assignment] # noqa: F811


@dataclass
Expand Down Expand Up @@ -66,7 +66,7 @@ def get_hf_cache_dir() -> Path | None:

try:
cache_info = scan_cache_dir()
return Path(cache_info.cache_dir)
return Path(cache_info.cache_dir) # type: ignore[attr-defined]
except Exception as e:
logger.debug(f"Failed to get cache directory: {e}")
return None
Expand Down Expand Up @@ -111,7 +111,7 @@ def scan_all_cached_models() -> dict[str, CacheInfo]:
hf_model_id=repo.repo_id,
size_bytes=repo.size_on_disk,
size_gb=repo.size_on_disk / (1024 * 1024 * 1024),
last_accessed=repo.last_accessed,
last_accessed=repo.last_accessed, # type: ignore[arg-type]
repo_path=repo.repo_path,
)

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ disallow_untyped_defs = false
ignore_missing_imports = true
check_untyped_defs = false

[[tool.mypy.overrides]]
module = "music_cli.hf_cache"
warn_unused_ignores = false

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Expand Down
8 changes: 5 additions & 3 deletions tests/test_ai_tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,17 @@ def test_file_exists_false(self):
def test_file_exists_true(self):
"""Test file_exists returns True for existing file."""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp_path = f.name
try:
track = AITrack(
prompt="test",
file_path=f.name,
file_path=tmp_path,
timestamp="2025-01-01T00:00:00",
duration=30,
)
assert track.file_exists() is True
# Clean up
Path(f.name).unlink()
finally:
Path(tmp_path).unlink(missing_ok=True)

def test_display_prompt_short(self):
"""Test display_prompt for short prompts."""
Expand Down
Loading
Loading