diff --git a/pythonlings/cli.py b/pythonlings/cli.py index f12905c..e450224 100644 --- a/pythonlings/cli.py +++ b/pythonlings/cli.py @@ -35,7 +35,7 @@ def _build_parser() -> argparse.ArgumentParser: ) p_update = sub.add_parser("update", help="Update an existing pythonlings workspace.") - p_update.add_argument("--path", type=Path, default=default_workspace_root()) + p_update.add_argument("--path", type=Path, default=None) sub.add_parser("watch", help="Launch the TUI in watch mode (default).") sub.add_parser("topics", help="Launch the TUI on the topic picker.") @@ -278,8 +278,14 @@ def main(argv: list[str] | None = None) -> int: try: root: Path | None = None - if args.command in ("init", "update"): + update_root: Path | None = None + if args.command == "init": migrate_legacy_state_dir(Path(args.path)) + elif args.command == "update": + explicit_root = args.path if args.path is not None else args.root + update_root = resolve_workspace_root( + Path.cwd(), explicit_root, create_if_missing=False + ).root else: launches_tui = args.command in (None, "watch", "start", "topics") resolved = resolve_workspace_root( @@ -305,7 +311,8 @@ def main(argv: list[str] | None = None) -> int: if args.command == "init": return _cmd_init(args.path, args.force) if args.command == "update": - return _cmd_update(args.path) + assert update_root is not None + return _cmd_update(update_root) assert root is not None if args.command == "verify": diff --git a/pythonlings/core/curriculum.py b/pythonlings/core/curriculum.py index 43c0988..530f5d2 100644 --- a/pythonlings/core/curriculum.py +++ b/pythonlings/core/curriculum.py @@ -1,3 +1,4 @@ +# pythonlings/core/curriculum.py from __future__ import annotations import shutil @@ -109,10 +110,14 @@ def init_workspace(path: Path, *, force: bool = False) -> Path: def update_workspace(path: Path) -> Path: path = path.expanduser().resolve() - if not (path / "info.toml").exists(): + if not (path / "info.toml").is_file(): raise WorkspaceError(f"{path} is not a pythonlings workspace") - src_root = source_root() + src_root = source_root().resolve() + if path == src_root: + raise WorkspaceError(f"cannot update the curriculum source at {path}") + + migrate_legacy_state_dir(path) _copy_path(src_root / "info.toml", path / "info.toml", overwrite=True) _copy_path(src_root / "checks", path / "checks", overwrite=True) _copy_path(src_root / "solutions", path / "solutions", overwrite=True) diff --git a/tests/integration/test_cli_workspace.py b/tests/integration/test_cli_workspace.py index ab22769..4b57107 100644 --- a/tests/integration/test_cli_workspace.py +++ b/tests/integration/test_cli_workspace.py @@ -1,8 +1,28 @@ +# tests/integration/test_cli_workspace.py from pathlib import Path +from pythonlings.core import curriculum from pythonlings.cli import main +_STALE_CHECK = "# stale check used to identify the updated workspace\n" + + +def _stale_workspace(root: Path) -> Path: + assert main(["init", "--path", str(root)]) == 0 + check = next((root / "checks").rglob("*.py")) + check.write_text(_STALE_CHECK, encoding="utf-8") + return check + + +def _assert_updated(check: Path) -> None: + assert check.read_text(encoding="utf-8") != _STALE_CHECK + + +def _assert_not_updated(check: Path) -> None: + assert check.read_text(encoding="utf-8") == _STALE_CHECK + + def test_init_command_creates_workspace(tmp_path: Path) -> None: target = tmp_path / "learn-python" @@ -102,3 +122,160 @@ def test_update_command_preserves_user_exercises(tmp_path: Path) -> None: "__pycache__/\n" "*.pyc\n" ) + + +def test_update_path_takes_precedence_over_root_and_cwd( + tmp_path: Path, monkeypatch +) -> None: + cwd = tmp_path / "cwd-ws" + root = tmp_path / "root-ws" + path = tmp_path / "path-ws" + cwd_check = _stale_workspace(cwd) + root_check = _stale_workspace(root) + path_check = _stale_workspace(path) + monkeypatch.chdir(cwd) + + code = main( + [ + "--root", + str(root), + "update", + "--path", + str(path), + ] + ) + + assert code == 0 + _assert_updated(path_check) + _assert_not_updated(root_check) + _assert_not_updated(cwd_check) + + +def test_update_root_takes_precedence_over_cwd(tmp_path: Path, monkeypatch) -> None: + cwd = tmp_path / "cwd-ws" + root = tmp_path / "root-ws" + cwd_check = _stale_workspace(cwd) + root_check = _stale_workspace(root) + monkeypatch.chdir(cwd) + + code = main(["--root", str(root), "update"]) + + assert code == 0 + _assert_updated(root_check) + _assert_not_updated(cwd_check) + + +def test_bare_update_prefers_current_workspace(tmp_path: Path, monkeypatch) -> None: + home = tmp_path / "home-ws" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + home_check = _stale_workspace(home) + cwd = tmp_path / "cwd-ws" + cwd_check = _stale_workspace(cwd) + monkeypatch.chdir(cwd) + + code = main(["update"]) + + assert code == 0 + _assert_updated(cwd_check) + _assert_not_updated(home_check) + + +def test_bare_update_uses_home_workspace_outside_workspace( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home-ws" + monkeypatch.setenv("PYTHONLINGS_HOME", str(home)) + home_check = _stale_workspace(home) + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.chdir(outside) + + code = main(["update"]) + + assert code == 0 + _assert_updated(home_check) + + +def test_update_missing_path_fails_without_creating_it( + tmp_path: Path, monkeypatch, capsys +) -> None: + target = tmp_path / "missing" + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.setenv("PYTHONLINGS_HOME", str(target)) + monkeypatch.chdir(outside) + + code = main(["update"]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert not target.exists() + + +def test_update_explicit_missing_path_fails_without_creating_it( + tmp_path: Path, capsys +) -> None: + target = tmp_path / "missing" + + code = main(["update", "--path", str(target)]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert not target.exists() + + +def test_update_non_workspace_fails_without_modifying_it( + tmp_path: Path, capsys +) -> None: + target = tmp_path / "not-a-workspace" + target.mkdir() + marker = target / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + legacy = target / ".pylings" + legacy.mkdir() + legacy_marker = legacy / "state.json" + legacy_marker.write_text("keep\n", encoding="utf-8") + + code = main(["--root", str(target), "update"]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert marker.read_text(encoding="utf-8") == "keep\n" + assert legacy_marker.read_text(encoding="utf-8") == "keep\n" + assert not (target / ".pythonlings").exists() + + +def test_update_rejects_directory_info_without_modifying_it( + tmp_path: Path, capsys +) -> None: + target = tmp_path / "not-a-workspace" + info = target / "info.toml" + info.mkdir(parents=True) + marker = info / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + + code = main(["update", "--path", str(target)]) + + assert code == 1 + assert "is not a pythonlings workspace" in capsys.readouterr().err + assert list(info.iterdir()) == [marker] + assert marker.read_text(encoding="utf-8") == "keep\n" + assert not (target / "checks").exists() + + +def test_bare_update_rejects_curriculum_source( + tmp_path: Path, monkeypatch, capsys +) -> None: + target = tmp_path / "source-workspace" + assert main(["init", "--path", str(target)]) == 0 + local_check = target / "checks" / "local-only.py" + local_check.write_text("keep\n", encoding="utf-8") + + monkeypatch.setattr(curriculum, "source_root", lambda: target) + monkeypatch.chdir(target) + + code = main(["update"]) + + assert code == 1 + assert "curriculum source" in capsys.readouterr().err + assert local_check.read_text(encoding="utf-8") == "keep\n"