muninn-cli 0.3.0__tar.gz → 0.3.1__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.
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/PKG-INFO +1 -1
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/commands.md +14 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/pack-spec.md +21 -1
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/plugin-dev.md +5 -4
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/pyproject.toml +1 -1
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/cli/manager.py +152 -1
- muninn_cli-0.3.1/tests/cli/test_dependencies.py +270 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/cli/test_manager.py +168 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/test_helpers.py +34 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/uv.lock +1 -1
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/workflows/ci.yml +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/workflows/deploy-docs.yml +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/workflows/publish.yml +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.gitignore +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.python-version +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.vscode/settings.json +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/LICENSE +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/README.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/README_zh.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/base-plugin.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/data-plugin.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/flashcard.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/matchers.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/getting-started.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/index.md +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/chemistry/elements.json +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/chemistry/manifest.json +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/chemistry/plugin.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/flashcard/manifest.json +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/flashcard/plugin.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/flashcard/words.csv +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/main.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/scripts/bump_version.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/__init__.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/cli/__init__.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/cli/runner.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/__init__.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/base_plugin.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/helpers.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/scheduler.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/state.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/main.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/ui.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/__init__.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/cli/__init__.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/conftest.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/__init__.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/test_scheduler.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/test_state.py +0 -0
- {muninn_cli-0.3.0 → muninn_cli-0.3.1}/zensical.toml +0 -0
|
@@ -24,6 +24,20 @@ The pack is copied to `~/.muninn/packs/<pack_id>/`. Installing the same
|
|
|
24
24
|
Muninn records the installation source in the pack's `manifest.json` so
|
|
25
25
|
that `muninn upgrade` knows where to check for updates later.
|
|
26
26
|
|
|
27
|
+
### Dependencies
|
|
28
|
+
|
|
29
|
+
If a pack includes a `requirements.txt` at its root, Muninn creates an
|
|
30
|
+
isolated virtual environment at `~/.muninn/venvs/<pack_id>/` and
|
|
31
|
+
installs the declared packages there. Each pack gets its own venv, so
|
|
32
|
+
two packs can depend on different versions of the same library without
|
|
33
|
+
conflict. No manual `pip install` step is required — the next
|
|
34
|
+
`muninn run` will find the installed packages automatically.
|
|
35
|
+
|
|
36
|
+
This is designed for packs that need third-party libraries (e.g. `openai`
|
|
37
|
+
for AI-powered answer checking, `requests` for fetching live data). The
|
|
38
|
+
per-pack venv keeps plugin dependencies isolated from your system Python
|
|
39
|
+
and from each other.
|
|
40
|
+
|
|
27
41
|
## `muninn upgrade`
|
|
28
42
|
|
|
29
43
|
Check for and install newer versions of installed packs.
|
|
@@ -7,8 +7,9 @@ a `plugin.py`.
|
|
|
7
7
|
|
|
8
8
|
``` bash
|
|
9
9
|
my-pack/
|
|
10
|
-
├── manifest.json
|
|
10
|
+
├── manifest.json # Required: pack metadata
|
|
11
11
|
├── plugin.py # Required: plugin logic
|
|
12
|
+
├── requirements.txt # Optional: pip dependencies
|
|
12
13
|
├── data.csv # Optional: static data
|
|
13
14
|
└── data.json # Optional: static data
|
|
14
15
|
```
|
|
@@ -65,3 +66,22 @@ When a pack is installed, Muninn records the installation source in
|
|
|
65
66
|
Pack authors should [bump the `version` field](https://semver.org/)
|
|
66
67
|
whenever they publish changes — that is the signal Muninn uses to
|
|
67
68
|
determine whether an upgrade is available.
|
|
69
|
+
|
|
70
|
+
## Dependencies
|
|
71
|
+
|
|
72
|
+
If your plugin needs third-party Python packages, place a standard
|
|
73
|
+
`requirements.txt` at the root of your pack:
|
|
74
|
+
|
|
75
|
+
``` text
|
|
76
|
+
openai>=1.0.0
|
|
77
|
+
httpx>=0.27.0
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Muninn creates an isolated virtual environment at
|
|
81
|
+
`~/.muninn/venvs/<pack_id>/` for each pack and installs the declared
|
|
82
|
+
packages there automatically during `muninn install` and
|
|
83
|
+
`muninn upgrade`. No action is needed from the user.
|
|
84
|
+
|
|
85
|
+
If dependency installation fails (e.g. a typo in the package name, a
|
|
86
|
+
network error), Muninn does **not** replace the pack. The existing
|
|
87
|
+
version stays in place and an error message is printed.
|
|
@@ -23,10 +23,11 @@ full control.
|
|
|
23
23
|
|
|
24
24
|
``` bash
|
|
25
25
|
my-pack/
|
|
26
|
-
├── manifest.json
|
|
27
|
-
├── plugin.py
|
|
28
|
-
├──
|
|
29
|
-
|
|
26
|
+
├── manifest.json # Package metadata
|
|
27
|
+
├── plugin.py # Core logic
|
|
28
|
+
├── requirements.txt # Optional: pip dependencies
|
|
29
|
+
├── data.csv # Static data (optional — whatever your plugin needs)
|
|
30
|
+
└── data.json # Alternative data format
|
|
30
31
|
```
|
|
31
32
|
|
|
32
33
|
## manifest.json
|
|
@@ -3,7 +3,9 @@ import json
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import shutil
|
|
6
|
+
import subprocess
|
|
6
7
|
import sys
|
|
8
|
+
import sysconfig
|
|
7
9
|
import tempfile
|
|
8
10
|
import urllib.request
|
|
9
11
|
import zipfile
|
|
@@ -13,6 +15,9 @@ from ..core.base_plugin import BaseRecitePlugin
|
|
|
13
15
|
# Timeout for remote pack downloads (in seconds)
|
|
14
16
|
DOWNLOAD_TIMEOUT = 30
|
|
15
17
|
|
|
18
|
+
# Timeout for subprocess operations (venv creation, pip install) in seconds
|
|
19
|
+
SUBPROCESS_TIMEOUT = 300
|
|
20
|
+
|
|
16
21
|
|
|
17
22
|
class PackageManager:
|
|
18
23
|
def __init__(self):
|
|
@@ -23,6 +28,36 @@ class PackageManager:
|
|
|
23
28
|
# Public API
|
|
24
29
|
# ------------------------------------------------------------------
|
|
25
30
|
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _validate_pack_id(pack_id: str) -> None:
|
|
33
|
+
"""Validate that pack_id is safe to use in filesystem paths.
|
|
34
|
+
|
|
35
|
+
Raises ValueError if pack_id contains path traversal sequences
|
|
36
|
+
or other unsafe characters.
|
|
37
|
+
"""
|
|
38
|
+
if not isinstance(pack_id, str):
|
|
39
|
+
raise TypeError(f"pack_id must be a string, got {type(pack_id).__name__}")
|
|
40
|
+
|
|
41
|
+
if not pack_id:
|
|
42
|
+
raise ValueError("pack_id cannot be empty")
|
|
43
|
+
|
|
44
|
+
# Check for path traversal attempts
|
|
45
|
+
if pack_id in (".", ".."):
|
|
46
|
+
raise ValueError(f"Invalid pack_id: '{pack_id}' is not allowed")
|
|
47
|
+
|
|
48
|
+
# Check for path separators
|
|
49
|
+
if os.sep in pack_id or "/" in pack_id or "\\" in pack_id:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Invalid pack_id: '{pack_id}' must not contain path separators"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Enforce alphanumeric, dots, underscores, and hyphens only
|
|
55
|
+
if not re.match(r"^[A-Za-z0-9._-]+$", pack_id):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Invalid pack_id: '{pack_id}' must contain only alphanumeric characters, "
|
|
58
|
+
"dots, underscores, and hyphens"
|
|
59
|
+
)
|
|
60
|
+
|
|
26
61
|
def install_pack(self, source: str) -> str:
|
|
27
62
|
"""Install a pack from a local directory, a zip file, or a GitHub URL.
|
|
28
63
|
|
|
@@ -53,10 +88,17 @@ class PackageManager:
|
|
|
53
88
|
|
|
54
89
|
def uninstall_pack(self, pack_id: str):
|
|
55
90
|
"""Remove a pack from the local packs directory."""
|
|
91
|
+
self._validate_pack_id(pack_id)
|
|
56
92
|
pack_dir = self._get_pack_dir(pack_id)
|
|
57
93
|
if not os.path.exists(pack_dir):
|
|
58
94
|
raise FileNotFoundError(f"Pack '{pack_id}' is not installed.")
|
|
59
95
|
shutil.rmtree(pack_dir)
|
|
96
|
+
|
|
97
|
+
# Also remove the per-pack virtual environment if it exists
|
|
98
|
+
venv_dir = self._get_pack_venv_dir(pack_id)
|
|
99
|
+
if os.path.exists(venv_dir):
|
|
100
|
+
shutil.rmtree(venv_dir, ignore_errors=True)
|
|
101
|
+
|
|
60
102
|
print(f"🗑️ Pack '{pack_id}' has been uninstalled.")
|
|
61
103
|
|
|
62
104
|
def upgrade_pack(self, pack_id: str) -> bool:
|
|
@@ -64,6 +106,7 @@ class PackageManager:
|
|
|
64
106
|
|
|
65
107
|
Returns ``True`` if an upgrade was performed, ``False`` otherwise.
|
|
66
108
|
"""
|
|
109
|
+
self._validate_pack_id(pack_id)
|
|
67
110
|
manifest = self._read_installed_manifest(pack_id)
|
|
68
111
|
if manifest is None:
|
|
69
112
|
raise FileNotFoundError(f"Pack '{pack_id}' is not installed.")
|
|
@@ -120,6 +163,7 @@ class PackageManager:
|
|
|
120
163
|
|
|
121
164
|
def load_plugin(self, pack_id: str) -> BaseRecitePlugin:
|
|
122
165
|
"""Dynamically load the plugin.py from the pack_id directory."""
|
|
166
|
+
self._validate_pack_id(pack_id)
|
|
123
167
|
pack_dir = self._get_pack_dir(pack_id)
|
|
124
168
|
if not os.path.exists(pack_dir):
|
|
125
169
|
raise FileNotFoundError(f"Pack '{pack_id}' not found.")
|
|
@@ -128,6 +172,12 @@ class PackageManager:
|
|
|
128
172
|
if not os.path.exists(plugin_path):
|
|
129
173
|
raise FileNotFoundError(f"plugin.py not found in {pack_id}.")
|
|
130
174
|
|
|
175
|
+
orig_sys_path = sys.path.copy()
|
|
176
|
+
# Make packages from the pack's isolated virtual environment importable (e.g. openai, httpx)
|
|
177
|
+
venv_site = self._get_pack_site_packages(pack_id)
|
|
178
|
+
if os.path.isdir(venv_site):
|
|
179
|
+
sys.path.insert(0, venv_site)
|
|
180
|
+
|
|
131
181
|
src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
132
182
|
sys.path.insert(0, src_dir)
|
|
133
183
|
|
|
@@ -152,7 +202,7 @@ class PackageManager:
|
|
|
152
202
|
f"No valid BaseRecitePlugin subclass found in {plugin_path}"
|
|
153
203
|
)
|
|
154
204
|
finally:
|
|
155
|
-
sys.path
|
|
205
|
+
sys.path[:] = orig_sys_path
|
|
156
206
|
|
|
157
207
|
def list_packs(self):
|
|
158
208
|
packs = []
|
|
@@ -382,12 +432,24 @@ class Plugin(DataPlugin):
|
|
|
382
432
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
383
433
|
raise ValueError("Invalid manifest: missing 'id'")
|
|
384
434
|
|
|
435
|
+
# Validate pack_id to prevent path traversal attacks
|
|
436
|
+
try:
|
|
437
|
+
self._validate_pack_id(pack_id)
|
|
438
|
+
except ValueError as e:
|
|
439
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
440
|
+
raise ValueError(f"Invalid manifest: {e}") from e
|
|
441
|
+
|
|
385
442
|
# Inject source tracking info before writing
|
|
386
443
|
if source_info:
|
|
387
444
|
manifest["source"] = source_info
|
|
388
445
|
with open(manifest_path, "w", encoding="utf-8") as f:
|
|
389
446
|
json.dump(manifest, f, indent=4, ensure_ascii=False)
|
|
390
447
|
|
|
448
|
+
# Install dependencies *before* replacing the existing pack so
|
|
449
|
+
# that a failed pip install does not leave the pack in a broken
|
|
450
|
+
# state or destroy the previous version.
|
|
451
|
+
self._install_pack_dependencies(temp_dir, pack_id)
|
|
452
|
+
|
|
391
453
|
target_dir = self._get_pack_dir(pack_id)
|
|
392
454
|
if os.path.exists(target_dir):
|
|
393
455
|
print(f"🔄 Updating existing pack: {pack_id}")
|
|
@@ -395,12 +457,101 @@ class Plugin(DataPlugin):
|
|
|
395
457
|
|
|
396
458
|
# Ensure temp_dir parent is writable for shutil.move
|
|
397
459
|
shutil.move(temp_dir, target_dir)
|
|
460
|
+
|
|
398
461
|
return pack_id
|
|
399
462
|
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# Internal: per-pack dependency management
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
# Each pack that declares a ``requirements.txt`` gets its own
|
|
467
|
+
# isolated venv under ``~/.muninn/venvs/<pack_id>/``. This
|
|
468
|
+
# prevents version conflicts between packs.
|
|
469
|
+
|
|
470
|
+
def _get_pack_venv_dir(self, pack_id: str) -> str:
|
|
471
|
+
"""Return the path to the isolated venv for *pack_id*."""
|
|
472
|
+
return os.path.join(os.path.expanduser("~/.muninn/venvs"), pack_id)
|
|
473
|
+
|
|
474
|
+
def _ensure_pack_venv(self, pack_id: str) -> str:
|
|
475
|
+
"""Return the path to the Python interpreter inside *pack_id*'s
|
|
476
|
+
isolated venv, creating the venv if it doesn't already exist."""
|
|
477
|
+
venv_dir = self._get_pack_venv_dir(pack_id)
|
|
478
|
+
exe_name = os.path.basename(sys.executable)
|
|
479
|
+
python_exe = os.path.join(
|
|
480
|
+
venv_dir,
|
|
481
|
+
"Scripts" if os.name == "nt" else "bin",
|
|
482
|
+
exe_name,
|
|
483
|
+
)
|
|
484
|
+
if os.path.isfile(python_exe):
|
|
485
|
+
return python_exe
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
subprocess.run(
|
|
489
|
+
[sys.executable, "-m", "venv", "--clear", venv_dir],
|
|
490
|
+
check=True,
|
|
491
|
+
capture_output=True,
|
|
492
|
+
text=True,
|
|
493
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
494
|
+
)
|
|
495
|
+
except subprocess.TimeoutExpired as exc:
|
|
496
|
+
raise RuntimeError(
|
|
497
|
+
f"Timed out creating dependency environment at '{venv_dir}' "
|
|
498
|
+
f"after {SUBPROCESS_TIMEOUT} seconds"
|
|
499
|
+
) from exc
|
|
500
|
+
except subprocess.CalledProcessError as exc:
|
|
501
|
+
raise RuntimeError(
|
|
502
|
+
"Failed to create dependency environment at "
|
|
503
|
+
f"'{venv_dir}'. Your Python installation may "
|
|
504
|
+
"not include 'venv' (try 'apt install python3-venv' "
|
|
505
|
+
f"on Debian/Ubuntu). Original error: {exc.stderr.strip()}"
|
|
506
|
+
) from exc
|
|
507
|
+
return python_exe
|
|
508
|
+
|
|
509
|
+
def _get_pack_site_packages(self, pack_id: str) -> str:
|
|
510
|
+
"""Return the site-packages directory for *pack_id*'s venv."""
|
|
511
|
+
return sysconfig.get_path(
|
|
512
|
+
"purelib",
|
|
513
|
+
scheme="venv",
|
|
514
|
+
vars={"base": self._get_pack_venv_dir(pack_id)},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _install_pack_dependencies(self, pack_dir: str, pack_id: str) -> None:
|
|
518
|
+
"""If *pack_dir* contains a ``requirements.txt``, install its
|
|
519
|
+
contents into the pack's isolated venv."""
|
|
520
|
+
req_path = os.path.join(pack_dir, "requirements.txt")
|
|
521
|
+
if not os.path.isfile(req_path):
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
# Skip empty requirements files --- venv creation would be wasteful.
|
|
525
|
+
if os.path.getsize(req_path) == 0:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
venv_python = self._ensure_pack_venv(pack_id)
|
|
529
|
+
pip = os.path.join(os.path.dirname(venv_python), "pip")
|
|
530
|
+
|
|
531
|
+
print(f"📦 Installing dependencies for '{pack_id}' ...")
|
|
532
|
+
try:
|
|
533
|
+
subprocess.run(
|
|
534
|
+
[pip, "install", "--quiet", "-r", req_path],
|
|
535
|
+
check=True,
|
|
536
|
+
capture_output=True,
|
|
537
|
+
text=True,
|
|
538
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
539
|
+
)
|
|
540
|
+
except subprocess.TimeoutExpired as exc:
|
|
541
|
+
raise RuntimeError(
|
|
542
|
+
f"Timed out installing dependencies for '{pack_id}' after {SUBPROCESS_TIMEOUT} seconds"
|
|
543
|
+
) from exc
|
|
544
|
+
except subprocess.CalledProcessError as exc:
|
|
545
|
+
raise RuntimeError(
|
|
546
|
+
f"Failed to install dependencies for '{pack_id}': {exc.stderr.strip()}"
|
|
547
|
+
) from exc
|
|
548
|
+
|
|
400
549
|
# ------------------------------------------------------------------
|
|
401
550
|
# Internal: upgrade helpers
|
|
402
551
|
# ------------------------------------------------------------------
|
|
403
552
|
|
|
553
|
+
# ------------------------------------------------------------------
|
|
554
|
+
|
|
404
555
|
def _read_installed_manifest(self, pack_id: str) -> dict | None:
|
|
405
556
|
manifest_path = os.path.join(self._get_pack_dir(pack_id), "manifest.json")
|
|
406
557
|
if not os.path.exists(manifest_path):
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Tests for per-pack venv dependency management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from src.cli.manager import PackageManager
|
|
11
|
+
|
|
12
|
+
# -----------------------------------------------------------------------
|
|
13
|
+
# Helpers
|
|
14
|
+
# -----------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_pack_with_deps(path, pack_id, version="1.0.0", requirements=None):
|
|
18
|
+
"""Create a minimal pack with an optional requirements.txt."""
|
|
19
|
+
os.makedirs(path, exist_ok=True)
|
|
20
|
+
(path / "manifest.json").write_text(
|
|
21
|
+
json.dumps(
|
|
22
|
+
{
|
|
23
|
+
"id": pack_id,
|
|
24
|
+
"name": f"{pack_id} Name",
|
|
25
|
+
"version": version,
|
|
26
|
+
"description": "Test pack",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
(path / "plugin.py").write_text(
|
|
31
|
+
"from core.base_plugin import BaseRecitePlugin\n"
|
|
32
|
+
"class Plugin(BaseRecitePlugin):\n"
|
|
33
|
+
" def load_data(self): pass\n"
|
|
34
|
+
" def get_all_problem_ids(self): return []\n"
|
|
35
|
+
" def render_statement(self, pid): return ''\n"
|
|
36
|
+
" def check_answer(self, pid, ui): return False\n"
|
|
37
|
+
" def get_expected_display(self, pid): return ''\n"
|
|
38
|
+
)
|
|
39
|
+
if requirements:
|
|
40
|
+
(path / "requirements.txt").write_text(requirements)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# -----------------------------------------------------------------------
|
|
44
|
+
# Tests
|
|
45
|
+
# -----------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestEnsurePackVenv:
|
|
49
|
+
# These two tests are the only ones that create *real* venvs,
|
|
50
|
+
# because they're testing the venv creation logic itself.
|
|
51
|
+
|
|
52
|
+
def test_creates_venv_for_pack(self, monkeypatch, tmp_path):
|
|
53
|
+
venvs_base = tmp_path / "venvs"
|
|
54
|
+
m = PackageManager()
|
|
55
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
56
|
+
|
|
57
|
+
python_exe = m._ensure_pack_venv("p1")
|
|
58
|
+
|
|
59
|
+
assert os.path.isfile(python_exe)
|
|
60
|
+
assert os.path.isdir(str(venvs_base / "p1"))
|
|
61
|
+
|
|
62
|
+
def test_separate_venvs_for_different_packs(self, monkeypatch, tmp_path):
|
|
63
|
+
venvs_base = tmp_path / "venvs"
|
|
64
|
+
m = PackageManager()
|
|
65
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
66
|
+
|
|
67
|
+
py_a = m._ensure_pack_venv("pack_a")
|
|
68
|
+
py_b = m._ensure_pack_venv("pack_b")
|
|
69
|
+
|
|
70
|
+
assert os.path.isfile(py_a)
|
|
71
|
+
assert os.path.isfile(py_b)
|
|
72
|
+
assert py_a != py_b
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestGetPackSitePackages:
|
|
76
|
+
def test_returns_distinct_paths(self, monkeypatch, tmp_path):
|
|
77
|
+
venvs_base = tmp_path / "venvs"
|
|
78
|
+
m = PackageManager()
|
|
79
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
80
|
+
|
|
81
|
+
site_a = m._get_pack_site_packages("x")
|
|
82
|
+
site_b = m._get_pack_site_packages("y")
|
|
83
|
+
|
|
84
|
+
assert site_a != site_b
|
|
85
|
+
assert "site-packages" in site_a
|
|
86
|
+
assert "site-packages" in site_b
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestInstallPackDependencies:
|
|
90
|
+
def test_noop_when_no_requirements(self, monkeypatch, tmp_path):
|
|
91
|
+
pack_dir = tmp_path / "pack"
|
|
92
|
+
os.makedirs(pack_dir)
|
|
93
|
+
m = PackageManager()
|
|
94
|
+
monkeypatch.setattr(
|
|
95
|
+
m, "_get_pack_venv_dir", lambda pid: str(tmp_path / "venvs" / pid)
|
|
96
|
+
)
|
|
97
|
+
m._install_pack_dependencies(str(pack_dir), "nopkg")
|
|
98
|
+
|
|
99
|
+
def test_noop_when_empty_requirements(self, monkeypatch, tmp_path):
|
|
100
|
+
pack_dir = tmp_path / "pack"
|
|
101
|
+
os.makedirs(pack_dir)
|
|
102
|
+
(pack_dir / "requirements.txt").write_text("")
|
|
103
|
+
m = PackageManager()
|
|
104
|
+
monkeypatch.setattr(
|
|
105
|
+
m, "_get_pack_venv_dir", lambda pid: str(tmp_path / "venvs" / pid)
|
|
106
|
+
)
|
|
107
|
+
m._install_pack_dependencies(str(pack_dir), "emptypkg")
|
|
108
|
+
|
|
109
|
+
def test_pip_called_in_correct_venv(self, monkeypatch, tmp_path):
|
|
110
|
+
"""pip install is invoked inside the pack's own venv.
|
|
111
|
+
|
|
112
|
+
``_ensure_pack_venv`` is mocked so this test does not pay the
|
|
113
|
+
cost of a real ``python -m venv`` call.
|
|
114
|
+
"""
|
|
115
|
+
venvs_base = tmp_path / "venvs"
|
|
116
|
+
pack_dir = tmp_path / "p1"
|
|
117
|
+
os.makedirs(pack_dir)
|
|
118
|
+
(pack_dir / "requirements.txt").write_text("foo>=1.0\n")
|
|
119
|
+
|
|
120
|
+
fake_python = str(venvs_base / "p1" / "bin" / os.path.basename(sys.executable))
|
|
121
|
+
m = PackageManager()
|
|
122
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
123
|
+
monkeypatch.setattr(m, "_ensure_pack_venv", lambda pid: fake_python)
|
|
124
|
+
|
|
125
|
+
calls = []
|
|
126
|
+
real_run = subprocess.run
|
|
127
|
+
|
|
128
|
+
def fake_run(args, **kwargs):
|
|
129
|
+
if any("pip" in str(a) for a in args):
|
|
130
|
+
calls.append((args, kwargs))
|
|
131
|
+
return subprocess.CompletedProcess(args, 0)
|
|
132
|
+
return real_run(args, **kwargs)
|
|
133
|
+
|
|
134
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
135
|
+
|
|
136
|
+
m._install_pack_dependencies(str(pack_dir), "p1")
|
|
137
|
+
|
|
138
|
+
assert len(calls) == 1
|
|
139
|
+
pip_args = calls[0][0]
|
|
140
|
+
assert "install" in pip_args
|
|
141
|
+
assert str(pack_dir / "requirements.txt") in pip_args
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestConflictingVersions:
|
|
145
|
+
def test_install_two_packs_with_conflicting_pins(self, monkeypatch, tmp_path):
|
|
146
|
+
venvs_base = tmp_path / "venvs"
|
|
147
|
+
packs_dir = tmp_path / "packs"
|
|
148
|
+
os.makedirs(packs_dir)
|
|
149
|
+
|
|
150
|
+
src_a = tmp_path / "src_a"
|
|
151
|
+
_make_pack_with_deps(src_a, "pack_a", "1.0.0", "foo==1.0.0\n")
|
|
152
|
+
src_b = tmp_path / "src_b"
|
|
153
|
+
_make_pack_with_deps(src_b, "pack_b", "1.0.0", "foo==2.0.0\n")
|
|
154
|
+
|
|
155
|
+
m = PackageManager()
|
|
156
|
+
monkeypatch.setattr(m, "packs_dir", str(packs_dir))
|
|
157
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
158
|
+
monkeypatch.setattr(
|
|
159
|
+
m,
|
|
160
|
+
"_ensure_pack_venv",
|
|
161
|
+
lambda pid: str(
|
|
162
|
+
venvs_base / pid / "bin" / os.path.basename(sys.executable)
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
pip_calls = []
|
|
167
|
+
real_run = subprocess.run
|
|
168
|
+
|
|
169
|
+
def fake_run(args, **kwargs):
|
|
170
|
+
if any("pip" in str(a) for a in args):
|
|
171
|
+
pip_calls.append((args, kwargs))
|
|
172
|
+
return subprocess.CompletedProcess(args, 0)
|
|
173
|
+
return real_run(args, **kwargs)
|
|
174
|
+
|
|
175
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
176
|
+
|
|
177
|
+
m.install_pack(str(src_a))
|
|
178
|
+
m.install_pack(str(src_b))
|
|
179
|
+
|
|
180
|
+
assert len(pip_calls) == 2
|
|
181
|
+
pip0 = pip_calls[0][0]
|
|
182
|
+
pip1 = pip_calls[1][0]
|
|
183
|
+
assert pip0[0] != pip1[0]
|
|
184
|
+
|
|
185
|
+
req_paths = {str(pip_calls[i][0][-1]) for i in range(2)}
|
|
186
|
+
assert all(r.endswith("requirements.txt") for r in req_paths)
|
|
187
|
+
|
|
188
|
+
def test_isolated_site_packages_after_install(self, monkeypatch, tmp_path):
|
|
189
|
+
venvs_base = tmp_path / "venvs"
|
|
190
|
+
packs_dir = tmp_path / "packs"
|
|
191
|
+
os.makedirs(packs_dir)
|
|
192
|
+
|
|
193
|
+
src_a = tmp_path / "src_a"
|
|
194
|
+
_make_pack_with_deps(src_a, "iso_a", "1.0.0", "foo==1.0")
|
|
195
|
+
src_b = tmp_path / "src_b"
|
|
196
|
+
_make_pack_with_deps(src_b, "iso_b", "1.0.0", "foo==2.0")
|
|
197
|
+
|
|
198
|
+
m = PackageManager()
|
|
199
|
+
monkeypatch.setattr(m, "packs_dir", str(packs_dir))
|
|
200
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
201
|
+
monkeypatch.setattr(m, "_install_pack_dependencies", lambda *a: None)
|
|
202
|
+
|
|
203
|
+
m.install_pack(str(src_a))
|
|
204
|
+
m.install_pack(str(src_b))
|
|
205
|
+
|
|
206
|
+
site_a = m._get_pack_site_packages("iso_a")
|
|
207
|
+
site_b = m._get_pack_site_packages("iso_b")
|
|
208
|
+
assert site_a != site_b
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestNoDepsDoesNotCreateVenv:
|
|
212
|
+
def test_pack_without_requirements(self, monkeypatch, tmp_path):
|
|
213
|
+
venvs_base = tmp_path / "venvs"
|
|
214
|
+
packs_dir = tmp_path / "packs"
|
|
215
|
+
os.makedirs(packs_dir)
|
|
216
|
+
|
|
217
|
+
src = tmp_path / "src"
|
|
218
|
+
_make_pack_with_deps(src, "nopkg", "1.0.0")
|
|
219
|
+
|
|
220
|
+
m = PackageManager()
|
|
221
|
+
monkeypatch.setattr(m, "packs_dir", str(packs_dir))
|
|
222
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
223
|
+
|
|
224
|
+
m.install_pack(str(src))
|
|
225
|
+
assert not os.path.isdir(str(venvs_base / "nopkg"))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestFailedDependencyPreservesPack:
|
|
229
|
+
def test_existing_pack_survives_failed_install(self, monkeypatch, tmp_path):
|
|
230
|
+
venvs_base = tmp_path / "venvs"
|
|
231
|
+
packs_dir = tmp_path / "packs"
|
|
232
|
+
os.makedirs(packs_dir)
|
|
233
|
+
|
|
234
|
+
src_old = tmp_path / "src_old"
|
|
235
|
+
_make_pack_with_deps(src_old, "survivor", "1.0.0")
|
|
236
|
+
m = PackageManager()
|
|
237
|
+
monkeypatch.setattr(m, "packs_dir", str(packs_dir))
|
|
238
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", lambda pid: str(venvs_base / pid))
|
|
239
|
+
monkeypatch.setattr(
|
|
240
|
+
m,
|
|
241
|
+
"_ensure_pack_venv",
|
|
242
|
+
lambda pid: str(
|
|
243
|
+
venvs_base / pid / "bin" / os.path.basename(sys.executable)
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
m.install_pack(str(src_old))
|
|
247
|
+
old_manifest = json.loads(
|
|
248
|
+
(packs_dir / "survivor" / "manifest.json").read_text()
|
|
249
|
+
)
|
|
250
|
+
assert old_manifest["version"] == "1.0.0"
|
|
251
|
+
|
|
252
|
+
src_bad = tmp_path / "src_bad"
|
|
253
|
+
_make_pack_with_deps(src_bad, "survivor", "2.0.0", "nonexistent_pkg==999\n")
|
|
254
|
+
|
|
255
|
+
real_run = subprocess.run
|
|
256
|
+
|
|
257
|
+
def fake_run(args, **kwargs):
|
|
258
|
+
if any("pip" in str(a) for a in args):
|
|
259
|
+
raise subprocess.CalledProcessError(
|
|
260
|
+
1, args, stderr="No matching distribution"
|
|
261
|
+
)
|
|
262
|
+
return real_run(args, **kwargs)
|
|
263
|
+
|
|
264
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
265
|
+
|
|
266
|
+
with pytest.raises(RuntimeError, match="Failed to install dependencies"):
|
|
267
|
+
m.install_pack(str(src_bad))
|
|
268
|
+
|
|
269
|
+
surviving = json.loads((packs_dir / "survivor" / "manifest.json").read_text())
|
|
270
|
+
assert surviving["version"] == "1.0.0"
|
|
@@ -170,6 +170,44 @@ class TestUninstallPack:
|
|
|
170
170
|
with pytest.raises(FileNotFoundError, match="not installed"):
|
|
171
171
|
m.uninstall_pack("nonexistent")
|
|
172
172
|
|
|
173
|
+
def test_uninstall_removes_venv(self, tmp_workspace, monkeypatch):
|
|
174
|
+
"""Verify that uninstall_pack removes the per-pack virtual environment."""
|
|
175
|
+
src = os.path.join(tmp_workspace, "packwithvenv")
|
|
176
|
+
_make_minimal_pack(src, pack_id="packwithvenv")
|
|
177
|
+
|
|
178
|
+
# Add a requirements.txt to trigger venv creation
|
|
179
|
+
with open(os.path.join(src, "requirements.txt"), "w") as f:
|
|
180
|
+
f.write("# empty requirements\n")
|
|
181
|
+
|
|
182
|
+
packs_dir = os.path.join(tmp_workspace, "fake_muninn", "packs")
|
|
183
|
+
venvs_dir = os.path.join(tmp_workspace, "fake_muninn", "venvs")
|
|
184
|
+
os.makedirs(packs_dir, exist_ok=True)
|
|
185
|
+
os.makedirs(venvs_dir, exist_ok=True)
|
|
186
|
+
|
|
187
|
+
m = PackageManager()
|
|
188
|
+
monkeypatch.setattr(m, "packs_dir", packs_dir)
|
|
189
|
+
|
|
190
|
+
# Mock _get_pack_venv_dir to use our test venvs_dir
|
|
191
|
+
|
|
192
|
+
def mock_get_venv(pack_id):
|
|
193
|
+
return os.path.join(venvs_dir, pack_id)
|
|
194
|
+
|
|
195
|
+
monkeypatch.setattr(m, "_get_pack_venv_dir", mock_get_venv)
|
|
196
|
+
|
|
197
|
+
# Install the pack (this will create the venv if requirements.txt exists)
|
|
198
|
+
m.install_pack(src)
|
|
199
|
+
|
|
200
|
+
# Verify venv was created
|
|
201
|
+
venv_dir = os.path.join(venvs_dir, "packwithvenv")
|
|
202
|
+
assert os.path.isdir(venv_dir), "venv should exist after install"
|
|
203
|
+
|
|
204
|
+
# Uninstall the pack
|
|
205
|
+
m.uninstall_pack("packwithvenv")
|
|
206
|
+
|
|
207
|
+
# Verify both pack_dir and venv_dir are removed
|
|
208
|
+
assert not os.path.exists(os.path.join(packs_dir, "packwithvenv"))
|
|
209
|
+
assert not os.path.exists(venv_dir), "venv should be removed after uninstall"
|
|
210
|
+
|
|
173
211
|
|
|
174
212
|
class TestUpgrade:
|
|
175
213
|
def test_upgrade_no_source_skips(self, monkeypatch, tmp_workspace):
|
|
@@ -322,3 +360,133 @@ class TestVersion:
|
|
|
322
360
|
assert not PackageManager._is_newer("0.9.0", "1.0.0")
|
|
323
361
|
assert PackageManager._is_newer("1.0.1", "1.0.0")
|
|
324
362
|
assert PackageManager._is_newer("1.1.0", "1.0.9")
|
|
363
|
+
|
|
364
|
+
def test_version_tuple_corner_cases(self):
|
|
365
|
+
# Prefix handling
|
|
366
|
+
assert PackageManager._version_tuple("v1.0.0") == (1, 0, 0)
|
|
367
|
+
assert PackageManager._version_tuple("V2.1.3") == (2, 1, 3)
|
|
368
|
+
# Suffix handling
|
|
369
|
+
assert PackageManager._version_tuple("1.0.0-beta") == (1, 0, 0)
|
|
370
|
+
assert PackageManager._version_tuple("1.2.3.alpha4") == (1, 2, 3)
|
|
371
|
+
# Short versions
|
|
372
|
+
assert PackageManager._version_tuple("1.0") == (1, 0, 0)
|
|
373
|
+
assert PackageManager._version_tuple("2") == (2, 0, 0)
|
|
374
|
+
# Invalid / unexpected formats gracefully falling back
|
|
375
|
+
assert PackageManager._version_tuple("v.1.0") == (0, 0, 0)
|
|
376
|
+
assert PackageManager._version_tuple("version-1.0") == (0, 0, 0)
|
|
377
|
+
assert PackageManager._version_tuple("1.b.c") == (1, 0, 0)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class TestPackIdValidation:
|
|
381
|
+
def test_valid_pack_ids(self):
|
|
382
|
+
"""Test that valid pack_ids are accepted."""
|
|
383
|
+
m = PackageManager()
|
|
384
|
+
# These should not raise
|
|
385
|
+
m._validate_pack_id("my-pack")
|
|
386
|
+
m._validate_pack_id("my_pack")
|
|
387
|
+
m._validate_pack_id("my.pack")
|
|
388
|
+
m._validate_pack_id("MyPack123")
|
|
389
|
+
m._validate_pack_id("pack-1.0.0")
|
|
390
|
+
m._validate_pack_id("pack_with_underscores")
|
|
391
|
+
|
|
392
|
+
def test_rejects_path_traversal(self):
|
|
393
|
+
"""Test that path traversal attempts are rejected."""
|
|
394
|
+
m = PackageManager()
|
|
395
|
+
with pytest.raises(ValueError, match="not allowed"):
|
|
396
|
+
m._validate_pack_id("..")
|
|
397
|
+
with pytest.raises(ValueError, match="not allowed"):
|
|
398
|
+
m._validate_pack_id(".")
|
|
399
|
+
|
|
400
|
+
def test_rejects_path_separators(self):
|
|
401
|
+
"""Test that pack_ids with path separators are rejected."""
|
|
402
|
+
m = PackageManager()
|
|
403
|
+
with pytest.raises(ValueError, match="path separators"):
|
|
404
|
+
m._validate_pack_id("../malicious")
|
|
405
|
+
with pytest.raises(ValueError, match="path separators"):
|
|
406
|
+
m._validate_pack_id("path/to/pack")
|
|
407
|
+
with pytest.raises(ValueError, match="path separators"):
|
|
408
|
+
m._validate_pack_id("path\\to\\pack")
|
|
409
|
+
|
|
410
|
+
def test_rejects_invalid_characters(self):
|
|
411
|
+
"""Test that pack_ids with invalid characters are rejected."""
|
|
412
|
+
m = PackageManager()
|
|
413
|
+
with pytest.raises(ValueError, match="alphanumeric"):
|
|
414
|
+
m._validate_pack_id("pack with spaces")
|
|
415
|
+
with pytest.raises(ValueError, match="alphanumeric"):
|
|
416
|
+
m._validate_pack_id("pack@version")
|
|
417
|
+
with pytest.raises(ValueError, match="alphanumeric"):
|
|
418
|
+
m._validate_pack_id("pack$name")
|
|
419
|
+
|
|
420
|
+
def test_rejects_empty_pack_id(self):
|
|
421
|
+
"""Test that empty pack_id is rejected."""
|
|
422
|
+
m = PackageManager()
|
|
423
|
+
with pytest.raises(ValueError, match="cannot be empty"):
|
|
424
|
+
m._validate_pack_id("")
|
|
425
|
+
|
|
426
|
+
def test_uninstall_validates_pack_id(self, tmp_workspace, monkeypatch):
|
|
427
|
+
"""Test that uninstall_pack validates pack_id."""
|
|
428
|
+
packs_dir = os.path.join(tmp_workspace, "packs")
|
|
429
|
+
os.makedirs(packs_dir, exist_ok=True)
|
|
430
|
+
m = PackageManager()
|
|
431
|
+
monkeypatch.setattr(m, "packs_dir", packs_dir)
|
|
432
|
+
with pytest.raises(ValueError, match="path separators"):
|
|
433
|
+
m.uninstall_pack("../malicious")
|
|
434
|
+
|
|
435
|
+
def test_load_plugin_validates_pack_id(self):
|
|
436
|
+
"""Test that load_plugin validates pack_id."""
|
|
437
|
+
m = PackageManager()
|
|
438
|
+
with pytest.raises(ValueError, match="path separators"):
|
|
439
|
+
m.load_plugin("../malicious")
|
|
440
|
+
|
|
441
|
+
def test_upgrade_pack_validates_pack_id(self):
|
|
442
|
+
"""Test that upgrade_pack validates pack_id."""
|
|
443
|
+
m = PackageManager()
|
|
444
|
+
with pytest.raises(ValueError, match="path separators"):
|
|
445
|
+
m.upgrade_pack("../malicious")
|
|
446
|
+
|
|
447
|
+
def test_install_rejects_malicious_manifest(self, tmp_workspace, monkeypatch):
|
|
448
|
+
"""Test that install_pack rejects packs with malicious pack_id in manifest."""
|
|
449
|
+
src = os.path.join(tmp_workspace, "malicious")
|
|
450
|
+
os.makedirs(src, exist_ok=True)
|
|
451
|
+
# Create manifest with path traversal in id
|
|
452
|
+
manifest = {
|
|
453
|
+
"id": "../escape",
|
|
454
|
+
"name": "Malicious Pack",
|
|
455
|
+
"author": "Attacker",
|
|
456
|
+
"version": "1.0.0",
|
|
457
|
+
"description": "Malicious pack",
|
|
458
|
+
}
|
|
459
|
+
with open(os.path.join(src, "manifest.json"), "w", encoding="utf-8") as f:
|
|
460
|
+
json.dump(manifest, f, indent=4)
|
|
461
|
+
|
|
462
|
+
packs_dir = os.path.join(tmp_workspace, "fake_muninn", "packs")
|
|
463
|
+
os.makedirs(packs_dir, exist_ok=True)
|
|
464
|
+
m = PackageManager()
|
|
465
|
+
monkeypatch.setattr(m, "packs_dir", packs_dir)
|
|
466
|
+
|
|
467
|
+
with pytest.raises(ValueError, match="Invalid manifest"):
|
|
468
|
+
m.install_pack(src)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class TestLoadPluginCornerCases:
|
|
472
|
+
def test_raises_if_no_valid_subclass(self, tmp_workspace, monkeypatch):
|
|
473
|
+
src = os.path.join(tmp_workspace, "badpack")
|
|
474
|
+
os.makedirs(src, exist_ok=True)
|
|
475
|
+
manifest = {"id": "badpack", "version": "1.0.0"}
|
|
476
|
+
with open(os.path.join(src, "manifest.json"), "w") as f:
|
|
477
|
+
json.dump(manifest, f)
|
|
478
|
+
|
|
479
|
+
# Plugin that doesn't subclass BaseRecitePlugin
|
|
480
|
+
with open(os.path.join(src, "plugin.py"), "w") as f:
|
|
481
|
+
f.write("class Plugin:\n pass\n")
|
|
482
|
+
|
|
483
|
+
packs_dir = os.path.join(tmp_workspace, "fake_muninn", "packs")
|
|
484
|
+
os.makedirs(packs_dir, exist_ok=True)
|
|
485
|
+
m = PackageManager()
|
|
486
|
+
monkeypatch.setattr(m, "packs_dir", packs_dir)
|
|
487
|
+
m.install_pack(src)
|
|
488
|
+
|
|
489
|
+
with pytest.raises(
|
|
490
|
+
ValueError, match="No valid BaseRecitePlugin subclass found"
|
|
491
|
+
):
|
|
492
|
+
m.load_plugin("badpack")
|
|
@@ -278,3 +278,37 @@ class TestFlashcardPlugin:
|
|
|
278
278
|
|
|
279
279
|
with pytest.raises(ValueError, match="Unsupported DATA_FILE"):
|
|
280
280
|
BadFlashcard(tmp_workspace)
|
|
281
|
+
|
|
282
|
+
def test_missing_back_key_raises_keyerror(self, tmp_workspace):
|
|
283
|
+
json_path = os.path.join(tmp_workspace, "words.json")
|
|
284
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
285
|
+
json.dump([{"front": "cat"}], f) # Missing "back"
|
|
286
|
+
|
|
287
|
+
p = _JsonFlashcard(tmp_workspace)
|
|
288
|
+
# render_statement and get_expected_display use .get("back", ""), so they won't raise
|
|
289
|
+
assert p.render_statement("0") == "【闪卡】 cat"
|
|
290
|
+
assert p.get_expected_display("0") == ""
|
|
291
|
+
|
|
292
|
+
# But check_answer uses Matchers.exact("back") which accesses data_item["back"] directly
|
|
293
|
+
with pytest.raises(KeyError):
|
|
294
|
+
p.check_answer("0", "anything")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TestMatchersCornerCases:
|
|
298
|
+
def test_missing_key_raises_keyerror(self):
|
|
299
|
+
m1 = Matchers.exact("key")
|
|
300
|
+
m2 = Matchers.exact_integer("key")
|
|
301
|
+
m3 = Matchers.case_insensitive("key")
|
|
302
|
+
m4 = Matchers.chinese_symbol_pair("key1", "key2")
|
|
303
|
+
m5 = Matchers.any_order("key1", "key2")
|
|
304
|
+
|
|
305
|
+
with pytest.raises(KeyError):
|
|
306
|
+
m1({}, "val")
|
|
307
|
+
with pytest.raises(KeyError):
|
|
308
|
+
m2({}, "123")
|
|
309
|
+
with pytest.raises(KeyError):
|
|
310
|
+
m3({}, "val")
|
|
311
|
+
with pytest.raises(KeyError):
|
|
312
|
+
m4({"key1": "A"}, "val") # Missing key2
|
|
313
|
+
with pytest.raises(KeyError):
|
|
314
|
+
m5({"key1": "A"}, "val") # Missing key2
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|