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.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. 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}