gitfence 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. fence/__init__.py +3 -0
  2. fence/anchors/__init__.py +0 -0
  3. fence/anchors/fingerprint.py +69 -0
  4. fence/check/__init__.py +0 -0
  5. fence/check/classify.py +84 -0
  6. fence/check/diff.py +127 -0
  7. fence/check/engine.py +263 -0
  8. fence/cli.py +576 -0
  9. fence/config.py +209 -0
  10. fence/context.py +77 -0
  11. fence/decisions/__init__.py +0 -0
  12. fence/decisions/build.py +579 -0
  13. fence/decisions/model.py +117 -0
  14. fence/decisions/obsolete.py +121 -0
  15. fence/decisions/reasons.py +150 -0
  16. fence/decisions/relevance.py +146 -0
  17. fence/diffing/__init__.py +0 -0
  18. fence/diffing/ranges.py +25 -0
  19. fence/diffing/symbols.py +21 -0
  20. fence/eval/__init__.py +0 -0
  21. fence/eval/backtest.py +271 -0
  22. fence/git/__init__.py +0 -0
  23. fence/git/blame.py +219 -0
  24. fence/git/history.py +171 -0
  25. fence/git/log.py +60 -0
  26. fence/git/runner.py +248 -0
  27. fence/github/__init__.py +0 -0
  28. fence/github/client.py +122 -0
  29. fence/github/comment.py +50 -0
  30. fence/github/sync.py +123 -0
  31. fence/globs.py +43 -0
  32. fence/index/__init__.py +0 -0
  33. fence/index/formatting.py +53 -0
  34. fence/index/ghosts.py +143 -0
  35. fence/index/indexer.py +219 -0
  36. fence/index/store.py +389 -0
  37. fence/lang/__init__.py +118 -0
  38. fence/lang/python.py +67 -0
  39. fence/lang/treesitter.py +165 -0
  40. fence/mcp/__init__.py +0 -0
  41. fence/mcp/server.py +306 -0
  42. fence/policy/__init__.py +0 -0
  43. fence/policy/acks.py +88 -0
  44. fence/render.py +287 -0
  45. fence/report.py +103 -0
  46. fence/scan.py +50 -0
  47. fence/scoring/__init__.py +0 -0
  48. fence/scoring/calibration.py +133 -0
  49. fence/scoring/strength.py +198 -0
  50. fence/signals/__init__.py +0 -0
  51. fence/signals/catalog.py +161 -0
  52. fence/signals/code.py +69 -0
  53. fence/text.py +43 -0
  54. fence/trace.py +65 -0
  55. gitfence-0.1.0.dist-info/METADATA +389 -0
  56. gitfence-0.1.0.dist-info/RECORD +59 -0
  57. gitfence-0.1.0.dist-info/WHEEL +4 -0
  58. gitfence-0.1.0.dist-info/entry_points.txt +3 -0
  59. gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/index/store.py ADDED
