diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d8ad82..05bf7cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,31 @@ on: jobs: test: - runs-on: ubuntu-latest + name: ${{ matrix.os }} py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + defaults: + run: + # bash on every platform, so the wheel glob and paths below behave the + # same way. Git Bash ships on the Windows runners. + shell: bash strategy: + # One platform failing should not hide the others. + fail-fast: false matrix: + os: [ubuntu-latest] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + include: + # Windows and macOS at the supported floor and ceiling. Console + # encoding and path handling are where they diverge from Linux, and + # both sit on the CLI's hot path -- a check prints " ✓", so the + # curriculum's own output has to survive the console it lands on. + # Ubuntu covers the versions in between. + - os: windows-latest + python-version: "3.9" + - os: windows-latest + python-version: "3.13" + - os: macos-latest + python-version: "3.13" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -22,5 +43,13 @@ jobs: - run: python -m pip install build - run: python -m build - run: python -m pip install --force-reinstall dist/*.whl - - run: pythonlings init --path /tmp/pythonlings-workspace - - run: pythonlings --root /tmp/pythonlings-workspace list + - run: pythonlings init --path "${{ runner.temp }}/pythonlings-workspace" + - run: pythonlings --root "${{ runner.temp }}/pythonlings-workspace" list + - name: CLI survives a non-UTF-8 console + # The regression that motivated Windows coverage: status glyphs and + # captured check output crashed on consoles that cannot encode them. + env: + PYTHONIOENCODING: ascii + run: | + pythonlings --root tests/fixtures/passing_curriculum verify + pythonlings --root "${{ runner.temp }}/pythonlings-workspace" list diff --git a/pythonlings/core/doctor.py b/pythonlings/core/doctor.py index 0f3529c..bbaf145 100644 --- a/pythonlings/core/doctor.py +++ b/pythonlings/core/doctor.py @@ -114,7 +114,10 @@ def _check_workspace(root: Path) -> CheckResult: try: mode = root.stat().st_mode except OSError as exc: - if exc.errno == errno.ELOOP: + # Windows reports an unresolvable path (a symlink loop among them) as + # ERROR_CANT_RESOLVE_FILENAME with no matching errno, so match on the + # winerror too rather than letting it escape. + if exc.errno == errno.ELOOP or getattr(exc, "winerror", None) == 1921: return CheckResult( "Workspace", CheckStatus.FAILURE, @@ -126,7 +129,13 @@ def _check_workspace(root: Path) -> CheckResult: CheckStatus.FAILURE, f"{root} does not exist; run `pythonlings init --path {root}`", ) - raise + # Anything else is still reported rather than raised: doctor exists to + # describe a broken workspace, so it must not crash on one. + return CheckResult( + "Workspace", + CheckStatus.FAILURE, + f"{root} could not be read ({exc.strerror or exc})", + ) if not stat.S_ISDIR(mode): return CheckResult( "Workspace", CheckStatus.FAILURE, f"{root} is not a directory" diff --git a/tests/integration/test_cli_doctor.py b/tests/integration/test_cli_doctor.py index 8dcaa5f..3ea8717 100644 --- a/tests/integration/test_cli_doctor.py +++ b/tests/integration/test_cli_doctor.py @@ -174,8 +174,12 @@ def test_doctor_unknown_home_user_is_friendly() -> None: assert result.returncode == 1 assert "[FAIL] Workspace:" in result.stdout - assert root in result.stdout - assert "check the path and symlinks" in result.stdout + # POSIX leaves an unknown ~user unexpanded, so the path stays literal and + # doctor reports it as unresolvable. Windows expands it to a path under the + # users directory, which is merely missing. Both are correct, so assert what + # holds either way -- a reported failure naming the user -- rather than + # pinning one platform's wording. + assert "pythonlings_no_such_user_93847" in result.stdout assert "Traceback" not in result.stdout + result.stderr diff --git a/tests/integration/test_cli_verify.py b/tests/integration/test_cli_verify.py index def55e8..599c3cb 100644 --- a/tests/integration/test_cli_verify.py +++ b/tests/integration/test_cli_verify.py @@ -214,6 +214,10 @@ def test_verify_keeps_unicode_symbol_under_utf8(tmp_path: Path) -> None: [sys.executable, "-m", "pythonlings", "--root", str(tmp_path), "verify"], capture_output=True, text=True, + # The child writes UTF-8 because of PYTHONIOENCODING above; decode it + # the same way rather than falling back to the parent's locale, which + # is not UTF-8 on Windows. + encoding="utf-8", env=env, ) diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 6b142cc..e27157d 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -95,7 +95,8 @@ def test_load_rejects_missing_exercise_path(tmp_path: Path) -> None: 'hint = "h"\n', encoding="utf-8", ) - with pytest.raises(ManifestError, match="exercises/missing.py"): + # The message echoes the path with the platform's separator. + with pytest.raises(ManifestError, match=r"exercises[\\/]missing\.py"): load(tmp_path)