ghostpkg 0.3.0__tar.gz → 0.4.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 (29) hide show
  1. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/CHANGELOG.md +24 -1
  2. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/PKG-INFO +23 -2
  3. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/README.en.md +22 -1
  4. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/README.md +11 -1
  5. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/__init__.py +1 -1
  6. ghostpkg-0.4.0/ghostpkg/cache.py +165 -0
  7. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/cli.py +30 -4
  8. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/pyproject.toml +1 -1
  9. ghostpkg-0.4.0/tests/test_cache.py +158 -0
  10. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  11. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  13. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  14. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/workflows/ci.yml +0 -0
  15. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.gitignore +0 -0
  16. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/CONTRIBUTING.md +0 -0
  17. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/LICENSE +0 -0
  18. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/SECURITY.md +0 -0
  19. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/banner.html +0 -0
  20. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/banner.png +0 -0
  21. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/demo.gif +0 -0
  22. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/make_demo.py +0 -0
  23. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/__main__.py +0 -0
  24. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/assess.py +0 -0
  25. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/data.py +0 -0
  26. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/manifests.py +0 -0
  27. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/registries.py +0 -0
  28. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/tests/test_assess.py +0 -0
  29. {ghostpkg-0.3.0 → ghostpkg-0.4.0}/tests/test_manifests.py +0 -0
@@ -6,6 +6,28 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-09-01
10
+
11
+ ### Added
12
+ - **On-disk cache for registry lookups.** Scanning a 150-package manifest drops
13
+ from 4.7s to 0.4s on a warm cache. Previously every scan cost one request per
14
+ dependency, every run, which is slow in CI and rude to the registry.
15
+ - `--no-cache` to bypass it, and `ghostpkg clear-cache` to delete it.
16
+ - `GHOSTPKG_CACHE_DIR` to override the location. The default is
17
+ `%LOCALAPPDATA%\ghostpkg` on Windows, `~/Library/Caches/ghostpkg` on macOS and
18
+ `$XDG_CACHE_HOME/ghostpkg` elsewhere, worked out without a dependency.
19
+
20
+ ### Notes on the cache design
21
+ - **Time-to-live depends on the answer, and "does not exist" is held for only an
22
+ hour.** A free name can be registered at any moment — that is the whole attack
23
+ — so a negative result must not be trusted for long. Young packages are held
24
+ six hours, established ones a day.
25
+ - Cache failures are never fatal. A corrupt file, a wrong schema, a malformed
26
+ entry or an unwritable directory all degrade to no cache rather than breaking
27
+ a run. There are tests for each of those.
28
+ - Written atomically via a temporary file and `os.replace`, once per run, so a
29
+ killed process cannot leave a half-written cache behind.
30
+
9
31
  ## [0.3.0] - 2026-09-01
10
32
 
11
33
  ### Fixed
@@ -87,7 +109,8 @@ First release.
87
109
  - No corpus of hallucinated package names is shipped, following the decision of
88
110
  the USENIX'25 authors not to publish theirs.
89
111
 
