From 9ec7308ad8585456d00e39372dd8581e976f65b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 29 Oct 2026 00:40:14 -0600 Subject: [PATCH 1/2] feat(environment): with `-E` and no ``, probe the current interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a target ``, `-E` had no effect: the SBOM reused the already-imported `sys.path`, which honored parent `PYTHON*` env vars at startup. Now the current executable is probed with `python -E` instead. Idea from #1123 by Elton Chang. Follow-up to #1106, see #1045. Co-authored-by: Elton Chang <80650461+Notle-eltoN@users.noreply.github.com> Signed-off-by: Julio César Suástegui --- cyclonedx_py/_internal/environment.py | 8 ++++--- docs/usage.rst | 8 +++---- tests/integration/test_cli_environment.py | 29 ++++++++++++++++++++++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index f3fda3ee..7c2a38d8 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -23,7 +23,7 @@ from os import getcwd, name as os_name from os.path import exists, isdir, join from subprocess import run # nosec -from sys import path as sys_path +from sys import executable, path as sys_path from textwrap import dedent from typing import TYPE_CHECKING, Any, Optional @@ -121,8 +121,7 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': dest='honor_py_env_vars', help='Run the target interpreter with `-E` (ignore PYTHON* environment\n' 'variables such as PYTHONPATH) when detecting its path.\n' - 'Only applies when a target `` is given; has no effect when\n' - 'analyzing the current environment.\n' + 'Without a target ``, the current interpreter is probed this way.\n' 'Interpreters that require PYTHONHOME to start may fail with this flag.') p.add_argument('--gather-license-texts', action='store_true', @@ -171,6 +170,9 @@ def __call__(self, *, # type:ignore[override] path: list[str] if python: path = self.__path4python(python, import_site, honor_py_env_vars) + elif not honor_py_env_vars: + # the already-running interpreter honored PYTHON* vars at startup; probe a fresh one + path = self.__path4python(executable, import_site, honor_py_env_vars) else: path = sys_path.copy() if path[0] in ('', getcwd()): diff --git a/docs/usage.rst b/docs/usage.rst index 24ce9746..e52be81f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -75,10 +75,10 @@ The full documentation can be issued by running with ``environment --help``: Prevents evaluation of `*.pth` files, but may lead to incomplete component detection. -E Run the target interpreter with `-E` (ignore PYTHON* environment variables such as PYTHONPATH) when - detecting its path. Only applies when a target - `` is given; has no effect when analyzing the - current environment. Interpreters that require - PYTHONHOME to start may fail with this flag. + detecting its path. Without a target ``, the + current interpreter is probed this way. Interpreters + that require PYTHONHOME to start may fail with this + flag. --gather-license-texts Enable license text gathering. --pyproject Path to the root component's `pyproject.toml` file. diff --git a/tests/integration/test_cli_environment.py b/tests/integration/test_cli_environment.py index c70c242a..990c36b3 100644 --- a/tests/integration/test_cli_environment.py +++ b/tests/integration/test_cli_environment.py @@ -22,7 +22,7 @@ from os import environ, mkdir, name as os_name from os.path import basename, dirname, join from subprocess import run # nosec:B404 -from sys import executable, stderr +from sys import executable, path as sys_path, stderr from tempfile import TemporaryDirectory from typing import Any from unittest import TestCase, skipIf @@ -194,6 +194,33 @@ def test_no_python_env_ignores_parent_pythonpath(self) -> None: self.assertIn(foreign_name, names1) self.assertNotIn(foreign_name, names2) + def test_no_python_given_env_ignores_parent_pythonpath(self) -> None: + """Follow-up for #1045: with -E and no target, the current interpreter is + probed via `python -E` instead of reusing the already-imported sys.path.""" + foreign_name = 'leakypkg' + with TemporaryDirectory() as foreign_root: + dist_info = join(foreign_root, f'{foreign_name}-9.9.9.dist-info') + mkdir(dist_info) + with open(join(dist_info, 'METADATA'), 'w', encoding='utf8') as fh: + fh.write('Metadata-Version: 2.1\n' + f'Name: {foreign_name}\n' + 'Version: 9.9.9\n') + # simulate a parent PYTHONPATH that the running interpreter already honored + leaky_path = [foreign_root, *sys_path] + with patch('cyclonedx_py._internal.environment.sys_path', leaky_path), \ + patch.dict(environ, {'PYTHONPATH': foreign_root}): + res, sbom1, err = run_cli( + 'environment', '--sv', '1.6', '--of', 'JSON', '--no-validate', '-o=-') + self.assertEqual(0, res, err) + res, sbom2, err = run_cli( + 'environment', '--sv', '1.6', '--of', 'JSON', '--no-validate', '-o=-', + '-E') # the important part + self.assertEqual(0, res, err) + names1 = {c['name'] for c in json_loads(sbom1).get('components') or ()} + names2 = {c['name'] for c in json_loads(sbom2).get('components') or ()} + self.assertIn(foreign_name, names1) + self.assertNotIn(foreign_name, names2) + def test_with_current_python(self) -> None: sv = SchemaVersion.V1_6 of = random.choice((OutputFormat.XML, OutputFormat.JSON)) # nosec B311 From b41bbb7fc65be9dc026e0dcf7ebcbe1b3b3d8f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 29 Oct 2026 07:34:23 -0600 Subject: [PATCH 2/2] feat(environment): with `-S` and no ``, probe the current interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the previous change to `-S` as requested in review: when `-S` and/or `-E` is given without a target ``, the current executable is probed with the corresponding flag(s) instead of reusing the already-imported `sys.path`. Without either flag, behavior is unchanged. Co-authored-by: Elton Chang <80650461+Notle-eltoN@users.noreply.github.com> Signed-off-by: Julio César Suástegui --- cyclonedx_py/_internal/environment.py | 8 ++++--- docs/usage.rst | 1 + tests/integration/test_cli_environment.py | 28 +++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index 7c2a38d8..65e61644 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -115,7 +115,8 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': action='store_false', dest='import_site', help='Do not implicitly import site during Python path detection.\n' - 'Prevents evaluation of `*.pth` files, but may lead to incomplete component detection.') + 'Prevents evaluation of `*.pth` files, but may lead to incomplete component detection.\n' + 'Without a target ``, the current interpreter is probed this way.') p.add_argument('-E', # mimic `python -E` action='store_false', dest='honor_py_env_vars', @@ -170,8 +171,9 @@ def __call__(self, *, # type:ignore[override] path: list[str] if python: path = self.__path4python(python, import_site, honor_py_env_vars) - elif not honor_py_env_vars: - # the already-running interpreter honored PYTHON* vars at startup; probe a fresh one + elif not import_site or not honor_py_env_vars: + # the already-running interpreter imported site and honored PYTHON* vars at startup; + # probe a fresh one with the requested flags path = self.__path4python(executable, import_site, honor_py_env_vars) else: path = sys_path.copy() diff --git a/docs/usage.rst b/docs/usage.rst index e52be81f..a4c3c6bc 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -73,6 +73,7 @@ The full documentation can be issued by running with ``environment --help``: -h, --help show this help message and exit -S Do not implicitly import site during Python path detection. Prevents evaluation of `*.pth` files, but may lead to incomplete component detection. + Without a target ``, the current interpreter is probed this way. -E Run the target interpreter with `-E` (ignore PYTHON* environment variables such as PYTHONPATH) when detecting its path. Without a target ``, the diff --git a/tests/integration/test_cli_environment.py b/tests/integration/test_cli_environment.py index 990c36b3..edbf76d6 100644 --- a/tests/integration/test_cli_environment.py +++ b/tests/integration/test_cli_environment.py @@ -221,6 +221,34 @@ def test_no_python_given_env_ignores_parent_pythonpath(self) -> None: self.assertIn(foreign_name, names1) self.assertNotIn(foreign_name, names2) + def test_no_python_given_site_flags_probe_current_interpreter(self) -> None: + """Follow-up for #1045: with -S and/or -E and no target, the current interpreter + is probed with the corresponding flags instead of reusing the already-imported sys.path.""" + sentinel = ['/sentinel/not/probed'] + for flags, expected in ( + (('-S',), ['-S']), + (('-E',), ['-E']), + (('-S', '-E'), ['-E', '-S']), + ): + with self.subTest(flags=flags), \ + patch('cyclonedx_py._internal.environment.sys_path', sentinel), \ + patch('cyclonedx_py._internal.environment.run', wraps=run) as run_spy: + res, sbom, err = run_cli( + 'environment', '--sv', '1.6', '--of', 'JSON', '--no-validate', '-o=-', + *flags) + self.assertEqual(0, res, err) + self.assertEqual(1, run_spy.call_count) + cmd = run_spy.call_args.args[0] + self.assertEqual(executable, cmd[0]) + self.assertEqual(expected, cmd[1:-2]) + + def test_no_python_given_no_flags_reuses_sys_path(self) -> None: + with patch('cyclonedx_py._internal.environment.run') as run_mock: + res, sbom, err = run_cli( + 'environment', '--sv', '1.6', '--of', 'JSON', '--no-validate', '-o=-') + self.assertEqual(0, res, err) + run_mock.assert_not_called() + def test_with_current_python(self) -> None: sv = SchemaVersion.V1_6 of = random.choice((OutputFormat.XML, OutputFormat.JSON)) # nosec B311