pybinaryguard 1.0.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.
Files changed (91) hide show
  1. pybinaryguard/__init__.py +78 -0
  2. pybinaryguard/__main__.py +7 -0
  3. pybinaryguard/_compat/__init__.py +0 -0
  4. pybinaryguard/agent/__init__.py +63 -0
  5. pybinaryguard/agent/guard.py +232 -0
  6. pybinaryguard/agent/recommender.py +283 -0
  7. pybinaryguard/agent/schema.py +200 -0
  8. pybinaryguard/agent/simulator.py +430 -0
  9. pybinaryguard/agent/tool_interface.py +474 -0
  10. pybinaryguard/analyzers/__init__.py +209 -0
  11. pybinaryguard/analyzers/base.py +40 -0
  12. pybinaryguard/analyzers/dependency_analyzer.py +336 -0
  13. pybinaryguard/analyzers/elf_analyzer.py +754 -0
  14. pybinaryguard/analyzers/symbol_analyzer.py +280 -0
  15. pybinaryguard/analyzers/wheel_analyzer.py +308 -0
  16. pybinaryguard/cli/__init__.py +5 -0
  17. pybinaryguard/cli/commands.py +414 -0
  18. pybinaryguard/cli/formatters.py +720 -0
  19. pybinaryguard/cli/main.py +250 -0
  20. pybinaryguard/diagnostics/__init__.py +13 -0
  21. pybinaryguard/diagnostics/explainer.py +356 -0
  22. pybinaryguard/diagnostics/findings.py +146 -0
  23. pybinaryguard/diagnostics/suggestions.py +508 -0
  24. pybinaryguard/frameworks/__init__.py +20 -0
  25. pybinaryguard/frameworks/onnxruntime.py +214 -0
  26. pybinaryguard/frameworks/pytorch.py +223 -0
  27. pybinaryguard/frameworks/tensorflow.py +266 -0
  28. pybinaryguard/frameworks/tensorrt.py +189 -0
  29. pybinaryguard/models/__init__.py +19 -0
  30. pybinaryguard/models/enums.py +102 -0
  31. pybinaryguard/models/finding.py +109 -0
  32. pybinaryguard/models/package.py +121 -0
  33. pybinaryguard/models/system.py +118 -0
  34. pybinaryguard/plugins/__init__.py +19 -0
  35. pybinaryguard/plugins/contrib/__init__.py +7 -0
  36. pybinaryguard/plugins/contrib/gstreamer.py +109 -0
  37. pybinaryguard/plugins/contrib/jetson.py +385 -0
  38. pybinaryguard/plugins/contrib/opencv.py +306 -0
  39. pybinaryguard/plugins/contrib/tensorrt.py +426 -0
  40. pybinaryguard/plugins/hooks.py +273 -0
  41. pybinaryguard/plugins/loader.py +190 -0
  42. pybinaryguard/predictor/__init__.py +23 -0
  43. pybinaryguard/predictor/dependency_graph.py +226 -0
  44. pybinaryguard/predictor/linker_simulator.py +161 -0
  45. pybinaryguard/predictor/predictor.py +144 -0
  46. pybinaryguard/predictor/resolver.py +282 -0
  47. pybinaryguard/probes/__init__.py +73 -0
  48. pybinaryguard/probes/base.py +45 -0
  49. pybinaryguard/probes/board_probe.py +248 -0
  50. pybinaryguard/probes/cpu_probe.py +176 -0
  51. pybinaryguard/probes/glibc_probe.py +215 -0
  52. pybinaryguard/probes/gpu_probe.py +430 -0
  53. pybinaryguard/probes/library_probe.py +132 -0
  54. pybinaryguard/probes/os_probe.py +227 -0
  55. pybinaryguard/probes/python_probe.py +135 -0
  56. pybinaryguard/probes/toolchain_probe.py +89 -0
  57. pybinaryguard/probes/venv_probe.py +111 -0
  58. pybinaryguard/profiles/__init__.py +12 -0
  59. pybinaryguard/profiles/engine.py +343 -0
  60. pybinaryguard/rules/__init__.py +24 -0
  61. pybinaryguard/rules/base.py +57 -0
  62. pybinaryguard/rules/builtin/__init__.py +136 -0
  63. pybinaryguard/rules/builtin/arch_rules.py +86 -0
  64. pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
  65. pybinaryguard/rules/builtin/container_rules.py +170 -0
  66. pybinaryguard/rules/builtin/cpu_rules.py +204 -0
  67. pybinaryguard/rules/builtin/cuda_rules.py +760 -0
  68. pybinaryguard/rules/builtin/dependency_rules.py +278 -0
  69. pybinaryguard/rules/builtin/framework_rules.py +252 -0
  70. pybinaryguard/rules/builtin/glibc_rules.py +320 -0
  71. pybinaryguard/rules/builtin/numpy_rules.py +110 -0
  72. pybinaryguard/rules/builtin/predictive_rules.py +165 -0
  73. pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
  74. pybinaryguard/rules/builtin/source_build_rules.py +150 -0
  75. pybinaryguard/rules/builtin/venv_rules.py +159 -0
  76. pybinaryguard/rules/engine.py +123 -0
  77. pybinaryguard/scanner.py +904 -0
  78. pybinaryguard/scoring/__init__.py +19 -0
  79. pybinaryguard/scoring/engine.py +416 -0
  80. pybinaryguard/snapshot/__init__.py +20 -0
  81. pybinaryguard/snapshot/generator.py +168 -0
  82. pybinaryguard/snapshot/lockfile.py +152 -0
  83. pybinaryguard/snapshot/verifier.py +315 -0
  84. pybinaryguard/validators/__init__.py +7 -0
  85. pybinaryguard/validators/import_validator.py +231 -0
  86. pybinaryguard-1.0.0.dist-info/METADATA +876 -0
  87. pybinaryguard-1.0.0.dist-info/RECORD +91 -0
  88. pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
  89. pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
  90. pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
  91. pybinaryguard-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,280 @@
