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
21 changes: 17 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,17 +211,30 @@ python -m build

#### 5. Test on TestPyPI (Recommended)

**A. Upload to TestPyPI:**
**A. Authentication Setup:**
TestPyPI and production PyPI require separate API tokens. Configure in `.envrc` (gitignored):
```bash
python -m twine upload --repository testpypi dist/*
# TestPyPI token (get from https://test.pypi.org/)
export TWINE_TESTPYPI_PASSWORD="pypi-[YOUR_TESTPYPI_TOKEN]"

# Production PyPI token (get from https://pypi.org/)
export TWINE_PASSWORD="pypi-[YOUR_PRODUCTION_TOKEN]"

# Twine username for both
export TWINE_USERNAME="__token__"
```

**B. Upload to TestPyPI:**
```bash
TWINE_PASSWORD="$TWINE_TESTPYPI_PASSWORD" python -m twine upload --repository testpypi dist/*
```

**B. Test Installation from TestPyPI:**
**C. Test Installation from TestPyPI:**
```bash
pip install --index-url https://test.pypi.org/simple/ idtap
```

**C. Verify TestPyPI Installation:**
**D. Verify TestPyPI Installation:**
```bash
python -c "import idtap; print(idtap.__version__)"
```
Expand Down
2 changes: 1 addition & 1 deletion idtap/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Python API package exposing IDTAP data classes and client."""

__version__ = "0.1.10"
__version__ = "0.1.12"

from .client import SwaraClient
from .auth import login_google
Expand Down
69 changes: 65 additions & 4 deletions idtap/audio_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,25 +108,86 @@ def to_json(self) -> Dict[str, Any]:

@dataclass
class AudioMetadata:
"""Complete metadata for an audio recording."""
"""Complete metadata for an audio recording.

