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,474 @@
1
+ """Agent-native tool interface for PyBinaryGuard.
2
+
3
+ Every function returns a structured, JSON-serializable dataclass that agents
4
+ can parse without interpreting human-formatted text. Each result includes
5
+ recommended actions classified by safety level.
6
+
7
+ Usage::
8
+
9
+ from pybinaryguard.agent import scan, check, simulate_install, doctor
10
+
11
+ report = scan() # Full environment scan
12
+ result = check("torch") # Single package
13
+ sim = simulate_install("torch==2.4.0+cu124") # Pre-install prediction
14
+ dx = doctor("GLIBC_2.34 not found") # Error diagnosis
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable, Dict, List, Optional, Tuple
23
+
24
+ from pybinaryguard.agent.recommender import ActionRecommender, RecommendedAction
25
+ from pybinaryguard.models.enums import ScanMode, Severity
26
+ from pybinaryguard.models.finding import Finding
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Result dataclasses
31
+ # ---------------------------------------------------------------------------
32
+
33
+ @dataclass
34
+ class ActionableReport:
35
+ """Structured scan result for agent consumption.
36
+
37
+ Every field is JSON-serializable. Agents should check ``risk_level``
38
+ and iterate ``safe_actions`` for auto-executable fixes.
39
+ """
40
+
41
+ health_score: int
42
+ risk_level: str # "low", "medium", "high", "critical"
43
+ total_packages: int
44
+ packages_scanned: int
45
+ scan_duration_ms: float
46
+ issues: List[Dict[str, object]] = field(default_factory=list)
47
+ safe_actions: List[Dict[str, object]] = field(default_factory=list)
48
+ review_actions: List[Dict[str, object]] = field(default_factory=list)
49
+ dangerous_actions: List[Dict[str, object]] = field(default_factory=list)
50
+ score_breakdown: Optional[Dict[str, object]] = None
51
+ detected_board: Optional[str] = None
52
+ profile_summary: Dict[str, str] = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> Dict[str, object]:
55
+ result: Dict[str, object] = {
56
+ "health_score": self.health_score,
57
+ "risk_level": self.risk_level,
58
+ "total_packages": self.total_packages,
59
+ "packages_scanned": self.packages_scanned,
60
+ "scan_duration_ms": round(self.scan_duration_ms, 1),
61
+ "issue_count": len(self.issues),
62
+ "issues": self.issues,
63
+ "safe_actions": self.safe_actions,
64
+ "review_actions": self.review_actions,
65
+ "dangerous_actions": self.dangerous_actions,
66
+ }
67
+ if self.score_breakdown:
68
+ result["score_breakdown"] = self.score_breakdown
69
+ if self.detected_board:
70
+ result["detected_board"] = self.detected_board
71
+ if self.profile_summary:
72
+ result["profile"] = self.profile_summary
73
+ return result
74
+
75
+ def to_json(self) -> str:
76
+ import json
77
+ return json.dumps(self.to_dict(), indent=2, default=str)
78
+
79
+
80
+ @dataclass
81
+ class AgentCheckResult:
82
+ """Structured result from checking a single package."""
83
+
84
+ package: str
85
+ compatible: bool
86
+ risk_level: str
87
+ issues: List[Dict[str, object]] = field(default_factory=list)
88
+ safe_actions: List[Dict[str, object]] = field(default_factory=list)
89
+ review_actions: List[Dict[str, object]] = field(default_factory=list)
90
+ dangerous_actions: List[Dict[str, object]] = field(default_factory=list)
91
+
92
+ def to_dict(self) -> Dict[str, object]:
93
+ return {
94
+ "package": self.package,
95
+ "compatible": self.compatible,
96
+ "risk_level": self.risk_level,
97
+ "issue_count": len(self.issues),
98
+ "issues": self.issues,
99
+ "safe_actions": self.safe_actions,
100
+ "review_actions": self.review_actions,
101
+ "dangerous_actions": self.dangerous_actions,
102
+ }
103
+
104
+ def to_json(self) -> str:
105
+ import json
106
+ return json.dumps(self.to_dict(), indent=2, default=str)
107
+
108
+
109
+ @dataclass
110
+ class AgentSimulateResult:
111
+ """Structured result from simulating a package installation."""
112
+
113
+ package_spec: str
114
+ predicted_compatible: bool
115
+ confidence: float
116
+ risk_level: str
117
+ warnings: List[Dict[str, object]] = field(default_factory=list)
118
+ blockers: List[Dict[str, object]] = field(default_factory=list)
119
+ parsed_tags: Optional[Dict[str, str]] = None
120
+
121
+ def to_dict(self) -> Dict[str, object]:
122
+ result: Dict[str, object] = {
123
+ "package_spec": self.package_spec,
124
+ "predicted_compatible": self.predicted_compatible,
125
+ "confidence": round(self.confidence, 2),
126
+ "risk_level": self.risk_level,
127
+ "blocker_count": len(self.blockers),
128
+ "blockers": self.blockers,
129
+ "warning_count": len(self.warnings),
130
+ "warnings": self.warnings,
131
+ }
132
+ if self.parsed_tags:
133
+ result["parsed_tags"] = self.parsed_tags
134
+ return result
135
+
136
+ def to_json(self) -> str:
137
+ import json
138
+ return json.dumps(self.to_dict(), indent=2, default=str)
139
+
140
+
141
+ @dataclass
142
+ class AgentDoctorResult:
143
+ """Structured result from diagnosing an error."""
144
+
145
+ error_message: str
146
+ diagnosis: str
147
+ fix_plan: List[Dict[str, object]] = field(default_factory=list)
148
+ auto_fix_safe: bool = False
149
+ related_findings: List[Dict[str, object]] = field(default_factory=list)
150
+
151
+ def to_dict(self) -> Dict[str, object]:
152
+ return {
153
+ "error_message": self.error_message,
154
+ "diagnosis": self.diagnosis,
155
+ "fix_plan": self.fix_plan,
156
+ "auto_fix_safe": self.auto_fix_safe,
157
+ "related_findings": self.related_findings,
158
+ }
159
+
160
+ def to_json(self) -> str:
161
+ import json
162
+ return json.dumps(self.to_dict(), indent=2, default=str)
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Helper functions
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def _risk_level(findings: List[Finding]) -> str:
170
+ """Compute risk level from findings."""
171
+ critical = sum(1 for f in findings if f.severity == Severity.CRITICAL)
172
+ warning = sum(1 for f in findings if f.severity == Severity.WARNING)
173
+ if critical >= 3:
174
+ return "critical"
175
+ if critical >= 1:
176
+ return "high"
177
+ if warning >= 3:
178
+ return "medium"
179
+ if warning >= 1:
180
+ return "low"
181
+ return "none"
182
+
183
+
184
+ def _findings_to_issues(findings: List[Finding]) -> List[Dict[str, object]]:
185
+ """Convert findings to agent-friendly issue dicts."""
186
+ issues: List[Dict[str, object]] = []
187
+ for f in findings:
188
+ if f.severity == Severity.PASSED:
189
+ continue
190
+ issue: Dict[str, object] = {
191
+ "type": f.rule_id,
192
+ "severity": f.severity.value,
193
+ "title": f.title,
194
+ "explanation": f.explanation,
195
+ }
196
+ if f.package:
197
+ issue["package"] = f.package
198
+ if f.suggestion:
199
+ issue["fix_hint"] = f.suggestion
200
+ if f.confidence < 1.0:
201
+ issue["confidence"] = round(f.confidence, 2)
202
+ issues.append(issue)
203
+ return issues
204
+
205
+
206
+ def _classify_actions(
207
+ actions: List[RecommendedAction],
208
+ ) -> Tuple[
209
+ List[Dict[str, object]],
210
+ List[Dict[str, object]],
211
+ List[Dict[str, object]],
212
+ ]:
213
+ """Split actions into safe, review, dangerous buckets."""
214
+ safe: List[Dict[str, object]] = []
215
+ review: List[Dict[str, object]] = []
216
+ dangerous: List[Dict[str, object]] = []
217
+ for action in actions:
218
+ d = action.to_dict()
219
+ if action.safety == "safe":
220
+ safe.append(d)
221
+ elif action.safety == "dangerous":
222
+ dangerous.append(d)
223
+ else:
224
+ review.append(d)
225
+ return safe, review, dangerous
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Core agent API functions
230
+ # ---------------------------------------------------------------------------
231
+
232
+ _recommender = ActionRecommender()
233
+
234
+
235
+ def scan(
236
+ scan_mode: str = "standard",
237
+ packages: Optional[List[str]] = None,
238
+ severity_threshold: str = "all",
239
+ timeout: float = 30.0,
240
+ ) -> ActionableReport:
241
+ """Full environment scan returning structured, agent-consumable results.
242
+
243
+ Parameters
244
+ ----------
245
+ scan_mode:
246
+ "fast", "standard", or "deep".
247
+ packages:
248
+ Restrict scan to these package names. ``None`` = scan all.
249
+ severity_threshold:
250
+ Minimum severity: "critical", "warning", "info", "all".
251
+ timeout:
252
+ Max probe time in seconds.
253
+
254
+ Returns
255
+ -------
256
+ ActionableReport
257
+ Structured report with health score, issues, and classified actions.
258
+ """
259
+ from pybinaryguard.scanner import Scanner
260
+
261
+ mode = ScanMode(scan_mode)
262
+ severity_map = {
263
+ "critical": Severity.CRITICAL,
264
+ "warning": Severity.WARNING,
265
+ "info": Severity.INFO,
266
+ "all": Severity.INFO,
267
+ }
268
+
269
+ scanner = Scanner(
270
+ packages=packages,
271
+ severity_threshold=severity_map.get(severity_threshold, Severity.INFO),
272
+ timeout=timeout,
273
+ scan_mode=mode,
274
+ )
275
+
276
+ report = scanner.run()
277
+ profile = scanner.get_profile()
278
+
279
+ # Generate actions
280
+ actions = _recommender.recommend(report.findings)
281
+ safe, review, dangerous = _classify_actions(actions)
282
+
283
+ # Score breakdown
284
+ breakdown_dict = None
285
+ if report.score_breakdown is not None:
286
+ breakdown_dict = report.score_breakdown.as_dict()
287
+
288
+ return ActionableReport(
289
+ health_score=report.health_score,
290
+ risk_level=_risk_level(report.findings),
291
+ total_packages=report.total_packages,
292
+ packages_scanned=report.packages_scanned,
293
+ scan_duration_ms=report.scan_duration_ms,
294
+ issues=_findings_to_issues(report.findings),
295
+ safe_actions=safe,
296
+ review_actions=review,
297
+ dangerous_actions=dangerous,
298
+ score_breakdown=breakdown_dict,
299
+ detected_board=report.detected_board,
300
+ profile_summary=profile.summary(),
301
+ )
302
+
303
+
304
+ def check(package: str, timeout: float = 30.0) -> AgentCheckResult:
305
+ """Check a single installed package.
306
+
307
+ Parameters
308
+ ----------
309
+ package:
310
+ Package name (e.g. "torch", "numpy").
311
+ timeout:
312
+ Max probe time in seconds.
313
+
314
+ Returns
315
+ -------
316
+ AgentCheckResult
317
+ Structured compatibility result with actions.
318
+ """
319
+ from pybinaryguard.scanner import Scanner
320
+
321
+ scanner = Scanner(packages=[package], timeout=timeout)
322
+ findings = scanner.check_package(package)
323
+
324
+ actions = _recommender.recommend(findings)
325
+ safe, review, dangerous = _classify_actions(actions)
326
+
327
+ critical = sum(1 for f in findings if f.severity == Severity.CRITICAL)
328
+
329
+ return AgentCheckResult(
330
+ package=package,
331
+ compatible=critical == 0,
332
+ risk_level=_risk_level(findings),
333
+ issues=_findings_to_issues(findings),
334
+ safe_actions=safe,
335
+ review_actions=review,
336
+ dangerous_actions=dangerous,
337
+ )
338
+
339
+
340
+ def simulate_install(package_spec: str) -> AgentSimulateResult:
341
+ """Predict compatibility BEFORE installing a package.
342
+
343
+ Analyses the package specifier (name, version pin, or wheel filename)
344
+ against the current system profile to predict whether installation
345
+ will produce a working binary.
346
+
347
+ Parameters
348
+ ----------
349
+ package_spec:
350
+ Package specifier. Examples:
351
+ - ``"torch"`` — name only, limited prediction
352
+ - ``"torch==2.4.0+cu124"`` — version with CUDA variant
353
+ - ``"torch-2.4.0+cu124-cp312-cp312-manylinux_2_17_x86_64.whl"``
354
+
355
+ Returns
356
+ -------
357
+ AgentSimulateResult
358
+ Prediction with blockers, warnings, and confidence.
359
+ """
360
+ from pybinaryguard.agent.simulator import simulate
361
+ return simulate(package_spec)
362
+
363
+
364
+ def doctor(
365
+ error_message: str,
366
+ package: Optional[str] = None,
367
+ timeout: float = 30.0,
368
+ ) -> AgentDoctorResult:
369
+ """Diagnose an error message and produce a structured fix plan.
370
+
371
+ Parameters
372
+ ----------
373
+ error_message:
374
+ The error string or traceback to diagnose.
375
+ package:
376
+ Optional package name related to the error.
377
+ timeout:
378
+ Max probe time in seconds.
379
+
380
+ Returns
381
+ -------
382
+ AgentDoctorResult
383
+ Diagnosis with fix plan and auto-fix safety indicator.
384
+ """
385
+ from pybinaryguard.diagnostics.explainer import diagnose_error
386
+
387
+ # Diagnose the error text
388
+ diagnosis_text = ""
389
+ fix_plan: List[Dict[str, object]] = []
390
+ related_findings: List[Dict[str, object]] = []
391
+
392
+ result = diagnose_error(error_message)
393
+ if result:
394
+ diagnosis_text = result.get("explanation", "Unknown error")
395
+ if result.get("fix_hint"):
396
+ fix_plan.append({
397
+ "step": 1,
398
+ "action": result["fix_hint"],
399
+ "safety": "review",
400
+ })
401
+
402
+ # If package specified, also run a check
403
+ if package:
404
+ from pybinaryguard.scanner import Scanner
405
+
406
+ scanner = Scanner(packages=[package], timeout=timeout)
407
+ findings = scanner.check_package(package)
408
+
409
+ related_findings = _findings_to_issues(findings)
410
+
411
+ # Generate fix actions from findings
412
+ actions = _recommender.recommend(findings)
413
+ for i, action in enumerate(actions):
414
+ fix_plan.append({
415
+ "step": len(fix_plan) + 1,
416
+ "action": action.command,
417
+ "target": action.target,
418
+ "reason": action.reason,
419
+ "safety": action.safety,
420
+ })
421
+
422
+ if not diagnosis_text:
423
+ diagnosis_text = "Could not match this error to a known pattern."
424
+
425
+ # Auto-fix is safe only if ALL fix plan items are "safe"
426
+ auto_fix_safe = (
427
+ len(fix_plan) > 0
428
+ and all(step.get("safety") == "safe" for step in fix_plan)
429
+ )
430
+
431
+ return AgentDoctorResult(
432
+ error_message=error_message,
433
+ diagnosis=diagnosis_text,
434
+ fix_plan=fix_plan,
435
+ auto_fix_safe=auto_fix_safe,
436
+ related_findings=related_findings,
437
+ )
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Framework integration helper
442
+ # ---------------------------------------------------------------------------
443
+
444
+ def as_agent_tool() -> Dict[str, Any]:
445
+ """Return a tool descriptor with bound handler functions.
446
+
447
+ Returns a dict that agent frameworks can register directly::
448
+
449
+ tool = pybinaryguard.agent.as_agent_tool()
450
+ # tool["scan"]["handler"](scan_mode="fast")
451
+
452
+ Returns
453
+ -------
454
+ Dict[str, Any]
455
+ Tool descriptors with ``schema`` and ``handler`` keys.
456
+ """
457
+ from pybinaryguard.agent.schema import get_tool_descriptors
458
+
459
+ schemas = get_tool_descriptors(format="json_schema")
460
+ handlers = {
461
+ "pybinaryguard_scan": scan,
462
+ "pybinaryguard_check": check,
463
+ "pybinaryguard_simulate_install": simulate_install,
464
+ "pybinaryguard_doctor": doctor,
465
+ }
466
+
467
+ tools: Dict[str, Any] = {}
468
+ for schema in schemas:
469
+ name = schema["name"]
470
+ tools[name] = {
471
+ "schema": schema,
472
+ "handler": handlers.get(name),
473
+ }
474
+ return tools
@@ -0,0 +1,209 @@
1
+ """Binary analyzers for PyBinaryGuard.
2
+
3
+ This module provides the analysis pipeline that inspects installed Python
4
+ packages for binary compatibility issues. Each analyzer is responsible
5
+ for a specific aspect of the analysis:
6
+
7
+ - **ELFAnalyzer** -- discovers and parses ELF shared objects.
8
+ - **WheelAnalyzer** -- reads wheel / dist-info metadata.
9
+ - **SymbolAnalyzer** -- extracts GLIBC version requirements and CPython ABI.
10
+ - **DependencyAnalyzer** -- resolves DT_NEEDED chains statically.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from typing import List, Optional, Sequence
18
+
19
+ from pybinaryguard.analyzers.base import AnalyzerBase
20
+ from pybinaryguard.analyzers.dependency_analyzer import DependencyAnalyzer
21
+ from pybinaryguard.analyzers.elf_analyzer import ELFAnalyzer, ELFParseError, MinimalELFParser
22
+ from pybinaryguard.analyzers.symbol_analyzer import (
23
+ SymbolAnalyzer,
24
+ compute_max_glibc,
25
+ parse_glibc_version,
26
+ )
27
+ from pybinaryguard.analyzers.wheel_analyzer import (
28
+ WheelAnalyzer,
29
+ find_dist_info_dirs,
30
+ )
31
+ from pybinaryguard.models.package import PackageBinaryInfo
32
+ from pybinaryguard.models.system import SystemProfile
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ __all__ = [
37
+ "AnalyzerBase",
38
+ "DependencyAnalyzer",
39
+ "ELFAnalyzer",
40
+ "ELFParseError",
41
+ "MinimalELFParser",
42
+ "SymbolAnalyzer",
43
+ "WheelAnalyzer",
44
+ "analyze_packages",
45
+ "compute_max_glibc",
46
+ "find_dist_info_dirs",
47
+ "get_all_analyzers",
48
+ "parse_glibc_version",
49
+ ]
50
+
51
+
52
+ def get_all_analyzers(
53
+ system_profile: Optional[SystemProfile] = None,
54
+ ) -> List[AnalyzerBase]:
55
+ """Return an ordered list of all built-in analyzers.
56
+
57
+ The order matters: the ELF analyzer must run first (it discovers
58
+ ``.so`` files), followed by the wheel analyzer (metadata), then the
59
+ symbol analyzer (refines GLIBC data), and finally the dependency
60
+ analyzer (resolves library paths).
61
+
62
+ Parameters
63
+ ----------
64
+ system_profile:
65
+ Optional system profile. Passed to analyzers that need system
66
+ information (e.g. ``DependencyAnalyzer``).
67
+
68
+ Returns
69
+ -------
70
+ list of AnalyzerBase
71
+ """
72
+ return [
73
+ ELFAnalyzer(),
74
+ WheelAnalyzer(),
75
+ SymbolAnalyzer(),
76
+ DependencyAnalyzer(system_profile=system_profile),
77
+ ]
78
+
79
+
80
+ def analyze_packages(
81
+ site_packages_paths: List[str],
82
+ system_profile: Optional[SystemProfile] = None,
83
+ analyzers: Optional[Sequence[AnalyzerBase]] = None,
84
+ ) -> List[PackageBinaryInfo]:
85
+ """Discover and analyze all installed packages with binary components.
86
+
87
+ Walks the given ``site-packages`` directories, creates a
88
+ ``PackageBinaryInfo`` for each discovered distribution, runs the
89
+ full analyzer pipeline, and returns only packages that contain
90
+ compiled extensions.
91
+
92
+ Parameters
93
+ ----------
94
+ site_packages_paths:
95
+ Paths to ``site-packages`` directories to scan.
96
+ system_profile:
97
+ Optional system profile for dependency resolution.
98
+ analyzers:
99
+ Optional list of analyzers to run. If ``None``, the default
100
+ set from ``get_all_analyzers()`` is used.
101
+
102
+ Returns
103
+ -------
104
+ list of PackageBinaryInfo
105
+ Only packages that have at least one ``.so`` file.
106
+ """
107
+ if analyzers is None:
108
+ analyzers = get_all_analyzers(system_profile=system_profile)
109
+
110
+ results: List[PackageBinaryInfo] = []
111
+
112
+ for site_path in site_packages_paths:
113
+ if not os.path.isdir(site_path):
114
+ logger.debug("Skipping non-existent site-packages: %s", site_path)
115
+ continue
116
+
117
+ dist_infos = find_dist_info_dirs(site_path)
118
+ logger.debug("Found %d distributions in %s", len(dist_infos), site_path)
119
+
120
+ for dist_info_path, pkg_name, pkg_version in dist_infos:
121
+ # Determine the package's install directory. This is
122
+ # typically a directory under site-packages with the same
123
+ # name as the package (lowercased), but we also check
124
+ # top_level.txt for accuracy.
125
+ install_path = _guess_install_path(site_path, dist_info_path, pkg_name)
126
+ if install_path is None:
127
+ continue
128
+
129
+ pkg_info = PackageBinaryInfo(
130
+ package_name=pkg_name,
131
+ package_version=pkg_version,
132
+ install_path=install_path,
133
+ )
134
+
135
+ for analyzer in analyzers:
136
+ try:
137
+ analyzer.analyze(pkg_info)
138
+ except Exception:
139
+ logger.debug(
140
+ "Analyzer %s failed on %s",
141
+ getattr(analyzer, "name", type(analyzer).__name__),
142
+ pkg_name,
143
+ exc_info=True,
144
+ )
145
+
146
+ if pkg_info.has_binaries:
147
+ results.append(pkg_info)
148
+
149
+ return results
150
+
151
+
152
+ def _guess_install_path(
153
+ site_path: str, dist_info_path: str, pkg_name: str
154
+ ) -> Optional[str]:
155
+ """Determine the package's install directory under *site_path*.
156
+
157
+ Checks (in order):
158
+ 1. ``top_level.txt`` in the dist-info directory
159
+ 2. A directory matching the package name (lowercased, with hyphens
160
+ replaced by underscores)
161
+ 3. The site-packages directory itself (for namespace packages or
162
+ single-file installs)
163
+
164
+ Returns
165
+ -------
166
+ str or None
167
+ The install path, or ``None`` if the package directory cannot
168
+ be determined.
169
+ """
170
+ # 1. Check top_level.txt
171
+ top_level_path = os.path.join(dist_info_path, "top_level.txt")
172
+ try:
173
+ if os.path.isfile(top_level_path):
174
+ with open(top_level_path, "r", encoding="utf-8", errors="replace") as fh:
175
+ for line in fh:
176
+ top_pkg = line.strip()
177
+ if not top_pkg:
178
+ continue
179
+ candidate = os.path.join(site_path, top_pkg)
180
+ if os.path.isdir(candidate):
181
+ return candidate
182
+ except OSError:
183
+ pass
184
+
185
+ # 2. Guess from package name
186
+ normalized = pkg_name.lower().replace("-", "_").replace(".", "_")
187
+ candidate = os.path.join(site_path, normalized)
188
+ if os.path.isdir(candidate):
189
+ return candidate
190
+
191
+ # 3. Try the RECORD file to find actual installed files
192
+ record_path = os.path.join(dist_info_path, "RECORD")
193
+ try:
194
+ if os.path.isfile(record_path):
195
+ with open(record_path, "r", encoding="utf-8", errors="replace") as fh:
196
+ for line in fh:
197
+ file_path = line.split(",", 1)[0].strip()
198
+ if file_path.endswith((".so", ".pyd")):
199
+ # The first path component is the package directory
200
+ parts = file_path.split("/", 1)
201
+ if len(parts) >= 1:
202
+ pkg_dir = os.path.join(site_path, parts[0])
203
+ if os.path.isdir(pkg_dir):
204
+ return pkg_dir
205
+ except OSError:
206
+ pass
207
+
208
+ # 4. Fall back to site-packages itself (namespace packages, etc.)
209
+ return site_path