bexar 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.
- bexar/__init__.py +3 -0
- bexar/__main__.py +6 -0
- bexar/analysis/__init__.py +1 -0
- bexar/analysis/project.py +529 -0
- bexar/analysis/statistics.py +130 -0
- bexar/atomic.py +84 -0
- bexar/cli.py +560 -0
- bexar/exceptions.py +21 -0
- bexar/integrity.py +527 -0
- bexar/packaging.py +543 -0
- bexar/parser.py +192 -0
- bexar/protection.py +575 -0
- bexar/transform/__init__.py +79 -0
- bexar/transform/constants.py +548 -0
- bexar/transform/engine.py +719 -0
- bexar/transform/identifiers.py +471 -0
- bexar/transform/packaging.py +1030 -0
- bexar/transform/profiles.py +320 -0
- bexar/transform/safety.py +404 -0
- bexar/transform/scan.py +249 -0
- bexar/transform/scopes.py +827 -0
- bexar/transform/strings.py +1632 -0
- bexar/verifier.py +451 -0
- bexar-0.1.0.dist-info/METADATA +167 -0
- bexar-0.1.0.dist-info/RECORD +29 -0
- bexar-0.1.0.dist-info/WHEEL +5 -0
- bexar-0.1.0.dist-info/entry_points.txt +2 -0
- bexar-0.1.0.dist-info/licenses/LICENSE +21 -0
- bexar-0.1.0.dist-info/top_level.txt +1 -0
bexar/__init__.py
ADDED
bexar/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static analysis helpers for Python source code."""
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
|
|
2
|
+
"""Bounded, non-executing analysis of Python project imports."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from bexar.exceptions import BexarError, SourceReadError
|
|
13
|
+
from bexar.parser import DEFAULT_MAX_SOURCE_BYTES, parse_file
|
|
14
|
+
|
|
15
|
+
DEFAULT_MAX_PROJECT_FILES = 10_000
|
|
16
|
+
DEFAULT_MAX_PROJECT_BYTES = 256 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
EXCLUDED_DIRECTORIES = {
|
|
19
|
+
".git",
|
|
20
|
+
".hg",
|
|
21
|
+
".svn",
|
|
22
|
+
".venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"build",
|
|
25
|
+
"dist",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"venv",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ImportReference:
|
|
33
|
+
"""A source-level import and its conservative resolution classification."""
|
|
34
|
+
|
|
35
|
+
source: str
|
|
36
|
+
line: int
|
|
37
|
+
name: str
|
|
38
|
+
resolution: str
|
|
39
|
+
target: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class DynamicImportReference:
|
|
44
|
+
"""A syntactically recognized dynamic import call."""
|
|
45
|
+
|
|
46
|
+
source: str
|
|
47
|
+
line: int
|
|
48
|
+
expression: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class ProjectAnalysis:
|
|
53
|
+
"""Import inventory and limits for one statically scanned source tree."""
|
|
54
|
+
|
|
55
|
+
root: str
|
|
56
|
+
source_root: str
|
|
57
|
+
files: tuple[str, ...]
|
|
58
|
+
imports: tuple[ImportReference, ...]
|
|
59
|
+
dynamic_imports: tuple[DynamicImportReference, ...]
|
|
60
|
+
total_source_bytes: int
|
|
61
|
+
target_python: str
|
|
62
|
+
grammar_compatible: bool
|
|
63
|
+
grammar_incompatible_files: tuple[str, ...]
|
|
64
|
+
python_files_scanned: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def analyze_project(
|
|
68
|
+
project: str | Path,
|
|
69
|
+
*,
|
|
70
|
+
max_files: int = DEFAULT_MAX_PROJECT_FILES,
|
|
71
|
+
max_bytes: int = DEFAULT_MAX_PROJECT_BYTES,
|
|
72
|
+
target_python: str = "3.11",
|
|
73
|
+
) -> ProjectAnalysis:
|
|
74
|
+
"""Inventory a source tree without importing or executing project code.
|
|
75
|
+
|
|
76
|
+
If ``project/src`` exists, it is treated as the import root; otherwise the
|
|
77
|
+
supplied directory is the import root. Only ``.py`` files are scanned.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(max_files, bool) or not isinstance(max_files, int) or max_files <= 0:
|
|
80
|
+
raise ValueError("max_files must be a positive integer")
|
|
81
|
+
|
|
82
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
|
83
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
84
|
+
|
|
85
|
+
target = _parse_target_version(target_python)
|
|
86
|
+
|
|
87
|
+
root = Path(project).resolve()
|
|
88
|
+
if not root.is_dir():
|
|
89
|
+
raise SourceReadError(f"project path is not a directory: {root}")
|
|
90
|
+
|
|
91
|
+
source_root = root / "src" if (root / "src").is_dir() else root
|
|
92
|
+
|
|
93
|
+
paths = _discover_python_files(source_root, max_files=max_files)
|
|
94
|
+
if not paths:
|
|
95
|
+
raise SourceReadError(f"no Python source files found under {source_root}")
|
|
96
|
+
|
|
97
|
+
total_bytes = 0
|
|
98
|
+
for path in paths:
|
|
99
|
+
try:
|
|
100
|
+
total_bytes += path.stat().st_size
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise SourceReadError(f"cannot inspect {path}: {error}") from error
|
|
103
|
+
|
|
104
|
+
if total_bytes > max_bytes:
|
|
105
|
+
raise SourceReadError(
|
|
106
|
+
f"project source exceeds the {max_bytes}-byte total limit"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
module_by_path = {
|
|
110
|
+
path: _module_name(path, source_root)
|
|
111
|
+
for path in paths
|
|
112
|
+
}
|
|
113
|
+
module_names = {
|
|
114
|
+
name for name in module_by_path.values() if name
|
|
115
|
+
}
|
|
116
|
+
package_names = _package_names(paths, source_root)
|
|
117
|
+
|
|
118
|
+
imports: list[ImportReference] = []
|
|
119
|
+
dynamic_imports: list[DynamicImportReference] = []
|
|
120
|
+
incompatible_files: list[str] = []
|
|
121
|
+
|
|
122
|
+
for path in paths:
|
|
123
|
+
try:
|
|
124
|
+
parsed = parse_file(path, max_bytes=DEFAULT_MAX_SOURCE_BYTES)
|
|
125
|
+
except BexarError as error:
|
|
126
|
+
relative = path.relative_to(source_root).as_posix()
|
|
127
|
+
raise SourceReadError(f"cannot analyze {relative}: {error}") from error
|
|
128
|
+
|
|
129
|
+
source_name = (
|
|
130
|
+
module_by_path[path]
|
|
131
|
+
or path.relative_to(source_root).as_posix()
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
package_context = (
|
|
135
|
+
module_by_path[path]
|
|
136
|
+
if path.name == "__init__.py"
|
|
137
|
+
else source_name.rpartition(".")[0]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not _supports_grammar(parsed.source, target):
|
|
141
|
+
incompatible_files.append(
|
|
142
|
+
path.relative_to(source_root).as_posix()
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for node in ast.walk(parsed.tree):
|
|
146
|
+
if isinstance(node, ast.Import):
|
|
147
|
+
for alias in node.names:
|
|
148
|
+
imports.append(
|
|
149
|
+
_resolve_absolute_import(
|
|
150
|
+
source_name,
|
|
151
|
+
alias.name,
|
|
152
|
+
module_names,
|
|
153
|
+
package_names,
|
|
154
|
+
node.lineno,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
elif isinstance(node, ast.ImportFrom):
|
|
159
|
+
imports.extend(
|
|
160
|
+
_resolve_from_import(
|
|
161
|
+
source_name,
|
|
162
|
+
node,
|
|
163
|
+
module_names,
|
|
164
|
+
package_names,
|
|
165
|
+
package_context,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
elif (
|
|
170
|
+
isinstance(node, ast.Call)
|
|
171
|
+
and _is_dynamic_import(node.func)
|
|
172
|
+
):
|
|
173
|
+
dynamic_imports.append(
|
|
174
|
+
DynamicImportReference(
|
|
175
|
+
source=source_name,
|
|
176
|
+
line=node.lineno,
|
|
177
|
+
expression=ast.unparse(node),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return ProjectAnalysis(
|
|
182
|
+
root=str(root),
|
|
183
|
+
source_root=str(source_root),
|
|
184
|
+
files=tuple(
|
|
185
|
+
path.relative_to(source_root).as_posix()
|
|
186
|
+
for path in paths
|
|
187
|
+
),
|
|
188
|
+
imports=tuple(imports),
|
|
189
|
+
dynamic_imports=tuple(dynamic_imports),
|
|
190
|
+
total_source_bytes=total_bytes,
|
|
191
|
+
target_python=f"{target[0]}.{target[1]}",
|
|
192
|
+
grammar_compatible=not incompatible_files,
|
|
193
|
+
grammar_incompatible_files=tuple(incompatible_files),
|
|
194
|
+
python_files_scanned=len(paths),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _discover_python_files(root: Path, *, max_files: int) -> list[Path]:
|
|
199
|
+
found: list[Path] = []
|
|
200
|
+
|
|
201
|
+
def raise_walk_error(error: OSError) -> None:
|
|
202
|
+
raise SourceReadError(
|
|
203
|
+
f"cannot scan project tree: {error}"
|
|
204
|
+
) from error
|
|
205
|
+
|
|
206
|
+
for current, directories, filenames in os.walk(
|
|
207
|
+
root,
|
|
208
|
+
topdown=True,
|
|
209
|
+
followlinks=False,
|
|
210
|
+
onerror=raise_walk_error,
|
|
211
|
+
):
|
|
212
|
+
current_path = Path(current)
|
|
213
|
+
|
|
214
|
+
directories[:] = sorted(
|
|
215
|
+
name
|
|
216
|
+
for name in directories
|
|
217
|
+
if name not in EXCLUDED_DIRECTORIES
|
|
218
|
+
and not (current_path / name).is_symlink()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for filename in sorted(filenames):
|
|
222
|
+
if filename.endswith(".py"):
|
|
223
|
+
path = current_path / filename
|
|
224
|
+
|
|
225
|
+
if path.is_symlink():
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
found.append(path)
|
|
229
|
+
|
|
230
|
+
if len(found) > max_files:
|
|
231
|
+
raise SourceReadError(
|
|
232
|
+
f"project contains more than the {max_files}-file limit"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return found
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _module_name(path: Path, source_root: Path) -> str:
|
|
239
|
+
relative = path.relative_to(source_root).with_suffix("")
|
|
240
|
+
parts = relative.parts
|
|
241
|
+
|
|
242
|
+
if parts[-1] == "__init__":
|
|
243
|
+
parts = parts[:-1]
|
|
244
|
+
|
|
245
|
+
if (source_root / "__init__.py").is_file():
|
|
246
|
+
return ".".join(
|
|
247
|
+
(source_root.name, *parts)
|
|
248
|
+
) if parts else source_root.name
|
|
249
|
+
|
|
250
|
+
return ".".join(parts)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _package_names(
|
|
254
|
+
paths: list[Path],
|
|
255
|
+
source_root: Path,
|
|
256
|
+
) -> set[str]:
|
|
257
|
+
packages = set()
|
|
258
|
+
|
|
259
|
+
if (source_root / "__init__.py").is_file():
|
|
260
|
+
packages.add(source_root.name)
|
|
261
|
+
|
|
262
|
+
for path in paths:
|
|
263
|
+
parts = path.relative_to(source_root).with_suffix("").parts
|
|
264
|
+
package_parts = parts[:-1]
|
|
265
|
+
|
|
266
|
+
if (source_root / "__init__.py").is_file():
|
|
267
|
+
package_parts = (source_root.name, *package_parts)
|
|
268
|
+
|
|
269
|
+
for index in range(1, len(package_parts) + 1):
|
|
270
|
+
packages.add(".".join(package_parts[:index]))
|
|
271
|
+
|
|
272
|
+
return packages
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _resolve_absolute_import(
|
|
276
|
+
source: str,
|
|
277
|
+
name: str,
|
|
278
|
+
module_names: set[str],
|
|
279
|
+
package_names: set[str],
|
|
280
|
+
line: int,
|
|
281
|
+
) -> ImportReference:
|
|
282
|
+
target = _find_project_target(
|
|
283
|
+
name,
|
|
284
|
+
module_names,
|
|
285
|
+
package_names,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if target is not None:
|
|
289
|
+
resolution = "project"
|
|
290
|
+
elif name.split(".", 1)[0] in sys.stdlib_module_names:
|
|
291
|
+
resolution = "stdlib"
|
|
292
|
+
else:
|
|
293
|
+
resolution = "external_or_unresolved"
|
|
294
|
+
|
|
295
|
+
return ImportReference(
|
|
296
|
+
source,
|
|
297
|
+
line,
|
|
298
|
+
name,
|
|
299
|
+
resolution,
|
|
300
|
+
target,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resolve_from_import(
|
|
305
|
+
source: str,
|
|
306
|
+
node: ast.ImportFrom,
|
|
307
|
+
module_names: set[str],
|
|
308
|
+
package_names: set[str],
|
|
309
|
+
package_context: str,
|
|
310
|
+
) -> list[ImportReference]:
|
|
311
|
+
if node.level:
|
|
312
|
+
package_parts = (
|
|
313
|
+
package_context.split(".")
|
|
314
|
+
if package_context
|
|
315
|
+
else []
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if node.level > len(package_parts):
|
|
319
|
+
return [
|
|
320
|
+
ImportReference(
|
|
321
|
+
source,
|
|
322
|
+
node.lineno,
|
|
323
|
+
_format_import_from(node),
|
|
324
|
+
"invalid_relative",
|
|
325
|
+
None,
|
|
326
|
+
)
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
base_parts = package_parts[
|
|
330
|
+
: len(package_parts) - node.level + 1
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
if node.module:
|
|
334
|
+
base_parts.extend(node.module.split("."))
|
|
335
|
+
|
|
336
|
+
base = ".".join(base_parts)
|
|
337
|
+
|
|
338
|
+
if not base and not node.names:
|
|
339
|
+
return [
|
|
340
|
+
ImportReference(
|
|
341
|
+
source,
|
|
342
|
+
node.lineno,
|
|
343
|
+
_format_import_from(node),
|
|
344
|
+
"invalid_relative",
|
|
345
|
+
None,
|
|
346
|
+
)
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
references = []
|
|
350
|
+
|
|
351
|
+
for alias in node.names:
|
|
352
|
+
name = ".".join(
|
|
353
|
+
part for part in (base, alias.name) if part
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
candidate = name if base else alias.name
|
|
357
|
+
|
|
358
|
+
target = _find_project_target(
|
|
359
|
+
candidate,
|
|
360
|
+
module_names,
|
|
361
|
+
package_names,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if target is None:
|
|
365
|
+
target = _find_project_target(
|
|
366
|
+
base,
|
|
367
|
+
module_names,
|
|
368
|
+
package_names,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
resolution = (
|
|
372
|
+
"project"
|
|
373
|
+
if target is not None
|
|
374
|
+
else "unresolved_relative"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
references.append(
|
|
378
|
+
ImportReference(
|
|
379
|
+
source,
|
|
380
|
+
node.lineno,
|
|
381
|
+
name,
|
|
382
|
+
resolution,
|
|
383
|
+
target,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return references
|
|
388
|
+
|
|
389
|
+
base = node.module or ""
|
|
390
|
+
|
|
391
|
+
if not base:
|
|
392
|
+
return [
|
|
393
|
+
ImportReference(
|
|
394
|
+
source,
|
|
395
|
+
node.lineno,
|
|
396
|
+
_format_import_from(node),
|
|
397
|
+
"unresolved",
|
|
398
|
+
None,
|
|
399
|
+
)
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
base_target = _find_project_target(
|
|
403
|
+
base,
|
|
404
|
+
module_names,
|
|
405
|
+
package_names,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
references = []
|
|
409
|
+
|
|
410
|
+
for alias in node.names:
|
|
411
|
+
candidate = f"{base}.{alias.name}"
|
|
412
|
+
|
|
413
|
+
target = _find_project_target(
|
|
414
|
+
candidate,
|
|
415
|
+
module_names,
|
|
416
|
+
package_names,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if target is None:
|
|
420
|
+
target = base_target
|
|
421
|
+
|
|
422
|
+
if target is not None:
|
|
423
|
+
resolution = "project"
|
|
424
|
+
elif base.split(".", 1)[0] in sys.stdlib_module_names:
|
|
425
|
+
resolution = "stdlib"
|
|
426
|
+
else:
|
|
427
|
+
resolution = "external_or_unresolved"
|
|
428
|
+
|
|
429
|
+
references.append(
|
|
430
|
+
ImportReference(
|
|
431
|
+
source,
|
|
432
|
+
node.lineno,
|
|
433
|
+
candidate,
|
|
434
|
+
resolution,
|
|
435
|
+
target,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return references
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _find_project_target(
|
|
443
|
+
name: str,
|
|
444
|
+
module_names: set[str],
|
|
445
|
+
package_names: set[str],
|
|
446
|
+
) -> str | None:
|
|
447
|
+
if not name:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
if name in module_names or name in package_names:
|
|
451
|
+
return name
|
|
452
|
+
|
|
453
|
+
parts = name.split(".")
|
|
454
|
+
|
|
455
|
+
for end in range(len(parts) - 1, 0, -1):
|
|
456
|
+
prefix = ".".join(parts[:end])
|
|
457
|
+
|
|
458
|
+
if prefix in module_names or prefix in package_names:
|
|
459
|
+
return prefix
|
|
460
|
+
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _format_import_from(node: ast.ImportFrom) -> str:
|
|
465
|
+
dots = "." * node.level
|
|
466
|
+
module = node.module or ""
|
|
467
|
+
names = ", ".join(alias.name for alias in node.names)
|
|
468
|
+
|
|
469
|
+
return f"from {dots}{module} import {names}"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _is_dynamic_import(function: ast.expr) -> bool:
|
|
473
|
+
if isinstance(function, ast.Name):
|
|
474
|
+
return function.id == "__import__"
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
isinstance(function, ast.Attribute)
|
|
478
|
+
and function.attr == "import_module"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _parse_target_version(value: str) -> tuple[int, int]:
|
|
483
|
+
if not isinstance(value, str):
|
|
484
|
+
raise ValueError(
|
|
485
|
+
"target_python must use MAJOR.MINOR format, such as 3.11"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
parts = value.split(".")
|
|
489
|
+
|
|
490
|
+
if (
|
|
491
|
+
len(parts) != 2
|
|
492
|
+
or not all(part.isdigit() for part in parts)
|
|
493
|
+
or any(len(part) > 1 and part.startswith("0") for part in parts)
|
|
494
|
+
):
|
|
495
|
+
raise ValueError(
|
|
496
|
+
"target_python must use MAJOR.MINOR format, such as 3.11"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
major, minor = (int(part) for part in parts)
|
|
500
|
+
|
|
501
|
+
if (
|
|
502
|
+
major != 3
|
|
503
|
+
or not 11 <= minor <= sys.version_info.minor
|
|
504
|
+
):
|
|
505
|
+
raise ValueError(
|
|
506
|
+
f"target Python must be between 3.11 and the running Python "
|
|
507
|
+
f"({sys.version_info.major}.{sys.version_info.minor})"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
return major, minor
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _supports_grammar(
|
|
514
|
+
source: str,
|
|
515
|
+
version: tuple[int, int],
|
|
516
|
+
) -> bool:
|
|
517
|
+
try:
|
|
518
|
+
ast.parse(source, feature_version=version)
|
|
519
|
+
except SyntaxError:
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def analysis_to_dict(
|
|
526
|
+
analysis: ProjectAnalysis,
|
|
527
|
+
) -> dict[str, object]:
|
|
528
|
+
"""Convert project analysis to a JSON-compatible dictionary."""
|
|
529
|
+
return asdict(analysis)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""AST-based source statistics that never execute inspected code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class SourceStatistics:
|
|
11
|
+
"""Counts of selected Python syntax constructs in one source file."""
|
|
12
|
+
|
|
13
|
+
source_bytes: int
|
|
14
|
+
lines: int
|
|
15
|
+
ast_nodes: int
|
|
16
|
+
functions: int
|
|
17
|
+
async_functions: int
|
|
18
|
+
classes: int
|
|
19
|
+
imports: int
|
|
20
|
+
relative_imports: int
|
|
21
|
+
string_literals: int
|
|
22
|
+
numeric_literals: int
|
|
23
|
+
dynamic_import_calls: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def collect_statistics(
|
|
27
|
+
tree: ast.AST,
|
|
28
|
+
*,
|
|
29
|
+
source_bytes: int,
|
|
30
|
+
source: str,
|
|
31
|
+
) -> SourceStatistics:
|
|
32
|
+
"""Count syntax constructs without compiling or executing source."""
|
|
33
|
+
|
|
34
|
+
if not isinstance(tree, ast.AST):
|
|
35
|
+
raise TypeError("tree must be an ast.AST instance")
|
|
36
|
+
|
|
37
|
+
if isinstance(source_bytes, bool) or not isinstance(source_bytes, int):
|
|
38
|
+
raise TypeError("source_bytes must be an integer")
|
|
39
|
+
|
|
40
|
+
if source_bytes < 0:
|
|
41
|
+
raise ValueError("source_bytes cannot be negative")
|
|
42
|
+
|
|
43
|
+
if not isinstance(source, str):
|
|
44
|
+
raise TypeError("source must be a string")
|
|
45
|
+
|
|
46
|
+
counts = {
|
|
47
|
+
"ast_nodes": 0,
|
|
48
|
+
"functions": 0,
|
|
49
|
+
"async_functions": 0,
|
|
50
|
+
"classes": 0,
|
|
51
|
+
"imports": 0,
|
|
52
|
+
"relative_imports": 0,
|
|
53
|
+
"string_literals": 0,
|
|
54
|
+
"numeric_literals": 0,
|
|
55
|
+
"dynamic_import_calls": 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for node in ast.walk(tree):
|
|
59
|
+
counts["ast_nodes"] += 1
|
|
60
|
+
|
|
61
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
62
|
+
counts["functions"] += 1
|
|
63
|
+
counts["async_functions"] += 1
|
|
64
|
+
|
|
65
|
+
elif isinstance(node, ast.FunctionDef):
|
|
66
|
+
counts["functions"] += 1
|
|
67
|
+
|
|
68
|
+
elif isinstance(node, ast.ClassDef):
|
|
69
|
+
counts["classes"] += 1
|
|
70
|
+
|
|
71
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
72
|
+
counts["imports"] += 1
|
|
73
|
+
|
|
74
|
+
if isinstance(node, ast.ImportFrom) and node.level > 0:
|
|
75
|
+
counts["relative_imports"] += 1
|
|
76
|
+
|
|
77
|
+
elif isinstance(node, ast.Constant):
|
|
78
|
+
value = node.value
|
|
79
|
+
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
counts["string_literals"] += 1
|
|
82
|
+
|
|
83
|
+
elif (
|
|
84
|
+
isinstance(value, (int, float, complex))
|
|
85
|
+
and not isinstance(value, bool)
|
|
86
|
+
):
|
|
87
|
+
counts["numeric_literals"] += 1
|
|
88
|
+
|
|
89
|
+
if isinstance(node, ast.Call) and _is_dynamic_import(node.func):
|
|
90
|
+
counts["dynamic_import_calls"] += 1
|
|
91
|
+
|
|
92
|
+
return SourceStatistics(
|
|
93
|
+
source_bytes=source_bytes,
|
|
94
|
+
lines=_count_lines(source),
|
|
95
|
+
**counts,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _count_lines(source: str) -> int:
|
|
100
|
+
"""Count physical lines without allocating a list."""
|
|
101
|
+
|
|
102
|
+
if not source:
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
line_breaks = (
|
|
106
|
+
source.count("\n")
|
|
107
|
+
+ source.count("\r")
|
|
108
|
+
- source.count("\r\n")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not source.endswith(("\n", "\r")):
|
|
112
|
+
line_breaks += 1
|
|
113
|
+
|
|
114
|
+
return line_breaks
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_dynamic_import(function: ast.expr) -> bool:
|
|
118
|
+
"""Recognize common dynamic-import call expressions."""
|
|
119
|
+
|
|
120
|
+
if isinstance(function, ast.Name):
|
|
121
|
+
return function.id == "__import__"
|
|
122
|
+
|
|
123
|
+
if isinstance(function, ast.Attribute):
|
|
124
|
+
return (
|
|
125
|
+
function.attr == "import_module"
|
|
126
|
+
and isinstance(function.value, ast.Name)
|
|
127
|
+
and function.value.id == "importlib"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return False
|