ghostpkg 0.1.0__tar.gz → 0.2.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 (27) hide show
  1. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/CHANGELOG.md +27 -1
  2. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/PKG-INFO +13 -6
  3. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/README.en.md +12 -5
  4. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/README.md +6 -2
  5. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/__init__.py +1 -1
  6. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/cli.py +12 -34
  7. ghostpkg-0.2.0/ghostpkg/manifests.py +193 -0
  8. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/pyproject.toml +1 -1
  9. ghostpkg-0.2.0/tests/test_manifests.py +187 -0
  10. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  11. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  13. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  14. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.gitignore +0 -0
  16. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/CONTRIBUTING.md +0 -0
  17. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/LICENSE +0 -0
  18. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/SECURITY.md +0 -0
  19. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/banner.html +0 -0
  20. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/banner.png +0 -0
  21. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/demo.gif +0 -0
  22. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/make_demo.py +0 -0
  23. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/__main__.py +0 -0
  24. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/assess.py +0 -0
  25. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/data.py +0 -0
  26. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/registries.py +0 -0
  27. {ghostpkg-0.1.0 → ghostpkg-0.2.0}/tests/test_assess.py +0 -0
@@ -6,6 +6,31 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-09-01
10
+
11
+ ### Fixed
12
+ - **`scan` reported nonsense for `pyproject.toml`.** Every file that was not
13
+ `package.json` was handed to the requirements.txt parser, so TOML keys were
14
+ read as package names: `build-backend` came back "does not exist on pypi",
15
+ and `version` came back "ok" because a package of that name exists. For a
16
+ security tool, confidently reporting nonsense is the worst failure mode.
17
+
18
+ ### Added
19
+ - `pyproject.toml` support in `scan`: PEP 621 `project.dependencies` and
20
+ `project.optional-dependencies`, plus Poetry's `tool.poetry` groups.
21
+ `[build-system] requires` is excluded, and Poetry's `python` constraint is
22
+ not treated as a package.
23
+ - Manifests are now detected explicitly. An unrecognised file is **refused with
24
+ an error rather than guessed at** — guessing was the root cause of the bug
25
+ above.
26
+ - A narrow TOML fallback parser for Python 3.9 and 3.10, which have no
27
+ `tomllib`. Adding a TOML dependency would contradict the zero-dependency rule,
28
+ and the fallback is tested by forcing the import to fail rather than by
29
+ trusting the interpreter the suite runs on.
30
+
31
+ ### Changed
32
+ - `--version` now reads `__version__` instead of a hardcoded string.
33
+
9
34
  ## [0.1.0] - 2026-09-01
10
35
 
11
36
  First release.
@@ -35,5 +60,6 @@ First release.
35
60
  - No corpus of hallucinated package names is shipped, following the decision of
36
61
  the USENIX'25 authors not to publish theirs.
37
62
 
