From c0112c2714bd4a9affc45700488cec075dda4166 Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:14:49 -0500 Subject: [PATCH] Add ability to edit safe .env.example options to Settings --- docs/roadmap_plan.md | 4 + docs/ui/pages/settings.md | 12 +- src/transcription/ui/pages/settings_page.py | 76 ++- .../ui/runtime_settings_store.py | 465 ++++++++++++++++++ tests/ui/test_pages_registration.py | 1 + tests/ui/test_runtime_settings_store.py | 76 +++ 6 files changed, 630 insertions(+), 4 deletions(-) create mode 100644 src/transcription/ui/runtime_settings_store.py create mode 100644 tests/ui/test_runtime_settings_store.py diff --git a/docs/roadmap_plan.md b/docs/roadmap_plan.md index 99f1151..bbdef3e 100644 --- a/docs/roadmap_plan.md +++ b/docs/roadmap_plan.md @@ -44,6 +44,10 @@ Objective: improve research value with person-centric outputs. ## V6.2 - Access Control and Multi-User Readiness +[ *More thoughts on user accounts:* +* *Create a generic "view only" user that does not have the rights to alter any of the data* +* *Limit user accounts access to data by Tag. I have distant family members that I would want to share the transcribed data with, but they would only be interested in a subset of it. For example my Cochran cousins would have no interest in Lancaster documents, so limit the Cochra Clan cousins to view-only access to documents tagged "cochran clan"* ] + Objective: prepare for managed collaboration beyond single-user operation. ### Scope diff --git a/docs/ui/pages/settings.md b/docs/ui/pages/settings.md index f520603..0048711 100644 --- a/docs/ui/pages/settings.md +++ b/docs/ui/pages/settings.md @@ -2,35 +2,41 @@ ## Purpose -Settings manages installation-local registries and editable text assets from one route. +Settings manages installation-local registries, safe runtime .env settings, and editable text assets from one route. ## Route | Route | Purpose | | --- | --- | -| `/settings` | Manage Document Types, Person Roles, Tags, Prompts, and Home Page Text. | +| `/settings` | Manage Runtime Settings, Document Types, Person Roles, Tags, Prompts, and Home Page Text. | ## Behavior - The page title is **Settings**. - Configuration surfaces are grouped as tabs: + - **Runtime Settings** - **Document Types** - **Person Roles** - **Tags** - **Prompts** - **Home Page Text** +- Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields. +- Runtime Settings persists changes to `.env`, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter. +- Runtime Settings changes require application restart to take effect. - Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails. - Prompts exposes only `transcribe_document.md` for editing and restore-from-backup. - Home Page Text edits the same Markdown content rendered on `/homepage`. ## Acceptance Checklist -- `/ui/settings` renders all five tabs. +- `/ui/settings` renders all six tabs. - Registry and prompt workflows keep existing validation and error handling. +- Runtime Settings excludes secret fields and rejects invalid values. - Saving Home Page Text persists content for the homepage view. ## Implementation Anchors - `src/transcription/ui/pages/settings_page.py` +- `src/transcription/ui/runtime_settings_store.py` - `src/transcription/ui/homepage_store.py` - `tests/ui/test_pages_registration.py` diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py index 1759396..b913990 100644 --- a/src/transcription/ui/pages/settings_page.py +++ b/src/transcription/ui/pages/settings_page.py @@ -21,6 +21,8 @@ from transcription.ui.components.primitives import section_header_row from transcription.ui.components.table.registry import render_registry_table from transcription.ui.homepage_store import read_homepage_markdown from transcription.ui.homepage_store import save_homepage_markdown +from transcription.ui.runtime_settings_store import read_runtime_settings_snapshot +from transcription.ui.runtime_settings_store import save_runtime_settings from transcription.ui.theme import page_header from ...db.session import SessionFactoryDep @@ -497,14 +499,78 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 with ui.row().classes("items-center gap-2"): ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary") + @ui.refreshable + async def render_runtime_settings() -> None: + with archival_card("Runtime Settings"): + ui.label("Edit non-secret .env settings. Secret fields are intentionally excluded.").classes( + "text-xs ui-text-muted" + ) + ui.label("Changes are persisted to .env and apply after restart.").classes( + "text-xs ui-text-muted mb-3" + ) + snapshot_outcome = await run_ui_action( + operation="settings.runtime.read", + title="Runtime settings unavailable", + action=lambda: _read_runtime_settings_snapshot(settings), + ) + if not snapshot_outcome.ok or snapshot_outcome.value is None: + return + snapshot = snapshot_outcome.value + field_controls: dict[str, Any] = {} + for field in snapshot.fields: + with ui.column().classes("w-full gap-1 py-2 ui-header-divider"): + ui.label(field.label).classes("text-sm font-semibold") + ui.label(field.description).classes("text-xs ui-text-muted") + if field.control == "bool": + control = ui.checkbox(field.env_key, value=bool(field.value)) + elif field.control == "select": + control = ( + ui.select( + list(field.options), + label=field.env_key, + value=str(field.value), + ) + .props("outlined") + .classes("w-full") + ) + else: + control = ( + ui.input(field.env_key, value=str(field.value)).props("outlined").classes("w-full") + ) + field_controls[field.field_name] = control + + async def save_runtime() -> None: + updates: dict[str, str | bool] = {} + for field in snapshot.fields: + control = field_controls[field.field_name] + if field.control == "bool": + updates[field.field_name] = bool(control.value) + else: + updates[field.field_name] = str(control.value or "") + save_outcome = await run_ui_action( + operation="settings.runtime.write", + title="Runtime settings save failed", + action=lambda: _write_runtime_settings(settings=settings, updates=updates), + ) + if not save_outcome.ok: + return + ui.notify("Runtime settings saved to .env", type="positive") + render_runtime_settings.refresh() + + with ui.row().classes("items-center gap-2 mt-3"): + ui.button("Save runtime settings", icon="save", on_click=save_runtime).classes("ui-btn-primary") + with ui.tabs().classes("w-full") as tabs: + runtime_settings_tab = ui.tab("Runtime Settings") document_types_tab = ui.tab("Document Types") person_roles_tab = ui.tab("Person Roles") tags_tab = ui.tab("Tags") prompts_tab = ui.tab("Prompts") home_page_text_tab = ui.tab("Home Page Text") - with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"): + with ui.tab_panels(tabs, value=runtime_settings_tab).classes("w-full"): + with ui.tab_panel(runtime_settings_tab): + await render_runtime_settings() with ui.tab_panel(document_types_tab): await render_document_types() with ui.tab_panel(person_roles_tab): @@ -546,3 +612,11 @@ async def _read_home_page_text(settings: Settings) -> str: async def _write_home_page_text(settings: Settings, markdown_text: str) -> None: await run_blocking(save_homepage_markdown, markdown_text, settings=settings) + + +async def _read_runtime_settings_snapshot(settings: Settings): + return await run_blocking(read_runtime_settings_snapshot, settings=settings) + + +async def _write_runtime_settings(*, settings: Settings, updates: dict[str, str | bool]): + return await run_blocking(save_runtime_settings, settings=settings, updates=updates) diff --git a/src/transcription/ui/runtime_settings_store.py b/src/transcription/ui/runtime_settings_store.py new file mode 100644 index 0000000..80d5e85 --- /dev/null +++ b/src/transcription/ui/runtime_settings_store.py @@ -0,0 +1,465 @@ +"""Safe runtime settings catalog and .env persistence helpers.""" + +from __future__ import annotations + +import re +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from pydantic import ValidationError + +from transcription.config import Provider +from transcription.config import Settings +from transcription.errors import AppError +from transcription.errors import ErrorCategory + +FieldControl = Literal["text", "bool", "select"] + + +@dataclass(frozen=True, slots=True) +class RuntimeSettingField: + """UI metadata and value for a safe, editable runtime setting.""" + + field_name: str + env_key: str + label: str + description: str + control: FieldControl + value: str | bool + options: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class RuntimeSettingsSnapshot: + """Typed snapshot of runtime settings editable in UI.""" + + env_file_path: Path + fields: tuple[RuntimeSettingField, ...] + + +@dataclass(frozen=True, slots=True) +class RuntimeSettingDescriptor: + field_name: str + env_key: str + label: str + description: str + control: FieldControl + options: tuple[str, ...] = () + + +SETTINGS_UI_EXCLUDED_FIELDS = frozenset( + { + "openrouter_api_key", + "database", + } +) + +RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = ( + RuntimeSettingDescriptor("host", "HOST", "Host", "Server bind host.", "text"), + RuntimeSettingDescriptor("port", "PORT", "Port", "Server bind port.", "text"), + RuntimeSettingDescriptor( + "log_level", + "LOG_LEVEL", + "Log level", + "Application log verbosity.", + "select", + options=("critical", "error", "warning", "info", "debug", "trace"), + ), + RuntimeSettingDescriptor("reload", "RELOAD", "Reload", "Auto-reload on code changes.", "bool"), + RuntimeSettingDescriptor("log_dir", "LOG_DIR", "Log directory", "Directory for rotating log files.", "text"), + RuntimeSettingDescriptor( + "log_file_name", + "LOG_FILE_NAME", + "Log file name", + "Active rotating log filename.", + "text", + ), + RuntimeSettingDescriptor( + "log_file_max_bytes", + "LOG_FILE_MAX_BYTES", + "Log file max bytes", + "Maximum bytes per log file before rotation.", + "text", + ), + RuntimeSettingDescriptor( + "log_file_backup_count", + "LOG_FILE_BACKUP_COUNT", + "Log file backup count", + "Number of rotated log files to keep.", + "text", + ), + RuntimeSettingDescriptor( + "provider", + "PROVIDER", + "Provider", + "Transcription provider key.", + "select", + options=tuple(member.value for member in Provider), + ), + RuntimeSettingDescriptor( + "provider_model", + "PROVIDER_MODEL", + "Provider model", + "Default model id used for transcription jobs.", + "text", + ), + RuntimeSettingDescriptor( + "provider_models", + "PROVIDER_MODELS", + "Provider models (JSON array)", + "Allowed model ids as a JSON array.", + "text", + ), + RuntimeSettingDescriptor( + "openrouter_http_referer", + "OPENROUTER_HTTP_REFERER", + "OpenRouter HTTP referer", + "Optional header value sent to OpenRouter.", + "text", + ), + RuntimeSettingDescriptor( + "openrouter_app_title", + "OPENROUTER_APP_TITLE", + "OpenRouter app title", + "Optional app title sent to OpenRouter.", + "text", + ), + RuntimeSettingDescriptor( + "default_prompt_name", + "DEFAULT_PROMPT_NAME", + "Default prompt file", + "Prompt filename used for new transcription requests.", + "text", + ), + RuntimeSettingDescriptor( + "transcription_temperature", + "TRANSCRIPTION_TEMPERATURE", + "Temperature", + "Optional sampling temperature (0.0 to 2.0).", + "text", + ), + RuntimeSettingDescriptor( + "transcription_top_p", + "TRANSCRIPTION_TOP_P", + "Top P", + "Optional nucleus sampling probability (0.0 to 1.0).", + "text", + ), + RuntimeSettingDescriptor( + "environment", + "ENVIRONMENT", + "Environment", + "Runtime environment mode.", + "select", + options=("development", "test", "production"), + ), + RuntimeSettingDescriptor( + "transcription_commit", + "TRANSCRIPTION_COMMIT", + "Transcription commit", + "Optional build or commit identifier for evidence provenance.", + "text", + ), + RuntimeSettingDescriptor( + "bootstrap_schema_on_startup", + "BOOTSTRAP_SCHEMA_ON_STARTUP", + "Bootstrap schema on startup", + "Create schema on startup when enabled.", + "bool", + ), + RuntimeSettingDescriptor( + "sqlite_check_same_thread", + "SQLITE_CHECK_SAME_THREAD", + "SQLite check same thread", + "SQLite engine same-thread flag.", + "bool", + ), + RuntimeSettingDescriptor( + "upload_dir", + "UPLOAD_DIR", + "Upload directory", + "Root directory for uploaded media and generated files.", + "text", + ), + RuntimeSettingDescriptor( + "prompt_dir", + "PROMPT_DIR", + "Prompt directory", + "Directory containing editable markdown prompts.", + "text", + ), + RuntimeSettingDescriptor( + "database_backup_dir", + "DATABASE_BACKUP_DIR", + "Database backup directory", + "Directory for generated database backups.", + "text", + ), + RuntimeSettingDescriptor( + "worker_max_retries", + "WORKER_MAX_RETRIES", + "Worker max retries", + "Maximum retries per source attempt.", + "text", + ), + RuntimeSettingDescriptor( + "worker_provider_timeout_seconds", + "WORKER_PROVIDER_TIMEOUT_SECONDS", + "Worker provider timeout seconds", + "Provider call timeout budget in seconds.", + "text", + ), + RuntimeSettingDescriptor( + "worker_stale_job_seconds", + "WORKER_STALE_JOB_SECONDS", + "Worker stale job seconds", + "Seconds before queued jobs are considered stale.", + "text", + ), + RuntimeSettingDescriptor( + "worker_retry_backoff_seconds", + "WORKER_RETRY_BACKOFF_SECONDS", + "Worker retry backoff seconds", + "Base backoff delay between retries.", + "text", + ), + RuntimeSettingDescriptor( + "worker_shutdown_grace_seconds", + "WORKER_SHUTDOWN_GRACE_SECONDS", + "Worker shutdown grace seconds", + "Grace period before forced worker shutdown.", + "text", + ), + RuntimeSettingDescriptor( + "worker_poll_interval_seconds", + "WORKER_POLL_INTERVAL_SECONDS", + "Worker poll interval seconds", + "Worker polling interval for queued jobs.", + "text", + ), + RuntimeSettingDescriptor( + "worker_min_transcription_chars", + "WORKER_MIN_TRANSCRIPTION_CHARS", + "Worker min transcription chars", + "Minimum character threshold for successful transcription.", + "text", + ), + RuntimeSettingDescriptor( + "worker_min_transcription_lines", + "WORKER_MIN_TRANSCRIPTION_LINES", + "Worker min transcription lines", + "Minimum line threshold for successful transcription.", + "text", + ), + RuntimeSettingDescriptor( + "worker_fail_on_finish_reason_length", + "WORKER_FAIL_ON_FINISH_REASON_LENGTH", + "Fail on finish reason length", + "Treat provider finish_reason=length as failure when enabled.", + "bool", + ), +) + + +def settings_catalog_field_names() -> frozenset[str]: + return frozenset(item.field_name for item in RUNTIME_SETTINGS_CATALOG) + + +def read_runtime_settings_snapshot(*, settings: Settings, env_file_path: Path | None = None) -> RuntimeSettingsSnapshot: + resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path) + fields: list[RuntimeSettingField] = [] + for descriptor in RUNTIME_SETTINGS_CATALOG: + value = getattr(settings, descriptor.field_name) + fields.append( + RuntimeSettingField( + field_name=descriptor.field_name, + env_key=descriptor.env_key, + label=descriptor.label, + description=descriptor.description, + control=descriptor.control, + value=_display_value(value), + options=descriptor.options, + ) + ) + return RuntimeSettingsSnapshot( + env_file_path=resolved_env_path, + fields=tuple(fields), + ) + + +def save_runtime_settings( + *, + settings: Settings, + updates: dict[str, str | bool], + env_file_path: Path | None = None, +) -> RuntimeSettingsSnapshot: + descriptors = {item.field_name: item for item in RUNTIME_SETTINGS_CATALOG} + unknown_keys = sorted(set(updates) - set(descriptors)) + if unknown_keys: + raise AppError( + "One or more settings fields cannot be edited from this page.", + category=ErrorCategory.VALIDATION, + suggestion="Refresh the page and submit only editable fields.", + detail=f"Unknown settings keys: {unknown_keys}", + ) + + resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path) + original_lines = _read_env_lines(resolved_env_path) + env_map = _parse_env_map(original_lines) + mutable_lines = list(original_lines) + + for field_name, raw_value in updates.items(): + descriptor = descriptors[field_name] + encoded = _encode_field_value(raw_value) + if encoded == "": + _delete_env_key(mutable_lines, descriptor.env_key) + env_map.pop(descriptor.env_key, None) + continue + _upsert_env_key(mutable_lines, descriptor.env_key, encoded) + env_map[descriptor.env_key] = encoded + + _validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path) + _write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines) + refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False) + return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path) + + +def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path: + if env_file_path is not None: + return env_file_path + + configured = settings.model_config.get("env_file") + if configured is None: + return Path(".env") + if isinstance(configured, Path): + return Path(configured) + if isinstance(configured, str): + return Path(configured) + if isinstance(configured, (list, tuple)) and configured: + first = configured[0] + if isinstance(first, Path): + return Path(first) + if isinstance(first, str): + return Path(first) + return Path(".env") + + +def _display_value(value: object) -> str | bool: + if isinstance(value, bool): + return value + if value is None: + return "" + if isinstance(value, tuple): + values = [str(item) for item in value] + escaped = ",".join(f'"{_escape_json_string(item)}"' for item in values) + return f"[{escaped}]" + if isinstance(value, Path): + return value.as_posix() + if isinstance(value, Provider): + return value.value + return str(value) + + +def _encode_field_value(raw_value: str | bool) -> str: + if isinstance(raw_value, bool): + return "true" if raw_value else "false" + text = str(raw_value).strip() + if text == "": + return "" + if text.startswith("[") and text.endswith("]"): + return text + if " " in text or "#" in text: + escaped = text.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return text + + +def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) -> None: + serialized = "\n".join(f"{key}={value}" for key, value in env_map.items()) + "\n" + with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", newline="\n", suffix=".env") as handle: + temp_path = Path(handle.name) + handle.write(serialized) + try: + Settings(_env_file=temp_path, _cli_parse_args=False) + except ValidationError as exc: + raise AppError( + "One or more settings values are invalid.", + category=ErrorCategory.VALIDATION, + suggestion="Review the highlighted values and use the same formats shown in .env.example.", + detail=f"Invalid runtime settings for {env_file_path}: {exc}", + ) from exc + finally: + temp_path.unlink(missing_ok=True) + + +def _read_env_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return path.read_text(encoding="utf-8").splitlines() + + +def _parse_env_map(lines: list[str]) -> dict[str, str]: + env_map: dict[str, str] = {} + for line in lines: + parsed = _parse_env_line(line) + if parsed is None: + continue + key, value = parsed + env_map[key] = value + return env_map + + +def _parse_env_line(line: str) -> tuple[str, str] | None: + if not line or line.lstrip().startswith("#"): + return None + match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)\s*=\s*(.*)$", line) + if not match: + return None + return match.group(1), match.group(2) + + +def _delete_env_key(lines: list[str], key: str) -> None: + for index, line in enumerate(lines): + parsed = _parse_env_line(line) + if parsed is None: + continue + current_key, _ = parsed + if current_key == key: + del lines[index] + return + + +def _upsert_env_key(lines: list[str], key: str, value: str) -> None: + for index, line in enumerate(lines): + parsed = _parse_env_line(line) + if parsed is None: + continue + current_key, _ = parsed + if current_key == key: + lines[index] = f"{key}={value}" + return + lines.append(f"{key}={value}") + + +def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + content = "\n".join(lines).rstrip("\n") + if content: + content += "\n" + with tempfile.NamedTemporaryFile( + mode="w", + delete=False, + dir=path.parent, + encoding="utf-8", + newline="\n", + suffix=".env.tmp", + ) as handle: + temp_path = Path(handle.name) + handle.write(content) + temp_path.replace(path) + + +def _escape_json_string(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/tests/ui/test_pages_registration.py b/tests/ui/test_pages_registration.py index b0655f2..1950803 100644 --- a/tests/ui/test_pages_registration.py +++ b/tests/ui/test_pages_registration.py @@ -26,6 +26,7 @@ class TestPageRegistration: assert jobs_response.status_code == 200 assert tags_response.status_code == 200 assert settings_response.status_code == 200 + assert "Runtime Settings" in settings_response.text assert "Document Types" in settings_response.text assert "Person Roles" in settings_response.text assert "Tags" in settings_response.text diff --git a/tests/ui/test_runtime_settings_store.py b/tests/ui/test_runtime_settings_store.py new file mode 100644 index 0000000..c262797 --- /dev/null +++ b/tests/ui/test_runtime_settings_store.py @@ -0,0 +1,76 @@ +"""Tests for safe runtime settings .env editing helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from transcription.config import Settings +from transcription.errors import AppError +from transcription.ui.runtime_settings_store import RUNTIME_SETTINGS_CATALOG +from transcription.ui.runtime_settings_store import SETTINGS_UI_EXCLUDED_FIELDS +from transcription.ui.runtime_settings_store import read_runtime_settings_snapshot +from transcription.ui.runtime_settings_store import save_runtime_settings +from transcription.ui.runtime_settings_store import settings_catalog_field_names + + +def _settings_for_runtime_editing(tmp_path: Path) -> Settings: + env_path = tmp_path / ".env" + env_path.write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8") + return Settings(_env_file=env_path, _cli_parse_args=False) + + +def test_runtime_settings_catalog_classifies_all_non_secret_settings_fields(): + available_fields = set(Settings.model_fields) + expected = available_fields - set(SETTINGS_UI_EXCLUDED_FIELDS) + assert settings_catalog_field_names() == expected + assert "openrouter_api_key" not in settings_catalog_field_names() + + +def test_runtime_settings_snapshot_includes_catalog_fields(tmp_path: Path): + settings = _settings_for_runtime_editing(tmp_path) + + snapshot = read_runtime_settings_snapshot(settings=settings, env_file_path=tmp_path / ".env") + + assert snapshot.env_file_path == tmp_path / ".env" + assert len(snapshot.fields) == len(RUNTIME_SETTINGS_CATALOG) + assert any(field.field_name == "port" for field in snapshot.fields) + assert any(field.field_name == "provider" for field in snapshot.fields) + + +def test_save_runtime_settings_writes_allowed_updates(tmp_path: Path): + settings = _settings_for_runtime_editing(tmp_path) + env_path = tmp_path / ".env" + env_path.write_text( + "# sample env\nOPENROUTER_API_KEY=test-key\nPORT=8000\nLOG_LEVEL=info\n", + encoding="utf-8", + ) + + snapshot = save_runtime_settings( + settings=settings, + updates={ + "port": "9001", + "log_level": "debug", + "reload": True, + }, + env_file_path=env_path, + ) + + updated = env_path.read_text(encoding="utf-8") + assert "PORT=9001" in updated + assert "LOG_LEVEL=debug" in updated + assert "RELOAD=true" in updated + assert any(field.field_name == "port" and str(field.value) == "9001" for field in snapshot.fields) + + +def test_save_runtime_settings_rejects_invalid_values(tmp_path: Path): + settings = _settings_for_runtime_editing(tmp_path) + env_path = tmp_path / ".env" + + with pytest.raises(AppError): + save_runtime_settings( + settings=settings, + updates={"port": "not-a-number"}, + env_file_path=env_path, + )