Update Docker configuration, enhance dependencies, and improve migration handling

- Added environment file support and a command for the server in `compose.yaml`.
- Updated the database image in `compose.yaml` to use `pgvector/pgvector:pg15`.
- Modified `pyproject.toml` to include additional dependencies for enhanced functionality.
- Updated `uv.lock` to reflect new package versions and added new packages for improved features.
- Enhanced migration scripts to ensure the `noteflow` schema exists before running migrations.
- Improved error handling in the `OllamaSummarizer` to include a new exception type.
- Refactored the `calendar` trigger to streamline imports and improve code clarity.
- Added module-level mocks in test configurations to handle missing dependencies gracefully.
This commit is contained in:
2025-12-20 16:49:47 -05:00
parent 7f82e5f944
commit cc9a7df54d
13 changed files with 3492 additions and 11 deletions

View File

@@ -5,6 +5,9 @@ services:
dockerfile: docker/server.Dockerfile
ports:
- "50051:50051"
env_file:
- .env
command: python scripts/dev_watch_server.py
environment:
NOTEFLOW_DATABASE_URL: postgresql+asyncpg://noteflow:noteflow@db:5432/noteflow
volumes:
@@ -14,7 +17,7 @@ services:
condition: service_healthy
db:
image: postgres:15
image: pgvector/pgvector:pg15
environment:
POSTGRES_DB: noteflow
POSTGRES_USER: noteflow

View File

@@ -8,7 +8,7 @@ dependencies = [
# Core
"pydantic>=2.0",
# Spike 1: UI + Tray + Hotkeys
"flet>=0.21",
"flet[all]>=0.21",
"pystray>=0.19",
"pillow>=10.0",
"pynput>=1.7",
@@ -125,6 +125,7 @@ filterwarnings = [
[dependency-groups]
dev = [
"basedpyright>=1.36.1",
"ruff>=0.14.9",
"watchfiles>=1.1.1",
]

View File

@@ -5,7 +5,7 @@ import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy import pool, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
@@ -75,6 +75,10 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None:
"""Execute migrations with the provided connection."""
# Ensure the noteflow schema exists before Alembic tries to create its version table
connection.execute(text("CREATE SCHEMA IF NOT EXISTS noteflow"))
connection.commit()
context.configure(
connection=connection,
target_metadata=target_metadata,

View File

@@ -77,7 +77,13 @@ class OllamaSummarizer:
# Try to list models to verify connectivity
client.list()
return True
except (ConnectionError, TimeoutError, RuntimeError, OSError):
except (
ConnectionError,
TimeoutError,
RuntimeError,
OSError,
ProviderUnavailableError,
):
return False
@property

View File

@@ -7,15 +7,12 @@ from __future__ import annotations
import json
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from noteflow.domain.triggers.entities import TriggerSignal, TriggerSource
if TYPE_CHECKING:
from collections.abc import Iterable
logger = logging.getLogger(__name__)

548
tests/client/conftest.py Normal file
View File

@@ -0,0 +1,548 @@
"""Shared fixtures for client component tests."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import numpy as np
import pytest
from numpy.typing import NDArray
if TYPE_CHECKING:
pass
# ============================================================================
# Mock Enums
# ============================================================================
class MockPlaybackState(Enum):
"""Mock PlaybackState for tests."""
STOPPED = auto()
PLAYING = auto()
PAUSED = auto()
# ============================================================================
# Mock Data Classes (mirror grpc.client types)
# ============================================================================
@dataclass
class MockTranscriptSegment:
"""Mock TranscriptSegment for testing."""
segment_id: int = 0
text: str = ""
start_time: float = 0.0
end_time: float = 1.0
language: str = "en"
is_final: bool = True
speaker_id: str = ""
speaker_confidence: float = 0.0
@dataclass
class MockServerInfo:
"""Mock ServerInfo for testing."""
version: str = "1.0.0"
asr_model: str = "base"
asr_ready: bool = True
uptime_seconds: float = 3600.0
active_meetings: int = 0
diarization_enabled: bool = False
diarization_ready: bool = False
@dataclass
class MockMeetingInfo:
"""Mock MeetingInfo for testing."""
id: str = "meeting-123"
title: str = "Test Meeting"
state: str = "stopped"
created_at: float = 1700000000.0
started_at: float = 1700000000.0
ended_at: float = 1700000060.0
duration_seconds: float = 60.0
segment_count: int = 5
@dataclass
class MockAnnotationInfo:
"""Mock AnnotationInfo for testing."""
id: str = "annotation-1"
meeting_id: str = "meeting-123"
annotation_type: str = "note"
text: str = "Test annotation"
start_time: float = 10.0
end_time: float = 10.0
segment_ids: list[int] = field(default_factory=list)
created_at: float = 1700000010.0
@dataclass
class MockExportResult:
"""Mock ExportResult for testing."""
content: str = "# Test Export\n\nContent here."
format_name: str = "markdown"
file_extension: str = "md"
@dataclass
class MockDiarizationResult:
"""Mock DiarizationResult for testing."""
job_id: str = "job-123"
status: str = "completed"
segments_updated: int = 10
speaker_ids: list[str] = field(default_factory=lambda: ["SPEAKER_00", "SPEAKER_01"])
error_message: str = ""
@property
def success(self) -> bool:
"""Check if diarization succeeded."""
return self.status == "completed" and not self.error_message
@property
def is_terminal(self) -> bool:
"""Check if job reached a terminal state."""
return self.status in {"completed", "failed"}
@dataclass
class MockRenameSpeakerResult:
"""Mock RenameSpeakerResult for testing."""
segments_updated: int = 5
success: bool = True
# ============================================================================
# Mock Infrastructure Classes
# ============================================================================
class MockRmsLevelProvider:
"""Mock RmsLevelProvider for tests."""
def __init__(self, db_value: float = -40.0) -> None:
"""Initialize mock level provider.
Args:
db_value: dB value to return from get_db().
"""
self._db_value = db_value
def get_db(self, frames: NDArray[np.float32]) -> float:
"""Return configured dB value.
Args:
frames: Audio frames (ignored).
Returns:
Configured dB value.
"""
return self._db_value
def set_db(self, db_value: float) -> None:
"""Set the dB value to return.
Args:
db_value: New dB value.
"""
self._db_value = db_value
@dataclass
class MockPlayback:
"""Mock SoundDevicePlayback for tests."""
state: MockPlaybackState = MockPlaybackState.STOPPED
total_duration: float = 0.0
_position_callbacks: list[Callable[[float], None]] = field(default_factory=list)
_last_seek_position: float = 0.0
_play_called: bool = False
_pause_called: bool = False
_resume_called: bool = False
_stop_called: bool = False
def play(self, buffer: list) -> None:
"""Mock play method."""
self._play_called = True
self.state = MockPlaybackState.PLAYING
if buffer:
self.total_duration = len(buffer) * 0.1 # Estimate duration
def pause(self) -> None:
"""Mock pause method."""
self._pause_called = True
self.state = MockPlaybackState.PAUSED
def resume(self) -> None:
"""Mock resume method."""
self._resume_called = True
self.state = MockPlaybackState.PLAYING
def stop(self) -> None:
"""Mock stop method."""
self._stop_called = True
self.state = MockPlaybackState.STOPPED
def seek(self, position: float) -> bool:
"""Mock seek method.
Args:
position: Position to seek to.
Returns:
True if seek succeeded.
"""
self._last_seek_position = position
return True
def add_position_callback(self, callback: Callable[[float], None]) -> None:
"""Add position callback.
Args:
callback: Callback to add.
"""
self._position_callbacks.append(callback)
def remove_position_callback(self, callback: Callable[[float], None]) -> None:
"""Remove position callback.
Args:
callback: Callback to remove.
"""
if callback in self._position_callbacks:
self._position_callbacks.remove(callback)
def simulate_position_update(self, position: float) -> None:
"""Simulate position update for testing.
Args:
position: Position to report.
"""
for callback in self._position_callbacks:
callback(position)
# ============================================================================
# MockAppState
# ============================================================================
@dataclass
class MockAppState:
"""Mock AppState for client component tests.
Provides synchronous execution of callbacks for deterministic testing.
"""
# Connection state
server_address: str = "localhost:50051"
connected: bool = False
recording: bool = False
server_info: MockServerInfo | None = None
# Meeting state
current_meeting: MockMeetingInfo | None = None
selected_meeting: MockMeetingInfo | None = None
meetings: list[MockMeetingInfo] = field(default_factory=list)
# Transcript state
transcript_segments: list[MockTranscriptSegment] = field(default_factory=list)
current_partial_text: str = ""
# Annotation state
annotations: list[MockAnnotationInfo] = field(default_factory=list)
# Recording state
recording_start_time: float | None = None
elapsed_seconds: int = 0
# Audio state
current_db_level: float = -60.0
session_audio_buffer: list = field(default_factory=list)
# Playback state
playback_position: float = 0.0
# Infrastructure mocks (created via default_factory)
level_provider: MockRmsLevelProvider = field(default_factory=MockRmsLevelProvider)
playback: MockPlayback = field(default_factory=MockPlayback)
# Page reference
_page: MagicMock | None = None
def request_update(self) -> None:
"""No-op for tests."""
def run_on_ui_thread(self, callback: Callable[[], None]) -> None:
"""Execute callback immediately for tests (synchronous).
Args:
callback: Callback to execute.
"""
callback()
def clear_transcript(self) -> None:
"""Clear transcript segments and partial text."""
self.transcript_segments.clear()
self.current_partial_text = ""
# ============================================================================
# Factory Functions
# ============================================================================
def create_mock_server_info(
version: str = "1.0.0",
asr_model: str = "base",
asr_ready: bool = True,
uptime_seconds: float = 3600.0,
active_meetings: int = 0,
diarization_enabled: bool = False,
diarization_ready: bool = False,
) -> MockServerInfo:
"""Create mock ServerInfo with specified values.
Args:
version: Server version.
asr_model: ASR model name.
asr_ready: Whether ASR is ready.
uptime_seconds: Server uptime.
active_meetings: Active meeting count.
diarization_enabled: Whether diarization is enabled.
diarization_ready: Whether diarization is ready.
Returns:
MockServerInfo instance.
"""
return MockServerInfo(
version=version,
asr_model=asr_model,
asr_ready=asr_ready,
uptime_seconds=uptime_seconds,
active_meetings=active_meetings,
diarization_enabled=diarization_enabled,
diarization_ready=diarization_ready,
)
def create_mock_meeting_info(
id: str = "meeting-123",
title: str = "Test Meeting",
state: str = "stopped",
created_at: float = 1700000000.0,
started_at: float = 1700000000.0,
ended_at: float = 1700000060.0,
duration_seconds: float = 60.0,
segment_count: int = 5,
) -> MockMeetingInfo:
"""Create mock MeetingInfo with specified values.
Args:
id: Meeting ID.
title: Meeting title.
state: Meeting state.
created_at: Creation timestamp.
started_at: Start timestamp.
ended_at: End timestamp.
duration_seconds: Duration in seconds.
segment_count: Segment count.
Returns:
MockMeetingInfo instance.
"""
return MockMeetingInfo(
id=id,
title=title,
state=state,
created_at=created_at,
started_at=started_at,
ended_at=ended_at,
duration_seconds=duration_seconds,
segment_count=segment_count,
)
def create_mock_annotation_info(
id: str = "annotation-1",
meeting_id: str = "meeting-123",
annotation_type: str = "note",
text: str = "Test annotation",
start_time: float = 10.0,
end_time: float = 10.0,
segment_ids: list[int] | None = None,
created_at: float = 1700000010.0,
) -> MockAnnotationInfo:
"""Create mock AnnotationInfo with specified values.
Args:
id: Annotation ID.
meeting_id: Meeting ID.
annotation_type: Annotation type.
text: Annotation text.
start_time: Start timestamp.
end_time: End timestamp.
segment_ids: Related segment IDs.
created_at: Creation timestamp.
Returns:
MockAnnotationInfo instance.
"""
return MockAnnotationInfo(
id=id,
meeting_id=meeting_id,
annotation_type=annotation_type,
text=text,
start_time=start_time,
end_time=end_time,
segment_ids=segment_ids or [],
created_at=created_at,
)
def create_mock_transcript_segment(
segment_id: int = 0,
text: str = "Test segment",
start_time: float = 0.0,
end_time: float = 1.0,
language: str = "en",
is_final: bool = True,
speaker_id: str = "",
speaker_confidence: float = 0.0,
) -> MockTranscriptSegment:
"""Create mock TranscriptSegment with specified values.
Args:
segment_id: Segment ID.
text: Segment text.
start_time: Start time.
end_time: End time.
language: Language code.
is_final: Whether segment is final.
speaker_id: Speaker ID.
speaker_confidence: Speaker confidence.
Returns:
MockTranscriptSegment instance.
"""
return MockTranscriptSegment(
segment_id=segment_id,
text=text,
start_time=start_time,
end_time=end_time,
language=language,
is_final=is_final,
speaker_id=speaker_id,
speaker_confidence=speaker_confidence,
)
# ============================================================================
# Pytest Fixtures
# ============================================================================
@pytest.fixture
def mock_app_state() -> MockAppState:
"""Create fresh MockAppState instance.
Returns:
MockAppState instance.
"""
return MockAppState()
@pytest.fixture
def mock_grpc_client() -> MagicMock:
"""Create mock NoteFlowClient with all methods.
Returns:
MagicMock configured as NoteFlowClient.
"""
client = MagicMock()
client.connect.return_value = True
client.disconnect.return_value = None
client.get_server_info.return_value = create_mock_server_info()
client.list_meetings.return_value = []
client.export_transcript.return_value = None
client.add_annotation.return_value = None
client.get_meeting_segments.return_value = []
client.refine_speaker_diarization.return_value = None
client.get_diarization_job_status.return_value = None
client.rename_speaker.return_value = None
return client
@pytest.fixture
def mock_meeting_info() -> MockMeetingInfo:
"""Create sample MockMeetingInfo.
Returns:
MockMeetingInfo instance.
"""
return create_mock_meeting_info()
@pytest.fixture
def mock_server_info() -> MockServerInfo:
"""Create sample MockServerInfo.
Returns:
MockServerInfo instance.
"""
return create_mock_server_info()
@pytest.fixture
def mock_page() -> MagicMock:
"""Create Flet page mock with dialog support.
Returns:
MagicMock configured as Flet page.
"""
page = MagicMock()
page.dialog = None
page.overlay = []
page.update = MagicMock()
page.run_thread = MagicMock()
return page
@pytest.fixture
def mock_app_state_with_page(mock_app_state: MockAppState, mock_page: MagicMock) -> MockAppState:
"""Create MockAppState with page attached.
Args:
mock_app_state: Base mock state.
mock_page: Mock page.
Returns:
MockAppState with page.
"""
mock_app_state._page = mock_page
return mock_app_state
@pytest.fixture
def sample_audio_frames() -> NDArray[np.float32]:
"""Create sample audio frames for testing.
Returns:
NumPy array of audio samples.
"""
return np.zeros(1600, dtype=np.float32) # 100ms at 16kHz

View File

@@ -0,0 +1,476 @@
"""Tests for AnnotationToolbarComponent."""
from __future__ import annotations
from unittest.mock import MagicMock
import flet as ft
import pytest
from noteflow.client.components.annotation_toolbar import AnnotationToolbarComponent
from .conftest import (
MockAnnotationInfo,
MockAppState,
MockMeetingInfo,
create_mock_annotation_info,
create_mock_meeting_info,
)
class TestAnnotationToolbarBuild:
"""Tests for AnnotationToolbarComponent.build()."""
def test_build_returns_flet_row(self, mock_app_state: MockAppState) -> None:
"""build() should return ft.Row."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
result = component.build()
assert isinstance(result, ft.Row)
def test_build_contains_four_buttons(self, mock_app_state: MockAppState) -> None:
"""build() should create four annotation buttons."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._action_btn is not None
assert component._decision_btn is not None
assert component._note_btn is not None
assert component._risk_btn is not None
def test_buttons_have_correct_labels(self, mock_app_state: MockAppState) -> None:
"""Buttons should have correct text labels."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._action_btn is not None
assert component._action_btn.text == "Action Item"
assert component._decision_btn is not None
assert component._decision_btn.text == "Decision"
assert component._note_btn is not None
assert component._note_btn.text == "Note"
assert component._risk_btn is not None
assert component._risk_btn.text == "Risk"
def test_buttons_have_correct_icons(self, mock_app_state: MockAppState) -> None:
"""Buttons should have correct icons."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._action_btn is not None
assert component._action_btn.icon == ft.Icons.CHECK_CIRCLE_OUTLINE
assert component._decision_btn is not None
assert component._decision_btn.icon == ft.Icons.GAVEL
assert component._note_btn is not None
assert component._note_btn.icon == ft.Icons.NOTE_ADD
assert component._risk_btn is not None
assert component._risk_btn.icon == ft.Icons.WARNING_AMBER
def test_buttons_initially_disabled(self, mock_app_state: MockAppState) -> None:
"""All buttons should be disabled initially."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._action_btn is not None
assert component._action_btn.disabled is True
assert component._decision_btn is not None
assert component._decision_btn.disabled is True
assert component._note_btn is not None
assert component._note_btn.disabled is True
assert component._risk_btn is not None
assert component._risk_btn.disabled is True
def test_row_initially_hidden(self, mock_app_state: MockAppState) -> None:
"""Row should be hidden by default."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._row is not None
assert component._row.visible is False
class TestAnnotationToolbarVisibility:
"""Tests for visibility control."""
def test_set_visible_true_shows_row(self, mock_app_state: MockAppState) -> None:
"""set_visible(True) should make row visible."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
component.set_visible(True)
assert component._row is not None
assert component._row.visible is True
def test_set_visible_false_hides_row(self, mock_app_state: MockAppState) -> None:
"""set_visible(False) should hide row."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
component.set_visible(True)
component.set_visible(False)
assert component._row is not None
assert component._row.visible is False
def test_set_enabled_true_enables_buttons(self, mock_app_state: MockAppState) -> None:
"""set_enabled(True) should enable all buttons."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
component.set_enabled(True)
assert component._action_btn is not None
assert component._action_btn.disabled is False
assert component._decision_btn is not None
assert component._decision_btn.disabled is False
assert component._note_btn is not None
assert component._note_btn.disabled is False
assert component._risk_btn is not None
assert component._risk_btn.disabled is False
def test_set_enabled_false_disables_buttons(self, mock_app_state: MockAppState) -> None:
"""set_enabled(False) should disable all buttons."""
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
component.build()
component.set_enabled(True)
component.set_enabled(False)
assert component._action_btn is not None
assert component._action_btn.disabled is True
assert component._decision_btn is not None
assert component._decision_btn.disabled is True
assert component._note_btn is not None
assert component._note_btn.disabled is True
assert component._risk_btn is not None
assert component._risk_btn.disabled is True
class TestAnnotationToolbarDialogs:
"""Tests for annotation dialog display."""
def test_show_annotation_dialog_sets_type(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_annotation_dialog should set current annotation type."""
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_annotation_dialog("action_item")
assert component._current_annotation_type == "action_item"
def test_show_annotation_dialog_creates_dialog(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_annotation_dialog should create dialog."""
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_annotation_dialog("note")
assert component._dialog is not None
assert isinstance(component._dialog, ft.AlertDialog)
def test_show_annotation_dialog_creates_text_field(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_annotation_dialog should create text field."""
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_annotation_dialog("decision")
assert component._text_field is not None
assert isinstance(component._text_field, ft.TextField)
assert component._text_field.multiline is True
@pytest.mark.parametrize(
("annotation_type", "expected_title"),
[
("action_item", "Add Action Item"),
("decision", "Add Decision"),
("note", "Add Note"),
("risk", "Add Risk"),
],
)
def test_dialog_title_matches_type(
self,
mock_app_state_with_page: MockAppState,
annotation_type: str,
expected_title: str,
) -> None:
"""Dialog title should match annotation type."""
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_annotation_dialog(annotation_type)
assert component._dialog is not None
assert component._dialog.title is not None
assert isinstance(component._dialog.title, ft.Text)
assert component._dialog.title.value == expected_title
def test_close_dialog_sets_open_false(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_close_dialog should set dialog.open to False."""
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_annotation_dialog("note")
assert component._dialog is not None
component._dialog.open = True
component._close_dialog()
assert component._dialog is not None
assert component._dialog.open is False
class TestAnnotationToolbarSubmission:
"""Tests for annotation submission."""
def test_submit_calls_add_annotation(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation should call client.add_annotation."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
mock_grpc_client.add_annotation.return_value = create_mock_annotation_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = "Test annotation text"
component._submit_annotation(MagicMock())
mock_grpc_client.add_annotation.assert_called_once()
def test_submit_uses_correct_params(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation should pass correct parameters."""
meeting = create_mock_meeting_info(id="meeting-456")
mock_app_state_with_page.current_meeting = meeting
mock_app_state_with_page.elapsed_seconds = 30
mock_grpc_client.add_annotation.return_value = create_mock_annotation_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("action_item")
assert component._text_field is not None
component._text_field.value = "Test action"
component._submit_annotation(MagicMock())
mock_grpc_client.add_annotation.assert_called_once_with(
meeting_id="meeting-456",
annotation_type="action_item",
text="Test action",
start_time=30.0,
end_time=30.0,
)
def test_submit_uses_playback_position_when_available(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation should use playback_position if > 0."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
mock_app_state_with_page.playback_position = 45.5
mock_app_state_with_page.elapsed_seconds = 100
mock_grpc_client.add_annotation.return_value = create_mock_annotation_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = "Test note"
component._submit_annotation(MagicMock())
call_kwargs = mock_grpc_client.add_annotation.call_args.kwargs
assert call_kwargs["start_time"] == 45.5
assert call_kwargs["end_time"] == 45.5
def test_submit_uses_elapsed_seconds_when_playback_zero(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation should fall back to elapsed_seconds."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
mock_app_state_with_page.playback_position = 0.0
mock_app_state_with_page.elapsed_seconds = 75
mock_grpc_client.add_annotation.return_value = create_mock_annotation_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = "Test note"
component._submit_annotation(MagicMock())
call_kwargs = mock_grpc_client.add_annotation.call_args.kwargs
assert call_kwargs["start_time"] == 75.0
assert call_kwargs["end_time"] == 75.0
def test_submit_creates_point_annotation(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation should create point annotation (start == end)."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
mock_grpc_client.add_annotation.return_value = create_mock_annotation_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("decision")
assert component._text_field is not None
component._text_field.value = "Test decision"
component._submit_annotation(MagicMock())
call_kwargs = mock_grpc_client.add_annotation.call_args.kwargs
assert call_kwargs["start_time"] == call_kwargs["end_time"]
def test_submit_appends_to_state_annotations(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation should append result to state.annotations."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
annotation_result = create_mock_annotation_info(id="new-annotation")
mock_grpc_client.add_annotation.return_value = annotation_result
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = "Test note"
component._submit_annotation(MagicMock())
assert len(mock_app_state_with_page.annotations) == 1
def test_submit_without_client_logs_warning(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_submit_annotation without client should not crash."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = "Test note"
# Should not raise
component._submit_annotation(MagicMock())
def test_submit_without_meeting_logs_warning(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation without meeting should not crash."""
mock_app_state_with_page.current_meeting = None
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = "Test note"
# Should not raise
component._submit_annotation(MagicMock())
mock_grpc_client.add_annotation.assert_not_called()
def test_submit_empty_text_does_nothing(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_submit_annotation with empty text should not call client."""
mock_app_state_with_page.current_meeting = create_mock_meeting_info()
component = AnnotationToolbarComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_annotation_dialog("note")
assert component._text_field is not None
component._text_field.value = " " # Whitespace only
component._submit_annotation(MagicMock())
mock_grpc_client.add_annotation.assert_not_called()
class TestAnnotationToolbarTimestamp:
"""Tests for timestamp calculation."""
def test_get_current_timestamp_prefers_playback(
self, mock_app_state: MockAppState
) -> None:
"""_get_current_timestamp should prefer playback_position."""
mock_app_state.playback_position = 25.5
mock_app_state.elapsed_seconds = 100
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
result = component._get_current_timestamp()
assert result == 25.5
def test_get_current_timestamp_fallback_elapsed(
self, mock_app_state: MockAppState
) -> None:
"""_get_current_timestamp should fall back to elapsed_seconds."""
mock_app_state.playback_position = 0.0
mock_app_state.elapsed_seconds = 42
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
result = component._get_current_timestamp()
assert result == 42.0
def test_get_current_timestamp_returns_float(
self, mock_app_state: MockAppState
) -> None:
"""_get_current_timestamp should always return float."""
mock_app_state.playback_position = 0.0
mock_app_state.elapsed_seconds = 60
component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None)
result = component._get_current_timestamp()
assert isinstance(result, float)

View File

@@ -0,0 +1,603 @@
"""Tests for ConnectionPanelComponent."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import flet as ft
import pytest
from noteflow.client.components.connection_panel import (
RECONNECT_ATTEMPTS,
RECONNECT_DELAY_SECONDS,
ConnectionPanelComponent,
)
from .conftest import MockAppState, MockServerInfo, create_mock_server_info
class TestConnectionPanelBuild:
"""Tests for ConnectionPanelComponent.build()."""
def test_build_returns_flet_column(self, mock_app_state: MockAppState) -> None:
"""build() should return ft.Column."""
component = ConnectionPanelComponent(mock_app_state)
result = component.build()
assert isinstance(result, ft.Column)
def test_build_contains_server_field(self, mock_app_state: MockAppState) -> None:
"""build() should create server address field."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._server_field is not None
assert isinstance(component._server_field, ft.TextField)
def test_build_contains_connect_button(self, mock_app_state: MockAppState) -> None:
"""build() should create connect button."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._connect_btn is not None
assert isinstance(component._connect_btn, ft.ElevatedButton)
def test_build_contains_status_text(self, mock_app_state: MockAppState) -> None:
"""build() should create status text."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._status_text is not None
assert isinstance(component._status_text, ft.Text)
def test_build_contains_server_info_text(
self, mock_app_state: MockAppState
) -> None:
"""build() should create server info text."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._server_info_text is not None
assert isinstance(component._server_info_text, ft.Text)
def test_initial_button_shows_connect(self, mock_app_state: MockAppState) -> None:
"""Button should show 'Connect' initially."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._connect_btn is not None
assert component._connect_btn.text == "Connect"
def test_initial_button_shows_cloud_off_icon(
self, mock_app_state: MockAppState
) -> None:
"""Button should show cloud_off icon initially."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._connect_btn is not None
assert component._connect_btn.icon == ft.Icons.CLOUD_OFF
def test_server_field_has_default_value(self, mock_app_state: MockAppState) -> None:
"""Server field should use state.server_address."""
mock_app_state.server_address = "custom:8080"
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._server_field is not None
assert component._server_field.value == "custom:8080"
def test_initial_status_text(self, mock_app_state: MockAppState) -> None:
"""Status should show 'Not connected'."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
assert component._status_text is not None
assert component._status_text.value == "Not connected"
class TestConnectionPanelServerChange:
"""Tests for server address changes."""
def test_on_server_change_updates_state(self, mock_app_state: MockAppState) -> None:
"""Server field change should update state.server_address."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
event = MagicMock()
event.control.value = "newserver:9090"
component._on_server_change(event)
assert mock_app_state.server_address == "newserver:9090"
class TestConnectionPanelButtonState:
"""Tests for button state updates."""
def test_update_button_state_when_connected(
self, mock_app_state: MockAppState
) -> None:
"""Button should show 'Disconnect' when connected."""
mock_app_state.connected = True
component = ConnectionPanelComponent(mock_app_state)
component.build()
component.update_button_state()
assert component._connect_btn is not None
assert component._connect_btn.text == "Disconnect"
assert component._connect_btn.icon == ft.Icons.CLOUD_DONE
def test_update_button_state_when_disconnected(
self, mock_app_state: MockAppState
) -> None:
"""Button should show 'Connect' when disconnected."""
mock_app_state.connected = False
component = ConnectionPanelComponent(mock_app_state)
component.build()
component.update_button_state()
assert component._connect_btn is not None
assert component._connect_btn.text == "Connect"
assert component._connect_btn.icon == ft.Icons.CLOUD_OFF
class TestConnectionPanelClient:
"""Tests for client property."""
def test_client_initially_none(self, mock_app_state: MockAppState) -> None:
"""client property should be None initially."""
component = ConnectionPanelComponent(mock_app_state)
assert component.client is None
class TestConnectionPanelDisconnect:
"""Tests for disconnect behavior."""
def test_disconnect_clears_client(self, mock_app_state: MockAppState) -> None:
"""disconnect() should clear client."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component.disconnect()
assert component._client is None
def test_disconnect_updates_state(self, mock_app_state: MockAppState) -> None:
"""disconnect() should update state.connected."""
mock_app_state.connected = True
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component.disconnect()
assert mock_app_state.connected is False
def test_disconnect_clears_server_info(self, mock_app_state: MockAppState) -> None:
"""disconnect() should clear state.server_info."""
mock_app_state.server_info = create_mock_server_info()
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component.disconnect()
assert mock_app_state.server_info is None
def test_disconnect_updates_button_state(
self, mock_app_state: MockAppState
) -> None:
"""disconnect() should update button to 'Connect'."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
assert component._connect_btn is not None
component._connect_btn.text = "Disconnect"
component.disconnect()
assert component._connect_btn is not None
assert component._connect_btn.text == "Connect"
def test_disconnect_calls_callback(self, mock_app_state: MockAppState) -> None:
"""disconnect() should invoke on_disconnected callback."""
callback = MagicMock()
component = ConnectionPanelComponent(mock_app_state, on_disconnected=callback)
component.build()
component._client = MagicMock()
component.disconnect()
callback.assert_called_once()
def test_disconnect_sets_manual_flag(self, mock_app_state: MockAppState) -> None:
"""disconnect() should set _manual_disconnect flag."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component.disconnect()
assert component._manual_disconnect is True
def test_disconnect_disables_auto_reconnect(
self, mock_app_state: MockAppState
) -> None:
"""disconnect() should disable auto reconnect."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component._auto_reconnect_enabled = True
component.disconnect()
assert component._auto_reconnect_enabled is False
class TestConnectionPanelConnect:
"""Tests for connection behavior."""
@patch("noteflow.client.components.connection_panel.NoteFlowClient")
def test_connect_creates_client(
self, mock_client_class: MagicMock, mock_app_state: MockAppState
) -> None:
"""_connect() should create NoteFlowClient."""
mock_client = MagicMock()
mock_client.connect.return_value = True
mock_client.get_server_info.return_value = create_mock_server_info()
mock_client_class.return_value = mock_client
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._connect()
mock_client_class.assert_called_once()
@patch("noteflow.client.components.connection_panel.NoteFlowClient")
def test_connect_success_updates_state(
self, mock_client_class: MagicMock, mock_app_state: MockAppState
) -> None:
"""Successful connection should update state."""
mock_client = MagicMock()
mock_client.connect.return_value = True
mock_client.get_server_info.return_value = create_mock_server_info()
mock_client_class.return_value = mock_client
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._connect()
assert mock_app_state.connected is True
assert mock_app_state.server_info is not None
@patch("noteflow.client.components.connection_panel.NoteFlowClient")
def test_connect_failure_shows_error(
self, mock_client_class: MagicMock, mock_app_state: MockAppState
) -> None:
"""Failed connection should show error status."""
mock_client = MagicMock()
mock_client.connect.return_value = False
mock_client_class.return_value = mock_client
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._connect()
assert component._status_text is not None
assert component._status_text.value is not None
assert "failed" in component._status_text.value.lower()
class TestConnectionPanelConnectSuccess:
"""Tests for successful connection handling."""
def test_on_connect_success_enables_auto_reconnect(
self, mock_app_state: MockAppState
) -> None:
"""_on_connect_success should enable auto reconnect."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
server_info = create_mock_server_info()
component._on_connect_success(server_info)
assert component._auto_reconnect_enabled is True
def test_on_connect_success_updates_button(
self, mock_app_state: MockAppState
) -> None:
"""_on_connect_success should update button."""
mock_app_state.connected = True
component = ConnectionPanelComponent(mock_app_state)
component.build()
server_info = create_mock_server_info()
component._on_connect_success(server_info)
assert component._connect_btn is not None
assert component._connect_btn.text == "Disconnect"
def test_on_connect_success_shows_server_info(
self, mock_app_state: MockAppState
) -> None:
"""_on_connect_success should display server info."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
server_info = create_mock_server_info(version="2.0.0", asr_model="large")
component._on_connect_success(server_info)
assert component._server_info_text is not None
assert component._server_info_text.value is not None
assert "2.0.0" in component._server_info_text.value
assert "large" in component._server_info_text.value
def test_on_connect_success_invokes_callback(
self, mock_app_state: MockAppState
) -> None:
"""_on_connect_success should invoke on_connected callback."""
callback = MagicMock()
component = ConnectionPanelComponent(mock_app_state, on_connected=callback)
component.build()
component._client = MagicMock()
server_info = create_mock_server_info()
component._on_connect_success(server_info)
callback.assert_called_once_with(component._client, server_info)
class TestConnectionPanelCallbacks:
"""Tests for callback invocation."""
def test_on_connected_callback_receives_client_and_info(
self, mock_app_state: MockAppState
) -> None:
"""on_connected should receive client and server info."""
callback = MagicMock()
component = ConnectionPanelComponent(mock_app_state, on_connected=callback)
component.build()
mock_client = MagicMock()
component._client = mock_client
server_info = create_mock_server_info()
component._on_connect_success(server_info)
call_args = callback.call_args
assert call_args[0][0] is mock_client
assert call_args[0][1] is server_info
def test_on_disconnected_callback_invoked(
self, mock_app_state: MockAppState
) -> None:
"""on_disconnected should be invoked on disconnect."""
callback = MagicMock()
component = ConnectionPanelComponent(mock_app_state, on_disconnected=callback)
component.build()
component._client = MagicMock()
component.disconnect()
callback.assert_called_once()
def test_on_connection_change_callback_forwarded(
self, mock_app_state: MockAppState
) -> None:
"""on_connection_change should be forwarded."""
callback = MagicMock()
component = ConnectionPanelComponent(
mock_app_state, on_connection_change_callback=callback
)
component.build()
component._auto_reconnect_enabled = True
component._handle_connection_change(True, "Connected")
callback.assert_called_once()
class TestConnectionPanelReconnection:
"""Tests for reconnection behavior (mocked threading)."""
def test_start_reconnect_loop_sets_flag(
self, mock_app_state: MockAppState
) -> None:
"""_start_reconnect_loop should set in_progress flag."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._reconnect_in_progress = False
with patch("threading.Thread") as mock_thread:
mock_thread.return_value.start = MagicMock()
component._start_reconnect_loop("test message")
assert component._reconnect_in_progress is True
def test_cancel_reconnect_sets_stop_event(
self, mock_app_state: MockAppState
) -> None:
"""_cancel_reconnect should set stop event."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._reconnect_stop_event.clear()
component._cancel_reconnect()
assert component._reconnect_stop_event.is_set()
def test_reconnect_worker_without_client_exits_early(
self, mock_app_state: MockAppState
) -> None:
"""_reconnect_worker without client should exit."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = None
component._reconnect_in_progress = True
component._reconnect_worker("test")
assert component._reconnect_in_progress is False
@patch("time.sleep")
def test_reconnect_worker_respects_stop_event(
self, mock_sleep: MagicMock, mock_app_state: MockAppState
) -> None:
"""_reconnect_worker should stop on stop event."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component._reconnect_stop_event.set()
component._reconnect_in_progress = True
component._reconnect_worker("test")
assert component._reconnect_in_progress is False
def test_attempt_reconnect_without_client_returns_false(
self, mock_app_state: MockAppState
) -> None:
"""_attempt_reconnect without client should return False."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = None
result = component._attempt_reconnect()
assert result is False
def test_manual_disconnect_disables_auto_reconnect(
self, mock_app_state: MockAppState
) -> None:
"""Manual disconnect should prevent auto reconnect."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component.disconnect()
assert component._manual_disconnect is True
assert component._auto_reconnect_enabled is False
class TestConnectionPanelConnectionChange:
"""Tests for connection state change handling."""
def test_handle_connection_change_connected_updates_state(
self, mock_app_state: MockAppState
) -> None:
"""_handle_connection_change(True) should update state."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._handle_connection_change(True, "Connected successfully")
assert mock_app_state.connected is True
def test_handle_connection_change_disconnected_updates_state(
self, mock_app_state: MockAppState
) -> None:
"""_handle_connection_change(False) should update state."""
mock_app_state.connected = True
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._manual_disconnect = True
component._handle_connection_change(False, "Disconnected")
assert mock_app_state.connected is False
def test_handle_connection_change_suppressed_does_nothing(
self, mock_app_state: MockAppState
) -> None:
"""_handle_connection_change should ignore when suppressed."""
mock_app_state.connected = False
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._suppress_connection_events = True
component._handle_connection_change(True, "Connected")
# State should not change when suppressed
assert mock_app_state.connected is False
def test_handle_connection_change_enables_auto_reconnect(
self, mock_app_state: MockAppState
) -> None:
"""_handle_connection_change(True) should enable auto reconnect."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._handle_connection_change(True, "Connected")
assert component._auto_reconnect_enabled is True
class TestConnectionPanelClickHandler:
"""Tests for connect/disconnect button click."""
def test_on_connect_click_when_connected_disconnects(
self, mock_app_state: MockAppState
) -> None:
"""Clicking when connected should disconnect."""
mock_app_state.connected = True
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._client = MagicMock()
component._on_connect_click(MagicMock())
assert component._client is None
@patch("threading.Thread")
def test_on_connect_click_when_disconnected_starts_connect(
self, mock_thread: MagicMock, mock_app_state: MockAppState
) -> None:
"""Clicking when disconnected should start connection."""
mock_app_state.connected = False
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._on_connect_click(MagicMock())
mock_thread.assert_called_once()
mock_thread.return_value.start.assert_called_once()
class TestConnectionPanelStatusUpdate:
"""Tests for status text updates."""
def test_update_status_changes_text(self, mock_app_state: MockAppState) -> None:
"""_update_status should change status text."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._update_status("New status", ft.Colors.BLUE)
assert component._status_text is not None
assert component._status_text.value == "New status"
def test_update_status_changes_color(self, mock_app_state: MockAppState) -> None:
"""_update_status should change text color."""
component = ConnectionPanelComponent(mock_app_state)
component.build()
component._update_status("Status", ft.Colors.RED)
assert component._status_text is not None
assert component._status_text.color == ft.Colors.RED

View File

@@ -0,0 +1,684 @@
"""Tests for MeetingLibraryComponent."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import flet as ft
import pytest
from noteflow.client.components.meeting_library import MeetingLibraryComponent
from .conftest import (
MockAppState,
MockDiarizationResult,
MockExportResult,
MockMeetingInfo,
MockRenameSpeakerResult,
MockTranscriptSegment,
create_mock_meeting_info,
create_mock_transcript_segment,
)
class TestMeetingLibraryBuild:
"""Tests for MeetingLibraryComponent.build()."""
def test_build_returns_flet_column(self, mock_app_state: MockAppState) -> None:
"""build() should return ft.Column."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
result = component.build()
assert isinstance(result, ft.Column)
def test_build_contains_search_field(self, mock_app_state: MockAppState) -> None:
"""build() should create search field."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._search_field is not None
assert isinstance(component._search_field, ft.TextField)
def test_build_contains_list_view(self, mock_app_state: MockAppState) -> None:
"""build() should create list view."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._list_view is not None
assert isinstance(component._list_view, ft.ListView)
def test_build_contains_export_button(self, mock_app_state: MockAppState) -> None:
"""build() should create export button."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._export_btn is not None
assert isinstance(component._export_btn, ft.ElevatedButton)
def test_build_contains_analyze_button(self, mock_app_state: MockAppState) -> None:
"""build() should create analyze button."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._analyze_btn is not None
assert isinstance(component._analyze_btn, ft.ElevatedButton)
def test_build_contains_rename_button(self, mock_app_state: MockAppState) -> None:
"""build() should create rename button."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._rename_btn is not None
assert isinstance(component._rename_btn, ft.ElevatedButton)
def test_build_contains_refresh_button(self, mock_app_state: MockAppState) -> None:
"""build() should create refresh button."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._refresh_btn is not None
assert isinstance(component._refresh_btn, ft.IconButton)
def test_buttons_initially_disabled(self, mock_app_state: MockAppState) -> None:
"""Action buttons should be disabled initially."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._export_btn is not None
assert component._export_btn.disabled is True
assert component._analyze_btn is not None
assert component._analyze_btn.disabled is True
assert component._rename_btn is not None
assert component._rename_btn.disabled is True
class TestMeetingLibraryRefresh:
"""Tests for refresh_meetings()."""
def test_refresh_calls_list_meetings(
self, mock_app_state: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""refresh_meetings() should call client.list_meetings()."""
component = MeetingLibraryComponent(
mock_app_state, get_client=lambda: mock_grpc_client
)
component.build()
component.refresh_meetings()
mock_grpc_client.list_meetings.assert_called_once_with(limit=50)
def test_refresh_populates_list_view(
self, mock_app_state: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""refresh_meetings() should populate list view."""
meetings = [
create_mock_meeting_info(id="1", title="Meeting 1"),
create_mock_meeting_info(id="2", title="Meeting 2"),
]
mock_grpc_client.list_meetings.return_value = meetings
component = MeetingLibraryComponent(
mock_app_state, get_client=lambda: mock_grpc_client
)
component.build()
component.refresh_meetings()
assert component._list_view is not None
assert len(component._list_view.controls) == 2
def test_refresh_without_client_does_not_crash(
self, mock_app_state: MockAppState
) -> None:
"""refresh_meetings() without client should not crash."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
# Should not raise
component.refresh_meetings()
def test_refresh_updates_state_meetings(
self, mock_app_state: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""refresh_meetings() should update state.meetings."""
meetings = [create_mock_meeting_info(id="1", title="Test")]
mock_grpc_client.list_meetings.return_value = meetings
component = MeetingLibraryComponent(
mock_app_state, get_client=lambda: mock_grpc_client
)
component.build()
component.refresh_meetings()
assert len(mock_app_state.meetings) == 1
class TestMeetingLibrarySearch:
"""Tests for search filtering."""
def test_search_filters_meetings_by_title(
self, mock_app_state: MockAppState
) -> None:
"""Search should filter meetings by title."""
mock_app_state.meetings = [
create_mock_meeting_info(id="1", title="Project Review"),
create_mock_meeting_info(id="2", title="Team Standup"),
create_mock_meeting_info(id="3", title="Project Planning"),
]
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._search_field is not None
component._search_field.value = "project"
component._render_meetings()
assert component._list_view is not None
assert len(component._list_view.controls) == 2
def test_search_case_insensitive(self, mock_app_state: MockAppState) -> None:
"""Search should be case insensitive."""
mock_app_state.meetings = [
create_mock_meeting_info(id="1", title="PROJECT Review"),
]
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._search_field is not None
component._search_field.value = "project"
component._render_meetings()
assert component._list_view is not None
assert len(component._list_view.controls) == 1
def test_search_empty_shows_all(self, mock_app_state: MockAppState) -> None:
"""Empty search should show all meetings."""
mock_app_state.meetings = [
create_mock_meeting_info(id="1", title="Meeting 1"),
create_mock_meeting_info(id="2", title="Meeting 2"),
]
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._search_field is not None
component._search_field.value = ""
component._render_meetings()
assert component._list_view is not None
assert len(component._list_view.controls) == 2
def test_search_no_matches_shows_empty(self, mock_app_state: MockAppState) -> None:
"""Search with no matches should show empty list."""
mock_app_state.meetings = [
create_mock_meeting_info(id="1", title="Meeting 1"),
]
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
assert component._search_field is not None
component._search_field.value = "nonexistent"
component._render_meetings()
assert component._list_view is not None
assert len(component._list_view.controls) == 0
class TestMeetingLibrarySelection:
"""Tests for meeting selection."""
def test_meeting_click_updates_state(self, mock_app_state: MockAppState) -> None:
"""Clicking meeting should update state.selected_meeting."""
meeting = create_mock_meeting_info(id="selected-meeting")
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._on_meeting_click(meeting)
assert mock_app_state.selected_meeting is not None
assert mock_app_state.selected_meeting.id == "selected-meeting"
def test_meeting_click_enables_export_button(
self, mock_app_state: MockAppState
) -> None:
"""Clicking meeting should enable export button."""
meeting = create_mock_meeting_info()
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._on_meeting_click(meeting)
assert component._export_btn is not None
assert component._export_btn.disabled is False
def test_meeting_click_enables_analyze_for_stopped_meeting(
self, mock_app_state: MockAppState
) -> None:
"""Clicking stopped meeting should enable analyze button."""
meeting = create_mock_meeting_info(state="stopped")
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._on_meeting_click(meeting)
assert component._analyze_btn is not None
assert component._analyze_btn.disabled is False
def test_meeting_click_disables_analyze_for_recording_meeting(
self, mock_app_state: MockAppState
) -> None:
"""Clicking recording meeting should disable analyze button."""
meeting = create_mock_meeting_info(state="recording")
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._on_meeting_click(meeting)
assert component._analyze_btn is not None
assert component._analyze_btn.disabled is True
def test_meeting_click_invokes_callback(
self, mock_app_state: MockAppState
) -> None:
"""Clicking meeting should invoke on_meeting_selected."""
callback = MagicMock()
meeting = create_mock_meeting_info()
component = MeetingLibraryComponent(
mock_app_state, get_client=lambda: None, on_meeting_selected=callback
)
component.build()
component._on_meeting_click(meeting)
callback.assert_called_once_with(meeting)
class TestMeetingLibraryExport:
"""Tests for export functionality."""
def test_show_export_dialog_creates_dialog(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_export_dialog should create dialog."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_export_dialog(MagicMock())
assert component._export_dialog is not None
assert isinstance(component._export_dialog, ft.AlertDialog)
def test_show_export_dialog_creates_format_dropdown(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_export_dialog should create format dropdown."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_export_dialog(MagicMock())
assert component._format_dropdown is not None
assert isinstance(component._format_dropdown, ft.Dropdown)
def test_export_calls_client(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_export should call client.export_transcript."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1")
mock_grpc_client.export_transcript.return_value = MockExportResult()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_export_dialog(MagicMock())
assert component._format_dropdown is not None
component._format_dropdown.value = "markdown"
component._do_export(MagicMock())
mock_grpc_client.export_transcript.assert_called_once_with("m1", "markdown")
def test_export_without_meeting_does_nothing(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_export without selected meeting should not call client."""
mock_app_state_with_page.selected_meeting = None
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._do_export(MagicMock())
mock_grpc_client.export_transcript.assert_not_called()
class TestMeetingLibraryDiarization:
"""Tests for speaker diarization functionality."""
def test_show_analyze_dialog_creates_dialog(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_analyze_dialog should create dialog."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(
state="stopped"
)
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_analyze_dialog(MagicMock())
assert component._analyze_dialog is not None
assert isinstance(component._analyze_dialog, ft.AlertDialog)
def test_show_analyze_dialog_creates_num_speakers_field(
self, mock_app_state_with_page: MockAppState
) -> None:
"""_show_analyze_dialog should create number of speakers field."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(
state="stopped"
)
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: None
)
component.build()
component._show_analyze_dialog(MagicMock())
assert component._num_speakers_field is not None
assert isinstance(component._num_speakers_field, ft.TextField)
def test_analyze_calls_refine_diarization(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_analyze should call client.refine_speaker_diarization."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1")
mock_grpc_client.refine_speaker_diarization.return_value = MockDiarizationResult()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_analyze_dialog(MagicMock())
component._do_analyze(MagicMock())
mock_grpc_client.refine_speaker_diarization.assert_called_once()
def test_analyze_parses_num_speakers(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_analyze should parse num_speakers from field."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1")
mock_grpc_client.refine_speaker_diarization.return_value = MockDiarizationResult()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_analyze_dialog(MagicMock())
assert component._num_speakers_field is not None
component._num_speakers_field.value = "3"
component._do_analyze(MagicMock())
call_args = mock_grpc_client.refine_speaker_diarization.call_args
assert call_args[0][1] == 3
@pytest.mark.parametrize(
("state", "expected"),
[
("stopped", True),
("completed", True),
("error", True),
("recording", False),
("starting", False),
],
)
def test_can_refine_speakers_checks_meeting_state(
self, state: str, expected: bool
) -> None:
"""_can_refine_speakers should check meeting state."""
meeting = create_mock_meeting_info(state=state)
result = MeetingLibraryComponent._can_refine_speakers(meeting)
assert result is expected
def test_show_analysis_progress_updates_button(
self, mock_app_state: MockAppState
) -> None:
"""_show_analysis_progress should update button text."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._show_analysis_progress("Running...")
assert component._analyze_btn is not None
assert component._analyze_btn.text == "Running..."
assert component._analyze_btn.disabled is True
def test_format_job_status_queued(self) -> None:
"""_format_job_status should format queued status."""
result = MeetingLibraryComponent._format_job_status("queued")
assert result == "Queued..."
def test_format_job_status_running(self) -> None:
"""_format_job_status should format running status."""
result = MeetingLibraryComponent._format_job_status("running")
assert result == "Refining..."
class TestMeetingLibrarySpeakerRename:
"""Tests for speaker rename functionality."""
def test_show_rename_dialog_creates_dialog(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_show_rename_dialog should create dialog."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(
state="stopped"
)
segments = [
create_mock_transcript_segment(speaker_id="SPEAKER_00"),
create_mock_transcript_segment(speaker_id="SPEAKER_01"),
]
mock_grpc_client.get_meeting_segments.return_value = segments
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_rename_dialog(MagicMock())
assert component._rename_dialog is not None
assert isinstance(component._rename_dialog, ft.AlertDialog)
def test_show_rename_dialog_creates_fields_per_speaker(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_show_rename_dialog should create field per speaker."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(
state="stopped"
)
segments = [
create_mock_transcript_segment(speaker_id="SPEAKER_00"),
create_mock_transcript_segment(speaker_id="SPEAKER_01"),
create_mock_transcript_segment(speaker_id="SPEAKER_00"),
]
mock_grpc_client.get_meeting_segments.return_value = segments
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_rename_dialog(MagicMock())
assert len(component._rename_fields) == 2
def test_no_speakers_shows_message(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_show_rename_dialog with no speakers should show message."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(
state="stopped"
)
segments = [create_mock_transcript_segment(speaker_id="")]
mock_grpc_client.get_meeting_segments.return_value = segments
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._show_rename_dialog(MagicMock())
# Should show message dialog instead of rename dialog
assert component._rename_dialog is None
def test_rename_calls_client_per_speaker(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_rename should call client.rename_speaker for each."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1")
mock_grpc_client.rename_speaker.return_value = MockRenameSpeakerResult()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._rename_fields = {
"SPEAKER_00": MagicMock(value="Alice"),
"SPEAKER_01": MagicMock(value="Bob"),
}
component._do_rename(MagicMock())
assert mock_grpc_client.rename_speaker.call_count == 2
def test_rename_skips_empty_names(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_rename should skip empty names."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1")
mock_grpc_client.rename_speaker.return_value = MockRenameSpeakerResult()
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._rename_fields = {
"SPEAKER_00": MagicMock(value="Alice"),
"SPEAKER_01": MagicMock(value=""), # Empty
}
component._do_rename(MagicMock())
mock_grpc_client.rename_speaker.assert_called_once()
def test_rename_skips_unchanged_names(
self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_do_rename should skip unchanged names."""
mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1")
component = MeetingLibraryComponent(
mock_app_state_with_page, get_client=lambda: mock_grpc_client
)
component.build()
component._rename_fields = {
"SPEAKER_00": MagicMock(value="SPEAKER_00"), # Unchanged
}
component._do_rename(MagicMock())
mock_grpc_client.rename_speaker.assert_not_called()
class TestMeetingLibraryDialogClose:
"""Tests for dialog close handlers."""
def test_close_export_dialog(self, mock_app_state: MockAppState) -> None:
"""_close_export_dialog should set open to False."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._export_dialog = MagicMock()
component._export_dialog.open = True
component._close_export_dialog()
assert component._export_dialog.open is False
def test_close_analyze_dialog(self, mock_app_state: MockAppState) -> None:
"""_close_analyze_dialog should set open to False."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._analyze_dialog = MagicMock()
component._analyze_dialog.open = True
component._close_analyze_dialog()
assert component._analyze_dialog.open is False
def test_close_rename_dialog(self, mock_app_state: MockAppState) -> None:
"""_close_rename_dialog should set open to False."""
component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None)
component.build()
component._rename_dialog = MagicMock()
component._rename_dialog.open = True
component._close_rename_dialog()
assert component._rename_dialog.open is False
class TestMeetingLibraryRefreshClick:
"""Tests for refresh button click."""
def test_on_refresh_click_calls_refresh(
self, mock_app_state: MockAppState, mock_grpc_client: MagicMock
) -> None:
"""_on_refresh_click should call refresh_meetings."""
component = MeetingLibraryComponent(
mock_app_state, get_client=lambda: mock_grpc_client
)
component.build()
component._on_refresh_click(MagicMock())
mock_grpc_client.list_meetings.assert_called_once()

View File

@@ -0,0 +1,463 @@
"""Tests for PlaybackControlsComponent."""
from __future__ import annotations
from unittest.mock import MagicMock
import flet as ft
import pytest
from noteflow.client.components.playback_controls import PlaybackControlsComponent
from noteflow.infrastructure.audio import PlaybackState
from .conftest import MockAppState, MockPlayback, MockPlaybackState
class TestPlaybackControlsBuild:
"""Tests for PlaybackControlsComponent.build()."""
def test_build_returns_flet_row(self, mock_app_state: MockAppState) -> None:
"""build() should return ft.Row."""
component = PlaybackControlsComponent(mock_app_state)
result = component.build()
assert isinstance(result, ft.Row)
def test_build_contains_play_button(self, mock_app_state: MockAppState) -> None:
"""build() should create play button."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._play_btn is not None
assert isinstance(component._play_btn, ft.IconButton)
def test_build_contains_stop_button(self, mock_app_state: MockAppState) -> None:
"""build() should create stop button."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._stop_btn is not None
assert isinstance(component._stop_btn, ft.IconButton)
def test_build_contains_timeline_slider(self, mock_app_state: MockAppState) -> None:
"""build() should create timeline slider."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._timeline_slider is not None
assert isinstance(component._timeline_slider, ft.Slider)
def test_build_contains_position_labels(self, mock_app_state: MockAppState) -> None:
"""build() should create position and duration labels."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._position_label is not None
assert isinstance(component._position_label, ft.Text)
assert component._duration_label is not None
assert isinstance(component._duration_label, ft.Text)
def test_initial_buttons_disabled(self, mock_app_state: MockAppState) -> None:
"""Buttons should be disabled initially."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._play_btn is not None
assert component._play_btn.disabled is True
assert component._stop_btn is not None
assert component._stop_btn.disabled is True
def test_initial_slider_disabled(self, mock_app_state: MockAppState) -> None:
"""Slider should be disabled initially."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._timeline_slider is not None
assert component._timeline_slider.disabled is True
def test_initial_visibility_hidden(self, mock_app_state: MockAppState) -> None:
"""Row should be hidden by default."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._row is not None
assert component._row.visible is False
def test_initial_position_label_zero(self, mock_app_state: MockAppState) -> None:
"""Position label should show 00:00."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._position_label is not None
assert component._position_label.value == "00:00"
def test_initial_play_button_icon(self, mock_app_state: MockAppState) -> None:
"""Play button should show play icon."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._play_btn is not None
assert component._play_btn.icon == ft.Icons.PLAY_ARROW
class TestPlaybackControlsVisibility:
"""Tests for visibility control."""
def test_set_visible_true_shows_row(self, mock_app_state: MockAppState) -> None:
"""set_visible(True) should make row visible."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.set_visible(True)
assert component._row is not None
assert component._row.visible is True
def test_set_visible_false_hides_row(self, mock_app_state: MockAppState) -> None:
"""set_visible(False) should hide row."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.set_visible(True)
component.set_visible(False)
assert component._row is not None
assert component._row.visible is False
class TestPlaybackControlsLoadAudio:
"""Tests for load_audio()."""
def test_load_audio_calls_playback_play(self, mock_app_state: MockAppState) -> None:
"""load_audio() should call playback.play()."""
mock_app_state.session_audio_buffer = [b"audio1", b"audio2"]
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.load_audio()
assert mock_app_state.playback._play_called is True
def test_load_audio_pauses_after_play(self, mock_app_state: MockAppState) -> None:
"""load_audio() should pause playback after loading."""
mock_app_state.session_audio_buffer = [b"audio1", b"audio2"]
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.load_audio()
assert mock_app_state.playback._pause_called is True
def test_load_audio_enables_buttons(self, mock_app_state: MockAppState) -> None:
"""load_audio() should enable play and stop buttons."""
mock_app_state.session_audio_buffer = [b"audio1", b"audio2"]
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.load_audio()
assert component._play_btn is not None
assert component._play_btn.disabled is False
assert component._stop_btn is not None
assert component._stop_btn.disabled is False
def test_load_audio_enables_slider(self, mock_app_state: MockAppState) -> None:
"""load_audio() should enable timeline slider."""
mock_app_state.session_audio_buffer = [b"audio1", b"audio2"]
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.load_audio()
assert component._timeline_slider is not None
assert component._timeline_slider.disabled is False
def test_load_audio_makes_visible(self, mock_app_state: MockAppState) -> None:
"""load_audio() should make controls visible."""
mock_app_state.session_audio_buffer = [b"audio1", b"audio2"]
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.load_audio()
assert component._row is not None
assert component._row.visible is True
def test_load_audio_resets_position(self, mock_app_state: MockAppState) -> None:
"""load_audio() should reset playback position to 0."""
mock_app_state.session_audio_buffer = [b"audio1", b"audio2"]
mock_app_state.playback_position = 50.0
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.load_audio()
assert mock_app_state.playback_position == 0.0
def test_load_audio_empty_buffer_logs_warning(
self, mock_app_state: MockAppState
) -> None:
"""load_audio() with empty buffer should not crash."""
mock_app_state.session_audio_buffer = []
component = PlaybackControlsComponent(mock_app_state)
component.build()
# Should not raise
component.load_audio()
class TestPlaybackControlsPlayPause:
"""Tests for play/pause button behavior."""
def test_play_click_from_stopped_calls_play(
self, mock_app_state: MockAppState
) -> None:
"""Clicking play when stopped should call playback.play()."""
mock_app_state.session_audio_buffer = [b"audio"]
mock_app_state.playback.state = MockPlaybackState.STOPPED
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._on_play_click(MagicMock())
assert mock_app_state.playback._play_called is True
def test_play_click_updates_icon_to_pause(
self, mock_app_state: MockAppState
) -> None:
"""Clicking play should change icon to pause."""
mock_app_state.session_audio_buffer = [b"audio"]
mock_app_state.playback.state = MockPlaybackState.STOPPED
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._on_play_click(MagicMock())
assert component._play_btn is not None
assert component._play_btn.icon == ft.Icons.PAUSE
def test_pause_click_from_playing_calls_pause(
self, mock_app_state: MockAppState
) -> None:
"""Clicking pause when playing should call playback.pause()."""
# Use the real PlaybackState from infrastructure
mock_app_state.playback.state = PlaybackState.PLAYING
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._active = True
component._on_play_click(MagicMock())
assert mock_app_state.playback._pause_called is True
def test_pause_click_updates_icon_to_play(
self, mock_app_state: MockAppState
) -> None:
"""Clicking pause should change icon to play."""
mock_app_state.playback.state = PlaybackState.PLAYING
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._active = True
component._on_play_click(MagicMock())
assert component._play_btn is not None
assert component._play_btn.icon == ft.Icons.PLAY_ARROW
def test_resume_from_paused_calls_resume(
self, mock_app_state: MockAppState
) -> None:
"""Clicking play when paused should call playback.resume()."""
mock_app_state.playback.state = PlaybackState.PAUSED
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._on_play_click(MagicMock())
assert mock_app_state.playback._resume_called is True
class TestPlaybackControlsStop:
"""Tests for stop button behavior."""
def test_stop_click_calls_stop(self, mock_app_state: MockAppState) -> None:
"""Clicking stop should call playback.stop()."""
mock_app_state.playback.state = MockPlaybackState.PLAYING
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._on_stop_click(MagicMock())
assert mock_app_state.playback._stop_called is True
def test_stop_click_resets_position(self, mock_app_state: MockAppState) -> None:
"""Clicking stop should reset playback position to 0."""
mock_app_state.playback_position = 50.0
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._on_stop_click(MagicMock())
assert mock_app_state.playback_position == 0.0
def test_stop_click_updates_icon_to_play(
self, mock_app_state: MockAppState
) -> None:
"""Clicking stop should change icon to play."""
mock_app_state.playback.state = MockPlaybackState.PLAYING
component = PlaybackControlsComponent(mock_app_state)
component.build()
# Simulate playing state icon
assert component._play_btn is not None
component._play_btn.icon = ft.Icons.PAUSE
component._on_stop_click(MagicMock())
assert component._play_btn is not None
assert component._play_btn.icon == ft.Icons.PLAY_ARROW
class TestPlaybackControlsSeek:
"""Tests for seek functionality."""
def test_slider_change_calls_seek(self, mock_app_state: MockAppState) -> None:
"""Changing slider should call playback.seek()."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._timeline_slider is not None
component._timeline_slider.value = 25.0
component._on_slider_change(MagicMock())
assert mock_app_state.playback._last_seek_position == 25.0
def test_seek_method_updates_position(self, mock_app_state: MockAppState) -> None:
"""seek() should update playback position."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component.seek(30.0)
assert mock_app_state.playback_position == 30.0
class TestPlaybackControlsPositionUpdates:
"""Tests for position update callbacks."""
def test_start_position_updates_registers_callback(
self, mock_app_state: MockAppState
) -> None:
"""_start_position_updates should register callback."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._start_position_updates()
assert len(mock_app_state.playback._position_callbacks) == 1
def test_stop_position_updates_unregisters_callback(
self, mock_app_state: MockAppState
) -> None:
"""_stop_position_updates should unregister callback."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._start_position_updates()
component._stop_position_updates()
assert len(mock_app_state.playback._position_callbacks) == 0
def test_position_callback_updates_slider(
self, mock_app_state: MockAppState
) -> None:
"""Position callback should update slider value."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._timeline_slider is not None
component._timeline_slider.disabled = False
component._start_position_updates()
mock_app_state.playback.simulate_position_update(15.0)
assert component._timeline_slider is not None
assert component._timeline_slider.value == 15.0
def test_position_callback_updates_state(
self, mock_app_state: MockAppState
) -> None:
"""Position callback should update state.playback_position."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._start_position_updates()
mock_app_state.playback.simulate_position_update(20.0)
assert mock_app_state.playback_position == 20.0
def test_position_callback_invokes_external_callback(
self, mock_app_state: MockAppState
) -> None:
"""Position callback should invoke on_position_change."""
callback = MagicMock()
component = PlaybackControlsComponent(mock_app_state, on_position_change=callback)
component.build()
component._start_position_updates()
mock_app_state.playback.simulate_position_update(10.0)
callback.assert_called_once_with(10.0)
def test_inactive_component_ignores_position_updates(
self, mock_app_state: MockAppState
) -> None:
"""Inactive component should ignore position updates."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._active = False
mock_app_state.playback._position_callbacks.append(component._on_position_update)
# Should not update state
initial_position = mock_app_state.playback_position
component._on_position_update(50.0)
assert mock_app_state.playback_position == initial_position
class TestPlaybackControlsPlaybackFinished:
"""Tests for playback completion handling."""
def test_on_playback_finished_resets_icon(
self, mock_app_state: MockAppState
) -> None:
"""_on_playback_finished should reset play button icon."""
component = PlaybackControlsComponent(mock_app_state)
component.build()
assert component._play_btn is not None
component._play_btn.icon = ft.Icons.PAUSE
component._on_playback_finished()
assert component._play_btn is not None
assert component._play_btn.icon == ft.Icons.PLAY_ARROW
def test_on_playback_finished_resets_position(
self, mock_app_state: MockAppState
) -> None:
"""_on_playback_finished should reset position to 0."""
mock_app_state.playback_position = 60.0
component = PlaybackControlsComponent(mock_app_state)
component.build()
component._on_playback_finished()
assert mock_app_state.playback_position == 0.0

View File

@@ -0,0 +1,328 @@
"""Tests for VuMeterComponent."""
from __future__ import annotations
from unittest.mock import patch
import flet as ft
import numpy as np
import pytest
from numpy.typing import NDArray
from noteflow.client.components.vu_meter import VU_UPDATE_INTERVAL, VuMeterComponent
from .conftest import MockAppState, MockRmsLevelProvider
class TestVuMeterBuild:
"""Tests for VuMeterComponent.build()."""
def test_build_returns_flet_row(self, mock_app_state: MockAppState) -> None:
"""build() should return ft.Row."""
component = VuMeterComponent(mock_app_state)
result = component.build()
assert isinstance(result, ft.Row)
def test_build_contains_progress_bar(self, mock_app_state: MockAppState) -> None:
"""build() should create progress bar."""
component = VuMeterComponent(mock_app_state)
component.build()
assert component._progress_bar is not None
assert isinstance(component._progress_bar, ft.ProgressBar)
def test_build_contains_label(self, mock_app_state: MockAppState) -> None:
"""build() should create dB label."""
component = VuMeterComponent(mock_app_state)
component.build()
assert component._label is not None
assert isinstance(component._label, ft.Text)
def test_initial_progress_bar_value_zero(self, mock_app_state: MockAppState) -> None:
"""Progress bar should start at 0."""
component = VuMeterComponent(mock_app_state)
component.build()
assert component._progress_bar is not None
assert component._progress_bar.value == 0
def test_initial_label_shows_minus_60_db(self, mock_app_state: MockAppState) -> None:
"""Label should start at -60 dB."""
component = VuMeterComponent(mock_app_state)
component.build()
assert component._label is not None
assert component._label.value == "-60 dB"
def test_progress_bar_has_green_color(self, mock_app_state: MockAppState) -> None:
"""Progress bar should start with green color."""
component = VuMeterComponent(mock_app_state)
component.build()
assert component._progress_bar is not None
assert component._progress_bar.color == ft.Colors.GREEN
class TestVuMeterAudioFrames:
"""Tests for VuMeterComponent.on_audio_frames()."""
def test_on_audio_frames_updates_level(
self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32]
) -> None:
"""on_audio_frames() should update state.current_db_level."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert mock_app_state.current_db_level == -30.0
def test_on_audio_frames_throttled(
self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32]
) -> None:
"""on_audio_frames() should throttle updates to VU_UPDATE_INTERVAL."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0)
component = VuMeterComponent(mock_app_state)
component.build()
# First call should update
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
first_level = mock_app_state.current_db_level
# Second call within throttle interval should NOT update
mock_app_state.level_provider.set_db(-10.0)
with patch("time.time", return_value=1.0 + VU_UPDATE_INTERVAL / 2):
component.on_audio_frames(sample_audio_frames)
# Level should remain unchanged (throttled)
assert mock_app_state.current_db_level == first_level
def test_on_audio_frames_updates_after_interval(
self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32]
) -> None:
"""on_audio_frames() should update after throttle interval passes."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0)
component = VuMeterComponent(mock_app_state)
component.build()
# First call
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
# Second call after interval
mock_app_state.level_provider.set_db(-10.0)
with patch("time.time", return_value=1.0 + VU_UPDATE_INTERVAL + 0.01):
component.on_audio_frames(sample_audio_frames)
assert mock_app_state.current_db_level == -10.0
def test_on_audio_frames_updates_progress_bar(
self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32]
) -> None:
"""on_audio_frames() should update progress bar value."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
# -30 dB normalized: (-30 + 60) / 60 = 0.5
assert component._progress_bar.value == 0.5
class TestVuMeterColorCoding:
"""Tests for VU meter color coding based on dB level."""
@pytest.mark.parametrize(
("db_level", "expected_color"),
[
(-5.0, ft.Colors.RED),
(-3.0, ft.Colors.RED),
(0.0, ft.Colors.RED),
],
)
def test_color_red_above_negative_6_db(
self,
mock_app_state: MockAppState,
sample_audio_frames: NDArray[np.float32],
db_level: float,
expected_color: str,
) -> None:
"""dB > -6 should show RED."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
assert component._progress_bar.color == expected_color
@pytest.mark.parametrize(
("db_level", "expected_color"),
[
(-6.0, ft.Colors.YELLOW),
(-10.0, ft.Colors.YELLOW),
(-15.0, ft.Colors.YELLOW),
(-19.0, ft.Colors.YELLOW),
],
)
def test_color_yellow_between_negative_20_and_negative_6(
self,
mock_app_state: MockAppState,
sample_audio_frames: NDArray[np.float32],
db_level: float,
expected_color: str,
) -> None:
"""-20 < dB <= -6 should show YELLOW."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
assert component._progress_bar.color == expected_color
@pytest.mark.parametrize(
("db_level", "expected_color"),
[
(-20.0, ft.Colors.GREEN),
(-30.0, ft.Colors.GREEN),
(-40.0, ft.Colors.GREEN),
(-60.0, ft.Colors.GREEN),
],
)
def test_color_green_at_or_below_negative_20_db(
self,
mock_app_state: MockAppState,
sample_audio_frames: NDArray[np.float32],
db_level: float,
expected_color: str,
) -> None:
"""dB <= -20 should show GREEN."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
assert component._progress_bar.color == expected_color
class TestVuMeterNormalization:
"""Tests for dB to progress bar value normalization."""
@pytest.mark.parametrize(
("db_level", "expected_value"),
[
(-60.0, 0.0),
(-30.0, 0.5),
(0.0, 1.0),
],
)
def test_normalize_db_to_progress_value(
self,
mock_app_state: MockAppState,
sample_audio_frames: NDArray[np.float32],
db_level: float,
expected_value: float,
) -> None:
"""dB should normalize to 0-1 range."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
assert component._progress_bar.value == pytest.approx(expected_value, abs=0.01)
def test_normalize_clamps_below_minus_60(
self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32]
) -> None:
"""dB below -60 should clamp to 0."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=-80.0)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
assert component._progress_bar.value == 0.0
def test_normalize_clamps_above_zero(
self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32]
) -> None:
"""dB above 0 should clamp to 1."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=10.0)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._progress_bar is not None
assert component._progress_bar.value == 1.0
class TestVuMeterLabelUpdate:
"""Tests for dB label updates."""
@pytest.mark.parametrize(
("db_level", "expected_label"),
[
(-60.0, "-60 dB"),
(-30.0, "-30 dB"),
(-10.0, "-10 dB"),
(0.0, "0 dB"),
],
)
def test_label_shows_db_value(
self,
mock_app_state: MockAppState,
sample_audio_frames: NDArray[np.float32],
db_level: float,
expected_label: str,
) -> None:
"""Label should show formatted dB value."""
mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level)
component = VuMeterComponent(mock_app_state)
component.build()
with patch("time.time", return_value=1.0):
component._last_update_time = 0.0
component.on_audio_frames(sample_audio_frames)
assert component._label is not None
assert component._label.value == expected_label

View File

@@ -14,6 +14,23 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
# ============================================================================
# Module-level mocks (run before pytest collection)
# ============================================================================
# Mock sounddevice if PortAudio is not available (must be done before collection)
if "sounddevice" not in sys.modules:
try:
import sounddevice as _sounddevice # noqa: F401
except OSError:
# PortAudio library not found - mock the module
sounddevice_module = types.ModuleType("sounddevice")
sounddevice_module.InputStream = MagicMock
sounddevice_module.OutputStream = MagicMock
sounddevice_module.query_devices = lambda: []
sounddevice_module.default = SimpleNamespace(device=(0, 0))
sys.modules["sounddevice"] = sounddevice_module
@pytest.fixture(autouse=True, scope="session")
def mock_optional_extras() -> None:

357
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.12"
[[package]]
@@ -123,6 +123,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -170,6 +179,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "arrow"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
]
[[package]]
name = "asteroid-filterbanks"
version = "0.4.0"
@@ -288,6 +310,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/7f/f0133313bffa303d32aa74468981eb6b2da7fadda6247c9aa0aeab8391b1/basedpyright-1.36.1-py3-none-any.whl", hash = "sha256:3d738484fe9681cdfe35dd98261f30a9a7aec64208bc91f8773a9aaa9b89dd16", size = 11881725, upload-time = "2025-12-11T14:55:43.805Z" },
]
[[package]]
name = "binaryornot"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chardet" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054, upload-time = "2017-08-03T15:55:25.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006, upload-time = "2017-08-03T15:55:31.23Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -354,6 +388,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "chardet"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -522,6 +565,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
]
[[package]]
name = "cookiecutter"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "arrow" },
{ name = "binaryornot" },
{ name = "click" },
{ name = "jinja2" },
{ name = "python-slugify" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c", size = 158767, upload-time = "2024-02-21T18:02:41.949Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d", size = 39177, upload-time = "2024-02-21T18:02:39.569Z" },
]
[[package]]
name = "coverage"
version = "7.13.0"
@@ -789,6 +851,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/3a/46ca34abf0725a754bc44ef474ad34aedcc3ea23b052d97b18b76715a6a9/EWMHlib-0.2-py3-none-any.whl", hash = "sha256:f5b07d8cfd4c7734462ee744c32d490f2f3233fa7ab354240069344208d2f6f5", size = 46657, upload-time = "2024-04-17T08:15:56.338Z" },
]
[[package]]
name = "fastapi"
version = "0.126.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/6c/28773e19bc203a2f3cf1d54a8e96ca7d05b58157a350aa4d8d37f2a5ba07/fastapi-0.126.0.tar.gz", hash = "sha256:f099fceb2a6d56dd21c59c4543d00be123dedacff869e76ae31ba3c0f963e2cd", size = 367455, upload-time = "2025-12-20T16:16:44.484Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/0b/d5f999f27cb90152a6aadf094205b4d0eeab6a6b03e3e60346cde988c1bd/fastapi-0.126.0-py3-none-any.whl", hash = "sha256:c9330b9731e3bd2caae0a00e76353f86adbf592c5a25649a1682f3a92aeaff41", size = 111758, upload-time = "2025-12-20T16:16:42.349Z" },
]
[[package]]
name = "faster-whisper"
version = "1.2.1"
@@ -836,6 +913,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/d0/9ba4ee34972e9e0cf54b1f7d17c695491632421f81301993f2aec8d12105/flet-0.28.3-py3-none-any.whl", hash = "sha256:649bfc4af7933956ecf44963df6c0d997bff9ceeaf89d3c86d96803840cab83e", size = 463000, upload-time = "2025-05-20T19:44:58.651Z" },
]
[package.optional-dependencies]
all = [
{ name = "flet-cli" },
{ name = "flet-desktop", marker = "sys_platform == 'darwin' or sys_platform == 'win32'" },
{ name = "flet-desktop-light", marker = "sys_platform == 'linux'" },
{ name = "flet-web" },
]
[[package]]
name = "flet-cli"
version = "0.28.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cookiecutter" },
{ name = "flet" },
{ name = "packaging" },
{ name = "qrcode" },
{ name = "toml" },
{ name = "watchdog" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/33/9398add46c07a8247a28dc05fd96e26d12d07c7153126ce67ed42a6439bb/flet_cli-0.28.3-py3-none-any.whl", hash = "sha256:2759e4526472a32a584836cf926a9c3ba8fe6e363b4305454a759668d4fcad70", size = 44149, upload-time = "2025-05-20T19:44:57.336Z" },
]
[[package]]
name = "flet-desktop"
version = "0.28.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flet" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/0e/f797d3052de953fa42b804b6ff170ad5c7708fc2959ffcce9d2a49a4b56c/flet_desktop-0.28.3.tar.gz", hash = "sha256:3e5db7b152de8cd3935e98eb39c162bf5c66f595d30c006f28d48d60e77a463d", size = 39876159, upload-time = "2025-05-20T19:39:28.548Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/e6/280350788df36041b825a1e2ceeb2a60672f208164f3f2ff75c2cc16f1c8/flet_desktop-0.28.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:6763b8e14863b0ee93e310720efa02202acae6d6ce8ff783663380f350f4f382", size = 47048073, upload-time = "2025-05-20T19:42:36.252Z" },
{ url = "https://files.pythonhosted.org/packages/34/e0/7a1486d8f71bca34ae928f5f8833d19ec0bda5ad5975e8db9e0543ae577b/flet_desktop-0.28.3-py3-none-macosx_12_0_arm64.whl", hash = "sha256:89797a387e743808733f308c7faa1158ab768966180c0fc4207f3e95d3b25db3", size = 47048072, upload-time = "2025-05-20T19:42:41.787Z" },
{ url = "https://files.pythonhosted.org/packages/c9/5f/85e74518b6ef0cebc6f7b1dfc63b523df821602745cf3a31a9607be18cf5/flet_desktop-0.28.3-py3-none-win32.whl", hash = "sha256:dc24f57ba725b974b4795b46e35f2b5348c4843f5117e9fc18b25c4abfa5caf4", size = 40265313, upload-time = "2025-05-20T19:39:19.761Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d0/c953ee675f2751f14329e23dbf538bb703c64ca1ad1c88e244ca17fe459e/flet_desktop-0.28.3-py3-none-win_amd64.whl", hash = "sha256:35db313302fd4c376ba9be4d43f953a5c67d1ba99180dc6afee702699ed14749", size = 40265319, upload-time = "2025-05-20T19:39:24.527Z" },
]
[[package]]
name = "flet-desktop-light"
version = "0.28.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flet" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/a9/3eb542246b49c40d39ba667c53f1f1f0c40f87b6b96d5542f29e2ae98eb3/flet_desktop_light-0.28.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:af6e53224bedd5c287c54ec3021ef3879abbba1265261a01e80badc3fbaddb86", size = 14978766, upload-time = "2025-05-20T19:31:54.681Z" },
{ url = "https://files.pythonhosted.org/packages/c6/06/a21078e117408519e4ea27f18d3826cf067d069d92e684a683261da1ab54/flet_desktop_light-0.28.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3dc473aae8d6cf4270e59bbb51e819068c343a49a8836cced2d17fb0359a7f5", size = 15579831, upload-time = "2025-05-20T19:32:11.698Z" },
{ url = "https://files.pythonhosted.org/packages/c0/83/195152cc264b37426c8595418d52d51a30ef55d55db9e1c3c22818c01e47/flet_desktop_light-0.28.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d45647d68ce0aaf5418c938e8e02f404bbb8814ece504f7242730520c3697c47", size = 14978756, upload-time = "2025-05-20T19:31:57.01Z" },
{ url = "https://files.pythonhosted.org/packages/07/03/6b44479989b55994c14a88c41b1bdc0635ee19509453d301b7caaff8e1af/flet_desktop_light-0.28.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3090ca7c07392c76bd47ef29f79744c7d114edda466be5f675abc5f65f1f5be7", size = 15579821, upload-time = "2025-05-20T19:32:13.836Z" },
]
[[package]]
name = "flet-web"
version = "0.28.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "flet" },
{ name = "uvicorn", extra = ["standard"] },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/8a/01f73ae7123090b2974f0c5ba70d7d9fc463f47f6e12daeca59fdc40e1a9/flet_web-0.28.3-py3-none-any.whl", hash = "sha256:919c13f374e7cee539d29a8ccd6decb739528ef257c88e60af4df1ebab045bfb", size = 3137744, upload-time = "2025-05-20T19:27:32.443Z" },
]
[[package]]
name = "fonttools"
version = "4.61.1"
@@ -1154,6 +1297,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -1892,7 +2064,7 @@ dependencies = [
{ name = "cryptography" },
{ name = "diart" },
{ name = "faster-whisper" },
{ name = "flet" },
{ name = "flet", extra = ["all"] },
{ name = "grpcio" },
{ name = "grpcio-tools" },
{ name = "keyring" },
@@ -1935,6 +2107,7 @@ triggers = [
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "ruff" },
{ name = "watchfiles" },
]
@@ -1949,7 +2122,7 @@ requires-dist = [
{ name = "diart", specifier = ">=0.9.2" },
{ name = "diart", marker = "extra == 'diarization'", specifier = ">=0.9.2" },
{ name = "faster-whisper", specifier = ">=1.0" },
{ name = "flet", specifier = ">=0.21" },
{ name = "flet", extras = ["all"], specifier = ">=0.21" },
{ name = "grpcio", specifier = ">=1.60" },
{ name = "grpcio-tools", specifier = ">=1.60" },
{ name = "keyring", specifier = ">=25.0" },
@@ -1980,6 +2153,7 @@ provides-extras = ["dev", "triggers", "summarization", "diarization"]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.36.1" },
{ name = "ruff", specifier = ">=0.14.9" },
{ name = "watchfiles", specifier = ">=1.1.1" },
]
@@ -5472,6 +5646,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" },
]
[[package]]
name = "pypng"
version = "0.20220715.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/93/cd/112f092ec27cca83e0516de0a3368dbd9128c187fb6b52aaaa7cde39c96d/pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1", size = 128992, upload-time = "2022-07-15T14:11:05.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", size = 58057, upload-time = "2022-07-15T14:11:03.713Z" },
]
[[package]]
name = "pyreadline3"
version = "3.5.4"
@@ -5559,6 +5742,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]]
name = "python-xlib"
version = "0.33"
@@ -5717,6 +5912,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "qrcode"
version = "7.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "pypng" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/35/ad6d4c5a547fe9a5baf85a9edbafff93fc6394b014fab30595877305fa59/qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845", size = 535974, upload-time = "2023-02-05T22:11:46.548Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/79/aaf0c1c7214f2632badb2771d770b1500d3d7cbdf2590ae62e721ec50584/qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", size = 46197, upload-time = "2023-02-05T22:11:43.4Z" },
]
[[package]]
name = "repath"
version = "0.9.0"
@@ -6157,6 +6366,19 @@ asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
@@ -6208,6 +6430,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -6242,6 +6473,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "torch"
version = "2.9.1"
@@ -6492,6 +6732,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchdog"
version = "4.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/38/764baaa25eb5e35c9a043d4c4588f9836edfe52a708950f4b6d5f714fd42/watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270", size = 126587, upload-time = "2024-08-11T07:38:01.623Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/f5/ea22b095340545faea37ad9a42353b265ca751f543da3fb43f5d00cdcd21/watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a", size = 100342, upload-time = "2024-08-11T07:37:16.393Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d2/8ce97dff5e465db1222951434e3115189ae54a9863aef99c6987890cc9ef/watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29", size = 92306, upload-time = "2024-08-11T07:37:17.997Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/1aeba2c31b25f79b03b15918155bc8c0b08101054fc727900f1a577d0d54/watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a", size = 92915, upload-time = "2024-08-11T07:37:19.967Z" },
{ url = "https://files.pythonhosted.org/packages/79/63/eb8994a182672c042d85a33507475c50c2ee930577524dd97aea05251527/watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b", size = 100343, upload-time = "2024-08-11T07:37:21.935Z" },
{ url = "https://files.pythonhosted.org/packages/ce/82/027c0c65c2245769580605bcd20a1dc7dfd6c6683c8c4e2ef43920e38d27/watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d", size = 92313, upload-time = "2024-08-11T07:37:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/2a/89/ad4715cbbd3440cb0d336b78970aba243a33a24b1a79d66f8d16b4590d6a/watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7", size = 92919, upload-time = "2024-08-11T07:37:24.715Z" },
{ url = "https://files.pythonhosted.org/packages/8a/b1/25acf6767af6f7e44e0086309825bd8c098e301eed5868dc5350642124b9/watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83", size = 82947, upload-time = "2024-08-11T07:37:45.388Z" },
{ url = "https://files.pythonhosted.org/packages/e8/90/aebac95d6f954bd4901f5d46dcd83d68e682bfd21798fd125a95ae1c9dbf/watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c", size = 82942, upload-time = "2024-08-11T07:37:46.722Z" },
{ url = "https://files.pythonhosted.org/packages/15/3a/a4bd8f3b9381824995787488b9282aff1ed4667e1110f31a87b871ea851c/watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a", size = 82947, upload-time = "2024-08-11T07:37:48.941Z" },
{ url = "https://files.pythonhosted.org/packages/09/cc/238998fc08e292a4a18a852ed8274159019ee7a66be14441325bcd811dfd/watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73", size = 82946, upload-time = "2024-08-11T07:37:50.279Z" },
{ url = "https://files.pythonhosted.org/packages/80/f1/d4b915160c9d677174aa5fae4537ae1f5acb23b3745ab0873071ef671f0a/watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc", size = 82947, upload-time = "2024-08-11T07:37:51.55Z" },
{ url = "https://files.pythonhosted.org/packages/db/02/56ebe2cf33b352fe3309588eb03f020d4d1c061563d9858a9216ba004259/watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757", size = 82944, upload-time = "2024-08-11T07:37:52.855Z" },
{ url = "https://files.pythonhosted.org/packages/01/d2/c8931ff840a7e5bd5dcb93f2bb2a1fd18faf8312e9f7f53ff1cf76ecc8ed/watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8", size = 82947, upload-time = "2024-08-11T07:37:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d8/cdb0c21a4a988669d7c210c75c6a2c9a0e16a3b08d9f7e633df0d9a16ad8/watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19", size = 82935, upload-time = "2024-08-11T07:37:56.668Z" },
{ url = "https://files.pythonhosted.org/packages/99/2e/b69dfaae7a83ea64ce36538cc103a3065e12c447963797793d5c0a1d5130/watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b", size = 82934, upload-time = "2024-08-11T07:37:57.991Z" },
{ url = "https://files.pythonhosted.org/packages/b0/0b/43b96a9ecdd65ff5545b1b13b687ca486da5c6249475b1a45f24d63a1858/watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c", size = 82933, upload-time = "2024-08-11T07:37:59.573Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
@@ -6580,6 +6900,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/9e/510086a9ed0dee3830da838f9207f5c787487813d5eb74eb19fe306e6a3e/websocket_server-0.6.4-py3-none-any.whl", hash = "sha256:aca2d8f7569c82fe3e949cbae1f9d3f3035ae15f1d4048085431c94b7dfd35be", size = 7534, upload-time = "2021-12-19T16:34:34.597Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "wrapt"
version = "2.0.1"