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.
- pybinaryguard/__init__.py +78 -0
- pybinaryguard/__main__.py +7 -0
- pybinaryguard/_compat/__init__.py +0 -0
- pybinaryguard/agent/__init__.py +63 -0
- pybinaryguard/agent/guard.py +232 -0
- pybinaryguard/agent/recommender.py +283 -0
- pybinaryguard/agent/schema.py +200 -0
- pybinaryguard/agent/simulator.py +430 -0
- pybinaryguard/agent/tool_interface.py +474 -0
- pybinaryguard/analyzers/__init__.py +209 -0
- pybinaryguard/analyzers/base.py +40 -0
- pybinaryguard/analyzers/dependency_analyzer.py +336 -0
- pybinaryguard/analyzers/elf_analyzer.py +754 -0
- pybinaryguard/analyzers/symbol_analyzer.py +280 -0
- pybinaryguard/analyzers/wheel_analyzer.py +308 -0
- pybinaryguard/cli/__init__.py +5 -0
- pybinaryguard/cli/commands.py +414 -0
- pybinaryguard/cli/formatters.py +720 -0
- pybinaryguard/cli/main.py +250 -0
- pybinaryguard/diagnostics/__init__.py +13 -0
- pybinaryguard/diagnostics/explainer.py +356 -0
- pybinaryguard/diagnostics/findings.py +146 -0
- pybinaryguard/diagnostics/suggestions.py +508 -0
- pybinaryguard/frameworks/__init__.py +20 -0
- pybinaryguard/frameworks/onnxruntime.py +214 -0
- pybinaryguard/frameworks/pytorch.py +223 -0
- pybinaryguard/frameworks/tensorflow.py +266 -0
- pybinaryguard/frameworks/tensorrt.py +189 -0
- pybinaryguard/models/__init__.py +19 -0
- pybinaryguard/models/enums.py +102 -0
- pybinaryguard/models/finding.py +109 -0
- pybinaryguard/models/package.py +121 -0
- pybinaryguard/models/system.py +118 -0
- pybinaryguard/plugins/__init__.py +19 -0
- pybinaryguard/plugins/contrib/__init__.py +7 -0
- pybinaryguard/plugins/contrib/gstreamer.py +109 -0
- pybinaryguard/plugins/contrib/jetson.py +385 -0
- pybinaryguard/plugins/contrib/opencv.py +306 -0
- pybinaryguard/plugins/contrib/tensorrt.py +426 -0
- pybinaryguard/plugins/hooks.py +273 -0
- pybinaryguard/plugins/loader.py +190 -0
- pybinaryguard/predictor/__init__.py +23 -0
- pybinaryguard/predictor/dependency_graph.py +226 -0
- pybinaryguard/predictor/linker_simulator.py +161 -0
- pybinaryguard/predictor/predictor.py +144 -0
- pybinaryguard/predictor/resolver.py +282 -0
- pybinaryguard/probes/__init__.py +73 -0
- pybinaryguard/probes/base.py +45 -0
- pybinaryguard/probes/board_probe.py +248 -0
- pybinaryguard/probes/cpu_probe.py +176 -0
- pybinaryguard/probes/glibc_probe.py +215 -0
- pybinaryguard/probes/gpu_probe.py +430 -0
- pybinaryguard/probes/library_probe.py +132 -0
- pybinaryguard/probes/os_probe.py +227 -0
- pybinaryguard/probes/python_probe.py +135 -0
- pybinaryguard/probes/toolchain_probe.py +89 -0
- pybinaryguard/probes/venv_probe.py +111 -0
- pybinaryguard/profiles/__init__.py +12 -0
- pybinaryguard/profiles/engine.py +343 -0
- pybinaryguard/rules/__init__.py +24 -0
- pybinaryguard/rules/base.py +57 -0
- pybinaryguard/rules/builtin/__init__.py +136 -0
- pybinaryguard/rules/builtin/arch_rules.py +86 -0
- pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
- pybinaryguard/rules/builtin/container_rules.py +170 -0
- pybinaryguard/rules/builtin/cpu_rules.py +204 -0
- pybinaryguard/rules/builtin/cuda_rules.py +760 -0
- pybinaryguard/rules/builtin/dependency_rules.py +278 -0
- pybinaryguard/rules/builtin/framework_rules.py +252 -0
- pybinaryguard/rules/builtin/glibc_rules.py +320 -0
- pybinaryguard/rules/builtin/numpy_rules.py +110 -0
- pybinaryguard/rules/builtin/predictive_rules.py +165 -0
- pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
- pybinaryguard/rules/builtin/source_build_rules.py +150 -0
- pybinaryguard/rules/builtin/venv_rules.py +159 -0
- pybinaryguard/rules/engine.py +123 -0
- pybinaryguard/scanner.py +904 -0
- pybinaryguard/scoring/__init__.py +19 -0
- pybinaryguard/scoring/engine.py +416 -0
- pybinaryguard/snapshot/__init__.py +20 -0
- pybinaryguard/snapshot/generator.py +168 -0
- pybinaryguard/snapshot/lockfile.py +152 -0
- pybinaryguard/snapshot/verifier.py +315 -0
- pybinaryguard/validators/__init__.py +7 -0
- pybinaryguard/validators/import_validator.py +231 -0
- pybinaryguard-1.0.0.dist-info/METADATA +876 -0
- pybinaryguard-1.0.0.dist-info/RECORD +91 -0
- pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
- pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
- pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|