generated from john/python-template
This commit is contained in:
@@ -21,7 +21,9 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
|
|
||||||
1. Deploy artifact/config to target environment.
|
1. Deploy artifact/config to target environment.
|
||||||
- V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build`
|
- V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build`
|
||||||
|
- `Settings` loads from explicit `_env_file`, then `ENV_FILE`, then the repository-root `.env.production`; it does not resolve relative to the process working directory.
|
||||||
- For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`.
|
- For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`.
|
||||||
|
- If deployment uses a non-default env-file location, set both `ENV_FILE` and `RUNTIME_SETTINGS_ENV_FILE` to that absolute path so startup reads and Settings-page writes stay aligned.
|
||||||
- For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db <sqlite-path-or-url> --target-db <postgres-url>` before switching runtime.
|
- For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db <sqlite-path-or-url> --target-db <postgres-url>` before switching runtime.
|
||||||
2. Validate service startup:
|
2. Validate service startup:
|
||||||
- `/healthz` responds `200`
|
- `/healthz` responds `200`
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ Settings manages installation-local registries, safe runtime .env settings, and
|
|||||||
- Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields.
|
- Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields.
|
||||||
- Runtime Settings is rendered as a compact two-column editor (**Setting**, **Value**) in a centered, narrower responsive container.
|
- Runtime Settings is rendered as a compact two-column editor (**Setting**, **Value**) in a centered, narrower responsive container.
|
||||||
- Runtime Settings persists changes to the resolved runtime env file, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter.
|
- Runtime Settings persists changes to the resolved runtime env file, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter.
|
||||||
- The write target resolution order is: explicit function override (tests/tools), `RUNTIME_SETTINGS_ENV_FILE` environment variable (deployment override), then `Settings.model_config.env_file` (default `.env.production`).
|
- `Settings` resolves its env file in this order: explicit `_env_file`, `ENV_FILE`, then the repository-root `.env.production`.
|
||||||
|
- Runtime Settings resolves its write target in this order: explicit function override (tests/tools), `RUNTIME_SETTINGS_ENV_FILE` environment variable (deployment override), `ENV_FILE`, then the repository-root `.env.production`.
|
||||||
- Runtime Settings changes require application restart to take effect.
|
- Runtime Settings changes require application restart to take effect.
|
||||||
- Runtime Settings renders a host-side restart command (`docker compose -f docker-compose.production.yml up -d --force-recreate app worker`) so operators can apply saved values without granting Docker control to the app container.
|
- Runtime Settings renders a host-side restart command (`docker compose -f docker-compose.production.yml up -d --force-recreate app worker`) so operators can apply saved values without granting Docker control to the app container.
|
||||||
- Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
|
- Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ are resolved by the provider adapters, not here.
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging.config
|
import logging.config
|
||||||
|
import os
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from functools import cache
|
from functools import cache
|
||||||
@@ -26,6 +27,16 @@ from pydantic_settings import BaseSettings
|
|||||||
from pydantic_settings import SettingsConfigDict
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_ENV_FILE_NAME = ".env.production"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_env_file_path() -> Path:
|
||||||
|
"""Resolve the runtime env file independent of the process working directory."""
|
||||||
|
override = os.getenv("ENV_FILE", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return PROJECT_ROOT / DEFAULT_ENV_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
class Provider(StrEnum):
|
||||||
@@ -66,7 +77,7 @@ DatabaseSettings = Annotated[
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env.production",
|
env_file=None,
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
env_nested_delimiter="__",
|
env_nested_delimiter="__",
|
||||||
@@ -75,6 +86,11 @@ class Settings(BaseSettings):
|
|||||||
frozen=True,
|
frozen=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, /, **values: Any) -> None:
|
||||||
|
if "_env_file" not in values:
|
||||||
|
values["_env_file"] = resolve_settings_env_file_path()
|
||||||
|
super().__init__(**values)
|
||||||
|
|
||||||
# --- NiceGUI Server ---
|
# --- NiceGUI Server ---
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ def new_error_id() -> str:
|
|||||||
return uuid4().hex[:8]
|
return uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def exception_detail(exc: BaseException) -> str:
|
||||||
|
"""Return internal-only root-cause text for persisted diagnostics."""
|
||||||
|
return f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
class AppError(RuntimeError):
|
class AppError(RuntimeError):
|
||||||
"""Base application error carrying user-safe handling metadata."""
|
"""Base application error carrying user-safe handling metadata."""
|
||||||
|
|
||||||
@@ -115,7 +120,7 @@ def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
|||||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
||||||
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
||||||
retriable=False,
|
retriable=False,
|
||||||
detail=f"{type(exc).__name__}: {exc}",
|
detail=exception_detail(exc),
|
||||||
)
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Unexpected error operation=%s error_id=%s",
|
"Unexpected error operation=%s error_id=%s",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class PromptLoadError(AppError):
|
|||||||
"""Raised when prompt artifacts cannot be loaded safely."""
|
"""Raised when prompt artifacts cannot be loaded safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class PromptStoreError(PromptLoadError):
|
||||||
|
"""Raised when prompt storage validation or persistence fails."""
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionError(AppError):
|
class TranscriptionError(AppError):
|
||||||
"""Raised when transcription execution fails."""
|
"""Raised when transcription execution fails."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,14 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..errors import AppError
|
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
|
from ..errors import exception_detail
|
||||||
|
from .errors import PromptStoreError
|
||||||
|
|
||||||
PROMPT_EXTENSION = ".md"
|
PROMPT_EXTENSION = ".md"
|
||||||
BACKUP_SUFFIX = ".bak"
|
BACKUP_SUFFIX = ".bak"
|
||||||
|
|
||||||
|
|
||||||
class PromptStoreError(AppError):
|
|
||||||
"""Raised when prompt storage validation or persistence fails."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PromptSummary:
|
class PromptSummary:
|
||||||
"""Read model for one editable prompt artifact."""
|
"""Read model for one editable prompt artifact."""
|
||||||
@@ -189,5 +186,5 @@ class PromptStore:
|
|||||||
message,
|
message,
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
||||||
detail=f"{type(exc).__name__}: {exc}",
|
detail=exception_detail(exc),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Tags browse and filter page registration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.services.documents import DocumentService
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
|
||||||
from transcription.ui.components.cards import archival_card
|
|
||||||
from transcription.ui.components.error_presenter import run_ui_action
|
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
|
||||||
from transcription.ui.components.primitives import section_header_row
|
|
||||||
from transcription.ui.theme import page_header
|
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
|
||||||
"""Register the tags browse/filter route."""
|
|
||||||
|
|
||||||
@ui.page("/tags")
|
|
||||||
async def tags_page(session_factory: SessionFactoryDep) -> None:
|
|
||||||
document_service = DocumentService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/tags")
|
|
||||||
|
|
||||||
tags_outcome = await run_ui_action(
|
|
||||||
operation="tags.list",
|
|
||||||
title="Tags unavailable",
|
|
||||||
action=document_service.list_tag_summaries,
|
|
||||||
)
|
|
||||||
if not tags_outcome.ok:
|
|
||||||
return
|
|
||||||
tag_summaries = tags_outcome.value or ()
|
|
||||||
tag_labels = [item.label for item in tag_summaries]
|
|
||||||
|
|
||||||
documents_outcome = await run_ui_action(
|
|
||||||
operation="documents.list",
|
|
||||||
title="Documents unavailable",
|
|
||||||
action=document_service.list_documents,
|
|
||||||
)
|
|
||||||
if not documents_outcome.ok:
|
|
||||||
return
|
|
||||||
documents = documents_outcome.value or ()
|
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
|
||||||
with section_header_row():
|
|
||||||
page_header("Tags", subtitle="Browse documents by tag.")
|
|
||||||
|
|
||||||
if not tag_summaries:
|
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
|
||||||
render_empty_state("No tags are configured yet.")
|
|
||||||
return
|
|
||||||
|
|
||||||
selected_tag = (
|
|
||||||
ui.select(tag_labels, label="Filter by tag")
|
|
||||||
.props("outlined clearable use-input")
|
|
||||||
.classes("w-full md:w-96 ui-form-surface")
|
|
||||||
)
|
|
||||||
|
|
||||||
@ui.refreshable
|
|
||||||
def render_groups() -> None:
|
|
||||||
selected = str(selected_tag.value or "").strip()
|
|
||||||
with ui.column().classes("w-full gap-3"):
|
|
||||||
rendered_any = False
|
|
||||||
for summary in tag_summaries:
|
|
||||||
if selected and summary.label != selected:
|
|
||||||
continue
|
|
||||||
tagged_documents = [
|
|
||||||
document
|
|
||||||
for document in documents
|
|
||||||
if any(
|
|
||||||
link.tag_ref is not None and link.tag_ref.id == summary.id
|
|
||||||
for link in document.document_tags
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if not tagged_documents:
|
|
||||||
continue
|
|
||||||
rendered_any = True
|
|
||||||
with archival_card(title=f"{summary.label} ({len(tagged_documents)})"):
|
|
||||||
for document in sorted(tagged_documents, key=lambda item: item.name.casefold()):
|
|
||||||
ui.button(
|
|
||||||
document.name,
|
|
||||||
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(f"/documents/{doc_id}"),
|
|
||||||
icon="description",
|
|
||||||
).props("flat dense no-caps").classes("self-start ui-link-primary text-xs")
|
|
||||||
|
|
||||||
if not rendered_any:
|
|
||||||
with archival_card(extra_classes="p-6"):
|
|
||||||
render_empty_state("No documents match this tag filter.", italic=True)
|
|
||||||
|
|
||||||
selected_tag.on_value_change(lambda _event: render_groups.refresh())
|
|
||||||
render_groups()
|
|
||||||
@@ -16,8 +16,10 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from transcription.config import Provider
|
from transcription.config import Provider
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
|
from transcription.config import resolve_settings_env_file_path
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.errors import exception_detail
|
||||||
|
|
||||||
FieldControl = Literal["text", "bool", "select"]
|
FieldControl = Literal["text", "bool", "select"]
|
||||||
|
|
||||||
@@ -385,13 +387,14 @@ def save_runtime_settings(
|
|||||||
"Runtime settings file is not writable.",
|
"Runtime settings file is not writable.",
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Verify file path and write permissions, then retry.",
|
suggestion="Verify file path and write permissions, then retry.",
|
||||||
detail=f"Failed writing runtime env file {resolved_env_path}: {type(exc).__name__}: {exc}",
|
detail=f"Failed writing runtime env file {resolved_env_path}: {exception_detail(exc)}",
|
||||||
) from exc
|
) from exc
|
||||||
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
||||||
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
|
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:
|
def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path:
|
||||||
|
_ = settings
|
||||||
if env_file_path is not None:
|
if env_file_path is not None:
|
||||||
return env_file_path
|
return env_file_path
|
||||||
|
|
||||||
@@ -399,20 +402,7 @@ def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) ->
|
|||||||
if override:
|
if override:
|
||||||
return Path(override)
|
return Path(override)
|
||||||
|
|
||||||
configured = settings.model_config.get("env_file")
|
return resolve_settings_env_file_path()
|
||||||
if configured is None:
|
|
||||||
return Path(".env.production")
|
|
||||||
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.production")
|
|
||||||
|
|
||||||
|
|
||||||
def _display_value(value: object) -> str | bool:
|
def _display_value(value: object) -> str | bool:
|
||||||
@@ -473,7 +463,7 @@ def _read_env_lines(path: Path) -> list[str]:
|
|||||||
"Runtime settings file is unreadable.",
|
"Runtime settings file is unreadable.",
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Verify file path and read permissions, then retry.",
|
suggestion="Verify file path and read permissions, then retry.",
|
||||||
detail=f"Failed reading runtime env file {path}: {type(exc).__name__}: {exc}",
|
detail=f"Failed reading runtime env file {path}: {exception_detail(exc)}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -551,7 +541,7 @@ def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
|
|||||||
"Runtime settings file is not writable.",
|
"Runtime settings file is not writable.",
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Verify file path and write permissions, then retry.",
|
suggestion="Verify file path and write permissions, then retry.",
|
||||||
detail=f"Failed writing runtime env file {path}: {type(exc).__name__}: {exc}",
|
detail=f"Failed writing runtime env file {path}: {exception_detail(exc)}",
|
||||||
) from exc
|
) from exc
|
||||||
finally:
|
finally:
|
||||||
if temp_path is not None:
|
if temp_path is not None:
|
||||||
|
|||||||
+10
-6
@@ -4,6 +4,7 @@ Every test gets a fresh in-memory SQLite database so tests are
|
|||||||
isolated, fast, and leave no artifacts on disk.
|
isolated, fast, and leave no artifacts on disk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -28,9 +29,9 @@ from transcription.services.jobs import JobService
|
|||||||
def isolate_settings_from_local_env_files(tmp_path_factory):
|
def isolate_settings_from_local_env_files(tmp_path_factory):
|
||||||
"""Point `Settings` at a controlled stub env file instead of a developer one.
|
"""Point `Settings` at a controlled stub env file instead of a developer one.
|
||||||
|
|
||||||
`Settings.model_config` declares `env_file=".env.production"`, resolved against the
|
`Settings()` resolves its env file through the shared config seam, so a repository-root
|
||||||
current working directory, so a repository-root pytest run would otherwise read real
|
pytest run would otherwise read a developer's real `.env.production` into tests that
|
||||||
deployment values into tests that assert declared defaults.
|
assert declared defaults.
|
||||||
|
|
||||||
The stub mirrors what `.github/workflows/quality-gate.yml` writes in CI: only
|
The stub mirrors what `.github/workflows/quality-gate.yml` writes in CI: only
|
||||||
`OPENROUTER_API_KEY`, which is required and which many tests need `get_settings()` to
|
`OPENROUTER_API_KEY`, which is required and which many tests need `get_settings()` to
|
||||||
@@ -42,12 +43,15 @@ def isolate_settings_from_local_env_files(tmp_path_factory):
|
|||||||
stub = tmp_path_factory.mktemp("settings-env") / ".env.test"
|
stub = tmp_path_factory.mktemp("settings-env") / ".env.test"
|
||||||
stub.write_text("OPENROUTER_API_KEY=test-placeholder-not-a-real-key\n", encoding="utf-8")
|
stub.write_text("OPENROUTER_API_KEY=test-placeholder-not-a-real-key\n", encoding="utf-8")
|
||||||
|
|
||||||
original = Settings.model_config.get("env_file")
|
original = os.environ.get("ENV_FILE")
|
||||||
Settings.model_config["env_file"] = str(stub)
|
os.environ["ENV_FILE"] = str(stub)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
Settings.model_config["env_file"] = original
|
if original is None:
|
||||||
|
os.environ.pop("ENV_FILE", None)
|
||||||
|
else:
|
||||||
|
os.environ["ENV_FILE"] = original
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.errors import PromptLoadError
|
||||||
from transcription.services.prompts import PromptStore
|
from transcription.services.prompts import PromptStore
|
||||||
from transcription.services.prompts import PromptStoreError
|
from transcription.services.prompts import PromptStoreError
|
||||||
|
|
||||||
@@ -79,6 +80,13 @@ def test_prompt_creation_and_empty_content_are_rejected(prompt_store):
|
|||||||
assert empty.value.category == ErrorCategory.VALIDATION
|
assert empty.value.category == ErrorCategory.VALIDATION
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_store_failures_are_catchable_as_prompt_load_errors(prompt_store):
|
||||||
|
store, _ = prompt_store
|
||||||
|
|
||||||
|
with pytest.raises(PromptLoadError):
|
||||||
|
store.read_prompt("missing.md")
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_requires_a_backup(prompt_store):
|
def test_recovery_requires_a_backup(prompt_store):
|
||||||
store, _ = prompt_store
|
store, _ = prompt_store
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
"""Suite-wide isolation of `Settings` from developer environment files.
|
"""Suite-wide isolation of `Settings` from developer environment files."""
|
||||||
|
|
||||||
`Settings.model_config` declares `env_file=".env.production"`, resolved relative to the
|
|
||||||
current working directory. Running pytest from the repository root therefore loads a real
|
|
||||||
developer env file into tests that construct `Settings(...)` directly, and the declared
|
|
||||||
field defaults stop being what the suite actually exercises.
|
|
||||||
|
|
||||||
That breaks verification in both directions: tests asserting default behavior fail locally
|
|
||||||
for environmental reasons, and tests asserting configured behavior can pass locally against
|
|
||||||
values that do not exist in CI. The autouse fixture in `conftest.py` neutralizes the class
|
|
||||||
level `env_file` so defaults are authoritative; tests that need file loading still pass
|
|
||||||
`_env_file=` explicitly, which takes precedence over the class config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import transcription.config as config_module
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
|
from transcription.config import resolve_settings_env_file_path
|
||||||
|
|
||||||
|
|
||||||
def test_settings_defaults_are_not_overridden_by_a_local_env_file():
|
def test_settings_defaults_are_not_overridden_by_a_local_env_file():
|
||||||
@@ -37,3 +27,23 @@ def test_explicit_env_file_still_loads(tmp_path):
|
|||||||
settings = Settings(openrouter_api_key="test-key", _env_file=env_path, _cli_parse_args=False)
|
settings = Settings(openrouter_api_key="test-key", _env_file=env_path, _cli_parse_args=False)
|
||||||
|
|
||||||
assert settings.port == 9123
|
assert settings.port == 9123
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_env_file_resolves_from_override_environment_variable(tmp_path, monkeypatch):
|
||||||
|
env_path = tmp_path / "custom.env"
|
||||||
|
env_path.write_text("PORT=9123\n", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("ENV_FILE", str(env_path))
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", _cli_parse_args=False)
|
||||||
|
|
||||||
|
assert settings.port == 9123
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_default_env_path_is_anchored_to_project_root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("ENV_FILE", raising=False)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setattr(config_module, "PROJECT_ROOT", tmp_path / "project-root")
|
||||||
|
|
||||||
|
resolved = resolve_settings_env_file_path()
|
||||||
|
|
||||||
|
assert resolved == tmp_path / "project-root" / ".env.production"
|
||||||
|
|||||||
@@ -92,10 +92,6 @@ KNOWN_ORPHANS: dict[str, str] = {
|
|||||||
"Public read helper currently unused by runtime flows, but retained as part of "
|
"Public read helper currently unused by runtime flows, but retained as part of "
|
||||||
"the SourceService API pending endpoint consolidation."
|
"the SourceService API pending endpoint consolidation."
|
||||||
),
|
),
|
||||||
"src/transcription/ui/pages/tags_page.py": (
|
|
||||||
"Retained temporarily as explicitly dead code until the planned route-retirement "
|
|
||||||
"cleanup deletes the stranded module."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ def test_runtime_settings_uses_override_env_file(tmp_path: Path, monkeypatch):
|
|||||||
assert "PORT=9001" in target.read_text(encoding="utf-8")
|
assert "PORT=9001" in target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_settings_falls_back_to_shared_settings_env_file_override(tmp_path: Path, monkeypatch):
|
||||||
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
|
target = tmp_path / "shared.env"
|
||||||
|
target.write_text("OPENROUTER_API_KEY=test-key\nPORT=8000\n", encoding="utf-8")
|
||||||
|
monkeypatch.delenv("RUNTIME_SETTINGS_ENV_FILE", raising=False)
|
||||||
|
monkeypatch.setenv("ENV_FILE", str(target))
|
||||||
|
|
||||||
|
snapshot = save_runtime_settings(
|
||||||
|
settings=settings,
|
||||||
|
updates={"port": "9001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.env_file_path == target
|
||||||
|
assert "PORT=9001" in target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch):
|
def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch):
|
||||||
settings = _settings_for_runtime_editing(tmp_path)
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
env_path = tmp_path / ".env"
|
env_path = tmp_path / ".env"
|
||||||
|
|||||||
Reference in New Issue
Block a user