diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index bf0fff7..b9be08d 100644 --- a/.github/workflows/continuous.yml +++ b/.github/workflows/continuous.yml @@ -85,6 +85,8 @@ jobs: exe="$(find artifacts -type f -name '*.exe' | head -n 1)" [ -n "$exe" ] || { echo "::error::no installer in the artifact"; exit 1; } cp "$exe" "release/$ASSET_NAME" + # Same installer, as a zip, for machines that refuse an .exe download (#173). + python packaging/zip_installer.py release cd release sha256sum * > SHA256SUMS echo "--- release payload ---" @@ -101,6 +103,8 @@ jobs: when="$(git log -1 --format=%cI "$GITHUB_SHA")" short="${GITHUB_SHA::7}" download="https://github.com/${GITHUB_REPOSITORY}/releases/download/${CONTINUOUS_TAG}/${ASSET_NAME}" + asset_zip="${ASSET_NAME%.exe}.zip" + download_zip="https://github.com/${GITHUB_REPOSITORY}/releases/download/${CONTINUOUS_TAG}/${asset_zip}" cat > notes.md < SHA256SUMS echo "--- release payload ---" @@ -120,6 +122,7 @@ jobs: set -euo pipefail version="${TAG#v}" installer="$(cd release && ls *.exe)" + installer_zip="${installer%.exe}.zip" cat > notes.md <.zip` beside each `.exe`, with the installer at the archive root under its own name. +- `release.yml` and `continuous.yml` run that before `SHA256SUMS`, and the release notes name the zip. +- Docs: `docs/windows-installer.md`, `docs/releasing.md`, `README.md`, and the continuous-installer design note. + +## Remaining + +- Land the branch and close the GitHub issue. The zip appears on the next tag and the next `continuous` publish, not on releases that already exist. diff --git a/.issueflows/04-designs-and-guides/continuous-installer.md b/.issueflows/04-designs-and-guides/continuous-installer.md index e12c591..b7021f7 100644 --- a/.issueflows/04-designs-and-guides/continuous-installer.md +++ b/.issueflows/04-designs-and-guides/continuous-installer.md @@ -14,7 +14,8 @@ easily downloaded from GitHub". A **single rolling prerelease** under the tag `continuous`, refreshed by `.github/workflows/continuous.yml` on every code-affecting push to `main`, -carrying one fixed-name asset `cellpy-simple-gui-continuous-setup.exe` plus +carrying one fixed-name asset `cellpy-simple-gui-continuous-setup.exe`, +the same installer as `cellpy-simple-gui-continuous-setup.zip` (#173), plus `SHA256SUMS`. The build itself was extracted from `release.yml` into a reusable `workflow_call` workflow (`windows-installer.yml`) so the tagged and the rolling installer share one recipe, including the #117 smoke-test gate. diff --git a/README.md b/README.md index 59d0d26..58f97e5 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ Two heavier routes, if you would rather not have Python in the picture: - **Windows installer** — 178 MB, no admin, Start-menu entry. Download the [latest release](https://github.com/cellpy/cellpy-simple-gui/releases/latest), or the [newest build from `main`](https://github.com/cellpy/cellpy-simple-gui/releases/tag/continuous) - (rebuilt on every code change). See [`docs/windows-installer.md`](docs/windows-installer.md). + (rebuilt on every code change). Each installer is also a `.zip` of that same + file, for machines that block an `.exe` download. See [`docs/windows-installer.md`](docs/windows-installer.md). - **Container** — `docker compose up`. See [`docs/deployment.md`](docs/deployment.md). --- diff --git a/docs/releasing.md b/docs/releasing.md index f65b0f8..1a15656 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -142,7 +142,7 @@ The tag triggers three workflows: |---|---| | `publish.yml` | sdist + wheel → PyPI | | `container.yml` | image → `ghcr.io/cellpy/cellpy-simple-gui` | -| `release.yml` | Windows installer + sdist + wheel + `SHA256SUMS` → the GitHub Release | +| `release.yml` | Windows installer, the same installer as a `.zip`, sdist + wheel + `SHA256SUMS` → the GitHub Release | The publish job **refuses to run if the tag and `__version__` disagree**, because a wrong version cannot be corrected after upload. diff --git a/docs/windows-installer.md b/docs/windows-installer.md index 064fca9..f2710c1 100644 --- a/docs/windows-installer.md +++ b/docs/windows-installer.md @@ -48,6 +48,12 @@ Two installers are kept on the | **Released** | [latest release](https://github.com/cellpy/cellpy-simple-gui/releases/latest) → `cellpy-simple-gui--setup.exe` | A tagged version, also on PyPI and GHCR. Start here. | | **Newest** | [`continuous`](https://github.com/cellpy/cellpy-simple-gui/releases/tag/continuous) → [`cellpy-simple-gui-continuous-setup.exe`](https://github.com/cellpy/cellpy-simple-gui/releases/download/continuous/cellpy-simple-gui-continuous-setup.exe) | Whatever is on `main` right now, rebuilt on every code change. It passed the same smoke test a release does, but nobody has used it for a week yet. | +Each of those downloads is also published as a `.zip` with the same name +(`cellpy-simple-gui--setup.zip`, and +`cellpy-simple-gui-continuous-setup.zip`). The zip contains that same +installer. Use it when a download of `.exe` files is blocked, then extract +and run the `.exe` inside. + The newest build shows up in *Add or remove programs* as e.g. `0.1.1+main.abc1234` — the released version it was built on, plus the commit. Its release notes name the commit and date. diff --git a/examples/mcp/server.py b/examples/mcp/server.py index 2c25476..f72466c 100644 --- a/examples/mcp/server.py +++ b/examples/mcp/server.py @@ -608,18 +608,26 @@ def offer(path: str, entity: Any) -> None: return entries -#: ``See :func:`cellpy.readers.capacity_curves.get_cap` `` and friends. -_REFERENCE = re.compile(r":(?:func|meth|obj|class):`~?([\w.]+)`") +#: Sphinx ``:func:`...` `` and the plain ``See `cellpy.a.b` `` form used +#: since the cellpy 2.1.5 docstring rewrite. A double-backtick span is a +#: literal, not a hop, so it is left alone. +_REFERENCE = re.compile( + r"(?:" + r":(?:func|meth|obj|class):`~?(?P[\w.]+)`" + r"|" + r"(?cellpy(?:\.[\w]+)+)`(?!`)" + r")" +) def _follow_reference(doc: str) -> tuple[str | None, str]: """Resolve a Sphinx cross-reference in `doc` to the docstring it points at. This is the single biggest win in the family, and it is worth saying why. - `CellpyCell.get_cap` takes 23 arguments, documents none of them, and its - whole docstring is "Gets the capacity for the run. See - :func:`cellpy.readers.capacity_curves.get_cap`." The delegate documents 22 - of its 24 in a full ``Args:`` block. + `CellpyCell.get_cap` points at the implementation with + ``See `cellpy.readers.capacity_curves.get_cap` `` (older builds used a + Sphinx ``:func:`` role and documented none of the arguments themselves). + The delegate still carries the full ``Args:`` block. So the documentation is not missing — it is one hop away, behind a marker that only a docs *site* resolves. Anyone reading the docstring where it is @@ -632,7 +640,7 @@ def _follow_reference(doc: str) -> tuple[str | None, str]: match = _REFERENCE.search(doc) if not match: return None, "" - target = match.group(1) + target = match.group("path") or match.group("path2") if target.split(".")[0] not in API_ROOTS: return None, "" try: diff --git a/llms-full.txt b/llms-full.txt index ff302cc..cb62ae3 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -164,7 +164,8 @@ Two heavier routes, if you would rather not have Python in the picture: - **Windows installer** — 178 MB, no admin, Start-menu entry. Download the [latest release](https://github.com/cellpy/cellpy-simple-gui/releases/latest), or the [newest build from `main`](https://github.com/cellpy/cellpy-simple-gui/releases/tag/continuous) - (rebuilt on every code change). See [`docs/windows-installer.md`](docs/windows-installer.md). + (rebuilt on every code change). Each installer is also a `.zip` of that same + file, for machines that block an `.exe` download. See [`docs/windows-installer.md`](docs/windows-installer.md). - **Container** — `docker compose up`. See [`docs/deployment.md`](docs/deployment.md). --- diff --git a/packaging/zip_installer.py b/packaging/zip_installer.py new file mode 100644 index 0000000..947b058 --- /dev/null +++ b/packaging/zip_installer.py @@ -0,0 +1,48 @@ +"""Zip each Windows installer ``.exe`` beside itself (#173). + +A corporate download filter often refuses ``.exe`` and allows ``.zip``. The +archive contains that same installer, at the root, under its own file name. +""" + +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path + + +def zip_installers(directory: Path) -> list[Path]: + """Write ``.zip`` next to every ``*.exe`` in ``directory``. + + Args: + directory: Folder that already holds the installer executable(s). + + Returns: + The zip paths, one per installer, in sorted name order. + + Raises: + SystemExit: When ``directory`` contains no ``.exe``. + """ + folder = Path(directory) + exes = sorted(p for p in folder.glob("*.exe") if p.is_file()) + if not exes: + raise SystemExit(f"no installer exe in {folder}") + written: list[Path] = [] + for exe in exes: + dest = exe.with_suffix(".zip") + with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(exe, exe.name) + written.append(dest) + return written + + +def main(argv: list[str] | None = None) -> None: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 1: + raise SystemExit("usage: zip_installer.py ") + for path in zip_installers(Path(args[0])): + print(path) + + +if __name__ == "__main__": + main() diff --git a/tests/test_mcp_prototype.py b/tests/test_mcp_prototype.py index 4647d84..5ed481d 100644 --- a/tests/test_mcp_prototype.py +++ b/tests/test_mcp_prototype.py @@ -242,11 +242,10 @@ async def main(): def test_describe_api_follows_the_reference_the_docstring_points_at(prototype): """The finding this family turns on. - `CellpyCell.get_cap` takes 23 arguments, documents none of them, and spends - its one-line docstring on ``See :func:`cellpy.readers.capacity_curves.get_cap```. - The delegate documents 22 of 24 in a full ``Args:`` block. Following the - reference is the difference between an unanswerable call and a documented - one, so it is asserted rather than left to be noticed. + `CellpyCell.get_cap` points at the implementation with + ``See `cellpy.readers.capacity_curves.get_cap` `` (a Sphinx ``:func:`` role + on older cellpy). Following that reference is what fills in arguments the + wrapper leaves out, so it is asserted rather than left to be noticed. """ async def steps(call): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a67c261..4593b3d 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -11,6 +11,7 @@ import importlib.util import os import sys +import zipfile from pathlib import Path import pytest @@ -360,3 +361,42 @@ def test_release_and_continuous_share_one_installer_build(): # The gate, not just the build. assert "packaging/smoke_test.py" in steps assert "installer.iss" in steps + + +def _load_zip_installer(): + spec = importlib.util.spec_from_file_location( + "_csg_zip_installer", PACKAGING / "zip_installer.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_zip_installer_wraps_each_exe(tmp_path): + """The zip is the installer, not a second build (#173).""" + exe = tmp_path / "cellpy-simple-gui-0.2.0-setup.exe" + exe.write_bytes(b"MZ-not-a-real-installer") + (tmp_path / "notes.txt").write_text("leave me", encoding="utf-8") + + written = _load_zip_installer().zip_installers(tmp_path) + + assert written == [tmp_path / "cellpy-simple-gui-0.2.0-setup.zip"] + with zipfile.ZipFile(written[0]) as archive: + assert archive.namelist() == ["cellpy-simple-gui-0.2.0-setup.exe"] + assert archive.read("cellpy-simple-gui-0.2.0-setup.exe") == b"MZ-not-a-real-installer" + assert (tmp_path / "notes.txt").read_text(encoding="utf-8") == "leave me" + + +def test_zip_installer_refuses_a_directory_with_no_exe(tmp_path): + with pytest.raises(SystemExit, match="no installer exe"): + _load_zip_installer().zip_installers(tmp_path) + + +def test_release_and_continuous_publish_the_installer_zip(): + """Both downloadable installers offer a .zip of the same .exe (#173).""" + for name, job in (("release.yml", "release"), ("continuous.yml", "publish")): + steps = " ".join( + str(s.get("run", "")) for s in _workflow(name)["jobs"][job]["steps"] + ) + assert "packaging/zip_installer.py release" in steps + assert ".zip" in steps