90
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.3.0...HEAD
112
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.4.0...HEAD
113
+ [0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
91
114
  [0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.0
92
115
  [0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
93
116
  [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.0
3
+ Version: 0.4.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
@@ -180,6 +180,7 @@ and report TOML keys as package names.
180
180
  | `--strict` | Promote warnings to blocks |
181
181
  | `--json` | Machine-readable output for scripts and CI |
182
182
  | `-q`, `--quiet` | Hide packages that passed |
183
+ | `--no-cache` | Neither read nor write the cache |
183
184
  | `--version` | Print the version |
184
185
 
185
186
  ### Exit codes
@@ -190,6 +191,25 @@ and report TOML keys as package names.
190
191
  | `1` | At least one package blocked |
191
192
  | `2` | Usage error, unreadable manifest, or the registry was unreachable |
192
193
 
194
+ ### Caching
195
+
196
+ Lookups are cached on disk, so re-scanning a 150-package manifest takes 0.4s
197
+ instead of 4.7s.
198
+
199
+ Time-to-live depends on the answer, and the negative case is the one that
200
+ matters: **"does not exist" is held for only an hour**, because a free name can
201
+ be registered at any moment and that is the entire attack. Young packages are
202
+ held six hours, established ones a day.
203
+
204
+ ```bash
205
+ ghostpkg scan requirements.txt --no-cache # bypass it
206
+ ghostpkg clear-cache # delete it
207
+ GHOSTPKG_CACHE_DIR=/tmp/gp ghostpkg check x # move it
208
+ ```
209
+
210
+ A corrupt, unreadable or unwritable cache degrades to no cache rather than
211
+ breaking your run.
212
+
193
213
  ### JSON output
194
214
 
195
215
  ```console
@@ -357,7 +377,8 @@ earns its keep. Use both.
357
377
  ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
358
378
  - Names shorter than five characters are not compared at all: below that the name
359
379
  space is too dense for edit distance to mean anything.
360
- - Every check is a live registry request. There is no caching yet.
380
+ - The cache lives on disk. `ghostpkg clear-cache` removes it and
381
+ `GHOSTPKG_CACHE_DIR` moves it.
361
382
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
362
383
  but it does mean a flaky network fails your build.
363
384
 
@@ -133,6 +133,7 @@ and report TOML keys as package names.
133
133
  | `--strict` | Promote warnings to blocks |
134
134
  | `--json` | Machine-readable output for scripts and CI |
135
135
  | `-q`, `--quiet` | Hide packages that passed |
136
+ | `--no-cache` | Neither read nor write the cache |
136
137
  | `--version` | Print the version |
137
138
 
138
139
  ### Exit codes
@@ -143,6 +144,25 @@ and report TOML keys as package names.
143
144
  | `1` | At least one package blocked |
144
145
  | `2` | Usage error, unreadable manifest, or the registry was unreachable |
145
146
 
147
+ ### Caching
148
+
149
+ Lookups are cached on disk, so re-scanning a 150-package manifest takes 0.4s
150
+ instead of 4.7s.
151
+
152
+ Time-to-live depends on the answer, and the negative case is the one that
153
+ matters: **"does not exist" is held for only an hour**, because a free name can
154
+ be registered at any moment and that is the entire attack. Young packages are
155
+ held six hours, established ones a day.
156
+
157
+ ```bash
158
+ ghostpkg scan requirements.txt --no-cache # bypass it
159
+ ghostpkg clear-cache # delete it
160
+ GHOSTPKG_CACHE_DIR=/tmp/gp ghostpkg check x # move it
161
+ ```
162
+
163
+ A corrupt, unreadable or unwritable cache degrades to no cache rather than
164
+ breaking your run.
165
+
146
166
  ### JSON output
147
167
 
148
168
  ```console
@@ -310,7 +330,8 @@ earns its keep. Use both.
310
330
  ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
311
331
  - Names shorter than five characters are not compared at all: below that the name
312
332
  space is too dense for edit distance to mean anything.
313
- - Every check is a live registry request. There is no caching yet.
333
+ - The cache lives on disk. `ghostpkg clear-cache` removes it and
334
+ `GHOSTPKG_CACHE_DIR` moves it.
314
335
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
315
336
  but it does mean a flaky network fails your build.
316
337
 
@@ -86,14 +86,23 @@ ghostpkg scan package.json
86
86
 
87
87
  # машинозчитуваний вивід
88
88
  ghostpkg check somepkg --json
89
+
90
+ # видалити кеш
91
+ ghostpkg clear-cache
89
92
  ```
90
93
 
94
+ Результати запитів кешуються на диск: повторна перевірка манифесту зі 150
95
+ залежностей займає 0,4 с замість 4,7 с. **Відповідь «пакета не існує»
96
+ зберігається лише годину** — вільне ім'я можуть зареєструвати будь-якої
97
+ миті, і саме в цьому вся атака.
98
+
91
99
  | Прапорець | Призначення |
92
100
  |---|---|
93
101
  | `-e`, `--ecosystem` | `pypi` (типово) або `npm` |
94
102
  | `--strict` | Підвищує попередження до блокувань |
95
103
  | `--json` | Вивід у JSON для скриптів і CI |
96
104
  | `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
105
+ | `--no-cache` | Не читати й не писати кеш |
97
106
 
98
107
  `scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
99
108
  та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
@@ -167,7 +176,8 @@ $ ghostpkg check react-router-dom-utils -e npm
167
176
  тому підробка під менш популярний пакет як схожа назва не позначиться.
168
177
  - Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
169
178
  щоб відстань редагування щось означала.
170
- - Кожна перевірка — це живий запит до реєстру. Кешування ще немає.
179
+ - Кеш зберігається локально. `ghostpkg clear-cache` видаляє його,
180
+ `GHOSTPKG_CACHE_DIR` змінює розташування.
171
181
 
172
182
  ---
173
183
 
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -0,0 +1,165 @@
1
+ """On-disk cache for registry lookups.
2
+
3
+ Scanning a manifest costs one request per dependency, every time. A 200-line
4
+ requirements.txt in CI is 200 requests on every run, which is slow for the user
5
+ and rude to the registry.
6
+
7
+ Time-to-live depends on what was cached, and the important case is the negative
8
+ one. A name that does not exist today can be registered tomorrow -- that is the
9
+ entire attack this tool exists to catch -- so "does not exist" is held only
10
+ briefly. Established packages change slowly and are held far longer.
11
+
12
+ Cache failures are never fatal. An unwritable or corrupt cache degrades to no
13
+ cache at all rather than breaking a build.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ from dataclasses import asdict
24
+ from pathlib import Path
25
+
26
+ from .registries import PackageFacts
27
+
28
+ SCHEMA = 1
29
+
30
+ TTL_MISSING = 60 * 60 # 1 hour -- a free name can be taken any time
31
+ TTL_YOUNG = 6 * 60 * 60 # 6 hours -- facts still moving
32
+ TTL_ESTABLISHED = 24 * 60 * 60 # 1 day -- changes slowly
33
+
34
+ ESTABLISHED_DAYS = 365
35
+
36
+
37
+ def default_dir() -> Path:
38
+ """Per-platform cache directory, worked out by hand.
39
+
40
+ A dependency for this would contradict the zero-dependency rule for the
41
+ sake of three lines.
42
+ """
43
+ override = os.environ.get("GHOSTPKG_CACHE_DIR")
44
+ if override:
45
+ return Path(override)
46
+ if sys.platform == "win32":
47
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
48
+ return Path(base) / "ghostpkg"
49
+ if sys.platform == "darwin":
50
+ return Path(os.path.expanduser("~/Library/Caches")) / "ghostpkg"
51
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
52
+ return Path(base) / "ghostpkg"
53
+
54
+
55
+ def ttl_for(facts: PackageFacts) -> int:
56
+ if not facts.exists:
57
+ return TTL_MISSING
58
+ if facts.age_days is not None and facts.age_days >= ESTABLISHED_DAYS:
59
+ return TTL_ESTABLISHED
60
+ return TTL_YOUNG
61
+
62
+
63
+ class Cache:
64
+ """Read once, write once. Threads only read, so no locking is needed."""
65
+
66
+ def __init__(self, directory: Path | None = None, enabled: bool = True) -> None:
67
+ self.enabled = enabled
68
+ self.directory = directory or default_dir()
69
+ self._entries: dict[str, dict] = {}
70
+ self._dirty = False
71
+ self.hits = 0
72
+ self.misses = 0
73
+ if self.enabled:
74
+ self._load()
75
+
76
+ @property
77
+ def path(self) -> Path:
78
+ return self.directory / "registry.json"
79
+
80
+ def _load(self) -> None:
81
+ try:
82
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
83
+ except (OSError, ValueError):
84
+ return
85
+ if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
86
+ return
87
+ entries = raw.get("entries")
88
+ if isinstance(entries, dict):
89
+ self._entries = entries
90
+
91
+ @staticmethod
92
+ def _key(ecosystem: str, name: str) -> str:
93
+ return f"{ecosystem}:{name.lower()}"
94
+
95
+ def get(self, ecosystem: str, name: str) -> PackageFacts | None:
96
+ if not self.enabled:
97
+ return None
98
+ entry = self._entries.get(self._key(ecosystem, name))
99
+ if not isinstance(entry, dict):
100
+ self.misses += 1
101
+ return None
102
+ stored_at = entry.get("at")
103
+ ttl = entry.get("ttl")
104
+ facts = entry.get("facts")
105
+ if not isinstance(stored_at, (int, float)) or not isinstance(ttl, (int, float)):
106
+ self.misses += 1
107
+ return None
108
+ if time.time() - stored_at > ttl:
109
+ self.misses += 1
110
+ return None
111
+ try:
112
+ revived = PackageFacts(**facts)
113
+ except (TypeError, ValueError):
114
+ self.misses += 1
115
+ return None
116
+ self.hits += 1
117
+ # keep the caller's spelling of the name rather than the cached one
118
+ if revived.name != name:
119
+ revived = PackageFacts(**{**asdict(revived), "name": name})
120
+ return revived
121
+
122
+ def put(self, facts: PackageFacts) -> None:
123
+ if not self.enabled:
124
+ return
125
+ self._entries[self._key(facts.ecosystem, facts.name)] = {
126
+ "at": time.time(),
127
+ "ttl": ttl_for(facts),
128
+ "facts": asdict(facts),
129
+ }
130
+ self._dirty = True
131
+
132
+ def _prune(self) -> None:
133
+ now = time.time()
134
+ self._entries = {
135
+ key: entry
136
+ for key, entry in self._entries.items()
137
+ if isinstance(entry, dict)
138
+ and isinstance(entry.get("at"), (int, float))
139
+ and now - entry["at"] <= entry.get("ttl", 0)
140
+ }
141
+
142
+ def save(self) -> None:
143
+ """Write atomically. Any failure is silent -- a cache is a convenience."""
144
+ if not self.enabled or not self._dirty:
145
+ return
146
+ self._prune()
147
+ payload = {"schema": SCHEMA, "entries": self._entries}
148
+ try:
149
+ self.directory.mkdir(parents=True, exist_ok=True)
150
+ handle, temporary = tempfile.mkstemp(dir=str(self.directory), suffix=".tmp")
151
+ with os.fdopen(handle, "w", encoding="utf-8") as file:
152
+ json.dump(payload, file)
153
+ os.replace(temporary, self.path)
154
+ except OSError:
155
+ try:
156
+ os.unlink(temporary) # noqa: F821
157
+ except (OSError, NameError):
158
+ pass
159
+
160
+ def clear(self) -> bool:
161
+ try:
162
+ self.path.unlink()
163
+ return True
164
+ except OSError:
165
+ return False
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
 
12
12
  from . import __version__
13
13
  from .assess import Finding, Verdict, assess
14
+ from .cache import Cache
14
15
  from .manifests import UnsupportedManifest, load_manifest
15
16
  from .registries import RegistryError, fetch
16
17
 
@@ -89,12 +90,25 @@ def summarise(findings: list[Finding], palette: Palette) -> None:
89
90
  print(palette.green(f" all {len(findings)} packages look fine"))
90
91
 
91
92
 
92
- def evaluate(names: list[str], ecosystem: str, strict: bool) -> list[Finding]:
93
+ def evaluate(
94
+ names: list[str],
95
+ ecosystem: str,
96
+ strict: bool,
97
+ cache: Cache | None = None,
98
+ ) -> list[Finding]:
93
99
  def one(name: str) -> Finding:
94
- return assess(fetch(name, ecosystem), strict=strict)
100
+ facts = cache.get(ecosystem, name) if cache else None
101
+ if facts is None:
102
+ facts = fetch(name, ecosystem)
103
+ if cache:
104
+ cache.put(facts)
105
+ return assess(facts, strict=strict)
95
106
 
96
107
  with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
97
- return list(pool.map(one, names))
108
+ findings = list(pool.map(one, names))
109
+ if cache:
110
+ cache.save()
111
+ return findings
98
112
 
99
113
 
100
114
  def build_parser() -> argparse.ArgumentParser:
@@ -124,6 +138,11 @@ def build_parser() -> argparse.ArgumentParser:
124
138
  )
125
139
  command.add_argument("--json", action="store_true", help="machine-readable output")
126
140
  command.add_argument("-q", "--quiet", action="store_true", help="hide passing packages")
141
+ command.add_argument(
142
+ "--no-cache", action="store_true", help="ignore and do not write the cache"
143
+ )
144
+
145
+ sub.add_parser("clear-cache", help="delete the cached registry lookups")
127
146
 
128
147
  return parser
129
148
 
@@ -131,6 +150,12 @@ def build_parser() -> argparse.ArgumentParser:
131
150
  def main(argv: list[str] | None = None) -> int:
132
151
  args = build_parser().parse_args(argv)
133
152
 
153
+ if args.command == "clear-cache":
154
+ cache = Cache(enabled=True)
155
+ removed = cache.clear()
156
+ print(f"ghostpkg: {'removed ' + str(cache.path) if removed else 'nothing to remove'}")
157
+ return EXIT_OK
158
+
134
159
  if args.command == "scan":
135
160
  if not args.path.exists():
136
161
  print(f"ghostpkg: no such file: {args.path}", file=sys.stderr)
@@ -149,8 +174,9 @@ def main(argv: list[str] | None = None) -> int:
149
174
  else:
150
175
  names, ecosystem = args.names, args.ecosystem
151
176
 
177
+ cache = Cache(enabled=not args.no_cache)
152
178
  try:
153
- findings = evaluate(names, ecosystem, args.strict)
179
+ findings = evaluate(names, ecosystem, args.strict, cache)
154
180
  except RegistryError as exc:
155
181
  print(f"ghostpkg: {exc}", file=sys.stderr)
156
182
  return EXIT_ERROR
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostpkg"
7
- version = "0.3.0"
7
+ version = "0.4.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,158 @@
1
+ """Tests for the registry lookup cache.
2
+
3
+ The security-relevant part is the time-to-live on a negative result. A name that
4
+ does not exist today can be registered tomorrow, and that is precisely the attack
5
+ ghostpkg exists to catch, so "does not exist" must not be held for long.
6
+ """
7
+
8
+ import json
9
+ import time
10
+
11
+ import pytest
12
+
13
+ from ghostpkg.cache import (
14
+ SCHEMA,
15
+ TTL_ESTABLISHED,
16
+ TTL_MISSING,
17
+ TTL_YOUNG,
18
+ Cache,
19
+ default_dir,
20
+ ttl_for,
21
+ )
22
+ from ghostpkg.registries import PackageFacts
23
+
24
+
25
+ def facts(**overrides) -> PackageFacts:
26
+ base = dict(
27
+ name="example",
28
+ ecosystem="pypi",
29
+ exists=True,
30
+ age_days=2000,
31
+ release_count=25,
32
+ has_repo_url=True,
33
+ )
34
+ base.update(overrides)
35
+ return PackageFacts(**base)
36
+
37
+
38
+ @pytest.fixture
39
+ def cache(tmp_path):
40
+ return Cache(directory=tmp_path, enabled=True)
41
+
42
+
43
+ class TestTimeToLive:
44
+ def test_missing_package_is_held_briefly(self):
45
+ """The attack is registering a name that is free right now."""
46
+ assert ttl_for(facts(exists=False)) == TTL_MISSING
47
+ assert TTL_MISSING <= 60 * 60
48
+
49
+ def test_missing_is_shorter_than_anything_else(self):
50
+ assert TTL_MISSING < TTL_YOUNG < TTL_ESTABLISHED
51
+
52
+ def test_established_package_is_held_longest(self):
53
+ assert ttl_for(facts(age_days=2000)) == TTL_ESTABLISHED
54
+
55
+ def test_young_package_gets_the_middle_ttl(self):
56
+ assert ttl_for(facts(age_days=10)) == TTL_YOUNG
57
+
58
+ def test_unknown_age_is_treated_as_young(self):
59
+ assert ttl_for(facts(age_days=None)) == TTL_YOUNG
60
+
61
+
62
+ class TestRoundTrip:
63
+ def test_stores_and_returns_facts(self, cache):
64
+ cache.put(facts(name="requests"))
65
+ got = cache.get("pypi", "requests")
66
+ assert got is not None
67
+ assert got.name == "requests"
68
+ assert got.release_count == 25
69
+
70
+ def test_survives_save_and_reload(self, cache, tmp_path):
71
+ cache.put(facts(name="requests"))
72
+ cache.save()
73
+ assert Cache(directory=tmp_path).get("pypi", "requests") is not None
74
+
75
+ def test_lookup_is_case_insensitive(self, cache):
76
+ cache.put(facts(name="Requests"))
77
+ assert cache.get("pypi", "requests") is not None
78
+
79
+ def test_returned_name_keeps_the_callers_spelling(self, cache):
80
+ cache.put(facts(name="Requests"))
81
+ assert cache.get("pypi", "requests").name == "requests"
82
+
83
+ def test_ecosystems_are_separate(self, cache):
84
+ cache.put(facts(name="express", ecosystem="npm"))
85
+ assert cache.get("npm", "express") is not None
86
+ assert cache.get("pypi", "express") is None
87
+
88
+ def test_expired_entry_is_a_miss(self, cache, tmp_path):
89
+ cache.put(facts(name="requests", exists=False))
90
+ cache.save()
91
+ raw = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8"))
92
+ for entry in raw["entries"].values():
93
+ entry["at"] = time.time() - (TTL_MISSING + 60)
94
+ (tmp_path / "registry.json").write_text(json.dumps(raw), encoding="utf-8")
95
+ assert Cache(directory=tmp_path).get("pypi", "requests") is None
96
+
97
+ def test_expired_entries_are_dropped_on_save(self, cache, tmp_path):
98
+ cache.put(facts(name="stale"))
99
+ for entry in cache._entries.values():
100
+ entry["at"] = time.time() - (TTL_ESTABLISHED + 60)
101
+ cache.put(facts(name="fresh"))
102
+ cache.save()
103
+ raw = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8"))
104
+ assert "pypi:fresh" in raw["entries"]
105
+ assert "pypi:stale" not in raw["entries"]
106
+
107
+
108
+ class TestDegradation:
109
+ """A cache is a convenience. It must never break a run."""
110
+
111
+ def test_disabled_cache_stores_nothing(self, tmp_path):
112
+ cache = Cache(directory=tmp_path, enabled=False)
113
+ cache.put(facts())
114
+ cache.save()
115
+ assert cache.get("pypi", "example") is None
116
+ assert not (tmp_path / "registry.json").exists()
117
+
118
+ def test_corrupt_file_is_ignored(self, tmp_path):
119
+ (tmp_path / "registry.json").write_text("not json at all", encoding="utf-8")
120
+ assert Cache(directory=tmp_path).get("pypi", "requests") is None
121
+
122
+ def test_wrong_schema_is_ignored(self, tmp_path):
123
+ (tmp_path / "registry.json").write_text(
124
+ json.dumps({"schema": SCHEMA + 99, "entries": {"pypi:x": {}}}),
125
+ encoding="utf-8",
126
+ )
127
+ assert Cache(directory=tmp_path).get("pypi", "x") is None
128
+
129
+ def test_malformed_entry_is_ignored(self, tmp_path):
130
+ (tmp_path / "registry.json").write_text(
131
+ json.dumps({"schema": SCHEMA, "entries": {"pypi:x": {"at": "soon"}}}),
132
+ encoding="utf-8",
133
+ )
134
+ assert Cache(directory=tmp_path).get("pypi", "x") is None
135
+
136
+ def test_unwritable_directory_does_not_raise(self, tmp_path):
137
+ blocked = tmp_path / "afile"
138
+ blocked.write_text("", encoding="utf-8")
139
+ cache = Cache(directory=blocked / "under-a-file")
140
+ cache.put(facts())
141
+ cache.save() # must not raise
142
+
143
+ def test_counts_hits_and_misses(self, cache):
144
+ cache.put(facts(name="requests"))
145
+ cache.get("pypi", "requests")
146
+ cache.get("pypi", "nothing-here")
147
+ assert cache.hits == 1
148
+ assert cache.misses == 1
149
+
150
+
151
+ class TestLocation:
152
+ def test_environment_override_wins(self, monkeypatch, tmp_path):
153
+ monkeypatch.setenv("GHOSTPKG_CACHE_DIR", str(tmp_path / "custom"))
154
+ assert default_dir() == tmp_path / "custom"
155
+
156
+ def test_default_is_platform_specific(self, monkeypatch):
157
+ monkeypatch.delenv("GHOSTPKG_CACHE_DIR", raising=False)
158
+ assert default_dir().name == "ghostpkg"
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