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 ADDED
@@ -0,0 +1,3 @@
1
+ """BEXAR Python source protection and compilation toolkit."""
2
+
3
+ __version__ = "0.1.0"
bexar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run BEXAR as a module."""
2
+
3
+ from bexar.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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