codetrail-cli 1.4.1__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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/web/app.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""The local page's application: routes over one target's facts, sources and signal (design section 7.1).
|
|
2
|
+
|
|
3
|
+
Every route is behind the security middleware. Templates autoescape; interface text comes from the reader's catalog,
|
|
4
|
+
and guide content is marked as English.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import secrets
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections import deque
|
|
18
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
19
|
+
from contextlib import ExitStack, asynccontextmanager, contextmanager
|
|
20
|
+
from datetime import UTC, date, datetime
|
|
21
|
+
from importlib.resources import files
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Annotated, Any
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, Query, Request
|
|
26
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
|
|
27
|
+
from fastapi.staticfiles import StaticFiles
|
|
28
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
29
|
+
from markupsafe import Markup
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
|
31
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
32
|
+
|
|
33
|
+
from codetrail.assistant import Assistant
|
|
34
|
+
from codetrail.assistant.estimate import CallEstimate, UpdateEstimate, estimate_call, tokens_text, when_text
|
|
35
|
+
from codetrail.assistant.routing import build_assistant
|
|
36
|
+
from codetrail.assistant.status import require_ready
|
|
37
|
+
from codetrail.assistant.usage import last_update_usage
|
|
38
|
+
from codetrail.bridge import bridge_router
|
|
39
|
+
from codetrail.config import GlobalConfig, Paths, load_target, model_choice
|
|
40
|
+
from codetrail.database import connect
|
|
41
|
+
from codetrail.errors import CodetrailError
|
|
42
|
+
from codetrail.facts import EntityKind
|
|
43
|
+
from codetrail.generate.run import Progress
|
|
44
|
+
from codetrail.generate.scope import sources_changed
|
|
45
|
+
from codetrail.guide import PAGE_ID, GuideRepository, Page, parse_page
|
|
46
|
+
from codetrail.learn import LearningState, page_checks
|
|
47
|
+
from codetrail.learn.routes import learning_router
|
|
48
|
+
from codetrail.lock import TargetBusy, target_in_use
|
|
49
|
+
from codetrail.metrics.history import recent_values, values_before
|
|
50
|
+
from codetrail.metrics.report import Metric, MetricsReport, build_report
|
|
51
|
+
from codetrail.metrics.repository import RepositoryReport, build_repository_report
|
|
52
|
+
from codetrail.repo.history import first_commit_time
|
|
53
|
+
from codetrail.repo.signal import Signal, behind
|
|
54
|
+
from codetrail.search import Result, SearchIndex, guide_documents
|
|
55
|
+
from codetrail.update import run_update
|
|
56
|
+
from codetrail.web.diagrams import dependencies_diagram, imports_diagram, system_diagram, system_parts
|
|
57
|
+
from codetrail.web.documentation import (
|
|
58
|
+
ReportCache,
|
|
59
|
+
age,
|
|
60
|
+
build_tiles,
|
|
61
|
+
commit_kind,
|
|
62
|
+
days_proposed,
|
|
63
|
+
filtered,
|
|
64
|
+
section_to_open,
|
|
65
|
+
segments,
|
|
66
|
+
)
|
|
67
|
+
from codetrail.web.i18n import Language, installed_languages
|
|
68
|
+
from codetrail.web.navigation import (
|
|
69
|
+
PINNABLE,
|
|
70
|
+
Navigation,
|
|
71
|
+
answers_newest_first,
|
|
72
|
+
build_navigation,
|
|
73
|
+
continue_reading,
|
|
74
|
+
initials,
|
|
75
|
+
path_neighbours,
|
|
76
|
+
read_pins,
|
|
77
|
+
steps_of,
|
|
78
|
+
)
|
|
79
|
+
from codetrail.web.render import render_body
|
|
80
|
+
from codetrail.web.repository_view import (
|
|
81
|
+
bar_heights,
|
|
82
|
+
busiest_weekday,
|
|
83
|
+
chart_document,
|
|
84
|
+
language_segments,
|
|
85
|
+
month_names,
|
|
86
|
+
months_table,
|
|
87
|
+
ramp_step,
|
|
88
|
+
script_json,
|
|
89
|
+
weekday_full_names,
|
|
90
|
+
)
|
|
91
|
+
from codetrail.web.security import OPEN_PATHS, SESSION_COOKIE, SecurityMiddleware, SessionState, login_response
|
|
92
|
+
from codetrail.web.target_view import TargetView
|
|
93
|
+
|
|
94
|
+
LANGUAGE_SETTING = "language"
|
|
95
|
+
logger = logging.getLogger(__name__)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LanguageChoice(BaseModel):
|
|
99
|
+
model_config = ConfigDict(extra="forbid")
|
|
100
|
+
language: str = Field(max_length=16)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
PINS_SETTING = "pinned_pages"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PinChoice(BaseModel):
|
|
107
|
+
model_config = ConfigDict(extra="forbid")
|
|
108
|
+
pages: list[Annotated[str, StringConstraints(max_length=200)]] = Field(max_length=50)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TargetInUseMiddleware:
|
|
112
|
+
"""Each request holds the target's in-use lock, so no removal runs under it and none recreates a removed target.
|
|
113
|
+
|
|
114
|
+
`serve` holds the lock too, but on the settings file it opened; an editor may have replaced that file since."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, app: ASGIApp, paths: Paths, name: str) -> None:
|
|
117
|
+
self.app = app
|
|
118
|
+
self.paths = paths
|
|
119
|
+
self.name = name
|
|
120
|
+
|
|
121
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
122
|
+
if scope["type"] != "http" or scope["path"].startswith(OPEN_PATHS): # no session, and no target data
|
|
123
|
+
await self.app(scope, receive, send)
|
|
124
|
+
return
|
|
125
|
+
held = ExitStack()
|
|
126
|
+
try:
|
|
127
|
+
held.enter_context(target_in_use(self.paths, self.name))
|
|
128
|
+
except TargetBusy as error:
|
|
129
|
+
await PlainTextResponse(str(error), 409)(scope, receive, send)
|
|
130
|
+
return
|
|
131
|
+
except CodetrailError as error:
|
|
132
|
+
await PlainTextResponse(str(error), 410)(scope, receive, send)
|
|
133
|
+
return
|
|
134
|
+
with held: # a streamed answer finishes before the app returns, so the lock covers it too
|
|
135
|
+
await self.app(scope, receive, send)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_app(
|
|
139
|
+
paths: Paths,
|
|
140
|
+
name: str,
|
|
141
|
+
session: SessionState,
|
|
142
|
+
settings: GlobalConfig,
|
|
143
|
+
locales: Path | None = None,
|
|
144
|
+
updater: Callable[[Callable[[UpdateEstimate], bool], Progress], object] | None = None,
|
|
145
|
+
assistant_for: Callable[[], Assistant] | None = None,
|
|
146
|
+
) -> ASGIApp:
|
|
147
|
+
"""The page, wrapped in the security middleware outside everything, so every response passes through it.
|
|
148
|
+
|
|
149
|
+
`updater` runs one update; the Update button runs it in a background thread (tests pass a fake one).
|
|
150
|
+
"""
|
|
151
|
+
stopping = threading.Event()
|
|
152
|
+
job = UpdateJob(
|
|
153
|
+
updater
|
|
154
|
+
or (lambda confirm, progress: run_update(paths, name, confirm=confirm, progress=progress, stop=stopping)),
|
|
155
|
+
stopping,
|
|
156
|
+
settings.server.update_cooldown_seconds,
|
|
157
|
+
settings.server.estimate_ttl_seconds,
|
|
158
|
+
settings.server.update_log_lines,
|
|
159
|
+
gettext_of=lambda: language().translations.gettext,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@asynccontextmanager
|
|
163
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
164
|
+
try:
|
|
165
|
+
yield
|
|
166
|
+
finally: # also when the server is cancelled rather than shut down (a hangup)
|
|
167
|
+
job.stop()
|
|
168
|
+
|
|
169
|
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
|
|
170
|
+
view = TargetView(paths, name)
|
|
171
|
+
guide = GuideRepository(paths.target_data(name) / "guide")
|
|
172
|
+
|
|
173
|
+
@contextmanager
|
|
174
|
+
def learning() -> Iterator[LearningState]:
|
|
175
|
+
connection = connect(view.data / "codetrail.db")
|
|
176
|
+
try:
|
|
177
|
+
yield LearningState(connection)
|
|
178
|
+
finally:
|
|
179
|
+
connection.close()
|
|
180
|
+
|
|
181
|
+
def real_assistant() -> Assistant:
|
|
182
|
+
target = load_target(paths, name)
|
|
183
|
+
require_ready(target, settings, kinds=("answer", "grade"))
|
|
184
|
+
return build_assistant(paths.target_data(name) / "source", settings, target)
|
|
185
|
+
|
|
186
|
+
languages = installed_languages(locales)
|
|
187
|
+
templates = str(files("codetrail.web").joinpath("templates"))
|
|
188
|
+
environments = {code: _environment(templates, language) for code, language in languages.items()}
|
|
189
|
+
signal_cache: dict[str, tuple[float, Signal | None]] = {}
|
|
190
|
+
index_lock = threading.Lock()
|
|
191
|
+
index_built: dict[str, object] = {"key": None, "index": None}
|
|
192
|
+
reports: ReportCache[MetricsReport] = ReportCache()
|
|
193
|
+
repository_reports: ReportCache[RepositoryReport] = ReportCache()
|
|
194
|
+
first_commits: ReportCache[date | None] = ReportCache()
|
|
195
|
+
|
|
196
|
+
def search_index() -> SearchIndex | None:
|
|
197
|
+
"""The index, rebuilt whenever the guide or the facts moved on (an update, a saved answer); None if broken.
|
|
198
|
+
|
|
199
|
+
The key includes uncommitted changes, so pages a failed update discards leave the index at the next search.
|
|
200
|
+
"""
|
|
201
|
+
with index_lock:
|
|
202
|
+
try:
|
|
203
|
+
database = view.data / "codetrail.db"
|
|
204
|
+
tracked = guide.git_dir.exists()
|
|
205
|
+
head = (guide.head(), guide.has_uncommitted_changes()) if tracked else None
|
|
206
|
+
index = index_built["index"] if isinstance(index_built["index"], SearchIndex) else SearchIndex()
|
|
207
|
+
if not database.exists():
|
|
208
|
+
key: object = (head, None)
|
|
209
|
+
if index_built["key"] != key:
|
|
210
|
+
index.rebuild(guide_documents(guide, None))
|
|
211
|
+
else:
|
|
212
|
+
with view.store() as store:
|
|
213
|
+
key = (head, store.latest_snapshot())
|
|
214
|
+
if index_built["key"] != key:
|
|
215
|
+
index.rebuild(guide_documents(guide, store))
|
|
216
|
+
except Exception as error: # search is optional; the rest of the page keeps working
|
|
217
|
+
logger.warning("Search isn't available: %s", type(error).__name__)
|
|
218
|
+
index_built.update(key=None)
|
|
219
|
+
return None
|
|
220
|
+
index_built.update(key=key, index=index)
|
|
221
|
+
return index
|
|
222
|
+
|
|
223
|
+
def search_results(query: str) -> tuple[list[Result], bool]:
|
|
224
|
+
found = search_index()
|
|
225
|
+
if found is None:
|
|
226
|
+
return [], False
|
|
227
|
+
return found.search(query, settings.search.max_query_chars, settings.search.max_results), True
|
|
228
|
+
|
|
229
|
+
def setting(key: str) -> str | None:
|
|
230
|
+
"""One of the target's stored settings, or None before the database exists."""
|
|
231
|
+
database = view.data / "codetrail.db"
|
|
232
|
+
if not database.exists():
|
|
233
|
+
return None
|
|
234
|
+
connection = connect(database)
|
|
235
|
+
try:
|
|
236
|
+
row = connection.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
|
237
|
+
finally:
|
|
238
|
+
connection.close()
|
|
239
|
+
return row["value"] if row else None
|
|
240
|
+
|
|
241
|
+
def store_setting(key: str, value: str) -> None:
|
|
242
|
+
"""Keeps one of the target's settings, replacing what it held."""
|
|
243
|
+
connection = connect(view.data / "codetrail.db")
|
|
244
|
+
try:
|
|
245
|
+
connection.execute(
|
|
246
|
+
"INSERT INTO settings (key, value) VALUES (?, ?)"
|
|
247
|
+
" ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
|
248
|
+
(key, value),
|
|
249
|
+
)
|
|
250
|
+
finally:
|
|
251
|
+
connection.close()
|
|
252
|
+
|
|
253
|
+
def language() -> Language:
|
|
254
|
+
return languages.get(setting(LANGUAGE_SETTING) or settings.ui.default_language) or languages["en"]
|
|
255
|
+
|
|
256
|
+
def call_estimates() -> dict[str, CallEstimate]:
|
|
257
|
+
"""What a question and a graded check are expected to use, for their buttons (design section 15.4)."""
|
|
258
|
+
database = view.data / "codetrail.db"
|
|
259
|
+
if not database.exists():
|
|
260
|
+
return {}
|
|
261
|
+
target = load_target(paths, name)
|
|
262
|
+
connection = connect(database)
|
|
263
|
+
try:
|
|
264
|
+
return {
|
|
265
|
+
kind: estimate_call(connection, kind, *model_choice(getattr(target.models, kind)), settings.estimates,
|
|
266
|
+
settings.prices)
|
|
267
|
+
for kind in ("answer", "grade")
|
|
268
|
+
} # fmt: skip
|
|
269
|
+
finally:
|
|
270
|
+
connection.close()
|
|
271
|
+
|
|
272
|
+
def statuses_of(pages: list[Page]) -> tuple[dict[str, str], list[str]]:
|
|
273
|
+
"""Each area's and concept's learning state, and the unread digests, newest first."""
|
|
274
|
+
if not (view.data / "codetrail.db").exists():
|
|
275
|
+
return {}, []
|
|
276
|
+
digests = sorted((page for page in pages if page.kind == "digest"), key=lambda page: str(page.meta.get(
|
|
277
|
+
"written_at", "")), reverse=True) # fmt: skip
|
|
278
|
+
with learning() as state:
|
|
279
|
+
statuses = {page.id: state.status(page).state for page in pages if page.kind in ("area", "concept")}
|
|
280
|
+
return statuses, state.unread_digests([page.id for page in digests])
|
|
281
|
+
|
|
282
|
+
def navigation(pages: list[Page]) -> Navigation:
|
|
283
|
+
statuses, unread = statuses_of(pages)
|
|
284
|
+
manifest = view.manifest()
|
|
285
|
+
try:
|
|
286
|
+
branch = load_target(paths, name).branch
|
|
287
|
+
except CodetrailError:
|
|
288
|
+
branch = ""
|
|
289
|
+
return build_navigation(pages, statuses, unread, branch, manifest.commit if manifest else "",
|
|
290
|
+
pinned=read_pins(setting(PINS_SETTING))[: settings.ui.max_pins]) # fmt: skip
|
|
291
|
+
|
|
292
|
+
def render(template: str, status_code: int = 200, **context: Any) -> HTMLResponse:
|
|
293
|
+
current = language()
|
|
294
|
+
context.setdefault("active", "")
|
|
295
|
+
context.setdefault("has_system", has_system())
|
|
296
|
+
if "nav" not in context:
|
|
297
|
+
context["nav"] = navigation(guide.pages() if guide.root.exists() else [])
|
|
298
|
+
html = (
|
|
299
|
+
environments[current.code]
|
|
300
|
+
.get_template(template)
|
|
301
|
+
.render(language=current, languages=list(languages.values()), token=session.token, target=name,
|
|
302
|
+
estimates=call_estimates(), max_pins=settings.ui.max_pins, **context)
|
|
303
|
+
) # fmt: skip
|
|
304
|
+
return HTMLResponse(html, status_code=status_code)
|
|
305
|
+
|
|
306
|
+
def has_system() -> bool:
|
|
307
|
+
"""Whether the facts hold any parts: the sidebar links to the system page only then."""
|
|
308
|
+
if not (view.data / "codetrail.db").exists():
|
|
309
|
+
return False
|
|
310
|
+
with view.store() as store:
|
|
311
|
+
return bool(store.entities(EntityKind.PART))
|
|
312
|
+
|
|
313
|
+
def not_found() -> HTMLResponse:
|
|
314
|
+
return render("error.html", status_code=404)
|
|
315
|
+
|
|
316
|
+
def current_signal() -> Signal | None:
|
|
317
|
+
cached = signal_cache.get("signal")
|
|
318
|
+
if cached and time.monotonic() - cached[0] < settings.signal.cache_seconds:
|
|
319
|
+
return cached[1]
|
|
320
|
+
try:
|
|
321
|
+
result: Signal | None = behind(paths, name)
|
|
322
|
+
except CodetrailError:
|
|
323
|
+
result = None
|
|
324
|
+
signal_cache["signal"] = (time.monotonic(), result)
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
@app.get("/login")
|
|
328
|
+
def login(code: str = "") -> Response:
|
|
329
|
+
return login_response(session, code)
|
|
330
|
+
|
|
331
|
+
@app.get("/", response_class=HTMLResponse)
|
|
332
|
+
def home() -> HTMLResponse:
|
|
333
|
+
counts: list[tuple[str, int]] = []
|
|
334
|
+
snapshot = None
|
|
335
|
+
last_update = None
|
|
336
|
+
system = None
|
|
337
|
+
documentation = None
|
|
338
|
+
repository = None
|
|
339
|
+
if (view.data / "codetrail.db").exists():
|
|
340
|
+
with view.store() as store:
|
|
341
|
+
snapshot = store.latest_snapshot()
|
|
342
|
+
diagram = system_diagram(store, None, settings.diagrams.max_nodes)
|
|
343
|
+
if diagram.nodes:
|
|
344
|
+
parts = len(store.entities(EntityKind.PART))
|
|
345
|
+
system = {"parts": parts, "connections": len(diagram.arrows)}
|
|
346
|
+
kinds: dict[str, int] = {}
|
|
347
|
+
for entity in store.entities():
|
|
348
|
+
kinds[str(entity.kind)] = kinds.get(str(entity.kind), 0) + 1
|
|
349
|
+
counts = sorted(kinds.items())
|
|
350
|
+
last_update = last_update_usage(store.connection)
|
|
351
|
+
recorded = [value for _, value in recent_values(store.connection, Metric.DOCUMENTED_SHARE, 2)]
|
|
352
|
+
documentation = build_tiles(
|
|
353
|
+
{Metric.DOCUMENTED_SHARE: recorded[-1]} if recorded else {},
|
|
354
|
+
{Metric.DOCUMENTED_SHARE: recorded[0]} if len(recorded) > 1 else {},
|
|
355
|
+
{},
|
|
356
|
+
)[Metric.DOCUMENTED_SHARE]
|
|
357
|
+
if snapshot is not None:
|
|
358
|
+
repository = repository_summary(store.connection, snapshot.id, snapshot.commit)
|
|
359
|
+
pages = guide.pages() if guide.root.exists() else []
|
|
360
|
+
nav = navigation(pages)
|
|
361
|
+
guide_pages = [page for page in pages if page.kind in ("area", "concept")]
|
|
362
|
+
digests = sorted((page for page in pages if page.kind == "digest"), key=lambda page: str(page.meta.get(
|
|
363
|
+
"written_at", "")), reverse=True) # fmt: skip
|
|
364
|
+
_, unread = statuses_of(pages)
|
|
365
|
+
unread_digests = [page for page in digests if page.id in set(unread)]
|
|
366
|
+
learned = sum(1 for page in guide_pages if nav.statuses.get(page.id) == "learned")
|
|
367
|
+
stale = [page for page in guide_pages if nav.statuses.get(page.id) == "stale"]
|
|
368
|
+
return render(
|
|
369
|
+
"home.html", nav=nav, active="home", signal=current_signal(), areas=view.areas(), counts=counts,
|
|
370
|
+
snapshot=snapshot, guide_pages=guide_pages, latest_digest=digests[0] if digests else None,
|
|
371
|
+
unread_digests=unread_digests, stale_pages=stale, learned=learned, last_update=last_update,
|
|
372
|
+
continue_reading=continue_reading(pages, nav.statuses), system=system, has_system=system is not None,
|
|
373
|
+
documentation=documentation, repository=repository,
|
|
374
|
+
) # fmt: skip
|
|
375
|
+
|
|
376
|
+
@app.get("/system", response_class=HTMLResponse)
|
|
377
|
+
def system() -> HTMLResponse:
|
|
378
|
+
if not (view.data / "codetrail.db").exists():
|
|
379
|
+
return not_found()
|
|
380
|
+
with view.store() as store:
|
|
381
|
+
diagram = system_diagram(store, None, settings.diagrams.max_nodes)
|
|
382
|
+
parts = system_parts(store)
|
|
383
|
+
if not parts:
|
|
384
|
+
return not_found()
|
|
385
|
+
return render("system.html", active="system", diagram=diagram, parts=parts, has_system=True)
|
|
386
|
+
|
|
387
|
+
@app.get("/progress", response_class=HTMLResponse)
|
|
388
|
+
def progress() -> HTMLResponse:
|
|
389
|
+
pages = guide.pages() if guide.root.exists() else []
|
|
390
|
+
nav = navigation(pages)
|
|
391
|
+
by_id = {page.id: page for page in pages}
|
|
392
|
+
paths_steps = [
|
|
393
|
+
(path, learned, total, [by_id[step] for step in steps_of(path) if step in by_id])
|
|
394
|
+
for path, learned, total in nav.paths
|
|
395
|
+
]
|
|
396
|
+
unpathed = [
|
|
397
|
+
page for page in [*nav.areas, *nav.concepts]
|
|
398
|
+
if not any(page.id in steps_of(path) for path, _, _ in nav.paths)
|
|
399
|
+
] # fmt: skip
|
|
400
|
+
return render("progress.html", nav=nav, active="progress", paths_steps=paths_steps, unpathed=unpathed)
|
|
401
|
+
|
|
402
|
+
@app.get("/answers", response_class=HTMLResponse)
|
|
403
|
+
def saved_answers() -> HTMLResponse:
|
|
404
|
+
pages = guide.pages() if guide.root.exists() else []
|
|
405
|
+
manifest = view.manifest()
|
|
406
|
+
answers = [
|
|
407
|
+
(answer, manifest is not None and sources_changed(answer, manifest))
|
|
408
|
+
for answer in answers_newest_first(page for page in pages if page.kind == "answer")
|
|
409
|
+
]
|
|
410
|
+
return render("answers.html", nav=navigation(pages), active="answers", answers=answers)
|
|
411
|
+
|
|
412
|
+
@app.get("/digests", response_class=HTMLResponse)
|
|
413
|
+
def digests() -> HTMLResponse:
|
|
414
|
+
pages = guide.pages() if guide.root.exists() else []
|
|
415
|
+
found = sorted((page for page in pages if page.kind == "digest"), key=lambda page: str(page.meta.get(
|
|
416
|
+
"written_at", "")), reverse=True) # fmt: skip
|
|
417
|
+
_, unread = statuses_of(pages)
|
|
418
|
+
return render("digests.html", nav=navigation(pages), active="digests", digests=found, unread=set(unread))
|
|
419
|
+
|
|
420
|
+
@app.get("/pages/{page_id:path}", response_class=HTMLResponse)
|
|
421
|
+
def guide_page(page_id: str, path: str | None = None) -> HTMLResponse:
|
|
422
|
+
if not PAGE_ID.fullmatch(page_id) or not (view.data / "codetrail.db").exists():
|
|
423
|
+
return not_found()
|
|
424
|
+
page = guide.read_page(page_id)
|
|
425
|
+
if page is None:
|
|
426
|
+
return not_found()
|
|
427
|
+
manifest = view.manifest()
|
|
428
|
+
changed = manifest is not None and page.kind != "digest" and sources_changed(page, manifest)
|
|
429
|
+
with view.store() as store:
|
|
430
|
+
segments = render_body(page.body, store, settings.diagrams.max_nodes)
|
|
431
|
+
with learning() as state:
|
|
432
|
+
status = state.status(page)
|
|
433
|
+
checks = [{"id": check["id"], "question": check.get("question", ""), "passed": check["id"] in status.passed}
|
|
434
|
+
for check in page_checks(page)] # the rubric stays on the server # fmt: skip
|
|
435
|
+
changes = None
|
|
436
|
+
if status.state == "stale" and status.learned_commit:
|
|
437
|
+
before = guide.file_at(status.learned_commit, page.id)
|
|
438
|
+
if before is not None:
|
|
439
|
+
changes = "\n".join(difflib.unified_diff(
|
|
440
|
+
parse_page(page.id, before).body.splitlines(), page.body.splitlines(),
|
|
441
|
+
"when you learned it", "now", lineterm="",
|
|
442
|
+
)) # fmt: skip
|
|
443
|
+
pages = guide.pages()
|
|
444
|
+
titles = {found.id: found.title for found in pages}
|
|
445
|
+
steps = [step for step in steps_of(page) if step in titles] if page.kind == "path" else [] # real pages only
|
|
446
|
+
in_path, previous, following = path_neighbours(
|
|
447
|
+
pages, page.id, path if path and PAGE_ID.fullmatch(path) else None
|
|
448
|
+
)
|
|
449
|
+
return render(
|
|
450
|
+
"page.html", nav=navigation(pages), active=page.id, page=page, segments=segments, sources_changed=changed,
|
|
451
|
+
status=status.state, checks=checks, changes=changes, in_path=in_path, previous=previous,
|
|
452
|
+
following=following, titles=titles, steps=steps,
|
|
453
|
+
) # fmt: skip
|
|
454
|
+
|
|
455
|
+
@app.get("/search", response_class=HTMLResponse)
|
|
456
|
+
def search_page(q: str = "") -> HTMLResponse:
|
|
457
|
+
results, available = search_results(q)
|
|
458
|
+
return render("search.html", query=q[: settings.search.max_query_chars], results=results, available=available)
|
|
459
|
+
|
|
460
|
+
@app.get("/search/results")
|
|
461
|
+
def search_json(q: str = "") -> Response:
|
|
462
|
+
results, available = search_results(q)
|
|
463
|
+
payload = [
|
|
464
|
+
{"title": result.document.title, "kind": result.document.kind, "link": result.document.link,
|
|
465
|
+
"snippet": [[text, matched] for text, matched in result.snippet]}
|
|
466
|
+
for result in results
|
|
467
|
+
] # fmt: skip
|
|
468
|
+
return JSONResponse({"results": payload, "available": available}, headers={"Cache-Control": "no-store"})
|
|
469
|
+
|
|
470
|
+
@app.post("/update")
|
|
471
|
+
def start_update() -> Response:
|
|
472
|
+
refusal = job.start()
|
|
473
|
+
if refusal is None:
|
|
474
|
+
return JSONResponse(job.status(), status_code=202)
|
|
475
|
+
busy = job.state in ("preparing", "waiting", "running")
|
|
476
|
+
return JSONResponse({**job.status(), "message": refusal}, status_code=409 if busy else 429)
|
|
477
|
+
|
|
478
|
+
@app.get("/update/status")
|
|
479
|
+
def update_status(after: Annotated[int, Query(ge=0, le=2**53)] = 0) -> Response:
|
|
480
|
+
"""The update's state, and its steps after number `after`, rendered for the page's update panel."""
|
|
481
|
+
status = job.status()
|
|
482
|
+
environment = environments[language().code]
|
|
483
|
+
if "estimate" in status: # rendered here, in the reader's language and autoescaped, for the page's dialog
|
|
484
|
+
status["estimate_html"] = environment.get_template("estimate.html").render(estimate=status["estimate"])
|
|
485
|
+
steps, status["next"] = job.steps(after)
|
|
486
|
+
log = environment.get_template("update_log.html")
|
|
487
|
+
status["log_html"] = log.render(steps=steps, target=name) if steps else ""
|
|
488
|
+
return JSONResponse(status)
|
|
489
|
+
|
|
490
|
+
@app.post("/update/confirm")
|
|
491
|
+
def confirm_update(decision: EstimateDecision) -> Response:
|
|
492
|
+
if not job.decide(decision.estimate_id, go_ahead=True):
|
|
493
|
+
expired = language().translations.gettext("That estimate isn't waiting any more; start the update again.")
|
|
494
|
+
return JSONResponse({"error": expired}, 428)
|
|
495
|
+
return JSONResponse({"state": "running"})
|
|
496
|
+
|
|
497
|
+
@app.post("/update/cancel")
|
|
498
|
+
def cancel_update(decision: EstimateDecision) -> Response:
|
|
499
|
+
if not job.decide(decision.estimate_id, go_ahead=False):
|
|
500
|
+
expired = language().translations.gettext("That estimate isn't waiting any more.")
|
|
501
|
+
return JSONResponse({"error": expired}, 428)
|
|
502
|
+
return JSONResponse({"state": "cancelled"})
|
|
503
|
+
|
|
504
|
+
@app.get("/areas/{scope:path}", response_class=HTMLResponse)
|
|
505
|
+
def area(scope: str) -> HTMLResponse:
|
|
506
|
+
files_here = view.files_under(scope)
|
|
507
|
+
if not files_here:
|
|
508
|
+
return not_found()
|
|
509
|
+
prefix = scope.rstrip("/") + "/"
|
|
510
|
+
with view.store() as store:
|
|
511
|
+
projects = [
|
|
512
|
+
(project, dependencies_diagram(store, project.id))
|
|
513
|
+
for project in store.entities(EntityKind.PROJECT)
|
|
514
|
+
if project.id.removeprefix("project:").startswith(prefix) or project.id == f"project:{scope}"
|
|
515
|
+
]
|
|
516
|
+
imports = imports_diagram(store, scope, settings.diagrams.max_nodes)
|
|
517
|
+
system = system_diagram(store, scope, settings.diagrams.max_nodes)
|
|
518
|
+
decisions = [
|
|
519
|
+
decision
|
|
520
|
+
for decision in store.entities(EntityKind.DECISION)
|
|
521
|
+
if str(decision.attributes.get("path", "")).startswith(prefix)
|
|
522
|
+
]
|
|
523
|
+
return render(
|
|
524
|
+
"area.html", scope=scope, files=files_here, projects=projects, imports=imports, decisions=decisions,
|
|
525
|
+
system=system,
|
|
526
|
+
) # fmt: skip
|
|
527
|
+
|
|
528
|
+
@app.get("/facts/{fact_id:path}", response_class=HTMLResponse)
|
|
529
|
+
def fact(fact_id: str) -> HTMLResponse:
|
|
530
|
+
if not (view.data / "codetrail.db").exists():
|
|
531
|
+
return not_found()
|
|
532
|
+
with view.store() as store:
|
|
533
|
+
entity = store.entity(fact_id)
|
|
534
|
+
if entity is None:
|
|
535
|
+
return not_found()
|
|
536
|
+
relations = store.relations()
|
|
537
|
+
outgoing = [relation for relation in relations if relation.source_id == fact_id]
|
|
538
|
+
incoming = [relation for relation in relations if relation.target_id == fact_id]
|
|
539
|
+
return render("fact.html", entity=entity, outgoing=outgoing, incoming=incoming)
|
|
540
|
+
|
|
541
|
+
@app.get("/source/{path:path}", response_class=HTMLResponse)
|
|
542
|
+
def source(path: str) -> HTMLResponse:
|
|
543
|
+
manifest = view.manifest()
|
|
544
|
+
if manifest is None or path not in manifest.files:
|
|
545
|
+
return not_found()
|
|
546
|
+
text = view.read_source(path)
|
|
547
|
+
lines = text.splitlines() if text is not None else None
|
|
548
|
+
return render("source.html", path=path, commit=manifest.commit, lines=lines)
|
|
549
|
+
|
|
550
|
+
@app.get("/decisions", response_class=HTMLResponse)
|
|
551
|
+
def decisions() -> HTMLResponse:
|
|
552
|
+
found = []
|
|
553
|
+
if (view.data / "codetrail.db").exists():
|
|
554
|
+
with view.store() as store:
|
|
555
|
+
found = store.entities(EntityKind.DECISION)
|
|
556
|
+
found.sort(key=lambda decision: str(decision.attributes.get("number", "")))
|
|
557
|
+
return render("decisions.html", active="decisions", decisions=found)
|
|
558
|
+
|
|
559
|
+
@app.get("/documentation", response_class=HTMLResponse)
|
|
560
|
+
def documentation() -> HTMLResponse:
|
|
561
|
+
"""The documentation metrics (design section 18.4): the live report, kept until the guide or facts move on."""
|
|
562
|
+
if not (view.data / "codetrail.db").exists():
|
|
563
|
+
return render("documentation.html", active="documentation", report=None)
|
|
564
|
+
with view.store() as store:
|
|
565
|
+
snapshot = store.latest_snapshot()
|
|
566
|
+
if snapshot is None:
|
|
567
|
+
return render("documentation.html", active="documentation", report=None)
|
|
568
|
+
tracked = guide.git_dir.exists()
|
|
569
|
+
today = date.today()
|
|
570
|
+
key = (snapshot.id, guide.head() if tracked else None, tracked and guide.has_uncommitted_changes(), today)
|
|
571
|
+
try:
|
|
572
|
+
report = reports.get(key, lambda: build_report(paths, name, settings, store, today))
|
|
573
|
+
except (CodetrailError, OSError) as error:
|
|
574
|
+
logger.warning("The documentation metrics aren't available: %s", error)
|
|
575
|
+
return render("documentation.html", active="documentation", report=None, unavailable=True)
|
|
576
|
+
before = values_before(store.connection, snapshot.id)
|
|
577
|
+
history = {
|
|
578
|
+
metric: [value for _, value in recent_values(store.connection, metric, settings.metrics.trend_updates)]
|
|
579
|
+
for metric in Metric
|
|
580
|
+
}
|
|
581
|
+
values, listed = report.values(), settings.metrics.max_listed
|
|
582
|
+
without_why = report.commits.without_why if report.commits else []
|
|
583
|
+
return render(
|
|
584
|
+
"documentation.html", active="documentation", report=report, tiles=build_tiles(values, before, history),
|
|
585
|
+
listed=listed, today=today, open_section=section_to_open(values),
|
|
586
|
+
commit_filter=filtered([commit_kind(commit) for commit in without_why], listed),
|
|
587
|
+
fact_filter=filtered([str(entity.kind) for entity in report.mentions.unmentioned], listed),
|
|
588
|
+
segments=segments, days_proposed=days_proposed,
|
|
589
|
+
) # fmt: skip
|
|
590
|
+
|
|
591
|
+
def repository_summary(connection: sqlite3.Connection, snapshot_id: int, commit: str) -> dict[str, object]:
|
|
592
|
+
"""The home card's numbers (design section 19.5): the latest recorded values and one git call, never the report.
|
|
593
|
+
|
|
594
|
+
The values are read each time, since an update records them a while after its snapshot. The first commit's
|
|
595
|
+
date is kept per snapshot, because the git call walks the whole history; a failed call isn't kept.
|
|
596
|
+
"""
|
|
597
|
+
summary: dict[str, object] = {}
|
|
598
|
+
for metric in (Metric.COMMITS, Metric.CODE_LINES):
|
|
599
|
+
recorded = recent_values(connection, metric, 1)
|
|
600
|
+
summary[str(metric)] = recorded[-1][1].numerator if recorded else None
|
|
601
|
+
|
|
602
|
+
def first_commit_date() -> date | None:
|
|
603
|
+
first = first_commit_time(view.data / "mirror.git", commit)
|
|
604
|
+
return datetime.fromtimestamp(first, UTC).date() if first is not None else None
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
summary["first"] = first_commits.get(snapshot_id, first_commit_date)
|
|
608
|
+
except (CodetrailError, OSError) as error:
|
|
609
|
+
logger.warning("The first commit isn't available: %s", error)
|
|
610
|
+
summary["first"] = None
|
|
611
|
+
return summary
|
|
612
|
+
|
|
613
|
+
@app.get("/repository", response_class=HTMLResponse)
|
|
614
|
+
def repository() -> HTMLResponse:
|
|
615
|
+
"""The repository statistics (design section 19.5): the live report, kept until the facts move on."""
|
|
616
|
+
if not (view.data / "codetrail.db").exists():
|
|
617
|
+
return render("repository.html", active="repository", report=None)
|
|
618
|
+
with view.store() as store:
|
|
619
|
+
snapshot = store.latest_snapshot()
|
|
620
|
+
if snapshot is None:
|
|
621
|
+
return render("repository.html", active="repository", report=None)
|
|
622
|
+
today = date.today()
|
|
623
|
+
tracked = guide.git_dir.exists()
|
|
624
|
+
key = (snapshot.id, guide.head() if tracked else None, tracked and guide.has_uncommitted_changes(), today)
|
|
625
|
+
try:
|
|
626
|
+
report = repository_reports.get(key, lambda: build_repository_report(
|
|
627
|
+
paths, name, settings, store, today)) # fmt: skip
|
|
628
|
+
except (CodetrailError, OSError) as error:
|
|
629
|
+
logger.warning("The repository statistics aren't available: %s", error)
|
|
630
|
+
return render("repository.html", active="repository", report=None, unavailable=True)
|
|
631
|
+
before = values_before(store.connection, snapshot.id)
|
|
632
|
+
history = {
|
|
633
|
+
metric: [value for _, value in recent_values(store.connection, metric, settings.metrics.trend_updates)]
|
|
634
|
+
for metric in Metric
|
|
635
|
+
}
|
|
636
|
+
activity, churn, current = report.activity, report.churn, language()
|
|
637
|
+
weekday = busiest_weekday(activity)
|
|
638
|
+
return render(
|
|
639
|
+
"repository.html", active="repository", report=report, tiles=build_tiles(report.values(), before, history),
|
|
640
|
+
age=age(activity.first, today) if activity and activity.first else None, today=today,
|
|
641
|
+
listed=settings.metrics.max_listed, history_limit=settings.metrics.history_limit,
|
|
642
|
+
largest_files=settings.metrics.largest_files, quiet_days=settings.metrics.quiet_days,
|
|
643
|
+
all_months=months_table(activity),
|
|
644
|
+
# script_json escapes <, > and &, so nothing in the document can end its <script> block.
|
|
645
|
+
chart_json=Markup(script_json(chart_document(report, current, today))), # noqa: S704
|
|
646
|
+
weekday_name=weekday_full_names(current)[weekday] if weekday is not None else None,
|
|
647
|
+
month_names=month_names(current),
|
|
648
|
+
area_titles={spot.page.id: spot.page.title for spot in churn.areas} if churn else {},
|
|
649
|
+
grid_highest=max((count or 0 for _, cells in churn.area_months for count in cells), default=0)
|
|
650
|
+
if churn else 0,
|
|
651
|
+
ramp_step=ramp_step, bar_heights=bar_heights, language_segments=language_segments,
|
|
652
|
+
) # fmt: skip
|
|
653
|
+
|
|
654
|
+
@app.post("/settings/pins")
|
|
655
|
+
def set_pins(choice: PinChoice) -> Response:
|
|
656
|
+
"""The pinned pages (design section 22.3): the whole list, replaced; only pages the guide holds."""
|
|
657
|
+
pages = list(dict.fromkeys(choice.pages))
|
|
658
|
+
pinnable = {page.id for page in guide.pages() if page.kind in PINNABLE} if guide.root.exists() else set()
|
|
659
|
+
if len(pages) > settings.ui.max_pins or any(page not in pinnable for page in pages):
|
|
660
|
+
return Response(status_code=400)
|
|
661
|
+
store_setting(PINS_SETTING, json.dumps(pages))
|
|
662
|
+
return Response(status_code=204)
|
|
663
|
+
|
|
664
|
+
@app.post("/settings/language")
|
|
665
|
+
def set_language(choice: LanguageChoice) -> Response:
|
|
666
|
+
language = choice.language
|
|
667
|
+
if language not in languages:
|
|
668
|
+
return Response(status_code=400)
|
|
669
|
+
store_setting(LANGUAGE_SETTING, language)
|
|
670
|
+
return Response(status_code=204)
|
|
671
|
+
|
|
672
|
+
@app.exception_handler(404)
|
|
673
|
+
def missing(request: Request, error: Exception) -> Response:
|
|
674
|
+
# A page with the token goes only to a signed-in reader; /static/ is open, so its 404s stay plain.
|
|
675
|
+
if request.url.path.startswith("/static/") or not session.is_session(request.cookies.get(SESSION_COOKIE)):
|
|
676
|
+
return PlainTextResponse("Not found.", status_code=404)
|
|
677
|
+
return not_found()
|
|
678
|
+
|
|
679
|
+
app.include_router(
|
|
680
|
+
bridge_router(
|
|
681
|
+
paths,
|
|
682
|
+
name,
|
|
683
|
+
assistant_for or real_assistant,
|
|
684
|
+
language,
|
|
685
|
+
settings.bridge.max_question_chars,
|
|
686
|
+
settings.diagrams.max_nodes,
|
|
687
|
+
settings.tools,
|
|
688
|
+
settings.prices,
|
|
689
|
+
max(
|
|
690
|
+
settings.providers.claude_code.timeout_seconds,
|
|
691
|
+
settings.providers.codex.timeout_seconds,
|
|
692
|
+
settings.providers.local.timeout_seconds,
|
|
693
|
+
),
|
|
694
|
+
settings.bridge.max_session_answers,
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
app.include_router(
|
|
698
|
+
learning_router(
|
|
699
|
+
paths,
|
|
700
|
+
name,
|
|
701
|
+
assistant_for or real_assistant,
|
|
702
|
+
language,
|
|
703
|
+
settings.bridge.max_question_chars,
|
|
704
|
+
settings.learn.grading_cooldown_seconds,
|
|
705
|
+
settings.tools,
|
|
706
|
+
settings.prices,
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
app.mount("/static", StaticFiles(directory=str(files("codetrail.web").joinpath("static"))), name="static")
|
|
710
|
+
return SecurityMiddleware(TargetInUseMiddleware(app, paths, name), session=session, port=settings.server.port)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def day_text(seconds: int) -> str:
|
|
714
|
+
"""The UTC day of a time a repository gave, or a dash for one Python's dates can't hold (past the year 9999)."""
|
|
715
|
+
try:
|
|
716
|
+
return datetime.fromtimestamp(seconds, UTC).date().isoformat()
|
|
717
|
+
except ValueError, OverflowError, OSError:
|
|
718
|
+
return "—"
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _environment(templates: str, language: Language) -> Environment:
|
|
722
|
+
environment = Environment(
|
|
723
|
+
loader=FileSystemLoader(templates),
|
|
724
|
+
autoescape=select_autoescape(["html"], default=True),
|
|
725
|
+
extensions=["jinja2.ext.i18n"],
|
|
726
|
+
)
|
|
727
|
+
environment.install_gettext_translations(language.translations, newstyle=True) # type: ignore[attr-defined]
|
|
728
|
+
environment.filters["tokens"] = tokens_text
|
|
729
|
+
environment.filters["utc"] = when_text
|
|
730
|
+
environment.filters["dollars"] = lambda value: "" if value is None else f"${value:.2f}"
|
|
731
|
+
environment.filters["number"] = lambda value: f"{value:,}"
|
|
732
|
+
environment.filters["day"] = day_text
|
|
733
|
+
environment.filters["initials"] = initials
|
|
734
|
+
return environment
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class EstimateDecision(BaseModel):
|
|
738
|
+
model_config = ConfigDict(extra="forbid")
|
|
739
|
+
estimate_id: str = Field(max_length=100)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
class UpdateJob:
|
|
743
|
+
"""One update at a time, run in a background thread; the target's lock also refuses one from the command line.
|
|
744
|
+
|
|
745
|
+
The update refreshes the facts (free), then waits with its estimate until the reader confirms it with the
|
|
746
|
+
estimate's id, cancels it, or `estimate_ttl_seconds` pass; only a confirmation lets paid work start (design 15.4).
|
|
747
|
+
`stopping` is set when the server stops; `run` stops its paid work then (design 15.5).
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
def __init__(
|
|
751
|
+
self,
|
|
752
|
+
run: Callable[[Callable[[UpdateEstimate], bool], Progress], object],
|
|
753
|
+
stopping: threading.Event,
|
|
754
|
+
cooldown_seconds: int = 0,
|
|
755
|
+
estimate_ttl_seconds: int = 300,
|
|
756
|
+
log_lines: int = 200,
|
|
757
|
+
gettext_of: Callable[[], Callable[[str], str]] = lambda: str,
|
|
758
|
+
) -> None:
|
|
759
|
+
self._run = run
|
|
760
|
+
self._gettext_of = gettext_of # the reader's catalog, for the messages the page shows (design section 7.2)
|
|
761
|
+
self.gettext: Callable[[str], str] = str
|
|
762
|
+
self._stopping = stopping
|
|
763
|
+
self._thread: threading.Thread | None = None
|
|
764
|
+
self._lock = threading.Lock()
|
|
765
|
+
self._cooldown = cooldown_seconds
|
|
766
|
+
self._ttl = estimate_ttl_seconds
|
|
767
|
+
self._log: deque[tuple[int, dict[str, object]]] = deque(maxlen=log_lines) # (number, step), newest last
|
|
768
|
+
self._numbered = 0 # keeps counting across updates, so a page asking for the steps after one misses nothing
|
|
769
|
+
self._finished_at: float | None = None
|
|
770
|
+
self._decided = threading.Event()
|
|
771
|
+
self._decision = False
|
|
772
|
+
self._declined = False
|
|
773
|
+
self.state = "idle"
|
|
774
|
+
self.message = ""
|
|
775
|
+
self.estimate: dict[str, object] | None = None
|
|
776
|
+
self.estimate_id: str | None = None
|
|
777
|
+
|
|
778
|
+
def _confirm(self, estimate: UpdateEstimate) -> bool:
|
|
779
|
+
if not estimate.lines:
|
|
780
|
+
return True
|
|
781
|
+
with self._lock:
|
|
782
|
+
if self._stopping.is_set(): # the server stopped while the facts were refreshed: nothing is asked
|
|
783
|
+
self._declined = True
|
|
784
|
+
return False
|
|
785
|
+
self._decided.clear()
|
|
786
|
+
self._decision = False
|
|
787
|
+
self.estimate, self.estimate_id = estimate.as_json(), secrets.token_urlsafe(16)
|
|
788
|
+
self.state = "waiting"
|
|
789
|
+
self._decided.wait(self._ttl)
|
|
790
|
+
with self._lock:
|
|
791
|
+
# Decided under the lock: a confirmation racing the expiry either counts, or is refused by decide().
|
|
792
|
+
answered = self._decided.is_set()
|
|
793
|
+
self.estimate, self.estimate_id = None, None
|
|
794
|
+
if not answered:
|
|
795
|
+
self.message = self.gettext("The estimate expired before it was confirmed, so nothing was spent.")
|
|
796
|
+
self.state = "running"
|
|
797
|
+
self._declined = not (answered and self._decision)
|
|
798
|
+
return not self._declined
|
|
799
|
+
|
|
800
|
+
def decide(self, estimate_id: str, go_ahead: bool) -> bool:
|
|
801
|
+
"""Confirms or cancels the waiting estimate; the id works once, and only while it waits."""
|
|
802
|
+
with self._lock:
|
|
803
|
+
expected = self.estimate_id
|
|
804
|
+
if (
|
|
805
|
+
self._stopping.is_set()
|
|
806
|
+
or self.state != "waiting"
|
|
807
|
+
or expected is None
|
|
808
|
+
or not hmac.compare_digest(estimate_id.encode(), expected.encode())
|
|
809
|
+
):
|
|
810
|
+
return False
|
|
811
|
+
self.estimate_id = None
|
|
812
|
+
self._decision = go_ahead
|
|
813
|
+
self._decided.set()
|
|
814
|
+
return True
|
|
815
|
+
|
|
816
|
+
def start(self) -> str | None:
|
|
817
|
+
"""Starts the update; returns why it can't (one running, or the last one finished too recently)."""
|
|
818
|
+
_ = self._reader_gettext() # outside the lock: it reads the reader's language
|
|
819
|
+
with self._lock: # stop() sets _stopping under it, so it joins every thread started here
|
|
820
|
+
if self._stopping.is_set():
|
|
821
|
+
return _("The server is stopping.")
|
|
822
|
+
if self.state in ("preparing", "waiting", "running"):
|
|
823
|
+
return _("An update is already running.")
|
|
824
|
+
if self._finished_at is not None and time.monotonic() - self._finished_at < self._cooldown:
|
|
825
|
+
return _("An update finished a moment ago; wait a few minutes before the next.")
|
|
826
|
+
self.gettext = _ # the update's own messages use the language chosen when it started
|
|
827
|
+
self.state, self.message, self._declined = "preparing", "", False
|
|
828
|
+
self._log.clear()
|
|
829
|
+
self._thread = threading.Thread(target=self._work, daemon=True)
|
|
830
|
+
self._thread.start()
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
def _reader_gettext(self) -> Callable[[str], str]:
|
|
834
|
+
"""The reader's catalog, or English if it can't be read: a message must never fail the update's handlers."""
|
|
835
|
+
try:
|
|
836
|
+
return self._gettext_of()
|
|
837
|
+
except Exception:
|
|
838
|
+
return str
|
|
839
|
+
|
|
840
|
+
def stop(self) -> None:
|
|
841
|
+
"""Declines a waiting estimate, stops paid work and waits for the update's cleanup (design section 15.5).
|
|
842
|
+
|
|
843
|
+
The worker is a daemon thread, which the interpreter would kill at exit without running its cleanup, so a
|
|
844
|
+
further signal (closing the terminal while this waits) doesn't end the wait; it's raised once the cleanup ran.
|
|
845
|
+
"""
|
|
846
|
+
with self._lock: # under the lock, so _confirm either sees it before waiting or is woken by it
|
|
847
|
+
self._stopping.set()
|
|
848
|
+
self._decided.set()
|
|
849
|
+
if self._thread is None:
|
|
850
|
+
return
|
|
851
|
+
reported = False
|
|
852
|
+
interrupted: BaseException | None = None
|
|
853
|
+
while self._thread.is_alive():
|
|
854
|
+
try:
|
|
855
|
+
if not reported: # inside the try: a signal while the line is written mustn't end the wait
|
|
856
|
+
reported = True
|
|
857
|
+
logger.warning("Stopping the update started from the page; waiting for its cleanup.")
|
|
858
|
+
self._thread.join()
|
|
859
|
+
except BaseException as error: # raised below, once the update stopped
|
|
860
|
+
interrupted = error
|
|
861
|
+
if interrupted is not None:
|
|
862
|
+
raise interrupted
|
|
863
|
+
|
|
864
|
+
def _work(self) -> None:
|
|
865
|
+
try:
|
|
866
|
+
result = self._run(self._confirm, self._report)
|
|
867
|
+
except CodetrailError as error:
|
|
868
|
+
self.state, self.message = "failed", str(error)
|
|
869
|
+
except Exception as error: # the page shows a generic message; details stay out of the response
|
|
870
|
+
self.state, self.message = (
|
|
871
|
+
"failed",
|
|
872
|
+
self.gettext("The update failed (%(error)s).") % {"error": type(error).__name__},
|
|
873
|
+
)
|
|
874
|
+
else:
|
|
875
|
+
if self._declined or getattr(result, "declined", False):
|
|
876
|
+
reason = self.message or self.gettext("The update was cancelled.")
|
|
877
|
+
declined = self.gettext("%(reason)s The facts were refreshed; the guide wasn't updated.")
|
|
878
|
+
self.state, self.message = "declined", declined % {"reason": reason}
|
|
879
|
+
else:
|
|
880
|
+
self.state, self.message = "done", ""
|
|
881
|
+
finally:
|
|
882
|
+
self._finished_at = time.monotonic()
|
|
883
|
+
|
|
884
|
+
def _report(self, step: dict[str, object]) -> None:
|
|
885
|
+
with self._lock:
|
|
886
|
+
self._numbered += 1
|
|
887
|
+
self._log.append((self._numbered, step))
|
|
888
|
+
|
|
889
|
+
def steps(self, after: int) -> tuple[list[dict[str, object]], int]:
|
|
890
|
+
"""The steps numbered after `after`, oldest first, and the last step's number."""
|
|
891
|
+
with self._lock:
|
|
892
|
+
return [step for number, step in self._log if number > after], self._numbered
|
|
893
|
+
|
|
894
|
+
def status(self) -> dict[str, object]:
|
|
895
|
+
if self.state == "waiting" and self.estimate is not None:
|
|
896
|
+
return {"state": self.state, "message": "", "estimate": self.estimate, "estimate_id": self.estimate_id}
|
|
897
|
+
return {"state": self.state, "message": self.message}
|