pydoctor-cli 2.0.1__tar.gz → 2.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 (64) hide show
  1. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/PKG-INFO +33 -3
  2. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/README.md +30 -0
  3. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/__init__.py +1 -1
  4. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/analysis/health_score.py +48 -5
  5. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/cache/cache_manager.py +8 -1
  6. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/cli/main.py +260 -60
  7. pydoctor_cli-2.2.0/pydoctor/core/__init__.py +17 -0
  8. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/core/analyzer.py +67 -1
  9. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/core/project.py +105 -78
  10. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/reports/json_formatter.py +44 -15
  11. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/reports/table_formatter.py +7 -2
  12. pydoctor_cli-2.2.0/pydoctor/scanners/ci_scanner.py +319 -0
  13. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/scanners/dependency_scanner.py +54 -9
  14. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/scanners/env_scanner.py +30 -12
  15. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/scanners/outdated_package_scanner.py +40 -14
  16. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/scanners/unused_package_scanner.py +160 -8
  17. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/scanners/vulnerability_scanner.py +10 -2
  18. pydoctor_cli-2.2.0/pydoctor/security/cvss.py +77 -0
  19. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/security/osv_client.py +140 -61
  20. pydoctor_cli-2.2.0/pydoctor/security/secrets.py +296 -0
  21. pydoctor_cli-2.2.0/pydoctor/utils/env_probe.py +145 -0
  22. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/utils/file_utils.py +4 -2
  23. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/utils/parser_utils.py +41 -0
  24. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/utils/pip_utils.py +40 -29
  25. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/PKG-INFO +33 -3
  26. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/SOURCES.txt +7 -3
  27. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pyproject.toml +3 -3
  28. pydoctor_cli-2.2.0/tests/test_ci_scanner.py +196 -0
  29. pydoctor_cli-2.2.0/tests/test_cli_ux.py +72 -0
  30. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/tests/test_env_scanner.py +27 -14
  31. pydoctor_cli-2.2.0/tests/test_environment.py +197 -0
  32. pydoctor_cli-2.2.0/tests/test_fix_command.py +141 -0
  33. pydoctor_cli-2.2.0/tests/test_osv_parsing.py +181 -0
  34. pydoctor_cli-2.2.0/tests/test_report_output.py +72 -0
  35. pydoctor_cli-2.2.0/tests/test_unused_scanner.py +192 -0
  36. pydoctor_cli-2.0.1/pydoctor/core/__init__.py +0 -7
  37. pydoctor_cli-2.0.1/pydoctor/performance/__init__.py +0 -5
  38. pydoctor_cli-2.0.1/pydoctor/performance/executor.py +0 -132
  39. pydoctor_cli-2.0.1/pydoctor/performance/parallel.py +0 -40
  40. pydoctor_cli-2.0.1/pydoctor/scanners/ci_scanner.py +0 -141
  41. pydoctor_cli-2.0.1/tests/test_ci_scanner.py +0 -39
  42. pydoctor_cli-2.0.1/tests/test_cli_ux.py +0 -38
  43. pydoctor_cli-2.0.1/tests/test_unused_scanner.py +0 -106
  44. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/LICENSE +0 -0
  45. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/analysis/__init__.py +0 -0
  46. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/cache/__init__.py +0 -0
  47. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/cli/__init__.py +0 -0
  48. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/config/__init__.py +0 -0
  49. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/config/settings.py +0 -0
  50. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/core/report.py +0 -0
  51. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/reports/__init__.py +0 -0
  52. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/reports/terminal_colors.py +0 -0
  53. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/scanners/__init__.py +0 -0
  54. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/security/__init__.py +0 -0
  55. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/utils/__init__.py +0 -0
  56. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor/utils/subprocess_utils.py +0 -0
  57. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
  58. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
  59. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/requires.txt +0 -0
  60. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
  61. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/setup.cfg +0 -0
  62. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/tests/conftest.py +0 -0
  63. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/tests/test_dependency_scanner.py +0 -0
  64. {pydoctor_cli-2.0.1 → pydoctor_cli-2.2.0}/tests/test_vulnerability_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydoctor-cli
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: Premium Python environment diagnostic CLI — your project's personal physician.
5
5
  Author-email: Favour Bamgboye <demiladebamgboye@gmail.com>
