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/repo/signal.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The "you're behind" signal: what landed since the last update, from git alone (design section 8.5).
|
|
2
|
+
|
|
3
|
+
It fetches into the mirror without rebuilding `source/`, under the target's lock taken without waiting: while an
|
|
4
|
+
update runs, the signal says so instead. Areas are the top-level folders of visible changed paths; excluded paths
|
|
5
|
+
are never counted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from codetrail.config import Paths, check_containment, load_target
|
|
13
|
+
from codetrail.database import connect
|
|
14
|
+
from codetrail.facts.store import FactStore
|
|
15
|
+
from codetrail.lock import TargetBusy, target_lock
|
|
16
|
+
from codetrail.repo.history import changed_paths, commit_count, merges_between
|
|
17
|
+
from codetrail.repo.mirror import read_file_at, refresh_mirror
|
|
18
|
+
from codetrail.repo.refresh import TARGET_IGNORE_FILE, ignore_lines
|
|
19
|
+
from codetrail.repo.rules import ExclusionRules
|
|
20
|
+
from codetrail.repo.source import SourceManifest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Signal:
|
|
25
|
+
updated_commit: str | None = None
|
|
26
|
+
head: str | None = None
|
|
27
|
+
merges: int = 0
|
|
28
|
+
commits: int = 0
|
|
29
|
+
areas: list[str] = field(default_factory=list)
|
|
30
|
+
updating: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def behind(paths: Paths, name: str) -> Signal:
|
|
34
|
+
target = load_target(paths, name)
|
|
35
|
+
check_containment(paths, target.repository)
|
|
36
|
+
data = paths.target_data(name)
|
|
37
|
+
try:
|
|
38
|
+
with target_lock(paths, name):
|
|
39
|
+
mirror = data / "mirror.git"
|
|
40
|
+
head = refresh_mirror(mirror, target.repository, target.branch)
|
|
41
|
+
snapshot = _latest_snapshot_commit(paths, name)
|
|
42
|
+
if snapshot is None:
|
|
43
|
+
return Signal(head=head)
|
|
44
|
+
rules = ExclusionRules(ignore_lines(paths, name, read_file_at(mirror, head, TARGET_IGNORE_FILE)))
|
|
45
|
+
manifest = SourceManifest.load(data / "source.json")
|
|
46
|
+
flagged = manifest.excluded_paths() if manifest else set()
|
|
47
|
+
visible = [path for path in changed_paths(mirror, snapshot, head)
|
|
48
|
+
if rules.reason(path) is None and path not in flagged] # fmt: skip
|
|
49
|
+
areas = sorted({path.split("/", 1)[0] for path in visible if "/" in path})
|
|
50
|
+
return Signal(snapshot, head, merges_between(mirror, snapshot, head), commit_count(mirror, snapshot, head),
|
|
51
|
+
areas) # fmt: skip
|
|
52
|
+
except TargetBusy:
|
|
53
|
+
return Signal(updating=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _latest_snapshot_commit(paths: Paths, name: str) -> str | None:
|
|
57
|
+
database = paths.target_data(name) / "codetrail.db"
|
|
58
|
+
if not database.exists():
|
|
59
|
+
return None
|
|
60
|
+
connection = connect(database)
|
|
61
|
+
try:
|
|
62
|
+
snapshot = FactStore(connection).latest_snapshot()
|
|
63
|
+
finally:
|
|
64
|
+
connection.close()
|
|
65
|
+
return snapshot.commit if snapshot else None
|
codetrail/repo/source.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The materialized sources: only the files Codetrail may see, at one commit (design sections 3.2 to 3.4).
|
|
2
|
+
|
|
3
|
+
`source/` is rebuilt in full on every refresh: written into `source.next/`, scanned with gitleaks, cleared of what it
|
|
4
|
+
flags, then swapped into place. If anything fails, `source.next/` is removed and `source/` stays as it was.
|
|
5
|
+
Symlinks, submodules and unsafe paths are never written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
from codetrail.errors import CodetrailError
|
|
18
|
+
from codetrail.repo.mirror import list_tree, read_blobs
|
|
19
|
+
from codetrail.repo.rules import ExclusionRules, Reason, on_disk_key
|
|
20
|
+
from codetrail.repo.secrets import SecretScanner
|
|
21
|
+
|
|
22
|
+
REGULAR_FILE_MODES = {"100644", "100755"}
|
|
23
|
+
# A full SHA-1 or SHA-256 object name, the only commit a manifest may name: never an option, a ref or a range.
|
|
24
|
+
FULL_COMMIT = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Excluded:
|
|
29
|
+
path: str
|
|
30
|
+
reason: Reason
|
|
31
|
+
rule: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SourceManifest:
|
|
36
|
+
commit: str
|
|
37
|
+
files: dict[str, str] # path -> blob
|
|
38
|
+
excluded: list[Excluded] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def save(self, path: Path) -> None:
|
|
41
|
+
path.write_text(json.dumps(asdict(self), indent=1, sort_keys=True), encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def load(cls, path: Path) -> SourceManifest | None:
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return None
|
|
47
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
if not isinstance(data["commit"], str) or not FULL_COMMIT.fullmatch(data["commit"]):
|
|
49
|
+
raise CodetrailError(f"{path} names no valid commit; run codetrail update again.")
|
|
50
|
+
excluded = [Excluded(item["path"], Reason(item["reason"]), item["rule"]) for item in data["excluded"]]
|
|
51
|
+
return cls(commit=data["commit"], files=data["files"], excluded=excluded)
|
|
52
|
+
|
|
53
|
+
def excluded_paths(self) -> set[str]:
|
|
54
|
+
return {item.path for item in self.excluded}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def safe_path(path: str) -> bool:
|
|
58
|
+
"""A relative path with no empty, `.`, `..` or `.git` parts (in any case), which stays inside its folder."""
|
|
59
|
+
if not path or path.startswith("/") or "\\" in path:
|
|
60
|
+
return False
|
|
61
|
+
if any("\udc80" <= character <= "\udcff" for character in path): # bytes that aren't UTF-8 (surrogateescape)
|
|
62
|
+
return False
|
|
63
|
+
parts = path.split("/")
|
|
64
|
+
return all(part and part not in (".", "..") and on_disk_key(part) != ".git" for part in parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def allowed_reader(source: Path, files: Mapping[str, str], max_bytes: int) -> Callable[[str], bytes | None]:
|
|
68
|
+
"""A reader of allowed files only: a listed path, inside `source/`, a plain file within the size cap.
|
|
69
|
+
|
|
70
|
+
The system pass (design 17.3) and the documentation metrics (design 18.2) read files only through it.
|
|
71
|
+
"""
|
|
72
|
+
root = source.resolve()
|
|
73
|
+
|
|
74
|
+
def read(path: str) -> bytes | None:
|
|
75
|
+
if path not in files:
|
|
76
|
+
return None
|
|
77
|
+
file = source / PurePosixPath(path)
|
|
78
|
+
try:
|
|
79
|
+
if file.is_symlink() or not file.resolve().is_relative_to(root) or not file.is_file():
|
|
80
|
+
return None
|
|
81
|
+
return file.read_bytes() if file.stat().st_size <= max_bytes else None
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
return read
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_source(
|
|
89
|
+
mirror: Path, commit: str, rules: ExclusionRules, scanner: SecretScanner, data_dir: Path
|
|
90
|
+
) -> SourceManifest:
|
|
91
|
+
source, next_source = data_dir / "source", data_dir / "source.next"
|
|
92
|
+
shutil.rmtree(next_source, ignore_errors=True)
|
|
93
|
+
next_source.mkdir(parents=True)
|
|
94
|
+
try:
|
|
95
|
+
files: dict[str, str] = {}
|
|
96
|
+
excluded: list[Excluded] = []
|
|
97
|
+
entries = list_tree(mirror, commit)
|
|
98
|
+
colliding = _colliding_paths([entry.path for entry in entries])
|
|
99
|
+
for entry in entries:
|
|
100
|
+
reason = Reason.UNSAFE_PATH if entry.path in colliding else _reason(entry.mode, entry.path, rules)
|
|
101
|
+
if reason is None:
|
|
102
|
+
files[entry.path] = entry.blob
|
|
103
|
+
else:
|
|
104
|
+
excluded.append(Excluded(entry.path, reason))
|
|
105
|
+
paths_by_blob: dict[str, list[str]] = {}
|
|
106
|
+
for path, blob in files.items():
|
|
107
|
+
paths_by_blob.setdefault(blob, []).append(path)
|
|
108
|
+
for blob, content in read_blobs(mirror, list(paths_by_blob)):
|
|
109
|
+
for path in paths_by_blob[blob]:
|
|
110
|
+
destination = next_source / PurePosixPath(path)
|
|
111
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
destination.write_bytes(content)
|
|
113
|
+
flagged: dict[str, str] = {}
|
|
114
|
+
for finding in scanner.scan_directory(next_source):
|
|
115
|
+
flagged.setdefault(finding.path, finding.rule)
|
|
116
|
+
for path, rule in sorted(flagged.items()):
|
|
117
|
+
(next_source / PurePosixPath(path)).unlink()
|
|
118
|
+
del files[path]
|
|
119
|
+
excluded.append(Excluded(path, Reason.GITLEAKS, rule))
|
|
120
|
+
manifest = SourceManifest(
|
|
121
|
+
commit=commit, files=dict(sorted(files.items())), excluded=sorted(excluded, key=lambda item: item.path)
|
|
122
|
+
)
|
|
123
|
+
_swap(next_source, source)
|
|
124
|
+
manifest.save(data_dir / "source.json")
|
|
125
|
+
return manifest
|
|
126
|
+
except BaseException:
|
|
127
|
+
shutil.rmtree(next_source, ignore_errors=True)
|
|
128
|
+
raise
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _colliding_paths(paths: list[str]) -> set[str]:
|
|
132
|
+
"""Paths that would share a file or folder on a case-insensitive, normalizing file system.
|
|
133
|
+
|
|
134
|
+
Each path and each of its parent folders is a name on disk; two different spellings of one name collide, and
|
|
135
|
+
every path under either spelling is excluded (A.txt with a.txt, Docs/a.md with docs/b.md, Notes with notes/x).
|
|
136
|
+
"""
|
|
137
|
+
spellings: dict[str, set[str]] = {}
|
|
138
|
+
for path in paths:
|
|
139
|
+
parts = path.split("/")
|
|
140
|
+
for end in range(1, len(parts) + 1):
|
|
141
|
+
prefix = "/".join(parts[:end])
|
|
142
|
+
spellings.setdefault(on_disk_key(prefix), set()).add(prefix)
|
|
143
|
+
clashing = {key for key, names in spellings.items() if len(names) > 1}
|
|
144
|
+
colliding = set()
|
|
145
|
+
for path in paths:
|
|
146
|
+
parts = path.split("/")
|
|
147
|
+
if any(on_disk_key("/".join(parts[:end])) in clashing for end in range(1, len(parts) + 1)):
|
|
148
|
+
colliding.add(path)
|
|
149
|
+
return colliding
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _reason(mode: str, path: str, rules: ExclusionRules) -> Reason | None:
|
|
153
|
+
if not safe_path(path):
|
|
154
|
+
return Reason.UNSAFE_PATH
|
|
155
|
+
if mode not in REGULAR_FILE_MODES:
|
|
156
|
+
return Reason.NOT_A_FILE
|
|
157
|
+
return rules.reason(path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _swap(next_source: Path, source: Path) -> None:
|
|
161
|
+
old = source.with_name("source.old")
|
|
162
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
163
|
+
if source.exists():
|
|
164
|
+
source.rename(old)
|
|
165
|
+
next_source.rename(source)
|
|
166
|
+
shutil.rmtree(old, ignore_errors=True)
|
codetrail/search.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Search over the guide: an in-memory SQLite FTS5 index of pages, saved answers, decisions and facts (design 16.3).
|
|
2
|
+
|
|
3
|
+
Only what the reader can already see in the guide is indexed: each page's title and body (never its front matter,
|
|
4
|
+
which holds the checks' rubrics), and the names and kinds of facts. Source files are never read. Queries never reach
|
|
5
|
+
FTS5's query language: every word is quoted as a literal, the last one matches as a prefix, and the expression is
|
|
6
|
+
always a bound parameter. Snippets are plain text, split into segments that say whether they matched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
import unicodedata
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
from markdown_it import MarkdownIt
|
|
20
|
+
|
|
21
|
+
from codetrail.facts import EntityKind
|
|
22
|
+
from codetrail.facts.store import FactStore
|
|
23
|
+
from codetrail.generate.validate import DIAGRAM, FACT_LINK, MARKER
|
|
24
|
+
from codetrail.guide import GuideRepository
|
|
25
|
+
|
|
26
|
+
WORD = re.compile(r"\w+")
|
|
27
|
+
SNIPPET_CHARS = 160
|
|
28
|
+
PARSER = MarkdownIt("commonmark").enable("table")
|
|
29
|
+
# Title, headings and text, weighted for bm25; the other columns are stored, not searched.
|
|
30
|
+
SCHEMA = (
|
|
31
|
+
"CREATE VIRTUAL TABLE documents USING fts5(title, headings, text, id UNINDEXED, kind UNINDEXED, "
|
|
32
|
+
"link UNINDEXED, tokenize = 'unicode61 remove_diacritics 2')"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Document:
|
|
38
|
+
id: str # a page id, "facts/<fact id>" or "decisions/<fact id>"
|
|
39
|
+
kind: str # "area", "concept", "path", "digest", "answer", "decision" or "fact"
|
|
40
|
+
title: str
|
|
41
|
+
headings: str
|
|
42
|
+
text: str
|
|
43
|
+
link: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Result:
|
|
48
|
+
document: Document
|
|
49
|
+
snippet: list[tuple[str, bool]] # plain-text segments, and whether each one matched
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def match_expression(query: str, max_chars: int) -> str | None:
|
|
53
|
+
"""The query as an FTS5 expression of quoted words, the last a prefix; None when no word is left."""
|
|
54
|
+
words = [word for word in query[:max_chars].split() if WORD.search(word)]
|
|
55
|
+
if not words:
|
|
56
|
+
return None
|
|
57
|
+
quoted = ['"' + word.replace('"', '""') + '"' for word in words]
|
|
58
|
+
return " ".join(quoted) + "*"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fold(text: str) -> str:
|
|
62
|
+
"""Lower case without accents, the way the index's tokenizer compares words."""
|
|
63
|
+
return "".join(char for char in unicodedata.normalize("NFKD", text.casefold()) if not unicodedata.combining(char))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _snippet(text: str, query: str, max_chars: int) -> list[tuple[str, bool]]:
|
|
67
|
+
terms = [_fold(word) for word in WORD.findall(query[:max_chars])]
|
|
68
|
+
if not text or not terms:
|
|
69
|
+
return [(text[:SNIPPET_CHARS], False)] if text else []
|
|
70
|
+
|
|
71
|
+
def matches(word: str) -> bool:
|
|
72
|
+
folded = _fold(word)
|
|
73
|
+
return folded in terms[:-1] or folded.startswith(terms[-1])
|
|
74
|
+
|
|
75
|
+
hits = [match for match in WORD.finditer(text) if matches(match.group())]
|
|
76
|
+
start = max(0, hits[0].start() - SNIPPET_CHARS // 3) if hits else 0
|
|
77
|
+
if start > 0: # begin at a word, not inside one
|
|
78
|
+
space = text.find(" ", start)
|
|
79
|
+
start = space + 1 if 0 <= space < (hits[0].start() if hits else len(text)) else start
|
|
80
|
+
end = min(len(text), start + SNIPPET_CHARS)
|
|
81
|
+
if end < len(text):
|
|
82
|
+
space = text.rfind(" ", start, end)
|
|
83
|
+
end = space if space > (hits[0].end() if hits else start) else end
|
|
84
|
+
segments: list[tuple[str, bool]] = [("…", False)] if start > 0 else []
|
|
85
|
+
position = start
|
|
86
|
+
for hit in hits:
|
|
87
|
+
if hit.start() < start or hit.end() > end:
|
|
88
|
+
continue
|
|
89
|
+
if hit.start() > position:
|
|
90
|
+
segments.append((text[position : hit.start()], False))
|
|
91
|
+
segments.append((hit.group(), True))
|
|
92
|
+
position = hit.end()
|
|
93
|
+
if position < end:
|
|
94
|
+
segments.append((text[position:end], False))
|
|
95
|
+
if end < len(text):
|
|
96
|
+
segments.append(("…", False))
|
|
97
|
+
return segments
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SearchIndex:
|
|
101
|
+
"""One in-memory index, shared by the page's requests; every use holds the lock."""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._connection = sqlite3.connect(":memory:", check_same_thread=False)
|
|
105
|
+
self._connection.execute(SCHEMA)
|
|
106
|
+
self._lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def rebuild(self, documents: Iterable[Document]) -> None:
|
|
109
|
+
rows = [_row(document) for document in documents]
|
|
110
|
+
with self._lock, self._connection:
|
|
111
|
+
self._connection.execute("DELETE FROM documents")
|
|
112
|
+
self._connection.executemany("INSERT INTO documents VALUES (?, ?, ?, ?, ?, ?)", rows)
|
|
113
|
+
|
|
114
|
+
def add(self, document: Document) -> None:
|
|
115
|
+
with self._lock, self._connection:
|
|
116
|
+
self._connection.execute("DELETE FROM documents WHERE id = ?", (document.id,))
|
|
117
|
+
self._connection.execute("INSERT INTO documents VALUES (?, ?, ?, ?, ?, ?)", _row(document))
|
|
118
|
+
|
|
119
|
+
def search(self, query: str, max_chars: int, limit: int) -> list[Result]:
|
|
120
|
+
expression = match_expression(query, max_chars)
|
|
121
|
+
if expression is None:
|
|
122
|
+
return []
|
|
123
|
+
with self._lock:
|
|
124
|
+
try:
|
|
125
|
+
rows = self._connection.execute(
|
|
126
|
+
"SELECT title, headings, text, id, kind, link FROM documents WHERE documents MATCH ?"
|
|
127
|
+
" ORDER BY bm25(documents, 10.0, 4.0, 1.0) LIMIT ?",
|
|
128
|
+
(expression, limit),
|
|
129
|
+
).fetchall()
|
|
130
|
+
except sqlite3.OperationalError: # every word is quoted, so this is FTS5 refusing an odd token
|
|
131
|
+
return []
|
|
132
|
+
results = []
|
|
133
|
+
for title, headings, text, identifier, kind, link in rows:
|
|
134
|
+
document = Document(identifier, kind, title, headings, text, link)
|
|
135
|
+
results.append(Result(document, _snippet(text, query, max_chars)))
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _row(document: Document) -> tuple[str, str, str, str, str, str]:
|
|
140
|
+
return (document.title, document.headings, document.text, document.id, document.kind, document.link)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _plain(body: str) -> tuple[str, str]:
|
|
144
|
+
"""A page body's headings and its text, without Markdown, rationale markers or diagram placeholders."""
|
|
145
|
+
lines = [line for line in body.splitlines() if not MARKER.match(line) and not DIAGRAM.match(line.strip())]
|
|
146
|
+
source = FACT_LINK.sub(lambda match: match.group(1), "\n".join(lines))
|
|
147
|
+
headings: list[str] = []
|
|
148
|
+
pieces: list[str] = []
|
|
149
|
+
in_heading = False
|
|
150
|
+
for token in PARSER.parse(source):
|
|
151
|
+
if token.type == "heading_open":
|
|
152
|
+
in_heading = True
|
|
153
|
+
elif token.type == "heading_close":
|
|
154
|
+
in_heading = False
|
|
155
|
+
elif token.type == "inline":
|
|
156
|
+
words = "".join(child.content for child in token.children or [] if child.type in ("text", "code_inline"))
|
|
157
|
+
(headings if in_heading else pieces).append(words)
|
|
158
|
+
elif token.type in ("fence", "code_block"):
|
|
159
|
+
pieces.append(token.content.strip())
|
|
160
|
+
return "\n".join(headings), " ".join(piece for piece in pieces if piece)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def guide_documents(guide: GuideRepository, store: FactStore | None) -> list[Document]:
|
|
164
|
+
"""Everything search covers: the guide's pages (title and body only), decisions, and facts by name and kind."""
|
|
165
|
+
documents = []
|
|
166
|
+
for page in guide.pages() if guide.root.exists() else []:
|
|
167
|
+
headings, text = _plain(page.body)
|
|
168
|
+
documents.append(Document(page.id, page.kind, page.title, headings, text, f"/pages/{page.id}"))
|
|
169
|
+
for entity in store.entities() if store is not None else []:
|
|
170
|
+
link = f"/facts/{quote(entity.id, safe='')}"
|
|
171
|
+
if entity.kind == EntityKind.DECISION:
|
|
172
|
+
title = f"{entity.attributes.get('number', '')}. {entity.attributes.get('title', '')}".strip(". ")
|
|
173
|
+
text = str(entity.attributes.get("status", ""))
|
|
174
|
+
documents.append(Document(f"decisions/{entity.id}", "decision", title, "", text, link))
|
|
175
|
+
else:
|
|
176
|
+
text = f"{entity.kind} {entity.attributes.get('name', '')}".strip()
|
|
177
|
+
documents.append(Document(f"facts/{entity.id}", "fact", entity.id, "", text, link))
|
|
178
|
+
return documents
|
codetrail/server.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""`codetrail serve`: the page on 127.0.0.1 only, opened through a single-use sign-in link (design section 7.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import webbrowser
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
from codetrail.config import Paths, check_containment, load_global, load_target
|
|
12
|
+
from codetrail.lock import target_in_use
|
|
13
|
+
from codetrail.web.app import create_app
|
|
14
|
+
from codetrail.web.security import SessionState
|
|
15
|
+
|
|
16
|
+
HOST = "127.0.0.1" # never configurable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Server:
|
|
21
|
+
app: ASGIApp
|
|
22
|
+
host: str
|
|
23
|
+
port: int
|
|
24
|
+
url: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def prepare_server(paths: Paths, name: str) -> Server:
|
|
28
|
+
target = load_target(paths, name)
|
|
29
|
+
check_containment(paths, target.repository)
|
|
30
|
+
settings = load_global(paths)
|
|
31
|
+
session = SessionState(settings.server.login_code_ttl_seconds, session_minutes=settings.server.session_minutes)
|
|
32
|
+
app = create_app(paths, name, session, settings)
|
|
33
|
+
port = settings.server.port
|
|
34
|
+
return Server(app, HOST, port, f"http://{HOST}:{port}/login?code={session.issue_login_code()}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(paths: Paths, name: str, open_browser: bool) -> None:
|
|
38
|
+
with target_in_use(paths, name): # for as long as it serves, so the target isn't removed under it
|
|
39
|
+
server = prepare_server(paths, name)
|
|
40
|
+
print(f"Codetrail is serving {name} at http://{server.host}:{server.port}/", flush=True)
|
|
41
|
+
print(f"Sign in (this link works once, for a short time): {server.url}", flush=True)
|
|
42
|
+
if open_browser:
|
|
43
|
+
webbrowser.open(server.url)
|
|
44
|
+
# No access log: the sign-in link's code would be written to it.
|
|
45
|
+
uvicorn.run(server.app, host=server.host, port=server.port, log_level="warning", access_log=False)
|