github-license-scanner 0.1.0__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,621 @@
1
+ """
2
+ Dependency manifest parsers for multiple language ecosystems.
3
+
4
+ Each parser receives file path + text content and returns a list of Dependency
5
+ objects. The public entry point is parse_dependencies(path, content).
6
+
7
+ Uses the packaging library for robust Python requirement parsing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import tomllib
15
+ from typing import Callable
16
+
17
+ from packaging.requirements import InvalidRequirement, Requirement
18
+
19
+ from .models import Dependency
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Public API
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def parse_dependencies(path: str, content: str) -> list[Dependency]:
26
+ """
27
+ Parse a dependency file and return declared packages.
28
+
29
+ The parser is chosen from the file basename. Unknown files yield [].
30
+ """
31
+ basename = path.replace("\\", "/").rsplit("/", 1)[-1]
32
+ lower = basename.lower()
33
+
34
+ if lower == "package.json":
35
+ return _parse_package_json(path, content)
36
+ if lower == "pyproject.toml":
37
+ return _parse_pyproject_toml(path, content)
38
+ if lower == "cargo.toml":
39
+ return _parse_cargo_toml(path, content)
40
+ if lower == "go.mod":
41
+ return _parse_go_mod(path, content)
42
+ if lower == "gemfile":
43
+ return _parse_gemfile(path, content)
44
+ if lower == "composer.json":
45
+ return _parse_composer_json(path, content)
46
+ if lower == "pom.xml":
47
+ return _parse_pom_xml(path, content)
48
+ if lower in {"build.gradle", "build.gradle.kts"}:
49
+ return _parse_gradle(path, content)
50
+ if lower == "pipfile":
51
+ return _parse_pipfile(path, content)
52
+ if lower == "setup.py":
53
+ return _parse_setup_py(path, content)
54
+ if re.match(r"^requirements(-[\w.-]+)?\.txt$", lower):
55
+ return _parse_requirements_txt(path, content)
56
+
57
+ return []
58
+
59
+
60
+ def parse_many(files: dict[str, str]) -> list[Dependency]:
61
+ """
62
+ Parse multiple path -> content mappings and deduplicate by (ecosystem, name).
63
+
64
+ Prefer production declarations over dev-only: if a package appears as both,
65
+ keep the non-dev entry.
66
+ """
67
+ by_key: dict[tuple[str, str], Dependency] = {}
68
+ for path, content in files.items():
69
+ for dep in parse_dependencies(path, content):
70
+ key = (dep.ecosystem, dep.name.lower())
71
+ existing = by_key.get(key)
72
+ if existing is None:
73
+ by_key[key] = dep
74
+ continue
75
+ # Upgrade dev → prod if we later see a runtime declaration
76
+ if existing.is_dev and not dep.is_dev:
77
+ by_key[key] = dep
78
+ return list(by_key.values())
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # npm / package.json
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _parse_package_json(path: str, content: str) -> list[Dependency]:
86
+ """Extract dependencies and devDependencies from package.json."""
87
+ try:
88
+ data = json.loads(content)
89
+ except json.JSONDecodeError:
90
+ return []
91
+
92
+ deps: list[Dependency] = []
93
+ for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
94
+ block = data.get(section) or {}
95
+ if not isinstance(block, dict):
96
+ continue
97
+ is_dev = section == "devDependencies"
98
+ for name, version in block.items():
99
+ if not isinstance(name, str):
100
+ continue
101
+ version_spec = str(version) if version is not None else None
102
+ # Skip workspace protocol / file: local refs for registry lookup
103
+ if version_spec and (
104
+ version_spec.startswith("workspace:")
105
+ or version_spec.startswith("file:")
106
+ or version_spec.startswith("link:")
107
+ ):
108
+ continue
109
+ deps.append(
110
+ Dependency(
111
+ name=name,
112
+ version_spec=version_spec,
113
+ ecosystem="npm",
114
+ source_file=path,
115
+ is_dev=is_dev,
116
+ )
117
+ )
118
+ return deps
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Python — requirements.txt / pyproject.toml / Pipfile / setup.py
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def _parse_requirement_line(line: str) -> tuple[str, str | None] | None:
126
+ """
127
+ Parse a single requirements.txt-style line into (name, version_spec).
128
+
129
+ Returns None for comments, blank lines, flags, and editable/VCS installs.
130
+ """
131
+ # Strip inline comments
132
+ if "#" in line:
133
+ line = line[: line.index("#")]
134
+ line = line.strip()
135
+ if not line:
136
+ return None
137
+ # pip options: -r, -e, --index-url, etc.
138
+ if line.startswith("-"):
139
+ return None
140
+ # VCS / URL installs
141
+ if re.match(r"^(git\+|hg\+|svn\+|bzr\+|https?://|svn\+)", line, re.I):
142
+ return None
143
+ if line.startswith("."):
144
+ return None
145
+
146
+ # Environment markers: package>=1; python_version>="3.10"
147
+ req_part = line.split(";", 1)[0].strip()
148
+ # Extras: package[extra]>=1.0
149
+ try:
150
+ req = Requirement(req_part)
151
+ name = req.name
152
+ version_spec = str(req.specifier) if req.specifier else None
153
+ return name, version_spec or None
154
+ except InvalidRequirement:
155
+ # Fallback: crude name extraction
156
+ m = re.match(r"^([A-Za-z0-9_.-]+)", req_part)
157
+ if m:
158
+ return m.group(1), None
159
+ return None
160
+
161
+
162
+ def _is_dev_requirements_path(path: str) -> bool:
163
+ """Heuristic: requirements-dev.txt, requirements/test.txt, etc."""
164
+ lower = path.replace("\\", "/").lower()
165
+ base = lower.rsplit("/", 1)[-1]
166
+ return bool(
167
+ re.search(r"(^|[_\-/])(dev|test|tests|lint|docs|doc|ci|typing)([_\-./]|$)", lower)
168
+ or re.search(r"requirements[-_.]?(dev|test|docs)", base)
169
+ )
170
+
171
+
172
+ def _parse_requirements_txt(path: str, content: str) -> list[Dependency]:
173
+ """Parse classic pip requirements files."""
174
+ is_dev = _is_dev_requirements_path(path)
175
+ deps: list[Dependency] = []
176
+ for raw_line in content.splitlines():
177
+ parsed = _parse_requirement_line(raw_line)
178
+ if not parsed:
179
+ continue
180
+ name, version_spec = parsed
181
+ deps.append(
182
+ Dependency(
183
+ name=name,
184
+ version_spec=version_spec,
185
+ ecosystem="pypi",
186
+ source_file=path,
187
+ is_dev=is_dev,
188
+ )
189
+ )
190
+ return deps
191
+
192
+
193
+ def _parse_pyproject_toml(path: str, content: str) -> list[Dependency]:
194
+ """
195
+ Parse PEP 621, Poetry, and PDM dependency sections from pyproject.toml.
196
+ """
197
+ try:
198
+ data = tomllib.loads(content)
199
+ except Exception: # noqa: BLE001 — invalid TOML
200
+ return []
201
+
202
+ deps: list[Dependency] = []
203
+ seen: dict[str, bool] = {} # name -> is_dev (prefer prod)
204
+
205
+ def add_req_string(req_str: str, *, is_dev: bool = False) -> None:
206
+ parsed = _parse_requirement_line(req_str)
207
+ if not parsed:
208
+ return
209
+ name, version_spec = parsed
210
+ key = name.lower()
211
+ if key in seen:
212
+ if seen[key] and not is_dev:
213
+ # upgrade later by removing old and re-adding
214
+ deps[:] = [d for d in deps if d.name.lower() != key]
215
+ else:
216
+ return
217
+ seen[key] = is_dev
218
+ deps.append(
219
+ Dependency(
220
+ name=name,
221
+ version_spec=version_spec,
222
+ ecosystem="pypi",
223
+ source_file=path,
224
+ is_dev=is_dev,
225
+ )
226
+ )
227
+
228
+ # PEP 621: [project] dependencies
229
+ project = data.get("project") or {}
230
+ for item in project.get("dependencies") or []:
231
+ if isinstance(item, str):
232
+ add_req_string(item, is_dev=False)
233
+
234
+ # optional-dependencies: treat as dev/extra (not default runtime ship)
235
+ optional = project.get("optional-dependencies") or {}
236
+ if isinstance(optional, dict):
237
+ for group_name, group_reqs in optional.items():
238
+ g_dev = str(group_name).lower() in {
239
+ "dev", "test", "tests", "lint", "docs", "doc", "typing", "ci"
240
+ } or True # extras are optional → mark as dev for closed-sale scoring
241
+ if isinstance(group_reqs, list):
242
+ for item in group_reqs:
243
+ if isinstance(item, str):
244
+ add_req_string(item, is_dev=g_dev)
245
+
246
+ # Poetry: [tool.poetry.dependencies] / group.dev
247
+ poetry = (data.get("tool") or {}).get("poetry") or {}
248
+ for section_name in ("dependencies", "dev-dependencies"):
249
+ block = poetry.get(section_name) or {}
250
+ if not isinstance(block, dict):
251
+ continue
252
+ is_dev = section_name == "dev-dependencies"
253
+ for name, spec in block.items():
254
+ if name.lower() == "python":
255
+ continue
256
+ key = name.lower()
257
+ if key in seen and not (seen[key] and not is_dev):
258
+ if seen[key] and not is_dev:
259
+ deps[:] = [d for d in deps if d.name.lower() != key]
260
+ else:
261
+ continue
262
+ seen[key] = is_dev
263
+ version_spec = None
264
+ if isinstance(spec, str):
265
+ version_spec = spec
266
+ elif isinstance(spec, dict):
267
+ version_spec = str(spec.get("version") or "")
268
+ deps.append(
269
+ Dependency(
270
+ name=name,
271
+ version_spec=version_spec,
272
+ ecosystem="pypi",
273
+ source_file=path,
274
+ is_dev=is_dev,
275
+ )
276
+ )
277
+
278
+ # Poetry 1.2+ groups (usually dev/test)
279
+ groups = poetry.get("group") or {}
280
+ if isinstance(groups, dict):
281
+ for group_name, group in groups.items():
282
+ if not isinstance(group, dict):
283
+ continue
284
+ gdeps = group.get("dependencies") or {}
285
+ if not isinstance(gdeps, dict):
286
+ continue
287
+ is_dev = str(group_name).lower() != "main"
288
+ for name, spec in gdeps.items():
289
+ key = name.lower()
290
+ if key in seen:
291
+ if seen[key] and not is_dev:
292
+ deps[:] = [d for d in deps if d.name.lower() != key]
293
+ else:
294
+ continue
295
+ seen[key] = is_dev
296
+ version_spec = spec if isinstance(spec, str) else None
297
+ deps.append(
298
+ Dependency(
299
+ name=name,
300
+ version_spec=version_spec if isinstance(version_spec, str) else None,
301
+ ecosystem="pypi",
302
+ source_file=path,
303
+ is_dev=is_dev,
304
+ )
305
+ )
306
+
307
+ return deps
308
+
309
+
310
+ def _parse_pipfile(path: str, content: str) -> list[Dependency]:
311
+ """Parse Pipenv Pipfile [packages] and [dev-packages]."""
312
+ try:
313
+ data = tomllib.loads(content)
314
+ except Exception: # noqa: BLE001
315
+ return []
316
+
317
+ deps: list[Dependency] = []
318
+ for section in ("packages", "dev-packages"):
319
+ block = data.get(section) or {}
320
+ if not isinstance(block, dict):
321
+ continue
322
+ is_dev = section == "dev-packages"
323
+ for name, spec in block.items():
324
+ version_spec = spec if isinstance(spec, str) else None
325
+ deps.append(
326
+ Dependency(
327
+ name=name,
328
+ version_spec=version_spec,
329
+ ecosystem="pypi",
330
+ source_file=path,
331
+ is_dev=is_dev,
332
+ )
333
+ )
334
+ return deps
335
+
336
+
337
+ def _parse_setup_py(path: str, content: str) -> list[Dependency]:
338
+ """
339
+ Best-effort extraction of install_requires from setup.py.
340
+
341
+ Only handles simple list literals; dynamic setup() calls are skipped.
342
+ """
343
+ deps: list[Dependency] = []
344
+ # install_requires=["a", "b>=1"]
345
+ match = re.search(
346
+ r"install_requires\s*=\s*\[(.*?)\]",
347
+ content,
348
+ re.DOTALL | re.IGNORECASE,
349
+ )
350
+ if not match:
351
+ return deps
352
+ body = match.group(1)
353
+ for item in re.findall(r"['\"]([^'\"]+)['\"]", body):
354
+ parsed = _parse_requirement_line(item)
355
+ if parsed:
356
+ name, version_spec = parsed
357
+ deps.append(
358
+ Dependency(
359
+ name=name,
360
+ version_spec=version_spec,
361
+ ecosystem="pypi",
362
+ source_file=path,
363
+ )
364
+ )
365
+ return deps
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Rust — Cargo.toml
370
+ # ---------------------------------------------------------------------------
371
+
372
+ def _parse_cargo_toml(path: str, content: str) -> list[Dependency]:
373
+ """Parse [dependencies], [dev-dependencies], [build-dependencies]."""
374
+ try:
375
+ data = tomllib.loads(content)
376
+ except Exception: # noqa: BLE001
377
+ return []
378
+
379
+ deps: list[Dependency] = []
380
+ seen: dict[str, bool] = {}
381
+ for section in ("dependencies", "dev-dependencies", "build-dependencies"):
382
+ block = data.get(section) or {}
383
+ if not isinstance(block, dict):
384
+ continue
385
+ is_dev = section in {"dev-dependencies", "build-dependencies"}
386
+ for name, spec in block.items():
387
+ key = name.lower()
388
+ if key in seen:
389
+ if seen[key] and not is_dev:
390
+ deps[:] = [d for d in deps if d.name.lower() != key]
391
+ else:
392
+ continue
393
+ seen[key] = is_dev
394
+ version_spec = None
395
+ if isinstance(spec, str):
396
+ version_spec = spec
397
+ elif isinstance(spec, dict):
398
+ # Skip path/git only crates (not on crates.io)
399
+ if spec.get("path") or spec.get("git"):
400
+ if not spec.get("version"):
401
+ continue
402
+ version_spec = str(spec.get("version") or "") or None
403
+ deps.append(
404
+ Dependency(
405
+ name=name,
406
+ version_spec=version_spec,
407
+ ecosystem="cargo",
408
+ source_file=path,
409
+ is_dev=is_dev,
410
+ )
411
+ )
412
+ return deps
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Go — go.mod
417
+ # ---------------------------------------------------------------------------
418
+
419
+ def _parse_go_mod(path: str, content: str) -> list[Dependency]:
420
+ """
421
+ Parse require directives from go.mod.
422
+
423
+ Example lines:
424
+ require github.com/foo/bar v1.2.3
425
+ require (
426
+ github.com/a/b v0.1.0
427
+ github.com/c/d v2.0.0 // indirect
428
+ )
429
+ """
430
+ deps: list[Dependency] = []
431
+ in_block = False
432
+ for raw in content.splitlines():
433
+ line = raw.split("//", 1)[0].strip()
434
+ if not line:
435
+ continue
436
+ if line.startswith("require ("):
437
+ in_block = True
438
+ continue
439
+ if in_block:
440
+ if line == ")":
441
+ in_block = False
442
+ continue
443
+ parts = line.split()
444
+ if len(parts) >= 1:
445
+ name = parts[0]
446
+ version = parts[1] if len(parts) > 1 else None
447
+ deps.append(
448
+ Dependency(
449
+ name=name,
450
+ version_spec=version,
451
+ ecosystem="go",
452
+ source_file=path,
453
+ )
454
+ )
455
+ continue
456
+ if line.startswith("require "):
457
+ rest = line[len("require ") :].strip()
458
+ if rest.startswith("("):
459
+ in_block = True
460
+ continue
461
+ parts = rest.split()
462
+ if parts:
463
+ deps.append(
464
+ Dependency(
465
+ name=parts[0],
466
+ version_spec=parts[1] if len(parts) > 1 else None,
467
+ ecosystem="go",
468
+ source_file=path,
469
+ )
470
+ )
471
+ return deps
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Ruby — Gemfile
476
+ # ---------------------------------------------------------------------------
477
+
478
+ def _parse_gemfile(path: str, content: str) -> list[Dependency]:
479
+ """Best-effort: gem 'name', gem \"name\", version."""
480
+ deps: list[Dependency] = []
481
+ pattern = re.compile(
482
+ r"""gem\s+['\"]([^'\"]+)['\"](?:\s*,\s*['\"]([^'\"]+)['\"])?""",
483
+ re.IGNORECASE,
484
+ )
485
+ for match in pattern.finditer(content):
486
+ name = match.group(1)
487
+ version = match.group(2)
488
+ deps.append(
489
+ Dependency(
490
+ name=name,
491
+ version_spec=version,
492
+ ecosystem="rubygems",
493
+ source_file=path,
494
+ )
495
+ )
496
+ return deps
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # PHP — composer.json
501
+ # ---------------------------------------------------------------------------
502
+
503
+ def _parse_composer_json(path: str, content: str) -> list[Dependency]:
504
+ """Parse require and require-dev from composer.json."""
505
+ try:
506
+ data = json.loads(content)
507
+ except json.JSONDecodeError:
508
+ return []
509
+
510
+ deps: list[Dependency] = []
511
+ for section in ("require", "require-dev"):
512
+ block = data.get(section) or {}
513
+ if not isinstance(block, dict):
514
+ continue
515
+ is_dev = section == "require-dev"
516
+ for name, version in block.items():
517
+ # Skip php runtime and extensions
518
+ if name == "php" or name.startswith("ext-"):
519
+ continue
520
+ deps.append(
521
+ Dependency(
522
+ name=name,
523
+ version_spec=str(version) if version is not None else None,
524
+ ecosystem="composer",
525
+ source_file=path,
526
+ is_dev=is_dev,
527
+ )
528
+ )
529
+ return deps
530
+
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # Java — pom.xml / Gradle (best-effort)
534
+ # ---------------------------------------------------------------------------
535
+
536
+ def _parse_pom_xml(path: str, content: str) -> list[Dependency]:
537
+ """Extract groupId:artifactId pairs from a Maven pom.xml (simple regex)."""
538
+ deps: list[Dependency] = []
539
+ # Match dependency blocks with groupId + artifactId
540
+ pattern = re.compile(
541
+ r"<dependency>\s*"
542
+ r"<groupId>([^<]+)</groupId>\s*"
543
+ r"<artifactId>([^<]+)</artifactId>"
544
+ r"(?:\s*<version>([^<]+)</version>)?",
545
+ re.DOTALL | re.IGNORECASE,
546
+ )
547
+ for match in pattern.finditer(content):
548
+ group_id = match.group(1).strip()
549
+ artifact_id = match.group(2).strip()
550
+ version = match.group(3).strip() if match.group(3) else None
551
+ # Skip property placeholders only versions without names
552
+ if "${" in group_id or "${" in artifact_id:
553
+ continue
554
+ name = f"{group_id}:{artifact_id}"
555
+ deps.append(
556
+ Dependency(
557
+ name=name,
558
+ version_spec=version,
559
+ ecosystem="maven",
560
+ source_file=path,
561
+ )
562
+ )
563
+ return deps
564
+
565
+
566
+ def _parse_gradle(path: str, content: str) -> list[Dependency]:
567
+ """
568
+ Best-effort Gradle dependency extraction.
569
+
570
+ Matches lines like:
571
+ implementation 'com.google.guava:guava:31.0'
572
+ implementation("org.slf4j:slf4j-api:2.0.0")
573
+
574
+ test*/compileOnly configurations are marked is_dev=True so they do not
575
+ auto-force open-source on the production sellability heuristic.
576
+ """
577
+ deps: list[Dependency] = []
578
+ pattern = re.compile(
579
+ r"""(?P<config>implementation|api|compileOnly|runtimeOnly|testImplementation|"""
580
+ r"""testCompileOnly|testRuntimeOnly|compile|testCompile|annotationProcessor)"""
581
+ r"""\s*[\(]?\s*['\"]([^:'\"]+):([^:'\"]+)(?::([^'\"]+))?['\"]""",
582
+ re.IGNORECASE,
583
+ )
584
+ dev_configs = {
585
+ "compileonly",
586
+ "testimplementation",
587
+ "testcompileonly",
588
+ "testruntimeonly",
589
+ "testcompile",
590
+ "annotationprocessor",
591
+ }
592
+ for match in pattern.finditer(content):
593
+ config = (match.group("config") or "").lower()
594
+ group_id, artifact_id, version = match.group(2), match.group(3), match.group(4)
595
+ name = f"{group_id}:{artifact_id}"
596
+ deps.append(
597
+ Dependency(
598
+ name=name,
599
+ version_spec=version,
600
+ ecosystem="gradle",
601
+ source_file=path,
602
+ is_dev=config in dev_configs,
603
+ )
604
+ )
605
+ return deps
606
+
607
+
608
+ # Map ecosystem -> human label (for UI)
609
+ ECOSYSTEM_LABELS: dict[str, str] = {
610
+ "npm": "npm (Node.js)",
611
+ "pypi": "PyPI (Python)",
612
+ "cargo": "crates.io (Rust)",
613
+ "go": "Go modules",
614
+ "rubygems": "RubyGems",
615
+ "composer": "Packagist (PHP)",
616
+ "maven": "Maven",
617
+ "gradle": "Gradle",
618
+ }
619
+
620
+ # Type alias for custom parsers (extension point)
621
+ ParserFn = Callable[[str, str], list[Dependency]]