6
6
  Maintainer-email: Favour Bamgboye <demiladebamgboye@gmail.com>
@@ -206,9 +206,9 @@ License: Apache License
206
206
  See the License for the specific language governing permissions and
207
207
  limitations under the License.
208
208
 
209
- Project-URL: Homepage, https://pydoctor.vercel.app
209
+ Project-URL: Homepage, https://pydoctor-docs.thally.app
210
210
  Project-URL: Repository, https://github.com/iamAgbaCoder/pydoctor-cli
211
- Project-URL: Documentation, https://pydoctor.vercel.app/docs
211
+ Project-URL: Documentation, https://pydoctor-docs.thally.app
212
212
  Project-URL: Bug Tracker, https://github.com/iamAgbaCoder/pydoctor-cli/issues
213
213
  Project-URL: Changelog, https://github.com/iamAgbaCoder/pydoctor-cli/releases
214
214
  Keywords: pydoctor,diagnosis,dependency-checker,vulnerability-scanner,python-health,cli-tool,security-audit,static-analysis
@@ -253,6 +253,7 @@ Dynamic: license-file
253
253
 
254
254
  **PyDoctor** is a standalone, developer-friendly diagnostic assistant for Python environments. It acts like a personal physician for your code — scanning your project and environment to prescribe fixes for misconfigurations, dependencies, and security vulnerabilities.
255
255
 
