patchahead 0.3.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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- patchahead-0.3.0.dist-info/top_level.txt +1 -0
patchahead/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Static analysis: parsing Python source and applying range-based edits."""
|
|
2
|
+
|
|
3
|
+
from patchahead.analysis.edits import (
|
|
4
|
+
EditError,
|
|
5
|
+
apply_edits,
|
|
6
|
+
combined_diff,
|
|
7
|
+
is_parseable,
|
|
8
|
+
unified_diff,
|
|
9
|
+
)
|
|
10
|
+
from patchahead.analysis.index import RepoIndex, build, discover_python_files, is_test_path
|
|
11
|
+
from patchahead.analysis.python_ast import (
|
|
12
|
+
MODULE_SCOPE,
|
|
13
|
+
AttributeAccess,
|
|
14
|
+
CallSite,
|
|
15
|
+
ColumnMap,
|
|
16
|
+
GetCallAccess,
|
|
17
|
+
ModuleAnalysis,
|
|
18
|
+
ParseError,
|
|
19
|
+
SourceRange,
|
|
20
|
+
SubscriptAccess,
|
|
21
|
+
analyze_source,
|
|
22
|
+
base_name,
|
|
23
|
+
iter_own_scope,
|
|
24
|
+
receiver_matches_owner,
|
|
25
|
+
receiver_name,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"MODULE_SCOPE",
|
|
30
|
+
"AttributeAccess",
|
|
31
|
+
"CallSite",
|
|
32
|
+
"ColumnMap",
|
|
33
|
+
"EditError",
|
|
34
|
+
"GetCallAccess",
|
|
35
|
+
"ModuleAnalysis",
|
|
36
|
+
"ParseError",
|
|
37
|
+
"RepoIndex",
|
|
38
|
+
"SourceRange",
|
|
39
|
+
"SubscriptAccess",
|
|
40
|
+
"analyze_source",
|
|
41
|
+
"apply_edits",
|
|
42
|
+
"base_name",
|
|
43
|
+
"build",
|
|
44
|
+
"combined_diff",
|
|
45
|
+
"discover_python_files",
|
|
46
|
+
"is_parseable",
|
|
47
|
+
"is_test_path",
|
|
48
|
+
"iter_own_scope",
|
|
49
|
+
"receiver_matches_owner",
|
|
50
|
+
"receiver_name",
|
|
51
|
+
"unified_diff",
|
|
52
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Applying source ranges edits, and rendering diffs.
|
|
2
|
+
|
|
3
|
+
Edits are applied to the original text by range, not by regenerating the module
|
|
4
|
+
from its AST. ``ast.unparse`` would discard every comment, blank line, and
|
|
5
|
+
formatting choice in the file, turning a two-token rename into a whole-file
|
|
6
|
+
rewrite that no reviewer would approve. Range edits keep the diff to exactly the
|
|
7
|
+
tokens that changed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import difflib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from patchahead.domain.plan import TextEdit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EditError(Exception):
|
|
20
|
+
"""Raised when a set of edits cannot be applied safely."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_source(path: Path) -> str:
|
|
24
|
+
"""Read a file as UTF-8 text with its line endings exactly as they are.
|
|
25
|
+
|
|
26
|
+
``Path.read_text`` translates ``\r\n`` to ``\n``, and writing that back
|
|
27
|
+
converts a whole Windows-style file to Unix endings: a two-token rename
|
|
28
|
+
becomes a diff of every line, and the patch no longer applies to the file
|
|
29
|
+
it was made from.
|
|
30
|
+
"""
|
|
31
|
+
return path.read_bytes().decode("utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_source(path: Path, text: str) -> None:
|
|
35
|
+
"""Write UTF-8 text without translating line endings. See :func:`read_source`."""
|
|
36
|
+
path.write_bytes(text.encode("utf-8"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def newline_of(text: str) -> str:
|
|
40
|
+
"""The line ending a file uses: ``\r\n`` if its first line ends that way."""
|
|
41
|
+
first = text.find("\n")
|
|
42
|
+
return "\r\n" if first > 0 and text[first - 1] == "\r" else "\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _offset(line_starts: list[int], line: int, col: int, length: int) -> int:
|
|
46
|
+
"""Absolute character offset for a 1-indexed line and 0-indexed column."""
|
|
47
|
+
if line < 1 or line > len(line_starts):
|
|
48
|
+
raise EditError(f"line {line} is out of range (file has {len(line_starts)} lines)")
|
|
49
|
+
offset = line_starts[line - 1] + col
|
|
50
|
+
if offset > length:
|
|
51
|
+
raise EditError(f"position {line}:{col} is past the end of the file")
|
|
52
|
+
return offset
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_edits(source: str, edits: list[TextEdit]) -> str:
|
|
56
|
+
"""Apply ``edits`` to ``source`` and return the new text.
|
|
57
|
+
|
|
58
|
+
Edits are applied from the end of the file backwards so earlier offsets stay
|
|
59
|
+
valid. Overlapping edits are rejected rather than silently resolved: two
|
|
60
|
+
handlers disagreeing about the same range is a bug, and producing a
|
|
61
|
+
plausible-looking merge would hide it.
|
|
62
|
+
"""
|
|
63
|
+
if not edits:
|
|
64
|
+
return source
|
|
65
|
+
|
|
66
|
+
line_starts = [0]
|
|
67
|
+
for index, char in enumerate(source):
|
|
68
|
+
if char == "\n":
|
|
69
|
+
line_starts.append(index + 1)
|
|
70
|
+
length = len(source)
|
|
71
|
+
|
|
72
|
+
spans: list[tuple[int, int, str]] = []
|
|
73
|
+
for edit in edits:
|
|
74
|
+
start = _offset(line_starts, edit.line, edit.col, length)
|
|
75
|
+
end = _offset(line_starts, edit.end_line, edit.end_col, length)
|
|
76
|
+
if end < start:
|
|
77
|
+
raise EditError(
|
|
78
|
+
f"edit at {edit.line}:{edit.col} ends before it starts "
|
|
79
|
+
f"({edit.end_line}:{edit.end_col})"
|
|
80
|
+
)
|
|
81
|
+
spans.append((start, end, edit.new_text))
|
|
82
|
+
|
|
83
|
+
spans.sort(key=lambda span: (span[0], span[1]))
|
|
84
|
+
for (_, end, _), (next_start, _, _) in zip(spans, spans[1:], strict=False):
|
|
85
|
+
if next_start < end:
|
|
86
|
+
raise EditError(
|
|
87
|
+
f"overlapping edits: span ending at offset {end} overlaps the span "
|
|
88
|
+
f"starting at offset {next_start}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
result = source
|
|
92
|
+
for start, end, new_text in reversed(spans):
|
|
93
|
+
result = result[:start] + new_text + result[end:]
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_parseable(source: str, path: str = "<patched>") -> tuple[bool, str]:
|
|
98
|
+
"""Whether ``source`` parses as Python, and the error if it does not.
|
|
99
|
+
|
|
100
|
+
Used by the syntax gate. ``ast.parse`` rather than ``compile`` because it
|
|
101
|
+
catches the same syntax errors without executing any compile-time side
|
|
102
|
+
effects, and gives a cleaner message.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
ast.parse(source, filename=path)
|
|
106
|
+
except SyntaxError as exc:
|
|
107
|
+
return False, f"{path}:{exc.lineno or '?'}:{exc.offset or '?'}: {exc.msg}"
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
return False, f"{path}: {exc}"
|
|
110
|
+
return True, ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def unified_diff(old: str, new: str, path: str, context: int = 3) -> str:
|
|
114
|
+
"""A git-style unified diff for one file.
|
|
115
|
+
|
|
116
|
+
``a/``-``b/`` prefixes and a trailing newline make the output directly
|
|
117
|
+
consumable by ``git apply`` and ``patch -p1``.
|
|
118
|
+
"""
|
|
119
|
+
if old == new:
|
|
120
|
+
return ""
|
|
121
|
+
diff = difflib.unified_diff(
|
|
122
|
+
old.splitlines(keepends=True),
|
|
123
|
+
new.splitlines(keepends=True),
|
|
124
|
+
fromfile=f"a/{path}",
|
|
125
|
+
tofile=f"b/{path}",
|
|
126
|
+
n=context,
|
|
127
|
+
)
|
|
128
|
+
text = "".join(diff)
|
|
129
|
+
if text and not text.endswith("\n"):
|
|
130
|
+
text += "\n"
|
|
131
|
+
return text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def combined_diff(files: list[tuple[str, str, str]], context: int = 3) -> str:
|
|
135
|
+
"""A single diff across several files, ordered by path.
|
|
136
|
+
|
|
137
|
+
``files`` is a list of ``(path, old_source, new_source)``.
|
|
138
|
+
"""
|
|
139
|
+
parts = [
|
|
140
|
+
unified_diff(old, new, path, context=context)
|
|
141
|
+
for path, old, new in sorted(files, key=lambda item: item[0])
|
|
142
|
+
]
|
|
143
|
+
return "".join(part for part in parts if part)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""A parsed, cached view of one repository's Python source.
|
|
2
|
+
|
|
3
|
+
Each file is read and parsed at most once per run, no matter how many handlers
|
|
4
|
+
analyze it. The prototype re-read and re-scanned every file for every pattern of
|
|
5
|
+
every change; this does one pass and shares it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from patchahead.analysis.edits import read_source
|
|
16
|
+
from patchahead.analysis.python_ast import ModuleAnalysis, ParseError, analyze_source
|
|
17
|
+
from patchahead.config import Config
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
#: Files larger than this are skipped. A multi-megabyte generated module is
|
|
22
|
+
#: never the hand-written integration code a migration targets, and parsing it
|
|
23
|
+
#: costs more than it can possibly return.
|
|
24
|
+
MAX_FILE_BYTES = 2_000_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RepoIndex:
|
|
29
|
+
"""Discovered and parsed Python modules under a repository root."""
|
|
30
|
+
|
|
31
|
+
root: Path
|
|
32
|
+
config: Config
|
|
33
|
+
#: relative POSIX path -> parsed module
|
|
34
|
+
modules: dict[str, ModuleAnalysis] = field(default_factory=dict)
|
|
35
|
+
#: relative POSIX path -> why it was skipped
|
|
36
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def file_count(self) -> int:
|
|
40
|
+
return len(self.modules)
|
|
41
|
+
|
|
42
|
+
def paths(self) -> list[str]:
|
|
43
|
+
return sorted(self.modules)
|
|
44
|
+
|
|
45
|
+
def get(self, path: str) -> ModuleAnalysis | None:
|
|
46
|
+
return self.modules.get(path)
|
|
47
|
+
|
|
48
|
+
def source_of(self, path: str) -> str:
|
|
49
|
+
module = self.modules.get(path)
|
|
50
|
+
return module.source if module else ""
|
|
51
|
+
|
|
52
|
+
def test_paths(self) -> list[str]:
|
|
53
|
+
"""Paths that look like pytest test modules."""
|
|
54
|
+
return [p for p in self.paths() if is_test_path(p)]
|
|
55
|
+
|
|
56
|
+
def non_test_paths(self) -> list[str]:
|
|
57
|
+
return [p for p in self.paths() if not is_test_path(p)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_test_path(path: str) -> bool:
|
|
61
|
+
"""Whether a relative path looks like a pytest test module.
|
|
62
|
+
|
|
63
|
+
Follows pytest's own default discovery rules (``test_*.py`` / ``*_test.py``),
|
|
64
|
+
plus the near-universal convention of a ``tests/`` directory.
|
|
65
|
+
"""
|
|
66
|
+
pure = PurePosixPath(path)
|
|
67
|
+
name = pure.name
|
|
68
|
+
if name.startswith("test_") or name.endswith("_test.py"):
|
|
69
|
+
return True
|
|
70
|
+
return any(part in ("test", "tests") for part in pure.parts[:-1])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_excluded(relative: str, patterns: list[str]) -> bool:
|
|
74
|
+
"""Whether a relative POSIX path is excluded by name or glob.
|
|
75
|
+
|
|
76
|
+
A bare name like ``node_modules`` excludes any directory with that name at
|
|
77
|
+
any depth; a pattern containing ``/`` or ``*`` is matched as a glob against
|
|
78
|
+
the whole relative path.
|
|
79
|
+
"""
|
|
80
|
+
parts = PurePosixPath(relative).parts
|
|
81
|
+
for pattern in patterns:
|
|
82
|
+
if "/" in pattern or "*" in pattern or "?" in pattern:
|
|
83
|
+
if fnmatch.fnmatch(relative, pattern):
|
|
84
|
+
return True
|
|
85
|
+
if fnmatch.fnmatch(relative, pattern.rstrip("/") + "/*"):
|
|
86
|
+
return True
|
|
87
|
+
elif pattern in parts:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def discover_python_files(root: Path, config: Config) -> list[Path]:
|
|
93
|
+
"""Every analyzable ``.py`` file under ``root``, honouring the config.
|
|
94
|
+
|
|
95
|
+
Directories are pruned during the walk rather than filtered afterwards, so
|
|
96
|
+
a ``node_modules`` or ``.venv`` inside the repository costs nothing.
|
|
97
|
+
"""
|
|
98
|
+
root = Path(root).resolve()
|
|
99
|
+
roots: list[Path] = []
|
|
100
|
+
if config.source_dirs:
|
|
101
|
+
for entry in config.source_dirs:
|
|
102
|
+
candidate = (root / entry).resolve()
|
|
103
|
+
if not candidate.is_dir():
|
|
104
|
+
log.warning("configured source_dir does not exist: %s", entry)
|
|
105
|
+
continue
|
|
106
|
+
if root not in candidate.parents and candidate != root:
|
|
107
|
+
log.warning("ignoring source_dir outside the repository: %s", entry)
|
|
108
|
+
continue
|
|
109
|
+
roots.append(candidate)
|
|
110
|
+
if not roots:
|
|
111
|
+
log.warning("no configured source_dirs exist; scanning the whole repository")
|
|
112
|
+
roots = [root]
|
|
113
|
+
else:
|
|
114
|
+
roots = [root]
|
|
115
|
+
|
|
116
|
+
# When `source_dirs` narrows analysis, test files are still discovered from
|
|
117
|
+
# the whole repository. `source_dirs` exists to stop PatchAhead proposing
|
|
118
|
+
# edits outside a project's own source; it is not a statement about where
|
|
119
|
+
# the tests live, and narrowing test discovery with it would silently
|
|
120
|
+
# disable the targeted-test validation gate.
|
|
121
|
+
scan_roots = list(roots)
|
|
122
|
+
if config.source_dirs and root not in scan_roots:
|
|
123
|
+
scan_roots.append(root)
|
|
124
|
+
|
|
125
|
+
found: list[Path] = []
|
|
126
|
+
seen: set[Path] = set()
|
|
127
|
+
for base in scan_roots:
|
|
128
|
+
restrict_to_tests = base == root and config.source_dirs
|
|
129
|
+
stack = [base]
|
|
130
|
+
while stack:
|
|
131
|
+
directory = stack.pop()
|
|
132
|
+
try:
|
|
133
|
+
entries = sorted(directory.iterdir())
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
log.debug("cannot list %s: %s", directory, exc)
|
|
136
|
+
continue
|
|
137
|
+
for entry in entries:
|
|
138
|
+
try:
|
|
139
|
+
relative = entry.resolve().relative_to(root).as_posix()
|
|
140
|
+
except ValueError:
|
|
141
|
+
# A symlink pointing outside the repository. Following it
|
|
142
|
+
# could make PatchAhead read or propose edits to files the
|
|
143
|
+
# user did not point it at.
|
|
144
|
+
log.debug("skipping path outside the repository: %s", entry)
|
|
145
|
+
continue
|
|
146
|
+
if is_excluded(relative, config.exclude):
|
|
147
|
+
continue
|
|
148
|
+
if entry.is_symlink():
|
|
149
|
+
log.debug("skipping symlink: %s", relative)
|
|
150
|
+
continue
|
|
151
|
+
if entry.is_dir():
|
|
152
|
+
stack.append(entry)
|
|
153
|
+
elif entry.suffix == ".py" and entry not in seen:
|
|
154
|
+
if restrict_to_tests and not is_test_path(relative):
|
|
155
|
+
continue
|
|
156
|
+
seen.add(entry)
|
|
157
|
+
found.append(entry)
|
|
158
|
+
return sorted(found)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build(root: Path, config: Config) -> RepoIndex:
|
|
162
|
+
"""Discover, read, and parse every Python file under ``root``.
|
|
163
|
+
|
|
164
|
+
Unreadable and unparseable files are recorded in
|
|
165
|
+
:attr:`RepoIndex.skipped` with a reason and reported to the user, rather
|
|
166
|
+
than dropped. A repository with a syntax error in one module is still
|
|
167
|
+
analyzable, but the user should know which module was not looked at.
|
|
168
|
+
"""
|
|
169
|
+
root = Path(root).resolve()
|
|
170
|
+
index = RepoIndex(root=root, config=config)
|
|
171
|
+
|
|
172
|
+
for path in discover_python_files(root, config):
|
|
173
|
+
relative = path.relative_to(root).as_posix()
|
|
174
|
+
try:
|
|
175
|
+
size = path.stat().st_size
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
index.skipped[relative] = f"cannot stat: {exc}"
|
|
178
|
+
continue
|
|
179
|
+
if size > MAX_FILE_BYTES:
|
|
180
|
+
index.skipped[relative] = f"file is {size} bytes (limit {MAX_FILE_BYTES})"
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
source = read_source(path)
|
|
184
|
+
except UnicodeDecodeError as exc:
|
|
185
|
+
index.skipped[relative] = f"not valid UTF-8: {exc.reason}"
|
|
186
|
+
continue
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
index.skipped[relative] = f"cannot read: {exc}"
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
index.modules[relative] = analyze_source(source, relative)
|
|
192
|
+
except ParseError as exc:
|
|
193
|
+
index.skipped[relative] = f"syntax error: {exc}"
|
|
194
|
+
|
|
195
|
+
log.debug(
|
|
196
|
+
"indexed %d module(s), skipped %d, under %s",
|
|
197
|
+
len(index.modules),
|
|
198
|
+
len(index.skipped),
|
|
199
|
+
root,
|
|
200
|
+
)
|
|
201
|
+
for relative, reason in index.skipped.items():
|
|
202
|
+
log.debug("skipped %s: %s", relative, reason)
|
|
203
|
+
return index
|