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.
Files changed (52) hide show
  1. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/PKG-INFO +1 -1
  2. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/commands.md +14 -0
  3. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/pack-spec.md +21 -1
  4. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/plugin-dev.md +5 -4
  5. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/pyproject.toml +1 -1
  6. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/cli/manager.py +152 -1
  7. muninn_cli-0.3.1/tests/cli/test_dependencies.py +270 -0
  8. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/cli/test_manager.py +168 -0
  9. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/test_helpers.py +34 -0
  10. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/uv.lock +1 -1
  11. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/workflows/ci.yml +0 -0
  14. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/workflows/deploy-docs.yml +0 -0
  15. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.github/workflows/publish.yml +0 -0
  16. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.gitignore +0 -0
  17. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.python-version +0 -0
  18. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/.vscode/settings.json +0 -0
  19. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/LICENSE +0 -0
  20. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/README.md +0 -0
  21. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/README_zh.md +0 -0
  22. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/base-plugin.md +0 -0
  23. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/data-plugin.md +0 -0
  24. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/flashcard.md +0 -0
  25. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/api/matchers.md +0 -0
  26. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/getting-started.md +0 -0
  27. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/docs/index.md +0 -0
  28. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/chemistry/elements.json +0 -0
  29. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/chemistry/manifest.json +0 -0
  30. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/chemistry/plugin.py +0 -0
  31. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/flashcard/manifest.json +0 -0
  32. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/flashcard/plugin.py +0 -0
  33. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/example/flashcard/words.csv +0 -0
  34. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/main.py +0 -0
  35. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/scripts/bump_version.py +0 -0
  36. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/__init__.py +0 -0
  37. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/cli/__init__.py +0 -0
  38. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/cli/runner.py +0 -0
  39. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/__init__.py +0 -0
  40. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/base_plugin.py +0 -0
  41. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/helpers.py +0 -0
  42. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/scheduler.py +0 -0
  43. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/core/state.py +0 -0
  44. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/main.py +0 -0
  45. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/src/ui.py +0 -0
  46. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/__init__.py +0 -0
  47. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/cli/__init__.py +0 -0
  48. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/conftest.py +0 -0
  49. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/__init__.py +0 -0
  50. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/test_scheduler.py +0 -0
  51. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/tests/core/test_state.py +0 -0
  52. {muninn_cli-0.3.0 → muninn_cli-0.3.1}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muninn-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Muninn - The Extensible Reciting CLI
5
5
  Project-URL: Repository, https://github.com/a1fredbao/muninn
6
6
  Project-URL: Issues, https://github.com/a1fredbao/muninn/issues
@@ -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 # Required: pack metadata
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 # Package metadata
27
- ├── plugin.py # Core logic
28
- ├── data.csv # Static data (optional — whatever your plugin needs)
29
- └── data.json # Alternative data format
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muninn-cli"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Muninn - The Extensible Reciting CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.pop(0)
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
@@ -127,7 +127,7 @@ wheels = [
127
127
 
128
128
  [[package]]
129
129
  name = "muninn-cli"
130
- version = "0.3.0"
130
+ version = "0.3.1"
131
131
  source = { editable = "." }
132
132
 
133
133
  [package.dev-dependencies]
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