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/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