loadcoach 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadcoach/__about__.py +1 -0
- loadcoach/__main__.py +12 -0
- loadcoach/bootstrap.py +157 -0
- loadcoach/cli/commands/config.py +169 -0
- loadcoach/cli/commands/db.py +249 -0
- loadcoach/cli/commands/evidence.py +347 -0
- loadcoach/cli/commands/generate.py +134 -0
- loadcoach/cli/commands/job.py +394 -0
- loadcoach/cli/commands/models.py +215 -0
- loadcoach/cli/commands/queue.py +162 -0
- loadcoach/cli/commands/reliability.py +92 -0
- loadcoach/cli/commands/route.py +191 -0
- loadcoach/cli/commands/system.py +137 -0
- loadcoach/cli/commands/tasks.py +158 -0
- loadcoach/cli/commands/token.py +146 -0
- loadcoach/cli/main.py +84 -0
- loadcoach/config/manual_capability_scores.toml +15 -0
- loadcoach/config/schemas/code_debug_findings.json +13 -0
- loadcoach/config/schemas/code_review_findings.json +25 -0
- loadcoach/config/schemas/content_review_findings.json +24 -0
- loadcoach/config/schemas/fact_check_findings.json +26 -0
- loadcoach/config/schemas/structured_extract.json +11 -0
- loadcoach/config/task_profiles.toml +417 -0
- loadcoach/config.py +894 -0
- loadcoach/domain/admission.py +230 -0
- loadcoach/domain/authorization.py +105 -0
- loadcoach/domain/circuit_breaker.py +357 -0
- loadcoach/domain/evidence_policy.py +848 -0
- loadcoach/domain/priority.py +158 -0
- loadcoach/domain/queue_state.py +214 -0
- loadcoach/domain/registry.py +194 -0
- loadcoach/domain/reliability.py +812 -0
- loadcoach/domain/retry_policy.py +192 -0
- loadcoach/domain/routing/constraints.py +567 -0
- loadcoach/domain/routing/context_budget.py +188 -0
- loadcoach/domain/routing/explanation.py +288 -0
- loadcoach/domain/routing/narrative.py +360 -0
- loadcoach/domain/routing/ranking.py +118 -0
- loadcoach/domain/routing/scoring.py +549 -0
- loadcoach/domain/routing/subject.py +343 -0
- loadcoach/domain/task_profile.py +221 -0
- loadcoach/domain/validation.py +399 -0
- loadcoach/infrastructure/db/migrations/.gitkeep +0 -0
- loadcoach/infrastructure/db/migrations/env.py +34 -0
- loadcoach/infrastructure/db/migrations/script.py.mako +26 -0
- loadcoach/infrastructure/db/migrations/versions/0001_initial_schema.py +161 -0
- loadcoach/infrastructure/db/migrations/versions/0002_routing_decisions.py +118 -0
- loadcoach/infrastructure/db/migrations/versions/0003_jobs_and_attempts.py +218 -0
- loadcoach/infrastructure/db/migrations/versions/0004_residency.py +84 -0
- loadcoach/infrastructure/db/migrations/versions/0005_capability_evidence.py +143 -0
- loadcoach/infrastructure/db/migrations/versions/0006_feedback_and_reliability.py +111 -0
- loadcoach/infrastructure/db/models.py +727 -0
- loadcoach/infrastructure/db/repositories/.gitkeep +0 -0
- loadcoach/infrastructure/db/repositories/settings.py +51 -0
- loadcoach/infrastructure/freeweight_client.py +470 -0
- loadcoach/infrastructure/providers/factory.py +63 -0
- loadcoach/observability/logging.py +144 -0
- loadcoach/prompts/.gitkeep +0 -0
- loadcoach/prompts/execution/structured_output.retry.v1.json +38 -0
- loadcoach/prompts/manifest.json +14 -0
- loadcoach/services/config_reference.py +154 -0
- loadcoach/services/dashboard.py +191 -0
- loadcoach/services/database.py +359 -0
- loadcoach/services/doctor.py +535 -0
- loadcoach/services/evidence.py +1563 -0
- loadcoach/services/execution.py +1589 -0
- loadcoach/services/feedback.py +245 -0
- loadcoach/services/health.py +379 -0
- loadcoach/services/job_events.py +287 -0
- loadcoach/services/machine.py +73 -0
- loadcoach/services/models.py +485 -0
- loadcoach/services/prompts.py +63 -0
- loadcoach/services/queue.py +1501 -0
- loadcoach/services/queue_stream.py +151 -0
- loadcoach/services/recovery.py +165 -0
- loadcoach/services/reliability.py +450 -0
- loadcoach/services/residency.py +367 -0
- loadcoach/services/retention.py +126 -0
- loadcoach/services/routing.py +903 -0
- loadcoach/services/settings.py +271 -0
- loadcoach/services/status.py +125 -0
- loadcoach/services/task_profiles.py +140 -0
- loadcoach/services/telemetry_stream.py +170 -0
- loadcoach/services/tokens.py +164 -0
- loadcoach/services/worker.py +1920 -0
- loadcoach/web/app.py +404 -0
- loadcoach/web/auth.py +189 -0
- loadcoach/web/csrf.py +55 -0
- loadcoach/web/limits.py +149 -0
- loadcoach/web/rate_limit.py +282 -0
- loadcoach/web/rendering.py +79 -0
- loadcoach/web/routes/access.py +44 -0
- loadcoach/web/routes/dashboard.py +36 -0
- loadcoach/web/routes/evidence.py +263 -0
- loadcoach/web/routes/generate.py +372 -0
- loadcoach/web/routes/jobs.py +411 -0
- loadcoach/web/routes/models.py +153 -0
- loadcoach/web/routes/queue.py +181 -0
- loadcoach/web/routes/reliability.py +103 -0
- loadcoach/web/routes/routing.py +203 -0
- loadcoach/web/routes/settings.py +106 -0
- loadcoach/web/routes/system.py +110 -0
- loadcoach/web/routes/task_profiles.py +59 -0
- loadcoach/web/routing_support.py +87 -0
- loadcoach/web/templates/.gitkeep +0 -0
- loadcoach/web/templates/dashboard/index.html +89 -0
- loadcoach/web/templates/error.html +34 -0
- loadcoach/web/templates/evidence/index.html +105 -0
- loadcoach/web/templates/jobs/detail.html +100 -0
- loadcoach/web/templates/jobs/index.html +46 -0
- loadcoach/web/templates/models/index.html +42 -0
- loadcoach/web/templates/queue/_live.html +63 -0
- loadcoach/web/templates/queue/index.html +47 -0
- loadcoach/web/templates/reliability/index.html +106 -0
- loadcoach/web/templates/routing/_narrative.html +67 -0
- loadcoach/web/templates/routing/detail.html +85 -0
- loadcoach/web/templates/routing/index.html +30 -0
- loadcoach/web/templates/settings/index.html +39 -0
- loadcoach/web/templates/system/index.html +84 -0
- loadcoach/web/templates/task_profiles/index.html +24 -0
- loadcoach-1.0.0.dist-info/METADATA +120 -0
- loadcoach-1.0.0.dist-info/RECORD +125 -0
- loadcoach-1.0.0.dist-info/WHEEL +4 -0
- loadcoach-1.0.0.dist-info/entry_points.txt +2 -0
- loadcoach-1.0.0.dist-info/licenses/LICENSE +201 -0
loadcoach/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
loadcoach/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""loadcoach.__main__ — ``python -m loadcoach``.
|
|
2
|
+
|
|
3
|
+
Delegates to the Typer app, whose root callback starts ``serve`` when invoked with no subcommand
|
|
4
|
+
(CLI Standards §1).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from loadcoach.cli.main import app
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
app()
|
loadcoach/bootstrap.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""loadcoach.bootstrap — the composition root: settings, logging and the ASGI app, wired once.
|
|
2
|
+
|
|
3
|
+
This module sits outside the ``web``/``cli``/``services``/``domain`` layer ordering that
|
|
4
|
+
``.importlinter`` enforces, precisely so it can import both configuration and the web layer.
|
|
5
|
+
``loadcoach.cli`` never imports it directly — the ``web-cli-independence`` contract forbids any
|
|
6
|
+
import chain from ``cli`` into ``web``, and this module imports ``web``. Instead, the CLI's
|
|
7
|
+
``serve`` command hands uvicorn the dotted string
|
|
8
|
+
``"loadcoach.bootstrap:create_app_from_environment"`` and lets uvicorn perform that import itself;
|
|
9
|
+
a string literal is invisible to import-linter's static analysis, so the two surfaces stay decoupled
|
|
10
|
+
at the source level while still running the same application in one process.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI
|
|
20
|
+
from sqlalchemy import func, select
|
|
21
|
+
|
|
22
|
+
from loadcoach.config import LOOPBACK_HOSTS, InsecureBindingError, LoadedSettings, load_settings
|
|
23
|
+
from loadcoach.infrastructure.db.models import ApiToken
|
|
24
|
+
from loadcoach.infrastructure.providers.factory import build_provider
|
|
25
|
+
from loadcoach.observability.logging import configure_logging
|
|
26
|
+
from loadcoach.services.database import Database, ensure_ready
|
|
27
|
+
from loadcoach.services.models import import_manual_capability_scores, try_discover_models
|
|
28
|
+
from loadcoach.services.task_profiles import import_task_profiles, read_task_profiles_file
|
|
29
|
+
from loadcoach.web.app import create_app
|
|
30
|
+
|
|
31
|
+
__all__ = ["Application", "bootstrap", "create_app_from_environment"]
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Application:
|
|
38
|
+
"""A fully wired application: the settings it was built from and its ASGI app."""
|
|
39
|
+
|
|
40
|
+
loaded_settings: LoadedSettings
|
|
41
|
+
app: FastAPI
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _has_active_token(database: Database) -> bool:
|
|
45
|
+
"""Return whether at least one non-revoked, unexpired API token exists.
|
|
46
|
+
|
|
47
|
+
The third member of ADR-0026's non-loopback refusal set — bind acknowledgement and
|
|
48
|
+
``server.allowed_hosts`` are checked in :func:`loadcoach.config.load_settings`, which reads no
|
|
49
|
+
database; this one needs the database, because LoadCoach stores tokens in the ``api_tokens``
|
|
50
|
+
table (created by ``loadcoach token create``) rather than in ``config.toml`` — a token an
|
|
51
|
+
operator can issue and revoke without editing a file.
|
|
52
|
+
"""
|
|
53
|
+
now = datetime.now(UTC)
|
|
54
|
+
with database.read() as session:
|
|
55
|
+
count = session.execute(
|
|
56
|
+
select(func.count())
|
|
57
|
+
.select_from(ApiToken)
|
|
58
|
+
.where(
|
|
59
|
+
ApiToken.revoked_at.is_(None),
|
|
60
|
+
(ApiToken.expires_at.is_(None)) | (ApiToken.expires_at > now),
|
|
61
|
+
)
|
|
62
|
+
).scalar_one()
|
|
63
|
+
return count > 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bootstrap() -> Application:
|
|
67
|
+
"""Load configuration, configure logging, ready the database, build the app.
|
|
68
|
+
|
|
69
|
+
Reads configuration through the standard precedence chain (defaults, file, environment) with
|
|
70
|
+
no CLI-argument layer of its own: a caller that needs CLI overrides applies them as environment
|
|
71
|
+
variables before calling this function, which is what ``loadcoach.cli.commands.system.serve``
|
|
72
|
+
does.
|
|
73
|
+
|
|
74
|
+
The startup revision check (database standards §5.1) and the non-loopback token requirement
|
|
75
|
+
(ADR-0026) both run here, in the composition root, and deliberately not inside
|
|
76
|
+
:func:`~loadcoach.web.app.create_app` — that function is documented as a pure function of
|
|
77
|
+
:class:`~loadcoach.config.Settings` precisely so tests can build an app without touching the
|
|
78
|
+
filesystem, and opening a database is neither pure nor free.
|
|
79
|
+
|
|
80
|
+
The shipped task profiles are validated and imported here too, before anything can route: a
|
|
81
|
+
malformed profile is a startup failure (dev-plan P2 acceptance criterion 3), not a
|
|
82
|
+
routing-time surprise. Model discovery is best-effort
|
|
83
|
+
(:func:`~loadcoach.services.models.try_discover_models`) — spec §5 promises LoadCoach starts
|
|
84
|
+
and serves with no provider, so an unreachable one at startup degrades health rather than
|
|
85
|
+
blocking the server.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The wired :class:`Application`.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ConfigurationError: Configuration is invalid, or an unsafe bind combination is configured.
|
|
92
|
+
InsecureBindingError: ``server.host`` is not loopback and no active API token exists.
|
|
93
|
+
MigrationRequired: The database is behind head and ``storage.auto_migrate`` is false.
|
|
94
|
+
SchemaAhead: The database was written by a newer application version.
|
|
95
|
+
DatabaseUnavailable: The configured database could not be reached at all.
|
|
96
|
+
TaskProfileInvalid: A shipped task profile fails validation.
|
|
97
|
+
"""
|
|
98
|
+
loaded = load_settings()
|
|
99
|
+
configure_logging(
|
|
100
|
+
level=loaded.settings.logging.level, log_format=loaded.settings.logging.format
|
|
101
|
+
)
|
|
102
|
+
database_url = loaded.settings.storage.database_url
|
|
103
|
+
if database_url is None: # pragma: no cover — StorageSettings always fills this in
|
|
104
|
+
message = "no database_url configured"
|
|
105
|
+
raise RuntimeError(message)
|
|
106
|
+
with Database.from_url(
|
|
107
|
+
database_url, statement_timeout_ms=loaded.settings.storage.statement_timeout_ms
|
|
108
|
+
) as database:
|
|
109
|
+
ensure_ready(
|
|
110
|
+
database,
|
|
111
|
+
auto_migrate=loaded.settings.storage.auto_migrate,
|
|
112
|
+
backup_retention=loaded.settings.storage.backup_retention,
|
|
113
|
+
)
|
|
114
|
+
if loaded.settings.server.host not in LOOPBACK_HOSTS and not _has_active_token(database):
|
|
115
|
+
raise InsecureBindingError(
|
|
116
|
+
"server.host is not loopback but no active API token exists. A non-loopback bind "
|
|
117
|
+
"must have at least one token created first: `loadcoach token create`.",
|
|
118
|
+
details={"field": "server.host", "host": loaded.settings.server.host},
|
|
119
|
+
)
|
|
120
|
+
if (
|
|
121
|
+
loaded.settings.server.host not in LOOPBACK_HOSTS
|
|
122
|
+
and not loaded.settings.server.trusted_proxies
|
|
123
|
+
):
|
|
124
|
+
# ADR-0014 §7's startup warning (F5/M5C-5): LoadCoach speaks HTTP and expects a TLS
|
|
125
|
+
# reverse proxy on any non-loopback bind; `trusted_proxies` being set is the one
|
|
126
|
+
# piece of configuration that evidences one. Without it, warn — and say what breaks:
|
|
127
|
+
# the UI's cookies are `Secure`, so over plain HTTP beyond loopback the browser
|
|
128
|
+
# stores neither the CSRF nor the token cookie and the 401 page's flow cannot work.
|
|
129
|
+
logger.warning(
|
|
130
|
+
"server.plain_http_exposure",
|
|
131
|
+
extra={
|
|
132
|
+
"host": loaded.settings.server.host,
|
|
133
|
+
"detail": (
|
|
134
|
+
"non-loopback bind with no [server] trusted_proxies configured: "
|
|
135
|
+
"ADR-0014 §7 expects a TLS reverse proxy here. Over plain HTTP the "
|
|
136
|
+
"browser refuses the UI's Secure cookies, so the tokened-bind page "
|
|
137
|
+
"flow needs HTTPS or loopback; the bearer-token API is unaffected."
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
profiles = read_task_profiles_file()
|
|
142
|
+
import_task_profiles(database, profiles, now=datetime.now(UTC))
|
|
143
|
+
provider = build_provider(loaded.settings.provider)
|
|
144
|
+
try_discover_models(database, provider, now=datetime.now(UTC))
|
|
145
|
+
# After discovery: a manual score names a model by canonical_id and is skipped, not an
|
|
146
|
+
# error, if that model has not been discovered yet.
|
|
147
|
+
import_manual_capability_scores(database, now=datetime.now(UTC))
|
|
148
|
+
return Application(loaded_settings=loaded, app=create_app(loaded.settings))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_app_from_environment() -> FastAPI:
|
|
152
|
+
"""Zero-argument ASGI factory: the target uvicorn imports by dotted name.
|
|
153
|
+
|
|
154
|
+
See the module docstring for why this is referenced by string rather than imported directly by
|
|
155
|
+
``loadcoach.cli``.
|
|
156
|
+
"""
|
|
157
|
+
return bootstrap().app
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""loadcoach.cli.commands.config — show, validate, init, path.
|
|
2
|
+
|
|
3
|
+
Only ``typer`` and ``json`` load at module level; ``loadcoach.config`` (which imports pydantic) is
|
|
4
|
+
imported lazily inside each command body, per the same startup-performance discipline as
|
|
5
|
+
:mod:`loadcoach.cli.commands.system`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
__all__ = ["app"]
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Configuration inspection and management.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _looks_secret(field_name: str) -> bool:
|
|
21
|
+
lowered = field_name.lower()
|
|
22
|
+
return any(marker in lowered for marker in ("token", "key", "secret", "password"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("show")
|
|
26
|
+
def show(
|
|
27
|
+
config: Annotated[
|
|
28
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
29
|
+
] = None,
|
|
30
|
+
json_output: Annotated[
|
|
31
|
+
bool, typer.Option("--json", help="Print JSON instead of a table.")
|
|
32
|
+
] = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Print the effective configuration, with the source of every value.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
loadcoach config show --json
|
|
38
|
+
"""
|
|
39
|
+
from loadcoach.config import ConfigurationError, load_settings
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
loaded = load_settings(config_path=config)
|
|
43
|
+
except ConfigurationError as exc:
|
|
44
|
+
typer.echo(f"Error: {exc.message} ({exc.code})", err=True)
|
|
45
|
+
raise typer.Exit(3) from exc
|
|
46
|
+
|
|
47
|
+
dumped = loaded.settings.model_dump(mode="json")
|
|
48
|
+
if json_output:
|
|
49
|
+
typer.echo(
|
|
50
|
+
json.dumps(
|
|
51
|
+
{
|
|
52
|
+
"values": dumped,
|
|
53
|
+
"sources": loaded.sources,
|
|
54
|
+
"config_path": str(loaded.config_path),
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
typer.echo(
|
|
61
|
+
f"# {loaded.config_path}{'' if loaded.config_file_used else ' (not found; defaults apply)'}"
|
|
62
|
+
)
|
|
63
|
+
for section, fields in dumped.items():
|
|
64
|
+
for field_name, value in fields.items():
|
|
65
|
+
path = f"{section}.{field_name}"
|
|
66
|
+
source = loaded.sources.get(path, "default")
|
|
67
|
+
rendered = "********" if _looks_secret(field_name) else value
|
|
68
|
+
typer.echo(f"{path:<40} {rendered!s:<24} ({source})")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("validate")
|
|
72
|
+
def validate(
|
|
73
|
+
config: Annotated[
|
|
74
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
75
|
+
] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Validate configuration without starting the service. Exit 0 or 3.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
loadcoach config validate --config ./config.toml
|
|
81
|
+
"""
|
|
82
|
+
from loadcoach.config import ConfigurationError, load_settings
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
load_settings(config_path=config)
|
|
86
|
+
except ConfigurationError as exc:
|
|
87
|
+
typer.echo(f"Error: {exc.message} ({exc.code})", err=True)
|
|
88
|
+
raise typer.Exit(3) from exc
|
|
89
|
+
typer.echo("Configuration is valid.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("path")
|
|
93
|
+
def path(
|
|
94
|
+
config: Annotated[
|
|
95
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
96
|
+
] = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Print the resolved configuration file location.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
loadcoach config path
|
|
102
|
+
"""
|
|
103
|
+
from loadcoach.config import resolve_config_path
|
|
104
|
+
|
|
105
|
+
typer.echo(str(resolve_config_path(config)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command("init")
|
|
109
|
+
def init(
|
|
110
|
+
config: Annotated[
|
|
111
|
+
str | None, typer.Option("--config", help="Path to write the config file to.")
|
|
112
|
+
] = None,
|
|
113
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Write a fully commented example configuration file.
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
loadcoach config init --force
|
|
119
|
+
"""
|
|
120
|
+
from loadcoach.config import EXAMPLE_CONFIG_TOML, resolve_config_path
|
|
121
|
+
|
|
122
|
+
target = resolve_config_path(config)
|
|
123
|
+
if target.exists() and not force:
|
|
124
|
+
typer.echo(f"Error: {target} already exists (use --force to overwrite).", err=True)
|
|
125
|
+
raise typer.Exit(3)
|
|
126
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
target.write_text(EXAMPLE_CONFIG_TOML, encoding="utf-8")
|
|
128
|
+
typer.echo(str(target))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("reference")
|
|
132
|
+
def reference(
|
|
133
|
+
check: Annotated[
|
|
134
|
+
bool,
|
|
135
|
+
typer.Option(
|
|
136
|
+
"--check", help="Exit 1 if docs/configuration.md differs from the generated text."
|
|
137
|
+
),
|
|
138
|
+
] = False,
|
|
139
|
+
output: Annotated[
|
|
140
|
+
str | None, typer.Option("--output", help="Write to this file instead of stdout.")
|
|
141
|
+
] = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Generate the configuration reference from the settings model (configuration standards §8).
|
|
144
|
+
|
|
145
|
+
Mode: local. With ``--check``, compares against ``--output`` (or ``docs/configuration.md``)
|
|
146
|
+
and exits 1 on drift, which is what CI runs.
|
|
147
|
+
"""
|
|
148
|
+
from pathlib import Path
|
|
149
|
+
|
|
150
|
+
from loadcoach.services.config_reference import render_configuration_reference
|
|
151
|
+
|
|
152
|
+
rendered = render_configuration_reference()
|
|
153
|
+
target = Path(output) if output else Path("docs/configuration.md")
|
|
154
|
+
if check:
|
|
155
|
+
committed = target.read_text(encoding="utf-8") if target.is_file() else ""
|
|
156
|
+
if committed != rendered:
|
|
157
|
+
typer.echo(
|
|
158
|
+
f"{target} differs from the generated reference; run "
|
|
159
|
+
"`loadcoach config reference --output docs/configuration.md`",
|
|
160
|
+
err=True,
|
|
161
|
+
)
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
typer.echo(f"{target} matches the settings model")
|
|
164
|
+
return
|
|
165
|
+
if output:
|
|
166
|
+
target.write_text(rendered, encoding="utf-8")
|
|
167
|
+
typer.echo(f"wrote {target}")
|
|
168
|
+
else:
|
|
169
|
+
typer.echo(rendered)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""loadcoach.cli.commands.db — upgrade, status, backup, restore.
|
|
2
|
+
|
|
3
|
+
Every command here is **local** mode (CLI standards §6): it runs the service layer in-process
|
|
4
|
+
against the configured database and needs no server running. Only ``typer`` and ``json`` load at
|
|
5
|
+
module level, so registering this subgroup never pulls in SQLAlchemy or Alembic
|
|
6
|
+
(CLI standards §12).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Annotated
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from loadcoach.config import StorageSettings
|
|
21
|
+
from loadcoach.services.database import Database
|
|
22
|
+
|
|
23
|
+
__all__ = ["app"]
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Database migration and maintenance.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _open_database(config: str | None) -> Iterator[tuple[Database, StorageSettings]]:
|
|
30
|
+
"""Resolve configuration and open one database handle for this command, or exit 3.
|
|
31
|
+
|
|
32
|
+
One handle per command, closed on the way out — the CLI is one-shot, so it neither needs nor
|
|
33
|
+
wants the server's application-lifetime engine.
|
|
34
|
+
"""
|
|
35
|
+
from loadcoach.config import ConfigurationError, load_settings
|
|
36
|
+
from loadcoach.services.database import Database
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
loaded = load_settings(config_path=config)
|
|
40
|
+
except ConfigurationError as exc:
|
|
41
|
+
typer.echo(f"Error: {exc.message} ({exc.code})", err=True)
|
|
42
|
+
raise typer.Exit(3) from exc
|
|
43
|
+
storage = loaded.settings.storage
|
|
44
|
+
if storage.database_url is None: # pragma: no cover — StorageSettings always fills this in
|
|
45
|
+
typer.echo("Error: no database_url configured (CONFIGURATION_ERROR)", err=True)
|
|
46
|
+
raise typer.Exit(3)
|
|
47
|
+
with Database.from_url(
|
|
48
|
+
storage.database_url, statement_timeout_ms=storage.statement_timeout_ms
|
|
49
|
+
) as database:
|
|
50
|
+
yield database, storage
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _human_bytes(value: int) -> str:
|
|
54
|
+
"""Render a byte count at human scale, exact below 1 KiB."""
|
|
55
|
+
if value < 1024:
|
|
56
|
+
return f"{value} B"
|
|
57
|
+
scaled = float(value)
|
|
58
|
+
for unit in ("KiB", "MiB", "GiB"):
|
|
59
|
+
scaled /= 1024
|
|
60
|
+
if scaled < 1024 or unit == "GiB":
|
|
61
|
+
return f"{scaled:.1f} {unit}"
|
|
62
|
+
raise AssertionError("unreachable: the GiB branch always returns") # pragma: no cover
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fail(exc: Exception) -> typer.Exit:
|
|
66
|
+
"""Print the standard CLI error line and return the exit-4 (dependency unavailable) signal."""
|
|
67
|
+
code = getattr(exc, "code", "DATABASE_ERROR")
|
|
68
|
+
typer.echo(f"Error: {exc} ({code})", err=True)
|
|
69
|
+
return typer.Exit(4)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("upgrade")
|
|
73
|
+
def upgrade(
|
|
74
|
+
revision: Annotated[str, typer.Argument(help="Target revision.")] = "head",
|
|
75
|
+
config: Annotated[
|
|
76
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
77
|
+
] = None,
|
|
78
|
+
json_output: Annotated[
|
|
79
|
+
bool, typer.Option("--json", help="Print JSON instead of text.")
|
|
80
|
+
] = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Migrate the database to REVISION. Mode: local. A no-op at the target revision (exit 0).
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
loadcoach db upgrade
|
|
86
|
+
"""
|
|
87
|
+
from weightsdb import DatabaseError
|
|
88
|
+
|
|
89
|
+
from loadcoach.services.database import upgrade as upgrade_database
|
|
90
|
+
|
|
91
|
+
with _open_database(config) as (database, storage):
|
|
92
|
+
try:
|
|
93
|
+
outcome = upgrade_database(
|
|
94
|
+
database, revision=revision, backup_retention=storage.backup_retention
|
|
95
|
+
)
|
|
96
|
+
except DatabaseError as exc:
|
|
97
|
+
raise _fail(exc) from exc
|
|
98
|
+
|
|
99
|
+
if json_output:
|
|
100
|
+
typer.echo(
|
|
101
|
+
json.dumps(
|
|
102
|
+
{
|
|
103
|
+
"from_revision": outcome.from_revision,
|
|
104
|
+
"to_revision": outcome.to_revision,
|
|
105
|
+
"backed_up": outcome.backed_up,
|
|
106
|
+
"backup_path": str(outcome.backup_path) if outcome.backup_path else None,
|
|
107
|
+
"pruned_backups": [str(path) for path in outcome.pruned_backups],
|
|
108
|
+
"restore_on_failure_available": outcome.restore_on_failure_available,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
typer.echo(f"{outcome.from_revision or '(empty)'} -> {outcome.to_revision}")
|
|
114
|
+
if outcome.backed_up:
|
|
115
|
+
typer.echo(f"Backup written to {outcome.backup_path}")
|
|
116
|
+
for path in outcome.pruned_backups:
|
|
117
|
+
typer.echo(f"Rotated out old backup {path}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("status")
|
|
121
|
+
def status(
|
|
122
|
+
config: Annotated[
|
|
123
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
124
|
+
] = None,
|
|
125
|
+
json_output: Annotated[
|
|
126
|
+
bool, typer.Option("--json", help="Print JSON instead of a table.")
|
|
127
|
+
] = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Report revision, table row counts and integrity status. Mode: local.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
loadcoach db status --json
|
|
133
|
+
"""
|
|
134
|
+
from weightsdb import DatabaseError
|
|
135
|
+
|
|
136
|
+
from loadcoach.services.database import get_status
|
|
137
|
+
|
|
138
|
+
with _open_database(config) as (database, _):
|
|
139
|
+
try:
|
|
140
|
+
report = get_status(database)
|
|
141
|
+
except DatabaseError as exc:
|
|
142
|
+
raise _fail(exc) from exc
|
|
143
|
+
|
|
144
|
+
if json_output:
|
|
145
|
+
typer.echo(
|
|
146
|
+
json.dumps(
|
|
147
|
+
{
|
|
148
|
+
"dialect": report.dialect,
|
|
149
|
+
"current_revision": report.current_revision,
|
|
150
|
+
"head_revision": report.head_revision,
|
|
151
|
+
"is_at_head": report.is_at_head,
|
|
152
|
+
"table_row_counts": report.table_row_counts,
|
|
153
|
+
"size_bytes": report.size_bytes,
|
|
154
|
+
"integrity_ok": report.integrity_ok,
|
|
155
|
+
"integrity_detail": report.integrity_detail,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
typer.echo(f"dialect: {report.dialect}")
|
|
161
|
+
typer.echo(
|
|
162
|
+
f"revision: {report.current_revision or '(none)'} (head: {report.head_revision})"
|
|
163
|
+
)
|
|
164
|
+
typer.echo(f"at head: {report.is_at_head}")
|
|
165
|
+
typer.echo(f"size: {_human_bytes(report.size_bytes)}")
|
|
166
|
+
typer.echo(f"integrity: {'ok' if report.integrity_ok else report.integrity_detail}")
|
|
167
|
+
for table, count in sorted(report.table_row_counts.items()):
|
|
168
|
+
typer.echo(f" {table:<24} {count}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command("backup")
|
|
172
|
+
def backup(
|
|
173
|
+
output: Annotated[
|
|
174
|
+
Path | None, typer.Option("--output", "-o", help="Backup destination.")
|
|
175
|
+
] = None,
|
|
176
|
+
config: Annotated[
|
|
177
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
178
|
+
] = None,
|
|
179
|
+
json_output: Annotated[
|
|
180
|
+
bool, typer.Option("--json", help="Print JSON instead of text.")
|
|
181
|
+
] = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Take a consistent backup of the database. Mode: local.
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
loadcoach db backup --output ./loadcoach-before-upgrade.sqlite3
|
|
187
|
+
"""
|
|
188
|
+
from weightsdb import DatabaseError
|
|
189
|
+
|
|
190
|
+
from loadcoach.services.database import backup_database
|
|
191
|
+
|
|
192
|
+
with _open_database(config) as (database, storage):
|
|
193
|
+
try:
|
|
194
|
+
result = backup_database(database, output=output, keep=storage.backup_retention)
|
|
195
|
+
except DatabaseError as exc:
|
|
196
|
+
raise _fail(exc) from exc
|
|
197
|
+
|
|
198
|
+
if json_output:
|
|
199
|
+
typer.echo(
|
|
200
|
+
json.dumps(
|
|
201
|
+
{
|
|
202
|
+
"path": str(result.path),
|
|
203
|
+
"size_bytes": result.size_bytes,
|
|
204
|
+
"created_at": result.created_at.isoformat(),
|
|
205
|
+
"dialect": result.dialect,
|
|
206
|
+
"pruned": [str(path) for path in result.pruned],
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
typer.echo(str(result.path))
|
|
212
|
+
for path in result.pruned:
|
|
213
|
+
typer.echo(f"Rotated out old backup {path}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command("restore")
|
|
217
|
+
def restore(
|
|
218
|
+
source: Annotated[Path, typer.Argument(help="Backup file to restore from.")],
|
|
219
|
+
yes: Annotated[
|
|
220
|
+
bool, typer.Option("--yes", "-y", help="Confirm the restore; required, non-interactive.")
|
|
221
|
+
] = False,
|
|
222
|
+
config: Annotated[
|
|
223
|
+
str | None, typer.Option("--config", help="Path to a config.toml file.")
|
|
224
|
+
] = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Restore the database from SOURCE, overwriting the current one. Mode: local.
|
|
227
|
+
|
|
228
|
+
Requires ``--yes``: there is no interactive prompt (CLI standards §5), and refusing without it
|
|
229
|
+
is exit 2 naming the flag that would have answered it.
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
loadcoach db restore ./backups/loadcoach-0001-20260829T090000Z.sqlite3 --yes
|
|
233
|
+
"""
|
|
234
|
+
from weightsdb import DatabaseError
|
|
235
|
+
|
|
236
|
+
from loadcoach.services.database import restore_database
|
|
237
|
+
|
|
238
|
+
if not yes:
|
|
239
|
+
typer.echo("Error: --yes is required to confirm this destructive operation.", err=True)
|
|
240
|
+
raise typer.Exit(2)
|
|
241
|
+
|
|
242
|
+
with _open_database(config) as (database, _):
|
|
243
|
+
try:
|
|
244
|
+
result = restore_database(database, source=source, confirm=True)
|
|
245
|
+
except DatabaseError as exc:
|
|
246
|
+
raise _fail(exc) from exc
|
|
247
|
+
typer.echo(
|
|
248
|
+
f"Restored {result.path} from {result.source} (revision {result.revision or '(none)'})"
|
|
249
|
+
)
|