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.
@@ -0,0 +1,123 @@
1
+ """Optimized package identifier using multiple strategies."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from src2id.core.config import SWHPIConfig
11
+ from src2id.core.models import PackageMatch, MatchType
12
+ from src2id.search import identify_source
13
+
14
+ console = Console()
15
+
16
+
17
+ class PackageIdentifier:
18
+ """Main package identifier using optimized strategy order."""
19
+
20
+ def __init__(self, config: Optional[SWHPIConfig] = None):
21
+ """Initialize the package identifier with configuration."""
22
+ self.config = config or SWHPIConfig()
23
+
24
+ async def identify_packages(self, path: Path, enhance_licenses: bool = True) -> List[PackageMatch]:
25
+ """
26
+ Identify packages using optimized strategies.
27
+
28
+ Args:
29
+ path: Directory path to analyze
30
+ enhance_licenses: Whether to use oslili for license enhancement
31
+
32
+ Returns:
33
+ List of package matches found
34
+ """
35
+ matches = []
36
+
37
+ try:
38
+ if self.config.verbose:
39
+ console.print("[bold blue]Starting package identification...[/bold blue]")
40
+ console.print("[dim]Using optimized strategy order: Hash Search → Web Search → SCANOSS[/dim]")
41
+ if self.config.use_swh:
42
+ console.print("[dim]Software Heritage checking enabled[/dim]")
43
+
44
+ # Use the optimized identification strategy
45
+ with Progress(
46
+ SpinnerColumn(),
47
+ TextColumn("[progress.description]{task.description}"),
48
+ console=console,
49
+ disable=not self.config.verbose
50
+ ) as progress:
51
+ task = progress.add_task("Identifying source...", total=None)
52
+
53
+ results = await identify_source(
54
+ path=path,
55
+ max_depth=self.config.max_depth,
56
+ confidence_threshold=self.config.report_match_threshold,
57
+ verbose=self.config.verbose,
58
+ use_swh=self.config.use_swh
59
+ )
60
+
61
+ progress.update(task, completed=True)
62
+
63
+ # Convert results to PackageMatch objects
64
+ if results["identified"]:
65
+ # Create a match from the best result
66
+ match = PackageMatch(
67
+ name=path.name,
68
+ version="unknown",
69
+ confidence_score=results["confidence"],
70
+ match_type=MatchType.EXACT if results["confidence"] > 0.8 else MatchType.FUZZY,
71
+ download_url=results["final_origin"],
72
+ purl=f"pkg:generic/{path.name}",
73
+ license="",
74
+ is_official_org=False
75
+ )
76
+
77
+ # Enhance with license detection if requested
78
+ if enhance_licenses:
79
+ try:
80
+ from src2id.integrations.oslili import OsliliIntegration
81
+ integration = OsliliIntegration()
82
+
83
+ if integration.available:
84
+ license_info = integration.detect_licenses(path)
85
+ if license_info["licenses"]:
86
+ match.license = ", ".join(license_info["licenses"][:3])
87
+ except ImportError:
88
+ pass
89
+
90
+ matches.append(match)
91
+
92
+ # Add additional candidates if confidence is lower
93
+ if results["confidence"] < 0.8 and results["candidates"]:
94
+ for candidate in results["candidates"][:2]: # Add top 2 alternatives
95
+ if candidate.get("origin") and candidate["origin"] != results["final_origin"]:
96
+ alt_match = PackageMatch(
97
+ name=path.name,
98
+ version="unknown",
99
+ confidence_score=candidate.get("confidence", 0.5),
100
+ match_type=MatchType.FUZZY,
101
+ download_url=candidate["origin"],
102
+ purl=f"pkg:generic/{path.name}",
103
+ license="",
104
+ is_official_org=False
105
+ )
106
+ matches.append(alt_match)
107
+
108
+ if self.config.verbose:
109
+ if matches:
110
+ console.print(f"[green]✓ Found {len(matches)} package matches[/green]")
111
+ else:
112
+ console.print("[yellow]No package matches found[/yellow]")
113
+
114
+ except Exception as e:
115
+ if self.config.verbose:
116
+ console.print(f"[red]Error during identification: {e}[/red]")
117
+ console.print_exception()
118
+
119
+ return matches
120
+
121
+
122
+ # Create an alias for backward compatibility
123
+ SHPackageIdentifier = PackageIdentifier
src2id/core/purl.py ADDED
@@ -0,0 +1,238 @@
1
+ """Package URL (PURL) generation."""
2
+
3
+ import re
4
+ from typing import Dict, Optional
5
+ from urllib.parse import quote, urlparse
6
+
7
+
8
+ class PURLGenerator:
9
+ """Generates Package URLs following PURL specification."""
10
+
11
+ def generate_purl(self, coordinates: Dict[str, str], confidence: float) -> Optional[str]:
12
+ """
13
+ Generate PURL only for high-confidence matches.
14
+
15
+ Args:
16
+ coordinates: Package coordinates (name, version, download_url)
17
+ confidence: Confidence score
18
+
19
+ Returns:
20
+ PURL string or None if confidence too low or invalid URL
21
+ """
22
+ # Only generate PURL for high confidence matches
23
+ if confidence < 0.85: # This threshold could be configurable
24
+ return None
25
+
26
+ download_url = coordinates.get('download_url', '')
27
+ name = coordinates.get('name', '')
28
+ version = coordinates.get('version', '')
29
+
30
+ if not download_url or not name:
31
+ return None
32
+
33
+ # Validate download URL
34
+ if not self._validate_download_url(download_url):
35
+ return None
36
+
37
+ # Generate PURL based on repository type using proper URL parsing
38
+ try:
39
+ parsed_url = urlparse(download_url)
40
+ hostname = parsed_url.hostname.lower() if parsed_url.hostname else ''
41
+
42
+ if hostname == 'github.com':
43
+ return self._generate_github_purl(download_url, name, version)
44
+ elif hostname == 'gitlab.com':
45
+ return self._generate_gitlab_purl(download_url, name, version)
46
+ elif hostname == 'pypi.org' or hostname.endswith('.pypi.org'):
47
+ return self._generate_pypi_purl(name, version)
48
+ elif hostname in ('npmjs.org', 'npmjs.com', 'registry.npmjs.org'):
49
+ return self._generate_npm_purl(name, version)
50
+ elif hostname == 'crates.io':
51
+ return self._generate_cargo_purl(name, version)
52
+ elif hostname == 'rubygems.org':
53
+ return self._generate_gem_purl(name, version)
54
+ elif hostname == 'packagist.org':
55
+ return self._generate_composer_purl(name, version)
56
+ elif hostname == 'nuget.org':
57
+ return self._generate_nuget_purl(name, version)
58
+ elif hostname == 'sourceforge.net' or hostname.endswith('.sourceforge.net'):
59
+ return self._generate_generic_purl(download_url, name, version)
60
+ else:
61
+ # For unknown sources, use generic PURL if possible
62
+ return self._generate_generic_purl(download_url, name, version)
63
+ except Exception:
64
+ return self._generate_generic_purl(download_url, name, version)
65
+
66
+ def _validate_download_url(self, url: str) -> bool:
67
+ """
68
+ Validate that URL follows expected package patterns.
69
+
70
+ Args:
71
+ url: Download URL
72
+
73
+ Returns:
74
+ True if valid URL
75
+ """
76
+ if not url:
77
+ return False
78
+
79
+ # Basic URL validation
80
+ try:
81
+ parsed = urlparse(url)
82
+ if not parsed.scheme or not parsed.netloc:
83
+ return False
84
+ except Exception:
85
+ return False
86
+
87
+ # Check for common invalid patterns
88
+ invalid_patterns = [
89
+ r'/pull/\d+', # Pull requests
90
+ r'/issues/\d+', # Issues
91
+ r'/wiki/', # Wiki pages
92
+ r'/blob/', # File browsing (not repo root)
93
+ ]
94
+
95
+ for pattern in invalid_patterns:
96
+ if re.search(pattern, url):
97
+ return False
98
+
99
+ return True
100
+
101
+ def _generate_github_purl(self, url: str, name: str, version: Optional[str]) -> Optional[str]:
102
+ """Generate GitHub PURL."""
103
+ org_repo = self._extract_github_org_repo(url)
104
+ if not org_repo:
105
+ return None
106
+
107
+ if version:
108
+ return f"pkg:github/{org_repo}@{self._encode_version(version)}"
109
+ else:
110
+ return f"pkg:github/{org_repo}"
111
+
112
+ def _generate_gitlab_purl(self, url: str, name: str, version: Optional[str]) -> Optional[str]:
113
+ """Generate GitLab PURL."""
114
+ org_repo = self._extract_gitlab_org_repo(url)
115
+ if not org_repo:
116
+ return None
117
+
118
+ if version:
119
+ return f"pkg:gitlab/{org_repo}@{self._encode_version(version)}"
120
+ else:
121
+ return f"pkg:gitlab/{org_repo}"
122
+
123
+ def _generate_pypi_purl(self, name: str, version: Optional[str]) -> str:
124
+ """Generate PyPI PURL."""
125
+ # PyPI package names should be lowercase with hyphens
126
+ name = name.lower().replace('_', '-')
127
+
128
+ if version:
129
+ return f"pkg:pypi/{name}@{self._encode_version(version)}"
130
+ else:
131
+ return f"pkg:pypi/{name}"
132
+
133
+ def _generate_npm_purl(self, name: str, version: Optional[str]) -> str:
134
+ """Generate npm PURL."""
135
+ # Handle scoped packages
136
+ if name.startswith('@'):
137
+ # Scoped package: @scope/name
138
+ parts = name[1:].split('/', 1)
139
+ if len(parts) == 2:
140
+ namespace = parts[0]
141
+ pkg_name = parts[1]
142
+ if version:
143
+ return f"pkg:npm/{namespace}/{pkg_name}@{self._encode_version(version)}"
144
+ else:
145
+ return f"pkg:npm/{namespace}/{pkg_name}"
146
+
147
+ if version:
148
+ return f"pkg:npm/{name}@{self._encode_version(version)}"
149
+ else:
150
+ return f"pkg:npm/{name}"
151
+
152
+ def _generate_cargo_purl(self, name: str, version: Optional[str]) -> str:
153
+ """Generate Cargo (Rust) PURL."""
154
+ if version:
155
+ return f"pkg:cargo/{name}@{self._encode_version(version)}"
156
+ else:
157
+ return f"pkg:cargo/{name}"
158
+
159
+ def _generate_gem_purl(self, name: str, version: Optional[str]) -> str:
160
+ """Generate RubyGems PURL."""
161
+ if version:
162
+ return f"pkg:gem/{name}@{self._encode_version(version)}"
163
+ else:
164
+ return f"pkg:gem/{name}"
165
+
166
+ def _generate_composer_purl(self, name: str, version: Optional[str]) -> str:
167
+ """Generate Composer (PHP) PURL."""
168
+ # Composer packages are vendor/package format
169
+ if '/' in name:
170
+ if version:
171
+ return f"pkg:composer/{name}@{self._encode_version(version)}"
172
+ else:
173
+ return f"pkg:composer/{name}"
174
+ return None
175
+
176
+ def _generate_nuget_purl(self, name: str, version: Optional[str]) -> str:
177
+ """Generate NuGet (.NET) PURL."""
178
+ if version:
179
+ return f"pkg:nuget/{name}@{self._encode_version(version)}"
180
+ else:
181
+ return f"pkg:nuget/{name}"
182
+
183
+ def _generate_generic_purl(
184
+ self, url: str, name: str, version: Optional[str]
185
+ ) -> Optional[str]:
186
+ """
187
+ Generate generic PURL for unknown package types.
188
+
189
+ Args:
190
+ url: Download URL
191
+ name: Package name
192
+ version: Package version
193
+
194
+ Returns:
195
+ Generic PURL or None
196
+ """
197
+ # For now, we don't generate generic PURLs for unknown sources
198
+ # This could be extended in the future
199
+ return None
200
+
201
+ def _extract_github_org_repo(self, url: str) -> Optional[str]:
202
+ """Extract org/repo from GitHub URL."""
203
+ match = re.search(r'github\.com[:/]([^/]+)/([^/\s]+)', url)
204
+ if match:
205
+ org = match.group(1)
206
+ repo = match.group(2).rstrip('.git')
207
+ return f"{org}/{repo}"
208
+ return None
209
+
210
+ def _extract_gitlab_org_repo(self, url: str) -> Optional[str]:
211
+ """Extract org/repo from GitLab URL."""
212
+ match = re.search(r'gitlab\.com[:/]([^/]+)/([^/\s?#]+)', url)
213
+ if match:
214
+ org = match.group(1)
215
+ repo = match.group(2)
216
+ if repo.endswith('.git'):
217
+ repo = repo[:-4]
218
+ if repo.endswith('/'):
219
+ repo = repo[:-1]
220
+ return f"{org}/{repo}"
221
+ return None
222
+
223
+ def _encode_version(self, version: str) -> str:
224
+ """
225
+ Encode version string for PURL.
226
+
227
+ Args:
228
+ version: Version string
229
+
230
+ Returns:
231
+ Encoded version
232
+ """
233
+ # Remove common version prefixes
234
+ version = version.lstrip('v')
235
+ version = version.lstrip('r')
236
+
237
+ # URL encode special characters
238
+ return quote(version, safe='.-_')