38
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.1.0...HEAD
63
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.2.0...HEAD
64
+ [0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
39
65
  [0.1.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -59,8 +59,8 @@ Language models invent library names. Attackers register those names in advance.
59
59
  **English** · [Українська](https://github.com/M1rwana12/ghostpkg/blob/main/README.md)
60
60
 
61
61
  [![CI](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
62
- [![PyPI](https://img.shields.io/pypi/v/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
63
- [![Python](https://img.shields.io/pypi/pyversions/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
62
+ [![PyPI](https://img.shields.io/pypi/v/ghostpkg?color=A78BFA&label=pypi)](https://pypi.org/project/ghostpkg/)
63
+ [![Python](https://img.shields.io/pypi/pyversions/ghostpkg?color=A78BFA&label=python)](https://pypi.org/project/ghostpkg/)
64
64
  [![Dependencies](https://img.shields.io/badge/dependencies-0-3FB950.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
65
65
  [![License](https://img.shields.io/badge/license-MIT-8B949E.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/LICENSE)
66
66
 
@@ -158,12 +158,19 @@ ghostpkg check react-router-dom-utils -e npm
158
158
 
159
159
  ```bash
160
160
  ghostpkg scan requirements.txt
161
+ ghostpkg scan pyproject.toml
161
162
  ghostpkg scan package.json
162
163
  ```
163
164
 
164
- `requirements.txt` parsing skips comments, flags, VCS URLs and direct links, and
165
- takes the bare project name off each requirement line. `package.json` reads
166
- `dependencies`, `devDependencies` and `optionalDependencies`.
165
+ | Manifest | What is read |
166
+ |---|---|
167
+ | `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
168
+ | `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
169
+ | `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`. |
170
+
171
+ Anything else is **refused with an error rather than guessed at**. Guessing is
172
+ what made an earlier version read `pyproject.toml` with the requirements parser
173
+ and report TOML keys as package names.
167
174
 
168
175
  ### Options
169
176
 
@@ -12,8 +12,8 @@ Language models invent library names. Attackers register those names in advance.
12
12
  **English** · [Українська](https://github.com/M1rwana12/ghostpkg/blob/main/README.md)
13
13
 
14
14
  [![CI](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
15
- [![PyPI](https://img.shields.io/pypi/v/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
16
- [![Python](https://img.shields.io/pypi/pyversions/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
15
+ [![PyPI](https://img.shields.io/pypi/v/ghostpkg?color=A78BFA&label=pypi)](https://pypi.org/project/ghostpkg/)
16
+ [![Python](https://img.shields.io/pypi/pyversions/ghostpkg?color=A78BFA&label=python)](https://pypi.org/project/ghostpkg/)
17
17
  [![Dependencies](https://img.shields.io/badge/dependencies-0-3FB950.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
18
18
  [![License](https://img.shields.io/badge/license-MIT-8B949E.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/LICENSE)
19
19
 
@@ -111,12 +111,19 @@ ghostpkg check react-router-dom-utils -e npm
111
111
 
112
112
  ```bash
113
113
  ghostpkg scan requirements.txt
114
+ ghostpkg scan pyproject.toml
114
115
  ghostpkg scan package.json
115
116
  ```
116
117
 
117
- `requirements.txt` parsing skips comments, flags, VCS URLs and direct links, and
118
- takes the bare project name off each requirement line. `package.json` reads
119
- `dependencies`, `devDependencies` and `optionalDependencies`.
118
+ | Manifest | What is read |
119
+ |---|---|
120
+ | `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
121
+ | `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
122
+ | `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`. |
123
+
124
+ Anything else is **refused with an error rather than guessed at**. Guessing is
125
+ what made an earlier version read `pyproject.toml` with the requirements parser
126
+ and report TOML keys as package names.
120
127
 
121
128
  ### Options
122
129
 
@@ -12,8 +12,8 @@
12
12
  [English](README.en.md) · **Українська**
13
13
 
14
14
  [![CI](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
15
- [![PyPI](https://img.shields.io/pypi/v/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
16
- [![Python](https://img.shields.io/pypi/pyversions/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
15
+ [![PyPI](https://img.shields.io/pypi/v/ghostpkg?color=A78BFA&label=pypi)](https://pypi.org/project/ghostpkg/)
16
+ [![Python](https://img.shields.io/pypi/pyversions/ghostpkg?color=A78BFA&label=python)](https://pypi.org/project/ghostpkg/)
17
17
  [![Залежності](https://img.shields.io/badge/%D0%B7%D0%B0%D0%BB%D0%B5%D0%B6%D0%BD%D0%BE%D1%81%D1%82%D0%B5%D0%B9-0-3FB950.svg)](pyproject.toml)
18
18
  [![Ліцензія](https://img.shields.io/badge/%D0%BB%D1%96%D1%86%D0%B5%D0%BD%D0%B7%D1%96%D1%8F-MIT-8B949E.svg)](LICENSE)
19
19
 
@@ -81,6 +81,7 @@ ghostpkg check react-router-dom-utils -e npm
81
81
 
82
82
  # перевірити всі залежності з маніфесту
83
83
  ghostpkg scan requirements.txt
84
+ ghostpkg scan pyproject.toml
84
85
  ghostpkg scan package.json
85
86
 
86
87
  # машинозчитуваний вивід
@@ -94,6 +95,9 @@ ghostpkg check somepkg --json
94
95
  | `--json` | Вивід у JSON для скриптів і CI |
95
96
  | `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
96
97
 
98
+ `scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
99
+ та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
100
+
97
101
  ---
98
102
 
99
103
  ## Що саме перевіряється
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -6,11 +6,12 @@ import argparse
6
6
  import concurrent.futures
7
7
  import json
8
8
  import os
9
- import re
10
9
  import sys
11
10
  from pathlib import Path
12
11
 
12
+ from . import __version__
13
13
  from .assess import Finding, Verdict, assess
14
+ from .manifests import UnsupportedManifest, load_manifest
14
15
  from .registries import RegistryError, fetch
15
16
 
16
17
  EXIT_OK = 0
@@ -96,42 +97,14 @@ def evaluate(names: list[str], ecosystem: str, strict: bool) -> list[Finding]:
96
97
  return list(pool.map(one, names))
97
98
 
98
99
 
99
- REQUIREMENT_LINE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
100
-
101
-
102
- def parse_requirements(text: str) -> list[str]:
103
- names = []
104
- for raw in text.splitlines():
105
- line = raw.strip()
106
- if not line or line.startswith(("#", "-", "git+", "http")):
107
- continue
108
- match = REQUIREMENT_LINE.match(line)
109
- if match:
110
- names.append(match.group(1))
111
- return names
112
-
113
-
114
- def parse_package_json(text: str) -> list[str]:
115
- data = json.loads(text)
116
- names: list[str] = []
117
- for key in ("dependencies", "devDependencies", "optionalDependencies"):
118
- names.extend((data.get(key) or {}).keys())
119
- return names
120
-
121
-
122
- def load_manifest(path: Path) -> tuple[list[str], str]:
123
- text = path.read_text(encoding="utf-8")
124
- if path.name == "package.json":
125
- return parse_package_json(text), "npm"
126
- return parse_requirements(text), "pypi"
127
-
128
-
129
100
  def build_parser() -> argparse.ArgumentParser:
130
101
  parser = argparse.ArgumentParser(
131
102
  prog="ghostpkg",
132
103
  description="Catch package names that do not exist before you install them.",
133
104
  )
134
- parser.add_argument("--version", action="version", version="ghostpkg 0.1.0")
105
+ parser.add_argument(
106
+ "--version", action="version", version=f"ghostpkg {__version__}"
107
+ )
135
108
  sub = parser.add_subparsers(dest="command", required=True)
136
109
 
137
110
  check = sub.add_parser("check", help="check one or more package names")
@@ -139,7 +112,9 @@ def build_parser() -> argparse.ArgumentParser:
139
112
  check.add_argument("-e", "--ecosystem", choices=("pypi", "npm"), default="pypi")
140
113
 
141
114
  scan = sub.add_parser("scan", help="check every dependency in a manifest")
142
- scan.add_argument("path", type=Path, help="requirements.txt or package.json")
115
+ scan.add_argument(
116
+ "path", type=Path, help="requirements*.txt, pyproject.toml or package.json"
117
+ )
143
118
 
144
119
  for command in (check, scan):
145
120
  command.add_argument(
@@ -162,7 +137,10 @@ def main(argv: list[str] | None = None) -> int:
162
137
  return EXIT_ERROR
163
138
  try:
164
139
  names, ecosystem = load_manifest(args.path)
165
- except (json.JSONDecodeError, UnicodeDecodeError) as exc:
140
+ except UnsupportedManifest as exc:
141
+ print(f"ghostpkg: {exc}", file=sys.stderr)
142
+ return EXIT_ERROR
143
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
166
144
  print(f"ghostpkg: could not parse {args.path}: {exc}", file=sys.stderr)
167
145
  return EXIT_ERROR
168
146
  if not names:
@@ -0,0 +1,193 @@
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
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostpkg"
7
- version = "0.1.0"
7
+ version = "0.2.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,187 @@
1
+ """Tests for manifest parsing.
2
+
3
+ The regression these guard against: an earlier version treated every file that
4
+ was not package.json as a requirements.txt, so `scan pyproject.toml` reported
5
+ TOML keys as package names -- `build-backend` came back "does not exist on
6
+ pypi", and `version` came back "ok" because a package of that name exists.
7
+ """
8
+
9
+ import json
10
+
11
+ import pytest
12
+
13
+ from ghostpkg.manifests import (
14
+ UnsupportedManifest,
15
+ load_manifest,
16
+ parse_package_json,
17
+ parse_pyproject,
18
+ parse_requirements,
19
+ )
20
+
21
+ PYPROJECT = """
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [project]
27
+ name = "example"
28
+ version = "1.0.0"
29
+ description = "not a dependency"
30
+ requires-python = ">=3.9"
31
+ dependencies = [
32
+ "requests>=2.31",
33
+ "click",
34
+ # a comment inside the array
35
+ "rich~=13.0",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = ["pytest>=7", "mypy"]
40
+ docs = ["sphinx"]
41
+
42
+ [tool.ruff]
43
+ line-length = 88
44
+ """
45
+
46
+ POETRY = """
47
+ [tool.poetry]
48
+ name = "example"
49
+ version = "1.0.0"
50
+
51
+ [tool.poetry.dependencies]
52
+ python = "^3.11"
53
+ requests = "^2.31"
54
+ httpx = { version = "^0.27", optional = true }
55
+
56
+ [tool.poetry.dev-dependencies]
57
+ pytest = "^8.0"
58
+ """
59
+
60
+
61
+ class TestPyproject:
62
+ def test_finds_pep621_dependencies(self):
63
+ names = parse_pyproject(PYPROJECT)
64
+ assert "requests" in names
65
+ assert "click" in names
66
+ assert "rich" in names
67
+
68
+ def test_finds_optional_dependencies(self):
69
+ names = parse_pyproject(PYPROJECT)
70
+ assert "pytest" in names
71
+ assert "mypy" in names
72
+ assert "sphinx" in names
73
+
74
+ @pytest.mark.parametrize(
75
+ "key", ["build-backend", "name", "version", "description", "requires-python"]
76
+ )
77
+ def test_toml_keys_are_not_treated_as_packages(self, key):
78
+ """The exact regression: TOML keys reported as package names."""
79
+ assert key not in parse_pyproject(PYPROJECT)
80
+
81
+ def test_build_system_requires_is_not_a_dependency(self):
82
+ """`requires` under [build-system] is a build dep, not a project one."""
83
+ assert "hatchling" not in parse_pyproject(PYPROJECT)
84
+
85
+ def test_poetry_dependencies(self):
86
+ names = parse_pyproject(POETRY)
87
+ assert "requests" in names
88
+ assert "httpx" in names
89
+ assert "pytest" in names
90
+
91
+ def test_poetry_python_constraint_is_skipped(self):
92
+ """`python` is an interpreter constraint, not a package."""
93
+ assert "python" not in parse_pyproject(POETRY)
94
+
95
+ def test_no_duplicates(self):
96
+ names = parse_pyproject(PYPROJECT)
97
+ assert len(names) == len(set(names))
98
+
99
+
100
+ class TestFallbackParser:
101
+ """Python 3.9 and 3.10 have no tomllib, so a narrow parser stands in.
102
+
103
+ These run the fallback explicitly rather than trusting the interpreter
104
+ version the suite happens to be executed on.
105
+ """
106
+
107
+ @pytest.fixture
108
+ def no_tomllib(self, monkeypatch):
109
+ import builtins
110
+
111
+ real_import = builtins.__import__
112
+
113
+ def fake_import(name, *args, **kwargs):
114
+ if name == "tomllib":
115
+ raise ImportError("no tomllib")
116
+ return real_import(name, *args, **kwargs)
117
+
118
+ monkeypatch.setattr(builtins, "__import__", fake_import)
119
+
120
+ def test_fallback_finds_dependencies(self, no_tomllib):
121
+ names = parse_pyproject(PYPROJECT)
122
+ assert "requests" in names
123
+ assert "click" in names
124
+ assert "rich" in names
125
+
126
+ def test_fallback_finds_optional_dependencies(self, no_tomllib):
127
+ names = parse_pyproject(PYPROJECT)
128
+ assert "pytest" in names
129
+ assert "sphinx" in names
130
+
131
+ def test_fallback_ignores_toml_keys(self, no_tomllib):
132
+ names = parse_pyproject(PYPROJECT)
133
+ for key in ("build-backend", "name", "version", "requires-python"):
134
+ assert key not in names
135
+
136
+ def test_fallback_reads_poetry(self, no_tomllib):
137
+ names = parse_pyproject(POETRY)
138
+ assert "requests" in names
139
+ assert "python" not in names
140
+
141
+
142
+ class TestRequirements:
143
+ def test_basic(self):
144
+ text = "requests==2.31.0\nflask>=3.0\n\n# comment\nnumpy\n"
145
+ assert parse_requirements(text) == ["requests", "flask", "numpy"]
146
+
147
+ def test_skips_flags_and_urls(self):
148
+ text = "-r base.txt\n--index-url https://example.com\ngit+https://x/y\nrequests\n"
149
+ assert parse_requirements(text) == ["requests"]
150
+
151
+
152
+ class TestPackageJson:
153
+ def test_all_dependency_groups(self):
154
+ text = json.dumps(
155
+ {
156
+ "dependencies": {"express": "^4"},
157
+ "devDependencies": {"jest": "^29"},
158
+ "optionalDependencies": {"fsevents": "^2"},
159
+ }
160
+ )
161
+ assert set(parse_package_json(text)) == {"express", "jest", "fsevents"}
162
+
163
+
164
+ class TestDetection:
165
+ def test_pyproject_is_recognised(self, tmp_path):
166
+ p = tmp_path / "pyproject.toml"
167
+ p.write_text(PYPROJECT, encoding="utf-8")
168
+ names, ecosystem = load_manifest(p)
169
+ assert ecosystem == "pypi"
170
+ assert "requests" in names
171
+
172
+ def test_package_json_is_npm(self, tmp_path):
173
+ p = tmp_path / "package.json"
174
+ p.write_text('{"dependencies": {"express": "^4"}}', encoding="utf-8")
175
+ assert load_manifest(p) == (["express"], "npm")
176
+
177
+ def test_requirements_variants(self, tmp_path):
178
+ p = tmp_path / "requirements-dev.txt"
179
+ p.write_text("pytest\n", encoding="utf-8")
180
+ assert load_manifest(p) == (["pytest"], "pypi")
181
+
182
+ def test_unknown_file_is_refused_not_guessed(self, tmp_path):
183
+ """Refusing beats guessing: guessing is what produced the TOML bug."""
184
+ p = tmp_path / "LICENSE"
185
+ p.write_text("MIT License\n", encoding="utf-8")
186
+ with pytest.raises(UnsupportedManifest):
187
+ load_manifest(p)
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