src2purl 1.3.2__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.
- src2id/__init__.py +15 -0
- src2id/cli/__init__.py +1 -0
- src2id/cli/main.py +318 -0
- src2id/cli/validate.py +93 -0
- src2id/core/__init__.py +1 -0
- src2id/core/cache.py +180 -0
- src2id/core/client.py +370 -0
- src2id/core/config.py +45 -0
- src2id/core/extractor.py +302 -0
- src2id/core/models.py +93 -0
- src2id/core/orchestrator.py +796 -0
- src2id/core/package_identifier.py +123 -0
- src2id/core/purl.py +238 -0
- src2id/core/scanner.py +369 -0
- src2id/core/scorer.py +217 -0
- src2id/core/subcomponent_detector.py +353 -0
- src2id/core/swhid.py +324 -0
- src2id/integrations/__init__.py +1 -0
- src2id/integrations/manifest_parser.py +652 -0
- src2id/integrations/oslili.py +228 -0
- src2id/integrations/upmex.py +305 -0
- src2id/search/__init__.py +34 -0
- src2id/search/hash_search.py +206 -0
- src2id/search/providers.py +310 -0
- src2id/search/strategies.py +391 -0
- src2id/utils/__init__.py +1 -0
- src2id/utils/datetime_utils.py +49 -0
- src2purl-1.3.2.dist-info/METADATA +279 -0
- src2purl-1.3.2.dist-info/RECORD +33 -0
- src2purl-1.3.2.dist-info/WHEEL +5 -0
- src2purl-1.3.2.dist-info/entry_points.txt +3 -0
- src2purl-1.3.2.dist-info/licenses/LICENSE +661 -0
- src2purl-1.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Optimized package identifier using multiple strategies."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
from src2id.core.config import SWHPIConfig
|
|
11
|
+
from src2id.core.models import PackageMatch, MatchType
|
|
12
|
+
from src2id.search import identify_source
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PackageIdentifier:
|
|
18
|
+
"""Main package identifier using optimized strategy order."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: Optional[SWHPIConfig] = None):
|
|
21
|
+
"""Initialize the package identifier with configuration."""
|
|
22
|
+
self.config = config or SWHPIConfig()
|
|
23
|
+
|
|
24
|
+
async def identify_packages(self, path: Path, enhance_licenses: bool = True) -> List[PackageMatch]:
|
|
25
|
+
"""
|
|
26
|
+
Identify packages using optimized strategies.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
path: Directory path to analyze
|
|
30
|
+
enhance_licenses: Whether to use oslili for license enhancement
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
List of package matches found
|
|
34
|
+
"""
|
|
35
|
+
matches = []
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
if self.config.verbose:
|
|
39
|
+
console.print("[bold blue]Starting package identification...[/bold blue]")
|
|
40
|
+
console.print("[dim]Using optimized strategy order: Hash Search → Web Search → SCANOSS[/dim]")
|
|
41
|
+
if self.config.use_swh:
|
|
42
|
+
console.print("[dim]Software Heritage checking enabled[/dim]")
|
|
43
|
+
|
|
44
|
+
# Use the optimized identification strategy
|
|
45
|
+
with Progress(
|
|
46
|
+
SpinnerColumn(),
|
|
47
|
+
TextColumn("[progress.description]{task.description}"),
|
|
48
|
+
console=console,
|
|
49
|
+
disable=not self.config.verbose
|
|
50
|
+
) as progress:
|
|
51
|
+
task = progress.add_task("Identifying source...", total=None)
|
|
52
|
+
|
|
53
|
+
results = await identify_source(
|
|
54
|
+
path=path,
|
|
55
|
+
max_depth=self.config.max_depth,
|
|
56
|
+
confidence_threshold=self.config.report_match_threshold,
|
|
57
|
+
verbose=self.config.verbose,
|
|
58
|
+
use_swh=self.config.use_swh
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
progress.update(task, completed=True)
|
|
62
|
+
|
|
63
|
+
# Convert results to PackageMatch objects
|
|
64
|
+
if results["identified"]:
|
|
65
|
+
# Create a match from the best result
|
|
66
|
+
match = PackageMatch(
|
|
67
|
+
name=path.name,
|
|
68
|
+
version="unknown",
|
|
69
|
+
confidence_score=results["confidence"],
|
|
70
|
+
match_type=MatchType.EXACT if results["confidence"] > 0.8 else MatchType.FUZZY,
|
|
71
|
+
download_url=results["final_origin"],
|
|
72
|
+
purl=f"pkg:generic/{path.name}",
|
|
73
|
+
license="",
|
|
74
|
+
is_official_org=False
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Enhance with license detection if requested
|
|
78
|
+
if enhance_licenses:
|
|
79
|
+
try:
|
|
80
|
+
from src2id.integrations.oslili import OsliliIntegration
|
|
81
|
+
integration = OsliliIntegration()
|
|
82
|
+
|
|
83
|
+
if integration.available:
|
|
84
|
+
license_info = integration.detect_licenses(path)
|
|
85
|
+
if license_info["licenses"]:
|
|
86
|
+
match.license = ", ".join(license_info["licenses"][:3])
|
|
87
|
+
except ImportError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
matches.append(match)
|
|
91
|
+
|
|
92
|
+
# Add additional candidates if confidence is lower
|
|
93
|
+
if results["confidence"] < 0.8 and results["candidates"]:
|
|
94
|
+
for candidate in results["candidates"][:2]: # Add top 2 alternatives
|
|
95
|
+
if candidate.get("origin") and candidate["origin"] != results["final_origin"]:
|
|
96
|
+
alt_match = PackageMatch(
|
|
97
|
+
name=path.name,
|
|
98
|
+
version="unknown",
|
|
99
|
+
confidence_score=candidate.get("confidence", 0.5),
|
|
100
|
+
match_type=MatchType.FUZZY,
|
|
101
|
+
download_url=candidate["origin"],
|
|
102
|
+
purl=f"pkg:generic/{path.name}",
|
|
103
|
+
license="",
|
|
104
|
+
is_official_org=False
|
|
105
|
+
)
|
|
106
|
+
matches.append(alt_match)
|
|
107
|
+
|
|
108
|
+
if self.config.verbose:
|
|
109
|
+
if matches:
|
|
110
|
+
console.print(f"[green]✓ Found {len(matches)} package matches[/green]")
|
|
111
|
+
else:
|
|
112
|
+
console.print("[yellow]No package matches found[/yellow]")
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
if self.config.verbose:
|
|
116
|
+
console.print(f"[red]Error during identification: {e}[/red]")
|
|
117
|
+
console.print_exception()
|
|
118
|
+
|
|
119
|
+
return matches
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Create an alias for backward compatibility
|
|
123
|
+
SHPackageIdentifier = PackageIdentifier
|
src2id/core/purl.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Package URL (PURL) generation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
from urllib.parse import quote, urlparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PURLGenerator:
|
|
9
|
+
"""Generates Package URLs following PURL specification."""
|
|
10
|
+
|
|
11
|
+
def generate_purl(self, coordinates: Dict[str, str], confidence: float) -> Optional[str]:
|
|
12
|
+
"""
|
|
13
|
+
Generate PURL only for high-confidence matches.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
coordinates: Package coordinates (name, version, download_url)
|
|
17
|
+
confidence: Confidence score
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
PURL string or None if confidence too low or invalid URL
|
|
21
|
+
"""
|
|
22
|
+
# Only generate PURL for high confidence matches
|
|
23
|
+
if confidence < 0.85: # This threshold could be configurable
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
download_url = coordinates.get('download_url', '')
|
|
27
|
+
name = coordinates.get('name', '')
|
|
28
|
+
version = coordinates.get('version', '')
|
|
29
|
+
|
|
30
|
+
if not download_url or not name:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
# Validate download URL
|
|
34
|
+
if not self._validate_download_url(download_url):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Generate PURL based on repository type using proper URL parsing
|
|
38
|
+
try:
|
|
39
|
+
parsed_url = urlparse(download_url)
|
|
40
|
+
hostname = parsed_url.hostname.lower() if parsed_url.hostname else ''
|
|
41
|
+
|
|
42
|
+
if hostname == 'github.com':
|
|
43
|
+
return self._generate_github_purl(download_url, name, version)
|
|
44
|
+
elif hostname == 'gitlab.com':
|
|
45
|
+
return self._generate_gitlab_purl(download_url, name, version)
|
|
46
|
+
elif hostname == 'pypi.org' or hostname.endswith('.pypi.org'):
|
|
47
|
+
return self._generate_pypi_purl(name, version)
|
|
48
|
+
elif hostname in ('npmjs.org', 'npmjs.com', 'registry.npmjs.org'):
|
|
49
|
+
return self._generate_npm_purl(name, version)
|
|
50
|
+
elif hostname == 'crates.io':
|
|
51
|
+
return self._generate_cargo_purl(name, version)
|
|
52
|
+
elif hostname == 'rubygems.org':
|
|
53
|
+
return self._generate_gem_purl(name, version)
|
|
54
|
+
elif hostname == 'packagist.org':
|
|
55
|
+
return self._generate_composer_purl(name, version)
|
|
56
|
+
elif hostname == 'nuget.org':
|
|
57
|
+
return self._generate_nuget_purl(name, version)
|
|
58
|
+
elif hostname == 'sourceforge.net' or hostname.endswith('.sourceforge.net'):
|
|
59
|
+
return self._generate_generic_purl(download_url, name, version)
|
|
60
|
+
else:
|
|
61
|
+
# For unknown sources, use generic PURL if possible
|
|
62
|
+
return self._generate_generic_purl(download_url, name, version)
|
|
63
|
+
except Exception:
|
|
64
|
+
return self._generate_generic_purl(download_url, name, version)
|
|
65
|
+
|
|
66
|
+
def _validate_download_url(self, url: str) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Validate that URL follows expected package patterns.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
url: Download URL
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if valid URL
|
|
75
|
+
"""
|
|
76
|
+
if not url:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# Basic URL validation
|
|
80
|
+
try:
|
|
81
|
+
parsed = urlparse(url)
|
|
82
|
+
if not parsed.scheme or not parsed.netloc:
|
|
83
|
+
return False
|
|
84
|
+
except Exception:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Check for common invalid patterns
|
|
88
|
+
invalid_patterns = [
|
|
89
|
+
r'/pull/\d+', # Pull requests
|
|
90
|
+
r'/issues/\d+', # Issues
|
|
91
|
+
r'/wiki/', # Wiki pages
|
|
92
|
+
r'/blob/', # File browsing (not repo root)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
for pattern in invalid_patterns:
|
|
96
|
+
if re.search(pattern, url):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def _generate_github_purl(self, url: str, name: str, version: Optional[str]) -> Optional[str]:
|
|
102
|
+
"""Generate GitHub PURL."""
|
|
103
|
+
org_repo = self._extract_github_org_repo(url)
|
|
104
|
+
if not org_repo:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
if version:
|
|
108
|
+
return f"pkg:github/{org_repo}@{self._encode_version(version)}"
|
|
109
|
+
else:
|
|
110
|
+
return f"pkg:github/{org_repo}"
|
|
111
|
+
|
|
112
|
+
def _generate_gitlab_purl(self, url: str, name: str, version: Optional[str]) -> Optional[str]:
|
|
113
|
+
"""Generate GitLab PURL."""
|
|
114
|
+
org_repo = self._extract_gitlab_org_repo(url)
|
|
115
|
+
if not org_repo:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
if version:
|
|
119
|
+
return f"pkg:gitlab/{org_repo}@{self._encode_version(version)}"
|
|
120
|
+
else:
|
|
121
|
+
return f"pkg:gitlab/{org_repo}"
|
|
122
|
+
|
|
123
|
+
def _generate_pypi_purl(self, name: str, version: Optional[str]) -> str:
|
|
124
|
+
"""Generate PyPI PURL."""
|
|
125
|
+
# PyPI package names should be lowercase with hyphens
|
|
126
|
+
name = name.lower().replace('_', '-')
|
|
127
|
+
|
|
128
|
+
if version:
|
|
129
|
+
return f"pkg:pypi/{name}@{self._encode_version(version)}"
|
|
130
|
+
else:
|
|
131
|
+
return f"pkg:pypi/{name}"
|
|
132
|
+
|
|
133
|
+
def _generate_npm_purl(self, name: str, version: Optional[str]) -> str:
|
|
134
|
+
"""Generate npm PURL."""
|
|
135
|
+
# Handle scoped packages
|
|
136
|
+
if name.startswith('@'):
|
|
137
|
+
# Scoped package: @scope/name
|
|
138
|
+
parts = name[1:].split('/', 1)
|
|
139
|
+
if len(parts) == 2:
|
|
140
|
+
namespace = parts[0]
|
|
141
|
+
pkg_name = parts[1]
|
|
142
|
+
if version:
|
|
143
|
+
return f"pkg:npm/{namespace}/{pkg_name}@{self._encode_version(version)}"
|
|
144
|
+
else:
|
|
145
|
+
return f"pkg:npm/{namespace}/{pkg_name}"
|
|
146
|
+
|
|
147
|
+
if version:
|
|
148
|
+
return f"pkg:npm/{name}@{self._encode_version(version)}"
|
|
149
|
+
else:
|
|
150
|
+
return f"pkg:npm/{name}"
|
|
151
|
+
|
|
152
|
+
def _generate_cargo_purl(self, name: str, version: Optional[str]) -> str:
|
|
153
|
+
"""Generate Cargo (Rust) PURL."""
|
|
154
|
+
if version:
|
|
155
|
+
return f"pkg:cargo/{name}@{self._encode_version(version)}"
|
|
156
|
+
else:
|
|
157
|
+
return f"pkg:cargo/{name}"
|
|
158
|
+
|
|
159
|
+
def _generate_gem_purl(self, name: str, version: Optional[str]) -> str:
|
|
160
|
+
"""Generate RubyGems PURL."""
|
|
161
|
+
if version:
|
|
162
|
+
return f"pkg:gem/{name}@{self._encode_version(version)}"
|
|
163
|
+
else:
|
|
164
|
+
return f"pkg:gem/{name}"
|
|
165
|
+
|
|
166
|
+
def _generate_composer_purl(self, name: str, version: Optional[str]) -> str:
|
|
167
|
+
"""Generate Composer (PHP) PURL."""
|
|
168
|
+
# Composer packages are vendor/package format
|
|
169
|
+
if '/' in name:
|
|
170
|
+
if version:
|
|
171
|
+
return f"pkg:composer/{name}@{self._encode_version(version)}"
|
|
172
|
+
else:
|
|
173
|
+
return f"pkg:composer/{name}"
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def _generate_nuget_purl(self, name: str, version: Optional[str]) -> str:
|
|
177
|
+
"""Generate NuGet (.NET) PURL."""
|
|
178
|
+
if version:
|
|
179
|
+
return f"pkg:nuget/{name}@{self._encode_version(version)}"
|
|
180
|
+
else:
|
|
181
|
+
return f"pkg:nuget/{name}"
|
|
182
|
+
|
|
183
|
+
def _generate_generic_purl(
|
|
184
|
+
self, url: str, name: str, version: Optional[str]
|
|
185
|
+
) -> Optional[str]:
|
|
186
|
+
"""
|
|
187
|
+
Generate generic PURL for unknown package types.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
url: Download URL
|
|
191
|
+
name: Package name
|
|
192
|
+
version: Package version
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Generic PURL or None
|
|
196
|
+
"""
|
|
197
|
+
# For now, we don't generate generic PURLs for unknown sources
|
|
198
|
+
# This could be extended in the future
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _extract_github_org_repo(self, url: str) -> Optional[str]:
|
|
202
|
+
"""Extract org/repo from GitHub URL."""
|
|
203
|
+
match = re.search(r'github\.com[:/]([^/]+)/([^/\s]+)', url)
|
|
204
|
+
if match:
|
|
205
|
+
org = match.group(1)
|
|
206
|
+
repo = match.group(2).rstrip('.git')
|
|
207
|
+
return f"{org}/{repo}"
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def _extract_gitlab_org_repo(self, url: str) -> Optional[str]:
|
|
211
|
+
"""Extract org/repo from GitLab URL."""
|
|
212
|
+
match = re.search(r'gitlab\.com[:/]([^/]+)/([^/\s?#]+)', url)
|
|
213
|
+
if match:
|
|
214
|
+
org = match.group(1)
|
|
215
|
+
repo = match.group(2)
|
|
216
|
+
if repo.endswith('.git'):
|
|
217
|
+
repo = repo[:-4]
|
|
218
|
+
if repo.endswith('/'):
|
|
219
|
+
repo = repo[:-1]
|
|
220
|
+
return f"{org}/{repo}"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _encode_version(self, version: str) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Encode version string for PURL.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
version: Version string
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Encoded version
|
|
232
|
+
"""
|
|
233
|
+
# Remove common version prefixes
|
|
234
|
+
version = version.lstrip('v')
|
|
235
|
+
version = version.lstrip('r')
|
|
236
|
+
|
|
237
|
+
# URL encode special characters
|
|
238
|
+
return quote(version, safe='.-_')
|