Skip to content

Commit dd2b417

Browse files
authored
Fix arcade.version.VERSION conversion from GitHub CI format (#2569)
* Fix converting from GH Action CI to Py version * Use explicitly clear regex approach to read version string * Add tests for specified behavior to our CI * Document the process clearly with a link to GH action config file * Increase exception specificity and test detail * Prevent dev panic by including failure reason in the error logs * Add test cases to cover more bad values (hex + bad dev_preview numbers) * Remove per-field check for now * Fix missing name + formatting * Placate Henry Ford's ghost (black formatter) * Proofreading and style fixes * Fix black formatter putting two f-strings onto a line w/o merging them * Fix typos * Add a Final[str] annotation to the VERSION constant * Cut down and rearrange the top-level docstring for clarity * Extended mode regex formatting to make it pretty * Update comment phrasing to be extra clear * Add special-casing to avoid churn on page titles in 3.0 / latest
1 parent 9c986e2 commit dd2b417

File tree

3 files changed

+237
-30
lines changed

3 files changed

+237
-30
lines changed

arcade/version.py

+141-29
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,160 @@
11
"""
2-
Version
2+
Loads the Arcade version into a Python-readable ``VERSION`` string.
33
4-
We are using a github action to bump the VERSION file versions.
4+
Everyday Arcade users may prefer accessing the ``VERSION`` string
5+
from Arcade's top-level alias:
56
6-
2.7.3-dev.5
7-
will go to:
8-
2.7.3-dev.6
7+
.. code-block:: python
98
10-
Problem is, python doesn't like that last period:
11-
2.7.3-dev.5
12-
should be
13-
2.7.3.dev5
14-
...and our github action doesn't like that pattern.
15-
So this will delete that last period and flip around the dash.
9+
import sys
10+
import arcade
11+
12+
if arcade.version < "3.0.0":
13+
# Using file=sys.stderr prints to the error stream (usually prints red)
14+
print("This game requires Arcade 3.0.0+ to run!", file=sys.stderr)
15+
16+
17+
Arcade contributors will benefit from understanding how and why
18+
this file loads and converts the contents of the ``VERSION`` file.
19+
20+
After a release build succeeds, GitHub's CI is configured to do
21+
the following:
22+
23+
#. Push the package files to PyPI
24+
#. Call the ``remorses/bump-version@js`` action to auto-increment
25+
Arcade's version on the development branch
26+
27+
This is where an edge case arises:
28+
29+
#. Our CI expects ``3.1.0-dev.1`` for dev preview builds
30+
#. Python expects ``3.1.0.dev1`` for dev preview builds
31+
32+
The ``VERSION`` file in this file's directory stores the version
33+
in the form the GH Action prefers. This allows it to auto-increment
34+
the version number on the ``development`` branch after we make an
35+
Arcade release to PyPI.
36+
37+
The auto-bump action is configured by the following file:
38+
https://github.com/pythonarcade/arcade/blob/development/.github/workflows/bump_version.yml
39+
40+
As an example, the GH action would auto-increment a dev preview's
41+
version after releasing the 5th dev preview of ``3.1.0`` by updating
42+
the ``VERSION`` file from this:
43+
44+
.. code-block::
45+
46+
3.1.0-dev.5
47+
48+
...to this:
49+
50+
.. code-block::
51+
52+
3.1.0-dev.6
1653
17-
ALSO note that this bumps the version AFTER the deploy.
18-
So if we are at version 2.7.3.dev5 that's the version deploy. Bump will bump it to dev6.
1954
"""
2055

2156
from __future__ import annotations
2257

23-
import os
58+
import re
59+
import sys
60+
from pathlib import Path
61+
from typing import Final
62+
63+
_HERE = Path(__file__).parent
64+
65+
# Grab version numbers + optional dev point preview
66+
# Assumes $MAJOR.$MINOR.$POINT format with optional -dev$DEV_PREVIEW
67+
# Q: Why did you use regex?!
68+
# A: If the dev_preview field is invalid, the whole match fails instantly
69+
_VERSION_REGEX = re.compile(
70+
r"""
71+
# First three version number fields
72+
(?P<major>[0-9]+)
73+
\.(?P<minor>[0-9]+)
74+
\.(?P<point>[0-9]+)
75+
# Optional dev preview suffix
76+
(?:
77+
-dev # Dev prefix as a literal
78+
\. # Point
79+
(?P<dev_preview>[0-9]+) # Dev preview number
80+
)?
81+
""",
82+
re.X,
83+
)
84+
85+
86+
def _parse_python_friendly_version(version_for_github_actions: str) -> str:
87+
"""Convert a GitHub CI version string to a Python-friendly one.
88+
89+
For example, ``3.1.0-dev.1`` would become ``3.1.0.dev1``.
2490
91+
Args:
92+
version_for_github_actions:
93+
A raw GitHub CI version string, as read from a file.
94+
Returns:
95+
A Python-friendly version string.
96+
"""
97+
# Quick preflight check: we don't support tuple format here!
98+
if not isinstance(version_for_github_actions, str):
99+
raise TypeError(
100+
f"Expected a string of the format MAJOR.MINOR.POINT"
101+
f"or MAJOR.MINOR.POINT-dev.DEV_PREVIEW,"
102+
f"not {version_for_github_actions!r}"
103+
)
25104

26-
def _rreplace(s, old, new, occurrence):
27-
li = s.rsplit(old, occurrence)
28-
return new.join(li)
105+
# Attempt to extract our raw data
106+
match = _VERSION_REGEX.fullmatch(version_for_github_actions.strip())
107+
if match is None:
108+
raise ValueError(
109+
f"String does not appear to be a version number: {version_for_github_actions!r}"
110+
)
29111

112+
# Build final output, including a dev preview version if present
113+
group_dict = match.groupdict()
114+
major, minor, point, dev_preview = group_dict.values()
115+
parts = [major, minor, point]
116+
if dev_preview is not None:
117+
parts.append(f"dev{dev_preview}")
118+
joined = ".".join(parts)
30119

31-
def _get_version():
32-
dirname = os.path.dirname(__file__) or "."
33-
my_path = f"{dirname}/VERSION"
120+
return joined
34121

122+
123+
def _parse_py_version_from_github_ci_file(
124+
version_path: str | Path = _HERE / "VERSION", write_errors_to=sys.stderr
125+
) -> str:
126+
"""Parse a Python-friendly version from a ``bump-version``-compatible file.
127+
128+
On failure, it will:
129+
130+
#. Print an error to stderr
131+
#. Return "0.0.0"
132+
133+
Args:
134+
version_path:
135+
The VERSION file's path, defaulting to the same directory as
136+
this file.
137+
write_errors_to:
138+
Makes CI simpler by allowing a stream mock to be passed easily.
139+
Returns:
140+
Either a converted version or "0.0.0" on failure.
141+
"""
142+
data = "0.0.0"
35143
try:
36-
text_file = open(my_path, "r")
37-
data = text_file.read().strip()
38-
text_file.close()
39-
data = _rreplace(data, ".", "", 1)
40-
data = _rreplace(data, "-", ".", 1)
41-
except Exception:
42-
print(f"ERROR: Unable to load version number via '{my_path}'.")
43-
data = "0.0.0"
144+
raw = Path(version_path).resolve().read_text().strip()
145+
data = _parse_python_friendly_version(raw)
146+
except Exception as e:
147+
print(
148+
f"ERROR: Unable to load version number via '{str(version_path)}': {e}",
149+
file=write_errors_to,
150+
)
44151

45152
return data
46153

47154

48-
VERSION = _get_version()
155+
VERSION: Final[str] = _parse_py_version_from_github_ci_file()
156+
"""A Python-friendly version string.
157+
158+
This value is converted from the GitHub-style ``VERSION`` file at the
159+
top-level of the arcade module.
160+
"""

doc/conf.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,23 @@ def nice_version(version_string: str) -> str:
8888
return '.'.join(out)
8989

9090

91-
NICE_VERSION = nice_version(VERSION)
91+
# pending: 3.0.1 or 3.1 release?
92+
# Maintain title bar continuity for live doc showing 3.0 as the version
93+
VERSION_SPECIAL_CASES = {'3.0.0': '3.0'}
94+
95+
96+
def _specialcase_version(nice: str) -> str:
97+
if nice in VERSION_SPECIAL_CASES:
98+
new = VERSION_SPECIAL_CASES[nice]
99+
log.info(f" Special-casing version {nice!r} to {new!r}")
100+
else:
101+
new = nice
102+
return new
103+
104+
105+
NICE_VERSION = _specialcase_version(nice_version(VERSION))
106+
# pending: end
107+
92108
log.info(f" Got nice version {NICE_VERSION=!r}")
93109

94110

tests/unit/test_version.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import sys
2+
import tempfile
3+
from unittest import mock
4+
5+
import pytest
6+
from arcade.version import (
7+
_parse_python_friendly_version,
8+
_parse_py_version_from_github_ci_file
9+
)
10+
11+
12+
@pytest.mark.parametrize("value, expected", [
13+
("3.0.0-dev.1", "3.0.0.dev1"),
14+
("3.0.0", "3.0.0"),
15+
# Edge cases
16+
("11.22.333-dev.4444", "11.22.333.dev4444"),
17+
("11.22.333", "11.22.333"),
18+
])
19+
class TestParsingWellFormedData:
20+
def test_parse_python_friendly_version(
21+
self, value, expected
22+
):
23+
assert _parse_python_friendly_version(value) == expected
24+
25+
def test_parse_py_version_from_github_ci_file(
26+
self, value, expected
27+
):
28+
29+
with tempfile.NamedTemporaryFile("w", delete=False) as f:
30+
f.write(value)
31+
f.close()
32+
33+
assert _parse_py_version_from_github_ci_file(
34+
f.name
35+
) == expected
36+
37+
38+
@pytest.mark.parametrize(
39+
"bad_value", (
40+
'',
41+
"This string is not a version number at all!"
42+
# Malformed version numbers
43+
"3",
44+
"3.",
45+
"3.1",
46+
"3.1.",
47+
"3.1.2.",
48+
"3.1.0.dev",
49+
"3.1.0-dev."
50+
# Hex is not valid in version numbers
51+
"A",
52+
"3.A.",
53+
"3.1.A",
54+
"3.1.0.A",
55+
"3.1.0-dev.A"
56+
)
57+
)
58+
def test_parse_python_friendly_version_raises_value_errors(bad_value):
59+
with pytest.raises(ValueError):
60+
_parse_python_friendly_version(bad_value)
61+
62+
63+
@pytest.mark.parametrize('bad_type', (
64+
None,
65+
0xBAD,
66+
0.1234,
67+
(3, 1, 0),
68+
('3', '1' '0')
69+
))
70+
def test_parse_python_friendly_version_raises_typeerror_on_bad_values(bad_type):
71+
with pytest.raises(TypeError):
72+
_parse_python_friendly_version(bad_type) # type: ignore # Type mistmatch is the point
73+
74+
75+
def test_parse_py_version_from_github_ci_file_returns_zeroes_on_errors():
76+
fake_stderr = mock.MagicMock(sys.stderr)
77+
assert _parse_py_version_from_github_ci_file(
78+
"FILEDOESNOTEXIST", write_errors_to=fake_stderr
79+
) == "0.0.0"

0 commit comments

Comments
 (0)