1
+ """Symbol analyzer for GLIBC version requirements and CPython ABI detection.
2
+
3
+ Operates on the ``SharedObjectInfo`` instances already populated by the
4
+ ``ELFAnalyzer``. This analyzer extracts the maximum GLIBC version
5
+ required across all ``.so`` files and detects CPython-specific symbols.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from typing import List, Optional, Tuple
13
+
14
+ from pybinaryguard.analyzers.base import AnalyzerBase
15
+ from pybinaryguard.models.package import PackageBinaryInfo, SharedObjectInfo
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Regex for GLIBC version strings like "GLIBC_2.17"
20
+ _GLIBC_VERSION_RE = re.compile(r"GLIBC_(\d+)\.(\d+)")
21
+
22
+ # Regex for GLIBCXX version strings like "GLIBCXX_3.4.29"
23
+ _GLIBCXX_VERSION_RE = re.compile(r"GLIBCXX_([\d.]+)")
24
+
25
+ # CPython ABI symbols begin with these prefixes
26
+ _CPYTHON_SYMBOL_PREFIXES = (
27
+ "_Py",
28
+ "Py",
29
+ "PyInit_",
30
+ "_PyArg_",
31
+ "PyModule_",
32
+ "PyErr_",
33
+ "PyObject_",
34
+ "PyType_",
35
+ "PyLong_",
36
+ "PyFloat_",
37
+ "PyUnicode_",
38
+ "PyList_",
39
+ "PyDict_",
40
+ "PyTuple_",
41
+ "PyBytes_",
42
+ "PyMem_",
43
+ "PyGILState_",
44
+ )
45
+
46
+
47
+ class SymbolAnalyzer(AnalyzerBase):
48
+ """Analyzes GNU version requirements and CPython symbols.
49
+
50
+ This analyzer runs *after* the ``ELFAnalyzer`` and refines the
51
+ package-level ``required_glibc`` and ``required_glibcxx`` fields by
52
+ inspecting the ``gnu_version_requirements`` already extracted from
53
+ each ``SharedObjectInfo``.
54
+
55
+ It also sets ``has_python_symbols`` on individual shared objects
56
+ based on whether their ``DT_NEEDED`` list references ``libpython``
57
+ or their version requirements reference CPython-specific version
58
+ tags.
59
+ """
60
+
61
+ name: str = "symbol"
62
+
63
+ def analyze(self, package_info: PackageBinaryInfo) -> PackageBinaryInfo:
64
+ """Compute aggregate GLIBC/GLIBCXX requirements and detect CPython ABI.
65
+
66
+ Parameters
67
+ ----------
68
+ package_info:
69
+ The package descriptor whose ``shared_objects`` list has
70
+ already been populated by the ELF analyzer.
71
+
72
+ Returns
73
+ -------
74
+ PackageBinaryInfo
75
+ The same (mutated) instance.
76
+ """
77
+ if not package_info.shared_objects:
78
+ return package_info
79
+
80
+ max_glibc: Optional[Tuple[int, int]] = None
81
+ max_glibcxx: Optional[str] = None
82
+
83
+ for so in package_info.shared_objects:
84
+ # Compute per-object GLIBC
85
+ so_glibc = self._max_glibc_from_requirements(so.gnu_version_requirements)
86
+ if so_glibc is not None:
87
+ so.required_glibc = so_glibc
88
+ if max_glibc is None or so_glibc > max_glibc:
89
+ max_glibc = so_glibc
90
+
91
+ # Compute per-object GLIBCXX
92
+ so_glibcxx = self._max_glibcxx_from_requirements(so.gnu_version_requirements)
93
+ if so_glibcxx is not None:
94
+ so.required_glibcxx = so_glibcxx
95
+ if max_glibcxx is None or _compare_glibcxx(so_glibcxx, max_glibcxx) > 0:
96
+ max_glibcxx = so_glibcxx
97
+
98
+ # Detect CPython ABI usage
99
+ so.has_python_symbols = self._detect_python_symbols(so)
100
+
101
+ if max_glibc is not None:
102
+ package_info.required_glibc = max_glibc
103
+
104
+ if max_glibcxx is not None:
105
+ package_info.required_glibcxx = max_glibcxx
106
+
107
+ return package_info
108
+
109
+ # ------------------------------------------------------------------
110
+ # GLIBC version extraction
111
+ # ------------------------------------------------------------------
112
+
113
+ @staticmethod
114
+ def _max_glibc_from_requirements(requirements: List[str]) -> Optional[Tuple[int, int]]:
115
+ """Extract the maximum GLIBC version from version requirement strings.
116
+
117
+ Parameters
118
+ ----------
119
+ requirements:
120
+ List of strings like ``"libc.so.6(GLIBC_2.17)"``.
121
+
122
+ Returns
123
+ -------
124
+ (major, minor) or None
125
+ """
126
+ max_ver: Optional[Tuple[int, int]] = None
127
+ for req in requirements:
128
+ match = _GLIBC_VERSION_RE.search(req)
129
+ if match:
130
+ ver = (int(match.group(1)), int(match.group(2)))
131
+ if max_ver is None or ver > max_ver:
132
+ max_ver = ver
133
+ return max_ver
134
+
135
+ @staticmethod
136
+ def _max_glibcxx_from_requirements(requirements: List[str]) -> Optional[str]:
137
+ """Extract the maximum GLIBCXX version string from version requirements.
138
+
139
+ Parameters
140
+ ----------
141
+ requirements:
142
+ List of strings like ``"libstdc++.so.6(GLIBCXX_3.4.29)"``.
143
+
144
+ Returns
145
+ -------
146
+ Version string like ``"GLIBCXX_3.4.29"`` or None.
147
+ """
148
+ max_ver: Optional[str] = None
149
+ for req in requirements:
150
+ match = _GLIBCXX_VERSION_RE.search(req)
151
+ if match:
152
+ ver_str = f"GLIBCXX_{match.group(1)}"
153
+ if max_ver is None or _compare_glibcxx(ver_str, max_ver) > 0:
154
+ max_ver = ver_str
155
+ return max_ver
156
+
157
+ # ------------------------------------------------------------------
158
+ # CPython symbol detection
159
+ # ------------------------------------------------------------------
160
+
161
+ @staticmethod
162
+ def _detect_python_symbols(so: SharedObjectInfo) -> bool:
163
+ """Detect whether *so* links against CPython.
164
+
165
+ Checks:
166
+ 1. DT_NEEDED contains ``libpython*.so*``
167
+ 2. DT_SONAME matches ``cpython-*`` pattern (CPython extension naming)
168
+ 3. The filename matches the CPython extension naming convention
169
+ (e.g. ``foo.cpython-312-x86_64-linux-gnu.so``)
170
+ """
171
+ # Check DT_NEEDED for libpython
172
+ for lib in so.dt_needed:
173
+ if lib.startswith("libpython"):
174
+ return True
175
+
176
+ # Check soname
177
+ if so.dt_soname and "cpython" in so.dt_soname.lower():
178
+ return True
179
+
180
+ # Check filename convention: *.cpython-XYZ-*.so
181
+ if ".cpython-" in so.filename:
182
+ return True
183
+
184
+ return False
185
+
186
+
187
+ def parse_glibc_version(version_string: str) -> Optional[Tuple[int, int]]:
188
+ """Parse a GLIBC version string into a ``(major, minor)`` tuple.
189
+
190
+ Parameters
191
+ ----------
192
+ version_string:
193
+ A string like ``"GLIBC_2.17"`` or just ``"2.17"``.
194
+
195
+ Returns
196
+ -------
197
+ (int, int) or None
198
+ The parsed version, or ``None`` if parsing fails.
199
+
200
+ Examples
201
+ --------
202
+ >>> parse_glibc_version("GLIBC_2.17")
203
+ (2, 17)
204
+ >>> parse_glibc_version("2.34")
205
+ (2, 34)
206
+ >>> parse_glibc_version("invalid")
207
+ """
208
+ # Try with prefix first
209
+ match = _GLIBC_VERSION_RE.search(version_string)
210
+ if match:
211
+ return (int(match.group(1)), int(match.group(2)))
212
+
213
+ # Try bare version
214
+ parts = version_string.strip().split(".")
215
+ if len(parts) >= 2:
216
+ try:
217
+ return (int(parts[0]), int(parts[1]))
218
+ except ValueError:
219
+ pass
220
+
221
+ return None
222
+
223
+
224
+ def compute_max_glibc(shared_objects: List[SharedObjectInfo]) -> Optional[Tuple[int, int]]:
225
+ """Compute the maximum required GLIBC version across shared objects.
226
+
227
+ Parameters
228
+ ----------
229
+ shared_objects:
230
+ The shared object descriptors, each with a ``required_glibc``
231
+ field that may or may not be set.
232
+
233
+ Returns
234
+ -------
235
+ (int, int) or None
236
+ The highest GLIBC version required, or ``None`` if none of the
237
+ objects require GLIBC.
238
+ """
239
+ max_ver: Optional[Tuple[int, int]] = None
240
+ for so in shared_objects:
241
+ if so.required_glibc is not None:
242
+ if max_ver is None or so.required_glibc > max_ver:
243
+ max_ver = so.required_glibc
244
+ return max_ver
245
+
246
+
247
+ def _compare_glibcxx(a: str, b: str) -> int:
248
+ """Compare two GLIBCXX version strings numerically.
249
+
250
+ Parameters
251
+ ----------
252
+ a, b:
253
+ Version strings like ``"GLIBCXX_3.4.29"``.
254
+
255
+ Returns
256
+ -------
257
+ int
258
+ Negative if *a* < *b*, zero if equal, positive if *a* > *b*.
259
+ """
260
+ def _parts(s: str) -> List[int]:
261
+ match = _GLIBCXX_VERSION_RE.search(s)
262
+ if not match:
263
+ return [0]
264
+ try:
265
+ return [int(x) for x in match.group(1).split(".")]
266
+ except ValueError:
267
+ return [0]
268
+
269
+ pa = _parts(a)
270
+ pb = _parts(b)
271
+
272
+ # Pad to equal length
273
+ max_len = max(len(pa), len(pb))
274
+ pa.extend([0] * (max_len - len(pa)))
275
+ pb.extend([0] * (max_len - len(pb)))
276
+
277
+ for va, vb in zip(pa, pb):
278
+ if va != vb:
279
+ return 1 if va > vb else -1
280
+ return 0
@@ -0,0 +1,308 @@
1
+ """Wheel / dist-info metadata analyzer.
2
+
3
+ Reads packaging metadata from ``.dist-info`` directories to extract wheel
4
+ tags, package identity, file lists, and framework-specific build markers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ from pybinaryguard.analyzers.base import AnalyzerBase
15
+ from pybinaryguard.models.package import PackageBinaryInfo, WheelTag
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Pre-compiled patterns
20
+ _TAG_RE = re.compile(r"^Tag:\s*(.+)$", re.MULTILINE)
21
+ _NAME_RE = re.compile(r"^Name:\s*(.+)$", re.MULTILINE)
22
+ _VERSION_RE = re.compile(r"^Version:\s*(.+)$", re.MULTILINE)
23
+ _CUDA_VERSION_RE = re.compile(r"\+cu(\d{2,3})")
24
+
25
+
26
+ class WheelAnalyzer(AnalyzerBase):
27
+ """Analyzes wheel packaging metadata from ``.dist-info`` directories.
28
+
29
+ Extracts:
30
+ - Wheel compatibility tags (``Tag:`` lines in ``WHEEL``)
31
+ - Package name and version (from ``METADATA``)
32
+ - File manifest (from ``RECORD``)
33
+ - Pure-Python detection
34
+ - CUDA build version from the version string
35
+ """
36
+
37
+ name: str = "wheel"
38
+
39
+ def analyze(self, package_info: PackageBinaryInfo) -> PackageBinaryInfo:
40
+ """Read dist-info metadata and enrich *package_info*.
41
+
42
+ Parameters
43
+ ----------
44
+ package_info:
45
+ The package descriptor to populate.
46
+
47
+ Returns
48
+ -------
49
+ PackageBinaryInfo
50
+ The same (mutated) instance.
51
+ """
52
+ dist_info = self._find_dist_info(package_info.install_path, package_info.package_name)
53
+ if dist_info is None:
54
+ return package_info
55
+
56
+ self._parse_wheel_file(dist_info, package_info)
57
+ self._parse_metadata_file(dist_info, package_info)
58
+ self._detect_pure_python(dist_info, package_info)
59
+ self._extract_cuda_version(package_info)
60
+
61
+ return package_info
62
+
63
+ # ------------------------------------------------------------------
64
+ # dist-info discovery
65
+ # ------------------------------------------------------------------
66
+
67
+ @staticmethod
68
+ def _find_dist_info(install_path: str, package_name: str) -> Optional[str]:
69
+ """Locate the ``.dist-info`` directory for *package_name*.
70
+
71
+ Searches the parent directory of *install_path* (typically
72
+ ``site-packages``) for a matching ``.dist-info`` folder.
73
+
74
+ Parameters
75
+ ----------
76
+ install_path:
77
+ The package's own install directory (e.g.
78
+ ``site-packages/numpy``).
79
+ package_name:
80
+ The distribution name (e.g. ``numpy``).
81
+
82
+ Returns
83
+ -------
84
+ str or None
85
+ Absolute path to the ``.dist-info`` directory, or ``None``.
86
+ """
87
+ site_dir = os.path.dirname(install_path)
88
+ if not os.path.isdir(site_dir):
89
+ return None
90
+
91
+ # Normalize: PEP 503 says compare names case-insensitively after
92
+ # replacing [-_.] with dashes.
93
+ normalized = _normalize_name(package_name)
94
+
95
+ try:
96
+ entries = os.listdir(site_dir)
97
+ except OSError as exc:
98
+ logger.debug("Cannot list %s: %s", site_dir, exc)
99
+ return None
100
+
101
+ for entry in sorted(entries):
102
+ if not entry.endswith(".dist-info"):
103
+ continue
104
+ # Entry format: <name>-<version>.dist-info
105
+ entry_name_part = entry.rsplit("-", 1)[0] if "-" in entry else entry
106
+ if _normalize_name(entry_name_part) == normalized:
107
+ full = os.path.join(site_dir, entry)
108
+ if os.path.isdir(full):
109
+ return full
110
+
111
+ # Broader search: sometimes the package directory name and the
112
+ # distribution name diverge (e.g. ``Pillow`` installs as ``PIL``).
113
+ # Try matching any dist-info whose top_level.txt contains the
114
+ # package directory basename.
115
+ pkg_basename = os.path.basename(install_path).lower()
116
+ for entry in sorted(entries):
117
+ if not entry.endswith(".dist-info"):
118
+ continue
119
+ full = os.path.join(site_dir, entry)
120
+ top_level_path = os.path.join(full, "top_level.txt")
121
+ try:
122
+ if os.path.isfile(top_level_path):
123
+ with open(top_level_path, "r", encoding="utf-8", errors="replace") as fh:
124
+ for line in fh:
125
+ if line.strip().lower() == pkg_basename:
126
+ return full
127
+ except OSError:
128
+ continue
129
+
130
+ return None
131
+
132
+ # ------------------------------------------------------------------
133
+ # WHEEL file
134
+ # ------------------------------------------------------------------
135
+
136
+ @staticmethod
137
+ def _parse_wheel_file(dist_info: str, package_info: PackageBinaryInfo) -> None:
138
+ """Parse ``WHEEL`` for ``Tag:`` entries."""
139
+ wheel_path = os.path.join(dist_info, "WHEEL")
140
+ try:
141
+ with open(wheel_path, "r", encoding="utf-8", errors="replace") as fh:
142
+ content = fh.read()
143
+ except OSError as exc:
144
+ logger.debug("Cannot read %s: %s", wheel_path, exc)
145
+ return
146
+
147
+ for match in _TAG_RE.finditer(content):
148
+ raw_tag = match.group(1).strip()
149
+ parts = raw_tag.split("-")
150
+ if len(parts) == 3:
151
+ package_info.wheel_tags.append(
152
+ WheelTag(interpreter=parts[0], abi=parts[1], platform=parts[2])
153
+ )
154
+ elif len(parts) > 3:
155
+ # Platform tag itself may contain hyphens (rare, but possible
156
+ # with compound tags like manylinux_2_17_x86_64.manylinux2014_x86_64)
157
+ package_info.wheel_tags.append(
158
+ WheelTag(
159
+ interpreter=parts[0],
160
+ abi=parts[1],
161
+ platform="-".join(parts[2:]),
162
+ )
163
+ )
164
+
165
+ # ------------------------------------------------------------------
166
+ # METADATA file
167
+ # ------------------------------------------------------------------
168
+
169
+ @staticmethod
170
+ def _parse_metadata_file(dist_info: str, package_info: PackageBinaryInfo) -> None:
171
+ """Parse ``METADATA`` for ``Name:`` and ``Version:``."""
172
+ meta_path = os.path.join(dist_info, "METADATA")
173
+ try:
174
+ with open(meta_path, "r", encoding="utf-8", errors="replace") as fh:
175
+ content = fh.read()
176
+ except OSError as exc:
177
+ logger.debug("Cannot read %s: %s", meta_path, exc)
178
+ return
179
+
180
+ name_match = _NAME_RE.search(content)
181
+ if name_match:
182
+ package_info.package_name = name_match.group(1).strip()
183
+
184
+ ver_match = _VERSION_RE.search(content)
185
+ if ver_match:
186
+ package_info.package_version = ver_match.group(1).strip()
187
+
188
+ # ------------------------------------------------------------------
189
+ # Pure-Python detection
190
+ # ------------------------------------------------------------------
191
+
192
+ @staticmethod
193
+ def _detect_pure_python(dist_info: str, package_info: PackageBinaryInfo) -> None:
194
+ """Determine if the package is pure Python.
195
+
196
+ A package is pure Python if:
197
+ - All wheel tags have platform ``any``, OR
198
+ - The RECORD file lists no ``.so`` / ``.pyd`` / ``.dll`` files
199
+ """
200
+ # Check tags first (faster)
201
+ if package_info.wheel_tags:
202
+ all_any = all(tag.platform == "any" for tag in package_info.wheel_tags)
203
+ if all_any:
204
+ package_info.is_pure_python = True
205
+ return
206
+ # Has platform-specific tags -> likely not pure
207
+ package_info.is_pure_python = False
208
+ return
209
+
210
+ # Fallback: scan RECORD
211
+ record_path = os.path.join(dist_info, "RECORD")
212
+ try:
213
+ with open(record_path, "r", encoding="utf-8", errors="replace") as fh:
214
+ for line in fh:
215
+ path_part = line.split(",", 1)[0].strip()
216
+ if path_part.endswith((".so", ".pyd", ".dll")) or ".so." in path_part:
217
+ package_info.is_pure_python = False
218
+ return
219
+ except OSError:
220
+ pass
221
+
222
+ # No evidence of binaries
223
+ package_info.is_pure_python = True
224
+
225
+ # ------------------------------------------------------------------
226
+ # CUDA version extraction
227
+ # ------------------------------------------------------------------
228
+
229
+ @staticmethod
230
+ def _extract_cuda_version(package_info: PackageBinaryInfo) -> None:
231
+ """Extract CUDA build version from the package version string.
232
+
233
+ Looks for the ``+cuXYZ`` local version suffix.
234
+ Examples:
235
+ - ``2.4.0+cu124`` -> ``(12, 4)``
236
+ - ``2.1.0+cu118`` -> ``(11, 8)``
237
+ - ``1.0.0+cu121`` -> ``(12, 1)``
238
+ """
239
+ match = _CUDA_VERSION_RE.search(package_info.package_version)
240
+ if match:
241
+ digits = match.group(1)
242
+ if len(digits) == 3:
243
+ major = int(digits[:2])
244
+ minor = int(digits[2:])
245
+ elif len(digits) == 2:
246
+ major = int(digits[0])
247
+ minor = int(digits[1])
248
+ else:
249
+ return
250
+ package_info.cuda_build_version = (major, minor)
251
+
252
+
253
+ def _normalize_name(name: str) -> str:
254
+ """Normalize a distribution name per PEP 503.
255
+
256
+ Lowercases and replaces all runs of ``[-_.]`` with a single dash.
257
+ """
258
+ return re.sub(r"[-_.]+", "-", name).lower()
259
+
260
+
261
+ def find_dist_info_dirs(site_packages: str) -> List[Tuple[str, str, str]]:
262
+ """Enumerate all ``.dist-info`` directories in a site-packages folder.
263
+
264
+ Returns
265
+ -------
266
+ list of (dist_info_path, package_name, package_version)
267
+ One tuple per discovered distribution.
268
+ """
269
+ results: List[Tuple[str, str, str]] = []
270
+ try:
271
+ entries = os.listdir(site_packages)
272
+ except OSError as exc:
273
+ logger.debug("Cannot list %s: %s", site_packages, exc)
274
+ return results
275
+
276
+ for entry in sorted(entries):
277
+ if not entry.endswith(".dist-info"):
278
+ continue
279
+ full = os.path.join(site_packages, entry)
280
+ if not os.path.isdir(full):
281
+ continue
282
+
283
+ # Parse name-version from the directory name
284
+ stem = entry[: -len(".dist-info")]
285
+ parts = stem.rsplit("-", 1)
286
+ if len(parts) == 2:
287
+ name, version = parts
288
+ else:
289
+ name = stem
290
+ version = "unknown"
291
+
292
+ # Override with METADATA if available
293
+ meta_path = os.path.join(full, "METADATA")
294
+ try:
295
+ with open(meta_path, "r", encoding="utf-8", errors="replace") as fh:
296
+ content = fh.read(4096) # Only need the header section
297
+ name_match = _NAME_RE.search(content)
298
+ if name_match:
299
+ name = name_match.group(1).strip()
300
+ ver_match = _VERSION_RE.search(content)
301
+ if ver_match:
302
+ version = ver_match.group(1).strip()
303
+ except OSError:
304
+ pass
305
+
306
+ results.append((full, name, version))
307
+
308
+ return results
@@ -0,0 +1,5 @@
1
+ """CLI package for PyBinaryGuard."""
2
+
3
+ from pybinaryguard.cli.main import main
4
+
5
+ __all__ = ["main"]