From b4831a89f9e1457c9b5604b6c84e0a4b5d0b595a Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 23 Oct 2026 00:53:38 -0400 Subject: [PATCH] ci(release): run version_check's reads from util/ scripts (#1967) Step 2 of #1967, third batch for create-release.yml: three of version_check's inline shell steps move into stdlib-only scripts, on the python3 setup-python puts first on PATH, with each step's name, id and env: unchanged; only the run: bodies differ. Get Version stays inline. * util/release_tiers.py: "Read the released Python versions", with #2079's copy of the released commit's util/_actions.py, where it has one. * util/release_published.py pypi|anaconda: "Check PyPI publish completeness" and "Check Anaconda publish completeness", which shared all but endpoint, expectation and count. Same curl, URLs and /tmp paths. * workflows.rst lists both scripts. tests/project/test_release_version_check_scripts_1967.py runs each former shell, kept verbatim, and its script on the same cases against stand-in python3, git, mktemp and curl, requiring the same status, output, $GITHUB_OUTPUT, calls and scratch files; the tables reach every exit and path of each shell, and planted divergences are caught. The tests that ran or pinned the shell now run or read the scripts. --- .github/workflows/create-release.yml | 117 +-- docs/source/contributing/workflows.rst | 9 + tests/project/test_actions_helper_2047.py | 8 +- ...onda_completeness_counts_every_leg_1922.py | 25 +- .../project/test_python_matrix_tiers_1633.py | 11 +- tests/project/test_python_versions_1660.py | 5 +- tests/project/test_release_gates.py | 43 +- .../test_release_tags_the_run_sha_1634.py | 4 +- ...test_release_version_check_scripts_1967.py | 840 ++++++++++++++++++ util/release_published.py | 256 ++++++ util/release_tiers.py | 210 +++++ 11 files changed, 1380 insertions(+), 148 deletions(-) create mode 100644 tests/project/test_release_version_check_scripts_1967.py create mode 100644 util/release_published.py create mode 100644 util/release_tiers.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 169312c7ca..65e3e5fe2b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -252,42 +252,7 @@ jobs: PCAPKIT_TAG_EXISTS: ${{ steps.check_tag.outputs.exists }} PCAPKIT_PYPI_WHEELS: ${{ steps.python-versions.outputs.pypi-wheels }} PCAPKIT_CONDA_BUILDS: ${{ steps.python-versions.outputs.conda-builds }} - run: | - set -euo pipefail - - wheels="$PCAPKIT_PYPI_WHEELS" - builds="$PCAPKIT_CONDA_BUILDS" - tiers=.github/python-versions.toml - if [ "$PCAPKIT_TAG_EXISTS" = true ]; then - # The checkout fetched every tag, but not one pushed since. - git rev-parse -q --verify "refs/tags/${PCAPKIT_TAG}^{commit}" > /dev/null \ - || git fetch -q --no-tags origin "+refs/tags/${PCAPKIT_TAG}:refs/tags/${PCAPKIT_TAG}" - from="$(git rev-parse "refs/tags/${PCAPKIT_TAG}^{commit}")" - if ! git cat-file -e "${from}:${tiers}" 2> /dev/null; then - from="$(git log --diff-filter=A --format=%H -- "$tiers" | tail -n 1)" - if [ -z "$from" ]; then - echo "::error title=No tier file in the history::${tiers} was never added to this checkout's history." - exit 1 - fi - echo "::notice title=Released before the tier file::${PCAPKIT_TAG} predates ${tiers}; counting the tiers ${from} added it with." - fi - if [ "$from" != "$(git rev-parse HEAD)" ]; then - released="$(mktemp -d)" - git show "${from}:${tiers}" > "${released}/python-versions.toml" - git show "${from}:util/python_versions.py" > "${released}/python_versions.py" - # #2047: the reader imports util/_actions.py from beside it, from the - # commit that has it. - if git cat-file -e "${from}:util/_actions.py" 2> /dev/null; then - git show "${from}:util/_actions.py" > "${released}/_actions.py" - fi - wheels="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" pypi-wheels)" - builds="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" conda-builds)" - fi - fi - - echo "${PCAPKIT_TAG}: counting the wheels ${wheels} and the conda builds ${builds}" - echo "pypi-wheels=${wheels}" >> "$GITHUB_OUTPUT" - echo "conda-builds=${builds}" >> "$GITHUB_OUTPUT" + run: python3 util/release_tiers.py # PyPI evidence: a wheel per supported-tier leg (``PCAPKIT_PYPI_WHEELS``; # #1633 -- a KTLO wheel neither completes the release nor, failing, leaves @@ -303,44 +268,7 @@ jobs: env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} PCAPKIT_PYPI_WHEELS: ${{ steps.released.outputs.pypi-wheels }} - run: | - set -euo pipefail - - # One wheel per supported leg, plus the sdist. - read -r -a wheels <<< "$PCAPKIT_PYPI_WHEELS" - expected=$(( ${#wheels[@]} + 1 )) - # `--retry-connrefused` and `|| echo 000` keep a pypi.org outage from - # failing `version_check` under `set -e`, which every publisher - # requires; "incomplete" is the safe default. - status="$(curl -s --retry 3 --retry-connrefused \ - -o /tmp/pypi_release.json -w '%{http_code}' \ - "https://pypi.org/pypi/pypcapkit/${PCAPKIT_VERSION}/json" || echo 000)" - - count=0 - if [ "$status" = "200" ]; then - # One line per `printf` argument, so the block scalar's indentation - # never reaches the source `python3 -` reads: indented `python3 -c` - # source compiles only on Python 3.14 (3.9 through 3.13 raise - # `IndentationError: unexpected indent`, measured). Not leaning on - # the setup-python above: the conda job's check runs before its own. - count="$(printf '%s\n' \ - "import json, os" \ - "with open('/tmp/pypi_release.json') as f:" \ - " data = json.load(f)" \ - "supported = set(os.environ['PCAPKIT_PYPI_WHEELS'].split())" \ - "names = [u.get('filename', '') for u in data.get('urls', [])]" \ - "wheels = {n.split('-')[2] for n in names if n.endswith('.whl') and n.count('-') == 4}" \ - "print(len(wheels & supported) + any(n.endswith('.tar.gz') for n in names))" \ - | python3 -)" - fi - - complete=false - if [ "$count" -ge "$expected" ]; then - complete=true - fi - - echo "http $status, found $count of $expected expected files for ${PCAPKIT_VERSION} on PyPI" - echo "PCAPKIT_PYPI_COMPLETE=$complete" >> "$GITHUB_OUTPUT" + run: python3 util/release_published.py pypi /tmp/pypi_release.json # Anaconda evidence, as PyPI's above, per platform and Python: the # job-level cost saver, under which ``conda``'s per-leg checks, the @@ -351,46 +279,7 @@ jobs: env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} PCAPKIT_CONDA_BUILDS: ${{ steps.released.outputs.conda-builds }} - run: | - set -euo pipefail - - # Each platform the ``conda`` legs publish, for each supported version. - export PCAPKIT_CONDA_SUBDIRS="linux-64 osx-64 osx-arm64" - read -r -a subdirs <<< "$PCAPKIT_CONDA_SUBDIRS" - read -r -a builds <<< "$PCAPKIT_CONDA_BUILDS" - expected=$(( ${#subdirs[@]} * ${#builds[@]} )) - # Same coupling risk, and the same fix, as the PyPI check above. - status="$(curl -s --retry 3 --retry-connrefused \ - -o /tmp/anaconda_release.json -w '%{http_code}' \ - "https://api.anaconda.org/release/jarryshaw/pypcapkit/${PCAPKIT_VERSION}" || echo 000)" - - count=0 - if [ "$status" = "200" ]; then - # The same `printf` form as the PyPI check above. - count="$(printf '%s\n' \ - "import json, os" \ - "with open('/tmp/anaconda_release.json') as f:" \ - " data = json.load(f)" \ - "platforms = set(os.environ['PCAPKIT_CONDA_SUBDIRS'].split())" \ - "py_tags = set(os.environ['PCAPKIT_CONDA_BUILDS'].split())" \ - "seen = {" \ - " (d.get('attrs', {}).get('subdir'), tag)" \ - " for d in data.get('distributions', [])" \ - " for tag in py_tags" \ - " if d.get('attrs', {}).get('build', '').startswith(tag + '_')" \ - " and d.get('attrs', {}).get('subdir') in platforms" \ - "}" \ - "print(len(seen))" \ - | python3 -)" - fi - - complete=false - if [ "$count" -ge "$expected" ]; then - complete=true - fi - - echo "http $status, found $count of $expected expected platform/Python combinations for ${PCAPKIT_VERSION} on Anaconda" - echo "PCAPKIT_CONDA_COMPLETE=$complete" >> "$GITHUB_OUTPUT" + run: python3 util/release_published.py anaconda /tmp/anaconda_release.json # #1748: resolved as #1634's check in ``github``, ``pypi`` and ``conda`` # resolves it, so the gates skip exactly the runs that check would refuse. diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index b16a65c0cb..02a0121ed1 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1282,6 +1282,15 @@ does; each has tests under :file:`tests/project/` that run it. - ``Read whether its vendor-update job ran`` in ``create-release.yml``'s ``vendor_update``, on the image's ``python3`` after a checkout of :file:`util/` alone (`#1748 `__). + * - :file:`util/release_tiers.py` + - ``Read the released Python versions`` in ``create-release.yml``'s + ``version_check``, on setup-python's ``python3`` (`#1660 + `__). + * - :file:`util/release_published.py` + - ``Check PyPI publish completeness`` and ``Check Anaconda publish + completeness`` in ``create-release.yml``'s ``version_check``, as + ``release_published.py pypi`` and ``anaconda``, on setup-python's + ``python3`` (`#888 `__). The summaries run on the image's ``/usr/bin/python3`` rather than the leg's interpreter, which may have failed to set up or be too broken to run anything, so diff --git a/tests/project/test_actions_helper_2047.py b/tests/project/test_actions_helper_2047.py index 475bc68ded..17038ca93d 100644 --- a/tests/project/test_actions_helper_2047.py +++ b/tests/project/test_actions_helper_2047.py @@ -45,9 +45,9 @@ #: The scripts that import the helper. SCRIPTS = ('apt_packages', 'check_suite_report', 'ci_timing', 'classify_changes', 'codeql_report', 'coverage_report', 'discussion_report', 'leg_summary', 'pip_install', 'project_status', - 'python_versions', 'regenerate_samples', 'release_status', 'release_tags', 'release_upload', - 'release_vendor_update', 'superseded', 'update_python_versions', 'vendor_code_changed', - 'vendor_test_gate', 'verdict_covers_checkout') + 'python_versions', 'regenerate_samples', 'release_published', 'release_status', 'release_tags', + 'release_tiers', 'release_upload', 'release_vendor_update', '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 #: library: the running interpreter's :data:`sys.stdlib_module_names` would #: pass ``tomllib``, which 3.7 lacks. @@ -66,8 +66,10 @@ 'project_status': (['--help'], {}), 'python_versions': (['latest'], {}), 'regenerate_samples': (['--help'], {}), + 'release_published': (['--help'], {}), 'release_status': (['--help'], {}), 'release_tags': (['--help'], {}), + 'release_tiers': (['--help'], {}), 'release_upload': (['--help'], {}), 'release_vendor_update': (['--help'], {}), 'superseded': (['--help'], {}), diff --git a/tests/project/test_conda_completeness_counts_every_leg_1922.py b/tests/project/test_conda_completeness_counts_every_leg_1922.py index 3b9f49e552..ee69e32f61 100644 --- a/tests/project/test_conda_completeness_counts_every_leg_1922.py +++ b/tests/project/test_conda_completeness_counts_every_leg_1922.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- """``version_check``'s Anaconda completeness check counts every platform the ``conda`` legs publish (#1922). -* The platforms it counts are the ones the ``conda`` job's ``os`` legs map to, - read from that job's matrix and its ``case "${{ matrix.os }}"`` arms. +* The platforms it counts, :file:`util/release_published.py`'s ``SUBDIRS`` + since #1967, are the ones the ``conda`` job's ``os`` legs map to, read from + that job's matrix and its ``case "${{ matrix.os }}"`` arms. * Run as the workflow runs it, with ``curl`` stubbed, a release reads complete only with each of those platforms for each build, so a missing ``osx-arm64`` upload reads incomplete and the ``conda`` gate runs the leg that lacks it. @@ -17,11 +18,13 @@ import re import shutil import subprocess # nosec: B404 +import sys import tempfile import unittest from typing import TYPE_CHECKING from tests import _workflows +from util import release_published try: import yaml @@ -38,8 +41,6 @@ #: A ``case "${{ matrix.os }}" in`` arm setting ``target_platform``. ARM = re.compile(r'^\s*([\w.-]+)\)\s*\n\s*target_platform="([^"]*)"', re.MULTILINE) -#: The check's own list of platforms, which sets both its count and its set. -SUBDIRS = re.compile(r'^\s*export PCAPKIT_CONDA_SUBDIRS="([^"]*)"\s*$', re.MULTILINE) def jobs() -> 'dict[str, Any]': @@ -70,16 +71,15 @@ class TheCheckCountsTheLegs(unittest.TestCase): """What the check counts is what the legs publish.""" def test_the_check_lists_each_published_platform(self) -> None: - listed = SUBDIRS.findall(check()['run']) - self.assertEqual(len(listed), 1, 'the check names its platforms in one PCAPKIT_CONDA_SUBDIRS list') - self.assertEqual(sorted(listed[0].split()), sorted(published())) + """Its one list of platforms, which sets both its count and its set.""" + self.assertEqual(check()['run'].split()[:3], ['python3', 'util/release_published.py', 'anaconda']) + self.assertEqual(sorted(release_published.SUBDIRS.split()), sorted(published())) self.assertIn('osx-arm64', published()) -@unittest.skipIf(shutil.which('bash') is None or shutil.which('python3') is None, - 'bash and python3 are needed to run the workflow shell') +@unittest.skipIf(shutil.which('python3') is None, 'python3 is needed to run the count') class TheCheckRunsAsTheWorkflowRunsIt(unittest.TestCase): - """The step's script, with ``curl`` answering from a fake Anaconda release.""" + """The step's script, on this interpreter, with ``curl`` answering from a fake Anaconda release.""" def complete(self, distributions: 'list[tuple[str, str]]') -> 'tuple[str, str]': """``PCAPKIT_CONDA_COMPLETE`` and the log for a release holding these (platform, build) pairs.""" @@ -96,8 +96,9 @@ def complete(self, distributions: 'list[tuple[str, str]]') -> 'tuple[str, str]': curl.chmod(0o755) output = scratch / 'output' output.write_text('', encoding='utf-8') - result = subprocess.run( # nosec: B603 B607 - ['bash', '-e', '-c', step['run'].replace('/tmp/anaconda_release.json', str(scratch / 'release.json'))], + words = step['run'].replace('/tmp/anaconda_release.json', str(scratch / 'release.json')).split() + result = subprocess.run( # nosec: B603 + [sys.executable, str(ROOT / words[1]), *words[2:]], env=dict(os.environ, PATH=f'{scratch}{os.pathsep}{os.environ["PATH"]}', REPLY=str(reply), GITHUB_OUTPUT=str(output), PCAPKIT_VERSION='1.5.0', PCAPKIT_CONDA_BUILDS=' '.join(BUILDS)), capture_output=True, text=True, check=False, timeout=60) diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 05ed53ca25..b9e73e149c 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -391,10 +391,13 @@ def pypi_complete(self, files: 'list[str]') -> 'str': curl.chmod(0o755) output = self.tmp / 'output' output.write_text('', encoding='utf-8') - result = bash(script.replace('/tmp/pypi_release.json', str(self.tmp / 'release.json')), - {'PATH': f'{bin_dir}{os.pathsep}{os.environ["PATH"]}', 'REPLY': str(reply), - 'GITHUB_OUTPUT': str(output), 'PCAPKIT_VERSION': '1.5.0', - 'PCAPKIT_PYPI_WHEELS': wheels}) + # #1967: ``python3 util/release_published.py pypi ...``, on this interpreter. + command = script_command(script.replace('/tmp/pypi_release.json', str(self.tmp / 'release.json'))) + assert command is not None, script + result = subprocess.run(command, env=dict(os.environ, PATH=f'{bin_dir}{os.pathsep}{os.environ["PATH"]}', + REPLY=str(reply), GITHUB_OUTPUT=str(output), + PCAPKIT_VERSION='1.5.0', PCAPKIT_PYPI_WHEELS=wheels), + capture_output=True, text=True, check=False, timeout=60) self.assertEqual(result.returncode, 0, result.stderr) return dict(line.split('=', 1) for line in output.read_text(encoding='utf-8').split())['PCAPKIT_PYPI_COMPLETE'] diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py index 08e9cd1dca..722597335d 100644 --- a/tests/project/test_python_versions_1660.py +++ b/tests/project/test_python_versions_1660.py @@ -908,8 +908,11 @@ def complete(self, distributions: 'list[tuple[str, str]]') -> 'str': curl.chmod(0o755) output = scratch / 'output' output.write_text('', encoding='utf-8') + # #1967: ``python3 util/release_published.py anaconda ...``, on this interpreter. + words = step['run'].replace('/tmp/anaconda_release.json', str(scratch / 'release.json')).split() + self.assertEqual(words[:2], ['python3', 'util/release_published.py']) result = subprocess.run( - ['bash', '-e', '-c', step['run'].replace('/tmp/anaconda_release.json', str(scratch / 'release.json'))], + [sys.executable, str(ROOT / words[1]), *words[2:]], env=dict(os.environ, PATH=f'{scratch}{os.pathsep}{os.environ["PATH"]}', REPLY=str(reply), GITHUB_OUTPUT=str(output), PCAPKIT_VERSION='1.5.0', PCAPKIT_CONDA_BUILDS=builds), capture_output=True, text=True, check=False, timeout=60) diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index a79b32e28a..e749e196a7 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -2190,6 +2190,10 @@ def test_dropping_any_gate_clause_is_caught(self) -> None: self.assertTrue(gate_coverage_violations(mutant, self.jobs)) +#: ``version_check``'s PyPI and Anaconda evidence checks, since #1967. +EVIDENCE_SCRIPT = 'util/release_published.py' + + class TestEvidenceBasedGating(WorkflowMixin, unittest.TestCase): """#888: ``tag``, ``pypi`` and ``conda`` read their own evidence now, instead of ``PCAPKIT_TAG_EXISTS`` -- the tag ``github`` creates, which @@ -2421,15 +2425,16 @@ def test_pypi_evidence_reads_the_per_version_endpoint(self) -> None: make the evidence check always read 0 files. """ - block = self.jobs['version_check'] - self.assertIn('pypi.org/pypi/pypcapkit/', block) - self.assertIn("data.get('urls'", block) + # #1967: the step runs util/release_published.py, which holds the check. + self.assertIn(f'run: python3 {EVIDENCE_SCRIPT} pypi ', self.jobs['version_check']) + script = (ROOT / EVIDENCE_SCRIPT).read_text(encoding='utf-8') + self.assertIn("PYPI_URL = 'https://pypi.org/pypi/pypcapkit/'", script) + self.assertIn("data.get('urls'", script) def test_conda_evidence_checks_every_platform(self) -> None: - block = self.jobs['version_check'] - self.assertIn('linux-64', block) - self.assertIn('osx-64', block) - self.assertIn('osx-arm64', block) + self.assertIn(f'run: python3 {EVIDENCE_SCRIPT} anaconda ', self.jobs['version_check']) + script = (ROOT / EVIDENCE_SCRIPT).read_text(encoding='utf-8') + self.assertIn("SUBDIRS = 'linux-64 osx-64 osx-arm64'", script) class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): @@ -3559,18 +3564,30 @@ class TestPythonSnippetsSurviveOlderInterpreters(WorkflowMixin, unittest.TestCas itself put there -- this class is what actually runs each snippet under an older interpreter to check that claim, rather than trusting it. + Since #1967 the PyPI and Anaconda evidence checks in ``version_check`` feed + ``python3 -`` from :file:`util/release_published.py` instead, so this runs + those snippets too, as that script builds them. + """ + def snippets(self) -> 'list[str]': + """The workflow's ``printf`` snippets, then the evidence script's.""" + from util import release_published # pylint: disable=import-outside-toplevel + return printf_python_snippets(self.text) + [ # type: ignore[attr-defined] + source('/tmp/release.json') for source in (release_published.pypi_source, + release_published.anaconda_source)] + def test_the_scan_found_the_snippets(self) -> None: """A scan that finds nothing would pass every test below it.""" snippets = printf_python_snippets(self.text) # type: ignore[attr-defined] self.assertEqual( - len(snippets), 3, - 'expected exactly 3 printf-fed python3 snippets (the PyPI and ' - 'Anaconda evidence checks in version_check, plus the per-leg ' - 'check in conda); found a different count -- if that is ' - 'intended, this expectation needs revisiting alongside it', + len(snippets), 1, + 'expected exactly 1 printf-fed python3 snippet (the per-leg ' + 'check in conda; version_check\'s two run from ' + 'util/release_published.py since #1967); found a different count ' + '-- if that is intended, this expectation needs revisiting alongside it', ) + self.assertEqual(len(self.snippets()), 3) def test_each_snippet_runs_without_an_indentation_error(self) -> None: interpreter = pre_314_interpreter() @@ -3581,7 +3598,7 @@ def test_each_snippet_runs_without_an_indentation_error(self) -> None: [interpreter, '--version'], capture_output=True, text=True, ).stdout.strip() - for index, snippet in enumerate(printf_python_snippets(self.text)): # type: ignore[attr-defined] + for index, snippet in enumerate(self.snippets()): with self.subTest(snippet=index): # `$target_platform`/`$py_tag` are bash interpolation the real # step performs before Python ever sees this text; harmless diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index 34d4b80623..09fa437a27 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -1236,7 +1236,9 @@ def complete(self, step: 'str', wheels: 'str', builds: 'str', reply: 'dict[str, self.output.write_text('', encoding='utf-8') env = dict(self.env, REPLY=str(self.tmp / 'reply.json'), PCAPKIT_VERSION=self.VERSION, PCAPKIT_PYPI_WHEELS=wheels, PCAPKIT_CONDA_BUILDS=builds) - result = subprocess.run([BASH, '-e', str(self.tmp / 'check.sh')], cwd=self.tmp, env=env, capture_output=True, + # #1967: ``python3 util/release_published.py ...``, on this interpreter. + command = script_command(script) or [BASH, '-e', str(self.tmp / 'check.sh')] + result = subprocess.run(command, cwd=self.tmp, env=env, capture_output=True, text=True, check=False, timeout=scale_timeout(60)) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) written = dict(line.split('=', 1) for line in self.output.read_text(encoding='utf-8').splitlines()) diff --git a/tests/project/test_release_version_check_scripts_1967.py b/tests/project/test_release_version_check_scripts_1967.py new file mode 100644 index 0000000000..1195316890 --- /dev/null +++ b/tests/project/test_release_version_check_scripts_1967.py @@ -0,0 +1,840 @@ +# -*- coding: utf-8 -*- +"""#1967: ``version_check``'s :file:`util/` scripts, run as Create Release runs them. + +* :class:`TestTheFormerShellAgrees` runs the shell ``version_check``'s "Read + the released Python versions", "Check PyPI publish completeness" and "Check + Anaconda publish completeness" held before :file:`util/release_tiers.py` and + :file:`util/release_published.py`, on the cases below, and each script on the + same, against stand-in ``python3``, ``git``, ``mktemp`` and ``curl``, and + requires the same status, standard output and error, + ``$GITHUB_OUTPUT``, calls and scratch files. The step bodies are kept here + verbatim, and each case table is checked to reach every way its shell ends + and every path through it. +* :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. + +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 +:meth:`_Stand.former` strips; the rest of standard error is compared whole, +``[``'s complaint as bash 5.2, ``ubuntu-latest``'s, words it. The runs against +a real repository are +:mod:`tests.project.test_release_tags_the_run_sha_1634`'s. + +""" + +from __future__ import annotations + +import ast +import os +import pathlib +import re +import shutil +import subprocess # nosec: B404 +import sys +import tempfile +import textwrap +import unittest +from typing import TYPE_CHECKING + +from tests._workflows import load, steps + +if TYPE_CHECKING: + from typing import Optional, Union + +#: Test tier (#1958): runs the tree's own scripts and the workflow's former shell. +__tier__ = 'LATEST' + +ROOT = pathlib.Path(__file__).resolve().parents[2] +UTIL = ROOT / 'util' +WORKFLOW = 'create-release.yml' +BASH = shutil.which('bash') or '' + + +def bash_version() -> 'str': + """The ``bash`` on ``PATH``'s ``major.minor``, or ``''``.""" + if not BASH: + return '' + found = subprocess.run([BASH, '-c', 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], # nosec: B603 + capture_output=True, text=True, check=False, timeout=30) + return found.stdout.strip() + + +#: Whether ``bash`` is 5.2, the runner's, whose ``[`` errors the scripts copy. +BASH_52 = bash_version() == '5.2' + +RUN = '1' * 40 +OTHER = '2' * 40 +ADDED = '3' * 40 +TAG = 'v1.5.0b12' + +#: A ``python3`` copying a ``-`` snippet to the log and running it on this +#: interpreter, or answering it with ``$COUNT_REPLY``; and answering a tag's +#: ``python_versions.py`` with ``$READER_WHEELS`` or ``$READER_BUILDS``, or +#: failing with ``$READER_FAIL``'s status. +FAKE_PYTHON3 = r'''#!/bin/sh +printf 'python3 %s\n' "$*" >> "$CALL_LOG" +if [ "$1" = - ]; then + cat > "$CALL_LOG.src" + cat "$CALL_LOG.src" >> "$CALL_LOG" + [ -z "${COUNT_REPLY+set}" ] || { printf '%s\n' "$COUNT_REPLY"; exit 0; } + exec "$REAL_PYTHON" - < "$CALL_LOG.src" +fi +case "$1" in + */python_versions.py) + [ -z "${READER_FAIL:-}" ] || { echo 'Traceback (most recent call last): reader' >&2; exit "$READER_FAIL"; } + for last; do :; done + case "$last" in + pypi-wheels) printf '%s\n' "$READER_WHEELS" ;; + conda-builds) printf '%s\n' "$READER_BUILDS" ;; + *) exit 98 ;; + esac + ;; + *) exit 99 ;; +esac +''' + +#: A ``git`` answering as "Read the released Python versions" calls it, from +#: the tags in ``$GIT_TAGS`` and ``$GIT_REMOTE_TAGS`` (`` `` lines), +#: the ``:`` blobs in ``$GIT_FILES``, ``$GIT_LOG_REPLY``'s bytes, +#: and ``$GIT_HEAD``; each ``*_FAIL`` fails its command with that status. +FAKE_GIT = r'''#!/bin/sh +printf 'git %s\n' "$*" >> "$CALL_LOG" +die() { [ "$1" != TERM ] || kill -TERM $$; exit "$1"; } +lookup() { awk -v tag="$1" '$1 == tag { print $2; exit }' "$2"; } +case "$1" in + rev-parse) + if [ "$2" = HEAD ]; then + [ -z "${HEAD_FAIL:-}" ] || { echo "fatal: ambiguous argument 'HEAD'" >&2; die "$HEAD_FAIL"; } + echo "$GIT_HEAD" + exit 0 + fi + if [ "$2" = -q ]; then ref="$4"; else ref="$2"; fi + tag="${ref#refs/tags/}" + tag="${tag%'^{commit}'}" + sha="$(lookup "$tag" "$GIT_TAGS")" + if [ -z "$sha" ]; then + [ "$2" = -q ] && exit 1 + echo "fatal: ambiguous argument '$ref': unknown revision" >&2 + die "${RESOLVE_FAIL:-128}" + fi + echo "$sha" + ;; + fetch) + [ -z "${FETCH_FAIL:-}" ] || { echo "fatal: couldn't find remote ref $6" >&2; die "$FETCH_FAIL"; } + tag="${6#+refs/tags/}" + tag="${tag%%:*}" + sha="$(lookup "$tag" "$GIT_REMOTE_TAGS")" + [ -z "$sha" ] || printf '%s %s\n' "$tag" "$sha" >> "$GIT_TAGS" + ;; + cat-file) + grep -qxF -- "$3" "$GIT_FILES" || { echo "fatal: path '$3' does not exist" >&2; exit 128; } + ;; + log) + [ -z "${LOG_FAIL:-}" ] || { echo 'fatal: bad revision' >&2; die "$LOG_FAIL"; } + cat "$GIT_LOG_REPLY" + ;; + show) + case "$2" in *"${SHOW_FAIL:-/none/}") echo "fatal: path '$2' does not exist" >&2; die 128 ;; esac + printf 'shown %s\n' "$2" + ;; + *) exit 99 ;; +esac +''' + +#: A ``mktemp`` making ``$SCRATCH``, or failing with ``$MKTEMP_FAIL``'s status. +FAKE_MKTEMP = r'''#!/bin/sh +printf 'mktemp %s\n' "$*" >> "$CALL_LOG" +[ -z "${MKTEMP_FAIL:-}" ] || { echo 'mktemp: failed to create directory via template' >&2; exit "$MKTEMP_FAIL"; } +mkdir -p "$SCRATCH" +echo "$SCRATCH" +''' + +#: A ``curl`` writing ``$CURL_BODY`` to its ``-o`` file, printing ``$CURL_CODE`` +#: for ``-w`` (``200`` unless set), and failing with ``$CURL_FAIL``'s status; it +#: logs whether ``PCAPKIT_CONDA_SUBDIRS`` reached it. +FAKE_CURL = r'''#!/bin/sh +printf 'curl [%s] %s\n' "${PCAPKIT_CONDA_SUBDIRS-unset}" "$*" >> "$CALL_LOG" +out= +while [ $# -gt 0 ]; do + [ "$1" != -o ] || { out="$2"; shift; } + shift +done +[ -z "${CURL_STDERR:-}" ] || echo "$CURL_STDERR" >&2 +[ -z "${CURL_BODY:-}" ] || cp "$CURL_BODY" "$out" +printf '%s' "${CURL_CODE-200}" +[ -z "${CURL_FAIL:-}" ] || { [ "$CURL_FAIL" != TERM ] || kill -TERM $$; exit "$CURL_FAIL"; } +''' + +#: What the stand-ins and the former shell run besides each other and bash's builtins. +TOOLS = ('awk', 'cat', 'cp', 'grep', 'mkdir', 'tail') +#: Each stand-in, by name. +FAKES = {'python3': FAKE_PYTHON3, 'git': FAKE_GIT, 'mktemp': FAKE_MKTEMP, 'curl': FAKE_CURL} + +#: "Read the released Python versions", before :file:`util/release_tiers.py`. +FORMER_TIERS = textwrap.dedent(r''' + set -euo pipefail + + wheels="$PCAPKIT_PYPI_WHEELS" + builds="$PCAPKIT_CONDA_BUILDS" + tiers=.github/python-versions.toml + if [ "$PCAPKIT_TAG_EXISTS" = true ]; then + # The checkout fetched every tag, but not one pushed since. + git rev-parse -q --verify "refs/tags/${PCAPKIT_TAG}^{commit}" > /dev/null \ + || git fetch -q --no-tags origin "+refs/tags/${PCAPKIT_TAG}:refs/tags/${PCAPKIT_TAG}" + from="$(git rev-parse "refs/tags/${PCAPKIT_TAG}^{commit}")" + if ! git cat-file -e "${from}:${tiers}" 2> /dev/null; then + from="$(git log --diff-filter=A --format=%H -- "$tiers" | tail -n 1)" + if [ -z "$from" ]; then + echo "::error title=No tier file in the history::${tiers} was never added to this checkout's history." + exit 1 + fi + echo "::notice title=Released before the tier file::${PCAPKIT_TAG} predates ${tiers}; counting the tiers ${from} added it with." + fi + if [ "$from" != "$(git rev-parse HEAD)" ]; then + released="$(mktemp -d)" + git show "${from}:${tiers}" > "${released}/python-versions.toml" + git show "${from}:util/python_versions.py" > "${released}/python_versions.py" + # #2047: the reader imports util/_actions.py from beside it, from the + # commit that has it. + if git cat-file -e "${from}:util/_actions.py" 2> /dev/null; then + git show "${from}:util/_actions.py" > "${released}/_actions.py" + fi + wheels="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" pypi-wheels)" + builds="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" conda-builds)" + fi + fi + + echo "${PCAPKIT_TAG}: counting the wheels ${wheels} and the conda builds ${builds}" + echo "pypi-wheels=${wheels}" >> "$GITHUB_OUTPUT" + echo "conda-builds=${builds}" >> "$GITHUB_OUTPUT" +''').lstrip() + +#: "Check PyPI publish completeness", before :file:`util/release_published.py`. +FORMER_PYPI = textwrap.dedent(r''' + set -euo pipefail + + # One wheel per supported leg, plus the sdist. + read -r -a wheels <<< "$PCAPKIT_PYPI_WHEELS" + expected=$(( ${#wheels[@]} + 1 )) + # `--retry-connrefused` and `|| echo 000` keep a pypi.org outage from + # failing `version_check` under `set -e`, which every publisher + # requires; "incomplete" is the safe default. + status="$(curl -s --retry 3 --retry-connrefused \ + -o /tmp/pypi_release.json -w '%{http_code}' \ + "https://pypi.org/pypi/pypcapkit/${PCAPKIT_VERSION}/json" || echo 000)" + + count=0 + if [ "$status" = "200" ]; then + # One line per `printf` argument, so the block scalar's indentation + # never reaches the source `python3 -` reads: indented `python3 -c` + # source compiles only on Python 3.14 (3.9 through 3.13 raise + # `IndentationError: unexpected indent`, measured). Not leaning on + # the setup-python above: the conda job's check runs before its own. + count="$(printf '%s\n' \ + "import json, os" \ + "with open('/tmp/pypi_release.json') as f:" \ + " data = json.load(f)" \ + "supported = set(os.environ['PCAPKIT_PYPI_WHEELS'].split())" \ + "names = [u.get('filename', '') for u in data.get('urls', [])]" \ + "wheels = {n.split('-')[2] for n in names if n.endswith('.whl') and n.count('-') == 4}" \ + "print(len(wheels & supported) + any(n.endswith('.tar.gz') for n in names))" \ + | python3 -)" + fi + + complete=false + if [ "$count" -ge "$expected" ]; then + complete=true + fi + + echo "http $status, found $count of $expected expected files for ${PCAPKIT_VERSION} on PyPI" + echo "PCAPKIT_PYPI_COMPLETE=$complete" >> "$GITHUB_OUTPUT" +''').lstrip() + +#: "Check Anaconda publish completeness", likewise. +FORMER_ANACONDA = textwrap.dedent(r''' + set -euo pipefail + + # Each platform the ``conda`` legs publish, for each supported version. + export PCAPKIT_CONDA_SUBDIRS="linux-64 osx-64 osx-arm64" + read -r -a subdirs <<< "$PCAPKIT_CONDA_SUBDIRS" + read -r -a builds <<< "$PCAPKIT_CONDA_BUILDS" + expected=$(( ${#subdirs[@]} * ${#builds[@]} )) + # Same coupling risk, and the same fix, as the PyPI check above. + status="$(curl -s --retry 3 --retry-connrefused \ + -o /tmp/anaconda_release.json -w '%{http_code}' \ + "https://api.anaconda.org/release/jarryshaw/pypcapkit/${PCAPKIT_VERSION}" || echo 000)" + + count=0 + if [ "$status" = "200" ]; then + # The same `printf` form as the PyPI check above. + count="$(printf '%s\n' \ + "import json, os" \ + "with open('/tmp/anaconda_release.json') as f:" \ + " data = json.load(f)" \ + "platforms = set(os.environ['PCAPKIT_CONDA_SUBDIRS'].split())" \ + "py_tags = set(os.environ['PCAPKIT_CONDA_BUILDS'].split())" \ + "seen = {" \ + " (d.get('attrs', {}).get('subdir'), tag)" \ + " for d in data.get('distributions', [])" \ + " for tag in py_tags" \ + " if d.get('attrs', {}).get('build', '').startswith(tag + '_')" \ + " and d.get('attrs', {}).get('subdir') in platforms" \ + "}" \ + "print(len(seen))" \ + | python3 -)" + fi + + complete=false + if [ "$count" -ge "$expected" ]; then + complete=true + fi + + echo "http $status, found $count of $expected expected platform/Python combinations for ${PCAPKIT_VERSION} on Anaconda" + echo "PCAPKIT_CONDA_COMPLETE=$complete" >> "$GITHUB_OUTPUT" +''').lstrip() + +#: Each step, its id, the ``run:`` it now holds, and the former shell, the +#: ``/tmp`` file it wrote standing for where the cases write it. +STEPS = { + 'Read the released Python versions': ('released', 'python3 util/release_tiers.py', FORMER_TIERS), + 'Check PyPI publish completeness': ('check_pypi', 'python3 util/release_published.py pypi /tmp/pypi_release.json', + FORMER_PYPI), + 'Check Anaconda publish completeness': ( + 'check_conda', 'python3 util/release_published.py anaconda /tmp/anaconda_release.json', FORMER_ANACONDA), +} + +#: A value standing for a variable left unset. +UNSET = None +#: A case's overrides: a variable's value, :data:`UNSET`, or bytes for one not in UTF-8. +Overrides = 'dict[str, Optional[Union[str, bytes]]]' + +#: "Read the released Python versions"'s base: this run's tiers, untagged. +TIERS_BASE = {'PCAPKIT_TAG': TAG, 'PCAPKIT_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_WHEELS': 'py311 py312 pp311', + 'PCAPKIT_CONDA_BUILDS': 'py311 py312', 'GIT_HEAD': RUN, 'READER_WHEELS': 'py311 pp311', + 'READER_BUILDS': 'py311'} +#: The tier file, and the helper its reader imports (#2047), at a commit, as ``$GIT_FILES`` lists them. +TIERS_AT = '{}:.github/python-versions.toml' +HELPER_AT = '{}:util/_actions.py' +TAGGED = {'PCAPKIT_TAG_EXISTS': 'true'} +#: Its cases: ``(name, overrides, local tags, remote tags, blobs, git log's reply)``. +TIERS_CASES = ( + ('untagged', {}, '', '', '', b''), + ('the check found no tag', {'PCAPKIT_TAG_EXISTS': ''}, '', '', '', b''), + ('the check spelt it otherwise', {'PCAPKIT_TAG_EXISTS': 'True'}, f'{TAG} {OTHER}', '', '', b''), + ('a tag on this commit', TAGGED, f'{TAG} {RUN}', '', TIERS_AT.format(RUN), b''), + ('a tag on an earlier commit', TAGGED, f'{TAG} {OTHER}', '', TIERS_AT.format(OTHER), b''), + ('a reader with its helper', TAGGED, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER) + '\n' + HELPER_AT.format(OTHER), b''), + ('a helper on this commit is not copied', TAGGED, f'{TAG} {RUN}', '', + TIERS_AT.format(RUN) + '\n' + HELPER_AT.format(RUN), b''), + ('the helper did not show', {**TAGGED, 'SHOW_FAIL': '_actions.py'}, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER) + '\n' + HELPER_AT.format(OTHER), b''), + ('the helper beside the tier file it was added with', TAGGED, f'{TAG} {OTHER}', '', + HELPER_AT.format(ADDED), f'{ADDED}\n'.encode()), + ('a helper only at the tagged commit', TAGGED, f'{TAG} {OTHER}', '', HELPER_AT.format(OTHER), + f'{ADDED}\n'.encode()), + ('a tag pushed since the checkout', TAGGED, '', f'{TAG} {OTHER}', TIERS_AT.format(OTHER), b''), + ('a tag fetched onto this commit', TAGGED, '', f'{TAG} {RUN}', TIERS_AT.format(RUN), b''), + ('the fetch failed', {**TAGGED, 'FETCH_FAIL': '1'}, '', f'{TAG} {OTHER}', '', b''), + ('the fetch failed otherwise', {**TAGGED, 'FETCH_FAIL': '128'}, '', '', '', b''), + ('the fetch was killed', {**TAGGED, 'FETCH_FAIL': 'TERM'}, '', '', '', b''), + ('no such tag anywhere', TAGGED, '', '', '', b''), + ('the tag would not resolve', {**TAGGED, 'RESOLVE_FAIL': '1'}, '', '', '', b''), + ('older than the tier file', TAGGED, f'{TAG} {OTHER}', '', '', f'{ADDED}\n'.encode()), + ('older, the file added on this commit', {**TAGGED, 'GIT_HEAD': ADDED}, f'{TAG} {OTHER}', '', '', + f'{ADDED}\n'.encode()), + ('older, the file added twice', TAGGED, f'{TAG} {OTHER}', '', '', f'{OTHER}\n{ADDED}\n'.encode()), + ('older, no trailing newline', TAGGED, f'{TAG} {OTHER}', '', '', ADDED.encode()), + ('older, trailing blank lines', TAGGED, f'{TAG} {OTHER}', '', '', f'{ADDED}\n\n'.encode()), + ('no tier file in the history', TAGGED, f'{TAG} {OTHER}', '', '', b''), + ('the log failed', {**TAGGED, 'LOG_FAIL': '128'}, f'{TAG} {OTHER}', '', '', b''), + ('the log was killed', {**TAGGED, 'LOG_FAIL': 'TERM'}, f'{TAG} {OTHER}', '', '', b''), + ('HEAD did not read', {**TAGGED, 'HEAD_FAIL': '128'}, f'{TAG} {RUN}', '', TIERS_AT.format(RUN), b''), + ('mktemp failed', {**TAGGED, 'MKTEMP_FAIL': '1'}, f'{TAG} {OTHER}', '', TIERS_AT.format(OTHER), b''), + ('the tier file did not show', {**TAGGED, 'SHOW_FAIL': 'python-versions.toml'}, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER), b''), + ('the reader did not show', {**TAGGED, 'SHOW_FAIL': 'python_versions.py'}, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER), b''), + ('the reader failed', {**TAGGED, 'READER_FAIL': '1'}, f'{TAG} {OTHER}', '', TIERS_AT.format(OTHER), b''), + ('the reader read nothing', {**TAGGED, 'READER_WHEELS': '', 'READER_BUILDS': ''}, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER), b''), + ('the reader printed two lines', {**TAGGED, 'READER_WHEELS': 'py311\npp311'}, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER), b''), + ('the scratch directory has no name', {**TAGGED, 'SCRATCH': ''}, f'{TAG} {OTHER}', '', + TIERS_AT.format(OTHER), b''), + ('awkward values pass through', {'PCAPKIT_TAG': "v1 'a' $b%:", 'PCAPKIT_PYPI_WHEELS': ' py311\tpp311 ', + 'PCAPKIT_CONDA_BUILDS': '\npy311'}, '', '', '', b''), + ('values not in utf-8', {'PCAPKIT_PYPI_WHEELS': b'py3\xff', **TAGGED, 'PCAPKIT_TAG': 'v1.5.0\u00e9'}, + 'v1.5.0\u00e9 ' + RUN, '', TIERS_AT.format(RUN), b''), + ('no git at all', {**TAGGED, 'PATH': 'nogit'}, '', '', '', b''), + ('no python3 at all', {**TAGGED, 'PATH': 'nopython3'}, f'{TAG} {OTHER}', '', TIERS_AT.format(OTHER), b''), + ('no wheels variable', {'PCAPKIT_PYPI_WHEELS': UNSET}, '', '', '', b''), + ('no builds variable', {'PCAPKIT_CONDA_BUILDS': UNSET}, '', '', '', b''), + ('no tag-exists variable', {'PCAPKIT_TAG_EXISTS': UNSET}, '', '', '', b''), + ('no tag variable, untagged', {'PCAPKIT_TAG': UNSET}, '', '', '', b''), + ('no tag variable, tagged', {**TAGGED, 'PCAPKIT_TAG': UNSET}, '', '', '', b''), + ('no output file', {'GITHUB_OUTPUT': UNSET}, '', '', '', b''), + ('an output file in no directory', {'GITHUB_OUTPUT': '/nonexistent/output'}, '', '', '', b''), +) + +#: "Check PyPI publish completeness"'s base, and the files of a complete release. +PYPI_BASE = {'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_PYPI_WHEELS': 'py311 py312 pp311'} +WHEELS = ['pypcapkit-1.5.0-py311-none-any.whl', 'pypcapkit-1.5.0-py312-none-any.whl', + 'pypcapkit-1.5.0-pp311-none-any.whl'] +SDIST = 'pypcapkit-1.5.0.tar.gz' + + +def urls(*names: 'str') -> 'str': + """A PyPI reply listing ``names``.""" + return '{"urls": [%s]}' % ', '.join('{"filename": "%s"}' % name for name in names) + + +#: Its cases: ``(name, overrides, the reply's body)``. +PYPI_CASES = ( + ('complete', {}, urls(*WHEELS, SDIST)), + ('complete with a ktlo wheel', {}, urls(*WHEELS, 'pypcapkit-1.5.0-py310-none-any.whl', SDIST)), + ('a wheel missing', {}, urls(*WHEELS[1:], SDIST)), + ('the sdist missing', {}, urls(*WHEELS)), + ('a wheel twice', {}, urls(WHEELS[0], WHEELS[0], WHEELS[1], SDIST)), + ('a name of the wrong shape', {}, urls('pypcapkit-1.5.0-py311-x-none-any.whl', *WHEELS[1:], SDIST)), + ('no files', {}, urls()), + ('releases alone', {}, '{"releases": {"1.5.0": [{"filename": "%s"}]}}' % SDIST), + ('no wheels expected', {'PCAPKIT_PYPI_WHEELS': ''}, urls(SDIST)), + ('the wheels on two lines', {'PCAPKIT_PYPI_WHEELS': 'py311 py312\npp311'}, urls(*WHEELS[:2], SDIST)), + ('the wheels split by tabs', {'PCAPKIT_PYPI_WHEELS': '\tpy311\t py312 pp311 '}, urls(*WHEELS[1:], SDIST)), + ('the wheels after a blank line', {'PCAPKIT_PYPI_WHEELS': '\npy311'}, urls(SDIST)), + ('not found', {'CURL_CODE': '404'}, '{"message": "Not Found"}'), + ('a server error, said', {'CURL_CODE': '503', 'CURL_STDERR': 'curl: (22) 503'}, ''), + ('no connection', {'CURL_CODE': '000', 'CURL_FAIL': '7'}, ''), + ('a 200 that failed to write', {'CURL_FAIL': '23'}, urls(*WHEELS, SDIST)), + ('curl was killed', {'CURL_CODE': '', 'CURL_FAIL': 'TERM'}, ''), + ('no curl at all', {'PATH': 'nocurl'}, urls(*WHEELS, SDIST)), + ('a body that is not json', {}, ''), + ('a body that is a list', {}, '[]'), + ('no python3 at all', {'PATH': 'nopython3'}, urls(*WHEELS, SDIST)), + ('a count that is not a number', {'COUNT_REPLY': 'four'}, urls(SDIST)), + ('an empty count', {'COUNT_REPLY': ''}, urls(SDIST)), + ('a count with blanks', {'COUNT_REPLY': '\t 4'}, urls(SDIST)), + ('a signed count', {'COUNT_REPLY': '+4'}, urls(SDIST)), + ('a negative count', {'COUNT_REPLY': '-4'}, urls(SDIST)), + ('a count past 64 bits', {'COUNT_REPLY': '9223372036854775808'}, urls(SDIST)), + ('a count at 64 bits', {'COUNT_REPLY': '9223372036854775807'}, urls(SDIST)), + ('a count with trailing blanks', {'COUNT_REPLY': '4 \t'}, urls(SDIST)), + ('a count after a vertical tab', {'COUNT_REPLY': '\v4'}, urls(SDIST)), + ('an awkward version', {'PCAPKIT_VERSION': "1.5 'a' $b"}, urls(*WHEELS, SDIST)), + ('a version not in utf-8', {'PCAPKIT_VERSION': b'1.5\xff'}, urls(SDIST)), + ('no version variable', {'PCAPKIT_VERSION': UNSET}, urls(*WHEELS, SDIST)), + ('no wheels variable', {'PCAPKIT_PYPI_WHEELS': UNSET}, urls(*WHEELS, SDIST)), + ('no output file', {'GITHUB_OUTPUT': UNSET}, urls(*WHEELS, SDIST)), + ('an output file in no directory', {'GITHUB_OUTPUT': '/nonexistent/output'}, urls(*WHEELS, SDIST)), +) + +#: "Check Anaconda publish completeness"'s base, and its platforms. +ANACONDA_BASE = {'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_CONDA_BUILDS': 'py311 py312'} +PLATFORMS = ('linux-64', 'osx-64', 'osx-arm64') + + +def distributions(*pairs: 'tuple[str, str]') -> 'str': + """An Anaconda reply holding each ``(subdir, build)``.""" + return '{"distributions": [%s]}' % ', '.join('{"attrs": {"subdir": "%s", "build": "%s"}}' % pair + for pair in pairs) + + +EVERY = tuple((platform, f'{build}_0') for platform in PLATFORMS for build in ('py311', 'py312')) +#: Its cases: ``(name, overrides, the reply's body)``. +ANACONDA_CASES = ( + ('complete', {}, distributions(*EVERY)), + ('complete twice over', {}, distributions(*EVERY, *EVERY)), + ('a platform missing', {}, distributions(*(pair for pair in EVERY if pair[0] != 'osx-64'))), + ('osx-arm64 missing for one build', {}, distributions(*(pair for pair in EVERY + if pair != ('osx-arm64', 'py312_0')))), + ('another platform is not counted', {}, distributions(*EVERY[1:], ('win-64', 'py311_0'))), + ('a build without its underscore', {}, distributions(*EVERY[1:], ('linux-64', 'py311'))), + ('a build of another tag', {}, distributions(*EVERY[1:], ('linux-64', 'py3110_0'))), + ('no attrs', {}, '{"distributions": [{}]}'), + ('no distributions', {}, '{}'), + ('no builds expected, found', {'PCAPKIT_CONDA_BUILDS': ''}, distributions()), + ('no builds expected, not found', {'PCAPKIT_CONDA_BUILDS': '', 'CURL_CODE': '404'}, ''), + ('no builds expected, no connection', {'PCAPKIT_CONDA_BUILDS': ' ', 'CURL_CODE': '000', 'CURL_FAIL': '6'}, + ''), + ('the builds on two lines', {'PCAPKIT_CONDA_BUILDS': 'py311\npy312'}, distributions(*EVERY)), + ('not found', {'CURL_CODE': '404'}, ''), + ('no connection', {'CURL_CODE': '000', 'CURL_FAIL': '7'}, ''), + ('curl was killed', {'CURL_CODE': '', 'CURL_FAIL': 'TERM'}, ''), + ('no curl at all', {'PATH': 'nocurl'}, distributions(*EVERY)), + ('a body that is not json', {}, 'not json'), + ('a distribution that is a string', {}, '{"distributions": ["x"]}'), + ('no python3 at all', {'PATH': 'nopython3'}, distributions(*EVERY)), + ('a count that is not a number', {'COUNT_REPLY': '6.0'}, distributions()), + ('a count past what is expected', {'COUNT_REPLY': '7'}, distributions()), + ('a version not in utf-8', {'PCAPKIT_VERSION': b'1.5\xff'}, distributions()), + ('no version variable', {'PCAPKIT_VERSION': UNSET}, distributions(*EVERY)), + ('no builds variable', {'PCAPKIT_CONDA_BUILDS': UNSET}, distributions(*EVERY)), + ('no output file', {'GITHUB_OUTPUT': UNSET}, distributions(*EVERY)), + ('an output file in no directory', {'GITHUB_OUTPUT': '/nonexistent/output'}, distributions(*EVERY)), +) + + +class _Stand(unittest.TestCase): + """A scratch directory with the stand-ins first on ``PATH``, and bin directories each lacking one.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1967-version-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + self.bins = {} # type: dict[str, pathlib.Path] + for label, left_out in (('bin', ''), ('nogit', 'git'), ('nopython3', 'python3'), ('nocurl', 'curl')): + directory = self.tmp / label + directory.mkdir() + for name, text in FAKES.items(): + if name != left_out: + (directory / name).write_text(text, encoding='utf-8') + (directory / name).chmod(0o755) + self.bins[label] = directory + self.tools = self.tmp / 'tools' + self.tools.mkdir() + for name in TOOLS: + found = shutil.which(name) + assert found is not None, name + (self.tools / name).symlink_to(found) + self.work = self.tmp / 'work' + self.work.mkdir() + self.output = self.tmp / 'output' + self.calls = self.tmp / 'calls' + self.reply = self.tmp / 'reply.json' + self.scratch = self.tmp / 'scratch' + self.shell = self.tmp / 'step.sh' + self.tags = self.tmp / 'tags' + self.remote = self.tmp / 'remote-tags' + self.files = self.tmp / 'files' + self.log = self.tmp / 'log' + + def path(self, label: 'str') -> 'str': + """``PATH`` for the bin directory ``label``: it, then :data:`TOOLS`.""" + return f'{self.bins[label]}{os.pathsep}{self.tools}' + + def env(self, base: 'dict[str, str]', overrides: 'Overrides') -> 'dict[str, bytes]': + """The step's environment: the stand-ins' settings, ``base``, then ``overrides``. + + ``PATH`` holds the stand-ins and :data:`TOOLS` alone; a case's ``PATH`` + names one of the bin directories, each lacking one stand-in, or a path + to use instead. + + """ + env = {'PATH': self.path('bin'), 'HOME': str(self.tmp), 'LC_ALL': 'C', 'GITHUB_OUTPUT': str(self.output), + 'CALL_LOG': str(self.calls), 'REAL_PYTHON': sys.executable, 'SCRATCH': str(self.scratch), + 'GIT_TAGS': str(self.tags), 'GIT_REMOTE_TAGS': str(self.remote), 'GIT_FILES': str(self.files), + 'GIT_LOG_REPLY': str(self.log), 'CURL_BODY': str(self.reply), + **base} # type: dict[str, Union[str, bytes]] + for name, value in overrides.items(): + if value is UNSET: + env.pop(name, None) + elif name == 'PATH' and value in self.bins: + env[name] = self.path(str(value)) + else: + env[name] = value + return {name: os.fsencode(value) for name, value in env.items()} + + def state(self, command: 'list[str]', env: 'dict[str, bytes]', reply: 'bytes', + git: 'tuple[str, str, str, bytes]') -> 'tuple[object, ...]': + """What ``command`` left: status, both streams, ``$GITHUB_OUTPUT``, the calls and the scratch files. + + The output file holds a line already, as a job's earlier steps leave. + + """ + for path in (self.output, self.calls, self.reply): + if path.exists(): + path.unlink() + shutil.rmtree(self.scratch, ignore_errors=True) + self.output.write_text('earlier=1\n', encoding='utf-8') + self.reply.write_bytes(reply) + for path, text in zip((self.tags, self.remote, self.files), git[:3]): + path.write_text(text + '\n' if text else '', encoding='utf-8') + self.log.write_bytes(git[3]) + result = subprocess.run(command, cwd=self.work, env=env, capture_output=True, # nosec: B603 + check=False, timeout=120) + scratch = sorted((path.name, path.read_bytes()) for path in self.scratch.iterdir()) \ + if self.scratch.is_dir() else None + return (result.returncode, result.stdout.decode('utf-8', 'surrogateescape'), + result.stderr.decode('utf-8', 'surrogateescape'), + *(path.read_text(encoding='utf-8', errors='surrogateescape') if path.exists() else None + for path in (self.output, self.calls)), scratch) + + def former(self, shell: 'str', env: 'dict[str, bytes]', reply: 'bytes' = b'', + git: 'tuple[str, str, str, bytes]' = ('', '', '', b'')) -> 'tuple[object, ...]': + """``shell`` as a step with no ``shell:`` runs it, ``bash -e``, its own errors' ``: line N:`` dropped.""" + self.shell.write_text(shell.replace('/tmp/pypi_release.json', str(self.tmp / 'release.json')) + .replace('/tmp/anaconda_release.json', str(self.tmp / 'release.json')), + encoding='utf-8') + found = self.state([BASH, '-e', str(self.shell)], env, reply, git) + stderr = re.sub(rf'(?m)^{re.escape(str(self.shell))}: line \d+: ', '', str(found[2])) + if not BASH_52: + # Bash 5.3 shortened ``[``'s message; 5.2, the runner's, is the one kept. + stderr = re.sub(r'(?m)^(\[: .*): integer expected$', r'\1: integer expression expected', stderr) + return (*found[:2], stderr, *found[3:]) + + def script(self, run: 'str', env: 'dict[str, bytes]', reply: 'bytes' = b'', + git: 'tuple[str, str, str, bytes]' = ('', '', '', b''), + util: 'pathlib.Path' = UTIL) -> 'tuple[object, ...]': + """``run``, ``python3 util/