diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c89c4f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +.PHONY: help venv install test api cli-help clean + +# Default target +help: + @echo "Music Track Generator - Available targets:" + @echo "" + @echo " make venv - Create a virtual environment" + @echo " make install - Install dependencies and package" + @echo " make test - Run tests with pytest" + @echo " make api - Start the API server" + @echo " make cli-help - Show CLI help" + @echo " make clean - Remove virtual environment and cache files" + @echo "" + +# Create virtual environment +venv: + @echo "Creating virtual environment..." + python -m venv venv + @echo "Virtual environment created. Activate with:" + @echo " source venv/bin/activate (Linux/Mac)" + @echo " venv\\Scripts\\activate (Windows)" + +# Install dependencies +install: + @echo "Installing dependencies..." + pip install -r requirements.txt + pip install -e . + @echo "Installation complete!" + +# Run tests +test: + @echo "Running tests..." + pytest tests/ -v + +# Start API server +api: + @echo "Starting API server on http://0.0.0.0:8080" + @echo "Press Ctrl+C to stop" + uvicorn music_generator.api:app --host 0.0.0.0 --port 8080 + +# Show CLI help +cli-help: + @echo "Music Track Generator CLI Commands:" + @echo "" + music-gen --help + @echo "" + @echo "Generate command options:" + @echo "" + music-gen generate --help + +# Clean up +clean: + @echo "Cleaning up..." + rm -rf venv + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + @echo "Clean complete!" diff --git a/README.md b/README.md index 0f55cbc..9bcb5ed 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,22 @@ Music track generation system with CLI and REST API. Generate music tracks with - Python 3.9 or higher - (Optional) Google Cloud Platform account for GCP mode -### Quick Install +### Quick Install with Makefile ```bash # Clone the repository git clone https://github.com/kngms/github-dev-sandbox.git cd github-dev-sandbox +# Create virtual environment and install +make venv +source venv/bin/activate # On Windows: venv\Scripts\activate +make install +``` + +### Manual Install + +```bash # Install dependencies pip install -r requirements.txt @@ -63,25 +72,91 @@ pip install -e . ## Quick Start -### CLI Usage (No Credentials Needed) +### Makefile Commands + +```bash +# Show all available commands +make help + +# Install dependencies +make install + +# Run tests +make test + +# Start API server +make api + +# Show CLI help +make cli-help +``` + +### CLI Usage (No Credentials Needed - Simulate Mode) ```bash # List available presets music-gen list-presets +# Expected output: +# ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ Name ┃ Genre ┃ Description ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ classical_orchestral ┃ classical ┃ Classical orchestral composition ┃ +# ┃ electronic_dance ┃ electronic ┃ Upbeat electronic dance music ┃ +# ┃ jazz_smooth ┃ jazz ┃ Smooth jazz with relaxed tempo ┃ +# ┃ pop_catchy ┃ pop ┃ Catchy pop song with radio-friendly... ┃ +# ┃ rock_anthem ┃ rock ┃ High-energy rock anthem with powerful...┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + # Generate a track (simulate mode by default) music-gen generate \ --text "Walking down the street, feeling the beat..." \ --genre rock \ --preset rock_anthem + +# Expected output: +# Using preset: rock_anthem +# +# Track Configuration: +# Mode: simulate +# Genre: rock +# Duration: 180s (3m 0s) +# Temperature: 0.8 +# +# Text Input: +# ╭────────────────────────────────────────────────────────────╮ +# │ Walking down the street, feeling the beat... │ +# ╰────────────────────────────────────────────────────────────╯ +# +# Estimated cost: $0.0280 USD +# Proceed with generation? [Y/n]: y +# +# ✓ Track generation completed! +# Status: simulated +# +# Track generation simulated (no GCP credentials required). ``` ### API Server ```bash # Start the API server (binds to 0.0.0.0:8080 by default) +make api +# or uvicorn music_generator.api:app --host 0.0.0.0 --port 8080 +# Expected startup logs: +# INFO: Started server process +# INFO: Waiting for application startup. +# ============================================================ +# Music Track Generator API - Startup Configuration +# ============================================================ +# MUSIC_GEN_MODE: simulate +# MUSIC_GEN_API_KEY set: no +# ============================================================ +# INFO: Application startup complete. +# INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit) + # Or set custom port PORT=3000 uvicorn music_generator.api:app --host 0.0.0.0 --port 3000 ``` @@ -185,6 +260,27 @@ uvicorn music_generator.api:app --host 0.0.0.0 --port 8080 ### API Endpoints +#### Get Configuration +```bash +# GET /config - Returns safe configuration info (no secrets) +curl http://localhost:8080/config + +# Response example: +# { +# "mode": "simulate", +# "region": null, +# "project": null, +# "presets_available": [ +# "classical_orchestral", +# "electronic_dance", +# "jazz_smooth", +# "pop_catchy", +# "rock_anthem" +# ], +# "auth_enabled": false +# } +``` + #### Generate Track ```bash # POST /tracks/generate @@ -197,6 +293,17 @@ curl -X POST http://localhost:8080/tracks/generate \ "preset_name": "rock_anthem" }' +# Response example: +# { +# "status": "simulated", +# "mode": "simulate", +# "genre": "rock", +# "duration_seconds": 180, +# "prompt": "Generate a rock music track with the following specifications...", +# "metadata": {...}, +# "message": "Track generation simulated (no GCP credentials required)." +# } + # With custom structure (no preset) curl -X POST http://localhost:8080/tracks/generate \ -H "Content-Type: application/json" \ diff --git a/src/music_generator/api.py b/src/music_generator/api.py index 2b7ae51..224fa3e 100644 --- a/src/music_generator/api.py +++ b/src/music_generator/api.py @@ -1,7 +1,9 @@ """FastAPI server for music track generation.""" import os +import sys from typing import Optional, List, Dict, Any +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Header, Depends, Query from pydantic import BaseModel, Field import logging @@ -10,15 +12,58 @@ from .generator import MusicGenerator from .presets import PresetManager -# Configure logging -logging.basicConfig(level=logging.INFO) +# Configure logging with UTF-8 encoding +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +# Ensure UTF-8 encoding for stdout (Python 3.7+ compatibility) +# reconfigure() is available in Python 3.7+ for text streams +if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + logger = logging.getLogger(__name__) + +def print_startup_banner(): + """Print startup configuration banner.""" + mode = os.getenv("MUSIC_GEN_MODE", "simulate") + api_key = os.getenv("MUSIC_GEN_API_KEY") + api_key_set = "yes" if api_key else "no" + + logger.info("=" * 60) + logger.info("Music Track Generator API - Startup Configuration") + logger.info("=" * 60) + logger.info(f"MUSIC_GEN_MODE: {mode}") + logger.info(f"MUSIC_GEN_API_KEY set: {api_key_set}") + + if mode == "gcp": + project = os.getenv("GOOGLE_CLOUD_PROJECT", "(not set)") + region = os.getenv("GOOGLE_CLOUD_REGION", "us-central1") + logger.info(f"GOOGLE_CLOUD_PROJECT: {project}") + logger.info(f"GOOGLE_CLOUD_REGION: {region}") + + logger.info("=" * 60) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifespan context manager for startup and shutdown events.""" + # Startup + print_startup_banner() + yield + # Shutdown (nothing to do for now) + + # Initialize FastAPI app app = FastAPI( title="Music Track Generator API", description="Generate music tracks with configurable genres, structures, and styles", - version="0.1.0" + version="0.1.0", + lifespan=lifespan ) # Get API key from environment (optional) @@ -62,6 +107,15 @@ class PromptTip(BaseModel): tips: Optional[str] +class ConfigResponse(BaseModel): + """API configuration response.""" + mode: str + region: Optional[str] + project: Optional[str] + presets_available: List[str] + auth_enabled: bool + + # API Key Authentication def verify_api_key( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), @@ -115,6 +169,26 @@ def root(): } +@app.get("/config", response_model=ConfigResponse) +def get_config(): + """Get API configuration information (safe, no secrets).""" + mode = os.getenv("MUSIC_GEN_MODE", "simulate") + region = os.getenv("GOOGLE_CLOUD_REGION", "us-central1") if mode == "gcp" else None + project = os.getenv("GOOGLE_CLOUD_PROJECT") if mode == "gcp" else None + auth_enabled = bool(API_KEY) + + # Get list of available presets + preset_names = preset_manager.list_presets() + + return ConfigResponse( + mode=mode, + region=region, + project=project, + presets_available=preset_names, + auth_enabled=auth_enabled + ) + + @app.post("/tracks/generate", response_model=GenerateTrackResponse, dependencies=[Depends(verify_api_key)]) def generate_track(request: GenerateTrackRequest): """Generate a music track. diff --git a/tests/test_api.py b/tests/test_api.py index d3f08d9..0214257 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,6 +12,49 @@ client = TestClient(app) +@pytest.fixture +def reload_api_module(): + """Fixture to reload API module with modified environment variables. + + This is a utility to help test environment-dependent behavior. + + Returns: + A function that takes environment variable key-value pairs and returns + (test_client, old_env_dict) tuple. + """ + def _reload_with_env(**env_vars): + # Save current env + old_env = {} + for key in env_vars: + old_env[key] = os.environ.get(key) + + # Set new env + for key, value in env_vars.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + # Reimport module + from importlib import reload + import music_generator.api as api_module + reload(api_module) + test_client = TestClient(api_module.app) + + return test_client, old_env + + return _reload_with_env + + +def restore_env(old_env): + """Helper to restore environment variables.""" + for key, value in old_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def test_root(): """Test root endpoint.""" response = client.get("/") @@ -203,21 +246,11 @@ def test_prompt_tips_filtered(): assert data[0]["preset_name"] == "rock_anthem" -def test_api_key_authentication(): +def test_api_key_authentication(reload_api_module): """Test API key authentication when configured.""" - # Save current env - old_api_key = os.environ.get("MUSIC_GEN_API_KEY") + test_client, old_env = reload_api_module(MUSIC_GEN_API_KEY="test_secret_key") try: - # Set API key - os.environ["MUSIC_GEN_API_KEY"] = "test_secret_key" - - # Reimport to pick up new env var - from importlib import reload - import music_generator.api as api_module - reload(api_module) - test_client = TestClient(api_module.app) - # Request without API key should fail response = test_client.get("/presets") assert response.status_code == 401 @@ -233,10 +266,85 @@ def test_api_key_authentication(): # Request with wrong API key response = test_client.get("/presets", headers={"X-API-Key": "wrong_key"}) assert response.status_code == 401 + finally: + restore_env(old_env) + + +def test_config_endpoint(reload_api_module): + """Test /config endpoint returns safe configuration info.""" + test_client, old_env = reload_api_module(MUSIC_GEN_API_KEY=None) + try: + response = test_client.get("/config") + assert response.status_code == 200 + data = response.json() + + # Check required keys + assert "mode" in data + assert "region" in data + assert "project" in data + assert "presets_available" in data + assert "auth_enabled" in data + + # Check values + assert data["mode"] == "simulate" + assert isinstance(data["presets_available"], list) + assert len(data["presets_available"]) > 0 + assert data["auth_enabled"] is False + + # Verify no secrets are exposed + assert "api_key" not in data + assert "credentials" not in data + assert "MUSIC_GEN_API_KEY" not in str(data) finally: - # Restore env - if old_api_key: - os.environ["MUSIC_GEN_API_KEY"] = old_api_key - else: - os.environ.pop("MUSIC_GEN_API_KEY", None) + restore_env(old_env) + + +def test_config_endpoint_gcp_mode(reload_api_module): + """Test /config endpoint in GCP mode.""" + test_client, old_env = reload_api_module( + MUSIC_GEN_MODE="gcp", + GOOGLE_CLOUD_PROJECT="test-project", + GOOGLE_CLOUD_REGION="us-west1" + ) + + try: + response = test_client.get("/config") + assert response.status_code == 200 + data = response.json() + + assert data["mode"] == "gcp" + assert data["region"] == "us-west1" + assert data["project"] == "test-project" + finally: + restore_env(old_env) + + +def test_generate_track_duration_error_structured(): + """Test that duration validation returns structured error.""" + response = client.post("/tracks/generate", json={ + "text_input": "Test lyrics", + "genre": "rock", + "duration_seconds": 300, # Too long (>240) + "structure": { + "intro": True, + "verse_count": 2, + "chorus_count": 2, + "bridge": True, + "outro": True + } + }) + assert response.status_code == 422 + data = response.json() + + # Pydantic returns a structured error with field location and details + detail = data.get("detail") + assert isinstance(detail, list) + assert len(detail) > 0 + + # Check the error has field information + error = detail[0] + assert "loc" in error + assert "duration_seconds" in error["loc"] + assert "msg" in error +