coltrace 0.1.0.dev4__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.
- coltrace/__init__.py +9 -0
- coltrace/__main__.py +6 -0
- coltrace/analysis.py +2206 -0
- coltrace/callbacks.py +579 -0
- coltrace/cli.py +668 -0
- coltrace/collector.py +561 -0
- coltrace/exporters.py +676 -0
- coltrace/graph.py +380 -0
- coltrace/history.py +441 -0
- coltrace/journey.py +578 -0
- coltrace/lineage_models.py +112 -0
- coltrace/models.py +92 -0
- coltrace/py.typed +0 -0
- coltrace/scanner.py +236 -0
- coltrace/templates/graph.html +418 -0
- coltrace/visual_layout.py +159 -0
- coltrace-0.1.0.dev4.dist-info/METADATA +241 -0
- coltrace-0.1.0.dev4.dist-info/RECORD +22 -0
- coltrace-0.1.0.dev4.dist-info/WHEEL +5 -0
- coltrace-0.1.0.dev4.dist-info/entry_points.txt +2 -0
- coltrace-0.1.0.dev4.dist-info/licenses/LICENSE +21 -0
- coltrace-0.1.0.dev4.dist-info/top_level.txt +1 -0
coltrace/models.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Serializable discovery records, independent of AST and graph libraries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
Evidence = Literal["confirmed", "inferred", "unresolved", "rejected"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Location:
|
|
14
|
+
path: str
|
|
15
|
+
line: int
|
|
16
|
+
column: int
|
|
17
|
+
end_line: int | None = None
|
|
18
|
+
end_column: int | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Diagnostic:
|
|
23
|
+
code: str
|
|
24
|
+
severity: Literal["warning", "error"]
|
|
25
|
+
message: str
|
|
26
|
+
location: Location
|
|
27
|
+
scope: str = "<module>"
|
|
28
|
+
hint: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class FileInfo:
|
|
33
|
+
path: str
|
|
34
|
+
module: str
|
|
35
|
+
status: Literal["parsed", "invalid", "unreadable"]
|
|
36
|
+
sha256: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class FunctionInfo:
|
|
41
|
+
name: str
|
|
42
|
+
scope: str
|
|
43
|
+
module: str
|
|
44
|
+
parameters: tuple[str, ...]
|
|
45
|
+
is_async: bool
|
|
46
|
+
location: Location
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DataFrameInfo:
|
|
51
|
+
id: str
|
|
52
|
+
scope: str
|
|
53
|
+
origin: str
|
|
54
|
+
receiver_evidence: Evidence
|
|
55
|
+
reason: str
|
|
56
|
+
location: Location
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ColumnOccurrence:
|
|
61
|
+
column: str | None
|
|
62
|
+
receiver: str
|
|
63
|
+
dataframe_id: str | None
|
|
64
|
+
receiver_evidence: Evidence
|
|
65
|
+
reason: str
|
|
66
|
+
access: Literal["read", "write", "delete", "read_write"]
|
|
67
|
+
syntax: Literal["subscript", "loc"]
|
|
68
|
+
supported: bool
|
|
69
|
+
scope: str
|
|
70
|
+
module: str
|
|
71
|
+
location: Location
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ScanReport:
|
|
76
|
+
files: tuple[FileInfo, ...]
|
|
77
|
+
functions: tuple[FunctionInfo, ...]
|
|
78
|
+
dataframes: tuple[DataFrameInfo, ...]
|
|
79
|
+
occurrences: tuple[ColumnOccurrence, ...]
|
|
80
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
81
|
+
exclude: tuple[str, ...] = ()
|
|
82
|
+
schema_version: str = "0.1"
|
|
83
|
+
report_kind: str = "column_occurrences"
|
|
84
|
+
tool_version: str = "0.1.0.dev4"
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, Any]:
|
|
87
|
+
"""Return JSON-compatible containers, with no AST or runtime objects."""
|
|
88
|
+
return json.loads(self.to_json()) # type: ignore[no-any-return]
|
|
89
|
+
|
|
90
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
91
|
+
"""Deterministic for the same source, config and Python interpreter."""
|
|
92
|
+
return json.dumps(asdict(self), ensure_ascii=False, sort_keys=True, indent=indent)
|
coltrace/py.typed
ADDED
|
File without changes
|
coltrace/scanner.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Deterministic discovery, encoding-aware parsing, and report assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import fnmatch
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import tokenize
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .collector import OccurrenceCollector
|
|
17
|
+
from .models import Diagnostic, FileInfo, Location, ScanReport
|
|
18
|
+
|
|
19
|
+
DEFAULT_IGNORES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
".venv",
|
|
22
|
+
"venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
".git",
|
|
25
|
+
".coltrace",
|
|
26
|
+
".pytest_cache",
|
|
27
|
+
".mypy_cache",
|
|
28
|
+
".ruff_cache",
|
|
29
|
+
"build",
|
|
30
|
+
"dist",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ScanConfig:
|
|
37
|
+
"""Excludes extend built-in ignores; explicit config overrides TOML discovery."""
|
|
38
|
+
|
|
39
|
+
exclude: tuple[str, ...] = ()
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not isinstance(self.exclude, tuple) or not all(
|
|
43
|
+
isinstance(pattern, str) and pattern for pattern in self.exclude
|
|
44
|
+
):
|
|
45
|
+
raise ValueError("exclude must be a tuple of non-empty glob strings")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_config(root: Path) -> ScanConfig:
|
|
49
|
+
path = root / "pyproject.toml"
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return ScanConfig()
|
|
52
|
+
try:
|
|
53
|
+
with path.open("rb") as stream:
|
|
54
|
+
document = tomllib.load(stream)
|
|
55
|
+
except (OSError, ValueError) as exc:
|
|
56
|
+
raise ValueError(f"Cannot read configuration {path}: {exc}") from exc
|
|
57
|
+
tool = document.get("tool", {})
|
|
58
|
+
if not isinstance(tool, dict):
|
|
59
|
+
raise ValueError("pyproject.toml 'tool' must be a table")
|
|
60
|
+
section = tool.get("coltrace", {})
|
|
61
|
+
if not isinstance(section, dict) or set(section) - {"exclude"}:
|
|
62
|
+
raise ValueError("[tool.coltrace] supports only the 'exclude' option in Phase 1")
|
|
63
|
+
exclude = section.get("exclude", [])
|
|
64
|
+
if not isinstance(exclude, list) or not all(isinstance(item, str) for item in exclude):
|
|
65
|
+
raise ValueError("[tool.coltrace].exclude must be an array of strings")
|
|
66
|
+
return ScanConfig(tuple(exclude))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ignored(relative: Path, config: ScanConfig) -> bool:
|
|
70
|
+
if any(part in DEFAULT_IGNORES for part in relative.parts):
|
|
71
|
+
return True
|
|
72
|
+
value = relative.as_posix()
|
|
73
|
+
return any(
|
|
74
|
+
fnmatch.fnmatchcase(value, pattern.rstrip("/"))
|
|
75
|
+
or ("/" not in pattern and any(fnmatch.fnmatchcase(p, pattern) for p in relative.parts))
|
|
76
|
+
for pattern in config.exclude
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_link(path: Path) -> bool:
|
|
81
|
+
if path.is_symlink():
|
|
82
|
+
return True
|
|
83
|
+
# Covers junctions on Python 3.11 too; conservatively skip Windows reparse points.
|
|
84
|
+
return os.name == "nt" and bool(
|
|
85
|
+
path.lstat().st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _discover(root: Path, config: ScanConfig, diagnostics: list[Diagnostic]) -> list[Path]:
|
|
90
|
+
files: list[Path] = []
|
|
91
|
+
|
|
92
|
+
def on_error(error: OSError) -> None:
|
|
93
|
+
path = Path(error.filename) if error.filename else root
|
|
94
|
+
relative = path.relative_to(root).as_posix()
|
|
95
|
+
diagnostics.append(
|
|
96
|
+
Diagnostic(
|
|
97
|
+
"CT_DISCOVERY_ERROR",
|
|
98
|
+
"error",
|
|
99
|
+
str(error),
|
|
100
|
+
Location(relative, 1, 0),
|
|
101
|
+
hint="Check directory permissions; this part of the scan is incomplete.",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
for directory, subdirs, names in os.walk(root, followlinks=False, onerror=on_error):
|
|
106
|
+
current = Path(directory)
|
|
107
|
+
subdirs[:] = sorted(
|
|
108
|
+
name
|
|
109
|
+
for name in subdirs
|
|
110
|
+
if not _is_link(current / name)
|
|
111
|
+
and not _ignored((current / name).relative_to(root), config)
|
|
112
|
+
)
|
|
113
|
+
files.extend(
|
|
114
|
+
current / name
|
|
115
|
+
for name in sorted(names)
|
|
116
|
+
if name.endswith(".py")
|
|
117
|
+
and not _is_link(current / name)
|
|
118
|
+
and not _ignored((current / name).relative_to(root), config)
|
|
119
|
+
)
|
|
120
|
+
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _module_name(relative: Path) -> str:
|
|
124
|
+
parts = relative.with_suffix("").parts
|
|
125
|
+
return ".".join(parts[:-1] if parts[-1] == "__init__" else parts) or "__init__"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def scan_project(path: str | Path, *, config: ScanConfig | None = None) -> ScanReport:
|
|
129
|
+
"""Scan a directory without importing it, executing it, or reading its data sources.
|
|
130
|
+
|
|
131
|
+
Relative paths in the report use this directory as their root. Invalid files
|
|
132
|
+
produce diagnostics and do not prevent valid files from being scanned.
|
|
133
|
+
"""
|
|
134
|
+
root = Path(path).resolve()
|
|
135
|
+
if not root.is_dir():
|
|
136
|
+
raise ValueError(f"Scan root must be an existing directory: {root}")
|
|
137
|
+
settings = config if config is not None else _load_config(root)
|
|
138
|
+
diagnostics: list[Diagnostic] = []
|
|
139
|
+
files: list[FileInfo] = []
|
|
140
|
+
collectors: list[OccurrenceCollector] = []
|
|
141
|
+
for source_path in _discover(root, settings, diagnostics):
|
|
142
|
+
relative = source_path.relative_to(root)
|
|
143
|
+
filename = relative.as_posix()
|
|
144
|
+
module = _module_name(relative)
|
|
145
|
+
digest = None
|
|
146
|
+
try:
|
|
147
|
+
raw = source_path.read_bytes()
|
|
148
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
149
|
+
encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
|
|
150
|
+
source = raw.decode(encoding).replace("\r\n", "\n").replace("\r", "\n")
|
|
151
|
+
except (UnicodeError, LookupError, SyntaxError) as exc:
|
|
152
|
+
files.append(FileInfo(filename, module, "invalid", digest))
|
|
153
|
+
diagnostics.append(
|
|
154
|
+
Diagnostic(
|
|
155
|
+
"CT_ENCODING_ERROR",
|
|
156
|
+
"error",
|
|
157
|
+
str(exc),
|
|
158
|
+
Location(filename, 1, 0),
|
|
159
|
+
hint="Use a valid Python encoding declaration or UTF-8.",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
files.append(FileInfo(filename, module, "unreadable", None))
|
|
165
|
+
diagnostics.append(
|
|
166
|
+
Diagnostic(
|
|
167
|
+
"CT_READ_ERROR",
|
|
168
|
+
"error",
|
|
169
|
+
str(exc),
|
|
170
|
+
Location(filename, 1, 0),
|
|
171
|
+
hint="Check file permissions and scan again.",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
tree = ast.parse(source, filename=filename)
|
|
177
|
+
except (SyntaxError, RecursionError) as exc:
|
|
178
|
+
location = Location(filename, 1, 0)
|
|
179
|
+
if isinstance(exc, SyntaxError):
|
|
180
|
+
location = Location(filename, exc.lineno or 1, max((exc.offset or 1) - 1, 0))
|
|
181
|
+
files.append(FileInfo(filename, module, "invalid", digest))
|
|
182
|
+
diagnostics.append(
|
|
183
|
+
Diagnostic(
|
|
184
|
+
"CT_SYNTAX_ERROR",
|
|
185
|
+
"error",
|
|
186
|
+
str(exc),
|
|
187
|
+
location,
|
|
188
|
+
hint="Check syntax and use an interpreter supporting the source grammar.",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
files.append(FileInfo(filename, module, "parsed", digest))
|
|
193
|
+
collector = OccurrenceCollector(filename, module, source)
|
|
194
|
+
try:
|
|
195
|
+
collector.visit(tree)
|
|
196
|
+
except RecursionError:
|
|
197
|
+
diagnostics.append(
|
|
198
|
+
Diagnostic(
|
|
199
|
+
"CT_ANALYSIS_LIMIT",
|
|
200
|
+
"error",
|
|
201
|
+
"The source exceeds AST traversal depth limits.",
|
|
202
|
+
Location(filename, 1, 0),
|
|
203
|
+
hint="Simplify nested expressions; this file's occurrences are omitted.",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
collectors.append(collector)
|
|
208
|
+
diagnostics.extend(collector.diagnostics)
|
|
209
|
+
return ScanReport(
|
|
210
|
+
files=tuple(files),
|
|
211
|
+
functions=tuple(item for c in collectors for item in c.functions),
|
|
212
|
+
dataframes=tuple(item for c in collectors for item in c.dataframes),
|
|
213
|
+
occurrences=tuple(
|
|
214
|
+
sorted(
|
|
215
|
+
(item for c in collectors for item in c.occurrences),
|
|
216
|
+
key=lambda item: (
|
|
217
|
+
item.location.path,
|
|
218
|
+
item.location.line,
|
|
219
|
+
item.location.column,
|
|
220
|
+
item.access,
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
),
|
|
224
|
+
diagnostics=tuple(
|
|
225
|
+
sorted(
|
|
226
|
+
diagnostics,
|
|
227
|
+
key=lambda d: (
|
|
228
|
+
d.location.path,
|
|
229
|
+
d.location.line,
|
|
230
|
+
d.location.column,
|
|
231
|
+
d.code,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
),
|
|
235
|
+
exclude=settings.exclude,
|
|
236
|
+
)
|