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/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
+ )