diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index f3fda3ee..65e61644 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 @@ -115,14 +115,14 @@ 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', 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 +171,10 @@ def __call__(self, *, # type:ignore[override] path: list[str] if python: path = self.__path4python(python, import_site, honor_py_env_vars) + 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() if path[0] in ('', getcwd()): diff --git a/docs/usage.rst b/docs/usage.rst index 24ce9746..a4c3c6bc 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -73,12 +73,13 @@ 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. 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..edbf76d6 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,61 @@ 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_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