From a567b2c0f535567aea0c7589cd3a20a8710dc7ab Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Fri, 21 Aug 2026 01:55:05 +0530 Subject: [PATCH 1/3] ci: run the suite on Windows and macOS Nothing in this project had ever executed on Windows. CI ran ubuntu-latest only, so the first Windows signal we ever received was a conda-forge build failing on a UnicodeEncodeError that had been reachable from pip installs the whole time. Adds Windows and macOS at the supported floor and ceiling, leaving ubuntu to cover the versions between, and makes the existing steps platform-neutral: the wheel glob and workspace path were both Unix-only. Also asserts the CLI survives a non-UTF-8 console, which is the specific regression that motivated this. --- .github/workflows/ci.yml | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) 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 From 95ef44169c3d3b36d7f51d32f23e3f9f871b4c01 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Fri, 21 Aug 2026 02:02:44 +0530 Subject: [PATCH 2/3] fix: make doctor and the suite work on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding Windows to CI surfaced four failures, none of which had ever been visible. doctor crashed on a workspace it could not stat. Windows reports an unresolvable path, a symlink loop among them, as ERROR_CANT_RESOLVE_FILENAME with no matching errno, so it fell through the ELOOP and ENOENT branches and the bare re-raise took down the command whose entire job is describing a broken workspace. It now matches on the winerror as well, and any other OSError is reported as a failed check rather than raised. The other three were platform assumptions in the tests, not product defects: - An unknown ~user stays literal on POSIX but expands to a path under the users directory on Windows. Both are reasonable; the assertion now checks the name reaches the report either way. - The manifest echoes a bad exercise path with the platform separator, so the expected message is now separator-agnostic. - The UTF-8 verify test set PYTHONIOENCODING on the child but let the parent decode with its own locale, which is not UTF-8 on Windows, so a correct ✓ arrived mojibaked. The parent now decodes as UTF-8 too. --- pythonlings/core/doctor.py | 13 +++++++++++-- tests/integration/test_cli_doctor.py | 4 +++- tests/integration/test_cli_verify.py | 4 ++++ tests/unit/test_manifest.py | 3 ++- 4 files changed, 20 insertions(+), 4 deletions(-) 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..1351afd 100644 --- a/tests/integration/test_cli_doctor.py +++ b/tests/integration/test_cli_doctor.py @@ -174,7 +174,9 @@ def test_doctor_unknown_home_user_is_friendly() -> None: assert result.returncode == 1 assert "[FAIL] Workspace:" in result.stdout - assert root in result.stdout + # POSIX leaves an unknown ~user unexpanded; Windows expands it to a path + # under the users directory. Either way the name has to reach the report. + assert "pythonlings_no_such_user_93847" in result.stdout assert "check the path and symlinks" 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) From 1e5374c49395382ca6d19641ca3fb066eca069d9 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Fri, 21 Aug 2026 08:44:53 +0530 Subject: [PATCH 3/3] test: assert the invariant, not one platform's doctor wording An unknown ~user stays literal on POSIX, so doctor reports the path as unresolvable. Windows expands it to a path under the users directory, which is merely missing, so doctor takes the does-not-exist branch instead. Both are correct. Assert what holds either way -- a reported workspace failure naming the user, and no traceback -- rather than pinning one platform's message. --- tests/integration/test_cli_doctor.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_cli_doctor.py b/tests/integration/test_cli_doctor.py index 1351afd..3ea8717 100644 --- a/tests/integration/test_cli_doctor.py +++ b/tests/integration/test_cli_doctor.py @@ -174,10 +174,12 @@ def test_doctor_unknown_home_user_is_friendly() -> None: assert result.returncode == 1 assert "[FAIL] Workspace:" in result.stdout - # POSIX leaves an unknown ~user unexpanded; Windows expands it to a path - # under the users directory. Either way the name has to reach the report. + # 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 "check the path and symlinks" in result.stdout assert "Traceback" not in result.stdout + result.stderr