generated from john/python-template
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661e2b1bec | ||
|
|
d75083a666 |
Vendored
+4
-8
@@ -8,14 +8,10 @@
|
|||||||
"module": "debugpy",
|
"module": "debugpy",
|
||||||
"args": [
|
"args": [
|
||||||
"-m",
|
"-m",
|
||||||
"uvicorn",
|
"transcription",
|
||||||
"transcription.app:create_app",
|
"--host", "127.0.0.1",
|
||||||
"--factory",
|
"--port", "9999",
|
||||||
"--host",
|
"--database.driver", "sqlite"
|
||||||
// "127.0.0.1",
|
|
||||||
"0.0.0.0",
|
|
||||||
"--port",
|
|
||||||
"8080"
|
|
||||||
],
|
],
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
|
|||||||
@@ -22,18 +22,80 @@ uv sync
|
|||||||
|
|
||||||
### 2) Configure environment
|
### 2) Configure environment
|
||||||
|
|
||||||
Create a `.env` file in the project root (minimum required setting shown):
|
Create a `.env` file in the project root with the required OpenRouter API key:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional settings (defaults shown):
|
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
|
||||||
|
|
||||||
|
### Configuration Source Precedence
|
||||||
|
|
||||||
|
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
|
||||||
|
|
||||||
|
1. CLI arguments (for example `--port 9999`)
|
||||||
|
2. Settings constructor arguments (used mainly in tests)
|
||||||
|
3. Environment variables
|
||||||
|
4. `.env` file values
|
||||||
|
5. Model defaults in code
|
||||||
|
|
||||||
|
Practical examples:
|
||||||
|
|
||||||
|
- `--port 9999` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
|
||||||
|
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
|
||||||
|
|
||||||
|
#### Server and runtime
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HOST` | `0.0.0.0` | Address on which the server listens. |
|
||||||
|
| `PORT` | `8000` | Server port. |
|
||||||
|
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
|
||||||
|
| `RELOAD` | `false` | Restart the development server when source files change. |
|
||||||
|
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
|
||||||
|
|
||||||
|
#### Provider
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROVIDER` | `openrouter` | Transcription provider. |
|
||||||
|
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
|
||||||
|
| `PROVIDER_MODEL` | Provider default | Optional model override. |
|
||||||
|
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
|
||||||
|
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
|
||||||
|
|
||||||
|
#### Database and files
|
||||||
|
|
||||||
|
Use nested env vars for database settings (recommended):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=sqlite:///./transcription.db
|
DATABASE__DRIVER=sqlite
|
||||||
|
DATABASE__PATH=app.db
|
||||||
|
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
|
||||||
|
SQLITE_CHECK_SAME_THREAD=false
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
PROMPT_DIR=./prompts
|
PROMPT_DIR=./prompts
|
||||||
|
```
|
||||||
|
|
||||||
|
For PostgreSQL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE__DRIVER=postgres
|
||||||
|
DATABASE__HOST=localhost
|
||||||
|
DATABASE__PORT=5432
|
||||||
|
DATABASE__DATABASE=transcription
|
||||||
|
DATABASE__USER=postgres
|
||||||
|
DATABASE__PASSWORD=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
|
||||||
|
|
||||||
|
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
|
||||||
|
|
||||||
|
#### Worker
|
||||||
|
|
||||||
|
```env
|
||||||
WORKER_MAX_RETRIES=0
|
WORKER_MAX_RETRIES=0
|
||||||
WORKER_RETRY_BACKOFF_SECONDS=0
|
WORKER_RETRY_BACKOFF_SECONDS=0
|
||||||
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
||||||
@@ -45,13 +107,17 @@ WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
|||||||
### 3) Run the app
|
### 3) Run the app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run uvicorn transcription.app:create_app --factory --reload
|
uv run python -m transcription --port 9999 --reload --database.driver sqlite --bootstrap-schema-on-startup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
|
||||||
|
|
||||||
### 4) Open in browser
|
### 4) Open in browser
|
||||||
|
|
||||||
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
|
- GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
|
||||||
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
|
- Health check: [http://localhost:9999/healthz](http://localhost:9999/healthz)
|
||||||
|
|
||||||
|
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
|
||||||
|
|
||||||
## How to navigate the GUI
|
## How to navigate the GUI
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class Settings(BaseSettings):
|
|||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
|
env_nested_delimiter="__",
|
||||||
cli_implicit_flags=True,
|
cli_implicit_flags=True,
|
||||||
cli_kebab_case=True,
|
cli_kebab_case=True,
|
||||||
)
|
)
|
||||||
@@ -74,7 +75,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# --- persistence ---
|
# --- persistence ---
|
||||||
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
||||||
database_url: str = "sqlite:///./transcription.db"
|
|
||||||
bootstrap_schema_on_startup: bool = False
|
bootstrap_schema_on_startup: bool = False
|
||||||
sqlite_check_same_thread: bool = False
|
sqlite_check_same_thread: bool = False
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def should_bootstrap_schema(self) -> bool:
|
def should_bootstrap_schema(self) -> bool:
|
||||||
"""Return whether startup should auto-create schema for this environment."""
|
"""Return whether startup should auto-create schema for this environment."""
|
||||||
if self.bootstrap_schema_on_startup is not None:
|
if "bootstrap_schema_on_startup" in self.model_fields_set:
|
||||||
return self.bootstrap_schema_on_startup
|
return self.bootstrap_schema_on_startup
|
||||||
return self.environment in {"development", "test"}
|
return self.environment in {"development", "test"}
|
||||||
|
|
||||||
@@ -101,13 +101,13 @@ class Settings(BaseSettings):
|
|||||||
@cache
|
@cache
|
||||||
def get_settings(**kwargs: Any) -> Settings:
|
def get_settings(**kwargs: Any) -> Settings:
|
||||||
"""Load cached settings without reading process CLI arguments."""
|
"""Load cached settings without reading process CLI arguments."""
|
||||||
return Settings(_cli_parse_args=False, **kwargs)
|
return Settings(_cli_parse_args=False, **kwargs) # pyright: ignore[reportCallIssue]
|
||||||
|
|
||||||
|
|
||||||
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
|
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
|
||||||
"""Load settings with CLI arguments at the executable boundary."""
|
"""Load settings with CLI arguments at the executable boundary."""
|
||||||
cli_args = True if args is None else list(args)
|
cli_args = True if args is None else list(args)
|
||||||
return Settings(_cli_parse_args=cli_args)
|
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG: dict[str, Any] = {
|
LOGGING_CONFIG: dict[str, Any] = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from .engine import dispose_engine
|
from .engine import dispose_engine
|
||||||
from .engine import get_database_url
|
from .engine import get_database_url
|
||||||
@@ -25,8 +26,14 @@ def get_session_factory(database_url: str) -> SessionFactory:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_session_factory(database_url: str | None = None) -> SessionFactory:
|
def resolve_session_factory(
|
||||||
return get_session_factory(database_url or get_database_url(get_settings()))
|
database_url: str | None = None,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> SessionFactory:
|
||||||
|
if database_url is not None:
|
||||||
|
return get_session_factory(database_url)
|
||||||
|
return get_session_factory(get_database_url(settings or get_settings()))
|
||||||
|
|
||||||
|
|
||||||
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
||||||
@@ -40,15 +47,20 @@ async def dispose_session_factory(database_url: str) -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def session_scope(
|
async def session_scope(
|
||||||
*,
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
database_url: str | None = None,
|
database_url: str | None = None,
|
||||||
|
session_factory: SessionFactory | None = None,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> AsyncGenerator[AsyncSession]:
|
) -> AsyncGenerator[AsyncSession]:
|
||||||
if session is not None:
|
if session is not None:
|
||||||
yield session
|
yield session
|
||||||
return
|
return
|
||||||
|
|
||||||
session_factory = resolve_session_factory(database_url)
|
active_session_factory = session_factory or resolve_session_factory(
|
||||||
async with session_factory() as owned_session:
|
database_url,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
async with active_session_factory() as owned_session:
|
||||||
yield owned_session
|
yield owned_session
|
||||||
|
|
||||||
|
|
||||||
@@ -58,9 +70,11 @@ type SessionScopeDep = Annotated[AsyncSession, Depends(session_scope)]
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction_scope(
|
async def transaction_scope(
|
||||||
*,
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
database_url: str | None = None,
|
database_url: str | None = None,
|
||||||
session: AsyncSessionTransaction | None = None,
|
session_factory: SessionFactory | None = None,
|
||||||
) -> AsyncGenerator[AsyncSessionTransaction]:
|
session: AsyncSession | AsyncSessionTransaction | None = None,
|
||||||
|
) -> AsyncGenerator[AsyncSession | AsyncSessionTransaction]:
|
||||||
match session:
|
match session:
|
||||||
case AsyncSession() as async_session:
|
case AsyncSession() as async_session:
|
||||||
if not async_session.in_transaction():
|
if not async_session.in_transaction():
|
||||||
@@ -71,9 +85,15 @@ async def transaction_scope(
|
|||||||
yield async_transaction
|
yield async_transaction
|
||||||
return
|
return
|
||||||
|
|
||||||
session_factory = resolve_session_factory(database_url)
|
active_session_factory = session_factory or resolve_session_factory(
|
||||||
async with session_factory().begin() as owned_session:
|
database_url,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
async with active_session_factory.begin() as owned_session:
|
||||||
yield owned_session
|
yield owned_session
|
||||||
|
|
||||||
|
|
||||||
type TransactionScopeDep = Annotated[AsyncSessionTransaction, Depends(transaction_scope)]
|
type TransactionScopeDep = Annotated[
|
||||||
|
AsyncSession | AsyncSessionTransaction,
|
||||||
|
Depends(transaction_scope),
|
||||||
|
]
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ class ServiceBase(ABC):
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _session_scope(self, session: AsyncSession | None = None):
|
async def _session_scope(self, session: AsyncSession | None = None):
|
||||||
"""Provide a transactional scope around a series of operations."""
|
"""Provide a transactional scope around a series of operations."""
|
||||||
async with session_scope(session=session) as active_session:
|
async with session_scope(
|
||||||
|
session_factory=self.session_factory,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
yield active_session
|
yield active_session
|
||||||
|
|
||||||
async def _finalize(
|
async def _finalize(
|
||||||
|
|||||||
Reference in New Issue
Block a user