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
src2id/core/extractor.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Package coordinate extraction from Software Heritage origins."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from src2id.core.models import SHOriginMatch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PackageCoordinateExtractor:
|
|
11
|
+
"""Extracts package metadata from SH origin URLs and metadata."""
|
|
12
|
+
|
|
13
|
+
# Known official organizations
|
|
14
|
+
OFFICIAL_ORGS = {
|
|
15
|
+
'github.com': [
|
|
16
|
+
'opencv', 'microsoft', 'google', 'facebook', 'apple', 'meta',
|
|
17
|
+
'llvm', 'boost-org', 'protocolbuffers', 'grpc', 'apache',
|
|
18
|
+
'python', 'nodejs', 'golang', 'rust-lang', 'torvalds',
|
|
19
|
+
'tensorflow', 'pytorch', 'numpy', 'scipy', 'pandas-dev',
|
|
20
|
+
'FFmpeg', 'VideoLAN', 'libav', 'x264', 'x265',
|
|
21
|
+
],
|
|
22
|
+
'gitlab.com': [
|
|
23
|
+
'freedesktop-sdk', 'gnome', 'kde', 'freedesktop', 'gstreamer',
|
|
24
|
+
],
|
|
25
|
+
'git.kernel.org': [
|
|
26
|
+
'pub/scm/linux/kernel',
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def extract_coordinates(self, origin: SHOriginMatch) -> Dict[str, Optional[str]]:
|
|
31
|
+
"""
|
|
32
|
+
Extract name, version, download_url from origin.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
origin: Origin match from Software Heritage
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dictionary with extracted coordinates
|
|
39
|
+
"""
|
|
40
|
+
url = origin.origin_url
|
|
41
|
+
|
|
42
|
+
# Start with generic extraction
|
|
43
|
+
coordinates = self._extract_generic_coordinates(url, origin.metadata)
|
|
44
|
+
|
|
45
|
+
# Enhance with platform-specific extraction using proper URL parsing
|
|
46
|
+
parsed_url = urlparse(url)
|
|
47
|
+
hostname = parsed_url.hostname.lower() if parsed_url.hostname else ''
|
|
48
|
+
|
|
49
|
+
if hostname == 'github.com':
|
|
50
|
+
coordinates.update(self._extract_github_coordinates(url, origin.metadata))
|
|
51
|
+
elif hostname == 'gitlab.com':
|
|
52
|
+
coordinates.update(self._extract_gitlab_coordinates(url, origin.metadata))
|
|
53
|
+
elif hostname == 'sourceforge.net' or hostname.endswith('.sourceforge.net'):
|
|
54
|
+
coordinates.update(self._extract_sourceforge_coordinates(url, origin.metadata))
|
|
55
|
+
elif hostname == 'pypi.org' or hostname.endswith('.pypi.org'):
|
|
56
|
+
coordinates.update(self._extract_pypi_coordinates(url))
|
|
57
|
+
elif hostname == 'registry.npmjs.org':
|
|
58
|
+
coordinates.update(self._extract_npm_coordinates(url))
|
|
59
|
+
elif hostname == 'git.kernel.org' or hostname == 'kernel.org':
|
|
60
|
+
coordinates.update(self._extract_kernel_coordinates(url, origin.metadata))
|
|
61
|
+
|
|
62
|
+
return coordinates
|
|
63
|
+
|
|
64
|
+
def _extract_generic_coordinates(
|
|
65
|
+
self, url: str, metadata: Dict
|
|
66
|
+
) -> Dict[str, Optional[str]]:
|
|
67
|
+
"""
|
|
68
|
+
Universal extraction that works for any repository.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
url: Repository URL
|
|
72
|
+
metadata: Origin metadata
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Generic coordinates
|
|
76
|
+
"""
|
|
77
|
+
coordinates = {
|
|
78
|
+
'download_url': url,
|
|
79
|
+
'name': None,
|
|
80
|
+
'version': None,
|
|
81
|
+
'license': None,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Try to extract name from URL
|
|
85
|
+
parsed = urlparse(url)
|
|
86
|
+
path_parts = parsed.path.strip('/').split('/')
|
|
87
|
+
|
|
88
|
+
if path_parts:
|
|
89
|
+
# Get the last meaningful part
|
|
90
|
+
name = path_parts[-1]
|
|
91
|
+
# Remove common suffixes
|
|
92
|
+
for suffix in ['.git', '.hg', '.svn', '.bzr']:
|
|
93
|
+
if name.endswith(suffix):
|
|
94
|
+
name = name[:-len(suffix)]
|
|
95
|
+
coordinates['name'] = name
|
|
96
|
+
|
|
97
|
+
# Try to extract version from metadata
|
|
98
|
+
if metadata:
|
|
99
|
+
# Look for version in various metadata fields
|
|
100
|
+
for key in ['version', 'tag', 'release', 'ref']:
|
|
101
|
+
if key in metadata:
|
|
102
|
+
coordinates['version'] = str(metadata[key])
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
return coordinates
|
|
106
|
+
|
|
107
|
+
def _extract_github_coordinates(
|
|
108
|
+
self, url: str, metadata: Dict
|
|
109
|
+
) -> Dict[str, Optional[str]]:
|
|
110
|
+
"""Extract coordinates from GitHub URLs."""
|
|
111
|
+
coordinates = {}
|
|
112
|
+
|
|
113
|
+
# Parse GitHub URL: https://github.com/owner/repo
|
|
114
|
+
match = re.search(r'github\.com[:/]([^/]+)/([^/\s]+)', url)
|
|
115
|
+
if match:
|
|
116
|
+
owner = match.group(1)
|
|
117
|
+
repo = match.group(2).rstrip('.git')
|
|
118
|
+
coordinates['name'] = repo
|
|
119
|
+
coordinates['download_url'] = f"https://github.com/{owner}/{repo}"
|
|
120
|
+
|
|
121
|
+
# Extract version from tags or releases in metadata
|
|
122
|
+
if metadata:
|
|
123
|
+
tags = metadata.get('tags', [])
|
|
124
|
+
if tags:
|
|
125
|
+
version = self._extract_version_from_tags(tags)
|
|
126
|
+
if version:
|
|
127
|
+
coordinates['version'] = version
|
|
128
|
+
|
|
129
|
+
return coordinates
|
|
130
|
+
|
|
131
|
+
def _extract_gitlab_coordinates(
|
|
132
|
+
self, url: str, metadata: Dict
|
|
133
|
+
) -> Dict[str, Optional[str]]:
|
|
134
|
+
"""Extract coordinates from GitLab URLs."""
|
|
135
|
+
coordinates = {}
|
|
136
|
+
|
|
137
|
+
# Parse GitLab URL
|
|
138
|
+
match = re.search(r'gitlab\.com[:/]([^/]+)/([^/\s]+)', url)
|
|
139
|
+
if match:
|
|
140
|
+
owner = match.group(1)
|
|
141
|
+
repo = match.group(2).rstrip('.git')
|
|
142
|
+
coordinates['name'] = repo
|
|
143
|
+
coordinates['download_url'] = f"https://gitlab.com/{owner}/{repo}"
|
|
144
|
+
|
|
145
|
+
# Extract version from metadata
|
|
146
|
+
if metadata:
|
|
147
|
+
tags = metadata.get('tags', [])
|
|
148
|
+
if tags:
|
|
149
|
+
version = self._extract_version_from_tags(tags)
|
|
150
|
+
if version:
|
|
151
|
+
coordinates['version'] = version
|
|
152
|
+
|
|
153
|
+
return coordinates
|
|
154
|
+
|
|
155
|
+
def _extract_sourceforge_coordinates(
|
|
156
|
+
self, url: str, metadata: Dict
|
|
157
|
+
) -> Dict[str, Optional[str]]:
|
|
158
|
+
"""Extract coordinates from SourceForge URLs."""
|
|
159
|
+
coordinates = {}
|
|
160
|
+
|
|
161
|
+
# Parse SourceForge URL patterns
|
|
162
|
+
# e.g., https://svn.code.sf.net/p/projectname/code/trunk
|
|
163
|
+
match = re.search(r'sourceforge\.net/p/([^/]+)', url)
|
|
164
|
+
if not match:
|
|
165
|
+
match = re.search(r'sf\.net/p/([^/]+)', url)
|
|
166
|
+
|
|
167
|
+
if match:
|
|
168
|
+
project = match.group(1)
|
|
169
|
+
coordinates['name'] = project
|
|
170
|
+
coordinates['download_url'] = f"https://sourceforge.net/projects/{project}/"
|
|
171
|
+
|
|
172
|
+
return coordinates
|
|
173
|
+
|
|
174
|
+
def _extract_kernel_coordinates(
|
|
175
|
+
self, url: str, metadata: Dict
|
|
176
|
+
) -> Dict[str, Optional[str]]:
|
|
177
|
+
"""Extract coordinates from kernel.org URLs."""
|
|
178
|
+
coordinates = {}
|
|
179
|
+
|
|
180
|
+
if 'linux/kernel' in url:
|
|
181
|
+
coordinates['name'] = 'linux'
|
|
182
|
+
coordinates['download_url'] = url
|
|
183
|
+
|
|
184
|
+
# Linux kernel uses specific version naming
|
|
185
|
+
if metadata:
|
|
186
|
+
tags = metadata.get('tags', [])
|
|
187
|
+
for tag in tags:
|
|
188
|
+
if re.match(r'v\d+\.\d+(\.\d+)?', tag):
|
|
189
|
+
coordinates['version'] = tag.lstrip('v')
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
return coordinates
|
|
193
|
+
|
|
194
|
+
def _extract_pypi_coordinates(self, url: str) -> Dict[str, Optional[str]]:
|
|
195
|
+
"""Extract coordinates from PyPI URLs."""
|
|
196
|
+
coordinates = {}
|
|
197
|
+
|
|
198
|
+
# Parse PyPI URL
|
|
199
|
+
match = re.search(r'pypi\.org/project/([^/]+)/?([^/]+)?', url)
|
|
200
|
+
if match:
|
|
201
|
+
package = match.group(1)
|
|
202
|
+
version = match.group(2) if match.group(2) else None
|
|
203
|
+
|
|
204
|
+
coordinates['name'] = package
|
|
205
|
+
if version:
|
|
206
|
+
coordinates['version'] = version
|
|
207
|
+
|
|
208
|
+
return coordinates
|
|
209
|
+
|
|
210
|
+
def _extract_npm_coordinates(self, url: str) -> Dict[str, Optional[str]]:
|
|
211
|
+
"""Extract coordinates from npm registry URLs."""
|
|
212
|
+
coordinates = {}
|
|
213
|
+
|
|
214
|
+
# Parse npm URL
|
|
215
|
+
match = re.search(r'registry\.npmjs\.org/([^/]+)/?([^/]+)?', url)
|
|
216
|
+
if match:
|
|
217
|
+
package = match.group(1)
|
|
218
|
+
version = match.group(2) if match.group(2) else None
|
|
219
|
+
|
|
220
|
+
coordinates['name'] = package.replace('%40', '@') # Handle scoped packages
|
|
221
|
+
if version:
|
|
222
|
+
coordinates['version'] = version
|
|
223
|
+
|
|
224
|
+
return coordinates
|
|
225
|
+
|
|
226
|
+
def _extract_version_from_tags(self, tags: List[str]) -> Optional[str]:
|
|
227
|
+
"""
|
|
228
|
+
Extract semantic version from git tags.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
tags: List of git tags
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Best version match or None
|
|
235
|
+
"""
|
|
236
|
+
if not tags:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
# Priority patterns for version matching
|
|
240
|
+
patterns = [
|
|
241
|
+
r'^v?(\d+\.\d+\.\d+)$', # Semantic version
|
|
242
|
+
r'^v?(\d+\.\d+)$', # Major.minor
|
|
243
|
+
r'^release-(\d+\.\d+\.\d+)$', # Release prefix
|
|
244
|
+
r'^r(\d+\.\d+\.\d+)$', # r prefix
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
for pattern in patterns:
|
|
248
|
+
for tag in tags:
|
|
249
|
+
match = re.match(pattern, tag)
|
|
250
|
+
if match:
|
|
251
|
+
return match.group(1)
|
|
252
|
+
|
|
253
|
+
# If no pattern matches, return the first tag that looks like a version
|
|
254
|
+
for tag in tags:
|
|
255
|
+
if re.search(r'\d+\.\d+', tag):
|
|
256
|
+
return tag
|
|
257
|
+
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def is_official_organization(self, url: str) -> bool:
|
|
261
|
+
"""
|
|
262
|
+
Check if URL belongs to known official organization.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
url: Repository URL
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
True if from official organization
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
parsed_url = urlparse(url)
|
|
272
|
+
hostname = parsed_url.hostname.lower() if parsed_url.hostname else ''
|
|
273
|
+
|
|
274
|
+
for domain, orgs in self.OFFICIAL_ORGS.items():
|
|
275
|
+
if hostname == domain or hostname.endswith('.' + domain):
|
|
276
|
+
org = self._extract_organization(url, domain)
|
|
277
|
+
if org and org.lower() in [o.lower() for o in orgs]:
|
|
278
|
+
return True
|
|
279
|
+
return False
|
|
280
|
+
except Exception:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
def _extract_organization(self, url: str, domain: str) -> Optional[str]:
|
|
284
|
+
"""
|
|
285
|
+
Extract organization name from repository URL.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
url: Repository URL
|
|
289
|
+
domain: Domain name
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Organization name or None
|
|
293
|
+
"""
|
|
294
|
+
if domain == 'github.com' or domain == 'gitlab.com':
|
|
295
|
+
match = re.search(rf'{domain}[:/]([^/]+)/', url)
|
|
296
|
+
if match:
|
|
297
|
+
return match.group(1)
|
|
298
|
+
elif domain == 'git.kernel.org':
|
|
299
|
+
if 'pub/scm/linux/kernel' in url:
|
|
300
|
+
return 'pub/scm/linux/kernel'
|
|
301
|
+
|
|
302
|
+
return None
|
src2id/core/models.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Data models for SHPI."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MatchType(str, Enum):
|
|
11
|
+
"""Type of match found in Software Heritage."""
|
|
12
|
+
EXACT = "exact"
|
|
13
|
+
FUZZY = "fuzzy"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PackageMatch:
|
|
18
|
+
"""Represents a package match found in Software Heritage."""
|
|
19
|
+
|
|
20
|
+
download_url: str
|
|
21
|
+
match_type: MatchType
|
|
22
|
+
confidence_score: float
|
|
23
|
+
name: Optional[str] = None
|
|
24
|
+
version: Optional[str] = None
|
|
25
|
+
license: Optional[str] = None
|
|
26
|
+
sh_url: Optional[str] = None
|
|
27
|
+
frequency_count: int = 0
|
|
28
|
+
is_official_org: bool = False
|
|
29
|
+
purl: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
32
|
+
"""Serialize to dictionary for JSON output."""
|
|
33
|
+
return {
|
|
34
|
+
"download_url": self.download_url,
|
|
35
|
+
"name": self.name,
|
|
36
|
+
"version": self.version,
|
|
37
|
+
"license": self.license,
|
|
38
|
+
"sh_url": self.sh_url,
|
|
39
|
+
"match_type": self.match_type.value,
|
|
40
|
+
"confidence_score": self.confidence_score,
|
|
41
|
+
"frequency_count": self.frequency_count,
|
|
42
|
+
"is_official_org": self.is_official_org,
|
|
43
|
+
"purl": self.purl,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DirectoryCandidate:
|
|
49
|
+
"""Represents a directory candidate for SWHID matching."""
|
|
50
|
+
|
|
51
|
+
path: Path
|
|
52
|
+
swhid: str
|
|
53
|
+
depth: int
|
|
54
|
+
specificity_score: float
|
|
55
|
+
file_count: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ContentCandidate:
|
|
60
|
+
"""Represents a file/content candidate for SWHID matching."""
|
|
61
|
+
|
|
62
|
+
path: Path
|
|
63
|
+
swhid: str
|
|
64
|
+
depth: int
|
|
65
|
+
size: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SHOriginMatch:
|
|
70
|
+
"""Represents an origin match from Software Heritage."""
|
|
71
|
+
|
|
72
|
+
origin_url: str
|
|
73
|
+
swhid: str
|
|
74
|
+
last_seen: datetime
|
|
75
|
+
match_type: MatchType = MatchType.EXACT
|
|
76
|
+
visit_count: int = 1
|
|
77
|
+
metadata: Dict[str, Any] = None
|
|
78
|
+
similarity_score: float = 1.0
|
|
79
|
+
|
|
80
|
+
def __post_init__(self):
|
|
81
|
+
"""Initialize default metadata if None."""
|
|
82
|
+
if self.metadata is None:
|
|
83
|
+
self.metadata = {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class SHAPIResponse:
|
|
88
|
+
"""Wrapper for Software Heritage API responses."""
|
|
89
|
+
|
|
90
|
+
data: Any
|
|
91
|
+
headers: Dict[str, str]
|
|
92
|
+
status: int
|
|
93
|
+
cached: bool = False
|