From 9c6b5e322f112cf4fd50ea23678c909876814f55 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 25 Oct 2026 10:02:29 -0400 Subject: [PATCH] test(util): raise the util scripts' syntax floor to 3.8 (#1103) Problem: the util scripts' syntax checks parsed with feature_version=(3, 7) and the util docstrings promised 3.7 syntax, though requires-python is >=3.8 and no leg has run 3.7 since #2051. Fix: the six checks parse at FLOOR from test_requires_python_floor_2051, which pins requires-python's floor; the docstrings and workflows.rst say 3.8. The self-documenting f-string check in test_actions_helper_2047 is removed: f'{x=}' is 3.8 syntax. Test: the changed tests pass; a walrus is rejected at (3, 7), accepted at FLOOR. --- docs/source/contributing/workflows.rst | 2 +- tests/project/test_actions_helper_2047.py | 73 +++++-------------- .../test_release_conda_scripts_1967.py | 7 +- .../project/test_release_reset_build_1967.py | 7 +- tests/project/test_release_scripts_1967.py | 7 +- .../test_release_status_scripts_1967.py | 7 +- ...test_release_version_check_scripts_1967.py | 7 +- util/_actions.py | 2 +- util/release_conda.py | 2 +- util/release_published.py | 2 +- util/release_reset_build.py | 2 +- util/release_status.py | 2 +- util/release_tags.py | 2 +- util/release_tiers.py | 2 +- util/release_upload.py | 2 +- 15 files changed, 46 insertions(+), 80 deletions(-) diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index a6e4be8cf0..c510d6e2c4 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1440,7 +1440,7 @@ setup-python, ``build`` on conda's base ``python`` under the login shell, from :file:`conda/pypcapkit`, and ``upload`` on setup-python's. A project pin runs each on the cases the shell it replaced handled and requires the same status, output and calls. -:file:`util/_actions.py` is standard library only, and keeps to 3.7's syntax, +:file:`util/_actions.py` is standard library only, and keeps to 3.8's syntax, since the scripts on ``/usr/bin/python3`` and on every leg's interpreter import it. The scripts a person runs as well, or alone, sit beside the CI-only ones in diff --git a/tests/project/test_actions_helper_2047.py b/tests/project/test_actions_helper_2047.py index c7de2c6e91..7409ba8c6c 100644 --- a/tests/project/test_actions_helper_2047.py +++ b/tests/project/test_actions_helper_2047.py @@ -2,8 +2,9 @@ """#2047: :file:`util/_actions.py`, the GitHub Actions plumbing the ``util/`` scripts share. * :class:`TestStandardLibraryOnly` holds the helper to the standard library and - to 3.7's syntax, since :file:`util/pip_install.py` imports it on every - interpreter the matrices hold, before anything is installed. + to the syntax of ``requires-python``'s floor, 3.8, since + :file:`util/pip_install.py` imports it on every interpreter the matrices hold, + before anything is installed. * :class:`TestCommands`, :class:`TestFiles` and :class:`TestRun` pin each function's exact output, GitHub's escaping included. * :class:`TestScriptsImportIt` loads each script that imports the helper in the @@ -26,16 +27,16 @@ import subprocess # nosec: B404 import sys import tempfile -import tokenize import unittest from typing import TYPE_CHECKING from unittest import mock from tests._workflows import WORKFLOWS, load +from tests.project.test_requires_python_floor_2051 import FLOOR from util import _actions if TYPE_CHECKING: - from typing import Any, Iterator, Optional + from typing import Any, Iterator #: Test tier (#1958): reads and runs the tree's own scripts, a question about the tree. __tier__ = 'LATEST' @@ -48,10 +49,10 @@ 'python_versions', 'regenerate_samples', 'release_conda', 'release_published', 'release_reset_build', 'release_status', 'release_tags', 'release_tiers', 'release_upload', 'superseded', 'update_python_versions', 'vendor_code_changed', 'vendor_test_gate', 'verdict_covers_checkout') -#: The top-level modules the helper may import, each in Python 3.7's standard +#: The top-level modules the helper may import, each in Python 3.8's standard #: library: the running interpreter's :data:`sys.stdlib_module_names` would -#: pass ``tomllib``, which 3.7 lacks. -STDLIB_37 = frozenset({'__future__', 'contextlib', 'os', 'subprocess', 'sys', 'typing', 'uuid'}) +#: pass ``tomllib``, which 3.8 lacks. +STDLIB_38 = frozenset({'__future__', 'contextlib', 'os', 'subprocess', 'sys', 'typing', 'uuid'}) #: A trivial run of each script, ``(arguments, environment)``, that gets past its imports. RUNS = { 'apt_packages': (['--help'], {}), @@ -106,65 +107,25 @@ def imported(source: 'str') -> 'set[str]': return names -def self_documenting(source: 'str') -> 'list[str]': - """Each f-string replacement field in ``source`` that ends in 3.8's ``=``, as ``line:col``. - - ``ast.parse(..., feature_version=(3, 7))`` accepts ``f'{x=}'``. Since 3.12 an - f-string tokenizes into its fields (PEP 701), and there the form is an ``=`` - token followed at once by the ``}``, ``!`` or ``:`` that ends the expression; - ``==``, ``:=`` and a keyword argument's ``=`` are other tokens or are followed - by an operand. - - """ - found = [] - depth = 0 - previous = None # type: Optional[tokenize.TokenInfo] - for token in tokenize.generate_tokens(io.StringIO(source).readline): - if token.type == tokenize.FSTRING_START: # pylint: disable=no-member - depth += 1 - elif token.type == tokenize.FSTRING_END: # pylint: disable=no-member - depth -= 1 - elif depth and token.type == tokenize.OP and token.string in ('}', '!', ':') \ - and previous is not None and previous.type == tokenize.OP and previous.string == '=': - found.append('%d:%d' % previous.start) - if token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT): - previous = token - return found - - class TestStandardLibraryOnly(unittest.TestCase): - """The helper imports nothing but 3.7's standard library, and is written in 3.7's syntax.""" + """The helper imports nothing but 3.8's standard library, and is written in 3.8's syntax.""" def setUp(self) -> None: self.source = HELPER.read_text(encoding='utf-8') - def test_it_parses_as_python_3_7(self) -> None: - ast.parse(self.source, filename=str(HELPER), feature_version=(3, 7)) + def test_it_parses_as_python_3_8(self) -> None: + ast.parse(self.source, filename=str(HELPER), feature_version=FLOOR) - def test_it_imports_only_what_3_7_has(self) -> None: - extra = sorted(imported(self.source) - STDLIB_37) + def test_it_imports_only_what_3_8_has(self) -> None: + extra = sorted(imported(self.source) - STDLIB_38) self.assertEqual(extra, [], f'util/_actions.py imports {extra}: check that each is in the standard ' - 'library of Python 3.7 and every interpreter the matrices hold, then add ' - 'it to STDLIB_37 in this test') + 'library of Python 3.8 and every interpreter the matrices hold, then add ' + 'it to STDLIB_38 in this test') self.assertIn('uuid', imported(self.source), 'the import inside a function is seen') def test_the_import_check_sees_a_newer_module(self) -> None: - self.assertEqual(sorted(imported('import tomllib\nfrom os import path\n') - STDLIB_37), ['tomllib']) - self.assertEqual(sorted(imported('def f():\n from . import x\n') - STDLIB_37), ['.']) - - @unittest.skipUnless(hasattr(tokenize, 'FSTRING_START'), 'f-strings tokenize into fields from 3.12 (PEP 701)') - def test_it_has_no_self_documenting_f_string(self) -> None: - self.assertEqual(self_documenting(self.source), []) - - @unittest.skipUnless(hasattr(tokenize, 'FSTRING_START'), 'f-strings tokenize into fields from 3.12 (PEP 701)') - def test_the_f_string_check_tells_the_forms_apart(self) -> None: - for source in ('f"{x=}"', 'f"{x = }"', 'f"{x=!r}"', 'f"{x=:>4}"', 'f"a {f(y)=} b"', 'f"{f\'{x=}\'}"'): - with self.subTest(source=source): - self.assertEqual(len(self_documenting(source + '\n')), 1, source) - for source in ('f"{a==b}"', 'f"x={x!r}"', 'f"{x:=^5}"', 'f"{(y:=1)}"', 'f"{g(a=1)}"', 'f"{x!r:=>4}"', - 'x = {"a": 1}', 'def f(a=1): return {a: a}', "f'{d[\"k=\"]}'"): - with self.subTest(source=source): - self.assertEqual(self_documenting(source + '\n'), [], source) + self.assertEqual(sorted(imported('import tomllib\nfrom os import path\n') - STDLIB_38), ['tomllib']) + self.assertEqual(sorted(imported('def f():\n from . import x\n') - STDLIB_38), ['.']) class TestCommands(unittest.TestCase): diff --git a/tests/project/test_release_conda_scripts_1967.py b/tests/project/test_release_conda_scripts_1967.py index 61ba73d65c..ab0c8fb428 100644 --- a/tests/project/test_release_conda_scripts_1967.py +++ b/tests/project/test_release_conda_scripts_1967.py @@ -13,7 +13,7 @@ run writes, and the errors for a leg on another platform. * :class:`TestTheStepsRunTheScripts` pins each step to its script and its ``env:``, both jobs' legs to :data:`release_conda.PLATFORMS`, and the script - to Python 3.7's syntax. + to Python 3.8's syntax. """ @@ -33,6 +33,7 @@ from typing import TYPE_CHECKING from tests._workflows import job, steps +from tests.project.test_requires_python_floor_2051 import FLOOR from util import release_conda if TYPE_CHECKING: @@ -561,8 +562,8 @@ def test_every_leg_is_mapped_and_every_mapping_is_a_leg(self) -> None: self.assertLessEqual(set(labels), set(release_conda.PLATFORMS)) self.assertEqual(set(release_conda.PLATFORMS), legs) - def test_the_scripts_parse_as_python_3_7(self) -> None: - ast.parse((UTIL / 'release_conda.py').read_text(encoding='utf-8'), feature_version=(3, 7)) + def test_the_scripts_parse_as_python_3_8(self) -> None: + ast.parse((UTIL / 'release_conda.py').read_text(encoding='utf-8'), feature_version=FLOOR) if __name__ == '__main__': diff --git a/tests/project/test_release_reset_build_1967.py b/tests/project/test_release_reset_build_1967.py index 5b1167678a..629ff21ea0 100644 --- a/tests/project/test_release_reset_build_1967.py +++ b/tests/project/test_release_reset_build_1967.py @@ -11,7 +11,7 @@ shell ends. * :class:`TestTheStepRunsTheScript` pins the step to its script and its ``env:``, after a whole checkout of the repository, its ``git`` calls to ones - that never pull, merge, rebase, reset or force, and the script to Python 3.7's + that never pull, merge, rebase, reset or force, and the script to Python 3.8's syntax. The shell ran as a step with no ``shell:`` runs on the runner, ``bash -e``, and @@ -37,6 +37,7 @@ from typing import TYPE_CHECKING from tests._workflows import steps +from tests.project.test_requires_python_floor_2051 import FLOOR if TYPE_CHECKING: from typing import Callable, Optional @@ -462,9 +463,9 @@ def test_its_git_never_pulls_merges_rebases_resets_or_forces(self) -> None: self.assertEqual(commands, {'rev-parse', 'show', 'fetch', 'merge-base', 'ls-remote', 'switch', 'commit', 'push'}) - def test_it_parses_as_python_3_7(self) -> None: + def test_it_parses_as_python_3_8(self) -> None: """``tag`` has no setup-python, so it runs on the image's ``python3``.""" - ast.parse(SCRIPT.read_text(encoding='utf-8'), feature_version=(3, 7)) + ast.parse(SCRIPT.read_text(encoding='utf-8'), feature_version=FLOOR) if __name__ == '__main__': diff --git a/tests/project/test_release_scripts_1967.py b/tests/project/test_release_scripts_1967.py index b1034cce3b..3fd3de951a 100644 --- a/tests/project/test_release_scripts_1967.py +++ b/tests/project/test_release_scripts_1967.py @@ -10,7 +10,7 @@ scripts against the same stand-ins and requires the same status, standard output and error, ``$GITHUB_OUTPUT`` and calls. * :class:`TestTheStepsRunTheScripts` pins each step to its script, after a whole - checkout of the repository, and the scripts to Python 3.7's syntax. + checkout of the repository, and the scripts to Python 3.8's syntax. Their runs against a real repository, and the steps' ``env:``, are :mod:`tests.project.test_release_tags_the_run_sha_1634`'s. @@ -32,6 +32,7 @@ from typing import TYPE_CHECKING from tests._workflows import load, steps +from tests.project.test_requires_python_floor_2051 import FLOOR if TYPE_CHECKING: from typing import Callable @@ -517,11 +518,11 @@ def test_each_job_checks_the_whole_tree_out_first(self) -> None: self.assertFalse([step for step in found if (step.get('name'), job) in {(n, j) for j, n in STEPS} and 'working-directory' in step]) - def test_the_scripts_parse_as_python_3_7(self) -> None: + def test_the_scripts_parse_as_python_3_8(self) -> None: """The steps run them on the image's ``python3``, or on a KTLO leg's.""" for name in ('release_tags.py', 'release_upload.py'): with self.subTest(script=name): - ast.parse((UTIL / name).read_text(encoding='utf-8'), feature_version=(3, 7)) + ast.parse((UTIL / name).read_text(encoding='utf-8'), feature_version=FLOOR) if __name__ == '__main__': diff --git a/tests/project/test_release_status_scripts_1967.py b/tests/project/test_release_status_scripts_1967.py index 4443eb7211..7cb1cfbf21 100644 --- a/tests/project/test_release_status_scripts_1967.py +++ b/tests/project/test_release_status_scripts_1967.py @@ -15,7 +15,7 @@ runs that ``run:`` on its own case table, which reaches each of its branches. * :class:`TestTheStepsRunTheScripts` pins ``release_status``'s step to its script, after a checkout of :file:`util/` alone, and the script to the - standard library and Python 3.7's syntax. + standard library and Python 3.8's syntax. The shell ran as a step with no ``shell:`` runs on the runner, ``bash -e``, and ``bash`` prefixes each of its own errors with the script's path and line, which @@ -43,6 +43,7 @@ from tests._workflows import load, steps from tests.project.test_release_gates import job_blocks +from tests.project.test_requires_python_floor_2051 import FLOOR if TYPE_CHECKING: from typing import Callable, Optional @@ -615,12 +616,12 @@ def test_no_step_holds_the_former_shell(self) -> None: with self.subTest(job=job, step=step.get('name'), line=line): self.assertNotIn(line, step.get('run', '')) - def test_the_scripts_need_only_util_and_python_3_7(self) -> None: + def test_the_scripts_need_only_util_and_python_3_8(self) -> None: """The sparse checkout holds :file:`util/`, and the image's ``python3`` nothing installed.""" allowed = {'__future__', 'argparse', 'os', 'subprocess', 'sys', 'typing', '_actions', 'util'} for name in ('release_status.py',): with self.subTest(script=name): - tree = ast.parse((UTIL / name).read_text(encoding='utf-8'), feature_version=(3, 7)) + tree = ast.parse((UTIL / name).read_text(encoding='utf-8'), feature_version=FLOOR) imported = {alias.name.split('.')[0] for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names} imported |= {str(node.module).split('.')[0] for node in ast.walk(tree) diff --git a/tests/project/test_release_version_check_scripts_1967.py b/tests/project/test_release_version_check_scripts_1967.py index a20d7c69cc..9ecd054948 100644 --- a/tests/project/test_release_version_check_scripts_1967.py +++ b/tests/project/test_release_version_check_scripts_1967.py @@ -13,7 +13,7 @@ * :class:`TestAPlantedDivergenceIsCaught` changes each script in a copy, one plant at a time, and requires the cases to tell it from the shell. * :class:`TestTheStepsRunTheScripts` pins each step to its script, and the - scripts to the standard library and Python 3.7's syntax. + scripts to the standard library and Python 3.8's syntax. The steps run with no ``shell:``, so on the runner's ``bash -e``, and ``bash`` prefixes each of its own errors with the script's path and line, which @@ -39,6 +39,7 @@ from typing import TYPE_CHECKING from tests._workflows import load, steps +from tests.project.test_requires_python_floor_2051 import FLOOR if TYPE_CHECKING: from typing import Optional, Union @@ -818,11 +819,11 @@ def test_no_step_holds_the_former_shell(self) -> None: with self.subTest(job=job, step=step.get('name'), line=line): self.assertNotIn(line, step.get('run', '')) - def test_the_scripts_need_only_util_and_python_3_7(self) -> None: + def test_the_scripts_need_only_util_and_python_3_8(self) -> None: allowed = {'__future__', 'argparse', 'os', 're', 'subprocess', 'sys', 'typing', '_actions', 'util'} for name in ('release_tiers.py', 'release_published.py'): with self.subTest(script=name): - tree = ast.parse((UTIL / name).read_text(encoding='utf-8'), feature_version=(3, 7)) + tree = ast.parse((UTIL / name).read_text(encoding='utf-8'), feature_version=FLOOR) imported = {alias.name.split('.')[0] for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names} imported |= {str(node.module).split('.')[0] for node in ast.walk(tree) diff --git a/util/_actions.py b/util/_actions.py index 7580dcaf9e..2dcc11c9f2 100644 --- a/util/_actions.py +++ b/util/_actions.py @@ -12,7 +12,7 @@ The scripts run on the image's ``/usr/bin/python3``, or on a leg's own interpreter, which may be an end-of-life KTLO one, GraalPy or a free-threaded build, and :file:`util/pip_install.py` is the step that installs anything at -all. So this module is standard library only and keeps to 3.7's syntax; a +all. So this module is standard library only and keeps to 3.8's syntax; a project test, :file:`tests/project/test_actions_helper_2047.py`, holds it to both. diff --git a/util/release_conda.py b/util/release_conda.py index 9708f0788b..8120c94506 100644 --- a/util/release_conda.py +++ b/util/release_conda.py @@ -46,7 +46,7 @@ step: every label is ``main`` or ``dev``. On ``windows-latest`` the paths are Windows' own rather than Git Bash's ``/tmp/...``. -The script is standard library only, and keeps to Python 3.7's syntax. +The script is standard library only, and keeps to Python 3.8's syntax. """ diff --git a/util/release_published.py b/util/release_published.py index a20d55aeb6..e064aeb850 100644 --- a/util/release_published.py +++ b/util/release_published.py @@ -34,7 +34,7 @@ ``[``'s error and reads ``false``; a variable it reads that is unset, which Actions never leaves it, is ``: unbound variable`` and status 1. -It keeps to the standard library and Python 3.7's syntax as the other +It keeps to the standard library and Python 3.8's syntax as the other :file:`util/` scripts do. """ diff --git a/util/release_reset_build.py b/util/release_reset_build.py index 268dba276a..c319455be3 100644 --- a/util/release_reset_build.py +++ b/util/release_reset_build.py @@ -30,7 +30,7 @@ ``set -u`` did. ``tag`` has no setup-python, so this runs on the image's ``python3``: standard -library only, and Python 3.7's syntax. Its ``git switch`` replaces the checkout, +library only, and Python 3.8's syntax. Its ``git switch`` replaces the checkout, :file:`util/` included, so everything it imports is imported first. """ diff --git a/util/release_status.py b/util/release_status.py index 97d606a35c..4ff9d90b81 100644 --- a/util/release_status.py +++ b/util/release_status.py @@ -34,7 +34,7 @@ ``set -u`` would have, rather than with a traceback. The job checks out only :file:`util/` and has no setup-python, so this runs on -the image's ``python3``: standard library only, and Python 3.7's syntax. +the image's ``python3``: standard library only, and Python 3.8's syntax. """ diff --git a/util/release_tags.py b/util/release_tags.py index ccda99ee20..8bb96580b6 100644 --- a/util/release_tags.py +++ b/util/release_tags.py @@ -30,7 +30,7 @@ The ``conda`` legs run it before setup-python, so ``python3`` is the image's, and ``github`` has no setup-python at all. So the script is standard library -only, and keeps to Python 3.7's syntax. +only, and keeps to Python 3.8's syntax. """ diff --git a/util/release_tiers.py b/util/release_tiers.py index 68e245f4e5..b79058f7a5 100644 --- a/util/release_tiers.py +++ b/util/release_tiers.py @@ -31,7 +31,7 @@ -d`` left it. It runs on setup-python's ``python3``, the interpreter it runs the tag's -reader on, but keeps to the standard library and Python 3.7's syntax as the +reader on, but keeps to the standard library and Python 3.8's syntax as the other :file:`util/` scripts do. """ diff --git a/util/release_upload.py b/util/release_upload.py index 1f90e0b6a2..ff3895a88c 100644 --- a/util/release_upload.py +++ b/util/release_upload.py @@ -27,7 +27,7 @@ that is not then a regular file gets an ``::error`` and exits 1. A failing ``gh`` ends the script with its status, as ``set -e`` ended the step. -The script is standard library only, and keeps to Python 3.7's syntax. +The script is standard library only, and keeps to Python 3.8's syntax. """