Args:
title: Optional title for the recording
musicians: List of Musician objects
location: Optional Location object
date: Optional RecordingDate object
ragas: List of raga specifications. Accepts multiple formats:
- AudioRaga objects: AudioRaga(name="Rageshree") (recommended)
- Strings: "Rageshree" (auto-converted to AudioRaga)
- Name dicts: {"name": "Rageshree"} (auto-converted to AudioRaga)
- Legacy format: {"Rageshree": {"performance_sections": {}}} (auto-converted)
sa_estimate: Optional fundamental frequency estimate in Hz
permissions: Permissions object for access control
"""
title: Optional[str] = None
musicians: List[Musician] = field(default_factory=list)
location: Optional[Location] = None
date: Optional[RecordingDate] = None
ragas: List[Raga] = field(default_factory=list)
ragas: List[Union[Raga, str, Dict[str, Any]]] = field(default_factory=list)
sa_estimate: Optional[float] = None
permissions: Permissions = field(default_factory=Permissions)

def _normalize_ragas(self, ragas: List[Union[Raga, str, Dict[str, Any]]]) -> List[Raga]:
"""Convert various raga input formats to AudioRaga objects."""
normalized = []

for i, raga in enumerate(ragas):
if isinstance(raga, Raga):
# Already an AudioRaga object
normalized.append(raga)
elif isinstance(raga, str):
# String format: "Rageshree" -> AudioRaga(name="Rageshree")
normalized.append(Raga(name=raga))
elif isinstance(raga, dict):
if 'name' in raga:
# Name dict format: {"name": "Rageshree"} -> AudioRaga(name="Rageshree")
normalized.append(Raga(name=raga['name']))
elif len(raga) == 1:
# Legacy format: {"Rageshree": {...}} -> AudioRaga(name="Rageshree")
raga_name = list(raga.keys())[0]
normalized.append(Raga(name=raga_name))
else:
raise ValueError(f"Raga at index {i}: Invalid dict format. "
f"Use {{'name': 'RagaName'}} or AudioRaga(name='RagaName') instead.")
else:
# Check for wrong Raga class (musical analysis Raga)
if hasattr(raga, 'name') and hasattr(raga, 'rule_set'):
raise ValueError(f"Raga at index {i}: Musical analysis Raga class not supported for uploads. "
f"Use AudioRaga(name='{raga.name}') instead.")
else:
raise ValueError(f"Raga at index {i}: Invalid raga format. "
f"Expected AudioRaga object, string, or dict with 'name' key. "
f"Got {type(raga).__name__}: {raga}")

return normalized

def _validate_ragas(self, ragas: List[Raga]) -> None:
"""Validate that all ragas are AudioRaga objects with to_json method."""
for i, raga in enumerate(ragas):
if not hasattr(raga, 'to_json'):
raise ValueError(f"Raga at index {i}: Object missing to_json method. "
f"Expected AudioRaga object, got {type(raga).__name__}")
if not hasattr(raga, 'name'):
raise ValueError(f"Raga at index {i}: Object missing name attribute. "
f"Expected AudioRaga object, got {type(raga).__name__}")

def to_json(self) -> Dict[str, Any]:
"""Convert to JSON format for API."""
# Convert musicians to dict format expected by API
musicians_dict = {}
for musician in self.musicians:
musicians_dict[musician.name] = musician.to_json()

# Convert ragas to dict format expected by API
# Normalize and validate ragas, then convert to dict format expected by API
normalized_ragas = self._normalize_ragas(self.ragas)
self._validate_ragas(normalized_ragas)

ragas_dict = {}
for raga in self.ragas:
for raga in normalized_ragas:
ragas_dict[raga.name] = raga.to_json()

result = {
Expand Down
20 changes: 15 additions & 5 deletions idtap/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ def get_auth_info(self) -> Dict[str, Any]:
"user_id": self.user_id,
"user_email": self.user.get("email") if self.user else None,
"storage_info": storage_info,
"token_expired": self.secure_storage.is_token_expired(
self.secure_storage.load_tokens() or {}
) if self.token else None
"token_expired": False if not self.token else None
}

def _auth_headers(self) -> Dict[str, str]:
Expand Down Expand Up @@ -301,7 +299,12 @@ def upload_audio(

Args:
file_path: Path to the audio file to upload
metadata: AudioMetadata object with recording information
metadata: AudioMetadata object with recording information.
Ragas can be specified in multiple formats:
- AudioRaga objects: AudioRaga(name="Rageshree") (recommended)
- Strings: "Rageshree" (auto-converted to AudioRaga)
- Name dicts: {"name": "Rageshree"} (auto-converted to AudioRaga)
- Legacy format: {"Rageshree": {"performance_sections": {}}} (auto-converted)
audio_event: Optional AudioEventConfig for associating with audio events
progress_callback: Optional callback for upload progress (0-100)

Expand All @@ -310,7 +313,7 @@ def upload_audio(

Raises:
FileNotFoundError: If the audio file doesn't exist
ValueError: If the file is not a supported audio format
ValueError: If the file is not a supported audio format or metadata validation fails
RuntimeError: If upload fails
"""
import os
Expand All @@ -328,6 +331,13 @@ def upload_audio(
raise ValueError(f"Unsupported audio format: {file_path_obj.suffix}. "
f"Supported formats: {', '.join(supported_extensions)}")

# Validate metadata early to provide clear error messages
try:
# This will trigger raga normalization and validation
metadata.to_json()
except ValueError as e:
raise ValueError(f"Metadata validation failed: {e}")

# Prepare form data
try:
# Prepare data fields
Expand Down
193 changes: 193 additions & 0 deletions idtap/tests/audio_metadata_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""Tests for AudioMetadata raga handling and validation."""

import pytest
from typing import Dict, Any

from idtap.audio_models import AudioMetadata, Raga as AudioRaga, Permissions
from idtap.classes.raga import Raga as MusicalRaga


class TestAudioMetadataRagaValidation:
"""Test cases for AudioMetadata raga format validation and normalization."""

def test_audioraga_objects_work(self):
"""Test that AudioRaga objects work correctly (baseline)."""
metadata = AudioMetadata(
ragas=[AudioRaga(name="Rageshree")]
)

result = metadata.to_json()

assert "ragas" in result
assert "Rageshree" in result["ragas"]
assert "performance sections" in result["ragas"]["Rageshree"]

def test_string_format_auto_converted(self):
"""Test that string ragas are auto-converted to AudioRaga objects."""
metadata = AudioMetadata(
ragas=["Rageshree", "Yaman"]
)

result = metadata.to_json()

assert "ragas" in result
assert "Rageshree" in result["ragas"]
assert "Yaman" in result["ragas"]
assert "performance sections" in result["ragas"]["Rageshree"]
assert "performance sections" in result["ragas"]["Yaman"]

def test_name_dict_format_auto_converted(self):
"""Test that name dict format is auto-converted to AudioRaga objects."""
metadata = AudioMetadata(
ragas=[{"name": "Rageshree"}, {"name": "Yaman"}]
)

result = metadata.to_json()

assert "ragas" in result
assert "Rageshree" in result["ragas"]
assert "Yaman" in result["ragas"]

def test_legacy_dict_format_auto_converted(self):
"""Test that legacy dict format is auto-converted to AudioRaga objects."""
metadata = AudioMetadata(
ragas=[{"Rageshree": {"performance_sections": {}}}]
)

result = metadata.to_json()

assert "ragas" in result
assert "Rageshree" in result["ragas"]
assert "performance sections" in result["ragas"]["Rageshree"]

def test_mixed_formats_work_together(self):
"""Test that different raga formats can be mixed in the same list."""
metadata = AudioMetadata(
ragas=[
AudioRaga(name="Rageshree"), # AudioRaga object
"Yaman", # String
{"name": "Bhairavi"}, # Name dict
{"Malkauns": {"performance_sections": {}}} # Legacy dict
]
)

result = metadata.to_json()

assert "ragas" in result
assert len(result["ragas"]) == 4
assert "Rageshree" in result["ragas"]
assert "Yaman" in result["ragas"]
assert "Bhairavi" in result["ragas"]
assert "Malkauns" in result["ragas"]

def test_empty_ragas_list_works(self):
"""Test that empty ragas list works correctly."""
metadata = AudioMetadata(ragas=[])

result = metadata.to_json()

assert "ragas" in result
assert result["ragas"] == {}

def test_invalid_dict_format_raises_error(self):
"""Test that invalid dict formats raise helpful error messages."""
metadata = AudioMetadata(
ragas=[{"invalid": "format", "multiple": "keys"}]
)

with pytest.raises(ValueError) as exc_info:
metadata.to_json()

assert "Raga at index 0" in str(exc_info.value)
assert "Invalid dict format" in str(exc_info.value)
assert "Use {'name': 'RagaName'} or AudioRaga(name='RagaName')" in str(exc_info.value)

def test_musical_raga_class_raises_helpful_error(self):
"""Test that using musical analysis Raga class raises helpful error."""
musical_raga = MusicalRaga({"name": "Rageshree"})
metadata = AudioMetadata(ragas=[musical_raga])

with pytest.raises(ValueError) as exc_info:
metadata.to_json()

assert "Raga at index 0" in str(exc_info.value)
assert "Musical analysis Raga class not supported for uploads" in str(exc_info.value)
assert "Use AudioRaga(name='Rageshree')" in str(exc_info.value)

def test_invalid_object_type_raises_error(self):
"""Test that invalid object types raise helpful error messages."""
metadata = AudioMetadata(ragas=[123]) # Invalid type

with pytest.raises(ValueError) as exc_info:
metadata.to_json()

assert "Raga at index 0" in str(exc_info.value)
assert "Invalid raga format" in str(exc_info.value)
assert "Expected AudioRaga object, string, or dict with 'name' key" in str(exc_info.value)
assert "Got int: 123" in str(exc_info.value)

def test_multiple_invalid_ragas_show_individual_errors(self):
"""Test that invalid ragas get their correct index in error messages."""
metadata = AudioMetadata(
ragas=[
"valid_string", # Valid (index 0)
{"invalid": "dict"}, # Invalid dict (index 1)
456 # Invalid type (index 2)
]
)

with pytest.raises(ValueError) as exc_info:
metadata.to_json()

# Should fail on the first invalid raga encountered during processing
# The actual behavior processes all items, so it fails on the last invalid one
error_msg = str(exc_info.value)
assert "Raga at index" in error_msg
assert "Invalid" in error_msg

def test_none_in_ragas_list_raises_error(self):
"""Test that None values in ragas list raise errors."""
metadata = AudioMetadata(ragas=[None])

with pytest.raises(ValueError) as exc_info:
metadata.to_json()

assert "Raga at index 0" in str(exc_info.value)
assert "Invalid raga format" in str(exc_info.value)

def test_original_user_case_now_works(self):
"""Test that the original failing user case now works with auto-conversion."""
# This is the exact format the user was trying to use
metadata = AudioMetadata(
title="Vilayat Khan - Rageshree gat",
ragas=[{"Rageshree": {"performance_sections": {}}}], # Original failing format
permissions=Permissions()
)

# This should now work without errors
result = metadata.to_json()

assert "ragas" in result
assert "Rageshree" in result["ragas"]
assert result["title"] == "Vilayat Khan - Rageshree gat"

def test_performance_sections_are_preserved_correctly(self):
"""Test that performance sections are handled correctly in all formats."""
raga1 = AudioRaga(name="Test1")
raga1.performance_sections = [] # Empty list

metadata = AudioMetadata(
ragas=[
raga1, # AudioRaga with empty performance_sections
"Test2", # String (will get default empty list)
{"name": "Test3"} # Dict (will get default empty list)
]
)

result = metadata.to_json()

# All should have the correct performance sections structure
for raga_name in ["Test1", "Test2", "Test3"]:
assert raga_name in result["ragas"]
assert "performance sections" in result["ragas"][raga_name]
assert result["ragas"][raga_name]["performance sections"] == {}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "idtap"
version = "0.1.10"
version = "0.1.12"
description = "Python client library for IDTAP - Interactive Digital Transcription and Analysis Platform for Hindustani music"
readme = "README.md"
license = {text = "MIT"}
Expand Down
Loading