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/client.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Software Heritage API client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from aiohttp import ClientError, ClientTimeout
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from swh.web.client.client import WebAPIClient
|
|
13
|
+
SWH_CLIENT_AVAILABLE = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
SWH_CLIENT_AVAILABLE = False
|
|
16
|
+
WebAPIClient = None
|
|
17
|
+
|
|
18
|
+
from src2id.core.cache import PersistentCache
|
|
19
|
+
from src2id.core.config import SWHPIConfig
|
|
20
|
+
from src2id.core.models import MatchType, SHAPIResponse, SHOriginMatch
|
|
21
|
+
from src2id.utils.datetime_utils import parse_datetime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SoftwareHeritageClient:
|
|
25
|
+
"""Handles all interactions with Software Heritage API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: SWHPIConfig):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the Software Heritage client.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config: Configuration settings
|
|
33
|
+
"""
|
|
34
|
+
self.config = config
|
|
35
|
+
self.session: Optional[aiohttp.ClientSession] = None
|
|
36
|
+
|
|
37
|
+
# Use official WebAPIClient if available, fallback to custom implementation
|
|
38
|
+
if SWH_CLIENT_AVAILABLE:
|
|
39
|
+
# Configure official client with authentication if available
|
|
40
|
+
client_kwargs = {"api_url": config.sh_api_base}
|
|
41
|
+
if config.api_token:
|
|
42
|
+
client_kwargs["bearer_token"] = config.api_token
|
|
43
|
+
if config.verbose:
|
|
44
|
+
print("Using API authentication token")
|
|
45
|
+
self.web_client = WebAPIClient(**client_kwargs)
|
|
46
|
+
self._use_official_client = True
|
|
47
|
+
if config.verbose:
|
|
48
|
+
print("Using official Software Heritage WebAPIClient")
|
|
49
|
+
else:
|
|
50
|
+
self.web_client = None
|
|
51
|
+
self._use_official_client = False
|
|
52
|
+
if config.verbose:
|
|
53
|
+
print("Using fallback HTTP client (install swh.web for better performance)")
|
|
54
|
+
|
|
55
|
+
# Use persistent cache if enabled
|
|
56
|
+
if config.cache_enabled:
|
|
57
|
+
self.cache = PersistentCache()
|
|
58
|
+
# Clean expired entries on startup
|
|
59
|
+
removed = self.cache.clean_expired()
|
|
60
|
+
if removed > 0 and config.verbose:
|
|
61
|
+
print(f"Cleaned {removed} expired cache entries")
|
|
62
|
+
else:
|
|
63
|
+
self.cache = None
|
|
64
|
+
self._rate_limiter = asyncio.Semaphore(5) # Max 5 concurrent requests
|
|
65
|
+
self._last_request_time = 0
|
|
66
|
+
|
|
67
|
+
async def __aenter__(self):
|
|
68
|
+
"""Async context manager entry."""
|
|
69
|
+
await self.start_session()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
73
|
+
"""Async context manager exit."""
|
|
74
|
+
await self.close_session()
|
|
75
|
+
|
|
76
|
+
async def start_session(self):
|
|
77
|
+
"""Start the aiohttp session."""
|
|
78
|
+
if self.session is None:
|
|
79
|
+
timeout = ClientTimeout(total=30)
|
|
80
|
+
headers = {}
|
|
81
|
+
if self.config.api_token:
|
|
82
|
+
headers["Authorization"] = f"Bearer {self.config.api_token}"
|
|
83
|
+
self.session = aiohttp.ClientSession(timeout=timeout, headers=headers)
|
|
84
|
+
|
|
85
|
+
async def close_session(self):
|
|
86
|
+
"""Close the aiohttp session."""
|
|
87
|
+
if self.session:
|
|
88
|
+
await self.session.close()
|
|
89
|
+
self.session = None
|
|
90
|
+
|
|
91
|
+
async def search_origins_by_keyword(self, keyword: str) -> List[Dict[str, Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Search for origins by keyword.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
keyword: Keyword to search for
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
List of origin data
|
|
100
|
+
"""
|
|
101
|
+
endpoint = f"/origin/search/{keyword}/"
|
|
102
|
+
response = await self._make_request(endpoint)
|
|
103
|
+
|
|
104
|
+
if not response or not response.data:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
return response.data if isinstance(response.data, list) else [response.data]
|
|
108
|
+
|
|
109
|
+
async def check_swhids_known(self, swhids: List[str]) -> Dict[str, bool]:
|
|
110
|
+
"""
|
|
111
|
+
Check which SWHIDs are known in the Software Heritage archive using batch API.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
swhids: List of Software Heritage Identifiers
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dictionary mapping SWHID to known status
|
|
118
|
+
"""
|
|
119
|
+
if not swhids:
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
# Use official client if available
|
|
123
|
+
if self._use_official_client and self.web_client:
|
|
124
|
+
try:
|
|
125
|
+
# Use the official known() method for batch checking
|
|
126
|
+
result = self.web_client.known(swhids)
|
|
127
|
+
return {swhid: data.get('known', False) for swhid, data in result.items()}
|
|
128
|
+
except Exception as e:
|
|
129
|
+
if self.config.verbose:
|
|
130
|
+
print(f"Official client failed, falling back: {e}")
|
|
131
|
+
# Fall through to custom implementation
|
|
132
|
+
|
|
133
|
+
# Ensure session is started for fallback requests
|
|
134
|
+
if not self.session:
|
|
135
|
+
await self.start_session()
|
|
136
|
+
|
|
137
|
+
# Fallback to individual requests
|
|
138
|
+
results = {}
|
|
139
|
+
if self.config.verbose:
|
|
140
|
+
print(f"Using fallback individual requests for {len(swhids)} SWHIDs")
|
|
141
|
+
|
|
142
|
+
for i, swhid in enumerate(swhids):
|
|
143
|
+
if self.config.verbose and len(swhids) > 5:
|
|
144
|
+
print(f"Checking SWHID {i+1}/{len(swhids)}: {swhid[:20]}...")
|
|
145
|
+
dir_info = await self._get_directory_info(swhid)
|
|
146
|
+
results[swhid] = dir_info is not None
|
|
147
|
+
|
|
148
|
+
return results
|
|
149
|
+
|
|
150
|
+
async def get_directory_origins(self, swhid: str) -> List[SHOriginMatch]:
|
|
151
|
+
"""
|
|
152
|
+
Get all origins containing this directory.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
swhid: Software Heritage Identifier for directory
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of origin matches
|
|
159
|
+
"""
|
|
160
|
+
# First, check if directory is known using batch API
|
|
161
|
+
known_status = await self.check_swhids_known([swhid])
|
|
162
|
+
if not known_status.get(swhid, False):
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
# Then get origins that contain this directory
|
|
166
|
+
origins_data = await self._get_directory_origins_data(swhid)
|
|
167
|
+
|
|
168
|
+
# Convert to our model
|
|
169
|
+
origins = []
|
|
170
|
+
for origin in origins_data:
|
|
171
|
+
try:
|
|
172
|
+
origin_match = SHOriginMatch(
|
|
173
|
+
origin_url=origin.get('url', ''),
|
|
174
|
+
swhid=swhid,
|
|
175
|
+
last_seen=parse_datetime(origin.get('last_seen')) or datetime.now(),
|
|
176
|
+
visit_count=origin.get('visit_count', 1),
|
|
177
|
+
metadata=origin.get('metadata', {}),
|
|
178
|
+
match_type=MatchType.EXACT
|
|
179
|
+
)
|
|
180
|
+
origins.append(origin_match)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
if self.config.verbose:
|
|
183
|
+
print(f"Error parsing origin {origin}: {e}")
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
return origins
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _get_directory_info(self, swhid: str) -> Optional[Dict[str, Any]]:
|
|
191
|
+
"""Get basic directory information."""
|
|
192
|
+
dir_hash = self._extract_hash_from_swhid(swhid)
|
|
193
|
+
if not dir_hash:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
endpoint = f"/directory/{dir_hash}/"
|
|
197
|
+
response = await self._make_request(endpoint)
|
|
198
|
+
|
|
199
|
+
return response.data if response else None
|
|
200
|
+
|
|
201
|
+
async def _get_directory_origins_data(self, swhid: str) -> List[Dict[str, Any]]:
|
|
202
|
+
"""Get origins data for a directory."""
|
|
203
|
+
# Note: The actual SH API might not have a direct endpoint for this
|
|
204
|
+
# We might need to search through snapshots/revisions
|
|
205
|
+
# This is a simplified version
|
|
206
|
+
|
|
207
|
+
dir_hash = self._extract_hash_from_swhid(swhid)
|
|
208
|
+
if not dir_hash:
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
# Try to find origins through various methods
|
|
212
|
+
# Method 1: Direct directory to origin mapping (if available)
|
|
213
|
+
endpoint = f"/directory/{dir_hash}/origins/"
|
|
214
|
+
response = await self._make_request(endpoint, allow_404=True)
|
|
215
|
+
|
|
216
|
+
if response and response.data:
|
|
217
|
+
return response.data if isinstance(response.data, list) else [response.data]
|
|
218
|
+
|
|
219
|
+
# Method 2: Search through graph (simplified)
|
|
220
|
+
# In reality, this would involve more complex graph traversal
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
async def _make_request(
|
|
224
|
+
self,
|
|
225
|
+
endpoint: str,
|
|
226
|
+
params: Optional[Dict[str, Any]] = None,
|
|
227
|
+
allow_404: bool = False
|
|
228
|
+
) -> Optional[SHAPIResponse]:
|
|
229
|
+
"""
|
|
230
|
+
Make a request to the Software Heritage API.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
endpoint: API endpoint path
|
|
234
|
+
params: Query parameters
|
|
235
|
+
allow_404: Whether to treat 404 as valid (empty) response
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
API response or None if error
|
|
239
|
+
"""
|
|
240
|
+
# Check cache first
|
|
241
|
+
cache_key = f"{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
|
|
242
|
+
if self.cache:
|
|
243
|
+
cached = self.cache.get(cache_key)
|
|
244
|
+
if cached:
|
|
245
|
+
if self.config.verbose:
|
|
246
|
+
if cached.status == 404:
|
|
247
|
+
print(f"Cache hit (404) for {endpoint}")
|
|
248
|
+
else:
|
|
249
|
+
print(f"Cache hit for {endpoint}")
|
|
250
|
+
# Return None for cached 404s with None data
|
|
251
|
+
if cached.status == 404 and cached.data is None:
|
|
252
|
+
return None
|
|
253
|
+
return cached
|
|
254
|
+
|
|
255
|
+
# Rate limiting
|
|
256
|
+
await self._handle_rate_limiting()
|
|
257
|
+
|
|
258
|
+
# Ensure session is started
|
|
259
|
+
if not self.session:
|
|
260
|
+
await self.start_session()
|
|
261
|
+
|
|
262
|
+
url = f"{self.config.sh_api_base}{endpoint}"
|
|
263
|
+
|
|
264
|
+
for retry in range(self.config.max_retries):
|
|
265
|
+
try:
|
|
266
|
+
async with self._rate_limiter:
|
|
267
|
+
timeout = aiohttp.ClientTimeout(total=self.config.request_timeout)
|
|
268
|
+
async with self.session.get(url, params=params, timeout=timeout) as response:
|
|
269
|
+
# Handle different status codes
|
|
270
|
+
if response.status == 200:
|
|
271
|
+
data = await response.json()
|
|
272
|
+
result = SHAPIResponse(
|
|
273
|
+
data=data,
|
|
274
|
+
headers=dict(response.headers),
|
|
275
|
+
status=response.status,
|
|
276
|
+
cached=False
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Cache the result
|
|
280
|
+
if self.cache is not None:
|
|
281
|
+
self.cache.set(cache_key, result)
|
|
282
|
+
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
elif response.status == 404:
|
|
286
|
+
if allow_404:
|
|
287
|
+
result = SHAPIResponse(
|
|
288
|
+
data=[],
|
|
289
|
+
headers=dict(response.headers),
|
|
290
|
+
status=response.status,
|
|
291
|
+
cached=False
|
|
292
|
+
)
|
|
293
|
+
# Cache 404 responses too to avoid repeated queries
|
|
294
|
+
if self.cache is not None:
|
|
295
|
+
self.cache.set(cache_key, result)
|
|
296
|
+
return result
|
|
297
|
+
if self.config.verbose:
|
|
298
|
+
print(f"404 Not Found: {url}")
|
|
299
|
+
# Cache negative result to avoid repeated queries
|
|
300
|
+
if self.cache is not None:
|
|
301
|
+
empty_result = SHAPIResponse(
|
|
302
|
+
data=None,
|
|
303
|
+
headers=dict(response.headers),
|
|
304
|
+
status=404,
|
|
305
|
+
cached=False
|
|
306
|
+
)
|
|
307
|
+
self.cache.set(cache_key, empty_result)
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
elif response.status == 429: # Rate limited
|
|
311
|
+
retry_after = int(response.headers.get('Retry-After', 60))
|
|
312
|
+
if self.config.verbose:
|
|
313
|
+
print(f"Rate limited. Waiting {retry_after} seconds...")
|
|
314
|
+
await asyncio.sleep(retry_after)
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
else:
|
|
318
|
+
if self.config.verbose:
|
|
319
|
+
print(f"HTTP {response.status}: {url}")
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
except asyncio.TimeoutError:
|
|
323
|
+
print(f"\n⚠️ Request timeout - Software Heritage API is not responding")
|
|
324
|
+
print("This may be due to network issues or API overload.")
|
|
325
|
+
print("Please try again later.")
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
except ClientError as e:
|
|
329
|
+
if self.config.verbose:
|
|
330
|
+
print(f"Request error (attempt {retry + 1}/{self.config.max_retries}): {e}")
|
|
331
|
+
if retry < self.config.max_retries - 1:
|
|
332
|
+
await asyncio.sleep(2 ** retry) # Exponential backoff
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
if self.config.verbose:
|
|
337
|
+
print(f"Unexpected error: {e}")
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
async def _handle_rate_limiting(self):
|
|
343
|
+
"""Implement rate limiting with configurable delay."""
|
|
344
|
+
current_time = asyncio.get_event_loop().time()
|
|
345
|
+
time_since_last = current_time - self._last_request_time
|
|
346
|
+
|
|
347
|
+
if time_since_last < self.config.rate_limit_delay:
|
|
348
|
+
await asyncio.sleep(self.config.rate_limit_delay - time_since_last)
|
|
349
|
+
|
|
350
|
+
self._last_request_time = asyncio.get_event_loop().time()
|
|
351
|
+
|
|
352
|
+
def _extract_hash_from_swhid(self, swhid: str) -> Optional[str]:
|
|
353
|
+
"""
|
|
354
|
+
Extract hash from SWHID string.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
swhid: SWHID string (e.g., swh:1:dir:abc123...)
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Hash part or None if invalid
|
|
361
|
+
"""
|
|
362
|
+
if not swhid or not swhid.startswith('swh:'):
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
parts = swhid.split(':')
|
|
366
|
+
if len(parts) == 4:
|
|
367
|
+
return parts[3]
|
|
368
|
+
|
|
369
|
+
return None
|
|
370
|
+
|
src2id/core/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Configuration management for SHPI."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SWHPIConfig:
|
|
9
|
+
"""Configuration for the Source Package Identifier tool."""
|
|
10
|
+
|
|
11
|
+
# Directory scanning parameters
|
|
12
|
+
max_depth: int = 5 # Maximum parent directory levels to scan
|
|
13
|
+
min_files: int = 3 # Minimum files in directory to consider meaningful
|
|
14
|
+
|
|
15
|
+
# Confidence thresholds
|
|
16
|
+
purl_generation_threshold: float = 0.85
|
|
17
|
+
report_match_threshold: float = 0.65
|
|
18
|
+
fuzzy_consideration_threshold: float = 0.5
|
|
19
|
+
|
|
20
|
+
# Scoring weights
|
|
21
|
+
score_weights: Dict[str, float] = field(default_factory=lambda: {
|
|
22
|
+
'recency': 0.3,
|
|
23
|
+
'popularity': 0.2,
|
|
24
|
+
'authority': 0.3,
|
|
25
|
+
'specificity': 0.2
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
# Software Heritage API configuration (optional)
|
|
29
|
+
sh_api_base: str = "https://archive.softwareheritage.org/api/1"
|
|
30
|
+
api_token: str = "" # Optional API token for authentication (bypasses rate limits)
|
|
31
|
+
use_swh: bool = False # Whether to include SWH in identification strategies
|
|
32
|
+
|
|
33
|
+
# General API configuration
|
|
34
|
+
rate_limit_delay: float = 0.5 # Seconds between API calls
|
|
35
|
+
request_timeout: float = 30.0 # Timeout for API requests in seconds
|
|
36
|
+
max_retries: int = 3
|
|
37
|
+
cache_enabled: bool = True
|
|
38
|
+
|
|
39
|
+
# Output configuration
|
|
40
|
+
output_format: str = "table" # "table" or "json"
|
|
41
|
+
verbose: bool = False
|
|
42
|
+
|
|
43
|
+
# Feature flags
|
|
44
|
+
enable_fuzzy_matching: bool = False
|
|
45
|
+
enable_batch_processing: bool = False
|