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.
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/CHANGELOG.md +27 -1
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/PKG-INFO +13 -6
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/README.en.md +12 -5
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/README.md +6 -2
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/cli.py +12 -34
- ghostpkg-0.2.0/ghostpkg/manifests.py +193 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/pyproject.toml +1 -1
- ghostpkg-0.2.0/tests/test_manifests.py +187 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/.gitignore +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/LICENSE +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/SECURITY.md +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/banner.html +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/banner.png +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/demo.gif +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/assess.py +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.2.0}/ghostpkg/registries.py +0 -0
- {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.
|
|
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.
|
|
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
|
[](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
|
|
62
|
-
[](https://pypi.org/project/ghostpkg/)
|
|
63
|
+
[](https://pypi.org/project/ghostpkg/)
|
|
64
64
|
[](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
|
|
65
65
|
[](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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
[](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
|
|
15
|
-
[](https://pypi.org/project/ghostpkg/)
|
|
16
|
+
[](https://pypi.org/project/ghostpkg/)
|
|
17
17
|
[](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
|
|
18
18
|
[](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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
[](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
|
|
15
|
-
[](https://pypi.org/project/ghostpkg/)
|
|
16
|
+
[](https://pypi.org/project/ghostpkg/)
|
|
17
17
|
[](pyproject.toml)
|
|
18
18
|
[](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
|
## Що саме перевіряється
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|