From fb93acca1aa338e7a377accd794c9bf4adc18546 Mon Sep 17 00:00:00 2001 From: khodowany Date: Thu, 8 Oct 2026 01:31:14 -0500 Subject: [PATCH] Cleanup --- README.md | 2 + modulus/build.py | 257 ++++++++++ modulus/build_context.py | 80 +++ modulus/clangd.py | 47 +- modulus/cli.py | 1025 +------------------------------------- modulus/conan.py | 72 +++ modulus/container.py | 167 +++++++ modulus/formatting.py | 7 +- modulus/lsp.py | 103 ++++ modulus/packaging.py | 249 +++++++++ modulus/process.py | 64 +++ modulus/publishing.py | 30 ++ modulus/run.py | 57 +++ tests/test_artifacts.py | 4 +- tests/test_bootstrap.py | 25 +- tests/test_clangd.py | 30 +- tests/test_cli.py | 267 +++++----- tests/test_formatting.py | 9 +- tests/test_python.py | 9 +- 19 files changed, 1297 insertions(+), 1207 deletions(-) create mode 100644 modulus/build.py create mode 100644 modulus/build_context.py create mode 100644 modulus/conan.py create mode 100644 modulus/container.py create mode 100644 modulus/lsp.py create mode 100644 modulus/packaging.py create mode 100644 modulus/process.py create mode 100644 modulus/publishing.py create mode 100644 modulus/run.py diff --git a/README.md b/README.md index 379c68a..0823e29 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Available components are `boost_json`, `openssl`, `sqlite`, `raylib`, and `minia Conan consumers must enable the matching `modulus/*:with_openssl`, `with_sqlite`, `with_raylib`, or `with_miniaudio` option to add that dependency to the graph. Applications combining several bridge consumers should set the union of these options on their direct Modulus requirement. All four options default to false. +The Python CLI dispatches to separate build, LSP, run, and packaging modules. Build context, subprocess execution, Conan access, publishing policy, and container identity/execution are lower-level utilities shared by those commands and editor adapters. The bootstrap entrypoint remains `modulus.cli.main`; project configuration and command-line flags are unchanged. + ## Build Use the checked-in tools directly inside CI or a compatible build environment: diff --git a/modulus/build.py b/modulus/build.py new file mode 100644 index 0000000..58b26a3 --- /dev/null +++ b/modulus/build.py @@ -0,0 +1,257 @@ +import argparse +import shutil + +import modulus.clangd +import modulus.conan +import modulus.container +import modulus.process +import modulus.python + +def clear_cmake_cache(target): + cache = target.build_dir / "CMakeCache.txt" + cmake_files = target.build_dir / "CMakeFiles" + if cache.exists(): + cache.unlink() + if cmake_files.exists(): + shutil.rmtree(cmake_files) + +def clear_conan_deps(target): + conan_deps = target.build_dir / "conan" + if conan_deps.exists(): + shutil.rmtree(conan_deps) + +def cmake_cache_values(path): + result = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return result + for line in lines: + if not line or line.startswith(("//", "#")) or "=" not in line: + continue + key, value = line.split("=", 1) + name = key.split(":", 1)[0] + result[name] = value + return result + +def cmake_bool_value(value): + text = str(value).upper() + if text.endswith("-NOTFOUND"): + return False + if text in {"1", "ON", "TRUE", "YES", "Y"}: + return True + if text in {"0", "OFF", "FALSE", "NO", "N", "IGNORE", "NOTFOUND", ""}: + return False + return None + +def cmake_cache_value_matches(configured, expected): + if isinstance(expected, bool): + return cmake_bool_value(configured) is expected + return configured == cmake_value(expected) + +def target_cmake_definitions_match(target): + definitions = target.cmake_definitions + if not definitions: + return True + values = cmake_cache_values(target.build_dir / "CMakeCache.txt") + for name, expected in definitions.items(): + configured = values.get(name) + if configured is None or not cmake_cache_value_matches(configured, expected): + return False + return True + +def target_is_configured(target): + return ( + (target.build_dir / "CMakeCache.txt").is_file() + and (target.build_dir / "build.ninja").is_file() + and target_cmake_definitions_match(target) + ) + +def dependency_build_args(context): + return ["--build=missing"] + +def configure_one(context, target, update, no_remote=False): + modulus.python.interpreter(context, install=True, offline=no_remote) + clear_conan_deps(target) + command = [ + "conan", + "install", + ".", + f"--output-folder={target.build_dir / 'conan'}", + f"--profile:host={target.profile}", + f"--profile:build={context.default_target.profile}", + *dependency_build_args(context), + "-o", + "&:with_tests=True" if target.can_run else "&:with_tests=False", + ] + if update: + command.append("--update") + if no_remote: + command.append("--no-remote") + status = modulus.process.run(context, command) + if status != 0: + return status + clear_cmake_cache(target) + return modulus.process.run( + context, + [ + "cmake", + "-S", + ".", + "-B", + target.build_dir, + "-G", + "Ninja", + f"-DCMAKE_TOOLCHAIN_FILE={target.build_dir / 'conan' / 'conan_toolchain.cmake'}", + f"-DCMAKE_BUILD_TYPE={target.build_type}", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + f"-DBUILD_TESTING={'ON' if target.can_run else 'OFF'}", + f"-DCMAKE_PREFIX_PATH={target.build_dir / 'conan'}", + *cmake_definition_args(modulus.python.cmake_definitions(context)), + *cmake_definition_args(target.cmake_definitions), + ], + ) + +def configure(context, argv): + if not modulus.process.in_docker(context): + status = modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/configure.py", + *argv, + ], + ) + return status or modulus.clangd.prepare(context) + parser = argparse.ArgumentParser(description=f"Configure {context.package['name']} for Conan/CMake targets.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to configure. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument("--all", action="store_true", help="Configure all supported targets.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes. Requires all dependencies to be available locally or buildable from local recipes.") + args = parser.parse_args(argv) + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if not args.no_remote: + status = modulus.conan.configure_conan_remote(context) + if status != 0: + return status + update = args.update + for target in context.selected_targets(args.target, args.all): + target.build_dir.mkdir(parents=True, exist_ok=True) + status = configure_one(context, target, update, no_remote=args.no_remote) + if status != 0: + return status + return 0 + +def build(context, argv): + if not modulus.process.in_docker(context): + return modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/build.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Build {context.package['name']} for configured targets.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to build. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument( + "--cmake-target", + action="append", + default=[], + help="CMake target to build. May be passed more than once.", + ) + parser.add_argument("--all", action="store_true", help="Build all supported targets.") + args = parser.parse_args(argv) + selected_targets = context.selected_targets(args.target, args.all) + if any(not target_is_configured(target) for target in selected_targets): + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + status = modulus.conan.configure_conan_remote(context) + if status != 0: + return status + for target in selected_targets: + if not target_is_configured(target): + target.build_dir.mkdir(parents=True, exist_ok=True) + status = configure_one(context, target, update=False) + if status != 0: + return status + cmake_targets = list(args.cmake_target) + if not cmake_targets: + cmake_targets = [None] + for cmake_target in cmake_targets: + command = [ + "cmake", + "--build", + target.build_dir, + ] + if cmake_target is not None: + command.extend(["--target", cmake_target]) + command.extend(["--parallel", "2"]) + status = modulus.process.run(context, command) + if status != 0: + return status + return 0 + +def test(context, argv): + if not modulus.process.in_docker(context): + return modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/test.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Run {context.package['name']} tests for configured targets.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to test. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument("--all", action="store_true", help="Test all supported targets.") + args = parser.parse_args(argv) + test_name = context.config["tests"]["name"] + for target in context.selected_targets(args.target, args.all): + if not target.can_run: + print(f"Skipping tests for {target.name}: target binaries cannot run in this container.") + continue + status = modulus.process.run( + context, + [ + "ctest", + "--test-dir", + target.build_dir, + "-R", + f"^{test_name}$", + "--output-on-failure", + ], + ) + if status != 0: + return status + return 0 + +def option_value(value): + if isinstance(value, bool): + return "True" if value else "False" + return str(value) + +def cmake_value(value): + if isinstance(value, bool): + return "ON" if value else "OFF" + return str(value) + +def cmake_definition_args(definitions): + result = [] + for name, value in definitions.items(): + result.append(f"-D{name}={cmake_value(value)}") + return result diff --git a/modulus/build_context.py b/modulus/build_context.py new file mode 100644 index 0000000..75d01ea --- /dev/null +++ b/modulus/build_context.py @@ -0,0 +1,80 @@ +import pathlib + +import modulus.config + +build_root = pathlib.Path("build") +common_dir = build_root / "build_common" +tools_dir = build_root / "build_tools" + +class build_target: + def __init__(self, name, profile, build_dir, build_type, can_run, cmake_definitions): + self.name = name + self.profile = profile + self.build_dir = build_dir + self.build_type = build_type + self.can_run = can_run + self.cmake_definitions = cmake_definitions + +def find_repo_root(start): + current = start.resolve() + for candidate in (current, *current.parents): + if (candidate / "modulus.json").is_file(): + return candidate + raise SystemExit("could not find modulus.json from current directory") + +def validate_build_directory(repo_root, source_dir, build_dir): + resolved = pathlib.Path(build_dir).resolve() + if resolved in {pathlib.Path(repo_root).resolve(), pathlib.Path(source_dir).resolve()}: + raise modulus.config.config_error(f"build directory must differ from the project and source roots: {build_dir}") + +class build_context: + def __init__(self, repo_root, config): + self.repo_root = repo_root + self.config = config + self.package = config["package"] + self.artifact = self.package["artifact"] + self.tooling = config["tooling"] + self.run_settings = self.tooling["run"] + self.lsp_settings = self.tooling.get("lsp", {"enabled": False, "compile_databases": []}) + self.build_root = repo_root / build_root + self.common_dir = repo_root / common_dir + self.tools_rel = tools_dir if (repo_root / tools_dir).exists() else pathlib.Path("tools") + self.tools_dir = repo_root / self.tools_rel + self.conan_cache_dir = self.common_dir / "conan_cache" + self.default_target_name = str(self.tooling["default_target"]) + self.targets = self._targets() + self.default_target = self.targets[self.default_target_name] + docker = self.tooling["docker"] + self.docker_image = str(docker["image"]) + self.dockerfile = repo_root / docker["dockerfile"] if docker.get("dockerfile") else self.tools_dir / "docker" / "Dockerfile" + self.docker_inputs = [self.tools_dir / "docker" / "format_requirements.txt", *(repo_root / path for path in docker.get("inputs", []))] + self.docker_workdir = str(repo_root) + self.docker_marker = pathlib.Path(str(docker["marker"])) + self.upload_env = str(self.tooling["upload_env"]) + self.package_upload_env = str(self.artifact["upload_env"]) + def _targets(self): + result = {} + targets = self.tooling["targets"] + for name, target in targets.items(): + validate_build_directory(self.repo_root, self.repo_root, self.repo_root / str(target["build_dir"])) + result[name] = build_target( + name=name, + profile=self.repo_root / str(target["profile"]), + build_dir=self.repo_root / str(target["build_dir"]), + build_type=str(target.get("build_type", "Release")), + can_run=bool(target.get("can_run", True)), + cmake_definitions=dict(target.get("cmake_definitions", {})), + ) + return result + def target(self, name): + try: + return self.targets[name] + except KeyError: + valid = ", ".join(sorted(self.targets)) + raise SystemExit(f"unknown target '{name}'. Valid targets: {valid}") from None + def selected_targets(self, names, all_targets): + if all_targets: + return list(self.targets.values()) + if not names: + return [self.default_target] + return [self.target(name) for name in names] diff --git a/modulus/clangd.py b/modulus/clangd.py index 123b2ca..c5f7d44 100644 --- a/modulus/clangd.py +++ b/modulus/clangd.py @@ -3,7 +3,6 @@ import hashlib import json import os import pathlib -import re import shutil import signal import subprocess @@ -13,37 +12,13 @@ import tempfile import uuid import modulus.config +import modulus.container -image_label = "org.khodo.modulus.dockerfile" cache_version = 1 def settings(context): return {**modulus.config.clangd_defaults, **context.lsp_settings.get("clangd", {})} -def recipe_digest(context): - digest = hashlib.sha256(context.dockerfile.read_bytes()) - for path in getattr(context, "docker_inputs", [context.dockerfile.parent / "format_requirements.txt"]): - digest.update(path.read_bytes()) - return digest.hexdigest() - -def current_image(context, architecture=None): - try: - result = subprocess.run( - ["docker", "image", "inspect", context.docker_image], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False, - ) - if result.returncode != 0: - return None - image = json.loads(result.stdout)[0] - labels = image.get("Config", {}).get("Labels") or {} - if labels.get(image_label) != recipe_digest(context): - return None - if architecture and image["Architecture"] != architecture: - return None - return image["Id"] if re.fullmatch(r"sha256:[0-9a-f]{64}", image["Id"]) else None - except (OSError, ValueError, KeyError, IndexError, TypeError): - return None - def cache_directory(context, image): cache_home = pathlib.Path(os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")).resolve() config = settings(context) @@ -52,14 +27,6 @@ def cache_directory(context, image): digest = hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest()[:20] return cache_home / "modulus" / "clangd" / image.removeprefix("sha256:") / digest -def isolated_run(image): - return [ - "docker", "run", "--rm", "--init", "--network", "none", "--read-only", - "--cap-drop=ALL", "--security-opt=no-new-privileges", - "--tmpfs", "/tmp:rw,nosuid,nodev", "--user", f"{os.getuid()}:{os.getgid()}", - "--env", "HOME=/tmp", - ] - def read_manifest(cache): data = json.loads((cache / "manifest.json").read_text(encoding="utf-8")) roots = data["roots"] @@ -93,9 +60,8 @@ def extract_headers(stream, destination, roots): raise ValueError(f"non-regular header archive entry: {member.name}") def prepare(context): - from modulus.cli import docker_platform_arch try: - image = current_image(context, docker_platform_arch()) + image = modulus.container.current_image(context, modulus.container.docker_platform_arch()) if image is None: raise RuntimeError("build image is missing or stale; run configure first") cache = cache_directory(context, image) @@ -107,7 +73,7 @@ def prepare(context): return 0 probe = context.tools_dir / "clangd_headers.py" result = subprocess.run( - [*isolated_run(image), "--volume", f"{probe}:/modulus_headers.py:ro", image, + [*modulus.container.isolated_run(image), "--volume", f"{probe}:/modulus_headers.py:ro", image, "python3", "/modulus_headers.py", json.dumps(settings(context))], stdout=subprocess.PIPE, text=True, check=True, ) @@ -115,7 +81,7 @@ def prepare(context): with tempfile.TemporaryDirectory(prefix=".headers_", dir=cache.parent) as temporary: stage = pathlib.Path(temporary) / "cache" stage.mkdir() - command = [*isolated_run(image), image, "tar", "--dereference", "--hard-dereference", + command = [*modulus.container.isolated_run(image), image, "tar", "--dereference", "--hard-dereference", "--exclude=*/.git", "--exclude=*/__pycache__", "-C", "/", "-cf", "-", *[root.lstrip("/") for root in roots]] with subprocess.Popen(command, stdout=subprocess.PIPE) as process: @@ -151,7 +117,7 @@ def server_arguments(context, argv): def launch_command(context, argv, image, cache, roots, name): root = context.repo_root return [ - *isolated_run(image), "--interactive", "--name", name, + *modulus.container.isolated_run(image), "--interactive", "--name", name, "--volume", f"{root}:{root}:ro", "--volume", f"{context.build_root}:{context.build_root}:rw", "--env", f"XDG_CACHE_HOME={context.common_dir / 'clangd'}", @@ -159,11 +125,10 @@ def launch_command(context, argv, image, cache, roots, name): ] def launch(context, argv): - from modulus.cli import docker_platform_arch try: if context.docker_marker.exists(): os.execv(settings(context)["executable"], server_arguments(context, argv)) - image = current_image(context, docker_platform_arch()) + image = modulus.container.current_image(context, modulus.container.docker_platform_arch()) if image is None: raise RuntimeError("missing or stale build image; run ./modulus.py configure (Modulus: ./tools/configure.py)") databases = [context.default_target.build_dir, *[ diff --git a/modulus/cli.py b/modulus/cli.py index c1958c6..fe26bd3 100644 --- a/modulus/cli.py +++ b/modulus/cli.py @@ -1,36 +1,24 @@ -import argparse -import os import pathlib -import shlex -import shutil -import subprocess import sys -import urllib.parse -import modulus.config -import modulus.export -import modulus.clangd -import modulus.formatting -import modulus.python import modulus.artifacts - -build_root = pathlib.Path("build") -common_dir = build_root / "build_common" -tools_dir = build_root / "build_tools" - -class build_target: - def __init__(self, name, profile, build_dir, build_type, can_run, cmake_definitions): - self.name = name - self.profile = profile - self.build_dir = build_dir - self.build_type = build_type - self.can_run = can_run - self.cmake_definitions = cmake_definitions +import modulus.build +import modulus.build_context +import modulus.clangd +import modulus.config +import modulus.container +import modulus.export +import modulus.formatting +import modulus.lsp +import modulus.packaging +import modulus.process +import modulus.python +import modulus.run def main(command): - repo_root = find_repo_root(pathlib.Path.cwd()) + repo_root = modulus.build_context.find_repo_root(pathlib.Path.cwd()) config = modulus.config.load_repo_config(repo_root) - context = build_context(repo_root, config) + context = modulus.build_context.build_context(repo_root, config) if command == "format": return modulus.formatting.launch(context, sys.argv[1:]) if command == "clangd": @@ -38,990 +26,21 @@ def main(command): if command in {"configure", "lsp"}: modulus.export.sync_editor_files(repo_root, config) if command == "configure": - return configure(context, sys.argv[1:]) + return modulus.build.configure(context, sys.argv[1:]) if command == "build": - return build(context, sys.argv[1:]) + return modulus.build.build(context, sys.argv[1:]) if command == "test": - return test(context, sys.argv[1:]) + return modulus.build.test(context, sys.argv[1:]) if command == "package": - return package(context, sys.argv[1:]) + return modulus.packaging.package(context, sys.argv[1:]) if command == "run": - return product_run(context, sys.argv[1:]) + return modulus.run.product_run(context, sys.argv[1:]) if command == "lsp": - return lsp(context, sys.argv[1:]) + return modulus.lsp.lsp(context, sys.argv[1:]) if command in {'python', 'publish'}: - if not in_docker(context): - return docker_run(context, [f'./{context.tools_rel}/{command}.py', *sys.argv[1:]]) + if not modulus.process.in_docker(context): + return modulus.container.docker_run(context, [f'./{context.tools_rel}/{command}.py', *sys.argv[1:]]) if command == 'python': return modulus.python.launch(context, sys.argv[1:]) return modulus.artifacts.main(context, sys.argv[1:]) raise SystemExit(f"unknown modulus command: {command}") - -def find_repo_root(start): - current = start.resolve() - for candidate in (current, *current.parents): - if (candidate / "modulus.json").is_file(): - return candidate - raise SystemExit("could not find modulus.json from current directory") - -class build_context: - def __init__(self, repo_root, config): - self.repo_root = repo_root - self.config = config - self.package = config["package"] - self.artifact = self.package["artifact"] - self.tooling = config["tooling"] - self.run_settings = self.tooling["run"] - self.lsp_settings = self.tooling.get("lsp", {"enabled": False, "compile_databases": []}) - self.build_root = repo_root / build_root - self.common_dir = repo_root / common_dir - self.tools_rel = tools_dir if (repo_root / tools_dir).exists() else pathlib.Path("tools") - self.tools_dir = repo_root / self.tools_rel - self.conan_cache_dir = self.common_dir / "conan_cache" - self.default_target_name = str(self.tooling["default_target"]) - self.targets = self._targets() - self.default_target = self.targets[self.default_target_name] - docker = self.tooling["docker"] - self.docker_image = str(docker["image"]) - self.dockerfile = repo_root / docker["dockerfile"] if docker.get("dockerfile") else self.tools_dir / "docker" / "Dockerfile" - self.docker_inputs = [self.tools_dir / "docker" / "format_requirements.txt", *(repo_root / path for path in docker.get("inputs", []))] - self.docker_workdir = str(repo_root) - self.docker_marker = pathlib.Path(str(docker["marker"])) - self.upload_env = str(self.tooling["upload_env"]) - self.package_upload_env = str(self.artifact["upload_env"]) - def _targets(self): - result = {} - targets = self.tooling["targets"] - for name, target in targets.items(): - validate_build_directory(self.repo_root, self.repo_root, self.repo_root / str(target["build_dir"])) - result[name] = build_target( - name=name, - profile=self.repo_root / str(target["profile"]), - build_dir=self.repo_root / str(target["build_dir"]), - build_type=str(target.get("build_type", "Release")), - can_run=bool(target.get("can_run", True)), - cmake_definitions=dict(target.get("cmake_definitions", {})), - ) - return result - def target(self, name): - try: - return self.targets[name] - except KeyError: - valid = ", ".join(sorted(self.targets)) - raise SystemExit(f"unknown target '{name}'. Valid targets: {valid}") from None - def selected_targets(self, names, all_targets): - if all_targets: - return list(self.targets.values()) - if not names: - return [self.default_target] - return [self.target(name) for name in names] - -def in_docker(context): - return direct_mode() or context.docker_marker.exists() - -def env_flag(name): - return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} - -def direct_mode(): - return env_flag("MODULUS_DIRECT") - -def in_container(): - return pathlib.Path("/.dockerenv").exists() - -def current_container_name(): - return os.environ.get("HOSTNAME", "").strip() - -def docker_platform_args(): - platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() - if not platform: - return [] - return [ - "--platform", - platform, - ] - -def conan_remote(context): - return str(context.tooling["remote"]["name"]) - -def conan_remote_url(context): - return context.tooling["remote"]["url"] - -def conan_user(): - return os.environ.get("CONAN_USER") - -def conan_token(): - return os.environ.get("CONAN_TOKEN") - -def conan_credentials(): - return conan_user(), conan_token() - -def require_conan_credentials(operation): - user, token = conan_credentials() - if bool(user) != bool(token): - print("CONAN_USER and CONAN_TOKEN must either both be set or both be unset", file=sys.stderr) - return None - if not user or not token: - print(f"CONAN_USER and CONAN_TOKEN are required to {operation}", file=sys.stderr) - return None - return user, token - -def subprocess_environment(context, command): - env = os.environ.copy() - env["CONAN_HOME"] = str(context.conan_cache_dir) - if str(command[0]) == "conan": - for name in list(env): - if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")): - del env[name] - env["CONAN_NON_INTERACTIVE"] = "1" - env["MODULUS_PYTHON_COMMON_DIR"] = str(context.common_dir) - env["MODULUS_PYTHON_OFFLINE"] = "1" if "--no-remote" in command else "0" - return env - -def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_command=None): - visible = display_command if display_command is not None else command - print("+", " ".join(shlex.quote(str(part)) for part in visible)) - env = subprocess_environment(context, command) - for name in env_unset or (): - env.pop(name, None) - if env_overrides: - env.update(env_overrides) - return subprocess.run([str(part) for part in command], cwd=cwd or context.repo_root, check=False, env=env).returncode - -def run_capture(context, command, cwd=None, env_overrides=None, display_command=None): - visible = display_command if display_command is not None else command - print("+", " ".join(shlex.quote(str(part)) for part in visible)) - env = subprocess_environment(context, command) - if env_overrides: - env.update(env_overrides) - completed = subprocess.run( - [str(part) for part in command], - cwd=cwd or context.repo_root, - check=False, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if completed.returncode != 0: - if completed.stdout: - print(completed.stdout, end="") - if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) - return completed - -def configure_conan_remote(context, authenticate=False): - remote = conan_remote(context) - credentials = require_conan_credentials("upload Conan packages") if authenticate else None - if authenticate and credentials is None: - return 1 - status = run( - context, - [ - "conan", - "remote", - "add", - "-f", - remote, - conan_remote_url(context), - ], - ) - if status != 0: - return status - if not authenticate: - return run(context, ["conan", "remote", "logout", remote]) - user, token = credentials - return run( - context, - [ - "conan", - "remote", - "login", - remote, - user, - "-p", - token, - ], - display_command=[ - "conan", - "remote", - "login", - remote, - user, - "-p", - "", - ], - ) - -def docker_passthrough_env(context, publishing=False): - names = ( - "CI", - "CONAN_HOME", - "MODULUS_DIRECT", - "MODULUS_ALLOW_PACKAGE_REPLACE", - context.upload_env, - context.package_upload_env, - ) - if publishing: - names += ("CONAN_TOKEN", "CONAN_USER", "PACKAGE_REGISTRY_TOKEN", "PACKAGE_REGISTRY_USER") - return names - -def docker_socket_args(context, command): - if command not in context.tooling["docker"].get("socket_commands", []): - return [] - socket = pathlib.Path("/var/run/docker.sock") - if not socket.is_socket(): - return [] - return ["--volume", f"{socket}:{socket}", "--group-add", str(socket.stat().st_gid)] - -def docker_platform_arch(): - platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() - if not platform: - return None - parts = platform.split("/") - if len(parts) < 2: - return None - return { - "amd64": "amd64", - "arm64": "arm64", - "arm64v8": "arm64", - }.get(parts[1]) - -def docker_image_available(context): - return modulus.clangd.current_image(context, docker_platform_arch()) is not None - -def build_image(context): - return run( - context, - [ - "docker", - "build", - *docker_platform_args(), - "--file", - context.dockerfile, - "--tag", - context.docker_image, - "--label", - f"{modulus.clangd.image_label}={modulus.clangd.recipe_digest(context)}", - "--build-arg", - f"MODULUS_TOOLS={context.tools_rel}", - ".", - ], - ) - -def ensure_build_image(context): - if docker_image_available(context): - return 0 - return build_image(context) - -def docker_run(context, command, docker_args=None, env_overrides=None): - status = ensure_build_image(context) - if status != 0: - return status - resolved_env_overrides = dict(env_overrides or {}) - operation = pathlib.Path(command[0]).stem - publishing = operation == "publish" or (operation == "package" and "--no-remote" not in command and (should_upload(context) or should_upload_artifacts(context))) - docker_command = [ - "docker", - "run", - "--rm", - "--interactive", - *docker_platform_args(), - "--user", - f"{os.getuid()}:{os.getgid()}", - "--env", - "HOME=/tmp", - *docker_socket_args(context, operation), - ] - if docker_args: - docker_command.extend(docker_args) - for name in docker_passthrough_env(context, publishing): - if name in os.environ: - docker_command.extend(["--env", name]) - if resolved_env_overrides: - for name in resolved_env_overrides: - docker_command.extend(["--env", name]) - if in_container(): - container_name = current_container_name() - if not container_name: - raise SystemExit("HOSTNAME is required to share the CI job workspace with the build container") - docker_command.extend([ - "--volumes-from", - container_name, - ]) - else: - docker_command.extend([ - "--volume", - f"{context.repo_root}:{context.docker_workdir}", - ]) - docker_command.extend([ - "--workdir", - context.docker_workdir, - context.docker_image, - *command, - ]) - return run(context, docker_command, env_overrides=resolved_env_overrides) - -def docker_publish_args(context): - args = [] - for port in context.run_settings.get("ports", []): - args.extend(["--publish", port]) - return args - -def clear_cmake_cache(target): - cache = target.build_dir / "CMakeCache.txt" - cmake_files = target.build_dir / "CMakeFiles" - if cache.exists(): - cache.unlink() - if cmake_files.exists(): - shutil.rmtree(cmake_files) - -def clear_conan_deps(target): - conan_deps = target.build_dir / "conan" - if conan_deps.exists(): - shutil.rmtree(conan_deps) - -def cmake_cache_values(path): - result = {} - try: - lines = path.read_text(encoding="utf-8").splitlines() - except FileNotFoundError: - return result - for line in lines: - if not line or line.startswith(("//", "#")) or "=" not in line: - continue - key, value = line.split("=", 1) - name = key.split(":", 1)[0] - result[name] = value - return result - -def cmake_bool_value(value): - text = str(value).upper() - if text.endswith("-NOTFOUND"): - return False - if text in {"1", "ON", "TRUE", "YES", "Y"}: - return True - if text in {"0", "OFF", "FALSE", "NO", "N", "IGNORE", "NOTFOUND", ""}: - return False - return None - -def cmake_cache_value_matches(configured, expected): - if isinstance(expected, bool): - return cmake_bool_value(configured) is expected - return configured == cmake_value(expected) - -def target_cmake_definitions_match(target): - definitions = target.cmake_definitions - if not definitions: - return True - values = cmake_cache_values(target.build_dir / "CMakeCache.txt") - for name, expected in definitions.items(): - configured = values.get(name) - if configured is None or not cmake_cache_value_matches(configured, expected): - return False - return True - -def target_is_configured(target): - return ( - (target.build_dir / "CMakeCache.txt").is_file() - and (target.build_dir / "build.ninja").is_file() - and target_cmake_definitions_match(target) - ) - -def package_name(context): - return context.package["name"] - -def dependency_build_args(context): - return ["--build=missing"] - -def configure_one(context, target, update, no_remote=False): - modulus.python.interpreter(context, install=True, offline=no_remote) - clear_conan_deps(target) - command = [ - "conan", - "install", - ".", - f"--output-folder={target.build_dir / 'conan'}", - f"--profile:host={target.profile}", - f"--profile:build={context.default_target.profile}", - *dependency_build_args(context), - "-o", - "&:with_tests=True" if target.can_run else "&:with_tests=False", - ] - if update: - command.append("--update") - if no_remote: - command.append("--no-remote") - status = run(context, command) - if status != 0: - return status - clear_cmake_cache(target) - return run( - context, - [ - "cmake", - "-S", - ".", - "-B", - target.build_dir, - "-G", - "Ninja", - f"-DCMAKE_TOOLCHAIN_FILE={target.build_dir / 'conan' / 'conan_toolchain.cmake'}", - f"-DCMAKE_BUILD_TYPE={target.build_type}", - "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - f"-DBUILD_TESTING={'ON' if target.can_run else 'OFF'}", - f"-DCMAKE_PREFIX_PATH={target.build_dir / 'conan'}", - *cmake_definition_args(modulus.python.cmake_definitions(context)), - *cmake_definition_args(target.cmake_definitions), - ], - ) - -def configure(context, argv): - if not in_docker(context): - status = docker_run( - context, - [ - f"./{context.tools_rel}/configure.py", - *argv, - ], - ) - return status or modulus.clangd.prepare(context) - parser = argparse.ArgumentParser(description=f"Configure {context.package['name']} for Conan/CMake targets.") - parser.add_argument( - "--target", - action="append", - default=[], - choices=sorted(context.targets), - help="Target to configure. May be passed more than once. Defaults to the default target.", - ) - parser.add_argument("--all", action="store_true", help="Configure all supported targets.") - parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") - parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes. Requires all dependencies to be available locally or buildable from local recipes.") - args = parser.parse_args(argv) - context.conan_cache_dir.mkdir(parents=True, exist_ok=True) - if not args.no_remote: - status = configure_conan_remote(context) - if status != 0: - return status - update = args.update - for target in context.selected_targets(args.target, args.all): - target.build_dir.mkdir(parents=True, exist_ok=True) - status = configure_one(context, target, update, no_remote=args.no_remote) - if status != 0: - return status - return 0 - -def build(context, argv): - if not in_docker(context): - return docker_run( - context, - [ - f"./{context.tools_rel}/build.py", - *argv, - ], - ) - parser = argparse.ArgumentParser(description=f"Build {context.package['name']} for configured targets.") - parser.add_argument( - "--target", - action="append", - default=[], - choices=sorted(context.targets), - help="Target to build. May be passed more than once. Defaults to the default target.", - ) - parser.add_argument( - "--cmake-target", - action="append", - default=[], - help="CMake target to build. May be passed more than once.", - ) - parser.add_argument("--all", action="store_true", help="Build all supported targets.") - args = parser.parse_args(argv) - selected_targets = context.selected_targets(args.target, args.all) - if any(not target_is_configured(target) for target in selected_targets): - context.conan_cache_dir.mkdir(parents=True, exist_ok=True) - status = configure_conan_remote(context) - if status != 0: - return status - for target in selected_targets: - if not target_is_configured(target): - target.build_dir.mkdir(parents=True, exist_ok=True) - status = configure_one(context, target, update=False) - if status != 0: - return status - cmake_targets = list(args.cmake_target) - if not cmake_targets: - cmake_targets = [None] - for cmake_target in cmake_targets: - command = [ - "cmake", - "--build", - target.build_dir, - ] - if cmake_target is not None: - command.extend(["--target", cmake_target]) - command.extend(["--parallel", "2"]) - status = run(context, command) - if status != 0: - return status - return 0 - -def test(context, argv): - if not in_docker(context): - return docker_run( - context, - [ - f"./{context.tools_rel}/test.py", - *argv, - ], - ) - parser = argparse.ArgumentParser(description=f"Run {context.package['name']} tests for configured targets.") - parser.add_argument( - "--target", - action="append", - default=[], - choices=sorted(context.targets), - help="Target to test. May be passed more than once. Defaults to the default target.", - ) - parser.add_argument("--all", action="store_true", help="Test all supported targets.") - args = parser.parse_args(argv) - test_name = context.config["tests"]["name"] - for target in context.selected_targets(args.target, args.all): - if not target.can_run: - print(f"Skipping tests for {target.name}: target binaries cannot run in this container.") - continue - status = run( - context, - [ - "ctest", - "--test-dir", - target.build_dir, - "-R", - f"^{test_name}$", - "--output-on-failure", - ], - ) - if status != 0: - return status - return 0 - -def lsp_compile_databases(context): - if not context.lsp_settings["enabled"]: - return [] - return list(context.lsp_settings["compile_databases"]) - -def lsp_database_map(context): - return { - str(database["name"]): database - for database in lsp_compile_databases(context) - } - -def clear_lsp_cmake_cache(build_dir): - cache = build_dir / "CMakeCache.txt" - cmake_files = build_dir / "CMakeFiles" - if cache.exists(): - cache.unlink() - if cmake_files.exists(): - shutil.rmtree(cmake_files) - -def validate_build_directory(repo_root, source_dir, build_dir): - resolved = pathlib.Path(build_dir).resolve() - if resolved in {pathlib.Path(repo_root).resolve(), pathlib.Path(source_dir).resolve()}: - raise modulus.config.config_error(f"build directory must differ from the project and source roots: {build_dir}") - -def configure_lsp_database(context, database): - modulus.python.interpreter(context, install=True) - build_dir = context.repo_root / str(database["build_dir"]) - source_dir = context.repo_root / str(database.get("source_dir", ".")) - validate_build_directory(context.repo_root, source_dir, build_dir) - build_dir.mkdir(parents=True, exist_ok=True) - clear_lsp_cmake_cache(build_dir) - return run( - context, - [ - "cmake", - "-S", - source_dir, - "-B", - build_dir, - "-G", - str(database.get("generator", "Ninja")), - "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - *cmake_definition_args(modulus.python.cmake_definitions(context)), - *cmake_definition_args(database.get("cmake_definitions", {})), - ], - env_overrides=dict(database.get("env", {})), - env_unset=list(database.get("unset_env", [])), - ) - -def lsp(context, argv): - if not in_docker(context): - status = docker_run( - context, - [ - f"./{context.tools_rel}/lsp.py", - *argv, - ], - ) - return status or modulus.clangd.prepare(context) - database_map = lsp_database_map(context) - parser = argparse.ArgumentParser(description=f"Refresh {context.package['name']} compile databases for clangd.") - parser.add_argument( - "--target", - action="append", - default=[], - choices=sorted(context.targets), - help="Configured build target compile database to refresh. May be passed more than once. Defaults to the default target.", - ) - parser.add_argument( - "--database", - action="append", - default=[], - choices=sorted(database_map), - help="Extra LSP compile database to refresh. May be passed more than once. Defaults to all configured LSP databases.", - ) - parser.add_argument("--no-build-targets", action="store_true", help="Skip Modulus build-target compile databases.") - parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") - parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes for Modulus build-target compile databases.") - args = parser.parse_args(argv) - if not args.no_build_targets: - context.conan_cache_dir.mkdir(parents=True, exist_ok=True) - if not args.no_remote: - status = configure_conan_remote(context) - if status != 0: - return status - for target in context.selected_targets(args.target, all_targets=False): - target.build_dir.mkdir(parents=True, exist_ok=True) - status = configure_one(context, target, args.update, no_remote=args.no_remote) - if status != 0: - return status - database_names = args.database or [str(database["name"]) for database in lsp_compile_databases(context)] - for name in database_names: - status = configure_lsp_database(context, database_map[name]) - if status != 0: - return status - return 0 - -def product_run(context, argv): - if not context.run_settings["enabled"]: - print(f"{package_name(context)} does not enable the Modulus run command", file=sys.stderr) - return 1 - if not in_docker(context): - return docker_run( - context, - [ - f"./{context.tools_rel}/run.py", - *argv, - ], - docker_args=docker_publish_args(context), - ) - parser = argparse.ArgumentParser(description=f"Run {context.package['name']} from a configured build target.") - parser.add_argument( - "--target", - default=str(context.run_settings["target"]), - choices=sorted(context.targets), - help="Configured target to run. Defaults to tooling.run.target.", - ) - parser.add_argument( - "args", - nargs=argparse.REMAINDER, - help="Arguments passed to the product executable. A leading -- separator is optional.", - ) - args = parser.parse_args(argv) - target = context.target(args.target) - if not target.can_run: - print(f"Target {target.name} is not runnable in this environment", file=sys.stderr) - return 1 - executable = pathlib.Path(str(context.run_settings["executable"])) - if not executable.is_absolute(): - executable = target.build_dir / executable - if not executable.is_file(): - print(f"Executable was not found: {executable}. Run python3 modulus.py build --target {target.name} first.", file=sys.stderr) - return 1 - command_args = list(context.run_settings["args"]) - extra_args = list(args.args) - if extra_args and extra_args[0] == "--": - extra_args = extra_args[1:] - command_args.extend(extra_args) - return run( - context, - [ - executable, - *command_args, - ], - env_overrides=dict(context.run_settings["env"]), - ) - -def create_package(context, target, update, no_remote=False): - command = [ - "conan", - "create", - ".", - f"--profile:host={target.profile}", - f"--profile:build={context.default_target.profile}", - f"--build={package_name(context)}/*", - *dependency_build_args(context), - "-o", - "&:with_tests=False", - ] - if update: - command.append("--update") - if no_remote: - command.append("--no-remote") - return run(context, command) - -def upload_all_packages(context, remote, force=False): - command = [ - "conan", - "upload", - f"{package_name(context)}/latest:*", - "-r", - remote, - "--confirm", - ] - if force: - command.append("--force") - return run(context, command) - -def allow_package_replace(): - return env_flag("MODULUS_ALLOW_PACKAGE_REPLACE") - -def upload_packages(context, replace=False): - if replace and not allow_package_replace(): - print("--replace requires MODULUS_ALLOW_PACKAGE_REPLACE=1", file=sys.stderr) - return 1 - remote = conan_remote(context) - try: - status = configure_conan_remote(context, authenticate=True) - if status == 0: - status = upload_all_packages(context, remote, force=replace) - finally: - logout_status = run(context, ["conan", "remote", "logout", remote]) - status = status or logout_status - if status != 0 and not replace: - print(f"upload failed for {package_name(context)}/latest. Use --replace with MODULUS_ALLOW_PACKAGE_REPLACE=1 to intentionally replace an existing version.", file=sys.stderr) - return status - -def should_upload(context): - return env_flag(context.upload_env) - -def artifact_packaging_enabled(context): - return bool(context.artifact["enabled"]) - -def should_upload_artifacts(context): - return env_flag(context.package_upload_env) - -def package_registry_credentials(): - user = os.environ.get("PACKAGE_REGISTRY_USER") - token = os.environ.get("PACKAGE_REGISTRY_TOKEN") - if bool(user) != bool(token): - print("PACKAGE_REGISTRY_USER and PACKAGE_REGISTRY_TOKEN must either both be set or both be unset", file=sys.stderr) - return None - if not user or not token: - print("PACKAGE_REGISTRY_USER and PACKAGE_REGISTRY_TOKEN are required to upload package artifacts", file=sys.stderr) - return None - return user, token - -def option_value(value): - if isinstance(value, bool): - return "True" if value else "False" - return str(value) - -def cmake_value(value): - if isinstance(value, bool): - return "ON" if value else "OFF" - return str(value) - -def cmake_definition_args(definitions): - result = [] - for name, value in definitions.items(): - result.append(f"-D{name}={cmake_value(value)}") - return result - -def package_artifact_conan_option_args(context): - result = [] - for name, value in context.artifact["conan_options"].items(): - result.extend(["-o", f"&:{name}={option_value(value)}"]) - return result - -def package_artifact_cmake_definition_args(context): - return cmake_definition_args(context.artifact["cmake_definitions"]) - -def package_artifact_targets(context, names, all_targets): - if all_targets or names: - return context.selected_targets(names, all_targets) - configured = context.artifact["targets"] - if configured: - return [context.target(name) for name in configured] - return [context.default_target] - -def package_artifact_build_root(context, target): - return context.common_dir / "artifacts" / target.name - -def package_artifact_dist_dir(context): - return context.build_root / "packages" - -def package_artifact_install_prefix(context, stage_root): - install_prefix = pathlib.Path(str(context.artifact["install_prefix"])) - if str(install_prefix) == ".": - return stage_root - return stage_root / install_prefix - -def create_artifact_package(context, target, update, no_remote=False): - modulus.python.interpreter(context, install=True, offline=no_remote) - root = package_artifact_build_root(context, target) - build_dir = root / "build" - stage_root = root / "stage" - conan_dir = build_dir / "conan" - if root.exists(): - shutil.rmtree(root) - build_dir.mkdir(parents=True) - stage_root.mkdir(parents=True) - install_prefix = package_artifact_install_prefix(context, stage_root) - install_prefix.mkdir(parents=True, exist_ok=True) - conan_command = [ - "conan", - "install", - ".", - f"--output-folder={conan_dir}", - f"--profile:host={target.profile}", - f"--profile:build={context.default_target.profile}", - *dependency_build_args(context), - "-o", - "&:with_tests=False", - *package_artifact_conan_option_args(context), - ] - if update: - conan_command.append("--update") - if no_remote: - conan_command.append("--no-remote") - status = run(context, conan_command) - if status != 0: - return status, [] - status = run( - context, - [ - "cmake", - "-S", - ".", - "-B", - build_dir, - "-G", - "Ninja", - f"-DCMAKE_TOOLCHAIN_FILE={conan_dir / 'conan_toolchain.cmake'}", - f"-DCMAKE_BUILD_TYPE={target.build_type}", - "-DBUILD_TESTING=OFF", - f"-DCMAKE_PREFIX_PATH={conan_dir}", - f"-DCMAKE_INSTALL_PREFIX={install_prefix}", - *cmake_definition_args(modulus.python.cmake_definitions(context)), - *package_artifact_cmake_definition_args(context), - ], - ) - if status != 0: - return status, [] - status = run( - context, - [ - "cmake", - "--build", - build_dir, - "--parallel", - "2", - ], - ) - if status != 0: - return status, [] - status = run( - context, - [ - "cmake", - "--install", - build_dir, - ], - ) - if status != 0: - return status, [] - dist_dir = package_artifact_dist_dir(context) - dist_dir.mkdir(parents=True, exist_ok=True) - archive = dist_dir / modulus.artifacts.archive_name(package_name(context), target.name) - if archive.exists(): - archive.unlink() - modulus.artifacts.create_archive(stage_root, archive) - checksum = modulus.artifacts.write_checksum(archive) - print(f"Created {archive}") - print(f"Created {checksum}") - return 0, [archive, checksum] - -def package_artifacts(context, argv): - if not in_docker(context): - return docker_run( - context, - [ - f"./{context.tools_rel}/package.py", - *argv, - ], - ) - parser = argparse.ArgumentParser(description=f"Create {context.package['name']} application tarballs.") - parser.add_argument( - "--target", - action="append", - default=[], - choices=sorted(context.targets), - help="Target to package. May be passed more than once. Defaults to package.artifact.targets.", - ) - parser.add_argument("--all", action="store_true", help="Package all supported targets.") - parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") - parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes or upload package artifacts.") - args = parser.parse_args(argv) - context.conan_cache_dir.mkdir(parents=True, exist_ok=True) - if should_upload_artifacts(context) and not args.no_remote and package_registry_credentials() is None: - return 1 - if not args.no_remote: - status = configure_conan_remote(context) - if status != 0: - return status - artifacts = [] - for target in package_artifact_targets(context, args.target, args.all): - status, created = create_artifact_package(context, target, args.update, no_remote=args.no_remote) - if status != 0: - return status - artifacts.extend(created) - if should_upload_artifacts(context) and not args.no_remote: - return modulus.artifacts.main(context, []) - return 0 - -def package(context, argv): - if artifact_packaging_enabled(context): - return package_artifacts(context, argv) - if not in_docker(context): - return docker_run( - context, - [ - f"./{context.tools_rel}/package.py", - *argv, - ], - ) - parser = argparse.ArgumentParser(description=f"Create {context.package['name']} Conan packages.") - parser.add_argument( - "--target", - action="append", - default=[], - choices=sorted(context.targets), - help="Target to package. May be passed more than once. Defaults to the default target.", - ) - parser.add_argument("--all", action="store_true", help="Package all supported targets.") - parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") - parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes. Requires all dependencies to be available locally or buildable from local recipes.") - parser.add_argument("--replace", action="store_true", help="Delete the remote package version before upload. Requires MODULUS_ALLOW_PACKAGE_REPLACE=1.") - args = parser.parse_args(argv) - context.conan_cache_dir.mkdir(parents=True, exist_ok=True) - if not args.no_remote: - status = configure_conan_remote(context) - if status != 0: - return status - update = args.update - for target in context.selected_targets(args.target, args.all): - status = create_package(context, target, update, no_remote=args.no_remote) - if status != 0: - return status - if should_upload(context) and not args.no_remote: - return upload_packages(context, replace=args.replace) - return 0 diff --git a/modulus/conan.py b/modulus/conan.py new file mode 100644 index 0000000..817afc5 --- /dev/null +++ b/modulus/conan.py @@ -0,0 +1,72 @@ +import os +import sys + +import modulus.process + +def conan_remote(context): + return str(context.tooling["remote"]["name"]) + +def conan_remote_url(context): + return context.tooling["remote"]["url"] + +def conan_user(): + return os.environ.get("CONAN_USER") + +def conan_token(): + return os.environ.get("CONAN_TOKEN") + +def conan_credentials(): + return conan_user(), conan_token() + +def require_conan_credentials(operation): + user, token = conan_credentials() + if bool(user) != bool(token): + print("CONAN_USER and CONAN_TOKEN must either both be set or both be unset", file=sys.stderr) + return None + if not user or not token: + print(f"CONAN_USER and CONAN_TOKEN are required to {operation}", file=sys.stderr) + return None + return user, token + +def configure_conan_remote(context, authenticate=False): + remote = conan_remote(context) + credentials = require_conan_credentials("upload Conan packages") if authenticate else None + if authenticate and credentials is None: + return 1 + status = modulus.process.run( + context, + [ + "conan", + "remote", + "add", + "-f", + remote, + conan_remote_url(context), + ], + ) + if status != 0: + return status + if not authenticate: + return modulus.process.run(context, ["conan", "remote", "logout", remote]) + user, token = credentials + return modulus.process.run( + context, + [ + "conan", + "remote", + "login", + remote, + user, + "-p", + token, + ], + display_command=[ + "conan", + "remote", + "login", + remote, + user, + "-p", + "", + ], + ) diff --git a/modulus/container.py b/modulus/container.py new file mode 100644 index 0000000..bb3c423 --- /dev/null +++ b/modulus/container.py @@ -0,0 +1,167 @@ +import hashlib +import json +import os +import pathlib +import re +import subprocess + +import modulus.process +import modulus.publishing + +def docker_platform_args(): + platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() + if not platform: + return [] + return [ + "--platform", + platform, + ] + +def docker_passthrough_env(context, publishing=False): + names = ( + "CI", + "CONAN_HOME", + "MODULUS_DIRECT", + "MODULUS_ALLOW_PACKAGE_REPLACE", + context.upload_env, + context.package_upload_env, + ) + if publishing: + names += ("CONAN_TOKEN", "CONAN_USER", "PACKAGE_REGISTRY_TOKEN", "PACKAGE_REGISTRY_USER") + return names + +def docker_socket_args(context, command): + if command not in context.tooling["docker"].get("socket_commands", []): + return [] + socket = pathlib.Path("/var/run/docker.sock") + if not socket.is_socket(): + return [] + return ["--volume", f"{socket}:{socket}", "--group-add", str(socket.stat().st_gid)] + +def docker_platform_arch(): + platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() + if not platform: + return None + parts = platform.split("/") + if len(parts) < 2: + return None + return { + "amd64": "amd64", + "arm64": "arm64", + "arm64v8": "arm64", + }.get(parts[1]) + +def docker_image_available(context): + return current_image(context, docker_platform_arch()) is not None + +def build_image(context): + return modulus.process.run( + context, + [ + "docker", + "build", + *docker_platform_args(), + "--file", + context.dockerfile, + "--tag", + context.docker_image, + "--label", + f"{image_label}={recipe_digest(context)}", + "--build-arg", + f"MODULUS_TOOLS={context.tools_rel}", + ".", + ], + ) + +def ensure_build_image(context): + if docker_image_available(context): + return 0 + return build_image(context) + +def docker_run(context, command, docker_args=None, env_overrides=None): + status = ensure_build_image(context) + if status != 0: + return status + resolved_env_overrides = dict(env_overrides or {}) + operation = pathlib.Path(command[0]).stem + publishing = operation == "publish" or (operation == "package" and "--no-remote" not in command and (modulus.publishing.should_upload(context) or modulus.publishing.should_upload_artifacts(context))) + docker_command = [ + "docker", + "run", + "--rm", + "--interactive", + *docker_platform_args(), + "--user", + f"{os.getuid()}:{os.getgid()}", + "--env", + "HOME=/tmp", + *docker_socket_args(context, operation), + ] + if docker_args: + docker_command.extend(docker_args) + for name in docker_passthrough_env(context, publishing): + if name in os.environ: + docker_command.extend(["--env", name]) + if resolved_env_overrides: + for name in resolved_env_overrides: + docker_command.extend(["--env", name]) + if modulus.process.in_container(): + container_name = modulus.process.current_container_name() + if not container_name: + raise SystemExit("HOSTNAME is required to share the CI job workspace with the build container") + docker_command.extend([ + "--volumes-from", + container_name, + ]) + else: + docker_command.extend([ + "--volume", + f"{context.repo_root}:{context.docker_workdir}", + ]) + docker_command.extend([ + "--workdir", + context.docker_workdir, + context.docker_image, + *command, + ]) + return modulus.process.run(context, docker_command, env_overrides=resolved_env_overrides) + +def docker_publish_args(context): + args = [] + for port in context.run_settings.get("ports", []): + args.extend(["--publish", port]) + return args + +image_label = "org.khodo.modulus.dockerfile" + +def recipe_digest(context): + digest = hashlib.sha256(context.dockerfile.read_bytes()) + for path in getattr(context, "docker_inputs", [context.dockerfile.parent / "format_requirements.txt"]): + digest.update(path.read_bytes()) + return digest.hexdigest() + +def current_image(context, architecture=None): + try: + result = subprocess.run( + ["docker", "image", "inspect", context.docker_image], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False, + ) + if result.returncode != 0: + return None + image = json.loads(result.stdout)[0] + labels = image.get("Config", {}).get("Labels") or {} + if labels.get(image_label) != recipe_digest(context): + return None + if architecture and image["Architecture"] != architecture: + return None + return image["Id"] if re.fullmatch(r"sha256:[0-9a-f]{64}", image["Id"]) else None + except (OSError, ValueError, KeyError, IndexError, TypeError): + return None + +def isolated_run(image): + return [ + "docker", "run", "--rm", "--init", "--network", "none", "--read-only", + "--cap-drop=ALL", "--security-opt=no-new-privileges", + "--tmpfs", "/tmp:rw,nosuid,nodev", "--user", f"{os.getuid()}:{os.getgid()}", + "--env", "HOME=/tmp", + ] diff --git a/modulus/formatting.py b/modulus/formatting.py index d08c7d9..d142c89 100644 --- a/modulus/formatting.py +++ b/modulus/formatting.py @@ -1,4 +1,3 @@ -"""Shared batch/check and stdout-clean editor formatting transport.""" import argparse import fnmatch import os @@ -6,7 +5,7 @@ import pathlib import subprocess import sys -import modulus.clangd +import modulus.container import modulus.format_layout suffixes = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".py"} @@ -77,12 +76,12 @@ def launch(context, argv): try: if context.docker_marker.exists() or os.environ.get("MODULUS_DIRECT") == "1": return local_format(context, args) - image = modulus.clangd.current_image(context) + image = modulus.container.current_image(context) if image is None: raise RuntimeError("missing or stale build image; run configure first") root = context.repo_root mode = "ro" if args.check or args.stdin_filename else "rw" - command = [*modulus.clangd.isolated_run(image), "--interactive", "--volume", f"{root}:{root}:{mode}", "--env", "PYTHONDONTWRITEBYTECODE=1", "--workdir", str(root), image, "python3", str(context.tools_dir / "format.py"), *argv] + command = [*modulus.container.isolated_run(image), "--interactive", "--volume", f"{root}:{root}:{mode}", "--env", "PYTHONDONTWRITEBYTECODE=1", "--workdir", str(root), image, "python3", str(context.tools_dir / "format.py"), *argv] return subprocess.run(command, check=False).returncode except (OSError, ValueError, SyntaxError, ImportError, RuntimeError, subprocess.SubprocessError) as error: detail = error.stderr if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error) diff --git a/modulus/lsp.py b/modulus/lsp.py new file mode 100644 index 0000000..b120ec0 --- /dev/null +++ b/modulus/lsp.py @@ -0,0 +1,103 @@ +import argparse +import pathlib +import shutil + +import modulus.build +import modulus.build_context +import modulus.clangd +import modulus.conan +import modulus.container +import modulus.process +import modulus.python + +def lsp_compile_databases(context): + if not context.lsp_settings["enabled"]: + return [] + return list(context.lsp_settings["compile_databases"]) + +def lsp_database_map(context): + return { + str(database["name"]): database + for database in lsp_compile_databases(context) + } + +def clear_lsp_cmake_cache(build_dir): + cache = build_dir / "CMakeCache.txt" + cmake_files = build_dir / "CMakeFiles" + if cache.exists(): + cache.unlink() + if cmake_files.exists(): + shutil.rmtree(cmake_files) + +def configure_lsp_database(context, database): + modulus.python.interpreter(context, install=True) + build_dir = context.repo_root / str(database["build_dir"]) + source_dir = context.repo_root / str(database.get("source_dir", ".")) + modulus.build_context.validate_build_directory(context.repo_root, source_dir, build_dir) + build_dir.mkdir(parents=True, exist_ok=True) + clear_lsp_cmake_cache(build_dir) + return modulus.process.run( + context, + [ + "cmake", + "-S", + source_dir, + "-B", + build_dir, + "-G", + str(database.get("generator", "Ninja")), + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + *modulus.build.cmake_definition_args(modulus.python.cmake_definitions(context)), + *modulus.build.cmake_definition_args(database.get("cmake_definitions", {})), + ], + env_overrides=dict(database.get("env", {})), + env_unset=list(database.get("unset_env", [])), + ) + +def lsp(context, argv): + if not modulus.process.in_docker(context): + status = modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/lsp.py", + *argv, + ], + ) + return status or modulus.clangd.prepare(context) + database_map = lsp_database_map(context) + parser = argparse.ArgumentParser(description=f"Refresh {context.package['name']} compile databases for clangd.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Configured build target compile database to refresh. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument( + "--database", + action="append", + default=[], + choices=sorted(database_map), + help="Extra LSP compile database to refresh. May be passed more than once. Defaults to all configured LSP databases.", + ) + parser.add_argument("--no-build-targets", action="store_true", help="Skip Modulus build-target compile databases.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes for Modulus build-target compile databases.") + args = parser.parse_args(argv) + if not args.no_build_targets: + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if not args.no_remote: + status = modulus.conan.configure_conan_remote(context) + if status != 0: + return status + for target in context.selected_targets(args.target, all_targets=False): + target.build_dir.mkdir(parents=True, exist_ok=True) + status = modulus.build.configure_one(context, target, args.update, no_remote=args.no_remote) + if status != 0: + return status + database_names = args.database or [str(database["name"]) for database in lsp_compile_databases(context)] + for name in database_names: + status = configure_lsp_database(context, database_map[name]) + if status != 0: + return status + return 0 diff --git a/modulus/packaging.py b/modulus/packaging.py new file mode 100644 index 0000000..7e284f7 --- /dev/null +++ b/modulus/packaging.py @@ -0,0 +1,249 @@ +import argparse +import pathlib +import shutil +import sys + +import modulus.artifacts +import modulus.build +import modulus.conan +import modulus.container +import modulus.process +import modulus.publishing +import modulus.python + +def create_package(context, target, update, no_remote=False): + command = [ + "conan", + "create", + ".", + f"--profile:host={target.profile}", + f"--profile:build={context.default_target.profile}", + f"--build={modulus.publishing.package_name(context)}/*", + *modulus.build.dependency_build_args(context), + "-o", + "&:with_tests=False", + ] + if update: + command.append("--update") + if no_remote: + command.append("--no-remote") + return modulus.process.run(context, command) + +def upload_all_packages(context, remote, force=False): + command = [ + "conan", + "upload", + f"{modulus.publishing.package_name(context)}/latest:*", + "-r", + remote, + "--confirm", + ] + if force: + command.append("--force") + return modulus.process.run(context, command) + +def upload_packages(context, replace=False): + if replace and not modulus.publishing.allow_package_replace(): + print("--replace requires MODULUS_ALLOW_PACKAGE_REPLACE=1", file=sys.stderr) + return 1 + remote = modulus.conan.conan_remote(context) + try: + status = modulus.conan.configure_conan_remote(context, authenticate=True) + if status == 0: + status = upload_all_packages(context, remote, force=replace) + finally: + logout_status = modulus.process.run(context, ["conan", "remote", "logout", remote]) + status = status or logout_status + if status != 0 and not replace: + print(f"upload failed for {modulus.publishing.package_name(context)}/latest. Use --replace with MODULUS_ALLOW_PACKAGE_REPLACE=1 to intentionally replace an existing version.", file=sys.stderr) + return status + +def package_artifact_conan_option_args(context): + result = [] + for name, value in context.artifact["conan_options"].items(): + result.extend(["-o", f"&:{name}={modulus.build.option_value(value)}"]) + return result + +def package_artifact_cmake_definition_args(context): + return modulus.build.cmake_definition_args(context.artifact["cmake_definitions"]) + +def package_artifact_targets(context, names, all_targets): + if all_targets or names: + return context.selected_targets(names, all_targets) + configured = context.artifact["targets"] + if configured: + return [context.target(name) for name in configured] + return [context.default_target] + +def package_artifact_build_root(context, target): + return context.common_dir / "artifacts" / target.name + +def package_artifact_dist_dir(context): + return context.build_root / "packages" + +def package_artifact_install_prefix(context, stage_root): + install_prefix = pathlib.Path(str(context.artifact["install_prefix"])) + if str(install_prefix) == ".": + return stage_root + return stage_root / install_prefix + +def create_artifact_package(context, target, update, no_remote=False): + modulus.python.interpreter(context, install=True, offline=no_remote) + root = package_artifact_build_root(context, target) + build_dir = root / "build" + stage_root = root / "stage" + conan_dir = build_dir / "conan" + if root.exists(): + shutil.rmtree(root) + build_dir.mkdir(parents=True) + stage_root.mkdir(parents=True) + install_prefix = package_artifact_install_prefix(context, stage_root) + install_prefix.mkdir(parents=True, exist_ok=True) + conan_command = [ + "conan", + "install", + ".", + f"--output-folder={conan_dir}", + f"--profile:host={target.profile}", + f"--profile:build={context.default_target.profile}", + *modulus.build.dependency_build_args(context), + "-o", + "&:with_tests=False", + *package_artifact_conan_option_args(context), + ] + if update: + conan_command.append("--update") + if no_remote: + conan_command.append("--no-remote") + status = modulus.process.run(context, conan_command) + if status != 0: + return status, [] + status = modulus.process.run( + context, + [ + "cmake", + "-S", + ".", + "-B", + build_dir, + "-G", + "Ninja", + f"-DCMAKE_TOOLCHAIN_FILE={conan_dir / 'conan_toolchain.cmake'}", + f"-DCMAKE_BUILD_TYPE={target.build_type}", + "-DBUILD_TESTING=OFF", + f"-DCMAKE_PREFIX_PATH={conan_dir}", + f"-DCMAKE_INSTALL_PREFIX={install_prefix}", + *modulus.build.cmake_definition_args(modulus.python.cmake_definitions(context)), + *package_artifact_cmake_definition_args(context), + ], + ) + if status != 0: + return status, [] + status = modulus.process.run( + context, + [ + "cmake", + "--build", + build_dir, + "--parallel", + "2", + ], + ) + if status != 0: + return status, [] + status = modulus.process.run( + context, + [ + "cmake", + "--install", + build_dir, + ], + ) + if status != 0: + return status, [] + dist_dir = package_artifact_dist_dir(context) + dist_dir.mkdir(parents=True, exist_ok=True) + archive = dist_dir / modulus.artifacts.archive_name(modulus.publishing.package_name(context), target.name) + if archive.exists(): + archive.unlink() + modulus.artifacts.create_archive(stage_root, archive) + checksum = modulus.artifacts.write_checksum(archive) + print(f"Created {archive}") + print(f"Created {checksum}") + return 0, [archive, checksum] + +def package_artifacts(context, argv): + if not modulus.process.in_docker(context): + return modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/package.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Create {context.package['name']} application tarballs.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to package. May be passed more than once. Defaults to package.artifact.targets.", + ) + parser.add_argument("--all", action="store_true", help="Package all supported targets.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes or upload package artifacts.") + args = parser.parse_args(argv) + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if modulus.publishing.should_upload_artifacts(context) and not args.no_remote and modulus.publishing.package_registry_credentials() is None: + return 1 + if not args.no_remote: + status = modulus.conan.configure_conan_remote(context) + if status != 0: + return status + artifacts = [] + for target in package_artifact_targets(context, args.target, args.all): + status, created = create_artifact_package(context, target, args.update, no_remote=args.no_remote) + if status != 0: + return status + artifacts.extend(created) + if modulus.publishing.should_upload_artifacts(context) and not args.no_remote: + return modulus.artifacts.main(context, []) + return 0 + +def package(context, argv): + if modulus.publishing.artifact_packaging_enabled(context): + return package_artifacts(context, argv) + if not modulus.process.in_docker(context): + return modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/package.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Create {context.package['name']} Conan packages.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to package. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument("--all", action="store_true", help="Package all supported targets.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes. Requires all dependencies to be available locally or buildable from local recipes.") + parser.add_argument("--replace", action="store_true", help="Delete the remote package version before upload. Requires MODULUS_ALLOW_PACKAGE_REPLACE=1.") + args = parser.parse_args(argv) + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if not args.no_remote: + status = modulus.conan.configure_conan_remote(context) + if status != 0: + return status + update = args.update + for target in context.selected_targets(args.target, args.all): + status = create_package(context, target, update, no_remote=args.no_remote) + if status != 0: + return status + if modulus.publishing.should_upload(context) and not args.no_remote: + return upload_packages(context, replace=args.replace) + return 0 diff --git a/modulus/process.py b/modulus/process.py new file mode 100644 index 0000000..30d572a --- /dev/null +++ b/modulus/process.py @@ -0,0 +1,64 @@ +import os +import pathlib +import shlex +import subprocess +import sys + +def in_docker(context): + return direct_mode() or context.docker_marker.exists() + +def env_flag(name): + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + +def direct_mode(): + return env_flag("MODULUS_DIRECT") + +def in_container(): + return pathlib.Path("/.dockerenv").exists() + +def current_container_name(): + return os.environ.get("HOSTNAME", "").strip() + +def subprocess_environment(context, command): + env = os.environ.copy() + env["CONAN_HOME"] = str(context.conan_cache_dir) + if str(command[0]) == "conan": + for name in list(env): + if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")): + del env[name] + env["CONAN_NON_INTERACTIVE"] = "1" + env["MODULUS_PYTHON_COMMON_DIR"] = str(context.common_dir) + env["MODULUS_PYTHON_OFFLINE"] = "1" if "--no-remote" in command else "0" + return env + +def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_command=None): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + env = subprocess_environment(context, command) + for name in env_unset or (): + env.pop(name, None) + if env_overrides: + env.update(env_overrides) + return subprocess.run([str(part) for part in command], cwd=cwd or context.repo_root, check=False, env=env).returncode + +def run_capture(context, command, cwd=None, env_overrides=None, display_command=None): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + env = subprocess_environment(context, command) + if env_overrides: + env.update(env_overrides) + completed = subprocess.run( + [str(part) for part in command], + cwd=cwd or context.repo_root, + check=False, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + if completed.stdout: + print(completed.stdout, end="") + if completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + return completed diff --git a/modulus/publishing.py b/modulus/publishing.py new file mode 100644 index 0000000..98abf3b --- /dev/null +++ b/modulus/publishing.py @@ -0,0 +1,30 @@ +import os +import sys + +import modulus.process + +def package_name(context): + return context.package["name"] + +def allow_package_replace(): + return modulus.process.env_flag("MODULUS_ALLOW_PACKAGE_REPLACE") + +def should_upload(context): + return modulus.process.env_flag(context.upload_env) + +def artifact_packaging_enabled(context): + return bool(context.artifact["enabled"]) + +def should_upload_artifacts(context): + return modulus.process.env_flag(context.package_upload_env) + +def package_registry_credentials(): + user = os.environ.get("PACKAGE_REGISTRY_USER") + token = os.environ.get("PACKAGE_REGISTRY_TOKEN") + if bool(user) != bool(token): + print("PACKAGE_REGISTRY_USER and PACKAGE_REGISTRY_TOKEN must either both be set or both be unset", file=sys.stderr) + return None + if not user or not token: + print("PACKAGE_REGISTRY_USER and PACKAGE_REGISTRY_TOKEN are required to upload package artifacts", file=sys.stderr) + return None + return user, token diff --git a/modulus/run.py b/modulus/run.py new file mode 100644 index 0000000..bef435e --- /dev/null +++ b/modulus/run.py @@ -0,0 +1,57 @@ +import argparse +import pathlib +import sys + +import modulus.container +import modulus.process +import modulus.publishing + +def product_run(context, argv): + if not context.run_settings["enabled"]: + print(f"{modulus.publishing.package_name(context)} does not enable the Modulus run command", file=sys.stderr) + return 1 + if not modulus.process.in_docker(context): + return modulus.container.docker_run( + context, + [ + f"./{context.tools_rel}/run.py", + *argv, + ], + docker_args=modulus.container.docker_publish_args(context), + ) + parser = argparse.ArgumentParser(description=f"Run {context.package['name']} from a configured build target.") + parser.add_argument( + "--target", + default=str(context.run_settings["target"]), + choices=sorted(context.targets), + help="Configured target to run. Defaults to tooling.run.target.", + ) + parser.add_argument( + "args", + nargs=argparse.REMAINDER, + help="Arguments passed to the product executable. A leading -- separator is optional.", + ) + args = parser.parse_args(argv) + target = context.target(args.target) + if not target.can_run: + print(f"Target {target.name} is not runnable in this environment", file=sys.stderr) + return 1 + executable = pathlib.Path(str(context.run_settings["executable"])) + if not executable.is_absolute(): + executable = target.build_dir / executable + if not executable.is_file(): + print(f"Executable was not found: {executable}. Run python3 modulus.py build --target {target.name} first.", file=sys.stderr) + return 1 + command_args = list(context.run_settings["args"]) + extra_args = list(args.args) + if extra_args and extra_args[0] == "--": + extra_args = extra_args[1:] + command_args.extend(extra_args) + return modulus.process.run( + context, + [ + executable, + *command_args, + ], + env_overrides=dict(context.run_settings["env"]), + ) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index c3c2299..83a6405 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,3 +1,4 @@ +import modulus.build_context import os import pathlib import tempfile @@ -6,7 +7,6 @@ import unittest.mock import urllib.error import modulus.artifacts -import modulus.cli import modulus.config class ArtifactTests(unittest.TestCase): @@ -15,7 +15,7 @@ class ArtifactTests(unittest.TestCase): self.addCleanup(self.temporary.cleanup) self.root = pathlib.Path(self.temporary.name) config = modulus.config.normalize({'package': {'name': 'example', 'artifact': {'enabled': True, 'targets': ['arm_64_gcc12_release']}}}) - self.context = modulus.cli.build_context(self.root, config) + self.context = modulus.build_context.build_context(self.root, config) self.directory = self.root / 'build/packages' self.directory.mkdir(parents=True) self.archive = self.directory / modulus.artifacts.archive_name('example', 'arm_64_gcc12_release') diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2a0c9b6..3732f26 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,3 +1,8 @@ +import modulus.conan +import modulus.build_context +import modulus.container +import modulus.packaging +import modulus.process import ast import json import os @@ -31,9 +36,9 @@ class BootstrapTests(unittest.TestCase): def test_read_environments_strip_all_credential_forms(self): credentials = dict.fromkeys(('CONAN_USER', 'CONAN_TOKEN', 'CONAN_LOGIN_USERNAME', 'CONAN_PASSWORD', 'CONAN_LOGIN_USERNAME_KHODO', 'CONAN_PASSWORD_KHODO'), 'secret') root = Path('/tmp/project') - context = modulus.cli.build_context(root, modulus.config.normalize({'package': {'name': 'sample'}})) + context = modulus.build_context.build_context(root, modulus.config.normalize({'package': {'name': 'sample'}})) with patch.dict(os.environ, credentials): - for env in (modulus.bootstrap.fetch_env(root), modulus.cli.subprocess_environment(context, ['conan', 'install'])): + for env in (modulus.bootstrap.fetch_env(root), modulus.process.subprocess_environment(context, ['conan', 'install'])): self.assertFalse(set(credentials) & env.keys()) self.assertEqual(env['CONAN_NON_INTERACTIVE'], '1') self.assertEqual(env['CONAN_HOME'], str(root / 'build/build_common/conan_cache')) @@ -100,26 +105,26 @@ class BootstrapTests(unittest.TestCase): modulus.export.sync_project(root, package) self.assertEqual(runtime.read_text(), 'old runtime') def test_upload_exception_still_logs_out(self): - context = modulus.cli.build_context(Path('/tmp/project'), modulus.config.normalize({'package': {'name': 'sample'}})) - with patch.object(modulus.cli, 'configure_conan_remote', return_value=0), patch.object(modulus.cli, 'upload_all_packages', side_effect=RuntimeError('upload failed')), patch.object(modulus.cli, 'run', return_value=0) as run: + context = modulus.build_context.build_context(Path('/tmp/project'), modulus.config.normalize({'package': {'name': 'sample'}})) + with patch.object(modulus.conan, 'configure_conan_remote', return_value=0), patch.object(modulus.packaging, 'upload_all_packages', side_effect=RuntimeError('upload failed')), patch.object(modulus.process, 'run', return_value=0) as run: with self.assertRaisesRegex(RuntimeError, 'upload failed'): - modulus.cli.upload_packages(context) + modulus.packaging.upload_packages(context) run.assert_called_once_with(context, ['conan', 'remote', 'logout', 'khodo']) def test_socket_is_scoped_and_docker_inputs_change_fingerprint(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) config = modulus.config.normalize({'package': {'name': 'sample'}, 'tooling': {'docker': {'dockerfile': 'package/build/Dockerfile', 'inputs': ['requirements.txt'], 'socket_commands': ['build', 'package']}}}) - context = modulus.cli.build_context(root, config) + context = modulus.build_context.build_context(root, config) context.dockerfile.parent.mkdir(parents=True) context.dockerfile.write_text('FROM scratch') context.docker_inputs[0].parent.mkdir(parents=True) for path in context.docker_inputs: path.write_text('old') - before = modulus.clangd.recipe_digest(context) + before = modulus.container.recipe_digest(context) context.docker_inputs[-1].write_text('new') - self.assertNotEqual(before, modulus.clangd.recipe_digest(context)) + self.assertNotEqual(before, modulus.container.recipe_digest(context)) with patch.object(Path, 'is_socket', return_value=True), patch.object(Path, 'stat') as stat: stat.return_value.st_gid = 123 for command in ('configure', 'test', 'lsp', 'clangd', 'format'): - self.assertEqual(modulus.cli.docker_socket_args(context, command), []) - self.assertIn('/var/run/docker.sock:/var/run/docker.sock', modulus.cli.docker_socket_args(context, 'package')) + self.assertEqual(modulus.container.docker_socket_args(context, command), []) + self.assertIn('/var/run/docker.sock:/var/run/docker.sock', modulus.container.docker_socket_args(context, 'package')) diff --git a/tests/test_clangd.py b/tests/test_clangd.py index 4520d7a..505e71a 100644 --- a/tests/test_clangd.py +++ b/tests/test_clangd.py @@ -1,3 +1,7 @@ +import modulus.process +import modulus.build_context +import modulus.container +import modulus.lsp import contextlib import io import json @@ -19,7 +23,7 @@ class clangd_test(unittest.TestCase): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = pathlib.Path(temporary.name) - self.context = modulus.cli.build_context(self.root, modulus.config.normalize({"package": {"name": "sample"}})) + self.context = modulus.build_context.build_context(self.root, modulus.config.normalize({"package": {"name": "sample"}})) self.context.docker_marker = self.root / "not-in-container" self.context.dockerfile.parent.mkdir(parents=True) self.context.dockerfile.write_text("FROM sample\n") @@ -39,15 +43,15 @@ class clangd_test(unittest.TestCase): with self.subTest(invalid=invalid), self.assertRaises(modulus.config.config_error): modulus.config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": invalid}}}) def test_image_fingerprint_and_architecture(self): - image = {"Id": self.image, "Architecture": "amd64", "Config": {"Labels": {modulus.clangd.image_label: modulus.clangd.recipe_digest(self.context)}}} + image = {"Id": self.image, "Architecture": "amd64", "Config": {"Labels": {modulus.container.image_label: modulus.container.recipe_digest(self.context)}}} with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, json.dumps([image]))): - self.assertEqual(modulus.clangd.current_image(self.context), self.image) - self.assertIsNone(modulus.clangd.current_image(self.context, "arm64")) + self.assertEqual(modulus.container.current_image(self.context), self.image) + self.assertIsNone(modulus.container.current_image(self.context, "arm64")) self.context.dockerfile.write_text("FROM changed\n") - self.assertIsNone(modulus.clangd.current_image(self.context)) + self.assertIsNone(modulus.container.current_image(self.context)) def test_cache_is_shared_by_image_and_header_settings_not_repo(self): cache = modulus.clangd.cache_directory(self.context, self.image) - other = modulus.cli.build_context(self.root / "other", self.context.config) + other = modulus.build_context.build_context(self.root / "other", self.context.config) self.assertEqual(cache, modulus.clangd.cache_directory(other, self.image)) self.context.lsp_settings["clangd"]["arguments"].append("--log=error") self.assertEqual(cache, modulus.clangd.cache_directory(self.context, self.image)) @@ -69,8 +73,8 @@ class clangd_test(unittest.TestCase): self.assertIn("--query-driver=/usr/bin/gcc-12,/usr/bin/g++-12,/usr/bin/aarch64-linux-gnu-gcc-12,/usr/bin/aarch64-linux-gnu-g++-12", command) def test_startup_failure_does_not_prepare_and_keeps_stdout_clean(self): output, errors = io.StringIO(), io.StringIO() - with patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.clangd, "prepare") as prepare, \ - patch.object(modulus.cli, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + with patch.object(modulus.container, "current_image", return_value=None), patch.object(modulus.clangd, "prepare") as prepare, \ + patch.object(modulus.container, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): self.assertEqual(modulus.clangd.launch(self.context, []), 1) prepare.assert_not_called() build.assert_not_called() @@ -100,7 +104,7 @@ class clangd_test(unittest.TestCase): cache = self.root / "cache" (cache / "rootfs/usr/include").mkdir(parents=True) (cache / "manifest.json").write_text(json.dumps({"version": modulus.clangd.cache_version, "roots": ["/usr/include"]})) - with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.clangd, "current_image", return_value=self.image), \ + with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.container, "current_image", return_value=self.image), \ patch.object(subprocess, "run") as run: self.assertEqual(modulus.clangd.prepare(self.context), 0) run.assert_not_called() @@ -109,7 +113,7 @@ class clangd_test(unittest.TestCase): process = MagicMock() process.__enter__.return_value = process process.stdout = self.archive("../escape") - with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.clangd, "current_image", return_value=self.image), \ + with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.container, "current_image", return_value=self.image), \ patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '["/usr/include"]')), \ patch.object(subprocess, "Popen", return_value=process), contextlib.redirect_stderr(io.StringIO()): self.assertEqual(modulus.clangd.prepare(self.context), 1) @@ -138,13 +142,13 @@ class clangd_test(unittest.TestCase): modulus.export.sync_nvim_file(self.root, {"enabled": False}) self.assertEqual(path.read_text(), "-- user settings\n") def test_lsp_prepares_after_successful_database_refresh_only(self): - with patch.object(modulus.cli, "in_docker", return_value=False), patch.object(modulus.cli, "docker_run", return_value=0) as run, \ + with patch.object(modulus.process, "in_docker", return_value=False), patch.object(modulus.container, "docker_run", return_value=0) as run, \ patch.object(modulus.clangd, "prepare", return_value=0) as prepare: - self.assertEqual(modulus.cli.lsp(self.context, []), 0) + self.assertEqual(modulus.lsp.lsp(self.context, []), 0) prepare.assert_called_once_with(self.context) prepare.reset_mock() run.return_value = 1 - self.assertEqual(modulus.cli.lsp(self.context, []), 1) + self.assertEqual(modulus.lsp.lsp(self.context, []), 1) prepare.assert_not_called() if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 1b847b7..7c4e3c5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,12 @@ +import modulus.process +import modulus.build +import modulus.build_context +import modulus.conan +import modulus.container +import modulus.export +import modulus.lsp +import modulus.packaging +import modulus.run import json import os import pathlib @@ -58,7 +67,7 @@ class upload_packages_test(unittest.TestCase): "name": "sample", }, } - return modulus.cli.build_context(repo_root or pathlib.Path(os.getcwd()), modulus.config.normalize(config)) + return modulus.build_context.build_context(repo_root or pathlib.Path(os.getcwd()), modulus.config.normalize(config)) def test_cli_import_does_not_import_conan_recipe_module(self): repo = pathlib.Path(__file__).resolve().parents[1] command = [ @@ -68,13 +77,17 @@ class upload_packages_test(unittest.TestCase): ] completed = subprocess.run(command, cwd=repo, check=False) self.assertEqual(completed.returncode, 0) + def test_build_context_does_not_import_command_or_execution_modules(self): + repo = pathlib.Path(__file__).resolve().parents[1] + command = [sys.executable, "-c", "import sys; import modulus.build_context; forbidden = {'modulus.cli', 'modulus.build', 'modulus.lsp', 'modulus.container', 'modulus.packaging', 'modulus.process'}; raise SystemExit(bool(forbidden & sys.modules.keys()))"] + self.assertEqual(subprocess.run(command, cwd=repo, check=False).returncode, 0) def test_project_root_build_is_rejected(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) context = self.context(root) context.config["tooling"]["targets"]["native"]["build_dir"] = "." with self.assertRaisesRegex(modulus.config.config_error, "project and source roots"): - modulus.cli.build_context(root, context.config) + modulus.build_context.build_context(root, context.config) def test_configure_and_lsp_refresh_project_editor_settings(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) @@ -84,9 +97,9 @@ class upload_packages_test(unittest.TestCase): ] config = {"package": {"name": "sample"}, "tooling": {"editor": {"clangd": fragments}}} (root / "modulus.json").write_text(json.dumps(config), encoding="utf-8") - with unittest.mock.patch.object(modulus.cli, "find_repo_root", return_value=root): + with unittest.mock.patch.object(modulus.build_context, "find_repo_root", return_value=root): for command in ("configure", "lsp"): - with self.subTest(command=command), unittest.mock.patch.object(modulus.cli, command, return_value=0): + with self.subTest(command=command), unittest.mock.patch.object({'configure': modulus.build, 'lsp': modulus.lsp}[command], command, return_value=0): (root / ".clangd").write_text("stale", encoding="utf-8") self.assertEqual(modulus.cli.main(command), 0) content = (root / ".clangd").read_text(encoding="utf-8").split("\n", 1)[1] @@ -100,36 +113,36 @@ class upload_packages_test(unittest.TestCase): context = self.context(root) cache = root / "CMakeCache.txt" cache.write_text("keep", encoding="utf-8") - with unittest.mock.patch.object(modulus.cli, "clear_lsp_cmake_cache") as clear: + with unittest.mock.patch.object(modulus.lsp, "clear_lsp_cmake_cache") as clear: for build_dir in (".", "alias", "subproject"): if build_dir == "alias": (root / "alias").symlink_to(root, target_is_directory=True) with self.subTest(build_dir=build_dir), self.assertRaises(modulus.config.config_error): - modulus.cli.configure_lsp_database(context, {"source_dir": "subproject", "build_dir": build_dir}) + modulus.lsp.configure_lsp_database(context, {"source_dir": "subproject", "build_dir": build_dir}) clear.assert_not_called() self.assertEqual(cache.read_text(encoding="utf-8"), "keep") - @unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=37) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.packaging, "upload_all_packages", return_value=37) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_failed_upload_does_not_force_by_default(self, run, configure, upload): context = self.context() with unittest.mock.patch.dict(os.environ, {}, clear=True): - status = modulus.cli.upload_packages(context) + status = modulus.packaging.upload_packages(context) self.assertEqual(status, 37) configure.assert_called_once_with(context, authenticate=True) upload.assert_called_once_with(context, "origin", force=False) run.assert_called_once_with(context, ["conan", "remote", "logout", "origin"]) - @unittest.mock.patch.object(modulus.cli, "upload_all_packages") - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote") + @unittest.mock.patch.object(modulus.packaging, "upload_all_packages") + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote") def test_replace_requires_guard_env(self, configure, upload): with unittest.mock.patch.dict(os.environ, {}, clear=True): - status = modulus.cli.upload_packages(self.context(), replace=True) + status = modulus.packaging.upload_packages(self.context(), replace=True) self.assertEqual(status, 1) configure.assert_not_called() upload.assert_not_called() - @unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.packaging, "upload_all_packages", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_replace_forces_upload_when_guarded(self, run, configure, upload): context = self.context() with unittest.mock.patch.dict( @@ -139,20 +152,20 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.upload_packages(context, replace=True) + status = modulus.packaging.upload_packages(context, replace=True) self.assertEqual(status, 0) configure.assert_called_once_with(context, authenticate=True) upload.assert_called_once_with(context, "origin", force=True) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_upload_defaults_to_latest_reference(self, run): with unittest.mock.patch.dict(os.environ, {}, clear=True): - status = modulus.cli.upload_all_packages(self.context(), "origin") + status = modulus.packaging.upload_all_packages(self.context(), "origin") self.assertEqual(status, 0) self.assertIn("sample/latest:*", run.call_args.args[1]) self.assertNotIn("--force", run.call_args.args[1]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_replace_upload_uses_conan_force(self, run): - status = modulus.cli.upload_all_packages(self.context(), "origin", force=True) + status = modulus.packaging.upload_all_packages(self.context(), "origin", force=True) self.assertEqual(status, 0) self.assertIn("--force", run.call_args.args[1]) def test_checked_in_ci_overwrites_latest_on_master(self): @@ -187,11 +200,11 @@ class upload_packages_test(unittest.TestCase): finally: os.chdir(previous_cwd) def test_docker_passthrough_includes_replace_env(self): - passthrough = modulus.cli.docker_passthrough_env(self.context()) + passthrough = modulus.container.docker_passthrough_env(self.context()) self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", passthrough) for name in ("CONAN_USER", "CONAN_TOKEN", "PACKAGE_REGISTRY_USER", "PACKAGE_REGISTRY_TOKEN"): self.assertNotIn(name, passthrough) - self.assertIn(name, modulus.cli.docker_passthrough_env(self.context(), publishing=True)) + self.assertIn(name, modulus.container.docker_passthrough_env(self.context(), publishing=True)) self.assertIn("SAMPLE_PACKAGE_UPLOAD", passthrough) self.assertNotIn("CONAN_PACKAGE_VERSION", passthrough) self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough) @@ -228,13 +241,13 @@ class upload_packages_test(unittest.TestCase): }, }, }) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_run_is_disabled_by_default(self, docker): - status = modulus.cli.product_run(self.context(), []) + status = modulus.run.product_run(self.context(), []) self.assertEqual(status, 1) docker.assert_not_called() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_run_executes_configured_product_executable(self, docker, run): with tempfile.TemporaryDirectory() as repo_dir: repo = pathlib.Path(repo_dir) @@ -251,7 +264,7 @@ class upload_packages_test(unittest.TestCase): context.default_target.build_dir.mkdir(parents=True) executable = context.default_target.build_dir / "sample-app" executable.write_text("#!/bin/sh\n", encoding="utf-8") - status = modulus.cli.product_run( + status = modulus.run.product_run( context, [ "--", @@ -271,15 +284,15 @@ class upload_packages_test(unittest.TestCase): ) self.assertEqual(run.call_args.kwargs["env_overrides"], {"SAMPLE_WEB_ROOT": "build/web"}) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "docker_run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=False) + @unittest.mock.patch.object(modulus.container, "docker_run", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=False) def test_run_publishes_configured_ports_when_wrapped_by_docker(self, docker, docker_run): context = self.context() context.run_settings.update({ "enabled": True, "ports": ["127.0.0.1:8000:8000/tcp"], }) - status = modulus.cli.product_run(context, ["--target", "native"]) + status = modulus.run.product_run(context, ["--target", "native"]) self.assertEqual(status, 0) docker_run.assert_called_once_with( context, @@ -294,7 +307,7 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_build_image_honors_docker_platform_env(self, run): context = self.context() with unittest.mock.patch.dict( @@ -304,7 +317,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.build_image(context) + status = modulus.container.build_image(context) self.assertEqual(status, 0) self.assertEqual( run.call_args.args[1], @@ -318,15 +331,15 @@ class upload_packages_test(unittest.TestCase): "--tag", "sample-build:dev", "--label", - f"{modulus.cli.modulus.clangd.image_label}={modulus.cli.modulus.clangd.recipe_digest(context)}", + f"{modulus.cli.modulus.container.image_label}={modulus.cli.modulus.container.recipe_digest(context)}", "--build-arg", "MODULUS_TOOLS=tools", ".", ], ) - @unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_container", return_value=False) + @unittest.mock.patch.object(modulus.container, "ensure_build_image", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_container", return_value=False) def test_docker_run_honors_docker_platform_env(self, in_container, run, ensure_image): context = self.context() with unittest.mock.patch.dict( @@ -336,7 +349,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.docker_run(context, ["true"]) + status = modulus.container.docker_run(context, ["true"]) self.assertEqual(status, 0) self.assertIn("--platform", run.call_args.args[1]) self.assertIn("linux/arm64", run.call_args.args[1]) @@ -344,17 +357,17 @@ class upload_packages_test(unittest.TestCase): in_container.assert_called_once() def test_conan_remote_url_uses_config_by_default(self): with unittest.mock.patch.dict(os.environ, {}, clear=True): - self.assertEqual(modulus.cli.conan_remote_url(self.context()), "https://example.test/conan") - @unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_container", return_value=False) + self.assertEqual(modulus.conan.conan_remote_url(self.context()), "https://example.test/conan") + @unittest.mock.patch.object(modulus.container, "ensure_build_image", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_container", return_value=False) def test_publish_forwards_credentials_but_python_does_not(self, in_container, run, ensure_image): context = self.context() with unittest.mock.patch.dict(os.environ, {"PACKAGE_REGISTRY_TOKEN": "secret"}, clear=True): - modulus.cli.docker_run(context, ["tools/publish.py"]) + modulus.container.docker_run(context, ["tools/publish.py"]) self.assertIn("PACKAGE_REGISTRY_TOKEN", run.call_args.args[1]) self.assertNotIn("secret", run.call_args.args[1]) - modulus.cli.docker_run(context, ["tools/python.py", "-m", "generator"]) + modulus.container.docker_run(context, ["tools/python.py", "-m", "generator"]) self.assertNotIn("PACKAGE_REGISTRY_TOKEN", run.call_args.args[1]) def test_conan_remote_url_uses_config_in_ci(self): with unittest.mock.patch.dict( @@ -364,7 +377,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - self.assertEqual(modulus.cli.conan_remote_url(self.context()), "https://example.test/conan") + self.assertEqual(modulus.conan.conan_remote_url(self.context()), "https://example.test/conan") def test_conan_credentials_only_uses_conan_environment(self): with unittest.mock.patch.dict( os.environ, @@ -375,13 +388,13 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - user, token = modulus.cli.conan_credentials() + user, token = modulus.conan.conan_credentials() self.assertEqual(user, "dev") self.assertEqual(token, "secret") - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_remote_skips_login_without_conan_credentials(self, run): with unittest.mock.patch.dict(os.environ, {}, clear=True): - status = modulus.cli.configure_conan_remote(self.context()) + status = modulus.conan.configure_conan_remote(self.context()) self.assertEqual(status, 0) self.assertEqual(len(run.call_args_list), 2) self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"]) @@ -396,13 +409,13 @@ class upload_packages_test(unittest.TestCase): "https://example.test/conan", ], ) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_remote_ignores_partial_conan_credentials(self, run): with unittest.mock.patch.dict(os.environ, {"CONAN_USER": "dev"}, clear=True): - status = modulus.cli.configure_conan_remote(self.context()) + status = modulus.conan.configure_conan_remote(self.context()) self.assertEqual(status, 0) self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_remote_skips_login_until_authentication_is_requested(self, run): context = self.context() with unittest.mock.patch.dict( @@ -413,7 +426,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.configure_conan_remote(context) + status = modulus.conan.configure_conan_remote(context) self.assertEqual(status, 0) self.assertEqual(len(run.call_args_list), 2) self.assertEqual( @@ -427,7 +440,7 @@ class upload_packages_test(unittest.TestCase): "https://example.test/conan", ], ) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_remote_logs_in_with_hidden_token_when_authenticating(self, run): context = self.context() with unittest.mock.patch.dict( @@ -438,7 +451,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.configure_conan_remote(context, authenticate=True) + status = modulus.conan.configure_conan_remote(context, authenticate=True) self.assertEqual(status, 0) self.assertEqual( run.call_args_list[0].args[1], @@ -526,7 +539,7 @@ class upload_packages_test(unittest.TestCase): "name": "sample", }, } - return modulus.cli.build_context(pathlib.Path(os.getcwd()), config) + return modulus.build_context.build_context(pathlib.Path(os.getcwd()), config) def test_selected_targets_default_to_default_target_only(self): context = self.multi_target_context() selected = context.selected_targets( @@ -541,8 +554,8 @@ class upload_packages_test(unittest.TestCase): True, ) self.assertEqual([target.name for target in selected], ["debug", "release"]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_build_can_target_one_cmake_target(self, docker, run): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) @@ -550,7 +563,7 @@ class upload_packages_test(unittest.TestCase): context.default_target.build_dir.mkdir(parents=True, exist_ok=True) (context.default_target.build_dir / "CMakeCache.txt").write_text("", encoding="utf-8") (context.default_target.build_dir / "build.ninja").write_text("", encoding="utf-8") - status = modulus.cli.build( + status = modulus.build.build( context, [ "--target", @@ -573,14 +586,14 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_build_auto_configures_missing_target(self, docker, configure_remote, configure_one, run): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) - status = modulus.cli.build(context, []) + status = modulus.build.build(context, []) self.assertEqual(status, 0) configure_remote.assert_called_once_with(context) configure_one.assert_called_once_with(context, context.default_target, update=False) @@ -595,17 +608,17 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_build_skips_configure_when_target_is_configured(self, docker, configure_remote, configure_one, run): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) context.default_target.build_dir.mkdir(parents=True) (context.default_target.build_dir / "CMakeCache.txt").write_text("", encoding="utf-8") (context.default_target.build_dir / "build.ninja").write_text("", encoding="utf-8") - status = modulus.cli.build(context, []) + status = modulus.build.build(context, []) self.assertEqual(status, 0) configure_remote.assert_not_called() configure_one.assert_not_called() @@ -620,10 +633,10 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_build_reconfigures_when_target_cmake_definition_differs(self, docker, configure_remote, configure_one, run): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) @@ -633,7 +646,7 @@ class upload_packages_test(unittest.TestCase): context.default_target.build_dir.mkdir(parents=True) (context.default_target.build_dir / "CMakeCache.txt").write_text("SAMPLE_WITH_WEB:BOOL=OFF\n", encoding="utf-8") (context.default_target.build_dir / "build.ninja").write_text("", encoding="utf-8") - status = modulus.cli.build(context, []) + status = modulus.build.build(context, []) self.assertEqual(status, 0) configure_remote.assert_called_once_with(context) configure_one.assert_called_once_with(context, context.default_target, update=False) @@ -648,26 +661,26 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=37) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "configure_one", return_value=37) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_build_stops_when_auto_configure_fails(self, docker, configure_remote, configure_one, run): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) - status = modulus.cli.build(context, []) + status = modulus.build.build(context, []) self.assertEqual(status, 37) configure_remote.assert_called_once_with(context) configure_one.assert_called_once_with(context, context.default_target, update=False) run.assert_not_called() docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.build, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_configure_no_remote_skips_remote_setup(self, docker, configure_remote, configure_one): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) - status = modulus.cli.configure( + status = modulus.build.configure( context, [ "--target", @@ -679,10 +692,10 @@ class upload_packages_test(unittest.TestCase): configure_remote.assert_not_called() configure_one.assert_called_once_with(context, context.default_target, False, no_remote=True) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0) - @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.packaging, "upload_packages", return_value=0) + @unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_package_no_remote_skips_remote_setup_and_upload(self, docker, configure_remote, create_package, upload): context = self.context() with unittest.mock.patch.dict( @@ -692,7 +705,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.package( + status = modulus.packaging.package( context, [ "--target", @@ -705,10 +718,10 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once_with(context, context.default_target, False, no_remote=True) upload.assert_not_called() docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0) - @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.packaging, "upload_packages", return_value=0) + @unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_package_configures_remote_once_before_upload(self, docker, configure_remote, create_package, upload): context = self.context() with unittest.mock.patch.dict( @@ -720,7 +733,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.package( + status = modulus.packaging.package( context, [ "--target", @@ -732,10 +745,10 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once_with(context, context.default_target, False, no_remote=False) upload.assert_called_once_with(context, replace=False) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=1) - @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.packaging, "upload_packages", return_value=1) + @unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_package_defers_conan_upload_credentials_until_after_create(self, docker, configure_remote, create_package, upload): context = self.context() with unittest.mock.patch.dict( @@ -745,7 +758,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.package( + status = modulus.packaging.package( context, [ "--target", @@ -757,9 +770,9 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once_with(context, context.default_target, False, no_remote=False) upload.assert_called_once_with(context, replace=False) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_package_checks_credentials_only_at_upload(self, docker, run, create_package): context = self.context() with unittest.mock.patch.dict( @@ -770,7 +783,7 @@ class upload_packages_test(unittest.TestCase): }, clear=True, ): - status = modulus.cli.package( + status = modulus.packaging.package( context, [ "--target", @@ -781,11 +794,11 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once() self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"]) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "clear_conan_deps") + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_one_passes_no_remote_to_conan_install(self, run, clear): context = self.context() - status = modulus.cli.configure_one(context, context.default_target, update=False, no_remote=True) + status = modulus.build.configure_one(context, context.default_target, update=False, no_remote=True) self.assertEqual(status, 0) conan_command = run.call_args_list[0].args[1] self.assertIn("--no-remote", conan_command) @@ -796,8 +809,8 @@ class upload_packages_test(unittest.TestCase): self.assertIn(f"-DCMAKE_TOOLCHAIN_FILE={context.default_target.build_dir / 'conan' / 'conan_toolchain.cmake'}", run.call_args_list[1].args[1]) self.assertIn("-DCMAKE_BUILD_TYPE=Release", run.call_args_list[1].args[1]) clear.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "clear_conan_deps") + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_one_passes_target_cmake_definitions(self, run, clear): context = self.context() context.default_target.cmake_definitions = { @@ -805,17 +818,17 @@ class upload_packages_test(unittest.TestCase): "SAMPLE_MODE": "dev", "SAMPLE_LIMIT": 2, } - status = modulus.cli.configure_one(context, context.default_target, update=False) + status = modulus.build.configure_one(context, context.default_target, update=False) self.assertEqual(status, 0) cmake_command = run.call_args_list[1].args[1] self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command) self.assertIn("-DSAMPLE_MODE=dev", cmake_command) self.assertIn("-DSAMPLE_LIMIT=2", cmake_command) clear.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "configure_lsp_database", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) - @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.lsp, "configure_lsp_database", return_value=0) + @unittest.mock.patch.object(modulus.build, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) def test_lsp_configures_default_target_and_extra_databases(self, docker, configure_remote, configure_one, configure_lsp): context = self.context() database = { @@ -833,18 +846,18 @@ class upload_packages_test(unittest.TestCase): "enabled": True, "compile_databases": [database], } - status = modulus.cli.lsp(context, []) + status = modulus.lsp.lsp(context, []) self.assertEqual(status, 0) configure_remote.assert_called_once_with(context) configure_one.assert_called_once_with(context, context.default_target, False, no_remote=False) configure_lsp.assert_called_once_with(context, database) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_lsp_database_passes_cmake_definitions_and_unsets_env(self, run): with tempfile.TemporaryDirectory() as repo_dir: repo = pathlib.Path(repo_dir) context = self.context(repo_root=repo) - status = modulus.cli.configure_lsp_database( + status = modulus.lsp.configure_lsp_database( context, { "name": "pico", @@ -865,30 +878,30 @@ class upload_packages_test(unittest.TestCase): self.assertIn("-DVAMP_PICO_FIRMWARE_PROJECT=ON", command) self.assertEqual(run.call_args.kwargs["env_overrides"], {"PICO_SDK_PATH": "/opt/pico-sdk"}) self.assertEqual(run.call_args.kwargs["env_unset"], ["CC", "CXX"]) - @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.build, "clear_conan_deps") + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_configure_one_allows_public_repos_to_build_missing_dependencies(self, run, clear): context = self.context() - status = modulus.cli.configure_one(context, context.default_target, update=False) + status = modulus.build.configure_one(context, context.default_target, update=False) self.assertEqual(status, 0) conan_command = run.call_args_list[0].args[1] self.assertIn("--build=missing", conan_command) clear.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_create_package_passes_no_remote_to_conan_create(self, run): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) - status = modulus.cli.create_package(context, context.default_target, update=False, no_remote=True) + status = modulus.packaging.create_package(context, context.default_target, update=False, no_remote=True) self.assertEqual(status, 0) self.assertIn("&:with_tests=False", run.call_args.args[1]) self.assertIn("--build=sample/*", run.call_args.args[1]) self.assertIn("--build=missing", run.call_args.args[1]) self.assertIn("--no-remote", run.call_args.args[1]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.process, "run", return_value=0) def test_create_package_allows_public_repos_to_build_missing_dependencies(self, run): with tempfile.TemporaryDirectory() as repo_dir: context = self.context(repo_root=pathlib.Path(repo_dir)) - status = modulus.cli.create_package(context, context.default_target, update=False) + status = modulus.packaging.create_package(context, context.default_target, update=False) self.assertEqual(status, 0) self.assertIn("--build=sample/*", run.call_args.args[1]) self.assertIn("--build=missing", run.call_args.args[1]) @@ -909,12 +922,12 @@ class upload_packages_test(unittest.TestCase): }) def fake_run(run_context, command, **kwargs): if command[:2] == ["cmake", "--install"]: - install_root = modulus.cli.package_artifact_build_root(context, context.default_target) / "stage" / "usr" + install_root = modulus.packaging.package_artifact_build_root(context, context.default_target) / "stage" / "usr" (install_root / "bin").mkdir(parents=True) (install_root / "bin" / "sample").write_text("binary\n", encoding="utf-8") return 0 - with unittest.mock.patch.object(modulus.cli, "run", side_effect=fake_run) as run: - status, artifacts = modulus.cli.create_artifact_package(context, context.default_target, update=False, no_remote=True) + with unittest.mock.patch.object(modulus.process, "run", side_effect=fake_run) as run: + status, artifacts = modulus.packaging.create_artifact_package(context, context.default_target, update=False, no_remote=True) self.assertEqual(status, 0) archive = repo / "build" / "packages" / "sample_native.tar.gz" checksum = archive.with_name(archive.name + ".sha256") @@ -933,13 +946,13 @@ class upload_packages_test(unittest.TestCase): cmake_command = run.call_args_list[1].args[1] self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command) self.assertIn(f"-DCMAKE_INSTALL_PREFIX={repo / 'build' / 'build_common' / 'artifacts' / 'native' / 'stage' / 'usr'}", cmake_command) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) - @unittest.mock.patch.object(modulus.cli, "create_artifact_package", return_value=(0, [])) + @unittest.mock.patch.object(modulus.process, "in_docker", return_value=True) + @unittest.mock.patch.object(modulus.packaging, "create_artifact_package", return_value=(0, [])) def test_artifact_command_needs_no_release_argument(self, create, docker): with tempfile.TemporaryDirectory() as temporary: context = self.context(repo_root=pathlib.Path(temporary)) context.package["artifact"]["enabled"] = True - self.assertEqual(modulus.cli.package_artifacts(context, ["--no-remote"]), 0) + self.assertEqual(modulus.packaging.package_artifacts(context, ["--no-remote"]), 0) create.assert_called_once_with(context, context.default_target, False, no_remote=True) if __name__ == "__main__": diff --git a/tests/test_formatting.py b/tests/test_formatting.py index eb0cde8..4729863 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,3 +1,6 @@ +import modulus.container +import modulus.build_context +import modulus.clangd import ast import contextlib import io @@ -114,13 +117,13 @@ class formatting_test(unittest.TestCase): def test_stdin_failure_has_no_output_and_never_builds_image(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) - context = modulus.cli.build_context(root, modulus.config.normalize({"package": {"name": "sample"}})) + context = modulus.build_context.build_context(root, modulus.config.normalize({"package": {"name": "sample"}})) context.docker_marker = root / "not-in-container" output, errors = io.StringIO(), io.StringIO() with patch.dict(os.environ, {"MODULUS_DIRECT": "1"}), patch("sys.stdin", io.StringIO("def broken(")), contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): self.assertEqual(modulus.formatting.launch(context, ["--stdin-filename", "test.py"]), 1) self.assertEqual(output.getvalue(), "") - with patch.dict(os.environ, {"MODULUS_DIRECT": "0"}), patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.cli, "ensure_build_image") as build, contextlib.redirect_stderr(errors): + with patch.dict(os.environ, {"MODULUS_DIRECT": "0"}), patch.object(modulus.container, "current_image", return_value=None), patch.object(modulus.container, "ensure_build_image") as build, contextlib.redirect_stderr(errors): self.assertEqual(modulus.formatting.launch(context, ["--check"]), 1) build.assert_not_called() def test_discovery_excludes_generated_and_symlinks(self): @@ -142,7 +145,7 @@ class formatting_test(unittest.TestCase): source = 'def example(name):\n return f"""first {name}\n\nsecond\n\nthird"""\n' self.assertEqual(self.formatted(source, ".py"), source) def test_alias_check_distinguishes_module_aliases_from_saved_callbacks(self): - source = 'import modulus.cli\nsaved = modulus.cli.docker_run\nconfig = modulus.cli\nItems = list[int]\n' + source = 'import modulus.cli\nsaved = modulus.container.docker_run\nconfig = modulus.cli\nItems = list[int]\n' self.assertEqual([line for line, _ in modulus.format_layout.alias_diagnostics(source, ".py")], [3, 4]) def test_exported_stdin_formatter_needs_no_compile_database(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_python.py b/tests/test_python.py index 2d4d16f..6c1ae52 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -1,3 +1,5 @@ +import modulus.build_context +import modulus.process import pathlib import subprocess import sys @@ -5,13 +7,12 @@ import tempfile import unittest import unittest.mock -import modulus.cli import modulus.config import modulus.python class PythonTests(unittest.TestCase): def context(self, root, groups): - return modulus.cli.build_context(root, modulus.config.normalize({'package': {'name': 'example'}, 'tooling': {'python': {'groups': 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), []) @@ -20,10 +21,10 @@ class PythonTests(unittest.TestCase): 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.cli.subprocess_environment(context, ['conan', 'install', '.', '--no-remote']) + 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.cli.subprocess_environment(context, ['conan', 'install', '.']) + environment = modulus.process.subprocess_environment(context, ['conan', 'install', '.']) self.assertEqual(environment['MODULUS_PYTHON_OFFLINE'], '0') 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: