Add Settings screen with YAML configuration

- New Settings screen (press 'x') for configuring app settings
- Settings stored in data/settings.yaml (user-editable)
- Editable fields: team abbrev, season, API URL, API key (masked), theme
- Team validation against database before saving
- Read-only team info display from database (name, sWAR cap, ID)
- Load order: defaults -> .env -> settings.yaml
- Added pyyaml dependency
This commit is contained in:
Cal Corum 2026-01-27 16:24:28 -06:00
parent 3c76ce1cf0
commit 9c59c79c98
5 changed files with 613 additions and 9 deletions

View File

@ -9,6 +9,7 @@ dependencies = [
"httpx>=0.28.1",
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
"pyyaml>=6.0",
"sqlalchemy>=2.0.46",
"textual>=7.3.0",
]

View File

@ -21,6 +21,7 @@ from .screens.gameday import GamedayScreen
from .screens.lineup import LineupScreen
from .screens.matchup import MatchupScreen
from .screens.roster import RosterScreen
from .screens.settings import SettingsScreen
# Configure logging
logging.basicConfig(
@ -39,6 +40,7 @@ class DashboardScreen(Screen):
Binding("g", "switch_screen('gameday')", "Gameday"),
Binding("l", "switch_screen('lineup')", "Lineup Builder"),
Binding("t", "switch_screen('transactions')", "Transactions"),
Binding("x", "switch_screen('settings')", "Settings"),
Binding("s", "sync_data", "Sync Data"),
Binding("q", "app.quit", "Quit"),
]
@ -99,6 +101,7 @@ class DashboardScreen(Screen):
with Horizontal(id="status-bar"):
yield Label("Last sync: Never", id="sync-status")
yield Button("Sync Now [s]", id="btn-sync", variant="success")
yield Button("Settings [x]", id="btn-settings", variant="default")
yield Footer()
@ -176,6 +179,11 @@ class DashboardScreen(Screen):
"""Sync data from league API."""
await self.action_sync_data()
@on(Button.Pressed, "#btn-settings")
def on_settings(self) -> None:
"""Navigate to settings screen."""
self.app.push_screen("settings")
async def action_sync_data(self) -> None:
"""Sync data from the league API."""
from .api.sync import sync_all
@ -559,6 +567,61 @@ class SBAScoutApp(App):
#lineup-save-controls Button {
width: auto;
}
/* Settings Screen Styles */
#settings-container {
padding: 1 2;
}
.settings-section {
margin-bottom: 2;
padding: 1;
border: solid $primary;
}
.settings-section .section-title {
text-style: bold;
margin-bottom: 1;
}
.setting-row {
height: 3;
margin-bottom: 1;
}
.setting-label {
width: 15;
content-align: left middle;
}
.setting-row Input {
width: 1fr;
}
.setting-row Select {
width: 1fr;
}
.setting-row Button {
width: auto;
margin-left: 1;
}
.info-display {
margin-top: 1;
padding: 1;
background: $surface;
height: auto;
}
#settings-buttons {
margin-top: 2;
height: 3;
}
#settings-buttons Button {
margin-right: 1;
}
"""
SCREENS = {
@ -568,6 +631,7 @@ class SBAScoutApp(App):
"gameday": GamedayScreen,
"lineup": LineupScreen,
"transactions": TransactionsScreen,
"settings": SettingsScreen,
}
BINDINGS: ClassVar = [

View File

@ -2,16 +2,75 @@
Configuration management for SBA Scout.
Uses pydantic-settings for environment variable support and type validation.
Rating weights can be modified via config file or environment variables.
User settings are stored in data/settings.yaml for easy editing.
Load order:
1. Pydantic defaults
2. .env file (for backwards compatibility / CI overrides)
3. data/settings.yaml (user-editable, takes precedence)
"""
from pathlib import Path
from typing import Self
import yaml
from pydantic import BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# =============================================================================
# User Settings (saved to YAML)
# =============================================================================
class UserAPISettings(BaseModel):
"""User-configurable API settings."""
base_url: str = Field(
default="https://sba.manticorum.com/", description="Base URL for the league API"
)
api_key: str = Field(default="", description="API key for authentication")
timeout: int = Field(default=30, description="Request timeout in seconds")
class UserTeamSettings(BaseModel):
"""User-configurable team settings.
Only abbrev and season are user-editable.
Other team data (name, sWAR cap, roster slots) comes from the database.
"""
abbrev: str = Field(default="WV", description="Your team's abbreviation")
season: int = Field(default=13, description="Current season number")
class UserUISettings(BaseModel):
"""User-configurable UI settings."""
theme: str = Field(default="dark", description="UI theme (dark or light)")
refresh_interval: int = Field(
default=300, description="Auto-refresh interval in seconds (0 to disable)"
)
class UserSettings(BaseModel):
"""
User-editable settings saved to data/settings.yaml.
These settings can be modified via the Settings screen in the app
or by editing the YAML file directly.
"""
api: UserAPISettings = Field(default_factory=UserAPISettings)
team: UserTeamSettings = Field(default_factory=UserTeamSettings)
ui: UserUISettings = Field(default_factory=UserUISettings)
# =============================================================================
# Rating Weights (kept for backwards compatibility)
# =============================================================================
class RatingWeights(BaseModel):
"""
Configurable weights for calculating composite batter ratings.
@ -71,6 +130,11 @@ class RatingWeights(BaseModel):
)
# =============================================================================
# Legacy Settings Classes (for .env compatibility)
# =============================================================================
class APISettings(BaseModel):
"""Settings for the SBA League API."""
@ -80,17 +144,27 @@ class APISettings(BaseModel):
class TeamSettings(BaseModel):
"""Settings for the user's team."""
"""Settings for the user's team.
team_id: int = Field(default=548, description="Your team's ID in the league")
Note: team_abbrev and current_season are the primary user settings.
Other fields (team_id, team_name, swar_cap, slots) are loaded from
the database after syncing.
"""
team_id: int = Field(default=0, description="Your team's ID in the league (from DB)")
team_abbrev: str = Field(default="WV", description="Your team's abbreviation")
team_name: str = Field(default="West Virginia Black Bears", description="Full team name")
team_name: str = Field(default="", description="Full team name (from DB)")
current_season: int = Field(default=13, description="Current season number")
swar_cap: float = Field(default=29.5, description="Season sWAR cap for roster")
minor_league_slots: int = Field(default=5, description="Number of minor league roster slots")
swar_cap: float = Field(default=0.0, description="Season sWAR cap for roster (from DB)")
minor_league_slots: int = Field(default=6, description="Number of minor league roster slots")
major_league_slots: int = Field(default=26, description="Number of major league roster slots")
# =============================================================================
# Main Settings Class
# =============================================================================
class Settings(BaseSettings):
"""
Main application settings.
@ -98,11 +172,11 @@ class Settings(BaseSettings):
Settings can be loaded from:
1. Environment variables (prefixed with SBA_SCOUT_)
2. .env file in the project root
3. config.json file in the data directory
3. data/settings.yaml file (user-editable, takes precedence)
Environment variables use double underscore for nested settings:
- SBA_SCOUT_TEAM__TEAM_ID=548
- SBA_SCOUT_RATING_WEIGHTS__HIT=1.2
- SBA_SCOUT_TEAM__TEAM_ABBREV=WV
- SBA_SCOUT_API__BASE_URL=https://...
"""
model_config = SettingsConfigDict(
@ -139,6 +213,10 @@ class Settings(BaseSettings):
"""Get SQLAlchemy database URL."""
return f"sqlite+aiosqlite:///{self.db_path}"
def get_settings_yaml_path(self) -> Path:
"""Get the path to the user settings YAML file."""
return self.db_path.parent / "settings.yaml"
def save_rating_weights(self, weights: RatingWeights) -> None:
"""
Save updated rating weights to a config file.
@ -165,6 +243,114 @@ class Settings(BaseSettings):
return self.rating_weights
# =============================================================================
# YAML Settings Management
# =============================================================================
def get_default_user_settings() -> UserSettings:
"""Get default user settings."""
return UserSettings()
def load_user_settings_yaml(settings_path: Path) -> UserSettings | None:
"""
Load user settings from YAML file.
Returns None if file doesn't exist.
"""
if not settings_path.exists():
return None
try:
with open(settings_path, "r") as f:
data = yaml.safe_load(f)
if data is None:
return None
return UserSettings(**data)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Failed to load settings.yaml: {e}")
return None
def save_user_settings_yaml(user_settings: UserSettings, settings_path: Path) -> None:
"""
Save user settings to YAML file.
Creates the parent directory if it doesn't exist.
"""
settings_path.parent.mkdir(parents=True, exist_ok=True)
# Add a header comment
header = """# SBA Scout User Settings
# Edit this file or use the Settings screen in the app
# Changes take effect immediately when saved via the app
"""
yaml_content = yaml.dump(
user_settings.model_dump(),
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
with open(settings_path, "w") as f:
f.write(header + yaml_content)
def apply_user_settings_to_settings(settings: Settings, user_settings: UserSettings) -> None:
"""
Apply user settings from YAML to the main Settings object.
This merges user settings on top of defaults/.env values.
"""
# API settings
settings.api.base_url = user_settings.api.base_url
settings.api.api_key = user_settings.api.api_key
settings.api.timeout = user_settings.api.timeout
# Team settings (only abbrev and season from user settings)
settings.team.team_abbrev = user_settings.team.abbrev
settings.team.current_season = user_settings.team.season
# UI settings
settings.theme = user_settings.ui.theme
settings.refresh_interval = user_settings.ui.refresh_interval
def get_user_settings_from_settings(settings: Settings) -> UserSettings:
"""
Extract user-editable settings from the main Settings object.
Used when saving settings to YAML.
"""
return UserSettings(
api=UserAPISettings(
base_url=settings.api.base_url,
api_key=settings.api.api_key,
timeout=settings.api.timeout,
),
team=UserTeamSettings(
abbrev=settings.team.team_abbrev,
season=settings.team.current_season,
),
ui=UserUISettings(
theme=settings.theme,
refresh_interval=settings.refresh_interval,
),
)
# =============================================================================
# Global Settings Instance
# =============================================================================
# Global settings instance - lazy loaded
_settings: Settings | None = None
@ -174,8 +360,16 @@ def get_settings() -> Settings:
global _settings
if _settings is None:
_settings = Settings()
# Load any saved rating weights
_settings.rating_weights = _settings.load_rating_weights()
# Load user settings from YAML if it exists
yaml_path = _settings.get_settings_yaml_path()
user_settings = load_user_settings_yaml(yaml_path)
if user_settings:
apply_user_settings_to_settings(_settings, user_settings)
return _settings

View File

@ -0,0 +1,297 @@
"""
Settings Screen - Configure application settings.
Allows users to:
- Set team abbreviation and season
- Configure API connection (base URL, API key)
- Adjust UI preferences (theme)
- View team info loaded from database
Settings are saved to data/settings.yaml and apply immediately.
"""
import logging
from typing import ClassVar, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, ScrollableContainer
from textual.screen import Screen
from textual.widgets import (
Button,
Footer,
Header,
Input,
Label,
Select,
Static,
)
from ..config import (
UserSettings,
UserAPISettings,
UserTeamSettings,
UserUISettings,
get_settings,
get_user_settings_from_settings,
reload_settings,
save_user_settings_yaml,
)
from ..db.queries import get_team_by_abbrev
from ..db.schema import get_session
logger = logging.getLogger(__name__)
class SettingsScreen(Screen):
"""Settings configuration screen."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
Binding("ctrl+s", "save_settings", "Save"),
]
# Track if API key is visible
_api_key_visible: bool = False
def compose(self) -> ComposeResult:
"""Compose the settings layout."""
settings = get_settings()
yield Header()
with ScrollableContainer(id="settings-container"):
# Team Settings Section
with Vertical(classes="settings-section"):
yield Label("Team", classes="section-title")
with Horizontal(classes="setting-row"):
yield Label("Abbreviation:", classes="setting-label")
yield Input(
value=settings.team.team_abbrev,
placeholder="e.g., WV",
id="team-abbrev-input",
max_length=10,
)
with Horizontal(classes="setting-row"):
yield Label("Season:", classes="setting-label")
yield Input(
value=str(settings.team.current_season),
placeholder="e.g., 13",
id="team-season-input",
max_length=4,
)
# Team info display (read-only, from database)
yield Static("", id="team-info-display", classes="info-display")
# API Settings Section
with Vertical(classes="settings-section"):
yield Label("API", classes="section-title")
with Horizontal(classes="setting-row"):
yield Label("Base URL:", classes="setting-label")
yield Input(
value=settings.api.base_url,
placeholder="https://sba.manticorum.com/",
id="api-url-input",
)
with Horizontal(classes="setting-row"):
yield Label("API Key:", classes="setting-label")
yield Input(
value=settings.api.api_key,
placeholder="Enter API key",
id="api-key-input",
password=True,
)
yield Button("Show", id="btn-toggle-key", variant="default")
# UI Settings Section
with Vertical(classes="settings-section"):
yield Label("UI", classes="section-title")
with Horizontal(classes="setting-row"):
yield Label("Theme:", classes="setting-label")
yield Select(
options=[
("Dark", "dark"),
("Light", "light"),
],
value=settings.theme,
id="theme-select",
allow_blank=False,
)
# Action Buttons
with Horizontal(id="settings-buttons"):
yield Button("Save", id="btn-save", variant="success")
yield Button("Reset to Defaults", id="btn-reset", variant="warning")
yield Footer()
async def on_mount(self) -> None:
"""Load team info when screen mounts."""
await self._refresh_team_info()
async def _refresh_team_info(self) -> None:
"""Load and display team info from database."""
abbrev_input = self.query_one("#team-abbrev-input", Input)
season_input = self.query_one("#team-season-input", Input)
info_display = self.query_one("#team-info-display", Static)
abbrev = abbrev_input.value.strip().upper()
try:
season = int(season_input.value.strip())
except ValueError:
info_display.update("[yellow]Enter a valid season number[/yellow]")
return
if not abbrev:
info_display.update("[yellow]Enter team abbreviation[/yellow]")
return
try:
async with get_session() as session:
team = await get_team_by_abbrev(session, abbrev, season)
if team:
info_display.update(
f"[green]Team found:[/green] {team.long_name}\n"
f"sWAR Cap: {team.salary_cap or 'N/A'} | "
f"ID: {team.id}"
)
else:
info_display.update(
f"[red]Team not found:[/red] {abbrev} (Season {season})\n"
"Run Sync from dashboard to load team data"
)
except Exception as e:
logger.error(f"Failed to load team info: {e}")
info_display.update(f"[red]Error loading team:[/red] {e}")
def _get_current_values(self) -> dict:
"""Get current values from all input fields."""
return {
"team_abbrev": self.query_one("#team-abbrev-input", Input).value.strip().upper(),
"team_season": self.query_one("#team-season-input", Input).value.strip(),
"api_url": self.query_one("#api-url-input", Input).value.strip(),
"api_key": self.query_one("#api-key-input", Input).value,
"theme": self.query_one("#theme-select", Select).value,
}
async def _validate_team(self) -> Optional[str]:
"""
Validate that the team exists in the database.
Returns error message if invalid, None if valid.
"""
values = self._get_current_values()
abbrev = values["team_abbrev"]
if not abbrev:
return "Team abbreviation is required"
try:
season = int(values["team_season"])
except ValueError:
return "Season must be a number"
try:
async with get_session() as session:
team = await get_team_by_abbrev(session, abbrev, season)
if not team:
return f"Team '{abbrev}' not found for season {season}. Run Sync first."
return None
except Exception as e:
return f"Error validating team: {e}"
# =========================================================================
# Event Handlers
# =========================================================================
async def on_input_changed(self, event: Input.Changed) -> None:
"""Refresh team info when team inputs change."""
if event.input.id in ("team-abbrev-input", "team-season-input"):
await self._refresh_team_info()
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-save":
await self.action_save_settings()
elif event.button.id == "btn-reset":
await self._reset_to_defaults()
elif event.button.id == "btn-toggle-key":
self._toggle_api_key_visibility()
def _toggle_api_key_visibility(self) -> None:
"""Toggle API key visibility."""
api_key_input = self.query_one("#api-key-input", Input)
toggle_btn = self.query_one("#btn-toggle-key", Button)
self._api_key_visible = not self._api_key_visible
api_key_input.password = not self._api_key_visible
toggle_btn.label = "Hide" if self._api_key_visible else "Show"
async def _reset_to_defaults(self) -> None:
"""Reset all settings to defaults."""
default_settings = UserSettings()
# Update inputs
self.query_one("#team-abbrev-input", Input).value = default_settings.team.abbrev
self.query_one("#team-season-input", Input).value = str(default_settings.team.season)
self.query_one("#api-url-input", Input).value = default_settings.api.base_url
self.query_one("#api-key-input", Input).value = default_settings.api.api_key
self.query_one("#theme-select", Select).value = default_settings.ui.theme
await self._refresh_team_info()
self.notify("Settings reset to defaults (not saved yet)")
# =========================================================================
# Actions
# =========================================================================
async def action_save_settings(self) -> None:
"""Save settings to YAML file."""
# Validate team first
error = await self._validate_team()
if error:
self.notify(error, severity="error")
return
values = self._get_current_values()
# Build user settings object
user_settings = UserSettings(
api=UserAPISettings(
base_url=values["api_url"],
api_key=values["api_key"],
timeout=30,
),
team=UserTeamSettings(
abbrev=values["team_abbrev"],
season=int(values["team_season"]),
),
ui=UserUISettings(
theme=values["theme"],
refresh_interval=300,
),
)
try:
# Save to YAML
settings = get_settings()
yaml_path = settings.get_settings_yaml_path()
save_user_settings_yaml(user_settings, yaml_path)
# Reload settings to apply changes
reload_settings()
self.notify(f"Settings saved to {yaml_path.name}", severity="information")
except Exception as e:
logger.error(f"Failed to save settings: {e}")
self.notify(f"Error saving settings: {e}", severity="error")

48
uv.lock generated
View File

@ -308,6 +308,52 @@ 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 = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ 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 = "rich"
version = "14.3.1"
@ -330,6 +376,7 @@ dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
{ name = "textual" },
]
@ -340,6 +387,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "sqlalchemy", specifier = ">=2.0.46" },
{ name = "textual", specifier = ">=7.3.0" },
]