import modulus.build_context import modulus.process import pathlib import runpy import types import os import subprocess import sys import tempfile import unittest import unittest.mock import modulus.config import modulus.python class PythonTests(unittest.TestCase): def context(self, root, groups): return modulus.build_context.build_context(root, modulus.config.normalize({'package': {'name': 'example'}, 'tooling': {'python': {'groups': groups}}})) def test_optional_packages_are_not_needed_by_default(self): with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(subprocess, 'run') as run: context = self.context(pathlib.Path(temporary), []) self.assertEqual(modulus.python.interpreter(context, install=True), pathlib.Path(sys.executable)) run.assert_not_called() def test_conan_reuses_the_project_environment_and_preserves_offline_mode(self): with tempfile.TemporaryDirectory() as temporary: context = self.context(pathlib.Path(temporary), ['assets']) environment = modulus.process.subprocess_environment(context, ['conan', 'install', '.', '--no-remote']) self.assertEqual(environment['MODULUS_PYTHON_COMMON_DIR'], str(context.common_dir)) self.assertEqual(environment['MODULUS_PYTHON_OFFLINE'], '1') environment = modulus.process.subprocess_environment(context, ['conan', 'install', '.']) self.assertEqual(environment['MODULUS_PYTHON_OFFLINE'], '0') def test_conan_helper_uses_build_context_module_and_preserves_contract(self): helper = runpy.run_path(str(pathlib.Path(__file__).resolve().parents[1] / 'conanfile.py'))['python_cmake_variables'] with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) (root / 'modulus.json').write_text('{"package":{"name":"example"},"tooling":{"python":{"groups":["assets"]}}}') package = root / 'package' (package / 'modulus').mkdir(parents=True) (package / 'modulus/cli.py').touch() recipe = types.SimpleNamespace(source_folder=str(root), build_folder=str(root / 'build'), dependencies={'modulus': types.SimpleNamespace(package_folder=str(package))}) executable = root / 'python/bin/python3' for offline in ('0', '1'): with unittest.mock.patch.dict(os.environ, {'MODULUS_PYTHON_COMMON_DIR': str(root / 'common'), 'MODULUS_PYTHON_OFFLINE': offline}), unittest.mock.patch.object(modulus.python, 'interpreter', return_value=executable) as interpreter: variables = helper(recipe) context = interpreter.call_args_list[0].args[0] self.assertIsInstance(context, modulus.build_context.build_context) self.assertEqual(context.common_dir, root / 'common') self.assertEqual(context.tools_dir, package / 'tools') self.assertEqual(interpreter.call_args_list[0].kwargs, {'install': True, 'offline': offline == '1'}) self.assertEqual(variables['Python3_EXECUTABLE'], str(executable)) self.assertEqual(variables['MODULUS_PYTHON_RUNNER'], str(package / 'tools/python_runner.py')) self.assertEqual(variables['MODULUS_PYTHON_ROOT'], str(package)) self.assertTrue(variables['MODULUS_PYTHON_REQUIREMENTS'].endswith('assets_requirements.txt')) with unittest.mock.patch.dict(os.environ, {}, clear=True), unittest.mock.patch.object(modulus.python, 'interpreter', return_value=executable) as interpreter: helper(recipe) self.assertEqual(interpreter.call_args_list[0].args[0].common_dir, root / 'build/build_python') def test_missing_dependencies_require_configure_and_never_download_during_build(self): with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(subprocess, 'run') as run: context = self.context(pathlib.Path(temporary), ['assets']) for options in ({}, {'install': True, 'offline': True}): with self.assertRaisesRegex(RuntimeError, 'configure'): modulus.python.interpreter(context, **options) run.assert_not_called() def test_unknown_or_duplicate_groups_are_rejected(self): for groups in (['unknown'], ['assets', 'assets'], 'assets'): with self.assertRaises(modulus.config.config_error): self.context(pathlib.Path('/tmp'), groups) def test_matching_environment_is_reused_and_changed_pins_require_configure(self): with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(subprocess, 'run') as run: context = self.context(pathlib.Path(temporary), ['assets']) executable = context.common_dir / 'python/bin/python3' executable.parent.mkdir(parents=True) executable.touch() marker = executable.parents[1] / 'requirements.sha256' marker.write_text(modulus.python.identity(modulus.python.requirement_files(context))) self.assertEqual(modulus.python.interpreter(context, offline=True), executable) with unittest.mock.patch.object(modulus.python, 'identity', return_value='changed'): with self.assertRaisesRegex(RuntimeError, 'configure'): modulus.python.interpreter(context) run.assert_not_called() def test_runner_imports_exported_package_even_with_bootstrap_in_working_directory(self): source = pathlib.Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) (root / 'modulus.py').write_text("raise AssertionError('bootstrap shadowed package')") result = subprocess.run([sys.executable, str(source / 'tools/python_runner.py'), str(source), '-c', 'import modulus.filesystem; print("loaded")'], cwd=root, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), 'loaded')