import json import pathlib import tempfile import unittest import modulus.config import modulus.export class modulus_export_test(unittest.TestCase): def config(self): return { "package": { "name": "sample", }, "boost": { "ref": "boost/1.91.0", "components": ["json"], "default_options": { "boost/*:without_charconv": False, }, }, "tests": { "name": "sample", }, } def write_config(self, root, config): (root / "modulus.json").write_text(json.dumps(config), encoding="utf-8") def write_package(self, package): for directory in ( "modulus", "tools/docker", "include/modulus", "tests", "lib/cmake/modulus", "share/modulus", "bin", ): (package / directory).mkdir(parents=True) (package / "conanfile.py").write_text("", encoding="utf-8") (package / "modulus.json").write_text("{}", encoding="utf-8") (package / "modulus" / "cli.py").write_text("", encoding="utf-8") (package / "modulus" / "config.py").write_text("", encoding="utf-8") (package / "modulus" / "export.py").write_text("", encoding="utf-8") (package / "modulus" / "formatting.py").write_text("", encoding="utf-8") (package / "modulus" / "format_layout.py").write_text("", encoding="utf-8") (package / "tools" / "configure.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "lsp.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "run.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "format.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "docker" / "format-requirements.txt").write_text("autopep8==2.3.2\n", encoding="utf-8") (package / "tools" / "docker" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") (package / "include" / "modulus" / "types.hpp").write_text("", encoding="utf-8") (package / "tests" / "test.cpp").write_text("", encoding="utf-8") (package / "lib" / "cmake" / "modulus" / "modulus-config.cmake").write_text("", encoding="utf-8") (package / "share" / "modulus" / "duplicate.py").write_text("", encoding="utf-8") (package / "bin" / "modulus-tool").write_text("", encoding="utf-8") def test_boost_requirement_options_enable_configured_components(self): config = modulus.config.normalize(self.config()) boost_options = modulus.config.boost_requirement_options(config) self.assertEqual(modulus.config.boost_ref(config), "boost/1.91.0") self.assertIs(boost_options["without_json"], False) self.assertIs(boost_options["without_container"], False) self.assertIs(boost_options["without_charconv"], False) self.assertIs(boost_options["without_atomic"], True) def test_default_targets_include_architecture_and_build_type_matrix(self): config = modulus.config.normalize(self.config()) targets = config["tooling"]["targets"] self.assertEqual( sorted(targets), [ "arm_64_gcc12_debug", "arm_64_gcc12_release", "x86_64_gcc12_debug", "x86_64_gcc12_release", ], ) self.assertEqual(config["tooling"]["default_target"], "x86_64_gcc12_debug") self.assertEqual(targets["x86_64_gcc12_debug"]["build_type"], "Debug") self.assertFalse(targets["arm_64_gcc12_release"]["can_run"]) self.assertEqual(targets["x86_64_gcc12_release"]["profile"], "build/build_tools/conan/profiles/x86_64_gcc12_release") self.assertFalse(config["package"]["artifact"]["enabled"]) self.assertFalse(config["tooling"]["run"]["enabled"]) self.assertFalse(config["tooling"]["lsp"]["enabled"]) self.assertEqual(config["tooling"]["lsp"]["compile_databases"], []) def test_arm_profiles_pin_compilers_and_separate_legacy_internal_binaries(self): root = pathlib.Path(__file__).resolve().parents[1] for build_type in ("debug", "release"): profile = (root / "tools/conan/profiles" / f"arm_64_gcc12_{build_type}").read_text() self.assertIn('compiler.version=12', profile) self.assertIn('tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}', profile) for name in ("modulus", "ligo", "noto", "luma", "sonus"): self.assertIn(f'{name}/*:tools.info.package_id:confs=["tools.build:compiler_executables"]', profile) for path in (".gitea/workflows/ci.yml", "tools/docker/Dockerfile"): source = (root / path).read_text() for compiler in ("gcc", "g++"): self.assertIn(f"{compiler}-12-aarch64-linux-gnu", source) self.assertNotIn(f"{compiler}-aarch64-linux-gnu", source) def test_product_artifact_and_run_config_are_validated(self): config = self.config() config["package"]["artifact"] = { "enabled": True, "targets": ["x86_64_gcc12_release"], "install_prefix": "usr", "cmake_definitions": { "SAMPLE_WITH_WEB": True, }, "conan_options": { "with_appliance_adapters": True, }, "upload_env": "SAMPLE_PACKAGE_UPLOAD", } config["tooling"] = { "run": { "enabled": True, "target": "x86_64_gcc12_debug", "executable": "sample", "env": { "SAMPLE_WEB_ROOT": "build/web", }, }, } normalized = modulus.config.normalize(config) self.assertEqual(normalized["package"]["artifact"]["install_prefix"], "usr") self.assertEqual(normalized["package"]["artifact"]["registry_url"], "https://git.thyme-labs.com/api/packages/khodo/generic") self.assertEqual(normalized["tooling"]["run"]["args"], []) config["package"]["artifact"]["targets"] = ["missing"] with self.assertRaisesRegex(modulus.config.config_error, "unknown target"): modulus.config.normalize(config) def test_target_cmake_definitions_are_validated_and_defaulted(self): config = self.config() config["tooling"] = { "default_target": "native", "targets": { "native": { "profile": "build/build_tools/conan/profiles/native", "build_dir": "build/build_native", "can_run": True, "build_type": "Debug", "cmake_definitions": { "SAMPLE_WITH_WEB": True, "SAMPLE_MODE": "dev", }, }, "release": { "profile": "build/build_tools/conan/profiles/release", "build_dir": "build/build_release", "can_run": True, "build_type": "Release", }, }, } normalized = modulus.config.normalize(config) self.assertEqual( normalized["tooling"]["targets"]["native"]["cmake_definitions"], { "SAMPLE_WITH_WEB": True, "SAMPLE_MODE": "dev", }, ) self.assertEqual(normalized["tooling"]["targets"]["release"]["cmake_definitions"], {}) config["tooling"]["targets"]["native"]["cmake_definitions"] = { "SAMPLE_BAD": [ "invalid", ], } with self.assertRaisesRegex(modulus.config.config_error, "tooling.targets.native.cmake_definitions"): modulus.config.normalize(config) def test_lsp_compile_databases_are_validated_and_defaulted(self): config = self.config() config["tooling"] = { "lsp": { "enabled": True, "compile_databases": [ { "name": "pico", "build_dir": "build/build_pico_debug", "cmake_definitions": { "VAMP_PICO_FIRMWARE_PROJECT": True, }, "unset_env": [ "CC", "CXX", ], }, ], }, } normalized = modulus.config.normalize(config) database = normalized["tooling"]["lsp"]["compile_databases"][0] self.assertEqual(database["source_dir"], ".") self.assertEqual(database["generator"], "Ninja") self.assertEqual(database["env"], {}) self.assertEqual(database["unset_env"], ["CC", "CXX"]) config["tooling"]["lsp"]["compile_databases"][0]["unset_env"] = ["bad-name"] with self.assertRaisesRegex(modulus.config.config_error, "tooling.lsp.compile_databases\\[0\\].unset_env"): modulus.config.normalize(config) def test_checked_in_profile_overrides_exported_default_profile(self): with tempfile.TemporaryDirectory() as repo_dir: repo = pathlib.Path(repo_dir) self.write_config(repo, self.config()) profile = repo / "conan" / "profiles" / "x86_64_gcc12_release" profile.parent.mkdir(parents=True) profile.write_text("[settings]\n", encoding="utf-8") config = modulus.config.load_repo_config(repo) self.assertEqual(config["tooling"]["targets"]["x86_64_gcc12_release"]["profile"], "conan/profiles/x86_64_gcc12_release") def test_docker_defaults_use_shared_modulus_marker(self): config = modulus.config.normalize(self.config()) self.assertEqual(config["tooling"]["docker"]["image"], "modulus-build:dev") self.assertEqual(config["tooling"]["docker"]["marker"], "/opt/modulus-build-image") self.assertNotIn("apt_architectures", config["tooling"]["docker"]) self.assertNotIn("apt_packages", config["tooling"]["docker"]) def test_export_project_copies_runtime_and_exported_tools(self): with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: repo = pathlib.Path(repo_dir) package = pathlib.Path(package_dir) self.write_config(repo, self.config()) self.write_package(package) modulus.export.sync_project(repo, package) self.assertFalse((repo / "modulus.py").exists()) self.assertFalse((repo / "build" / "build_common" / "manifest.json").exists()) self.assertTrue((repo / "build" / "build_tools" / "configure.py").is_file()) self.assertTrue((repo / "build" / "build_tools" / "lsp.py").is_file()) self.assertTrue((repo / "build" / "build_tools" / "run.py").is_file()) self.assertTrue((repo / "build" / "build_tools" / "docker" / "Dockerfile").is_file()) self.assertFalse((repo / "build" / "build_tools" / "docs").exists()) self.assertFalse((repo / "build" / "build_tools" / "lsp").exists()) for destination in modulus.export.editor_files.values(): self.assertTrue((repo / destination).is_file()) runtime = repo / "build" / "build_common" / "modulus" self.assertTrue((runtime / "modulus" / "cli.py").is_file()) self.assertTrue((runtime / "modulus" / "config.py").is_file()) self.assertTrue((runtime / "modulus" / "export.py").is_file()) self.assertTrue((runtime / "modulus" / "formatting.py").is_file()) self.assertTrue((runtime / "modulus" / "format_layout.py").is_file()) self.assertTrue((repo / "build/build_tools/format.py").is_file()) self.assertTrue((repo / "build/build_tools/docker/format-requirements.txt").is_file()) self.assertFalse((runtime / "CMakeLists.txt").exists()) self.assertFalse((runtime / "README.md").exists()) self.assertFalse((runtime / "conanfile.py").exists()) self.assertFalse((runtime / "cmake").exists()) self.assertFalse((runtime / "include").exists()) self.assertFalse((runtime / "schemas").exists()) self.assertFalse((runtime / "src").exists()) self.assertFalse((runtime / "tools").exists()) self.assertFalse((runtime / "tests").exists()) self.assertFalse((runtime / "package").exists()) self.assertFalse((runtime / "lib").exists()) self.assertFalse((runtime / "share").exists()) self.assertFalse((runtime / "bin").exists()) def test_editor_defaults_use_configured_target(self): config = modulus.config.normalize(self.config()) config["tooling"].pop("editor") config["tooling"]["targets"][config["tooling"]["default_target"]]["build_dir"] = "build/custom" config = modulus.config.normalize(config) editor = config["tooling"]["editor"] self.assertEqual(editor["clangd"][0]["CompileFlags"]["CompilationDatabase"], "build/custom") self.assertEqual(editor["clangd"][0]["Diagnostics"]["ClangTidy"]["Remove"], ["*"]) self.assertIn("conanfile.py", editor["pyright"]["include"]) self.assertNotIn("package/conanfile.py", editor["pyright"]["include"]) def test_editor_replacements_and_refresh_are_idempotent(self): config = self.config() editor = { "clangd": [ {"CompileFlags": {"CompilationDatabase": "build/native"}}, {"If": {"PathMatch": "src/pico/.*"}, "CompileFlags": {"CompilationDatabase": "build/pico"}}, ], "clang_format": {"BasedOnStyle": "LLVM", "IndentWidth": 2}, "pyright": {"include": ["generator"]}, } config["tooling"] = {"editor": editor} normalized = modulus.config.normalize(config) self.assertEqual(normalized["tooling"]["editor"], {**editor, "nvim": {"enabled": True}}) with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) modulus.export.sync_editor_files(root, normalized) for name, filename in modulus.export.editor_files.items(): path = root / filename content = path.read_text(encoding="utf-8") if name != "pyright": content = content.split("\n", 1)[1] documents = [json.loads(part) for part in content.split("\n---\n")] self.assertEqual(documents if name == "clangd" else documents[0], editor[name]) before = {path.name: path.stat().st_mtime_ns for path in root.iterdir()} modulus.export.sync_editor_files(root, normalized) self.assertEqual(before, {path.name: path.stat().st_mtime_ns for path in root.iterdir()}) def test_editor_config_rejects_invalid_shapes(self): for editor in (None, [], {"unknown": {}}, {"clangd": {}}, {"clangd": []}, {"clangd": ["bad"]}, {"pyright": []}, {"clang_format": None}, {"pyright": {"value": float("nan")}}): with self.subTest(editor=editor): config = self.config() config["tooling"] = {"editor": editor} with self.assertRaises(modulus.config.config_error): modulus.config.normalize(config) def test_export_requires_packaged_tools(self): with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: repo = pathlib.Path(repo_dir) package = pathlib.Path(package_dir) self.write_config(repo, self.config()) (package / "modulus").mkdir() (package / "modulus" / "cli.py").write_text("", encoding="utf-8") with self.assertRaisesRegex(RuntimeError, "missing tools directory"): modulus.export.sync_project(repo, package) def test_normalize_rejects_invalid_cmake_values(self): config = self.config() config["package"]["name"] = "BadName" with self.assertRaisesRegex(modulus.config.config_error, "package.name"): modulus.config.normalize(config) config = self.config() config["docs"] = {} with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: docs"): modulus.config.normalize(config) def test_unknown_top_level_config_keys_are_rejected(self): config = self.config() config["old_config"] = {} with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: old_config"): modulus.config.normalize(config) def test_removed_setup_generation_fields_are_rejected(self): config = self.config() config["schema_version"] = 1 with self.assertRaisesRegex(modulus.config.config_error, "schema_version"): modulus.config.normalize(config) config = self.config() config["package"]["cmake_target"] = "sample::sample" with self.assertRaisesRegex(modulus.config.config_error, "cmake_target"): modulus.config.normalize(config) config = self.config() config["exports"] = ["include/*.hpp"] with self.assertRaisesRegex(modulus.config.config_error, "exports"): modulus.config.normalize(config) config = self.config() config["dependencies"] = [ { "ref": "openssl/4.0.1", }, ] with self.assertRaisesRegex(modulus.config.config_error, "dependencies"): modulus.config.normalize(config) config = self.config() config["examples"] = [ { "target": "sample-example", }, ] with self.assertRaisesRegex(modulus.config.config_error, "examples"): modulus.config.normalize(config) config = self.config() config["tooling"] = { "docker": { "apt_packages": ["libdrm-dev"], }, } with self.assertRaisesRegex(modulus.config.config_error, "apt_packages"): modulus.config.normalize(config) config = self.config() config["tooling"] = { "targets": { "native": { "profile": "build/profile", "build_dir": "build/native", "options": { "glfw/*:with_x11": False, }, }, }, } with self.assertRaisesRegex(modulus.config.config_error, "options"): modulus.config.normalize(config) config = self.config() config["tooling"] = { "remote": { "update_by_default": True, }, } with self.assertRaisesRegex(modulus.config.config_error, "update_by_default"): modulus.config.normalize(config) if __name__ == "__main__": unittest.main()