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 ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Source to PURL (src2purl)
3
+
4
+ A tool for identifying package coordinates and PURLs from source code
5
+ using multiple strategies including SCANOSS, hash search, web search, and
6
+ optionally Software Heritage archive.
7
+ """
8
+
9
+ __version__ = "1.3.2"
10
+ __author__ = "Oscar Valenzuela B."
11
+
12
+ from src2id.core.config import SWHPIConfig
13
+
14
+ # Keep SWHPIConfig name for backward compatibility, but it's really Src2IdConfig now
15
+ __all__ = ["SWHPIConfig"]
src2id/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command-line interface for SHPI."""
src2id/cli/main.py ADDED
@@ -0,0 +1,318 @@
1
+ """Main CLI entry point for src2purl."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ import warnings
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ # Suppress urllib3 warnings about LibreSSL
12
+ warnings.filterwarnings('ignore', message='urllib3 v2 only supports OpenSSL')
13
+
14
+ import click
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+ from tabulate import tabulate
18
+
19
+ from src2id import __version__
20
+ from src2id.core.config import SWHPIConfig
21
+ from src2id.core.models import PackageMatch
22
+ from src2id.core.orchestrator import SHPackageIdentifier
23
+
24
+ console = Console()
25
+
26
+
27
+ @click.command()
28
+ @click.argument("path", type=click.Path(exists=True, path_type=Path), required=False)
29
+ @click.option(
30
+ "--max-depth",
31
+ type=int,
32
+ default=2,
33
+ help="Maximum parent directory levels to scan",
34
+ )
35
+ @click.option(
36
+ "--confidence-threshold",
37
+ type=float,
38
+ default=0.3,
39
+ help="Minimum confidence to report matches",
40
+ )
41
+ @click.option(
42
+ "--output-format",
43
+ type=click.Choice(["json", "table"]),
44
+ default="table",
45
+ help="Output format",
46
+ )
47
+ @click.option(
48
+ "--enable-fuzzy",
49
+ is_flag=True,
50
+ help="Enable fuzzy matching (keyword search) when exact matches fail",
51
+ )
52
+ @click.option(
53
+ "--no-cache",
54
+ is_flag=True,
55
+ help="Disable API response caching",
56
+ )
57
+ @click.option(
58
+ "--clear-cache",
59
+ is_flag=True,
60
+ help="Clear all cached API responses and exit",
61
+ )
62
+ @click.option(
63
+ "--no-license-detection",
64
+ is_flag=True,
65
+ help="Skip automatic license detection from local source code",
66
+ )
67
+ @click.option(
68
+ "--detect-subcomponents",
69
+ is_flag=True,
70
+ help="Detect and identify multiple subcomponents in the project",
71
+ )
72
+ @click.option(
73
+ "--use-swh",
74
+ is_flag=True,
75
+ help="Include Software Heritage archive checking (slower but more comprehensive)",
76
+ )
77
+ @click.option(
78
+ "--api-token",
79
+ envvar="SWH_API_TOKEN",
80
+ help="Software Heritage API token for authentication (can also be set via SWH_API_TOKEN env var)",
81
+ )
82
+ @click.option(
83
+ "--verbose",
84
+ "-v",
85
+ is_flag=True,
86
+ help="Verbose output for debugging",
87
+ )
88
+ @click.version_option(version=__version__)
89
+ def main(
90
+ path: Optional[Path],
91
+ max_depth: int,
92
+ confidence_threshold: float,
93
+ output_format: str,
94
+ enable_fuzzy: bool,
95
+ no_cache: bool,
96
+ clear_cache: bool,
97
+ no_license_detection: bool,
98
+ detect_subcomponents: bool,
99
+ use_swh: bool,
100
+ api_token: Optional[str],
101
+ verbose: bool,
102
+ ) -> None:
103
+ """
104
+ Source Package Identifier - Identify package coordinates from source code.
105
+
106
+ Analyzes the given PATH to identify packages using multiple identification strategies
107
+ including SCANOSS fingerprinting, hash search, and optionally Software Heritage archive.
108
+ """
109
+ # Handle cache clearing
110
+ if clear_cache:
111
+ from src2id.core.cache import PersistentCache
112
+ cache = PersistentCache()
113
+ cache.clear()
114
+ stats = cache.get_cache_stats()
115
+ console.print("[green]✓ Cache cleared successfully[/green]")
116
+ console.print(f"[dim]Cache directory: {stats['cache_dir']}[/dim]")
117
+ sys.exit(0)
118
+
119
+ # Require path for normal operation
120
+ if not path:
121
+ console.print("[red]Error: PATH argument is required[/red]")
122
+ sys.exit(1)
123
+
124
+ # Create configuration
125
+ config = SWHPIConfig(
126
+ max_depth=max_depth,
127
+ report_match_threshold=confidence_threshold,
128
+ cache_enabled=not no_cache,
129
+ enable_fuzzy_matching=enable_fuzzy,
130
+ output_format=output_format,
131
+ api_token=api_token or "",
132
+ verbose=verbose,
133
+ use_swh=use_swh,
134
+ )
135
+
136
+ # Always show analysis header (not just in verbose mode)
137
+ console.print(f"[dim]src2purl v{__version__}[/dim]")
138
+ console.print(f"[dim]Analyzing: {path}[/dim]")
139
+ console.print(f"[dim]Max depth: {max_depth}[/dim]")
140
+ console.print(f"[dim]Confidence threshold: {confidence_threshold}[/dim]")
141
+
142
+ # Show strategy configuration
143
+ if use_swh:
144
+ console.print(f"[dim]Strategies: Hash Search, Web Search, SCANOSS, SWH[/dim]")
145
+ if api_token:
146
+ console.print(f"[dim]SWH auth: [green]✓ Using API token[/green][/dim]")
147
+ else:
148
+ console.print(f"[dim]Strategies: Hash Search, Web Search, SCANOSS[/dim]")
149
+
150
+ # Show cache status
151
+ if not no_cache:
152
+ from src2id.core.cache import PersistentCache
153
+ cache = PersistentCache()
154
+ stats = cache.get_cache_stats()
155
+ console.print(f"[dim]Cache: {stats['entries']} entries ({stats['total_size_mb']} MB)[/dim]")
156
+ console.print()
157
+
158
+ try:
159
+ if detect_subcomponents:
160
+ # Use subcomponent detection
161
+ from src2id.core.subcomponent_detector import identify_subcomponents
162
+ results = asyncio.run(identify_subcomponents(
163
+ root_path=path,
164
+ max_depth=max_depth,
165
+ confidence_threshold=confidence_threshold,
166
+ verbose=verbose,
167
+ use_swh=use_swh
168
+ ))
169
+
170
+ # Convert to matches format for output
171
+ matches = []
172
+ if results.get('subcomponents'):
173
+ for comp in results['subcomponents']:
174
+ if comp['identified']:
175
+ from src2id.core.models import PackageMatch, MatchType
176
+ match = PackageMatch(
177
+ name=Path(comp['path']).name,
178
+ version="unknown",
179
+ confidence_score=comp['confidence'],
180
+ match_type=MatchType.EXACT if comp['confidence'] > 0.8 else MatchType.FUZZY,
181
+ download_url=comp['repository'],
182
+ purl=f"pkg:{comp['type']}/{Path(comp['path']).name}",
183
+ license="",
184
+ is_official_org=False
185
+ )
186
+ matches.append(match)
187
+ else:
188
+ # Use the standard identifier with UPMEX integration
189
+ identifier = SHPackageIdentifier(config)
190
+ matches = asyncio.run(identifier.identify_packages(path, enhance_licenses=not no_license_detection))
191
+
192
+ # Output results
193
+ if output_format == "json":
194
+ output_json(matches, config)
195
+ else:
196
+ output_table(matches, config, path)
197
+
198
+ except KeyboardInterrupt:
199
+ console.print("\n[yellow]Interrupted by user[/yellow]")
200
+ sys.exit(1)
201
+ except Exception as e:
202
+ console.print(f"[red]Error: {e}[/red]")
203
+ if verbose:
204
+ console.print_exception()
205
+ sys.exit(1)
206
+
207
+
208
+ def output_json(matches: list[PackageMatch], config: SWHPIConfig) -> None:
209
+ """Output results as JSON."""
210
+ match_list = []
211
+ for match in matches:
212
+ match_list.append({
213
+ "name": match.name,
214
+ "version": match.version,
215
+ "confidence": round(match.confidence_score, 3),
216
+ "type": match.match_type.value if match.match_type else "unknown",
217
+ "url": match.download_url,
218
+ "purl": match.purl,
219
+ "license": match.license,
220
+ "official": match.is_official_org,
221
+ })
222
+
223
+ output = {
224
+ "matches": match_list,
225
+ "count": len(matches),
226
+ "threshold": config.report_match_threshold,
227
+ }
228
+ print(json.dumps(output, indent=2, default=str))
229
+
230
+
231
+ def show_local_source_analysis(path: Path, config: SWHPIConfig) -> None:
232
+ """Show analysis of local source code."""
233
+ console.print("[bold]Local Source Analysis[/bold]")
234
+
235
+ # Detect licenses in local source code
236
+ try:
237
+ from src2id.integrations.oslili import OsliliIntegration
238
+ integration = OsliliIntegration()
239
+
240
+ if integration.available:
241
+ license_info = integration.detect_licenses(path)
242
+
243
+ if license_info["licenses"]:
244
+ license_list = ", ".join(license_info["licenses"][:3])
245
+ if len(license_info["licenses"]) > 3:
246
+ license_list += f" and {len(license_info['licenses']) - 3} more"
247
+ console.print(f"[green]✓[/green] Licenses detected: [yellow]{license_list}[/yellow]")
248
+ console.print(f"[dim] Confidence: {license_info['confidence']:.1%}[/dim]")
249
+ else:
250
+ console.print("[yellow]⚠[/yellow] No licenses detected in source code")
251
+ else:
252
+ console.print("[dim]• License detection unavailable[/dim]")
253
+ except ImportError:
254
+ console.print("[dim]• License detection unavailable[/dim]")
255
+
256
+ # Show directory scan info
257
+ console.print(f"[dim]• Scanned {path} and subdirectories[/dim]")
258
+ console.print()
259
+
260
+
261
+ def output_table(matches: list[PackageMatch], config: SWHPIConfig, path: Path) -> None:
262
+ """Output results as a formatted table."""
263
+
264
+ # Show local source analysis
265
+ show_local_source_analysis(path, config)
266
+
267
+ if not matches:
268
+ console.print("[yellow]No package matches found.[/yellow]")
269
+ return
270
+
271
+ if config.verbose:
272
+ # Use rich table for verbose output
273
+ table = Table(title="Package Matches")
274
+ table.add_column("Name", style="cyan", no_wrap=True)
275
+ table.add_column("Confidence", justify="right", style="green")
276
+ table.add_column("Method", style="yellow")
277
+ table.add_column("PURL", style="blue")
278
+ table.add_column("Source", style="dim")
279
+ table.add_column("URL", style="dim")
280
+
281
+ for match in matches:
282
+ table.add_row(
283
+ match.name or "Unknown",
284
+ f"{match.confidence_score:.2f}",
285
+ match.match_type.value,
286
+ match.purl or "N/A",
287
+ "Repository",
288
+ match.download_url,
289
+ )
290
+
291
+ console.print(table)
292
+ else:
293
+ # Use rich table for clean standard output (better than tabulate)
294
+ table = Table(show_header=True, header_style="bold magenta")
295
+ table.add_column("Name", style="cyan", no_wrap=True)
296
+ table.add_column("Confidence", justify="right", style="green")
297
+ table.add_column("Method", style="yellow")
298
+ table.add_column("PURL", style="blue", max_width=50)
299
+
300
+ for match in matches:
301
+ table.add_row(
302
+ match.name or "Unknown",
303
+ f"{match.confidence_score:.2f}",
304
+ match.match_type.value,
305
+ match.purl or "N/A",
306
+ )
307
+
308
+ console.print(table)
309
+
310
+ # Show result summary for both modes
311
+ if config.verbose:
312
+ console.print(f"\n[green]Found {len(matches)} matches[/green]")
313
+ else:
314
+ console.print(f"\n✓ Found [green]{len(matches)}[/green] package matches")
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
src2id/cli/validate.py ADDED
@@ -0,0 +1,93 @@
1
+ """SWHID validation command-line tool."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from src2id.core.swhid import SWHIDGenerator
10
+
11
+
12
+ @click.command()
13
+ @click.argument("path", type=click.Path(exists=True, path_type=Path))
14
+ @click.option(
15
+ "--expected-swhid",
16
+ help="Expected SWHID to compare against",
17
+ )
18
+ @click.option(
19
+ "--use-fallback",
20
+ is_flag=True,
21
+ help="Use fallback implementation instead of swh.model",
22
+ )
23
+ @click.option(
24
+ "-v", "--verbose",
25
+ is_flag=True,
26
+ help="Verbose output",
27
+ )
28
+ def validate_swhid(
29
+ path: Path,
30
+ expected_swhid: Optional[str],
31
+ use_fallback: bool,
32
+ verbose: bool,
33
+ ) -> None:
34
+ """Validate SWHID generation for a directory or file.
35
+
36
+ This tool generates a SWHID for the given PATH and optionally
37
+ compares it against an expected value.
38
+ """
39
+ generator = SWHIDGenerator(use_swh_model=not use_fallback)
40
+
41
+ # Determine if path is file or directory
42
+ if path.is_file():
43
+ generated_swhid = generator.generate_content_swhid(path)
44
+ path_type = "file"
45
+ else:
46
+ generated_swhid = generator.generate_directory_swhid(path)
47
+ path_type = "directory"
48
+
49
+ # Output generated SWHID
50
+ click.echo(f"Generated SWHID for {path_type} '{path.name}':")
51
+ click.echo(f" {generated_swhid}")
52
+
53
+ if verbose:
54
+ click.echo(f"\nImplementation: {'fallback' if use_fallback else 'swh.model'}")
55
+ click.echo(f"Path: {path.absolute()}")
56
+
57
+ if path.is_dir():
58
+ # Count files
59
+ file_count = sum(1 for _ in path.rglob("*") if _.is_file())
60
+ click.echo(f"Files in directory: {file_count}")
61
+
62
+ # Validate format
63
+ if generator.validate_swhid(generated_swhid):
64
+ click.echo("✓ Valid SWHID format")
65
+ else:
66
+ click.echo("✗ Invalid SWHID format", err=True)
67
+ sys.exit(1)
68
+
69
+ # Compare with expected if provided
70
+ if expected_swhid:
71
+ click.echo(f"\nExpected SWHID:")
72
+ click.echo(f" {expected_swhid}")
73
+
74
+ if generated_swhid == expected_swhid:
75
+ click.echo("✓ SWHIDs match!")
76
+ else:
77
+ click.echo("✗ SWHIDs do not match", err=True)
78
+
79
+ # Show difference
80
+ gen_parts = generated_swhid.split(":")
81
+ exp_parts = expected_swhid.split(":")
82
+
83
+ if len(gen_parts) == 4 and len(exp_parts) == 4:
84
+ if gen_parts[2] != exp_parts[2]:
85
+ click.echo(f" Type mismatch: {gen_parts[2]} vs {exp_parts[2]}")
86
+ if gen_parts[3] != exp_parts[3]:
87
+ click.echo(f" Hash mismatch: {gen_parts[3]} vs {exp_parts[3]}")
88
+
89
+ sys.exit(1)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ validate_swhid()
@@ -0,0 +1 @@
1
+ """Core components of the SH Package Identifier."""
src2id/core/cache.py ADDED
@@ -0,0 +1,180 @@
1
+ """Persistent cache for API responses."""
2
+
3
+ import json
4
+ import hashlib
5
+ from pathlib import Path
6
+ from datetime import datetime, timedelta
7
+ from typing import Any, Dict, Optional
8
+
9
+ from src2id.core.models import SHAPIResponse
10
+
11
+
12
+ class PersistentCache:
13
+ """File-based persistent cache for API responses."""
14
+
15
+ def __init__(self, cache_dir: Optional[Path] = None, ttl_hours: int = 24):
16
+ """
17
+ Initialize persistent cache.
18
+
19
+ Args:
20
+ cache_dir: Directory to store cache files
21
+ ttl_hours: Time-to-live for cache entries in hours
22
+ """
23
+ if cache_dir is None:
24
+ # Use user's cache directory
25
+ cache_dir = Path.home() / '.cache' / 'swhpi'
26
+
27
+ self.cache_dir = Path(cache_dir)
28
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
29
+ self.ttl = timedelta(hours=ttl_hours)
30
+
31
+ # In-memory cache for current session
32
+ self.memory_cache: Dict[str, SHAPIResponse] = {}
33
+
34
+ def get(self, key: str) -> Optional[SHAPIResponse]:
35
+ """
36
+ Get cached response if it exists and is not expired.
37
+
38
+ Args:
39
+ key: Cache key
40
+
41
+ Returns:
42
+ Cached response or None
43
+ """
44
+ # Check memory cache first
45
+ if key in self.memory_cache:
46
+ return self.memory_cache[key]
47
+
48
+ # Check file cache
49
+ cache_file = self._get_cache_file(key)
50
+ if not cache_file.exists():
51
+ return None
52
+
53
+ try:
54
+ with open(cache_file, 'r') as f:
55
+ data = json.load(f)
56
+
57
+ # Check if expired
58
+ cached_time = datetime.fromisoformat(data['timestamp'])
59
+ if datetime.now() - cached_time > self.ttl:
60
+ # Expired, remove file
61
+ cache_file.unlink(missing_ok=True)
62
+ return None
63
+
64
+ # Reconstruct response
65
+ response = SHAPIResponse(
66
+ data=data['data'],
67
+ headers=data.get('headers', {}),
68
+ status=data.get('status', 200),
69
+ cached=True
70
+ )
71
+
72
+ # Store in memory cache for faster access
73
+ self.memory_cache[key] = response
74
+ return response
75
+
76
+ except (json.JSONDecodeError, KeyError, ValueError):
77
+ # Corrupted cache file, remove it
78
+ cache_file.unlink(missing_ok=True)
79
+ return None
80
+
81
+ def set(self, key: str, response: SHAPIResponse) -> None:
82
+ """
83
+ Store response in cache.
84
+
85
+ Args:
86
+ key: Cache key
87
+ response: Response to cache
88
+ """
89
+ # Store in memory cache
90
+ self.memory_cache[key] = response
91
+
92
+ # Store in file cache
93
+ cache_file = self._get_cache_file(key)
94
+
95
+ try:
96
+ cache_data = {
97
+ 'timestamp': datetime.now().isoformat(),
98
+ 'data': response.data,
99
+ 'headers': response.headers,
100
+ 'status': response.status
101
+ }
102
+
103
+ with open(cache_file, 'w') as f:
104
+ json.dump(cache_data, f, indent=2, default=str)
105
+
106
+ except (TypeError, ValueError) as e:
107
+ # Can't serialize, skip caching
108
+ if cache_file.exists():
109
+ cache_file.unlink(missing_ok=True)
110
+
111
+ def clear(self) -> None:
112
+ """Clear all cache entries."""
113
+ # Clear memory cache
114
+ self.memory_cache.clear()
115
+
116
+ # Clear file cache
117
+ for cache_file in self.cache_dir.glob('*.json'):
118
+ cache_file.unlink(missing_ok=True)
119
+
120
+ def clean_expired(self) -> int:
121
+ """
122
+ Remove expired cache entries.
123
+
124
+ Returns:
125
+ Number of entries removed
126
+ """
127
+ removed = 0
128
+
129
+ for cache_file in self.cache_dir.glob('*.json'):
130
+ try:
131
+ with open(cache_file, 'r') as f:
132
+ data = json.load(f)
133
+
134
+ cached_time = datetime.fromisoformat(data['timestamp'])
135
+ if datetime.now() - cached_time > self.ttl:
136
+ cache_file.unlink(missing_ok=True)
137
+ removed += 1
138
+
139
+ except (json.JSONDecodeError, KeyError, ValueError):
140
+ # Corrupted file, remove it
141
+ cache_file.unlink(missing_ok=True)
142
+ removed += 1
143
+
144
+ return removed
145
+
146
+ def _get_cache_file(self, key: str) -> Path:
147
+ """
148
+ Get cache file path for a key.
149
+
150
+ Args:
151
+ key: Cache key
152
+
153
+ Returns:
154
+ Path to cache file
155
+ """
156
+ # Create a safe filename from the key
157
+ key_hash = hashlib.sha256(key.encode()).hexdigest()[:16]
158
+ # Include part of the key for debugging
159
+ safe_key = key.replace('/', '_').replace(':', '_')[:50]
160
+ filename = f"{safe_key}_{key_hash}.json"
161
+
162
+ return self.cache_dir / filename
163
+
164
+ def get_cache_stats(self) -> Dict[str, Any]:
165
+ """
166
+ Get cache statistics.
167
+
168
+ Returns:
169
+ Dictionary with cache stats
170
+ """
171
+ cache_files = list(self.cache_dir.glob('*.json'))
172
+ total_size = sum(f.stat().st_size for f in cache_files)
173
+
174
+ return {
175
+ 'cache_dir': str(self.cache_dir),
176
+ 'entries': len(cache_files),
177
+ 'memory_entries': len(self.memory_cache),
178
+ 'total_size_bytes': total_size,
179
+ 'total_size_mb': round(total_size / (1024 * 1024), 2)
180
+ }