@@ -0,0 +1,389 @@
1
+ """The persistent per-repo index: one SQLite file under the cache directory.
2
+
3
+ Crash safety comes from SQLite itself: WAL journaling, and every indexing batch is
4
+ one transaction, so a killed run leaves the last complete batch and nothing half
5
+ written. Writes are idempotent upserts, so two concurrent `fence index` runs only
6
+ duplicate work; they cannot corrupt each other.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sqlite3
14
+ import time
15
+ from collections.abc import Iterable, Iterator
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ SCHEMA_VERSION = 1
21
+
22
+ # Each entry upgrades the schema by one version. Never edit a shipped entry;
23
+ # append a new one.
24
+ MIGRATIONS: tuple[str, ...] = (
25
+ """
26
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
27
+ CREATE TABLE commits (
28
+ sha TEXT PRIMARY KEY,
29
+ parents TEXT NOT NULL, -- space-separated
30
+ author_name TEXT NOT NULL,
31
+ author_email TEXT NOT NULL,
32
+ author_time INTEGER NOT NULL,
33
+ committer_time INTEGER NOT NULL,
34
+ subject TEXT NOT NULL,
35
+ body TEXT NOT NULL,
36
+ pr_number INTEGER,
37
+ pr_source TEXT, -- 'merge-subject' | 'squash-subject' | 'github'
38
+ files_changed INTEGER NOT NULL DEFAULT 0,
39
+ format_only INTEGER NOT NULL DEFAULT 0,
40
+ bulk INTEGER NOT NULL DEFAULT 0,
41
+ orphaned INTEGER NOT NULL DEFAULT 0,
42
+ seq INTEGER NOT NULL -- indexing order, oldest first
43
+ );
44
+ CREATE INDEX commits_pr ON commits(pr_number);
45
+ CREATE TABLE file_changes (
46
+ sha TEXT NOT NULL,
47
+ path TEXT NOT NULL,
48
+ old_path TEXT,
49
+ status TEXT NOT NULL, -- A M D R T (git --raw letters)
50
+ similarity INTEGER,
51
+ added INTEGER, -- NULL for binary
52
+ deleted INTEGER,
53
+ PRIMARY KEY (sha, path)
54
+ );
55
+ CREATE INDEX file_changes_path ON file_changes(path);
56
+ CREATE INDEX file_changes_old_path ON file_changes(old_path);
57
+ CREATE TABLE signals (
58
+ sha TEXT NOT NULL,
59
+ signal_id TEXT NOT NULL,
60
+ detail TEXT NOT NULL, -- the matched text, verbatim
61
+ PRIMARY KEY (sha, signal_id, detail)
62
+ );
63
+ CREATE TABLE refs (name TEXT PRIMARY KEY, sha TEXT NOT NULL);
64
+ CREATE TABLE reverts (sha TEXT PRIMARY KEY, reverted_sha TEXT NOT NULL);
65
+ CREATE INDEX reverts_target ON reverts(reverted_sha);
66
+ CREATE TABLE prs (
67
+ number INTEGER PRIMARY KEY,
68
+ title TEXT, body TEXT, labels TEXT, state TEXT, merged_at TEXT,
69
+ merge_sha TEXT, url TEXT, author TEXT, reviewers TEXT, fetched_at INTEGER
70
+ );
71
+ CREATE TABLE issues (
72
+ number INTEGER PRIMARY KEY,
73
+ title TEXT, body TEXT, labels TEXT, state TEXT, closed_at TEXT, url TEXT,
74
+ fetched_at INTEGER, retrieval_status TEXT NOT NULL DEFAULT 'ok'
75
+ );
76
+ CREATE TABLE pr_issue_links (
77
+ pr_number INTEGER NOT NULL, issue_number INTEGER NOT NULL, kind TEXT NOT NULL,
78
+ PRIMARY KEY (pr_number, issue_number)
79
+ );
80
+ CREATE TABLE review_comments (
81
+ id INTEGER PRIMARY KEY, pr_number INTEGER NOT NULL, path TEXT, line INTEGER,
82
+ body TEXT, author TEXT, url TEXT, created_at TEXT
83
+ );
84
+ CREATE INDEX review_comments_pr ON review_comments(pr_number);
85
+ CREATE TABLE http_cache (url TEXT PRIMARY KEY, etag TEXT, body TEXT, fetched_at INTEGER);
86
+ CREATE TABLE query_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER);
87
+ CREATE TABLE tombstones (
88
+ id TEXT PRIMARY KEY, sha TEXT NOT NULL, path TEXT NOT NULL, symbol TEXT,
89
+ fingerprint TEXT NOT NULL, shingles TEXT NOT NULL, record TEXT NOT NULL
90
+ );
91
+ """,
92
+ )
93
+
94
+
95
+ class IndexUnavailable(Exception):
96
+ """The index is missing, corrupt, or from a newer Fence."""
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class CommitRow:
101
+ sha: str
102
+ parents: tuple[str, ...]
103
+ author_name: str
104
+ author_email: str
105
+ author_time: int
106
+ committer_time: int
107
+ subject: str
108
+ body: str
109
+ pr_number: int | None = None
110
+ pr_source: str | None = None
111
+ files_changed: int = 0
112
+ format_only: bool = False
113
+ bulk: bool = False
114
+ orphaned: bool = False
115
+
116
+ @property
117
+ def message(self) -> str:
118
+ return f"{self.subject}\n\n{self.body}".strip() if self.body else self.subject
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class FileChange:
123
+ sha: str
124
+ path: str
125
+ old_path: str | None
126
+ status: str
127
+ similarity: int | None
128
+ added: int | None
129
+ deleted: int | None
130
+
131
+
132
+ def db_path_for(cache_dir: Path, repo_root: Path) -> Path:
133
+ key = hashlib.sha256(str(repo_root.resolve()).lower().encode()).hexdigest()[:16]
134
+ return cache_dir / "index" / f"{repo_root.name[:40]}-{key}.sqlite3"
135
+
136
+
137
+ class Store:
138
+ def __init__(self, conn: sqlite3.Connection, path: Path | None) -> None:
139
+ self.conn = conn
140
+ self.path = path
141
+
142
+ @classmethod
143
+ def open(cls, path: Path | str, *, create: bool = True) -> Store:
144
+ if str(path) != ":memory:":
145
+ p = Path(path)
146
+ if not p.exists() and not create:
147
+ raise IndexUnavailable(f"no index at {p}; run `fence index` first")
148
+ p.parent.mkdir(parents=True, exist_ok=True)
149
+ try:
150
+ conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
151
+ conn.row_factory = sqlite3.Row
152
+ conn.execute("PRAGMA busy_timeout = 30000")
153
+ conn.execute("PRAGMA journal_mode = WAL")
154
+ conn.execute("PRAGMA synchronous = NORMAL")
155
+ conn.execute("PRAGMA foreign_keys = ON")
156
+ store = cls(conn, None if str(path) == ":memory:" else Path(path))
157
+ store._migrate()
158
+ except sqlite3.DatabaseError as e:
159
+ raise IndexUnavailable(
160
+ f"index at {path} is unreadable ({e}); run `fence reindex`"
161
+ ) from None
162
+ return store
163
+
164
+ def close(self) -> None:
165
+ self.conn.close()
166
+
167
+ def _migrate(self) -> None:
168
+ has_meta = self.conn.execute(
169
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta'"
170
+ ).fetchone()
171
+ version = int(self.get_meta("schema_version") or 0) if has_meta else 0
172
+ if version > SCHEMA_VERSION:
173
+ raise IndexUnavailable(
174
+ f"index schema v{version} is newer than this Fence (v{SCHEMA_VERSION}); upgrade"
175
+ )
176
+ for target in range(version + 1, SCHEMA_VERSION + 1):
177
+ with self.transaction():
178
+ for stmt in _split_sql(MIGRATIONS[target - 1]):
179
+ self.conn.execute(stmt)
180
+ self.set_meta("schema_version", str(target))
181
+
182
+ @contextmanager
183
+ def transaction(self) -> Iterator[sqlite3.Connection]:
184
+ """BEGIN IMMEDIATE ... COMMIT, rolled back on any exception."""
185
+ self.conn.execute("BEGIN IMMEDIATE")
186
+ try:
187
+ yield self.conn
188
+ except BaseException:
189
+ self.conn.execute("ROLLBACK")
190
+ raise
191
+ self.conn.execute("COMMIT")
192
+
193
+ # meta -------------------------------------------------------------------
194
+
195
+ def get_meta(self, key: str) -> str | None:
196
+ row = self.conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
197
+ return row["value"] if row else None
198
+
199
+ def set_meta(self, key: str, value: str) -> None:
200
+ self.conn.execute(
201
+ "INSERT INTO meta(key, value) VALUES(?, ?) "
202
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
203
+ (key, value),
204
+ )
205
+
206
+ # commits ----------------------------------------------------------------
207
+
208
+ def next_seq(self) -> int:
209
+ row = self.conn.execute("SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM commits").fetchone()
210
+ return int(row["n"])
211
+
212
+ def put_commits(self, commits: Iterable[CommitRow], changes: Iterable[FileChange]) -> None:
213
+ """Upsert a batch. Call inside transaction()."""
214
+ seq = self.next_seq()
215
+ for c in commits:
216
+ self.conn.execute(
217
+ "INSERT INTO commits(sha, parents, author_name, author_email, author_time,"
218
+ " committer_time, subject, body, pr_number, pr_source, files_changed,"
219
+ " format_only, bulk, orphaned, seq) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)"
220
+ " ON CONFLICT(sha) DO UPDATE SET orphaned = 0",
221
+ (
222
+ c.sha, " ".join(c.parents), c.author_name, c.author_email, c.author_time,
223
+ c.committer_time, c.subject, c.body, c.pr_number, c.pr_source,
224
+ c.files_changed, int(c.format_only), int(c.bulk), seq,
225
+ ),
226
+ )
227
+ seq += 1
228
+ self.conn.executemany(
229
+ "INSERT OR REPLACE INTO file_changes VALUES(?,?,?,?,?,?,?)",
230
+ [
231
+ (f.sha, f.path, f.old_path, f.status, f.similarity, f.added, f.deleted)
232
+ for f in changes
233
+ ],
234
+ )
235
+
236
+ def put_signals(self, rows: Iterable[tuple[str, str, str]]) -> None:
237
+ self.conn.executemany("INSERT OR IGNORE INTO signals VALUES(?,?,?)", list(rows))
238
+
239
+ def put_revert(self, sha: str, reverted_sha: str) -> None:
240
+ self.conn.execute("INSERT OR REPLACE INTO reverts VALUES(?,?)", (sha, reverted_sha))
241
+
242
+ def has_commit(self, sha: str) -> bool:
243
+ return self.conn.execute("SELECT 1 FROM commits WHERE sha=?", (sha,)).fetchone() is not None
244
+
245
+ def commit(self, sha: str) -> CommitRow | None:
246
+ row = self.conn.execute("SELECT * FROM commits WHERE sha = ?", (sha,)).fetchone()
247
+ return _commit_row(row) if row else None
248
+
249
+ def commits(self, shas: Iterable[str]) -> dict[str, CommitRow]:
250
+ shas = list(dict.fromkeys(shas))
251
+ out: dict[str, CommitRow] = {}
252
+ for chunk in _chunks(shas, 500):
253
+ marks = ",".join("?" * len(chunk))
254
+ for row in self.conn.execute(f"SELECT * FROM commits WHERE sha IN ({marks})", chunk): # noqa: S608
255
+ out[row["sha"]] = _commit_row(row)
256
+ return out
257
+
258
+ def commit_count(self, *, include_orphaned: bool = False) -> int:
259
+ sql = "SELECT COUNT(*) AS n FROM commits"
260
+ if not include_orphaned:
261
+ sql += " WHERE orphaned = 0"
262
+ return int(self.conn.execute(sql).fetchone()["n"])
263
+
264
+ def signals_for(self, sha: str) -> list[tuple[str, str]]:
265
+ return [
266
+ (r["signal_id"], r["detail"])
267
+ for r in self.conn.execute(
268
+ "SELECT signal_id, detail FROM signals WHERE sha = ? ORDER BY signal_id, detail",
269
+ (sha,),
270
+ )
271
+ ]
272
+
273
+ def reverts_of(self, sha: str, *, as_of: int | None = None) -> list[str]:
274
+ """Commits that revert `sha` (committed at or before `as_of`, if given)."""
275
+ return [
276
+ r["sha"]
277
+ for r in self.conn.execute(
278
+ "SELECT r.sha FROM reverts r JOIN commits c ON c.sha = r.sha "
279
+ "WHERE r.reverted_sha = ? AND c.committer_time <= ? ORDER BY c.seq",
280
+ (sha, as_of if as_of is not None else 2**62),
281
+ )
282
+ ]
283
+
284
+ def reverted_by(self, sha: str) -> str | None:
285
+ row = self.conn.execute("SELECT reverted_sha FROM reverts WHERE sha = ?", (sha,)).fetchone()
286
+ return row["reverted_sha"] if row else None
287
+
288
+ def changes_for(self, sha: str) -> list[FileChange]:
289
+ return [
290
+ FileChange(**dict(r))
291
+ for r in self.conn.execute(
292
+ "SELECT * FROM file_changes WHERE sha = ? ORDER BY path", (sha,)
293
+ )
294
+ ]
295
+
296
+ def path_history(self, path: str, *, limit: int = 50) -> list[str]:
297
+ """Earlier names of `path`, following renames backwards, oldest last."""
298
+ names = [path]
299
+ seen = {path}
300
+ current = path
301
+ for _ in range(limit):
302
+ row = self.conn.execute(
303
+ "SELECT fc.old_path FROM file_changes fc JOIN commits c ON c.sha = fc.sha"
304
+ " WHERE fc.path = ? AND fc.status = 'R' AND fc.old_path IS NOT NULL"
305
+ " AND c.orphaned = 0 ORDER BY c.seq DESC LIMIT 1",
306
+ (current,),
307
+ ).fetchone()
308
+ if not row or row["old_path"] in seen:
309
+ break
310
+ current = row["old_path"]
311
+ names.append(current)
312
+ seen.add(current)
313
+ return names
314
+
315
+ # refs and orphan detection ------------------------------------------------
316
+
317
+ def refs(self) -> dict[str, str]:
318
+ return {r["name"]: r["sha"] for r in self.conn.execute("SELECT name, sha FROM refs")}
319
+
320
+ def set_ref(self, name: str, sha: str) -> None:
321
+ self.conn.execute(
322
+ "INSERT INTO refs VALUES(?, ?) ON CONFLICT(name) DO UPDATE SET sha = excluded.sha",
323
+ (name, sha),
324
+ )
325
+
326
+ def mark_orphans(self, reachable: Iterable[str]) -> int:
327
+ """Mark every commit not in `reachable` as orphaned. Returns how many changed."""
328
+ self.conn.execute("CREATE TEMP TABLE IF NOT EXISTS reach (sha TEXT PRIMARY KEY)")
329
+ self.conn.execute("DELETE FROM reach")
330
+ for chunk in _chunks(list(reachable), 5000):
331
+ self.conn.executemany("INSERT OR IGNORE INTO reach VALUES(?)", [(s,) for s in chunk])
332
+ cur = self.conn.execute(
333
+ "UPDATE commits SET orphaned = 1 WHERE orphaned = 0 "
334
+ "AND sha NOT IN (SELECT sha FROM reach)"
335
+ )
336
+ self.conn.execute("UPDATE commits SET orphaned = 0 WHERE sha IN (SELECT sha FROM reach)")
337
+ return cur.rowcount
338
+
339
+ # query cache ------------------------------------------------------------
340
+
341
+ def cache_get(self, key: str) -> object | None:
342
+ row = self.conn.execute("SELECT value FROM query_cache WHERE key = ?", (key,)).fetchone()
343
+ return json.loads(row["value"]) if row else None
344
+
345
+ def cache_put(self, key: str, value: object) -> None:
346
+ self.conn.execute(
347
+ "INSERT OR REPLACE INTO query_cache VALUES(?, ?, ?)",
348
+ (key, json.dumps(value, sort_keys=True), int(time.time())),
349
+ )
350
+
351
+ def clear_query_cache(self) -> None:
352
+ self.conn.execute("DELETE FROM query_cache")
353
+
354
+ # health -----------------------------------------------------------------
355
+
356
+ def integrity_ok(self) -> bool:
357
+ row = self.conn.execute("PRAGMA quick_check").fetchone()
358
+ return bool(row) and row[0] == "ok"
359
+
360
+
361
+ def _commit_row(row: sqlite3.Row) -> CommitRow:
362
+ return CommitRow(
363
+ sha=row["sha"],
364
+ parents=tuple(p for p in row["parents"].split(" ") if p),
365
+ author_name=row["author_name"],
366
+ author_email=row["author_email"],
367
+ author_time=row["author_time"],
368
+ committer_time=row["committer_time"],
369
+ subject=row["subject"],
370
+ body=row["body"],
371
+ pr_number=row["pr_number"],
372
+ pr_source=row["pr_source"],
373
+ files_changed=row["files_changed"],
374
+ format_only=bool(row["format_only"]),
375
+ bulk=bool(row["bulk"]),
376
+ orphaned=bool(row["orphaned"]),
377
+ )
378
+
379
+
380
+ def _split_sql(script: str) -> list[str]:
381
+ # Migration scripts are ours and contain no ';' inside literals, so a plain
382
+ # split is enough, and it lets each statement run inside our transaction
383
+ # (executescript would commit implicitly).
384
+ return [s.strip() for s in script.split(";") if s.strip()]
385
+
386
+
387
+ def _chunks(items: list, size: int) -> Iterator[list]:
388
+ for i in range(0, len(items), size):
389
+ yield items[i : i + size]
fence/lang/__init__.py ADDED
@@ -0,0 +1,118 @@
1
+ """Pluggable language layer: symbols and normalized tokens per language.
2
+
3
+ A plugin answers two questions about source text: which named scopes
4
+ (functions, methods, classes) exist and where, and what the code is once
5
+ whitespace, comments and quote style are stripped away. The first gives symbol
6
+ anchors; the second gives fingerprints for move detection and format-only
7
+ commit detection. Unsupported languages fall back to file and line anchors,
8
+ and `fallback_tokens` gives a whitespace-insensitive token stream.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import PurePosixPath
16
+ from typing import Literal, Protocol
17
+
18
+ SymbolKind = Literal["function", "class", "module"]
19
+ MODULE = "<module>"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Symbol:
24
+ qualname: str
25
+ kind: SymbolKind
26
+ start_line: int # 1-based, first decorator/annotation line if any
27
+ end_line: int
28
+
29
+
30
+ class LanguagePlugin(Protocol):
31
+ name: str
32
+ extensions: tuple[str, ...]
33
+
34
+ def symbols(self, source: str) -> list[Symbol] | None:
35
+ """All named scopes, parents before children. None if the source cannot be parsed."""
36
+
37
+ def tokens(self, source: str) -> list[str]:
38
+ """Code tokens with comments and layout removed and string quoting normalized."""
39
+
40
+
41
+ _REGISTRY: dict[str, LanguagePlugin] = {}
42
+ _BY_EXT: dict[str, str] = {}
43
+
44
+
45
+ def register(plugin: LanguagePlugin) -> None:
46
+ _REGISTRY[plugin.name] = plugin
47
+ for ext in plugin.extensions:
48
+ _BY_EXT[ext] = plugin.name
49
+
50
+
51
+ def _ensure_loaded() -> None:
52
+ if _REGISTRY:
53
+ return
54
+ from fence.lang import python, treesitter
55
+
56
+ register(python.PythonPlugin())
57
+ for plugin in treesitter.plugins():
58
+ register(plugin)
59
+
60
+
61
+ def plugin_for(path: str, enabled: list[str] | None = None) -> LanguagePlugin | None:
62
+ _ensure_loaded()
63
+ name = _BY_EXT.get(PurePosixPath(path).suffix.lower())
64
+ if name is None or (enabled is not None and _family(name) not in enabled):
65
+ return None
66
+ return _REGISTRY[name]
67
+
68
+
69
+ def _family(name: str) -> str:
70
+ return "typescript" if name == "tsx" else name
71
+
72
+
73
+ def supported_languages() -> list[str]:
74
+ _ensure_loaded()
75
+ return sorted({_family(n) for n in _REGISTRY})
76
+
77
+
78
+ def enclosing_symbol(path: str, source: str, line: int) -> Symbol | None:
79
+ """Innermost symbol containing `line`; `<module>` outside any scope; None if unparsable
80
+ or unsupported."""
81
+ if line < 1:
82
+ raise ValueError(f"line must be >= 1, got {line}")
83
+ total = len(source.splitlines())
84
+ if line > total:
85
+ return None
86
+ plugin = plugin_for(path)
87
+ if plugin is None:
88
+ return None
89
+ syms = plugin.symbols(source)
90
+ if syms is None:
91
+ return None
92
+ best: Symbol | None = None
93
+ for s in syms: # parents come before children, so the last match is the innermost
94
+ if s.start_line <= line <= s.end_line:
95
+ best = s
96
+ return best or Symbol(MODULE, "module", 1, total)
97
+
98
+
99
+ _FALLBACK_TOKEN = re.compile(r"\w+|[^\w\s]")
100
+
101
+
102
+ def fallback_tokens(source: str) -> list[str]:
103
+ return _FALLBACK_TOKEN.findall(source)
104
+
105
+
106
+ def tokens_for(path: str, source: str) -> list[str]:
107
+ plugin = plugin_for(path)
108
+ return plugin.tokens(source) if plugin else fallback_tokens(source)
109
+
110
+
111
+ def normalize_string_literal(text: str) -> str:
112
+ """'x', "x", '''x''', r"x", `x` -> a single canonical token, so quote style is ignored."""
113
+ body = text.lstrip("rRbBuUfF")
114
+ for q in ('"""', "'''", '"', "'", "`"):
115
+ if body.startswith(q) and body.endswith(q) and len(body) >= 2 * len(q):
116
+ body = body[len(q) : -len(q)]
117
+ break
118
+ return "STR:" + body
fence/lang/python.py ADDED
@@ -0,0 +1,67 @@
1
+ """Python plugin: the stdlib `ast` and `tokenize` modules, which implement the exact
2
+ grammar of the running interpreter. No third-party parser is needed."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ import io
8
+ import tokenize
9
+ import warnings
10
+
11
+ from fence.lang import Symbol, normalize_string_literal
12
+
13
+ _Scope = ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
14
+ _SKIP = {
15
+ tokenize.COMMENT, tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT,
16
+ tokenize.ENCODING, tokenize.ENDMARKER,
17
+ }
18
+
19
+
20
+ class PythonPlugin:
21
+ name = "python"
22
+ extensions = (".py", ".pyi")
23
+
24
+ def symbols(self, source: str) -> list[Symbol] | None:
25
+ try:
26
+ # Old code is often valid but deprecated (invalid escapes): parsing it must
27
+ # not print SyntaxWarnings into Fence's output.
28
+ with warnings.catch_warnings():
29
+ warnings.simplefilter("ignore", SyntaxWarning)
30
+ tree = ast.parse(source)
31
+ except (SyntaxError, ValueError):
32
+ return None
33
+ out: list[Symbol] = []
34
+ for node, names in _walk_scopes(tree, ()):
35
+ start = min([node.lineno, *(d.lineno for d in node.decorator_list)])
36
+ kind = "class" if isinstance(node, ast.ClassDef) else "function"
37
+ out.append(Symbol(".".join(names), kind, start, node.end_lineno or node.lineno))
38
+ return out
39
+
40
+ def tokens(self, source: str) -> list[str]:
41
+ out: list[str] = []
42
+ try:
43
+ for tok in tokenize.generate_tokens(io.StringIO(source).readline):
44
+ if tok.type in _SKIP:
45
+ continue
46
+ if tok.type == tokenize.STRING:
47
+ out.append(normalize_string_literal(tok.string))
48
+ else:
49
+ out.append(tok.string)
50
+ except (tokenize.TokenError, SyntaxError, IndentationError):
51
+ from fence.lang import fallback_tokens
52
+
53
+ return fallback_tokens(source)
54
+ # A trailing comma is layout in Python (black adds and removes them).
55
+ return [t for i, t in enumerate(out) if not (t == "," and i + 1 < len(out)
56
+ and out[i + 1] in (")", "]", "}"))]
57
+
58
+
59
+ def _walk_scopes(node: ast.AST, prefix: tuple[str, ...]):
60
+ for child in ast.iter_child_nodes(node):
61
+ if isinstance(child, _Scope):
62
+ names = (*prefix, child.name)
63
+ yield child, names
64
+ yield from _walk_scopes(child, names)
65
+ else:
66
+ # Scopes can sit inside if/try/with blocks; keep looking with the same prefix.
67
+ yield from _walk_scopes(child, prefix)