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/__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()
|
src2id/core/__init__.py
ADDED
|
@@ -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
|
+
}
|