256
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pydoctor-cli?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pydoctor-cli)
256
257
  [![CI Status](https://github.com/iamAgbaCoder/pydoctor-cli/actions/workflows/publish.yml/badge.svg)](https://github.com/iamAgbaCoder/pydoctor-cli/actions)
257
258
  [![PyPI version](https://img.shields.io/pypi/v/pydoctor-cli.svg?color=blue&style=flat-square)](https://pypi.org/project/pydoctor-cli/)
258
259
  [![Python versions](https://img.shields.io/pypi/pyversions/pydoctor-cli.svg?style=flat-square)](https://pypi.org/project/pydoctor-cli/)
@@ -299,6 +300,16 @@ pipx install pydoctor-cli
299
300
  pip install pydoctor-cli
300
301
  ```
301
302
 
303
+ > Install `pydoctor-cli`, not `pydoctor`: that is an unrelated project (Twisted's API doc generator) that installs a command with the same name.
304
+
305
+ PyDoctor scans your **project's** Python, wherever PyDoctor itself is installed. It uses the active virtualenv, or a `.venv`/`venv` in the project, and tells you which interpreter it chose. To choose one yourself:
306
+
307
+ ```bash
308
+ pydoctor diagnose --python .venv/bin/python # or set PYDOCTOR_PYTHON
309
+ ```
310
+
311
+ 📖 Full documentation: **https://pydoctor-docs.thally.app**
312
+
302
313
  ---
303
314
 
304
315
  ## ⚡ Usage
@@ -323,6 +334,25 @@ pydoctor fix
323
334
 
324
335
  ## Release History
325
336
 
337
+ ### v2.2.0
338
+ Results you can trust: every fix in this release removes a case where PyDoctor reported a problem as fine, or got a severity wrong.
339
+
340
+ - **Scans the right environment:** facts come from your project's interpreter, not PyDoctor's. Under pipx or `uv tool`, and in uv environments (which have no pip), 2.1.0 saw 0 packages and reported everything as OK. Works without pip (package metadata, `uv pip list --outdated`). New `--python` / `PYDOCTOR_PYTHON`; the report shows which interpreter was scanned and why.
341
+ - **Fails loudly:** `ENV_PACKAGES_UNAVAILABLE` (error) when the environment can't be read, `ENV_DEPS_NOT_INSTALLED` when the scanned Python has none of your dependencies, and outdated checks that can't run say so instead of "all up to date".
342
+ - **Virtualenv detection works:** it was always "active". The `diagnose` no-venv prompt now appears, and never blocks CI or piped runs.
343
+ - **Real CVSS scores:** severity is computed from the CVSS v3 vector. Previously a 3.1 (Low) and a 9.8 (Critical) vulnerability were both shown as critical. GitHub's `MODERATE` is now medium.
344
+ - **Right fix version:** for advisories with one fix per release line, `fix` targets your line (Django 5.1.2 → 5.1.4, not 4.2.17).
345
+ - **CI guard finds real leaks:** scans every committed text file, adds Stripe, OpenAI, Anthropic, Slack, Google, private-key, database-URL and `SECRET_KEY` rules, flags committed `.env` files, and checks workflows for `pull_request_target` "pwn requests", script injection and `write-all` permissions. Findings have line numbers; add `# pydoctor: ignore` to silence one. Zero findings on Flask, Django, requests, httpx, FastAPI, DRF and pip.
346
+ - **A score that moves:** a package's advisories count once, categories have diminishing returns, and any critical issue keeps the score below "Good". Expect different (usually higher) scores than 2.1.0.
347
+ - **JSON:** includes `health`; `scan-security --json` reports advisory IDs, aliases, fixed version and CVSS score.
348
+ - Smaller: database drivers, hashers and cache clients picked by Django settings count as used; faster advisory fetching; `--no-cache` no longer wipes the cache; circular import fixed; `github <repo>` and `docker` describe what they actually do.
349
+
350
+ ### v2.1.0
351
+ - **`pydoctor fix` reports what really happened:** upgrades are verified against the installed version. If a pin in `pyproject.toml` blocks an upgrade, pydoctor says so and shows the constraint instead of printing "fixed".
352
+ - **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
353
+ - **No accidental removals:** `pydoctor fix <package>` no longer offers to uninstall it. Removal only runs with `--remove`.
354
+ - **Fewer false "unused" reports:** packages imported under a different name (`decouple`), loaded from strings (Django `INSTALLED_APPS`, `MIDDLEWARE`, backends), or run from a Procfile/Dockerfile (`gunicorn`) are recognised as used. Remaining findings are labelled "Possibly unused".
355
+
326
356
  ### v2.0.1
327
357
  - **Security scans fail closed:** if the OSV database can't be reached, pydoctor now reports `SEC_SCAN_INCOMPLETE` (error, exit 1) instead of a clean result.
328
358
  - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
@@ -4,6 +4,7 @@
4
4
 
5
5
  **PyDoctor** is a standalone, developer-friendly diagnostic assistant for Python environments. It acts like a personal physician for your code — scanning your project and environment to prescribe fixes for misconfigurations, dependencies, and security vulnerabilities.
6
6
 
7
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pydoctor-cli?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pydoctor-cli)
7
8
  [![CI Status](https://github.com/iamAgbaCoder/pydoctor-cli/actions/workflows/publish.yml/badge.svg)](https://github.com/iamAgbaCoder/pydoctor-cli/actions)
8
9
  [![PyPI version](https://img.shields.io/pypi/v/pydoctor-cli.svg?color=blue&style=flat-square)](https://pypi.org/project/pydoctor-cli/)
9
10
  [![Python versions](https://img.shields.io/pypi/pyversions/pydoctor-cli.svg?style=flat-square)](https://pypi.org/project/pydoctor-cli/)
@@ -50,6 +51,16 @@ pipx install pydoctor-cli
50
51
  pip install pydoctor-cli
51
52
  ```
52
53
 
54
+ > Install `pydoctor-cli`, not `pydoctor`: that is an unrelated project (Twisted's API doc generator) that installs a command with the same name.
55
+
56
+ PyDoctor scans your **project's** Python, wherever PyDoctor itself is installed. It uses the active virtualenv, or a `.venv`/`venv` in the project, and tells you which interpreter it chose. To choose one yourself:
57
+
58
+ ```bash
59
+ pydoctor diagnose --python .venv/bin/python # or set PYDOCTOR_PYTHON
60
+ ```
61
+
62
+ 📖 Full documentation: **https://pydoctor-docs.thally.app**
63
+
53
64
  ---
54
65
 
55
66
  ## ⚡ Usage
@@ -74,6 +85,25 @@ pydoctor fix
74
85
 
75
86
  ## Release History
76
87
 
88
+ ### v2.2.0
89
+ Results you can trust: every fix in this release removes a case where PyDoctor reported a problem as fine, or got a severity wrong.
90
+
91
+ - **Scans the right environment:** facts come from your project's interpreter, not PyDoctor's. Under pipx or `uv tool`, and in uv environments (which have no pip), 2.1.0 saw 0 packages and reported everything as OK. Works without pip (package metadata, `uv pip list --outdated`). New `--python` / `PYDOCTOR_PYTHON`; the report shows which interpreter was scanned and why.
92
+ - **Fails loudly:** `ENV_PACKAGES_UNAVAILABLE` (error) when the environment can't be read, `ENV_DEPS_NOT_INSTALLED` when the scanned Python has none of your dependencies, and outdated checks that can't run say so instead of "all up to date".
93
+ - **Virtualenv detection works:** it was always "active". The `diagnose` no-venv prompt now appears, and never blocks CI or piped runs.
94
+ - **Real CVSS scores:** severity is computed from the CVSS v3 vector. Previously a 3.1 (Low) and a 9.8 (Critical) vulnerability were both shown as critical. GitHub's `MODERATE` is now medium.
95
+ - **Right fix version:** for advisories with one fix per release line, `fix` targets your line (Django 5.1.2 → 5.1.4, not 4.2.17).
96
+ - **CI guard finds real leaks:** scans every committed text file, adds Stripe, OpenAI, Anthropic, Slack, Google, private-key, database-URL and `SECRET_KEY` rules, flags committed `.env` files, and checks workflows for `pull_request_target` "pwn requests", script injection and `write-all` permissions. Findings have line numbers; add `# pydoctor: ignore` to silence one. Zero findings on Flask, Django, requests, httpx, FastAPI, DRF and pip.
97
+ - **A score that moves:** a package's advisories count once, categories have diminishing returns, and any critical issue keeps the score below "Good". Expect different (usually higher) scores than 2.1.0.
98
+ - **JSON:** includes `health`; `scan-security --json` reports advisory IDs, aliases, fixed version and CVSS score.
99
+ - Smaller: database drivers, hashers and cache clients picked by Django settings count as used; faster advisory fetching; `--no-cache` no longer wipes the cache; circular import fixed; `github <repo>` and `docker` describe what they actually do.
100
+
101
+ ### v2.1.0
102
+ - **`pydoctor fix` reports what really happened:** upgrades are verified against the installed version. If a pin in `pyproject.toml` blocks an upgrade, pydoctor says so and shows the constraint instead of printing "fixed".
103
+ - **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
104
+ - **No accidental removals:** `pydoctor fix <package>` no longer offers to uninstall it. Removal only runs with `--remove`.
105
+ - **Fewer false "unused" reports:** packages imported under a different name (`decouple`), loaded from strings (Django `INSTALLED_APPS`, `MIDDLEWARE`, backends), or run from a Procfile/Dockerfile (`gunicorn`) are recognised as used. Remaining findings are labelled "Possibly unused".
106
+
77
107
  ### v2.0.1
78
108
  - **Security scans fail closed:** if the OSV database can't be reached, pydoctor now reports `SEC_SCAN_INCOMPLETE` (error, exit 1) instead of a clean result.
79
109
  - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
@@ -9,6 +9,6 @@ Author: Favour Bamgboye
9
9
  License: MIT
10
10
  """
11
11
 
12
- __version__ = "2.0.1"
12
+ __version__ = "2.2.0"
13
13
  __author__ = "Favour Bamgboye"
14
14
  __description__ = "Python environment diagnostic CLI — your project's doctor."
@@ -6,6 +6,7 @@ Calculates the numerical project health score and verdict.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import math
9
10
  from dataclasses import dataclass
10
11
 
11
12
  from pydoctor.core.report import DiagnosisReport, Issue
@@ -18,20 +19,62 @@ class HealthMetrics:
18
19
  message: str
19
20
 
20
21
 
22
+ # Each category can cost at most this many points. Within a category every
23
+ # extra issue still lowers the score, but by less (diminishing returns), so
24
+ # fixing issues in a project with dozens of them visibly moves the score.
25
+ CATEGORY_CAPS: dict[str, float] = {
26
+ "security": 60,
27
+ "ci": 50,
28
+ "dependencies": 40,
29
+ "environment": 30,
30
+ "outdated": 15,
31
+ "unused": 10,
32
+ }
33
+
34
+ # A project with any critical issue can't be rated Good or Excellent.
35
+ CRITICAL_CEILING = 69
36
+
37
+ # Issues that mean part of the scan didn't run.
38
+ INCOMPLETE_CODES = {"ENV_PACKAGES_UNAVAILABLE", "SEC_SCAN_INCOMPLETE"}
39
+
40
+
21
41
  def calculate_health(report: DiagnosisReport) -> HealthMetrics:
22
42
  """
23
43
  Calculate project health score (0-100) based on identified issues.
24
44
  """
25
45
  score = _compute_score(report)
26
- categories = list({i.category for i in report.issues})
27
- return _get_verdict(score, categories)
46
+ categories = list({i.category for i in report.issues if i.code not in INCOMPLETE_CODES})
47
+ metrics = _get_verdict(score, categories)
48
+ if any(i.code in INCOMPLETE_CODES for i in report.issues):
49
+ metrics.verdict = "Incomplete"
50
+ metrics.message = (
51
+ "Some checks couldn't run, so this score only covers what was checked. "
52
+ "See the errors above."
53
+ )
54
+ return metrics
28
55
 
29
56
 
30
57
  def _compute_score(report: DiagnosisReport) -> int:
31
- score = 100
58
+ raw: dict[str, float] = {}
59
+ # One upgrade usually fixes every advisory for a package, so a package
60
+ # costs the penalty of its worst advisory, not one penalty per advisory.
61
+ per_package: dict[str, int] = {}
32
62
  for issue in report.issues:
33
- score -= _calculate_issue_penalty(issue)
34
- return max(0, score)
63
+ penalty = _calculate_issue_penalty(issue)
64
+ if issue.code == "SEC_VULNERABILITY" and issue.package:
65
+ per_package[issue.package] = max(per_package.get(issue.package, 0), penalty)
66
+ continue
67
+ raw[issue.category] = raw.get(issue.category, 0) + penalty
68
+ if per_package:
69
+ raw["security"] = raw.get("security", 0) + sum(per_package.values())
70
+ lost = 0.0
71
+ for category, points in raw.items():
72
+ cap = CATEGORY_CAPS.get(category)
73
+ lost += cap * (1 - math.exp(-points / cap)) if cap else points
74
+ score = max(0, round(100 - lost))
75
+ if any(i.severity.lower() == "critical" for i in report.issues):
76
+ score = min(score, CRITICAL_CEILING)
77
+ return score
35
78
 
36
79
 
37
80
  def _calculate_issue_penalty(issue: Issue) -> int:
@@ -79,7 +79,7 @@ class CacheManager:
79
79
 
80
80
  return entry.get("value")
81
81
 
82
- def set(self, key: str, value: Any) -> None:
82
+ def set(self, key: str, value: Any, save: bool = True) -> None:
83
83
  """
84
84
  Store a value in the cache with the current timestamp.
85
85
 
@@ -87,8 +87,15 @@ class CacheManager:
87
87
  ----------
88
88
  key: Cache key.
89
89
  value: Any JSON-serialisable value.
90
+ save: Write to disk now. Pass False when setting many keys, then call
91
+ ``save()`` once (writing the whole file per key is quadratic).
90
92
  """
91
93
  self._data[key] = {"_ts": time.time(), "value": value}
94
+ if save:
95
+ self._save()
96
+
97
+ def save(self) -> None:
98
+ """Persist the cache to disk."""
92
99
  self._save()
93
100
 
94
101
  def invalidate(self, key: str) -> None: