GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — winpython/.github/scripts/cycle_config.py at master · winpython/winpython

Expand file tree

/

cycle_config.py

Copy path
More file actions
More file actions

Latest commit

 

History

History
History
250 lines (208 loc) · 10.2 KB
/

cycle_config.py

Copy path

File metadata and controls

250 lines (208 loc) · 10.2 KB
Raw
Copy raw file
Download raw file
Open symbols panel
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""Read a cycle TOML and emit its build configuration as GITHUB_OUTPUT lines.
python .github/scripts/cycle_config.py cycles/2026_04.toml all
python .github/scripts/cycle_config.py cycles/2026_04.toml 3.14
python .github/scripts/cycle_config.py cycles/2026_04.toml all slim,slimf
Writes to $GITHUB_OUTPUT when set, otherwise stdout, so it can be run locally
to see exactly what a workflow run would get. tomllib is stdlib from 3.11, and
GitHub runners are newer than that, so this needs no dependency.
Everything the build job needs is decided here rather than re-derived per
matrix leg in PowerShell:
* which (python, flavor) pairs this cycle can build -- right architecture,
and a pylock present in the cycle directory. A flavor the cycle declares
but has no lockfile for costs nothing, so it can stay declared until it
comes back, and "all" skips a Python whose lockfiles are not committed yet
rather than failing the cycle over it.
* the version, tarball and paths each pair needs, carried by the leg itself.
A leg naming its own Python is what lets "all" build the whole cycle in one
dispatch instead of one per Python version.
* the tag its release goes under. Derived from the cycle name and release
level -- 2026_04 at level b1 publishes under "2026-04b1" -- so a respin is
a level bump rather than a tag to invent. A cycle wanting some other tag
says so with a release_tag key.
* a cycle with nothing to build fails here, loudly, instead of starting
runners whose every step is then skipped.
* a flavor's optional post suffix ("_post1"): a rebuild of the same
lockfile under new file names. It can join a release that is already
published, since nothing there gets replaced.
Paths are relative to the working directory, which in CI is the checkout root.
"""
import datetime
import json
import os
import sys
import tomllib
from pathlib import Path
# git refuses these in a ref name; catching them here beats failing at upload
# time, an hour into a build. Not the whole of git-check-ref-format, just the
# parts a hand-written tag realistically trips over.
TAG_FORBIDDEN = set(" ~^:?*[\\\x7f") | {chr(c) for c in range(32)}
# a post suffix lands in file names and URLs
POST_ALLOWED = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
def python_facts(requested: str, entry: dict) -> dict:
"""What every flavor of one Python shares.
A trailing F marks the free-threaded build: it picks a different tarball
and a different set of flavors, but it is not part of the version number.
"""
ver2 = entry["ver2"]
# the check the PowerShell version did: ver2's first 3 parts must appear in
# the tarball URL, so a copy-paste slip between the two cannot go unnoticed
short = ".".join(ver2.split(".")[:3])
if short not in entry["src"]:
raise SystemExit(f"{requested}: '{short}' not found in src {entry['src']}")
build_location = f"WPy64-{ver2.replace('.', '')}"
return {
"python_versionf": requested,
"python_version": requested[:-1] if requested.endswith("F") else requested,
"archdet": "64F" if requested.endswith("F") else "64",
"ver2": ver2,
"src": entry["src"],
"sha": entry["sha"],
"build_location": build_location,
"destwheelhouse": rf"{build_location}\wheelhouse\included.wheels",
}
def flavor_entry(cfg: dict, flavor: dict, python: dict) -> dict | None:
"""One matrix leg, or None when this flavor has no lockfile to build.
Names follow the layout the publish step writes: pylock.64-<ver2 with
underscores><flavor><release level>.toml, plus the _wheels variants the
wheelhouse flavor adds.
"""
cycle_dir = Path(cfg["cycle_dir"])
level = cfg.get("release_level", "")
stem = f"64-{python['ver2'].replace('.', '_')}{flavor['name']}{level}"
lockfile = cycle_dir / f"pylock.{stem}.toml"
if not lockfile.is_file():
return None
def optional(path: Path) -> str:
return path.as_posix() if path.is_file() else ""
post = flavor.get("post", "")
if set(post) - POST_ALLOWED:
raise SystemExit(f"{flavor['name']}: post {post!r} must be letters, digits, '.', '_' or '-'")
leg = {key: value for key, value in python.items() if key != "archdet"}
leg.update(
name=flavor["name"],
PANDOC=flavor["PANDOC"],
formats=flavor["formats"],
lockfile=lockfile.as_posix(),
lockfile_wheels=optional(cycle_dir / f"pylock.{stem}_wheels.toml"),
requirements_wheels=optional(cycle_dir / f"requir.{stem}_wheels.txt"),
post=post,
winpyver=f"{python['ver2']}{flavor['name']}{level}{post}",
artifact_name=f"publish_{python['python_version']}{flavor['name']}",
)
return leg
def legs_for(cfg: dict, requested: str) -> list[dict]:
"""Every buildable (flavor) leg of one Python of this cycle."""
python = python_facts(requested, cfg["pythons"][requested])
found = []
for flavor in cfg["flavors"]:
if str(flavor.get("WINPYARCHDET", "")) != python["archdet"]:
continue
leg = flavor_entry(cfg, flavor, python)
if leg is not None:
found.append(leg)
return found
def release_tag(cfg: dict, cycle_name: str) -> str:
"""The tag this cycle's release goes under.
"2026_04" at release level "b1" gives "2026-04b1", the name the cycle goes
by in public. No date in it on purpose: a tag has to stay put so that
re-running one missing flavor lands on the release the others are already
on, and so that the download URLs the site publishes keep resolving. A
second build of the same cycle is a release_level bump, which says more
than a date would.
"""
tag = cfg.get("release_tag") or f"{cycle_name.replace('_', '-')}{cfg.get('release_level', '')}"
bad = sorted(TAG_FORBIDDEN.intersection(tag))
if bad or ".." in tag or tag.startswith("/") or tag.endswith(("/", ".", ".lock")):
raise SystemExit(f"release tag {tag!r} is not a usable git ref name")
return tag
def ordinal(day: int) -> str:
"""1st, 2nd, 3rd, 4th ... and the 11th/12th/13th exceptions."""
if 11 <= day % 100 <= 13:
return f"{day}th"
return f"{day}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') }"
def release_title(cfg: dict, cycle_name: str, today: datetime.date | None = None) -> str:
""""WinPython 2026-04 b1 of September 6th 2026", the shape releases have used.
Kept deliberately close to the titles written by hand before this: cycle,
then the level after a space -- the tag runs them together, the title does
not -- then the date, no comma, which is how all but one of them read.
The date is the day the draft is opened, since that is the only day the
build knows about. A cycle publishing much later than it was built can
still have the title edited; the tag, which is what URLs are built on,
does not move.
"""
today = today or datetime.date.today()
level = cfg.get("release_level", "")
name = f"{cycle_name.replace('_', '-')} {level}".strip()
return f"WinPython {name} of {today:%B} {ordinal(today.day)} {today.year}"
def wanted_flavors(cfg: dict, flavors: str) -> set[str] | None:
"""The flavor names a comma-separated input asks for; None means all.
A typo would otherwise filter everything out, or quietly build less than
asked, so a name the cycle does not declare is refused.
"""
if flavors.strip() in ("", "all"):
return None
names = {name.strip() for name in flavors.split(",") if name.strip()}
declared = {flavor["name"] for flavor in cfg["flavors"]}
unknown = names - declared
if unknown:
raise SystemExit(
f"unknown flavor {', '.join(sorted(unknown))}; this cycle declares "
f"{', '.join(sorted(declared))} (or 'all')"
)
return names
def build_config(cfg: dict, requested: str, cycle_name: str, flavors: str = "all") -> dict:
pythons = cfg["pythons"]
only = wanted_flavors(cfg, flavors)
if requested == "all":
wanted = list(pythons)
elif requested in pythons:
wanted = [requested]
else:
raise SystemExit(
f"no entry for python {requested!r}; this cycle offers "
f"{', '.join(sorted(pythons))} (or 'all')"
)
legs = [leg for python in wanted for leg in legs_for(cfg, python)]
if only is not None:
legs = [leg for leg in legs if leg["name"] in only]
if not legs and only is not None:
raise SystemExit(f"{requested}: none of {flavors} can be built for this Python")
if not legs:
missing = ", ".join(
f"pylock.64-{pythons[p]['ver2'].replace('.', '_')}<flavor>"
f"{cfg.get('release_level', '')}.toml"
for p in wanted
)
raise SystemExit(
f"{requested}: no {missing} under {cfg['cycle_dir']}; "
"commit the lockfiles for this cycle before dispatching"
)
return {
"cycle_dir": cfg["cycle_dir"],
"release_level": cfg.get("release_level", ""),
"release_tag": release_tag(cfg, cycle_name),
"release_title": release_title(cfg, cycle_name),
# every leg renamed by a post suffix: none of its files can replace one
"adds_only": "true" if all(leg["post"] for leg in legs) else "false",
"pandoc_source": cfg["pandoc"]["source"],
"pandoc_sha256": cfg["pandoc"]["sha256"],
# consumed by the build job as strategy.matrix via fromJSON
"matrix": json.dumps({"leg": legs}, separators=(",", ":")),
}
def main(argv: list[str]) -> None:
if len(argv) not in (3, 4):
raise SystemExit(f"usage: {Path(argv[0]).name} <cycle.toml> <python_version|all> [flavors|all]")
cfg_path = Path(argv[1])
if not cfg_path.is_file():
raise SystemExit(f"no such cycle file: {cfg_path}")
with cfg_path.open("rb") as fh:
cfg = tomllib.load(fh)
config = build_config(cfg, argv[2], cfg_path.stem, argv[3] if len(argv) == 4 else "all")
rendered = "".join(f"{k}={v}\n" for k, v in config.items())
out = os.environ.get("GITHUB_OUTPUT")
if out:
with open(out, "a", encoding="utf-8") as fh:
fh.write(rendered)
sys.stdout.write(rendered)
if __name__ == "__main__":
main(sys.argv)
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.