From 8777e5e5aae03e1cbfec97be925369e24ceb1d2d Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 23 Oct 2026 18:23:09 -0400 Subject: [PATCH] fix(release): read the environment without os.environb where Windows lacks it (#2120) util/release_published.py and util/release_tiers.py read each variable from os.environb, which exists only where os.supports_bytes_environ holds, so release_conda.py build died on the win-64 conda leg at its first variable. Both now read through a new util/_actions.py getenvb, which falls back to os.fsencode(os.environ[name]); an unset variable is still Unbound. Tested on an emulated Windows os module in tests/project/test_conda_windows_leg_2060.py. --- docs/source/contributing/workflows.rst | 5 +- tests/project/test_conda_windows_leg_2060.py | 73 +++++++++++++++++++- util/_actions.py | 30 ++++++-- util/release_published.py | 4 +- util/release_tiers.py | 4 +- 5 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index b45da30439..b10477ffd9 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1320,8 +1320,9 @@ to a script. Each script's docstring says what it does; each has tests under - No. * - :file:`util/_actions.py` - No step: it is imported by the scripts that share its annotations, step - outputs, job summary lines and logged runs (`#2047 - `__). + outputs, job summary lines and logged runs (:issue:`2047`), and its + environment read as bytes where Windows has no ``os.environb`` + (:issue:`2120`). - No: imported only. * - :file:`util/codeql_report.py` - ``Write the report`` in ``codeql-analysis.yml``'s ``discussion`` (see `CI diff --git a/tests/project/test_conda_windows_leg_2060.py b/tests/project/test_conda_windows_leg_2060.py index 03014580c9..1bee8a4027 100644 --- a/tests/project/test_conda_windows_leg_2060.py +++ b/tests/project/test_conda_windows_leg_2060.py @@ -20,6 +20,9 @@ :file:`util/conda_vendor.py` reads a ``.pyd``'s PE machine, and ``--smoke`` takes a Windows package's ``Lib/site-packages``, or from Python 3.15 its ``lib/python/site-packages`` (conda-forge's CFEP-27), whose path names no Python. +* :class:`TestTheEnvironmentWithoutEnvironb`: the scripts read their variables + where :data:`os.environb` is missing, as on Windows (#2120), where + ``release_conda.py build`` died reading ``PCAPKIT_CONDA_OS``. Nothing here runs on Windows; what a real Conda Update run shows is the rest. @@ -28,6 +31,7 @@ from __future__ import annotations import bz2 +import contextlib import copy import io import json @@ -45,10 +49,10 @@ from tests import _workflows from tests.project.test_conda_native_platforms_1822 import DISTRIBUTION, SHIM, elf, load, pe from tests.project.test_release_gates import PUBLISHING_MARKERS, STATUS, evaluate_condition -from util import release_conda +from util import _actions, release_conda, release_tiers if TYPE_CHECKING: - from typing import Any + from typing import Any, Iterator #: Test tier (#1958): reads the workflow and recipe files, and drives the script offline. __tier__ = 'LATEST' @@ -370,5 +374,70 @@ def test_a_windows_package_for_another_python_fails(self) -> None: self.assertIn('is built for python2.7, and this is ', log) +@contextlib.contextmanager +def without_environb(**environ: 'str') -> 'Iterator[None]': + """``os`` as Windows has it, no :data:`os.environb`, with ``environ`` set and nothing else (#2120).""" + environb = os.environb + with mock.patch.dict(os.environ, environ, clear=True), \ + mock.patch.object(os, 'supports_bytes_environ', False): + del os.environb + try: + yield + finally: + os.environb = environb + + +class TestTheEnvironmentWithoutEnvironb(unittest.TestCase): + """#2120: each ``var()``, and the helper they share, on an ``os`` with no ``environb``.""" + + VALUE = 'caf\xe9 windows-latest' + + def test_the_emulation_has_no_environb(self) -> None: + with without_environb(): + self.assertFalse(hasattr(os, 'environb')) + self.assertTrue(hasattr(os, 'environb')) + + def test_the_helper_reads_os_environ(self) -> None: + with without_environb(PCAPKIT_CONDA_OS=self.VALUE): + self.assertEqual(_actions.getenvb('PCAPKIT_CONDA_OS'), os.fsencode(self.VALUE)) + with self.assertRaises(KeyError): + _actions.getenvb('PCAPKIT_UNSET') + + def test_the_helper_reads_os_environb_where_it_exists(self) -> None: + with mock.patch.dict(os.environb, {b'PCAPKIT_CONDA_OS': b'\xff'}): + self.assertEqual(_actions.getenvb('PCAPKIT_CONDA_OS'), b'\xff') + + def test_each_var_reads_the_value_and_refuses_an_unset_one(self) -> None: + for module in (release_conda.release_published, release_tiers): + with self.subTest(module=module.__name__), without_environb(PCAPKIT_CONDA_OS=self.VALUE): + self.assertEqual(module.var('PCAPKIT_CONDA_OS'), os.fsencode(self.VALUE)) + with self.assertRaises(module.Unbound) as caught: + module.var('PCAPKIT_UNSET') + self.assertEqual(str(caught.exception), 'PCAPKIT_UNSET') + + def test_build_gets_past_its_variables(self) -> None: + """``build`` reads both variables and runs ``conda build``, where it raised AttributeError.""" + with tempfile.TemporaryDirectory() as temp: + output = os.path.join(temp, 'output') + out_dir = os.path.join(temp, 'compilation-x') + with without_environb(PCAPKIT_CONDA_OS=WINDOWS, PCAPKIT_PYTHON_VERSION='3.15', GITHUB_OUTPUT=output), \ + mock.patch.object(release_conda.tempfile, 'mkdtemp', return_value=out_dir), \ + mock.patch.object(release_conda, 'call', return_value=7) as call: + status = release_conda.main(['build']) + with open(output, encoding='utf-8') as file: + written = file.read() + self.assertEqual(status, 7) + call.assert_called_once_with('conda', 'build', '.', '--package-format', '.tar.bz2', '--no-anaconda-upload', + '--output-folder', out_dir, '--python', '3.15') + self.assertEqual(written, f'out_dir={out_dir}\n') + + def test_build_still_names_an_unset_variable(self) -> None: + err = io.StringIO() + with without_environb(), mock.patch.object(release_conda, 'call') as call, mock.patch('sys.stderr', err): + status = release_conda.main(['build']) + self.assertEqual((status, err.getvalue()), (1, 'PCAPKIT_CONDA_OS: unbound variable\n')) + call.assert_not_called() + + if __name__ == '__main__': unittest.main() diff --git a/util/_actions.py b/util/_actions.py index fd72ac7e1e..9b99674a0d 100644 --- a/util/_actions.py +++ b/util/_actions.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- """The GitHub Actions plumbing the :file:`util/` scripts share (#2047). -Workflow commands, ``$GITHUB_OUTPUT`` and ``$GITHUB_STEP_SUMMARY`` writes, and -the run that copies a command's output to the log and to a file, written once -here instead of in each script. A script that the steps run as ``python -util/