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
9 changes: 8 additions & 1 deletion .github/workflows/continuous.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---"
Expand All @@ -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 <<EOF
**This is the newest code on \`main\`, not a release.** It is rebuilt on
every code change and may be broken in ways a release would not be. For
Expand All @@ -113,10 +117,13 @@ jobs:
| Committed | ${when} |
| Version | \`${VERSION}+${BUILD_ID}\` (what *Add or remove programs* will show) |
| Download | [\`${ASSET_NAME}\`](${download}) — this link never changes |
| Zip | [\`${asset_zip}\`](${download_zip}) — the same installer, when \`.exe\` downloads are blocked |

## Install

Windows, no Python needed: download \`${ASSET_NAME}\` below. It
Windows, no Python needed: download \`${ASSET_NAME}\` below, or
\`${asset_zip}\` when a download of \`.exe\` files is blocked (it
contains that same installer). It
installs per-user — no admin prompt — and uninstalls cleanly without
touching your projects. Installing it over a released version (or a
release over it) **replaces** the install; the two do not sit side by
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ jobs:
mkdir -p release
find artifacts -type f \( -name '*.exe' -o -name '*.whl' -o -name '*.tar.gz' \) \
-exec cp {} release/ \;
# 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 ---"
Expand All @@ -120,6 +122,7 @@ jobs:
set -euo pipefail
version="${TAG#v}"
installer="$(cd release && ls *.exe)"
installer_zip="${installer%.exe}.zip"
cat > notes.md <<EOF
## Install

Expand All @@ -130,7 +133,9 @@ jobs:
cellpy-simple-gui
\`\`\`

**Windows, no Python needed:** download \`${installer}\` below.
**Windows, no Python needed:** download \`${installer}\` below,
or \`${installer_zip}\` when a download of \`.exe\` files is blocked.
The zip contains that same installer — extract it and run the \`.exe\`.
It installs per-user — no admin prompt — and uninstalls cleanly without
touching your projects. It is **unsigned**, so SmartScreen will warn on
first run: *More info → Run anyway*. See
Expand Down
7 changes: 7 additions & 0 deletions .issueflows/01-current-issues/issue173_original.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Issue #173: Create also a zip file with the installer during release.

Source: https://github.com/cellpy/cellpy-simple-gui/issues/173

## Original issue text

Being on a corporate PC, downloading .exe files is often prohibited. Create also a zip file that people can select to download instead.
13 changes: 13 additions & 0 deletions .issueflows/01-current-issues/issue173_status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Issue #173 status

- [ ] Done

## Done

- `packaging/zip_installer.py` writes `<installer>.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.
3 changes: 2 additions & 1 deletion .issueflows/04-designs-and-guides/continuous-installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down
2 changes: 1 addition & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/windows-installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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-<version>-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.
Expand Down
22 changes: 15 additions & 7 deletions examples/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<path>[\w.]+)`"
r"|"
r"(?<!`)`(?P<path2>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
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down
48 changes: 48 additions & 0 deletions packaging/zip_installer.py
Original file line number Diff line number Diff line change
@@ -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 ``<name>.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 <directory>")
for path in zip_installers(Path(args[0])):
print(path)


if __name__ == "__main__":
main()
9 changes: 4 additions & 5 deletions tests/test_mcp_prototype.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
40 changes: 40 additions & 0 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import importlib.util
import os
import sys
import zipfile
from pathlib import Path

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