Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions pythonlings/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -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(
Expand All @@ -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":
Expand Down
9 changes: 7 additions & 2 deletions pythonlings/core/curriculum.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# pythonlings/core/curriculum.py
from __future__ import annotations

import shutil
Expand Down Expand Up @@ -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)
Expand Down
177 changes: 177 additions & 0 deletions tests/integration/test_cli_workspace.py
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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"
Loading