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,796 @@
|
|
|
1
|
+
"""Main orchestrator for the SH Package Identifier."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
|
|
11
|
+
from src2id.core.config import SWHPIConfig
|
|
12
|
+
from src2id.core.models import DirectoryCandidate, ContentCandidate, MatchType, PackageMatch, SHOriginMatch
|
|
13
|
+
from src2id.search import SourceIdentifier, create_default_registry
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SHPackageIdentifier:
|
|
19
|
+
"""Main orchestrator class that coordinates all components."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: Optional[SWHPIConfig] = None):
|
|
22
|
+
"""Initialize the package identifier with configuration."""
|
|
23
|
+
self.config = config or SWHPIConfig()
|
|
24
|
+
|
|
25
|
+
# Components will be lazily initialized
|
|
26
|
+
self._swhid_generator = None
|
|
27
|
+
self._sh_client = None
|
|
28
|
+
self._scanner = None
|
|
29
|
+
self._coordinate_extractor = None
|
|
30
|
+
self._confidence_scorer = None
|
|
31
|
+
self._purl_generator = None
|
|
32
|
+
self._source_identifier = None
|
|
33
|
+
self._search_registry = None
|
|
34
|
+
self._upmex_integration = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def swhid_generator(self):
|
|
38
|
+
"""Lazy load SWHID generator."""
|
|
39
|
+
if self._swhid_generator is None:
|
|
40
|
+
from src2id.core.swhid import SWHIDGenerator
|
|
41
|
+
self._swhid_generator = SWHIDGenerator()
|
|
42
|
+
return self._swhid_generator
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def sh_client(self):
|
|
46
|
+
"""Lazy load Software Heritage client."""
|
|
47
|
+
if self._sh_client is None:
|
|
48
|
+
from src2id.core.client import SoftwareHeritageClient
|
|
49
|
+
self._sh_client = SoftwareHeritageClient(self.config)
|
|
50
|
+
return self._sh_client
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def scanner(self):
|
|
54
|
+
"""Lazy load directory scanner."""
|
|
55
|
+
if self._scanner is None:
|
|
56
|
+
from src2id.core.scanner import DirectoryScanner
|
|
57
|
+
self._scanner = DirectoryScanner(self.config, self.swhid_generator)
|
|
58
|
+
return self._scanner
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def coordinate_extractor(self):
|
|
62
|
+
"""Lazy load package coordinate extractor."""
|
|
63
|
+
if self._coordinate_extractor is None:
|
|
64
|
+
from src2id.core.extractor import PackageCoordinateExtractor
|
|
65
|
+
self._coordinate_extractor = PackageCoordinateExtractor()
|
|
66
|
+
return self._coordinate_extractor
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def confidence_scorer(self):
|
|
70
|
+
"""Lazy load confidence scorer."""
|
|
71
|
+
if self._confidence_scorer is None:
|
|
72
|
+
from src2id.core.scorer import ConfidenceScorer
|
|
73
|
+
self._confidence_scorer = ConfidenceScorer(self.config)
|
|
74
|
+
return self._confidence_scorer
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def purl_generator(self):
|
|
78
|
+
"""Lazy load PURL generator."""
|
|
79
|
+
if self._purl_generator is None:
|
|
80
|
+
from src2id.core.purl import PURLGenerator
|
|
81
|
+
self._purl_generator = PURLGenerator()
|
|
82
|
+
return self._purl_generator
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def source_identifier(self):
|
|
86
|
+
"""Lazy load source identifier."""
|
|
87
|
+
if self._source_identifier is None:
|
|
88
|
+
self._source_identifier = SourceIdentifier(
|
|
89
|
+
swh_client=self.sh_client,
|
|
90
|
+
search_registry=self.search_registry,
|
|
91
|
+
verbose=self.config.verbose
|
|
92
|
+
)
|
|
93
|
+
return self._source_identifier
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def search_registry(self):
|
|
97
|
+
"""Lazy load search provider registry."""
|
|
98
|
+
if self._search_registry is None:
|
|
99
|
+
self._search_registry = create_default_registry(verbose=self.config.verbose)
|
|
100
|
+
return self._search_registry
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def upmex_integration(self):
|
|
104
|
+
"""Lazy load UPMEX integration."""
|
|
105
|
+
if self._upmex_integration is None:
|
|
106
|
+
from src2id.integrations.upmex import UpmexIntegration
|
|
107
|
+
self._upmex_integration = UpmexIntegration(enabled=True)
|
|
108
|
+
return self._upmex_integration
|
|
109
|
+
|
|
110
|
+
async def identify_packages(self, path: Path, enhance_licenses: bool = True) -> List[PackageMatch]:
|
|
111
|
+
"""
|
|
112
|
+
Main entry point for package identification.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
path: Directory path to analyze
|
|
116
|
+
enhance_licenses: Whether to use oslili for license enhancement
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of package matches found
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
if self.config.verbose:
|
|
123
|
+
console.print("[bold blue]Starting package identification...[/bold blue]")
|
|
124
|
+
|
|
125
|
+
# Step 1: PRIMARY - Hash-based discovery (SWHIDs + Software Heritage + Web Search)
|
|
126
|
+
if self.config.verbose:
|
|
127
|
+
console.print("[bold blue]Phase 1: Hash-based package discovery[/bold blue]")
|
|
128
|
+
|
|
129
|
+
dir_candidates, file_candidates = await self._scan_directories(path)
|
|
130
|
+
|
|
131
|
+
# Phase 1: Hash-based discovery
|
|
132
|
+
hash_based_matches = []
|
|
133
|
+
|
|
134
|
+
if dir_candidates or file_candidates:
|
|
135
|
+
# Step 1a: Query for matches based on configuration
|
|
136
|
+
if self.config.use_swh:
|
|
137
|
+
# Query Software Heritage for matches (both dirs and files)
|
|
138
|
+
all_matches = await self._find_matches(dir_candidates, file_candidates)
|
|
139
|
+
else:
|
|
140
|
+
all_matches = []
|
|
141
|
+
if self.config.verbose:
|
|
142
|
+
console.print("[dim]Skipping Software Heritage (use --use-swh to enable)[/dim]")
|
|
143
|
+
|
|
144
|
+
if not all_matches:
|
|
145
|
+
# Try keyword search with GitHub and SCANOSS
|
|
146
|
+
if self.config.verbose:
|
|
147
|
+
console.print("[yellow]Trying web search (GitHub, SCANOSS)[/yellow]")
|
|
148
|
+
|
|
149
|
+
# Try keyword search
|
|
150
|
+
keyword_matches = await self._find_keyword_matches(path)
|
|
151
|
+
if keyword_matches:
|
|
152
|
+
all_matches = keyword_matches
|
|
153
|
+
|
|
154
|
+
# Step 1b: Process hash-based matches
|
|
155
|
+
if all_matches:
|
|
156
|
+
hash_based_matches = await self._process_matches(all_matches)
|
|
157
|
+
if self.config.verbose:
|
|
158
|
+
console.print(f"[green]✓ Phase 1 complete: Found {len(hash_based_matches)} packages via hash-based discovery[/green]")
|
|
159
|
+
else:
|
|
160
|
+
if self.config.verbose:
|
|
161
|
+
console.print("[yellow]Phase 1 complete: No packages found via hash-based discovery[/yellow]")
|
|
162
|
+
|
|
163
|
+
# Phase 2: Manifest-based enhancement and supplementation
|
|
164
|
+
if self.config.verbose:
|
|
165
|
+
console.print("[bold blue]Phase 2: Manifest-based validation and enhancement[/bold blue]")
|
|
166
|
+
|
|
167
|
+
# Extract package metadata directly using UPMEX
|
|
168
|
+
manifest_matches = self._extract_with_upmex(path)
|
|
169
|
+
|
|
170
|
+
# Merge and enhance with manifest data
|
|
171
|
+
enhanced_matches = self._merge_and_enhance_matches(hash_based_matches, manifest_matches, path)
|
|
172
|
+
|
|
173
|
+
if self.config.verbose:
|
|
174
|
+
hash_count = len(hash_based_matches)
|
|
175
|
+
manifest_count = len(manifest_matches)
|
|
176
|
+
final_count = len(enhanced_matches)
|
|
177
|
+
console.print(f"[green]✓ Phase 2 complete: {hash_count} hash-based + {manifest_count} manifest-based = {final_count} total packages[/green]")
|
|
178
|
+
|
|
179
|
+
# Final deduplication and sorting
|
|
180
|
+
final_matches = self._prioritize_and_deduplicate(enhanced_matches)
|
|
181
|
+
|
|
182
|
+
# Step 5: Optionally enhance with oslili license detection
|
|
183
|
+
if enhance_licenses:
|
|
184
|
+
try:
|
|
185
|
+
from src2id.integrations.oslili import enhance_with_oslili
|
|
186
|
+
final_matches = enhance_with_oslili(final_matches, path)
|
|
187
|
+
# License enhancement is now silent by default
|
|
188
|
+
except ImportError:
|
|
189
|
+
if self.config.verbose:
|
|
190
|
+
console.print("[yellow]oslili not available for license enhancement[/yellow]")
|
|
191
|
+
|
|
192
|
+
# Match count is now shown in CLI output, not here
|
|
193
|
+
|
|
194
|
+
return final_matches
|
|
195
|
+
finally:
|
|
196
|
+
# Clean up the session if it was created
|
|
197
|
+
if self._sh_client is not None:
|
|
198
|
+
await self._sh_client.close_session()
|
|
199
|
+
|
|
200
|
+
def _extract_with_upmex(self, path: Path) -> List[PackageMatch]:
|
|
201
|
+
"""
|
|
202
|
+
Try to extract package metadata directly using UPMEX.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
path: Directory path to analyze
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of package matches found via direct metadata extraction
|
|
209
|
+
"""
|
|
210
|
+
if not self.upmex_integration.enabled:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
matches = self.upmex_integration.extract_metadata_from_directory(path)
|
|
215
|
+
|
|
216
|
+
if self.config.verbose and matches:
|
|
217
|
+
console.print("[green]Found package metadata files:[/green]")
|
|
218
|
+
for match in matches:
|
|
219
|
+
if match.name:
|
|
220
|
+
console.print(f" [green]📦 {match.name} v{match.version or 'unknown'}[/green]")
|
|
221
|
+
if match.license:
|
|
222
|
+
console.print(f" License: {match.license}")
|
|
223
|
+
if match.purl:
|
|
224
|
+
console.print(f" PURL: {match.purl}")
|
|
225
|
+
|
|
226
|
+
return matches
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
if self.config.verbose:
|
|
230
|
+
console.print(f"[yellow]UPMEX extraction failed: {e}[/yellow]")
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
def _merge_and_enhance_matches(
|
|
234
|
+
self,
|
|
235
|
+
hash_based_matches: List[PackageMatch],
|
|
236
|
+
manifest_matches: List[PackageMatch],
|
|
237
|
+
path: Path
|
|
238
|
+
) -> List[PackageMatch]:
|
|
239
|
+
"""
|
|
240
|
+
Intelligently merge hash-based and manifest-based matches.
|
|
241
|
+
|
|
242
|
+
Strategy:
|
|
243
|
+
1. Use manifest data to enhance/validate hash-based matches
|
|
244
|
+
2. Add manifest-only packages as supplementary findings
|
|
245
|
+
3. Prefer hash-based discovery but enhance with manifest precision
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
hash_based_matches: Packages found via SWHID/Software Heritage
|
|
249
|
+
manifest_matches: Packages found via manifest parsing
|
|
250
|
+
path: The scanned directory path
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Enhanced and merged list of package matches
|
|
254
|
+
"""
|
|
255
|
+
if not manifest_matches:
|
|
256
|
+
return hash_based_matches
|
|
257
|
+
|
|
258
|
+
if not hash_based_matches:
|
|
259
|
+
# Only manifest matches found - add them all as supplementary
|
|
260
|
+
if self.config.verbose:
|
|
261
|
+
console.print(f"[blue]Adding {len(manifest_matches)} manifest-only packages[/blue]")
|
|
262
|
+
return manifest_matches
|
|
263
|
+
|
|
264
|
+
# Create enhanced results starting with hash-based matches
|
|
265
|
+
enhanced_matches = list(hash_based_matches)
|
|
266
|
+
|
|
267
|
+
# Create lookup for hash-based matches by normalized name/URL
|
|
268
|
+
hash_lookup = {}
|
|
269
|
+
for match in hash_based_matches:
|
|
270
|
+
# Try multiple keys for matching
|
|
271
|
+
keys = []
|
|
272
|
+
if match.name:
|
|
273
|
+
keys.append(match.name.lower())
|
|
274
|
+
# Handle different name formats (e.g., "org:artifact" vs "artifact")
|
|
275
|
+
if ':' in match.name:
|
|
276
|
+
keys.append(match.name.split(':')[-1].lower())
|
|
277
|
+
if match.download_url:
|
|
278
|
+
# Normalize URL for matching
|
|
279
|
+
normalized_url = self._normalize_url_for_matching(match.download_url)
|
|
280
|
+
keys.append(normalized_url)
|
|
281
|
+
|
|
282
|
+
for key in keys:
|
|
283
|
+
hash_lookup[key] = match
|
|
284
|
+
|
|
285
|
+
# Process manifest matches
|
|
286
|
+
supplementary_count = 0
|
|
287
|
+
enhanced_count = 0
|
|
288
|
+
|
|
289
|
+
for manifest_match in manifest_matches:
|
|
290
|
+
# Try to find corresponding hash-based match
|
|
291
|
+
corresponding_hash_match = None
|
|
292
|
+
|
|
293
|
+
# Check by name
|
|
294
|
+
if manifest_match.name:
|
|
295
|
+
name_key = manifest_match.name.lower()
|
|
296
|
+
if name_key in hash_lookup:
|
|
297
|
+
corresponding_hash_match = hash_lookup[name_key]
|
|
298
|
+
elif ':' in manifest_match.name:
|
|
299
|
+
# Try just the artifact name
|
|
300
|
+
artifact_key = manifest_match.name.split(':')[-1].lower()
|
|
301
|
+
if artifact_key in hash_lookup:
|
|
302
|
+
corresponding_hash_match = hash_lookup[artifact_key]
|
|
303
|
+
|
|
304
|
+
# Check by URL if no name match
|
|
305
|
+
if not corresponding_hash_match and manifest_match.download_url:
|
|
306
|
+
url_key = self._normalize_url_for_matching(manifest_match.download_url)
|
|
307
|
+
if url_key in hash_lookup:
|
|
308
|
+
corresponding_hash_match = hash_lookup[url_key]
|
|
309
|
+
|
|
310
|
+
if corresponding_hash_match:
|
|
311
|
+
# ENHANCE: Update hash-based match with manifest precision
|
|
312
|
+
enhanced_count += 1
|
|
313
|
+
if self.config.verbose:
|
|
314
|
+
console.print(f"[cyan]Enhanced {corresponding_hash_match.name or 'package'} with manifest data[/cyan]")
|
|
315
|
+
|
|
316
|
+
# Keep hash-based match but enhance with manifest data
|
|
317
|
+
if manifest_match.version and not corresponding_hash_match.version:
|
|
318
|
+
corresponding_hash_match.version = manifest_match.version
|
|
319
|
+
|
|
320
|
+
if manifest_match.license and not corresponding_hash_match.license:
|
|
321
|
+
corresponding_hash_match.license = manifest_match.license
|
|
322
|
+
elif manifest_match.license and corresponding_hash_match.license:
|
|
323
|
+
# Combine licenses if different
|
|
324
|
+
if manifest_match.license not in corresponding_hash_match.license:
|
|
325
|
+
corresponding_hash_match.license = f"{corresponding_hash_match.license}, {manifest_match.license}"
|
|
326
|
+
|
|
327
|
+
if manifest_match.purl and not corresponding_hash_match.purl:
|
|
328
|
+
corresponding_hash_match.purl = manifest_match.purl
|
|
329
|
+
|
|
330
|
+
# Boost confidence slightly for validation
|
|
331
|
+
corresponding_hash_match.confidence_score = min(1.0, corresponding_hash_match.confidence_score + 0.05)
|
|
332
|
+
|
|
333
|
+
else:
|
|
334
|
+
# SUPPLEMENT: Add manifest-only package
|
|
335
|
+
supplementary_count += 1
|
|
336
|
+
if self.config.verbose:
|
|
337
|
+
console.print(f"[blue]Added supplementary package: {manifest_match.name or 'unknown'}[/blue]")
|
|
338
|
+
|
|
339
|
+
# Slightly lower confidence for manifest-only packages
|
|
340
|
+
manifest_match.confidence_score = min(0.85, manifest_match.confidence_score)
|
|
341
|
+
enhanced_matches.append(manifest_match)
|
|
342
|
+
|
|
343
|
+
if self.config.verbose and (enhanced_count > 0 or supplementary_count > 0):
|
|
344
|
+
console.print(f"[green]✓ Enhanced {enhanced_count} packages, added {supplementary_count} supplementary packages[/green]")
|
|
345
|
+
|
|
346
|
+
return enhanced_matches
|
|
347
|
+
|
|
348
|
+
def _normalize_url_for_matching(self, url: str) -> str:
|
|
349
|
+
"""Normalize URL for matching between hash-based and manifest-based results."""
|
|
350
|
+
if not url:
|
|
351
|
+
return ""
|
|
352
|
+
|
|
353
|
+
# Remove protocol and common suffixes
|
|
354
|
+
normalized = url.lower()
|
|
355
|
+
normalized = normalized.replace('https://', '').replace('http://', '')
|
|
356
|
+
normalized = normalized.replace('.git', '')
|
|
357
|
+
normalized = normalized.rstrip('/')
|
|
358
|
+
|
|
359
|
+
# Extract key parts for matching using proper URL parsing
|
|
360
|
+
try:
|
|
361
|
+
from urllib.parse import urlparse
|
|
362
|
+
parsed = urlparse(url if url.startswith(('http://', 'https://')) else f'https://{url}')
|
|
363
|
+
hostname = parsed.hostname.lower() if parsed.hostname else ''
|
|
364
|
+
path_parts = parsed.path.strip('/').split('/')
|
|
365
|
+
|
|
366
|
+
if hostname == 'github.com' and len(path_parts) >= 2:
|
|
367
|
+
return f"github.com/{path_parts[0]}/{path_parts[1]}"
|
|
368
|
+
elif hostname == 'gitlab.com' and len(path_parts) >= 2:
|
|
369
|
+
return f"gitlab.com/{path_parts[0]}/{path_parts[1]}"
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
return normalized
|
|
374
|
+
|
|
375
|
+
async def _scan_directories(self, path: Path) -> Tuple[List[DirectoryCandidate], List[ContentCandidate]]:
|
|
376
|
+
"""Scan directories and files to generate SWHID candidates."""
|
|
377
|
+
if self.config.verbose:
|
|
378
|
+
with Progress(
|
|
379
|
+
SpinnerColumn(),
|
|
380
|
+
TextColumn("[progress.description]{task.description}"),
|
|
381
|
+
console=console,
|
|
382
|
+
) as progress:
|
|
383
|
+
task = progress.add_task("Scanning directories and files...", total=None)
|
|
384
|
+
|
|
385
|
+
# Scan the main path - returns both dirs and files
|
|
386
|
+
dir_candidates, file_candidates = self.scanner.scan_recursive(path)
|
|
387
|
+
|
|
388
|
+
# Also scan subdirectories for better matching
|
|
389
|
+
progress.update(task, description="Scanning subdirectories...")
|
|
390
|
+
subdirs = await self._scan_subdirectories(path)
|
|
391
|
+
dir_candidates.extend(subdirs)
|
|
392
|
+
|
|
393
|
+
progress.update(task, completed=True)
|
|
394
|
+
else:
|
|
395
|
+
dir_candidates, file_candidates = self.scanner.scan_recursive(path)
|
|
396
|
+
subdirs = await self._scan_subdirectories(path)
|
|
397
|
+
dir_candidates.extend(subdirs)
|
|
398
|
+
|
|
399
|
+
# Remove duplicate directories based on SWHID
|
|
400
|
+
seen_swhids = set()
|
|
401
|
+
unique_dirs = []
|
|
402
|
+
for candidate in dir_candidates:
|
|
403
|
+
if candidate.swhid not in seen_swhids:
|
|
404
|
+
seen_swhids.add(candidate.swhid)
|
|
405
|
+
unique_dirs.append(candidate)
|
|
406
|
+
|
|
407
|
+
if self.config.verbose:
|
|
408
|
+
console.print(f"[dim]Generated {len(unique_dirs)} directory candidates and {len(file_candidates)} file candidates[/dim]")
|
|
409
|
+
# Show breakdown
|
|
410
|
+
target_count = sum(1 for c in unique_dirs if c.path == path)
|
|
411
|
+
parent_count = sum(1 for c in unique_dirs if path in c.path.parents)
|
|
412
|
+
child_count = sum(1 for c in unique_dirs if c.path.parent == path)
|
|
413
|
+
|
|
414
|
+
if child_count > 0 or file_candidates:
|
|
415
|
+
console.print(f"[dim] → Directories: {len(unique_dirs)} (Target: {target_count}, Parents: {parent_count}, Children: {child_count})[/dim]")
|
|
416
|
+
console.print(f"[dim] → Files: {len(file_candidates)} collected[/dim]")
|
|
417
|
+
|
|
418
|
+
return unique_dirs, file_candidates
|
|
419
|
+
|
|
420
|
+
async def _scan_subdirectories(self, path: Path) -> List[DirectoryCandidate]:
|
|
421
|
+
"""Scan immediate subdirectories for better matching."""
|
|
422
|
+
candidates = []
|
|
423
|
+
|
|
424
|
+
# Priority directories that rarely change
|
|
425
|
+
priority_dirs = ['cmake', 'docs', 'doc', 'tools', 'packaging',
|
|
426
|
+
'data', 'po', 'translations', 'config', 'scripts']
|
|
427
|
+
|
|
428
|
+
subdirs_checked = 0
|
|
429
|
+
max_subdirs = 10
|
|
430
|
+
|
|
431
|
+
# Check priority directories first
|
|
432
|
+
for dir_name in priority_dirs:
|
|
433
|
+
if subdirs_checked >= max_subdirs:
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
subdir = path / dir_name
|
|
437
|
+
if subdir.exists() and subdir.is_dir():
|
|
438
|
+
try:
|
|
439
|
+
from src2id.core.models import DirectoryCandidate
|
|
440
|
+
|
|
441
|
+
file_count = sum(1 for _ in subdir.rglob('*') if _.is_file())
|
|
442
|
+
if file_count >= 3: # Low threshold for subdirs
|
|
443
|
+
swhid = self.swhid_generator.generate_directory_swhid(subdir)
|
|
444
|
+
|
|
445
|
+
candidate = DirectoryCandidate(
|
|
446
|
+
path=subdir,
|
|
447
|
+
swhid=swhid,
|
|
448
|
+
depth=1,
|
|
449
|
+
specificity_score=0.8, # High score for stable dirs
|
|
450
|
+
file_count=file_count
|
|
451
|
+
)
|
|
452
|
+
candidates.append(candidate)
|
|
453
|
+
subdirs_checked += 1
|
|
454
|
+
|
|
455
|
+
except (PermissionError, OSError):
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
return candidates
|
|
459
|
+
|
|
460
|
+
async def _find_matches(self, dir_candidates: List[DirectoryCandidate], file_candidates: List[ContentCandidate]) -> List[SHOriginMatch]:
|
|
461
|
+
"""Find matches in Software Heritage for all candidates (dirs and files)."""
|
|
462
|
+
all_matches = []
|
|
463
|
+
# Find the most specific path (highest specificity score) as target
|
|
464
|
+
target_path = max(dir_candidates, key=lambda c: c.specificity_score).path if dir_candidates else None
|
|
465
|
+
|
|
466
|
+
# Track match statistics
|
|
467
|
+
parent_matches = 0
|
|
468
|
+
child_matches = 0
|
|
469
|
+
target_match = False
|
|
470
|
+
|
|
471
|
+
with Progress(
|
|
472
|
+
SpinnerColumn(),
|
|
473
|
+
TextColumn("[progress.description]{task.description}"),
|
|
474
|
+
console=console,
|
|
475
|
+
disable=not self.config.verbose,
|
|
476
|
+
) as progress:
|
|
477
|
+
task = progress.add_task(
|
|
478
|
+
"Checking archive status (batch)...",
|
|
479
|
+
total=len(dir_candidates) + len(file_candidates)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Batch check all SWHIDs (both dirs and files) for efficiency
|
|
483
|
+
all_swhids = []
|
|
484
|
+
swhid_to_candidate = {}
|
|
485
|
+
|
|
486
|
+
# Add directory SWHIDs
|
|
487
|
+
for candidate in dir_candidates:
|
|
488
|
+
all_swhids.append(candidate.swhid)
|
|
489
|
+
swhid_to_candidate[candidate.swhid] = ("dir", candidate)
|
|
490
|
+
|
|
491
|
+
# Add file SWHIDs
|
|
492
|
+
for candidate in file_candidates:
|
|
493
|
+
all_swhids.append(candidate.swhid)
|
|
494
|
+
swhid_to_candidate[candidate.swhid] = ("file", candidate)
|
|
495
|
+
|
|
496
|
+
if self.config.verbose:
|
|
497
|
+
console.print(f"[dim]Batch checking {len(all_swhids)} SWHIDs ({len(dir_candidates)} dirs, {len(file_candidates)} files)...[/dim]")
|
|
498
|
+
|
|
499
|
+
known_status = await self.sh_client.check_swhids_known(all_swhids)
|
|
500
|
+
|
|
501
|
+
# Filter to only items that exist in archive
|
|
502
|
+
existing_dirs = []
|
|
503
|
+
existing_files = []
|
|
504
|
+
|
|
505
|
+
for swhid, is_known in known_status.items():
|
|
506
|
+
if is_known:
|
|
507
|
+
# Convert CoreSWHID back to string if needed
|
|
508
|
+
swhid_str = str(swhid) if not isinstance(swhid, str) else swhid
|
|
509
|
+
if swhid_str in swhid_to_candidate:
|
|
510
|
+
item_type, candidate = swhid_to_candidate[swhid_str]
|
|
511
|
+
if item_type == "dir":
|
|
512
|
+
existing_dirs.append(candidate)
|
|
513
|
+
else:
|
|
514
|
+
existing_files.append(candidate)
|
|
515
|
+
|
|
516
|
+
if self.config.verbose:
|
|
517
|
+
dirs_found = len(existing_dirs)
|
|
518
|
+
files_found = len(existing_files)
|
|
519
|
+
total_dirs = len(dir_candidates)
|
|
520
|
+
total_files = len(file_candidates)
|
|
521
|
+
console.print(f"[dim]Found {dirs_found}/{total_dirs} directories and {files_found}/{total_files} files in archive[/dim]")
|
|
522
|
+
|
|
523
|
+
# Now get detailed origins info for existing directories
|
|
524
|
+
# Note: Files don't have origins, but we can report them as found
|
|
525
|
+
progress.update(task, description="Getting origin details...")
|
|
526
|
+
|
|
527
|
+
# Report found files
|
|
528
|
+
if self.config.verbose and existing_files:
|
|
529
|
+
console.print(f"[green]✓ Found {len(existing_files)} files in archive:[/green]")
|
|
530
|
+
for file_candidate in existing_files[:10]: # Show first 10
|
|
531
|
+
rel_path = file_candidate.path.name
|
|
532
|
+
console.print(f" [green]📄 {rel_path}[/green]")
|
|
533
|
+
if len(existing_files) > 10:
|
|
534
|
+
console.print(f" [dim]... and {len(existing_files) - 10} more files[/dim]")
|
|
535
|
+
|
|
536
|
+
# Process directories for origin information
|
|
537
|
+
for candidate in existing_dirs:
|
|
538
|
+
# Determine relationship
|
|
539
|
+
if target_path:
|
|
540
|
+
if candidate.path == target_path:
|
|
541
|
+
relationship = "target"
|
|
542
|
+
elif candidate.path.parent == target_path:
|
|
543
|
+
relationship = "child"
|
|
544
|
+
elif target_path in candidate.path.parents:
|
|
545
|
+
relationship = "parent"
|
|
546
|
+
else:
|
|
547
|
+
relationship = "other"
|
|
548
|
+
else:
|
|
549
|
+
relationship = "unknown"
|
|
550
|
+
|
|
551
|
+
# Get detailed origin information for known directories
|
|
552
|
+
if self.config.verbose:
|
|
553
|
+
console.print(f"[dim]Getting origins: {candidate.swhid}[/dim]")
|
|
554
|
+
exact_matches = await self._find_exact_matches(candidate)
|
|
555
|
+
|
|
556
|
+
if exact_matches:
|
|
557
|
+
all_matches.extend(exact_matches)
|
|
558
|
+
|
|
559
|
+
# Update statistics
|
|
560
|
+
if relationship == "target":
|
|
561
|
+
target_match = True
|
|
562
|
+
elif relationship == "child":
|
|
563
|
+
child_matches += 1
|
|
564
|
+
elif relationship == "parent":
|
|
565
|
+
parent_matches += 1
|
|
566
|
+
|
|
567
|
+
if self.config.verbose:
|
|
568
|
+
if relationship == "child":
|
|
569
|
+
console.print(
|
|
570
|
+
f"[green]✓ Found exact match for subdirectory: {candidate.path.name}[/green]"
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
console.print(
|
|
574
|
+
f"[green]✓ Found exact match for {candidate.path.name}[/green]"
|
|
575
|
+
)
|
|
576
|
+
# Early termination on high-confidence exact match
|
|
577
|
+
if any(self._is_high_confidence_match(m) for m in exact_matches):
|
|
578
|
+
break
|
|
579
|
+
|
|
580
|
+
progress.advance(task)
|
|
581
|
+
|
|
582
|
+
# Log non-existing directories in verbose mode
|
|
583
|
+
if self.config.verbose:
|
|
584
|
+
non_existing_dirs = [
|
|
585
|
+
candidate for candidate in dir_candidates
|
|
586
|
+
if not known_status.get(candidate.swhid, False)
|
|
587
|
+
]
|
|
588
|
+
for candidate in non_existing_dirs:
|
|
589
|
+
console.print(f"[yellow]✗ No match for {candidate.path.name} ({candidate.swhid[:12]}...)[/yellow]")
|
|
590
|
+
|
|
591
|
+
# Try keyword search fallback if no exact matches and fuzzy is enabled
|
|
592
|
+
if not all_matches and self.config.enable_fuzzy_matching:
|
|
593
|
+
if self.config.verbose:
|
|
594
|
+
console.print("[yellow]No exact matches found - trying keyword search[/yellow]")
|
|
595
|
+
# Only try keyword search if fuzzy matching is enabled
|
|
596
|
+
keyword_matches = await self._find_keyword_matches(dir_candidates[0].path if dir_candidates else Path("."))
|
|
597
|
+
all_matches.extend(keyword_matches)
|
|
598
|
+
|
|
599
|
+
# Report match summary
|
|
600
|
+
if self.config.verbose and (child_matches > 0 or parent_matches > 0):
|
|
601
|
+
console.print("\n[bold]Match Summary:[/bold]")
|
|
602
|
+
if target_match:
|
|
603
|
+
console.print(" [green]✓ Target directory found in archive[/green]")
|
|
604
|
+
else:
|
|
605
|
+
console.print(" [yellow]✗ Target directory not found[/yellow]")
|
|
606
|
+
|
|
607
|
+
if child_matches > 0:
|
|
608
|
+
console.print(f" [green]✓ {child_matches} subdirectories found in archive[/green]")
|
|
609
|
+
console.print(" [dim]→ Subdirectory matches indicate partial repository presence[/dim]")
|
|
610
|
+
|
|
611
|
+
if parent_matches > 0:
|
|
612
|
+
console.print(f" [blue]ℹ {parent_matches} parent directories checked[/blue]")
|
|
613
|
+
|
|
614
|
+
return all_matches
|
|
615
|
+
|
|
616
|
+
async def _find_exact_matches(self, candidate: DirectoryCandidate) -> List[SHOriginMatch]:
|
|
617
|
+
"""Find exact SWHID matches in Software Heritage."""
|
|
618
|
+
try:
|
|
619
|
+
return await self.sh_client.get_directory_origins(candidate.swhid)
|
|
620
|
+
except Exception as e:
|
|
621
|
+
if self.config.verbose:
|
|
622
|
+
console.print(f"[red]Error querying SH for {candidate.swhid}: {e}[/red]")
|
|
623
|
+
return []
|
|
624
|
+
|
|
625
|
+
async def _find_fuzzy_matches(self, candidate: DirectoryCandidate) -> List[SHOriginMatch]:
|
|
626
|
+
"""Find fuzzy matches using similarity algorithms."""
|
|
627
|
+
# Not implemented yet - placeholder for Issue #9
|
|
628
|
+
return []
|
|
629
|
+
|
|
630
|
+
async def _find_keyword_matches(self, path: Path) -> List[SHOriginMatch]:
|
|
631
|
+
"""Find matches by searching for project name keywords."""
|
|
632
|
+
# Extract potential project name from path
|
|
633
|
+
# Walk up the path to find the most likely project name
|
|
634
|
+
parts = path.parts
|
|
635
|
+
|
|
636
|
+
# Common subdirectory names to skip
|
|
637
|
+
skip_dirs = {'packaging', 'src', 'lib', 'bin', 'build', 'dist', 'test',
|
|
638
|
+
'tests', 'test_data', 'Projects', 'tmp', 'temp', 'vendor',
|
|
639
|
+
'node_modules', '.git', 'docs', 'doc', 'cmake', 'po', 'data'}
|
|
640
|
+
|
|
641
|
+
keywords_to_try = []
|
|
642
|
+
|
|
643
|
+
# Try to find the project name by walking up the path
|
|
644
|
+
for i in range(len(parts) - 1, -1, -1):
|
|
645
|
+
part = parts[i]
|
|
646
|
+
if part not in skip_dirs and not part.startswith('.'):
|
|
647
|
+
keywords_to_try.append(part)
|
|
648
|
+
break
|
|
649
|
+
|
|
650
|
+
# If we didn't find anything, use the immediate parent if it's not a skip dir
|
|
651
|
+
if not keywords_to_try and path.parent.name not in skip_dirs:
|
|
652
|
+
keywords_to_try.append(path.parent.name)
|
|
653
|
+
|
|
654
|
+
# As a last resort, use the current directory name if it's not generic
|
|
655
|
+
if not keywords_to_try and path.name not in skip_dirs:
|
|
656
|
+
keywords_to_try.append(path.name)
|
|
657
|
+
|
|
658
|
+
all_origin_urls = set()
|
|
659
|
+
origin_matches = []
|
|
660
|
+
|
|
661
|
+
for keyword in keywords_to_try:
|
|
662
|
+
if self.config.verbose:
|
|
663
|
+
console.print(f"[dim]Searching for origins with keyword: {keyword}[/dim]")
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
origins = await self.sh_client.search_origins_by_keyword(keyword)
|
|
667
|
+
|
|
668
|
+
for origin_data in origins:
|
|
669
|
+
url = origin_data.get('url', '')
|
|
670
|
+
if url and url not in all_origin_urls:
|
|
671
|
+
all_origin_urls.add(url)
|
|
672
|
+
|
|
673
|
+
# Calculate similarity score based on URL matching
|
|
674
|
+
similarity_score = 0.5 # Base score for keyword match
|
|
675
|
+
|
|
676
|
+
# Boost score for official organization matches
|
|
677
|
+
if f"{keyword}-org" in url.lower() or f"/{keyword}/{keyword}" in url.lower():
|
|
678
|
+
similarity_score = 0.9
|
|
679
|
+
elif f"/{keyword}/" in url.lower() or url.lower().endswith(f"/{keyword}"):
|
|
680
|
+
similarity_score = 0.7
|
|
681
|
+
elif keyword.lower() in url.lower():
|
|
682
|
+
similarity_score = 0.6
|
|
683
|
+
|
|
684
|
+
# Create a match for each origin found
|
|
685
|
+
origin_match = SHOriginMatch(
|
|
686
|
+
origin_url=url,
|
|
687
|
+
swhid="", # No specific SWHID since this is keyword search
|
|
688
|
+
last_seen=datetime.now(),
|
|
689
|
+
visit_count=1,
|
|
690
|
+
metadata={'keyword_match': keyword},
|
|
691
|
+
match_type=MatchType.FUZZY,
|
|
692
|
+
similarity_score=similarity_score
|
|
693
|
+
)
|
|
694
|
+
origin_matches.append(origin_match)
|
|
695
|
+
|
|
696
|
+
if self.config.verbose and len(origin_matches) <= 3:
|
|
697
|
+
console.print(f" [green]→ Found: {url} (score: {similarity_score:.2f})[/green]")
|
|
698
|
+
except Exception as e:
|
|
699
|
+
if self.config.verbose:
|
|
700
|
+
console.print(f"[red]Error searching for keyword {keyword}: {e}[/red]")
|
|
701
|
+
|
|
702
|
+
if self.config.verbose and origin_matches:
|
|
703
|
+
console.print(f"[green]Found {len(origin_matches)} potential matches via keyword search[/green]")
|
|
704
|
+
|
|
705
|
+
# Sort by similarity score (highest first) and return top matches
|
|
706
|
+
origin_matches.sort(key=lambda x: x.similarity_score, reverse=True)
|
|
707
|
+
return origin_matches[:10] # Limit to top 10 matches
|
|
708
|
+
|
|
709
|
+
async def _process_matches(
|
|
710
|
+
self, matches: List[SHOriginMatch]
|
|
711
|
+
) -> List[PackageMatch]:
|
|
712
|
+
"""Process matches to extract package information."""
|
|
713
|
+
package_matches = []
|
|
714
|
+
|
|
715
|
+
for match in matches:
|
|
716
|
+
# Extract package coordinates
|
|
717
|
+
coordinates = self.coordinate_extractor.extract_coordinates(match)
|
|
718
|
+
|
|
719
|
+
# Calculate confidence score
|
|
720
|
+
confidence = self.confidence_scorer.calculate_confidence({
|
|
721
|
+
'match_type': match.match_type,
|
|
722
|
+
'similarity_score': getattr(match, 'similarity_score', 1.0),
|
|
723
|
+
'frequency_rank': match.visit_count,
|
|
724
|
+
'is_official_org': self.coordinate_extractor.is_official_organization(
|
|
725
|
+
match.origin_url
|
|
726
|
+
),
|
|
727
|
+
'last_activity': match.last_seen
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
# Skip low confidence matches
|
|
731
|
+
if confidence < self.config.report_match_threshold:
|
|
732
|
+
continue
|
|
733
|
+
|
|
734
|
+
# Generate PURL if confidence is high enough
|
|
735
|
+
purl = None
|
|
736
|
+
if confidence >= self.config.purl_generation_threshold:
|
|
737
|
+
purl = self.purl_generator.generate_purl(coordinates, confidence)
|
|
738
|
+
|
|
739
|
+
package_match = PackageMatch(
|
|
740
|
+
download_url=coordinates.get('download_url', match.origin_url),
|
|
741
|
+
name=coordinates.get('name'),
|
|
742
|
+
version=coordinates.get('version'),
|
|
743
|
+
license=coordinates.get('license'),
|
|
744
|
+
sh_url=f"{self.config.sh_api_base}/directory/{match.swhid}/",
|
|
745
|
+
match_type=match.match_type,
|
|
746
|
+
confidence_score=confidence,
|
|
747
|
+
frequency_count=match.visit_count,
|
|
748
|
+
is_official_org=self.coordinate_extractor.is_official_organization(
|
|
749
|
+
match.origin_url
|
|
750
|
+
),
|
|
751
|
+
purl=purl
|
|
752
|
+
)
|
|
753
|
+
package_matches.append(package_match)
|
|
754
|
+
|
|
755
|
+
return package_matches
|
|
756
|
+
|
|
757
|
+
def _prioritize_and_deduplicate(
|
|
758
|
+
self, matches: List[PackageMatch]
|
|
759
|
+
) -> List[PackageMatch]:
|
|
760
|
+
"""Sort by confidence and remove duplicates."""
|
|
761
|
+
# Group by base repository URL
|
|
762
|
+
grouped = {}
|
|
763
|
+
for match in matches:
|
|
764
|
+
base_url = self._extract_base_repo_url(match.download_url)
|
|
765
|
+
if base_url not in grouped or match.confidence_score > grouped[base_url].confidence_score:
|
|
766
|
+
grouped[base_url] = match
|
|
767
|
+
|
|
768
|
+
# Sort by official orgs first, then confidence
|
|
769
|
+
result = list(grouped.values())
|
|
770
|
+
result.sort(key=lambda m: (-1 if m.is_official_org else 0, -m.confidence_score))
|
|
771
|
+
|
|
772
|
+
return result
|
|
773
|
+
|
|
774
|
+
def _extract_base_repo_url(self, url: str) -> str:
|
|
775
|
+
"""Extract base repository URL for deduplication."""
|
|
776
|
+
try:
|
|
777
|
+
from urllib.parse import urlparse
|
|
778
|
+
parsed = urlparse(url)
|
|
779
|
+
hostname = parsed.hostname.lower() if parsed.hostname else ''
|
|
780
|
+
|
|
781
|
+
if hostname in ('github.com', 'gitlab.com'):
|
|
782
|
+
path_parts = parsed.path.strip('/').split('/')
|
|
783
|
+
if len(path_parts) >= 2:
|
|
784
|
+
return f"{parsed.scheme}://{hostname}/{path_parts[0]}/{path_parts[1]}"
|
|
785
|
+
except Exception:
|
|
786
|
+
pass
|
|
787
|
+
return url
|
|
788
|
+
|
|
789
|
+
def _is_high_confidence_match(self, match: SHOriginMatch) -> bool:
|
|
790
|
+
"""Check if a match is high confidence for early termination."""
|
|
791
|
+
# Simple heuristic - can be improved
|
|
792
|
+
return (
|
|
793
|
+
match.match_type.value == "exact" and
|
|
794
|
+
match.visit_count > 10 and
|
|
795
|
+
self.coordinate_extractor.is_official_organization(match.origin_url)
|
|
796
|
+
)
|