Guide for contributing to music-cli.
- Python 3.10+
- FFmpeg
- Git
git clone https://github.com/luongnv89/music-cli
cd music-cli
# Create virtual environment
python -m venv venv
source venv/bin/activate # Linux/macOS
# or: venv\Scripts\activate # Windows
# Install with dev dependencies
pip install -e ".[dev]"
# Install pre-commit hooks
pre-commit installmusic-cli/
├── music_cli/
│ ├── __init__.py # Package version
│ ├── __main__.py # Module entry point
│ ├── cli.py # Click CLI commands
│ ├── client.py # Socket client
│ ├── config.py # Configuration management
│ ├── daemon.py # Background daemon
│ ├── history.py # Playback history
│ ├── context/
│ │ ├── mood.py # Mood-based selection
│ │ └── temporal.py # Time-based selection
│ ├── player/
│ │ ├── base.py # Abstract player
│ │ └── ffplay.py # FFplay implementation
│ └── sources/
│ ├── local.py # Local files
│ ├── radio.py # Radio streams
│ └── ai_generator.py # MusicGen (optional)
├── tests/
│ ├── test_config.py
│ ├── test_context.py
│ └── test_history.py
├── docs/
│ ├── architecture.md
│ ├── development.md
│ └── user-guide.md
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml
├── pyproject.toml
├── .pre-commit-config.yaml
└── README.md
# Run CLI directly
python -m music_cli --help
mc play
# Run daemon in foreground (for debugging)
python -m music_cli.daemon# Format code
black music_cli/
# Lint
ruff check music_cli/ --fix
# Type check
mypy music_cli/
# Security scan
bandit -c pyproject.toml -r music_cli/
# All checks via pre-commit
pre-commit run --all-files# Run all tests
pytest
# With coverage
pytest --cov=music_cli --cov-report=term-missing
# Specific test file
pytest tests/test_config.py -v
# Specific test
pytest tests/test_config.py::TestConfig::test_config_creates_directory -v# Build package
python -m build
# Check package
twine check dist/*
# Install locally built package
pip install dist/music_cli-0.1.0-py3-none-any.whl- Add handler in
daemon.py:
async def _cmd_mycommand(self, args: dict) -> dict:
"""Handle my new command."""
# Implementation
return {"status": "ok"}- Register in
_process_command:
handlers = {
# ...existing...
"mycommand": self._cmd_mycommand,
}- Add CLI command in
cli.py:
@main.command()
def mycommand():
"""My new command."""
client = ensure_daemon()
response = client.send_command("mycommand")
click.echo(response)- Create
sources/mysource.py:
from ..player.base import TrackInfo
class MySource:
def get_track(self, query: str) -> TrackInfo | None:
# Implementation
return TrackInfo(
source="...",
source_type="mysource",
title="...",
)- Add to daemon's
_cmd_play:
elif mode == "mysource":
track = self.my_source.get_track(source)- Create
player/myplayer.pyextendingPlayer:
from .base import Player, PlayerState, TrackInfo
class MyPlayer(Player):
async def play(self, track: TrackInfo) -> bool:
# Implementation
pass
async def stop(self) -> None:
pass
# ...other methods...- Update config to support backend selection.
- Black with 100-char line length
- Ruff for imports and linting
Use type hints for function signatures:
def get_track(self, path: str) -> TrackInfo | None:
...
async def play(self, track: TrackInfo) -> bool:
...Use Google-style docstrings:
def get_mood_radio(self, mood: str) -> str | None:
"""Get radio URL for a specific mood.
Args:
mood: The mood tag (focus, happy, sad, etc.)
Returns:
Stream URL or None if mood not found.
"""# Specific exceptions
try:
result = risky_operation()
except (FileNotFoundError, PermissionError) as e:
logger.warning(f"Operation failed: {e}")
return None
# Re-raise with context
except ConnectionError as e:
raise ConnectionError("Daemon not responding") from eclass TestMyFeature:
"""Tests for MyFeature."""
def test_basic_case(self, tmp_path: Path) -> None:
"""Test the basic use case."""
# Arrange
feature = MyFeature(config_dir=tmp_path)
# Act
result = feature.do_something()
# Assert
assert result.status == "ok"
def test_edge_case(self) -> None:
"""Test edge case handling."""
...Use pytest fixtures for common setup:
@pytest.fixture
def config(tmp_path):
return Config(config_dir=tmp_path)
def test_with_config(config):
assert config.get("player.volume") == 80-
ci.yml: Runs on push/PR
- Lint (Black, Ruff, mypy, Bandit)
- Test (Python 3.10-3.12, macOS + Linux)
- Build verification
-
release.yml: Runs on version tags
- Build package
- Create GitHub release
- (Optional) Publish to PyPI
# Update version in music_cli/__init__.py
# Commit changes
git add -A && git commit -m "Bump version to 0.2.0"
# Create and push tag
git tag v0.2.0
git push origin main --tags# Find and kill process
ps aux | grep music_cli.daemon
kill <pid>
# Clean up files
rm ~/.config/music-cli/music-cli.sock
rm ~/.config/music-cli/music-cli.pid# Clean pytest cache
rm -rf .pytest_cache/
# Reinstall in dev mode
pip install -e ".[dev]"# Update hooks
pre-commit autoupdate
# Run specific hook
pre-commit run black --all-files
# Skip hooks (emergency only)
git commit --no-verify