From 752190df74ce0b2944404a66c92558fd26a8875f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:17:07 +0000 Subject: [PATCH 1/3] Initial plan From f7ba302f5bfc000012ca1d29ba665efd41c6e2af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:27:49 +0000 Subject: [PATCH 2/3] Add startup banner, /config endpoint, validation error handling, and Makefile Co-authored-by: kngms <167722266+kngms@users.noreply.github.com> --- Makefile | 62 +++++++++++++++++ README.md | 103 +++++++++++++++++++++++++++ src/music_generator/api.py | 124 +++++++++++++++++++++++++++++++-- tests/test_api.py | 138 ++++++++++++++++++++++++++++++++++++- 4 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6849c33 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +.PHONY: venv install test api cli-help clean help + +# Default Python version +PYTHON := python3 +VENV := venv +BIN := $(VENV)/bin + +help: + @echo "Music Track Generator - Makefile" + @echo "" + @echo "Available targets:" + @echo " venv - Create a virtual environment" + @echo " install - Install dependencies in virtual environment" + @echo " test - Run tests" + @echo " api - Start the API server" + @echo " cli-help - Show CLI help" + @echo " clean - Remove virtual environment and cache files" + @echo "" + +venv: + @echo "Creating virtual environment..." + $(PYTHON) -m venv $(VENV) + @echo "Virtual environment created at ./$(VENV)" + @echo "Activate with: source $(BIN)/activate" + +install: venv + @echo "Installing dependencies..." + $(BIN)/pip install --upgrade pip + $(BIN)/pip install -r requirements.txt + $(BIN)/pip install -e . + @echo "Installation complete!" + +test: + @echo "Running tests..." + pytest tests/ -v + +api: + @echo "Starting API server..." + @echo "Access the API at http://localhost:8080" + @echo "API documentation at http://localhost:8080/docs" + uvicorn music_generator.api:app --host 0.0.0.0 --port 8080 + +cli-help: + @echo "Music Track Generator CLI Help" + @echo "================================" + @echo "" + music-gen --help + @echo "" + @echo "Generate command help:" + @echo "----------------------" + music-gen generate --help + +clean: + @echo "Cleaning up..." + rm -rf $(VENV) + rm -rf __pycache__ + rm -rf .pytest_cache + rm -rf src/music_generator/__pycache__ + rm -rf tests/__pycache__ + rm -rf *.egg-info + rm -rf build dist + @echo "Cleanup complete!" diff --git a/README.md b/README.md index 0f55cbc..4ab670c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,25 @@ pip install -e . ## Quick Start +### Using the Makefile (Recommended) + +```bash +# Show available commands +make help + +# Create virtual environment and install dependencies +make install + +# Run tests +make test + +# Start API server +make api + +# Show CLI help +make cli-help +``` + ### CLI Usage (No Credentials Needed) ```bash @@ -76,16 +95,75 @@ music-gen generate \ --preset rock_anthem ``` +#### Expected Output in Simulate Mode + +When running in simulate mode, you'll see output like this: + +``` +INFO:music_generator.generator:Initialized MusicGenerator in simulate mode (no GCP credentials required) +INFO:music_generator.generator:Generating rock track (180s) in simulate mode + +✅ Track generation simulated successfully! + +Status: simulated +Mode: simulate +Genre: rock +Duration: 180 seconds + +Generated Prompt: +Generate a rock music track with the following specifications: + +Duration: 180 seconds (3 minutes 0 seconds) + +Song structure: intro -> verse 1 -> chorus -> verse 2 -> chorus -> bridge -> outro + +Lyrics/Text input: +Walking down the street, feeling the beat... + +Style references: +- style: arena rock +- style: anthemic +- sound: electric guitar and drums + +Create a track that follows this structure and incorporates the provided text and style references. +Temperature: 0.8 + +💡 Message: Track generation simulated (no GCP credentials required). +``` + ### API Server ```bash # Start the API server (binds to 0.0.0.0:8080 by default) uvicorn music_generator.api:app --host 0.0.0.0 --port 8080 +# Or use make +make api + # Or set custom port PORT=3000 uvicorn music_generator.api:app --host 0.0.0.0 --port 3000 ``` +#### Expected Startup Output + +When starting the API server, you'll see configuration details: + +``` +INFO:music_generator.api:====================================================================== +INFO:music_generator.api:🎵 Music Track Generator API - Startup Configuration +INFO:music_generator.api:====================================================================== +INFO:music_generator.api:Mode: simulate +INFO:music_generator.api:API Key Authentication: no +INFO:music_generator.api:Available Presets: 5 loaded +INFO:music_generator.api:====================================================================== +INFO:music_generator.api:✅ Server ready to accept requests +INFO:music_generator.api:====================================================================== +INFO: Started server process [12345] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit) +``` + Access API documentation at `http://localhost:8080/docs` ## CLI Usage @@ -272,6 +350,31 @@ curl http://localhost:8080/prompt-tips?preset_name=rock_anthem curl http://localhost:8080/health ``` +#### Configuration +```bash +# GET /config - Get current server configuration +curl http://localhost:8080/config +``` + +**Example response:** +```json +{ + "mode": "simulate", + "region": null, + "project": null, + "presets_available": [ + "rock_anthem", + "jazz_smooth", + "electronic_dance", + "classical_orchestral", + "pop_catchy" + ], + "auth_enabled": false +} +``` + +**Note:** The `/config` endpoint requires authentication if `MUSIC_GEN_API_KEY` is set. + ### API Authentication When `MUSIC_GEN_API_KEY` is set, all endpoints require authentication: diff --git a/src/music_generator/api.py b/src/music_generator/api.py index 2b7ae51..9ec63f8 100644 --- a/src/music_generator/api.py +++ b/src/music_generator/api.py @@ -2,7 +2,10 @@ import os from typing import Optional, List, Dict, Any +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Header, Depends, Query +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field import logging @@ -10,15 +13,61 @@ 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', + encoding='utf-8' +) logger = logging.getLogger(__name__) -# Initialize FastAPI app +# Initialize shared components +preset_manager = PresetManager() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler for startup and shutdown events.""" + # Startup + logger.info("=" * 70) + logger.info("🎵 Music Track Generator API - Startup Configuration") + logger.info("=" * 70) + + # Get configuration values + mode = os.getenv("MUSIC_GEN_MODE", "simulate") + api_key_set = "yes" if os.getenv("MUSIC_GEN_API_KEY") else "no" + project = os.getenv("GOOGLE_CLOUD_PROJECT", "not set") + region = os.getenv("GOOGLE_CLOUD_REGION", "us-central1") + + # Print core configuration + logger.info(f"Mode: {mode}") + logger.info(f"API Key Authentication: {api_key_set}") + + # Print GCP-specific configuration when in gcp mode + if mode == "gcp": + logger.info(f"Google Cloud Project: {project}") + logger.info(f"Google Cloud Region: {region}") + + # Print available presets + presets = preset_manager.list_presets() + logger.info(f"Available Presets: {len(presets)} loaded") + + logger.info("=" * 70) + logger.info("✅ Server ready to accept requests") + logger.info("=" * 70) + + yield + + # Shutdown (if needed) + logger.info("Shutting down...") + + +# Initialize FastAPI app with lifespan 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 +111,28 @@ class PromptTip(BaseModel): tips: Optional[str] +class ConfigResponse(BaseModel): + """Response model for configuration endpoint.""" + mode: str + region: Optional[str] + project: Optional[str] + presets_available: List[str] + auth_enabled: bool + + +class ValidationErrorDetail(BaseModel): + """Validation error detail.""" + field: str + message: str + type: str + + +class ValidationErrorResponse(BaseModel): + """Structured validation error response.""" + detail: str + errors: List[ValidationErrorDetail] + + # API Key Authentication def verify_api_key( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), @@ -85,8 +156,28 @@ def verify_api_key( raise HTTPException(status_code=401, detail="Invalid or missing API key") -# Initialize shared components -preset_manager = PresetManager() +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc: RequestValidationError): + """Custom handler for validation errors to return structured responses.""" + errors = [] + for error in exc.errors(): + # Get field name from loc (location) + field = ".".join(str(loc) for loc in error["loc"]) if error["loc"] else "unknown" + errors.append( + ValidationErrorDetail( + field=field, + message=error["msg"], + type=error["type"] + ) + ) + + return JSONResponse( + status_code=422, + content={ + "detail": "Validation error", + "errors": [e.model_dump() for e in errors] + } + ) def get_generator() -> MusicGenerator: @@ -294,3 +385,24 @@ def health_check(): "status": "healthy", "mode": os.getenv("MUSIC_GEN_MODE", "simulate") } + + +@app.get("/config", response_model=ConfigResponse, dependencies=[Depends(verify_api_key)]) +def get_config(): + """Get current configuration information. + + Returns safe configuration details without exposing 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 + presets = preset_manager.list_presets() + auth_enabled = bool(os.getenv("MUSIC_GEN_API_KEY")) + + return ConfigResponse( + mode=mode, + region=region, + project=project, + presets_available=presets, + auth_enabled=auth_enabled + ) diff --git a/tests/test_api.py b/tests/test_api.py index d3f08d9..5d97c9c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,8 +4,9 @@ import pytest from fastapi.testclient import TestClient -# Set test environment +# Set test environment and clear any leftover API key os.environ["MUSIC_GEN_MODE"] = "simulate" +os.environ.pop("MUSIC_GEN_API_KEY", None) from music_generator.api import app @@ -240,3 +241,138 @@ def test_api_key_authentication(): os.environ["MUSIC_GEN_API_KEY"] = old_api_key else: os.environ.pop("MUSIC_GEN_API_KEY", None) + + # Reload module again to restore original state + from importlib import reload + import music_generator.api as api_module + reload(api_module) + + +def test_get_config(): + """Test getting configuration information.""" + response = client.get("/config") + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "mode" in data + assert "region" in data + assert "project" in data + assert "presets_available" in data + assert "auth_enabled" in data + + # Check values for simulate mode + assert data["mode"] == "simulate" + assert data["region"] is None # Not set in simulate mode + assert data["project"] is None # Not set in simulate mode + assert isinstance(data["presets_available"], list) + assert len(data["presets_available"]) > 0 + assert data["auth_enabled"] is False + + +def test_get_config_gcp_mode(): + """Test configuration endpoint in GCP mode.""" + # Save current env + old_mode = os.environ.get("MUSIC_GEN_MODE") + old_project = os.environ.get("GOOGLE_CLOUD_PROJECT") + old_region = os.environ.get("GOOGLE_CLOUD_REGION") + + try: + # Set GCP mode environment + os.environ["MUSIC_GEN_MODE"] = "gcp" + os.environ["GOOGLE_CLOUD_PROJECT"] = "test-project-123" + os.environ["GOOGLE_CLOUD_REGION"] = "us-west1" + + # Reimport to pick up new env vars + from importlib import reload + import music_generator.api as api_module + reload(api_module) + test_client = TestClient(api_module.app) + + response = test_client.get("/config") + assert response.status_code == 200 + data = response.json() + + # Check GCP-specific values + assert data["mode"] == "gcp" + assert data["project"] == "test-project-123" + assert data["region"] == "us-west1" + assert isinstance(data["presets_available"], list) + + finally: + # Restore env + if old_mode: + os.environ["MUSIC_GEN_MODE"] = old_mode + else: + os.environ.pop("MUSIC_GEN_MODE", None) + if old_project: + os.environ["GOOGLE_CLOUD_PROJECT"] = old_project + else: + os.environ.pop("GOOGLE_CLOUD_PROJECT", None) + if old_region: + os.environ["GOOGLE_CLOUD_REGION"] = old_region + else: + os.environ.pop("GOOGLE_CLOUD_REGION", None) + + # Reload module again to restore original state + from importlib import reload + import music_generator.api as api_module + reload(api_module) + + +def test_validation_error_structure(): + """Test that validation errors return structured responses with field names.""" + # Test with invalid duration (too short) + response = client.post("/tracks/generate", json={ + "text_input": "Test lyrics", + "genre": "rock", + "duration_seconds": 30, # Too short, must be >= 60 + "structure": { + "intro": True, + "verse_count": 2, + "chorus_count": 2, + "bridge": True, + "outro": True + } + }) + + assert response.status_code == 422 + data = response.json() + + # Check structured error response + assert "detail" in data + assert "errors" in data + assert isinstance(data["errors"], list) + assert len(data["errors"]) > 0 + + # Check error structure + error = data["errors"][0] + assert "field" in error + assert "message" in error + assert "type" in error + + # Check that the field is identified correctly + assert "duration_seconds" in error["field"] + + +def test_validation_error_missing_fields(): + """Test validation errors for missing required fields.""" + # Test with missing required fields + response = client.post("/tracks/generate", json={ + "genre": "rock", + # Missing text_input + "duration_seconds": 180 + }) + + assert response.status_code == 422 + data = response.json() + + # Check structured error response + assert "detail" in data + assert "errors" in data + assert isinstance(data["errors"], list) + assert len(data["errors"]) > 0 + + # Check that text_input field is mentioned + field_names = [err["field"] for err in data["errors"]] + assert any("text_input" in field for field in field_names) From 0fa667dcc4e8f4272e07890c4de164a39a9500f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:31:22 +0000 Subject: [PATCH 3/3] Address code review feedback - fix unused parameter and clarify initialization order Co-authored-by: kngms <167722266+kngms@users.noreply.github.com> --- src/music_generator/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/music_generator/api.py b/src/music_generator/api.py index 9ec63f8..7c1e593 100644 --- a/src/music_generator/api.py +++ b/src/music_generator/api.py @@ -21,7 +21,7 @@ ) logger = logging.getLogger(__name__) -# Initialize shared components +# Initialize shared components (before lifespan to ensure availability) preset_manager = PresetManager() @@ -157,7 +157,7 @@ def verify_api_key( @app.exception_handler(RequestValidationError) -async def validation_exception_handler(request, exc: RequestValidationError): +async def validation_exception_handler(_request, exc: RequestValidationError): """Custom handler for validation errors to return structured responses.""" errors = [] for error in exc.errors():