Compare commits
5 Commits
0c9ba1bff4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bebf61cb89 | |||
| fb93acca1a | |||
| 3efbe5020a | |||
| 83232175e3 | |||
| 1612e68650 |
@@ -29,22 +29,22 @@ jobs:
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
cmake \
|
cmake \
|
||||||
g++-12 \
|
g++-12 \
|
||||||
g++-aarch64-linux-gnu \
|
g++-12-aarch64-linux-gnu \
|
||||||
gcc-12 \
|
gcc-12 \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-12-aarch64-linux-gnu \
|
||||||
git \
|
git \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
python3 \
|
python3 \
|
||||||
python3-venv
|
python3-venv
|
||||||
python3 -m venv build/build_common/ci_python
|
python3 -m venv build/build_common/ci_python
|
||||||
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/docker/format-requirements.txt
|
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/docker/format_requirements.txt
|
||||||
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
||||||
- name: Restore Conan cache
|
- name: Restore Conan cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: build/build_common/conan_cache
|
path: build/build_common/conan_cache
|
||||||
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'tools/conan/profiles/*') }}
|
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'tools/conan/profiles/*', '.gitea/workflows/ci.yml') }}
|
||||||
restore-keys: conan-${{ runner.os }}-
|
restore-keys: conan-${{ runner.os }}-
|
||||||
- name: Configure native release
|
- name: Configure native release
|
||||||
run: ./tools/configure.py --target x86_64_gcc12_release --update
|
run: ./tools/configure.py --target x86_64_gcc12_release --update
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
run: python3 -m unittest discover -s tests -p 'test_*.py'
|
run: python3 -m unittest discover -s tests -p 'test_*.py'
|
||||||
- name: Check code style
|
- name: Check code style
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install --no-cache-dir -r tools/docker/format-requirements.txt
|
python3 -m pip install --no-cache-dir -r tools/docker/format_requirements.txt
|
||||||
python3 tools/format.py --check
|
python3 tools/format.py --check
|
||||||
- name: Build native release
|
- name: Build native release
|
||||||
run: ./tools/build.py --target x86_64_gcc12_release
|
run: ./tools/build.py --target x86_64_gcc12_release
|
||||||
|
|||||||
+6
-6
@@ -51,7 +51,7 @@ if(BUILD_TESTING)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
install(TARGETS modulus
|
install(TARGETS modulus
|
||||||
EXPORT modulus-targets
|
EXPORT modulus_targets
|
||||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
@@ -59,7 +59,7 @@ install(TARGETS modulus
|
|||||||
|
|
||||||
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||||
|
|
||||||
install(EXPORT modulus-targets
|
install(EXPORT modulus_targets
|
||||||
NAMESPACE modulus::
|
NAMESPACE modulus::
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus
|
||||||
)
|
)
|
||||||
@@ -71,18 +71,18 @@ install(FILES
|
|||||||
|
|
||||||
configure_package_config_file(
|
configure_package_config_file(
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/cmake/modulus_config.cmake.in
|
${CMAKE_CURRENT_SOURCE_DIR}/cmake/modulus_config.cmake.in
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/modulus-config.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/modulusConfig.cmake
|
||||||
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus
|
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus
|
||||||
)
|
)
|
||||||
|
|
||||||
write_basic_package_version_file(
|
write_basic_package_version_file(
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/modulus-config-version.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/modulusConfigVersion.cmake
|
||||||
VERSION ${PROJECT_VERSION}
|
VERSION ${PROJECT_VERSION}
|
||||||
COMPATIBILITY SameMajorVersion
|
COMPATIBILITY SameMajorVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
install(FILES
|
install(FILES
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/modulus-config.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/modulusConfig.cmake
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/modulus-config-version.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/modulusConfigVersion.cmake
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
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
|
## Build
|
||||||
|
|
||||||
Use the checked-in tools directly inside CI or a compatible build environment:
|
Use the checked-in tools directly inside CI or a compatible build environment:
|
||||||
@@ -25,6 +27,8 @@ MODULUS_DIRECT=1 ./tools/configure.py --target x86_64_gcc12_release
|
|||||||
|
|
||||||
Outside that environment, downstream repos use `python3 modulus.py <command>` to fetch the public `modulus/latest` package from the khodo Conan registry and export the tool wrappers into `build/build_tools/`.
|
Outside that environment, downstream repos use `python3 modulus.py <command>` to fetch the public `modulus/latest` package from the khodo Conan registry and export the tool wrappers into `build/build_tools/`.
|
||||||
|
|
||||||
|
GCC 12 profiles select versioned native and ARM compiler executables. Install `gcc-12-aarch64-linux-gnu` and `g++-12-aarch64-linux-gnu` for ARM builds; the unversioned packages can select a different compiler on newer distributions. ARM profiles include the selected compiler executables in the internal libraries' Conan package IDs so previously mislabeled binaries are not reused. Third-party package IDs are unchanged. Publish Modulus before rerunning the sibling libraries' package workflows so they export the corrected profiles.
|
||||||
|
|
||||||
`build` automatically configures a target when CMake metadata is missing. `configure --update` refreshes dependencies and, in consumer repos, the exported Python tooling. Failed downloads/exports leave the previous tooling in place. `--no-remote` uses only the project-local cache, including initial bootstrap; it never downloads packages. Each consumer's tracked `modulus.py` is copied unchanged from `modulus/bootstrap.py`. Update that canonical source and synchronize consumer copies together.
|
`build` automatically configures a target when CMake metadata is missing. `configure --update` refreshes dependencies and, in consumer repos, the exported Python tooling. Failed downloads/exports leave the previous tooling in place. `--no-remote` uses only the project-local cache, including initial bootstrap; it never downloads packages. Each consumer's tracked `modulus.py` is copied unchanged from `modulus/bootstrap.py`. Update that canonical source and synchronize consumer copies together.
|
||||||
|
|
||||||
Projects can select a tracked build recipe using `tooling.docker.dockerfile` and list additional build-context inputs in `tooling.docker.inputs`. Those files, the Dockerfile, and the shared formatting requirements determine the image fingerprint used by builds and clangd. Paths are project-relative. `tooling.docker.socket_commands` optionally permits host Docker socket access for `build` and/or `package`; the default is no socket access. Editor, configure, and test commands never receive the socket.
|
Projects can select a tracked build recipe using `tooling.docker.dockerfile` and list additional build-context inputs in `tooling.docker.inputs`. Those files, the Dockerfile, and the shared formatting requirements determine the image fingerprint used by builds and clangd. Paths are project-relative. `tooling.docker.socket_commands` optionally permits host Docker socket access for `build` and/or `package`; the default is no socket access. Editor, configure, and test commands never receive the socket.
|
||||||
@@ -37,6 +41,16 @@ Refresh clangd compile databases with:
|
|||||||
|
|
||||||
Product repos can add typed extra CMake compile databases under `tooling.lsp.compile_databases`; this is intended for cases like Product Vamp's Pico firmware tree.
|
Product repos can add typed extra CMake compile databases under `tooling.lsp.compile_databases`; this is intended for cases like Product Vamp's Pico firmware tree.
|
||||||
|
|
||||||
|
## Build Python
|
||||||
|
|
||||||
|
Build infrastructure is available as exported `modulus` modules. `modulus.filesystem.publish_directories()` replaces validated directories with rollback; `modulus.artifacts` owns archive naming, checksums, and explicit generic-registry publishing. Product-specific generators and asset policies stay in the consumer.
|
||||||
|
|
||||||
|
Projects needing asset tooling opt in with `"tooling": {"python": {"groups": ["assets"]}}`. The pinned Pillow, defusedxml, FontTools, and aggdraw requirements live in `modulus/assets_requirements.txt`. Configure prepares an isolated environment under `build/build_common/python`, fingerprinted by the interpreter, platform, and dependency manifest. Builds and offline commands reuse it without downloading; missing or stale dependencies require configure. C++-only repos use no optional environment, and Python dependencies never enter runtime packages.
|
||||||
|
|
||||||
|
Run `./modulus.py python -m generator` (or another project module) through the exported launcher. It adds the exported Modulus package before the consumer's `modulus.py`, without changing the working directory. Configure supplies `Python3_EXECUTABLE`, `MODULUS_PYTHON_RUNNER`, and `MODULUS_PYTHON_ROOT` for CMake commands. Conan consumers can use `python_cmake_variables(recipe)` from the Modulus Python requirement.
|
||||||
|
|
||||||
|
`./modulus.py publish` validates all configured archives and checksums before accessing the registry. Credentials are forwarded only for explicit publishing or enabled package uploads, never for configure/build downloads. Project-owned artifact and build paths use underscores; dependency names, CLI flags, Git URLs, and standard tool filenames retain their upstream spelling.
|
||||||
|
|
||||||
## Editor Configuration
|
## Editor Configuration
|
||||||
|
|
||||||
`modulus.json` is the source for generated `.clangd`, `.clang-format`, and `pyrightconfig.json` files. Configure, LSP refresh, and tooling export regenerate them only when their content changes. Keep these generated files ignored; customize `tooling.editor` instead:
|
`modulus.json` is the source for generated `.clangd`, `.clang-format`, and `pyrightconfig.json` files. Configure, LSP refresh, and tooling export regenerate them only when their content changes. Keep these generated files ignored; customize `tooling.editor` instead:
|
||||||
@@ -62,7 +76,7 @@ Each supplied entry replaces that file's entire configuration. Omitted entries u
|
|||||||
|
|
||||||
Run `python3 tools/format.py` in Modulus, or `./modulus.py format` in a consumer. Optional repository-relative paths restrict the operation. `format --check` reports layout differences and prohibited aliases without writing files; CI runs this check. `format --stdin-filename /absolute/buffer.cpp` reads stdin and writes only formatted text, for editor integrations.
|
Run `python3 tools/format.py` in Modulus, or `./modulus.py format` in a consumer. Optional repository-relative paths restrict the operation. `format --check` reports layout differences and prohibited aliases without writing files; CI runs this check. `format --stdin-filename /absolute/buffer.cpp` reads stdin and writes only formatted text, for editor integrations.
|
||||||
|
|
||||||
The build image pins clang-format 19.1.7, autopep8 2.3.2, and the syntax parser dependencies in `tools/docker/format-requirements.txt`. Python uses two-space indentation for both blocks and continuations, including multiline tuples, lists, dictionaries, imports, and calls. Continuations indent two spaces from their opening line rather than aligning to a column; closing brackets align with the opening line. Line-length and aggressive semantic fixes are disabled. C++ has no column limit, uses braces, and preserves include order and groups. The shared syntax-aware pass joins simple signatures/calls, removes blank lines inside functions and class/struct bodies, separates definitions outside classes by one blank line, and pads namespace interiors without indenting their bodies. Class members, including access labels, defaulted/deleted constructors, inline methods, and nested types, have no intervening blank lines. Blank lines within literal and comment contents are preserved. Comments, multiline strings, structured literals, and preprocessor content are not joined. Python formatting verifies AST equivalence before writing. C++ formatting supplements clang-format using concrete syntax boundaries, including common test macros; it is not a C++ type checker.
|
The build image pins clang-format 19.1.7, autopep8 2.3.2, and the syntax parser dependencies in `tools/docker/format_requirements.txt`. Python uses two-space indentation for both blocks and continuations, including multiline tuples, lists, dictionaries, imports, and calls. Continuations indent two spaces from their opening line rather than aligning to a column; closing brackets align with the opening line. Line-length and aggressive semantic fixes are disabled. C++ has no column limit, uses braces, and preserves include order and groups. The shared syntax-aware pass joins simple signatures/calls, removes blank lines inside functions and class/struct bodies, separates definitions outside classes by one blank line, and pads namespace interiors without indenting their bodies. Class members, including access labels, defaulted/deleted constructors, inline methods, and nested types, have no intervening blank lines. Blank lines within literal and comment contents are preserved. Comments, multiline strings, structured literals, and preprocessor content are not joined. Python formatting verifies AST equivalence before writing. C++ formatting supplements clang-format using concrete syntax boundaries, including common test macros; it is not a C++ type checker.
|
||||||
|
|
||||||
Use full type/module names rather than C++ type/namespace aliases, Python type aliases, or renamed imports. The existing `std::*_t` portability definitions in `include/modulus/types.hpp` are the sole local exception; third-party SDK definitions are not rewritten. Formatting changes layout only, so alias diagnostics require explicit code changes.
|
Use full type/module names rather than C++ type/namespace aliases, Python type aliases, or renamed imports. The existing `std::*_t` portability definitions in `include/modulus/types.hpp` are the sole local exception; third-party SDK definitions are not rewritten. Formatting changes layout only, so alias diagnostics require explicit code changes.
|
||||||
|
|
||||||
@@ -89,7 +103,7 @@ Server settings are separate from `.clangd`'s parsing/diagnostic settings:
|
|||||||
"clangd": {
|
"clangd": {
|
||||||
"executable": "/usr/bin/clangd-19",
|
"executable": "/usr/bin/clangd-19",
|
||||||
"arguments": ["--background-index", "--completion-style=detailed", "--header-insertion=never"],
|
"arguments": ["--background-index", "--completion-style=detailed", "--header-insertion=never"],
|
||||||
"query_drivers": ["/usr/bin/gcc-12", "/usr/bin/g++-12", "/usr/bin/aarch64-linux-gnu-gcc", "/usr/bin/aarch64-linux-gnu-g++"],
|
"query_drivers": ["/usr/bin/gcc-12", "/usr/bin/g++-12", "/usr/bin/aarch64-linux-gnu-gcc-12", "/usr/bin/aarch64-linux-gnu-g++-12"],
|
||||||
"header_roots": []
|
"header_roots": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +145,8 @@ Generic application artifacts default to:
|
|||||||
https://git.thyme-labs.com/api/packages/khodo/generic
|
https://git.thyme-labs.com/api/packages/khodo/generic
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Application archives have stable names, `<package>-<target>.tar.gz`, with matching SHA-256 files. Each build replaces the corresponding local artifact. Generic uploads use the registry's required `latest` slot; application packaging has no release number or version argument. Conan library references and external dependency pins remain unchanged.
|
||||||
|
|
||||||
Configure/build/test do not require package credentials for public packages. Read operations log out only the configured public remote in the project-local Conan home and strip credential environment variables, including native Conan username/password variables. Authentication failures are noninteractive errors, not password prompts. Package contents and global Conan configuration are untouched. Credentials are validated only for publishing, redacted in command logs, and the remote is logged out in a `finally` cleanup after every upload attempt so CI caches do not retain the session.
|
Configure/build/test do not require package credentials for public packages. Read operations log out only the configured public remote in the project-local Conan home and strip credential environment variables, including native Conan username/password variables. Authentication failures are noninteractive errors, not password prompts. Package contents and global Conan configuration are untouched. Credentials are validated only for publishing, redacted in command logs, and the remote is logged out in a `finally` cleanup after every upload attempt so CI caches do not retain the session.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ if(NOT TARGET Boost::json)
|
|||||||
set(modulus_NOT_FOUND_MESSAGE "Modulus requires Boost.JSON")
|
set(modulus_NOT_FOUND_MESSAGE "Modulus requires Boost.JSON")
|
||||||
return()
|
return()
|
||||||
endif()
|
endif()
|
||||||
include("${CMAKE_CURRENT_LIST_DIR}/modulus-targets.cmake")
|
include("${CMAKE_CURRENT_LIST_DIR}/modulus_targets.cmake")
|
||||||
foreach(_modulus_component IN LISTS modulus_FIND_COMPONENTS)
|
foreach(_modulus_component IN LISTS modulus_FIND_COMPONENTS)
|
||||||
set(modulus_${_modulus_component}_FOUND FALSE)
|
set(modulus_${_modulus_component}_FOUND FALSE)
|
||||||
if(_modulus_component STREQUAL "modulus" OR _modulus_component STREQUAL "boost_json")
|
if(_modulus_component STREQUAL "modulus" OR _modulus_component STREQUAL "boost_json")
|
||||||
|
|||||||
+12
-1
@@ -7,6 +7,16 @@ import conan.tools.cmake
|
|||||||
import conan.tools.files
|
import conan.tools.files
|
||||||
|
|
||||||
import modulus.config
|
import modulus.config
|
||||||
|
import modulus.python
|
||||||
|
import modulus.build_context
|
||||||
|
|
||||||
|
def python_cmake_variables(recipe):
|
||||||
|
root = pathlib.Path(recipe.dependencies['modulus'].package_folder)
|
||||||
|
context = modulus.build_context.build_context(pathlib.Path(recipe.source_folder), modulus.config.load_repo_config(recipe.source_folder))
|
||||||
|
context.tools_dir = root / 'tools'
|
||||||
|
context.common_dir = pathlib.Path(os.environ.get('MODULUS_PYTHON_COMMON_DIR', str(pathlib.Path(recipe.build_folder) / 'build_python')))
|
||||||
|
modulus.python.interpreter(context, install=True, offline=os.environ.get('MODULUS_PYTHON_OFFLINE') == '1')
|
||||||
|
return modulus.python.cmake_definitions(context)
|
||||||
|
|
||||||
class modulus_recipe(conan.ConanFile):
|
class modulus_recipe(conan.ConanFile):
|
||||||
name = "modulus"
|
name = "modulus"
|
||||||
@@ -63,7 +73,7 @@ class modulus_recipe(conan.ConanFile):
|
|||||||
"glfw/*:with_wayland": False,
|
"glfw/*:with_wayland": False,
|
||||||
"glfw/*:with_x11": False,
|
"glfw/*:with_x11": False,
|
||||||
}
|
}
|
||||||
exports = "modulus/*.py", "modulus.json", "!build", "!.git", "!test_package/build"
|
exports = "modulus/*.py", "modulus/*.txt", "modulus.json", "!build", "!.git", "!test_package/build"
|
||||||
exports_sources = (
|
exports_sources = (
|
||||||
"!build",
|
"!build",
|
||||||
"!.git",
|
"!.git",
|
||||||
@@ -148,6 +158,7 @@ class modulus_recipe(conan.ConanFile):
|
|||||||
cmake.install()
|
cmake.install()
|
||||||
package = pathlib.Path(self.package_folder)
|
package = pathlib.Path(self.package_folder)
|
||||||
conan.tools.files.copy(self, "*.py", src=pathlib.Path(self.recipe_folder) / "modulus", dst=package / "modulus", keep_path=True)
|
conan.tools.files.copy(self, "*.py", src=pathlib.Path(self.recipe_folder) / "modulus", dst=package / "modulus", keep_path=True)
|
||||||
|
conan.tools.files.copy(self, "*.txt", src=pathlib.Path(self.recipe_folder) / "modulus", dst=package / "modulus", keep_path=True)
|
||||||
conan.tools.files.copy(self, "*", src=pathlib.Path(self.source_folder) / "tools", dst=package / "tools", keep_path=True)
|
conan.tools.files.copy(self, "*", src=pathlib.Path(self.source_folder) / "tools", dst=package / "tools", keep_path=True)
|
||||||
def package_info(self):
|
def package_info(self):
|
||||||
cpp_info = self.cpp_info
|
cpp_info = self.cpp_info
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
REGISTRY_SLOT = "latest"
|
||||||
|
PAGE_LIMIT = 50
|
||||||
|
|
||||||
|
def archive_name(package_name, target):
|
||||||
|
return f'{package_name}_{target}.tar.gz'
|
||||||
|
|
||||||
|
def create_archive(source_dir, archive):
|
||||||
|
with tarfile.open(archive, 'w:gz') as output:
|
||||||
|
for path in sorted(source_dir.rglob('*')):
|
||||||
|
output.add(path, arcname=path.relative_to(source_dir), recursive=False)
|
||||||
|
|
||||||
|
def checksum_text(archive):
|
||||||
|
with archive.open('rb') as stream:
|
||||||
|
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||||
|
return f'{digest} {archive.name}\n'
|
||||||
|
|
||||||
|
def write_checksum(archive):
|
||||||
|
checksum = archive.with_name(archive.name + '.sha256')
|
||||||
|
checksum.write_text(checksum_text(archive), encoding='utf-8')
|
||||||
|
return checksum
|
||||||
|
|
||||||
|
def parse_registry_url(registry_url):
|
||||||
|
parsed = urllib.parse.urlparse(registry_url.rstrip("/"))
|
||||||
|
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
|
||||||
|
if len(parts) < 4 or parts[-4] != "api" or parts[-3] != "packages" or parts[-1] != "generic":
|
||||||
|
raise SystemExit("package artifact registry_url must end with /api/packages/{owner}/generic")
|
||||||
|
owner = parts[-2]
|
||||||
|
prefix = "/" + "/".join(urllib.parse.quote(part, safe="") for part in parts[:-4]) if parts[:-4] else ""
|
||||||
|
return parsed, prefix, owner
|
||||||
|
|
||||||
|
def build_url(parsed, path, query=""):
|
||||||
|
return urllib.parse.urlunparse((
|
||||||
|
parsed.scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
path,
|
||||||
|
"",
|
||||||
|
query,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
|
||||||
|
def quote_path(*parts):
|
||||||
|
return "/".join(urllib.parse.quote(part, safe="") for part in parts)
|
||||||
|
|
||||||
|
def version_list_url(parsed, prefix, owner, package_name, page):
|
||||||
|
path = f"{prefix}/api/v1/packages/{quote_path(owner, 'generic', package_name)}"
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"page": page,
|
||||||
|
"limit": PAGE_LIMIT,
|
||||||
|
})
|
||||||
|
return build_url(parsed, path, query)
|
||||||
|
|
||||||
|
def version_delete_url(parsed, prefix, owner, package_name, version):
|
||||||
|
path = f"{prefix}/api/packages/{quote_path(owner, 'generic', package_name, version)}"
|
||||||
|
return build_url(parsed, path)
|
||||||
|
|
||||||
|
def file_upload_url(registry_url, package_name, path):
|
||||||
|
return "/".join((
|
||||||
|
registry_url.rstrip("/"),
|
||||||
|
urllib.parse.quote(package_name, safe=""),
|
||||||
|
REGISTRY_SLOT,
|
||||||
|
urllib.parse.quote(path.name, safe=""),
|
||||||
|
))
|
||||||
|
|
||||||
|
def authorization(user, token):
|
||||||
|
encoded = base64.b64encode(f"{user}:{token}".encode("utf-8")).decode("ascii")
|
||||||
|
return f"Basic {encoded}"
|
||||||
|
|
||||||
|
def request(method, url, auth, data=None, headers=None, missing_ok=False):
|
||||||
|
request_headers = {
|
||||||
|
"Authorization": auth,
|
||||||
|
**(headers or {}),
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
|
||||||
|
try:
|
||||||
|
return urllib.request.urlopen(request, timeout=120)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if missing_ok and error.code == 404:
|
||||||
|
return None
|
||||||
|
message = f"{method} {url} failed with HTTP {error.code}"
|
||||||
|
raise SystemExit(message) from error
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
raise SystemExit(f"{method} {url} failed: {error.reason}") from error
|
||||||
|
|
||||||
|
def list_versions(parsed, prefix, owner, package_name, auth):
|
||||||
|
versions = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
response = request("GET", version_list_url(parsed, prefix, owner, package_name, page), auth, missing_ok=True)
|
||||||
|
if response is None:
|
||||||
|
return []
|
||||||
|
with response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise SystemExit("Gitea package version list response was not an array")
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("version"), str):
|
||||||
|
versions.append(item["version"])
|
||||||
|
if len(data) < PAGE_LIMIT:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return sorted(set(versions))
|
||||||
|
|
||||||
|
def delete_version(parsed, prefix, owner, package_name, version, auth):
|
||||||
|
url = version_delete_url(parsed, prefix, owner, package_name, version)
|
||||||
|
response = request("DELETE", url, auth, missing_ok=True)
|
||||||
|
if response is None:
|
||||||
|
print(f"Package version already absent: {package_name}/{version}")
|
||||||
|
return
|
||||||
|
with response:
|
||||||
|
if response.status != 204:
|
||||||
|
raise SystemExit(f"DELETE {url} returned HTTP {response.status}")
|
||||||
|
print(f"Deleted package version: {package_name}/{version}")
|
||||||
|
|
||||||
|
def discover_artifacts(dist_dir, package_name, targets):
|
||||||
|
artifacts = []
|
||||||
|
for target in sorted(set(targets)):
|
||||||
|
archive = dist_dir / archive_name(package_name, target)
|
||||||
|
artifacts.extend((archive, archive.with_name(archive.name + ".sha256")))
|
||||||
|
missing = [path for path in artifacts if not path.is_file()]
|
||||||
|
if missing:
|
||||||
|
found = sorted(path.name for path in dist_dir.glob(f"{package_name}*.tar.gz*") if path.is_file())
|
||||||
|
raise SystemExit("missing artifact files: " + ", ".join(str(path) for path in missing)
|
||||||
|
+ "\nFound: " + (", ".join(found) or "none")
|
||||||
|
+ "\nPublish the current Modulus tooling, run configure --update, and recreate the package so the builder and publisher use the same filenames.")
|
||||||
|
for archive, checksum in zip(artifacts[::2], artifacts[1::2]):
|
||||||
|
if archive.is_symlink() or checksum.is_symlink():
|
||||||
|
raise SystemExit('artifact files must not be symlinks')
|
||||||
|
if checksum.read_text(encoding='utf-8') != checksum_text(archive):
|
||||||
|
raise SystemExit(f'artifact checksum mismatch: {archive}')
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
def upload_file(registry_url, package_name, path, auth):
|
||||||
|
url = file_upload_url(registry_url, package_name, path)
|
||||||
|
headers = {
|
||||||
|
"Content-Length": str(path.stat().st_size),
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
}
|
||||||
|
with path.open("rb") as input_file:
|
||||||
|
response = request("PUT", url, auth, data=input_file, headers=headers)
|
||||||
|
with response:
|
||||||
|
if response.status != 201:
|
||||||
|
raise SystemExit(f"PUT {url} returned HTTP {response.status}")
|
||||||
|
print(f"Uploaded package file: {package_name}/{path.name}")
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
parser = argparse.ArgumentParser(description="Publish the configured rolling Gitea generic package.")
|
||||||
|
parser.add_argument("--dist-dir", type=pathlib.Path)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
def main(context, argv):
|
||||||
|
args = parse_args(argv)
|
||||||
|
if not context.artifact['enabled']:
|
||||||
|
raise SystemExit('artifact publishing is not enabled in modulus.json')
|
||||||
|
package_name, registry_url = context.package['name'], context.artifact['registry_url']
|
||||||
|
targets = context.artifact['targets'] or [context.default_target_name]
|
||||||
|
artifacts = discover_artifacts(args.dist_dir or context.build_root / 'packages', package_name, targets)
|
||||||
|
user = os_environ("PACKAGE_REGISTRY_USER")
|
||||||
|
token = os_environ("PACKAGE_REGISTRY_TOKEN")
|
||||||
|
auth = authorization(user, token)
|
||||||
|
parsed, prefix, owner = parse_registry_url(registry_url)
|
||||||
|
existing_versions = list_versions(parsed, prefix, owner, package_name, auth)
|
||||||
|
# Gitea rejects duplicate file names within a generic package version, so
|
||||||
|
# replacing the rolling release requires deleting the old version first.
|
||||||
|
if REGISTRY_SLOT in existing_versions:
|
||||||
|
delete_version(parsed, prefix, owner, package_name, REGISTRY_SLOT, auth)
|
||||||
|
for artifact in artifacts:
|
||||||
|
upload_file(registry_url, package_name, artifact, auth)
|
||||||
|
for version in existing_versions:
|
||||||
|
if version != REGISTRY_SLOT:
|
||||||
|
delete_version(parsed, prefix, owner, package_name, version, auth)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def os_environ(name):
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
raise SystemExit(f"{name} is required")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Pillow==12.3.0
|
||||||
|
defusedxml==0.7.1
|
||||||
|
fonttools==4.65.0
|
||||||
|
aggdraw==1.4.1
|
||||||
@@ -9,7 +9,7 @@ import sys
|
|||||||
|
|
||||||
PACKAGE_REF = "modulus/latest"
|
PACKAGE_REF = "modulus/latest"
|
||||||
CONAN_VERSION = "2.26.2"
|
CONAN_VERSION = "2.26.2"
|
||||||
FETCH_IMAGE = "modulus-fetch:dev"
|
FETCH_IMAGE = "modulus_fetch:dev"
|
||||||
COMMANDS = (
|
COMMANDS = (
|
||||||
"configure",
|
"configure",
|
||||||
"build",
|
"build",
|
||||||
@@ -19,6 +19,8 @@ COMMANDS = (
|
|||||||
"clangd",
|
"clangd",
|
||||||
"format",
|
"format",
|
||||||
"run",
|
"run",
|
||||||
|
"python",
|
||||||
|
"publish",
|
||||||
)
|
)
|
||||||
COMMON_DIR = pathlib.Path("build") / "build_common"
|
COMMON_DIR = pathlib.Path("build") / "build_common"
|
||||||
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
||||||
@@ -26,7 +28,7 @@ RUNTIME_DIR = COMMON_DIR / "modulus"
|
|||||||
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
||||||
DEFAULT_REMOTE_NAME = "khodo"
|
DEFAULT_REMOTE_NAME = "khodo"
|
||||||
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||||
DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image"
|
DEFAULT_DOCKER_MARKER = "/opt/modulus_build_image"
|
||||||
|
|
||||||
def repo_root():
|
def repo_root():
|
||||||
root = pathlib.Path(__file__).resolve().parent
|
root = pathlib.Path(__file__).resolve().parent
|
||||||
@@ -66,6 +68,9 @@ def runtime_ready(root):
|
|||||||
and (root / TOOLS_DIR / "format.py").is_file()
|
and (root / TOOLS_DIR / "format.py").is_file()
|
||||||
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
|
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
|
||||||
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
|
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "python.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "artifacts.py").is_file()
|
||||||
|
and (root / TOOLS_DIR / "python_runner.py").is_file()
|
||||||
)
|
)
|
||||||
|
|
||||||
def env_flag(name):
|
def env_flag(name):
|
||||||
@@ -114,9 +119,9 @@ RUN apt-get update \\
|
|||||||
python3-venv \\
|
python3-venv \\
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN python3 -m venv /opt/modulus-python
|
RUN python3 -m venv /opt/modulus_python
|
||||||
|
|
||||||
ENV PATH=/opt/modulus-python/bin:$PATH
|
ENV PATH=/opt/modulus_python/bin:$PATH
|
||||||
|
|
||||||
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
+9
-44
@@ -3,7 +3,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -13,37 +12,13 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import modulus.config
|
import modulus.config
|
||||||
|
import modulus.container
|
||||||
|
|
||||||
image_label = "org.khodo.modulus.dockerfile"
|
|
||||||
cache_version = 1
|
cache_version = 1
|
||||||
|
|
||||||
def settings(context):
|
def settings(context):
|
||||||
return {**modulus.config.clangd_defaults, **context.lsp_settings.get("clangd", {})}
|
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):
|
def cache_directory(context, image):
|
||||||
cache_home = pathlib.Path(os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")).resolve()
|
cache_home = pathlib.Path(os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")).resolve()
|
||||||
config = settings(context)
|
config = settings(context)
|
||||||
@@ -52,14 +27,6 @@ def cache_directory(context, image):
|
|||||||
digest = hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest()[:20]
|
digest = hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest()[:20]
|
||||||
return cache_home / "modulus" / "clangd" / image.removeprefix("sha256:") / digest
|
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):
|
def read_manifest(cache):
|
||||||
data = json.loads((cache / "manifest.json").read_text(encoding="utf-8"))
|
data = json.loads((cache / "manifest.json").read_text(encoding="utf-8"))
|
||||||
roots = data["roots"]
|
roots = data["roots"]
|
||||||
@@ -93,9 +60,8 @@ def extract_headers(stream, destination, roots):
|
|||||||
raise ValueError(f"non-regular header archive entry: {member.name}")
|
raise ValueError(f"non-regular header archive entry: {member.name}")
|
||||||
|
|
||||||
def prepare(context):
|
def prepare(context):
|
||||||
from modulus.cli import docker_platform_arch
|
|
||||||
try:
|
try:
|
||||||
image = current_image(context, docker_platform_arch())
|
image = modulus.container.current_image(context, modulus.container.docker_platform_arch())
|
||||||
if image is None:
|
if image is None:
|
||||||
raise RuntimeError("build image is missing or stale; run configure first")
|
raise RuntimeError("build image is missing or stale; run configure first")
|
||||||
cache = cache_directory(context, image)
|
cache = cache_directory(context, image)
|
||||||
@@ -107,15 +73,15 @@ def prepare(context):
|
|||||||
return 0
|
return 0
|
||||||
probe = context.tools_dir / "clangd_headers.py"
|
probe = context.tools_dir / "clangd_headers.py"
|
||||||
result = subprocess.run(
|
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))],
|
"python3", "/modulus_headers.py", json.dumps(settings(context))],
|
||||||
stdout=subprocess.PIPE, text=True, check=True,
|
stdout=subprocess.PIPE, text=True, check=True,
|
||||||
)
|
)
|
||||||
roots = json.loads(result.stdout)
|
roots = json.loads(result.stdout)
|
||||||
with tempfile.TemporaryDirectory(prefix=".headers-", dir=cache.parent) as temporary:
|
with tempfile.TemporaryDirectory(prefix=".headers_", dir=cache.parent) as temporary:
|
||||||
stage = pathlib.Path(temporary) / "cache"
|
stage = pathlib.Path(temporary) / "cache"
|
||||||
stage.mkdir()
|
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__",
|
"--exclude=*/.git", "--exclude=*/__pycache__",
|
||||||
"-C", "/", "-cf", "-", *[root.lstrip("/") for root in roots]]
|
"-C", "/", "-cf", "-", *[root.lstrip("/") for root in roots]]
|
||||||
with subprocess.Popen(command, stdout=subprocess.PIPE) as process:
|
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):
|
def launch_command(context, argv, image, cache, roots, name):
|
||||||
root = context.repo_root
|
root = context.repo_root
|
||||||
return [
|
return [
|
||||||
*isolated_run(image), "--interactive", "--name", name,
|
*modulus.container.isolated_run(image), "--interactive", "--name", name,
|
||||||
"--volume", f"{root}:{root}:ro",
|
"--volume", f"{root}:{root}:ro",
|
||||||
"--volume", f"{context.build_root}:{context.build_root}:rw",
|
"--volume", f"{context.build_root}:{context.build_root}:rw",
|
||||||
"--env", f"XDG_CACHE_HOME={context.common_dir / 'clangd'}",
|
"--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):
|
def launch(context, argv):
|
||||||
from modulus.cli import docker_platform_arch
|
|
||||||
try:
|
try:
|
||||||
if context.docker_marker.exists():
|
if context.docker_marker.exists():
|
||||||
os.execv(settings(context)["executable"], server_arguments(context, argv))
|
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:
|
if image is None:
|
||||||
raise RuntimeError("missing or stale build image; run ./modulus.py configure (Modulus: ./tools/configure.py)")
|
raise RuntimeError("missing or stale build image; run ./modulus.py configure (Modulus: ./tools/configure.py)")
|
||||||
databases = [context.default_target.build_dir, *[
|
databases = [context.default_target.build_dir, *[
|
||||||
@@ -177,7 +142,7 @@ def launch(context, argv):
|
|||||||
roots = read_manifest(cache)
|
roots = read_manifest(cache)
|
||||||
except (OSError, ValueError, KeyError) as error:
|
except (OSError, ValueError, KeyError) as error:
|
||||||
raise RuntimeError("header cache is missing or invalid; run the lsp command") from error
|
raise RuntimeError("header cache is missing or invalid; run the lsp command") from error
|
||||||
name = "modulus-clangd-" + uuid.uuid4().hex
|
name = "modulus_clangd_" + uuid.uuid4().hex
|
||||||
command = launch_command(context, argv, image, cache, roots, name)
|
command = launch_command(context, argv, image, cache, roots, name)
|
||||||
return run_server(command, name)
|
return run_server(command, name)
|
||||||
except (OSError, ValueError, RuntimeError) as error:
|
except (OSError, ValueError, RuntimeError) as error:
|
||||||
|
|||||||
+25
-1065
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
"<hidden>",
|
||||||
|
],
|
||||||
|
)
|
||||||
+17
-3
@@ -5,6 +5,7 @@ import re
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import modulus.format_layout
|
import modulus.format_layout
|
||||||
|
import modulus.python
|
||||||
|
|
||||||
class config_error(RuntimeError):
|
class config_error(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -79,6 +80,7 @@ test_keys = {
|
|||||||
"name",
|
"name",
|
||||||
}
|
}
|
||||||
tooling_keys = {
|
tooling_keys = {
|
||||||
|
"python",
|
||||||
"editor",
|
"editor",
|
||||||
"default_target",
|
"default_target",
|
||||||
"targets",
|
"targets",
|
||||||
@@ -107,7 +109,7 @@ clangd_defaults = {
|
|||||||
"arguments": ["--background-index", "--completion-style=detailed", "--header-insertion=never"],
|
"arguments": ["--background-index", "--completion-style=detailed", "--header-insertion=never"],
|
||||||
"query_drivers": [
|
"query_drivers": [
|
||||||
"/usr/bin/gcc-12", "/usr/bin/g++-12",
|
"/usr/bin/gcc-12", "/usr/bin/g++-12",
|
||||||
"/usr/bin/aarch64-linux-gnu-gcc", "/usr/bin/aarch64-linux-gnu-g++",
|
"/usr/bin/aarch64-linux-gnu-gcc-12", "/usr/bin/aarch64-linux-gnu-g++-12",
|
||||||
],
|
],
|
||||||
"header_roots": [],
|
"header_roots": [],
|
||||||
}
|
}
|
||||||
@@ -230,6 +232,10 @@ def normalize(data):
|
|||||||
)
|
)
|
||||||
if not isinstance(tooling, dict):
|
if not isinstance(tooling, dict):
|
||||||
raise config_error("modulus.json tooling must be an object")
|
raise config_error("modulus.json tooling must be an object")
|
||||||
|
python = tooling.setdefault("python", {})
|
||||||
|
if not isinstance(python, dict):
|
||||||
|
raise config_error("modulus.json tooling.python must be an object")
|
||||||
|
python.setdefault("groups", [])
|
||||||
formatting = tooling.setdefault("format", {})
|
formatting = tooling.setdefault("format", {})
|
||||||
if not isinstance(formatting, dict):
|
if not isinstance(formatting, dict):
|
||||||
raise config_error("modulus.json tooling.format must be an object")
|
raise config_error("modulus.json tooling.format must be an object")
|
||||||
@@ -241,8 +247,8 @@ def normalize(data):
|
|||||||
"docker",
|
"docker",
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
tooling["docker"].setdefault("image", "modulus-build:dev")
|
tooling["docker"].setdefault("image", "modulus_build:dev")
|
||||||
tooling["docker"].setdefault("marker", "/opt/modulus-build-image")
|
tooling["docker"].setdefault("marker", "/opt/modulus_build_image")
|
||||||
tooling["docker"].setdefault("inputs", [])
|
tooling["docker"].setdefault("inputs", [])
|
||||||
tooling["docker"].setdefault("socket_commands", [])
|
tooling["docker"].setdefault("socket_commands", [])
|
||||||
tooling.setdefault("upload_env", f"{environment_prefix(name)}_CONAN_UPLOAD")
|
tooling.setdefault("upload_env", f"{environment_prefix(name)}_CONAN_UPLOAD")
|
||||||
@@ -410,6 +416,14 @@ def validate_tests(tests):
|
|||||||
validate_cmake_name(tests.get("name"), "tests.name", optional=True)
|
validate_cmake_name(tests.get("name"), "tests.name", optional=True)
|
||||||
|
|
||||||
def validate_tooling(tooling):
|
def validate_tooling(tooling):
|
||||||
|
python = tooling.get('python', {})
|
||||||
|
if not isinstance(python, dict):
|
||||||
|
raise config_error('modulus.json tooling.python must be an object')
|
||||||
|
validate_keys(python, {'groups'}, 'tooling.python')
|
||||||
|
groups = python.get('groups', [])
|
||||||
|
validate_string_list(groups, 'tooling.python.groups')
|
||||||
|
if len(set(groups)) != len(groups) or any(group not in modulus.python.GROUPS for group in groups):
|
||||||
|
raise config_error('tooling.python.groups must contain unique supported groups: ' + ', '.join(modulus.python.GROUPS))
|
||||||
if "editor" in tooling:
|
if "editor" in tooling:
|
||||||
validate_editor(tooling["editor"])
|
validate_editor(tooling["editor"])
|
||||||
validate_cmake_name(tooling.get("default_target"), "tooling.default_target", optional=True)
|
validate_cmake_name(tooling.get("default_target"), "tooling.default_target", optional=True)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
+3
-19
@@ -5,6 +5,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import modulus.config
|
import modulus.config
|
||||||
|
import modulus.filesystem
|
||||||
|
|
||||||
build_dir = pathlib.Path("build")
|
build_dir = pathlib.Path("build")
|
||||||
common_dir = build_dir / "build_common"
|
common_dir = build_dir / "build_common"
|
||||||
@@ -48,29 +49,12 @@ def sync_project(repo_root, package_root):
|
|||||||
config = modulus.config.load_repo_config(repo_root)
|
config = modulus.config.load_repo_config(repo_root)
|
||||||
stage_parent = repo_root / common_dir
|
stage_parent = repo_root / common_dir
|
||||||
stage_parent.mkdir(parents=True, exist_ok=True)
|
stage_parent.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.TemporaryDirectory(prefix=".modulus-export-", dir=stage_parent) as directory:
|
with tempfile.TemporaryDirectory(prefix=".modulus_export_", dir=stage_parent) as directory:
|
||||||
staged = pathlib.Path(directory)
|
staged = pathlib.Path(directory)
|
||||||
sync_runtime(staged, package_root)
|
sync_runtime(staged, package_root)
|
||||||
sync_tools(staged, package_root)
|
sync_tools(staged, package_root)
|
||||||
installed = []
|
with modulus.filesystem.publish_directories([(staged / relative, repo_root / relative) for relative in (runtime_dir, tools_dir)]):
|
||||||
backups = []
|
|
||||||
try:
|
|
||||||
for index, relative in enumerate((runtime_dir, tools_dir)):
|
|
||||||
target = repo_root / relative
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if target.exists():
|
|
||||||
backup = staged / f"backup-{index}"
|
|
||||||
target.rename(backup)
|
|
||||||
backups.append((backup, target))
|
|
||||||
(staged / relative).rename(target)
|
|
||||||
installed.append(target)
|
|
||||||
sync_editor_files(repo_root, config)
|
sync_editor_files(repo_root, config)
|
||||||
except BaseException:
|
|
||||||
for target in installed:
|
|
||||||
shutil.rmtree(target)
|
|
||||||
for backup, target in backups:
|
|
||||||
backup.rename(target)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def sync_runtime(repo_root, package_root):
|
def sync_runtime(repo_root, package_root):
|
||||||
dst = repo_root / runtime_dir
|
dst = repo_root / runtime_dir
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Publish validated build directories with rollback on a failed replacement."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
def has_symlink(path):
|
||||||
|
path = pathlib.Path(path).absolute()
|
||||||
|
return any(parent.is_symlink() for parent in (path, *path.parents))
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def publish_directories(replacements):
|
||||||
|
pairs = [(pathlib.Path(source).absolute(), pathlib.Path(target).absolute()) for source, target in replacements]
|
||||||
|
paths = [path for pair in pairs for path in pair]
|
||||||
|
for path in paths:
|
||||||
|
if has_symlink(path):
|
||||||
|
raise ValueError(f'publication paths must not contain symlinks: {path}')
|
||||||
|
pairs = [(source.resolve(), target.resolve()) for source, target in pairs]
|
||||||
|
paths = [path for pair in pairs for path in pair]
|
||||||
|
for index, path in enumerate(paths):
|
||||||
|
if any(path == other or path.is_relative_to(other) or other.is_relative_to(path) for other in paths[:index]):
|
||||||
|
raise ValueError('publication paths must not overlap')
|
||||||
|
for source, target in pairs:
|
||||||
|
if not source.is_dir() or (target.exists() and not target.is_dir()):
|
||||||
|
raise ValueError('publication requires directories')
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if source.stat().st_dev != target.parent.stat().st_dev:
|
||||||
|
raise ValueError('publication must remain on the same filesystem')
|
||||||
|
installed, backups, recovery = [], [], []
|
||||||
|
try:
|
||||||
|
for source, target in pairs:
|
||||||
|
temporary = pathlib.Path(tempfile.mkdtemp(prefix='.publish_', dir=target.parent))
|
||||||
|
recovery.append(temporary)
|
||||||
|
if target.exists():
|
||||||
|
backup = temporary / 'previous'
|
||||||
|
target.rename(backup)
|
||||||
|
backups.append((backup, target))
|
||||||
|
source.rename(target)
|
||||||
|
installed.append(target)
|
||||||
|
yield
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
for target in reversed(installed):
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
for backup, target in reversed(backups):
|
||||||
|
backup.rename(target)
|
||||||
|
except BaseException as error:
|
||||||
|
raise RuntimeError(f'publication rollback failed; recovery directories retained: {recovery}') from error
|
||||||
|
for temporary in recovery:
|
||||||
|
shutil.rmtree(temporary)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
for temporary in recovery:
|
||||||
|
shutil.rmtree(temporary)
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
"""Shared batch/check and stdout-clean editor formatting transport."""
|
|
||||||
import argparse
|
import argparse
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
import os
|
||||||
@@ -6,7 +5,7 @@ import pathlib
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import modulus.clangd
|
import modulus.container
|
||||||
import modulus.format_layout
|
import modulus.format_layout
|
||||||
|
|
||||||
suffixes = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".py"}
|
suffixes = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".py"}
|
||||||
@@ -77,12 +76,12 @@ def launch(context, argv):
|
|||||||
try:
|
try:
|
||||||
if context.docker_marker.exists() or os.environ.get("MODULUS_DIRECT") == "1":
|
if context.docker_marker.exists() or os.environ.get("MODULUS_DIRECT") == "1":
|
||||||
return local_format(context, args)
|
return local_format(context, args)
|
||||||
image = modulus.clangd.current_image(context)
|
image = modulus.container.current_image(context)
|
||||||
if image is None:
|
if image is None:
|
||||||
raise RuntimeError("missing or stale build image; run configure first")
|
raise RuntimeError("missing or stale build image; run configure first")
|
||||||
root = context.repo_root
|
root = context.repo_root
|
||||||
mode = "ro" if args.check or args.stdin_filename else "rw"
|
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
|
return subprocess.run(command, check=False).returncode
|
||||||
except (OSError, ValueError, SyntaxError, ImportError, RuntimeError, subprocess.SubprocessError) as error:
|
except (OSError, ValueError, SyntaxError, ImportError, RuntimeError, subprocess.SubprocessError) as error:
|
||||||
detail = error.stderr if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error)
|
detail = error.stderr if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error)
|
||||||
|
|||||||
+103
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Optional, build-host Python dependencies and exported module execution."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import modulus.filesystem
|
||||||
|
|
||||||
|
GROUPS = ('assets',)
|
||||||
|
|
||||||
|
def requirement_files(context):
|
||||||
|
return [pathlib.Path(__file__).with_name(f'{group}_requirements.txt') for group in context.tooling['python']['groups']]
|
||||||
|
|
||||||
|
def identity(files):
|
||||||
|
data = [sys.version, str(pathlib.Path(sys.executable).resolve()), platform.system(), platform.machine(), [(path.name, path.read_text()) for path in files]]
|
||||||
|
return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
|
||||||
|
|
||||||
|
def interpreter(context, *, install=False, offline=False):
|
||||||
|
files = requirement_files(context)
|
||||||
|
if not files:
|
||||||
|
return pathlib.Path(sys.executable)
|
||||||
|
root = context.common_dir / 'python'
|
||||||
|
if modulus.filesystem.has_symlink(root):
|
||||||
|
raise ValueError('build Python environment must not contain symlinks in its directory path')
|
||||||
|
executable = root / 'bin' / 'python3'
|
||||||
|
marker = root / 'requirements.sha256'
|
||||||
|
fingerprint = identity(files)
|
||||||
|
if executable.is_file() and marker.is_file() and marker.read_text().strip() == fingerprint:
|
||||||
|
return executable
|
||||||
|
if not install or offline:
|
||||||
|
raise RuntimeError('Build Python dependencies are unavailable or changed; run ./modulus.py configure with network access first')
|
||||||
|
root.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run([sys.executable, '-m', 'venv', '--clear', str(root)], check=True)
|
||||||
|
subprocess.run([str(executable), '-m', 'pip', 'install', '--disable-pip-version-check', *(item for path in files for item in ('-r', str(path)))], check=True)
|
||||||
|
marker.write_text(fingerprint + '\n')
|
||||||
|
return executable
|
||||||
|
|
||||||
|
def module_root(context):
|
||||||
|
return context.tools_dir.parent if (context.tools_dir.parent / 'modulus' / 'cli.py').is_file() else context.common_dir / 'modulus'
|
||||||
|
|
||||||
|
def command(context, arguments):
|
||||||
|
return [str(interpreter(context)), str(context.tools_dir / 'python_runner.py'), str(module_root(context)), *arguments]
|
||||||
|
|
||||||
|
def cmake_definitions(context):
|
||||||
|
return {
|
||||||
|
'Python3_EXECUTABLE': str(interpreter(context)),
|
||||||
|
'MODULUS_PYTHON_RUNNER': str(context.tools_dir / 'python_runner.py'),
|
||||||
|
'MODULUS_PYTHON_ROOT': str(module_root(context)),
|
||||||
|
'MODULUS_PYTHON_REQUIREMENTS': ';'.join(str(path) for path in requirement_files(context)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def launch(context, arguments):
|
||||||
|
return subprocess.run(command(context, arguments), cwd=context.repo_root, check=False).returncode
|
||||||
@@ -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"]),
|
||||||
|
)
|
||||||
@@ -134,6 +134,17 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"python": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"groups": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {"enum": ["assets"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
add_executable(modulus-test
|
add_executable(modulus_test
|
||||||
test.cpp
|
test.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(modulus-test PRIVATE
|
target_include_directories(modulus_test PRIVATE
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}
|
${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
)
|
)
|
||||||
target_link_libraries(modulus-test PRIVATE
|
target_link_libraries(modulus_test PRIVATE
|
||||||
modulus::modulus
|
modulus::modulus
|
||||||
)
|
)
|
||||||
target_compile_features(modulus-test PRIVATE cxx_std_23)
|
target_compile_features(modulus_test PRIVATE cxx_std_23)
|
||||||
target_compile_definitions(modulus-test PRIVATE
|
target_compile_definitions(modulus_test PRIVATE
|
||||||
$<$<CONFIG:Debug>:MODULUS_EXPECT_DEBUG_ASSERTS>
|
$<$<CONFIG:Debug>:MODULUS_EXPECT_DEBUG_ASSERTS>
|
||||||
$<$<CONFIG:Release>:MODULUS_EXPECT_NDEBUG>
|
$<$<CONFIG:Release>:MODULUS_EXPECT_NDEBUG>
|
||||||
$<$<CONFIG:RelWithDebInfo>:MODULUS_EXPECT_NDEBUG>
|
$<$<CONFIG:RelWithDebInfo>:MODULUS_EXPECT_NDEBUG>
|
||||||
$<$<CONFIG:MinSizeRel>:MODULUS_EXPECT_NDEBUG>
|
$<$<CONFIG:MinSizeRel>:MODULUS_EXPECT_NDEBUG>
|
||||||
)
|
)
|
||||||
add_test(NAME modulus COMMAND modulus-test)
|
add_test(NAME modulus COMMAND modulus_test)
|
||||||
set_tests_properties(modulus PROPERTIES TIMEOUT 20)
|
set_tests_properties(modulus PROPERTIES TIMEOUT 20)
|
||||||
add_custom_target(check
|
add_custom_target(check
|
||||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import modulus.build_context
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import modulus.artifacts
|
||||||
|
import modulus.config
|
||||||
|
|
||||||
|
class ArtifactTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
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.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')
|
||||||
|
self.archive.write_bytes(b'package')
|
||||||
|
self.checksum = modulus.artifacts.write_checksum(self.archive)
|
||||||
|
self.enterContext(unittest.mock.patch.dict(os.environ, {'PACKAGE_REGISTRY_USER': 'test', 'PACKAGE_REGISTRY_TOKEN': 'secret'}))
|
||||||
|
def test_discovery_uses_the_same_names_as_creation(self):
|
||||||
|
self.assertEqual(self.archive.name, 'example_arm_64_gcc12_release.tar.gz')
|
||||||
|
self.assertEqual(modulus.artifacts.discover_artifacts(self.directory, 'example', ['arm_64_gcc12_release']), [self.archive, self.checksum])
|
||||||
|
def test_missing_or_invalid_checksums_never_touch_registry(self):
|
||||||
|
with unittest.mock.patch.object(modulus.artifacts, 'request') as request:
|
||||||
|
self.archive.write_bytes(b'corrupt')
|
||||||
|
with self.assertRaisesRegex(SystemExit, 'checksum mismatch'):
|
||||||
|
modulus.artifacts.main(self.context, [])
|
||||||
|
self.checksum.unlink()
|
||||||
|
with self.assertRaisesRegex(SystemExit, 'missing artifact'):
|
||||||
|
modulus.artifacts.main(self.context, [])
|
||||||
|
request.assert_not_called()
|
||||||
|
def test_replacement_and_cleanup_are_ordered_after_validation(self):
|
||||||
|
events = []
|
||||||
|
with unittest.mock.patch.object(modulus.artifacts, 'list_versions', return_value=['latest', 'old']), \
|
||||||
|
unittest.mock.patch.object(modulus.artifacts, 'delete_version', side_effect=lambda *args: events.append(('delete', args[-2]))), \
|
||||||
|
unittest.mock.patch.object(modulus.artifacts, 'upload_file', side_effect=lambda *args: events.append(('upload', args[2].name))):
|
||||||
|
self.assertEqual(modulus.artifacts.main(self.context, []), 0)
|
||||||
|
self.assertEqual(events, [('delete', 'latest'), ('upload', self.archive.name), ('upload', self.checksum.name), ('delete', 'old')])
|
||||||
|
def test_failed_upload_does_not_delete_other_slots(self):
|
||||||
|
with unittest.mock.patch.object(modulus.artifacts, 'list_versions', return_value=['latest', 'old']), \
|
||||||
|
unittest.mock.patch.object(modulus.artifacts, 'delete_version') as delete, \
|
||||||
|
unittest.mock.patch.object(modulus.artifacts, 'upload_file', side_effect=SystemExit('upload failed')):
|
||||||
|
with self.assertRaisesRegex(SystemExit, 'upload failed'):
|
||||||
|
modulus.artifacts.main(self.context, [])
|
||||||
|
self.assertEqual([call.args[-2] for call in delete.call_args_list], ['latest'])
|
||||||
|
def test_request_errors_and_credentials_are_not_logged(self):
|
||||||
|
for code in (401, 500):
|
||||||
|
with unittest.mock.patch.object(modulus.artifacts.urllib.request, 'urlopen', side_effect=urllib.error.HTTPError('https://example.test', code, 'failed', {}, None)):
|
||||||
|
with self.assertRaises(SystemExit) as caught:
|
||||||
|
modulus.artifacts.request('PUT', 'https://example.test', 'secret')
|
||||||
|
self.assertNotIn('secret', str(caught.exception))
|
||||||
+15
-10
@@ -1,3 +1,8 @@
|
|||||||
|
import modulus.conan
|
||||||
|
import modulus.build_context
|
||||||
|
import modulus.container
|
||||||
|
import modulus.packaging
|
||||||
|
import modulus.process
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -31,9 +36,9 @@ class BootstrapTests(unittest.TestCase):
|
|||||||
def test_read_environments_strip_all_credential_forms(self):
|
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')
|
credentials = dict.fromkeys(('CONAN_USER', 'CONAN_TOKEN', 'CONAN_LOGIN_USERNAME', 'CONAN_PASSWORD', 'CONAN_LOGIN_USERNAME_KHODO', 'CONAN_PASSWORD_KHODO'), 'secret')
|
||||||
root = Path('/tmp/project')
|
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):
|
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.assertFalse(set(credentials) & env.keys())
|
||||||
self.assertEqual(env['CONAN_NON_INTERACTIVE'], '1')
|
self.assertEqual(env['CONAN_NON_INTERACTIVE'], '1')
|
||||||
self.assertEqual(env['CONAN_HOME'], str(root / 'build/build_common/conan_cache'))
|
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)
|
modulus.export.sync_project(root, package)
|
||||||
self.assertEqual(runtime.read_text(), 'old runtime')
|
self.assertEqual(runtime.read_text(), 'old runtime')
|
||||||
def test_upload_exception_still_logs_out(self):
|
def test_upload_exception_still_logs_out(self):
|
||||||
context = modulus.cli.build_context(Path('/tmp/project'), modulus.config.normalize({'package': {'name': 'sample'}}))
|
context = modulus.build_context.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:
|
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'):
|
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'])
|
run.assert_called_once_with(context, ['conan', 'remote', 'logout', 'khodo'])
|
||||||
def test_socket_is_scoped_and_docker_inputs_change_fingerprint(self):
|
def test_socket_is_scoped_and_docker_inputs_change_fingerprint(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
config = modulus.config.normalize({'package': {'name': 'sample'}, 'tooling': {'docker': {'dockerfile': 'package/build/Dockerfile', 'inputs': ['requirements.txt'], 'socket_commands': ['build', 'package']}}})
|
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.parent.mkdir(parents=True)
|
||||||
context.dockerfile.write_text('FROM scratch')
|
context.dockerfile.write_text('FROM scratch')
|
||||||
context.docker_inputs[0].parent.mkdir(parents=True)
|
context.docker_inputs[0].parent.mkdir(parents=True)
|
||||||
for path in context.docker_inputs:
|
for path in context.docker_inputs:
|
||||||
path.write_text('old')
|
path.write_text('old')
|
||||||
before = modulus.clangd.recipe_digest(context)
|
before = modulus.container.recipe_digest(context)
|
||||||
context.docker_inputs[-1].write_text('new')
|
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:
|
with patch.object(Path, 'is_socket', return_value=True), patch.object(Path, 'stat') as stat:
|
||||||
stat.return_value.st_gid = 123
|
stat.return_value.st_gid = 123
|
||||||
for command in ('configure', 'test', 'lsp', 'clangd', 'format'):
|
for command in ('configure', 'test', 'lsp', 'clangd', 'format'):
|
||||||
self.assertEqual(modulus.cli.docker_socket_args(context, command), [])
|
self.assertEqual(modulus.container.docker_socket_args(context, command), [])
|
||||||
self.assertIn('/var/run/docker.sock:/var/run/docker.sock', modulus.cli.docker_socket_args(context, 'package'))
|
self.assertIn('/var/run/docker.sock:/var/run/docker.sock', modulus.container.docker_socket_args(context, 'package'))
|
||||||
|
|||||||
+19
-15
@@ -1,3 +1,7 @@
|
|||||||
|
import modulus.process
|
||||||
|
import modulus.build_context
|
||||||
|
import modulus.container
|
||||||
|
import modulus.lsp
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -19,7 +23,7 @@ class clangd_test(unittest.TestCase):
|
|||||||
temporary = tempfile.TemporaryDirectory()
|
temporary = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(temporary.cleanup)
|
self.addCleanup(temporary.cleanup)
|
||||||
self.root = pathlib.Path(temporary.name)
|
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.docker_marker = self.root / "not-in-container"
|
||||||
self.context.dockerfile.parent.mkdir(parents=True)
|
self.context.dockerfile.parent.mkdir(parents=True)
|
||||||
self.context.dockerfile.write_text("FROM sample\n")
|
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):
|
with self.subTest(invalid=invalid), self.assertRaises(modulus.config.config_error):
|
||||||
modulus.config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": invalid}}})
|
modulus.config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": invalid}}})
|
||||||
def test_image_fingerprint_and_architecture(self):
|
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]))):
|
with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, json.dumps([image]))):
|
||||||
self.assertEqual(modulus.clangd.current_image(self.context), self.image)
|
self.assertEqual(modulus.container.current_image(self.context), self.image)
|
||||||
self.assertIsNone(modulus.clangd.current_image(self.context, "arm64"))
|
self.assertIsNone(modulus.container.current_image(self.context, "arm64"))
|
||||||
self.context.dockerfile.write_text("FROM changed\n")
|
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):
|
def test_cache_is_shared_by_image_and_header_settings_not_repo(self):
|
||||||
cache = modulus.clangd.cache_directory(self.context, self.image)
|
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.assertEqual(cache, modulus.clangd.cache_directory(other, self.image))
|
||||||
self.context.lsp_settings["clangd"]["arguments"].append("--log=error")
|
self.context.lsp_settings["clangd"]["arguments"].append("--log=error")
|
||||||
self.assertEqual(cache, modulus.clangd.cache_directory(self.context, self.image))
|
self.assertEqual(cache, modulus.clangd.cache_directory(self.context, self.image))
|
||||||
@@ -66,11 +70,11 @@ class clangd_test(unittest.TestCase):
|
|||||||
self.assertNotIn(unwanted, " ".join(command))
|
self.assertNotIn(unwanted, " ".join(command))
|
||||||
volumes = [command[index + 1] for index, argument in enumerate(command) if argument == "--volume"]
|
volumes = [command[index + 1] for index, argument in enumerate(command) if argument == "--volume"]
|
||||||
self.assertFalse(any(volume.startswith(str(pathlib.Path.home()) + ":") for volume in volumes))
|
self.assertFalse(any(volume.startswith(str(pathlib.Path.home()) + ":") for volume in volumes))
|
||||||
self.assertIn("--query-driver=/usr/bin/gcc-12,/usr/bin/g++-12,/usr/bin/aarch64-linux-gnu-gcc,/usr/bin/aarch64-linux-gnu-g++", command)
|
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):
|
def test_startup_failure_does_not_prepare_and_keeps_stdout_clean(self):
|
||||||
output, errors = io.StringIO(), io.StringIO()
|
output, errors = io.StringIO(), io.StringIO()
|
||||||
with patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.clangd, "prepare") as prepare, \
|
with patch.object(modulus.container, "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):
|
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)
|
self.assertEqual(modulus.clangd.launch(self.context, []), 1)
|
||||||
prepare.assert_not_called()
|
prepare.assert_not_called()
|
||||||
build.assert_not_called()
|
build.assert_not_called()
|
||||||
@@ -100,7 +104,7 @@ class clangd_test(unittest.TestCase):
|
|||||||
cache = self.root / "cache"
|
cache = self.root / "cache"
|
||||||
(cache / "rootfs/usr/include").mkdir(parents=True)
|
(cache / "rootfs/usr/include").mkdir(parents=True)
|
||||||
(cache / "manifest.json").write_text(json.dumps({"version": modulus.clangd.cache_version, "roots": ["/usr/include"]}))
|
(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:
|
patch.object(subprocess, "run") as run:
|
||||||
self.assertEqual(modulus.clangd.prepare(self.context), 0)
|
self.assertEqual(modulus.clangd.prepare(self.context), 0)
|
||||||
run.assert_not_called()
|
run.assert_not_called()
|
||||||
@@ -109,12 +113,12 @@ class clangd_test(unittest.TestCase):
|
|||||||
process = MagicMock()
|
process = MagicMock()
|
||||||
process.__enter__.return_value = process
|
process.__enter__.return_value = process
|
||||||
process.stdout = self.archive("../escape")
|
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, "run", return_value=subprocess.CompletedProcess([], 0, '["/usr/include"]')), \
|
||||||
patch.object(subprocess, "Popen", return_value=process), contextlib.redirect_stderr(io.StringIO()):
|
patch.object(subprocess, "Popen", return_value=process), contextlib.redirect_stderr(io.StringIO()):
|
||||||
self.assertEqual(modulus.clangd.prepare(self.context), 1)
|
self.assertEqual(modulus.clangd.prepare(self.context), 1)
|
||||||
self.assertFalse(cache.exists())
|
self.assertFalse(cache.exists())
|
||||||
self.assertEqual(list(cache.parent.glob(".headers-*")), [])
|
self.assertEqual(list(cache.parent.glob(".headers_*")), [])
|
||||||
def test_cleanup_always_waits_for_child_even_if_docker_removal_times_out(self):
|
def test_cleanup_always_waits_for_child_even_if_docker_removal_times_out(self):
|
||||||
process = MagicMock()
|
process = MagicMock()
|
||||||
process.wait.side_effect = [0, subprocess.TimeoutExpired("docker", 5), 0]
|
process.wait.side_effect = [0, subprocess.TimeoutExpired("docker", 5), 0]
|
||||||
@@ -138,13 +142,13 @@ class clangd_test(unittest.TestCase):
|
|||||||
modulus.export.sync_nvim_file(self.root, {"enabled": False})
|
modulus.export.sync_nvim_file(self.root, {"enabled": False})
|
||||||
self.assertEqual(path.read_text(), "-- user settings\n")
|
self.assertEqual(path.read_text(), "-- user settings\n")
|
||||||
def test_lsp_prepares_after_successful_database_refresh_only(self):
|
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:
|
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.assert_called_once_with(self.context)
|
||||||
prepare.reset_mock()
|
prepare.reset_mock()
|
||||||
run.return_value = 1
|
run.return_value = 1
|
||||||
self.assertEqual(modulus.cli.lsp(self.context, []), 1)
|
self.assertEqual(modulus.lsp.lsp(self.context, []), 1)
|
||||||
prepare.assert_not_called()
|
prepare.assert_not_called()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class container_clangd_test(unittest.TestCase):
|
|||||||
session.close()
|
session.close()
|
||||||
def test_eof_and_termination_remove_container(self):
|
def test_eof_and_termination_remove_container(self):
|
||||||
def containers():
|
def containers():
|
||||||
return set(subprocess.check_output(["docker", "ps", "-q", "--filter", "name=modulus-clangd-"], text=True).split())
|
return set(subprocess.check_output(["docker", "ps", "-q", "--filter", "name=modulus_clangd_"], text=True).split())
|
||||||
before = containers()
|
before = containers()
|
||||||
for terminate in (False, True):
|
for terminate in (False, True):
|
||||||
session = lsp_session(self.roots()[0])
|
session = lsp_session(self.roots()[0])
|
||||||
|
|||||||
+152
-158
@@ -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 json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -58,7 +67,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"name": "sample",
|
"name": "sample",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return modulus.cli.build_context(repo_root or pathlib.Path(os.getcwd()), 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):
|
def test_cli_import_does_not_import_conan_recipe_module(self):
|
||||||
repo = pathlib.Path(__file__).resolve().parents[1]
|
repo = pathlib.Path(__file__).resolve().parents[1]
|
||||||
command = [
|
command = [
|
||||||
@@ -68,13 +77,17 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
completed = subprocess.run(command, cwd=repo, check=False)
|
completed = subprocess.run(command, cwd=repo, check=False)
|
||||||
self.assertEqual(completed.returncode, 0)
|
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):
|
def test_project_root_build_is_rejected(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = pathlib.Path(directory)
|
root = pathlib.Path(directory)
|
||||||
context = self.context(root)
|
context = self.context(root)
|
||||||
context.config["tooling"]["targets"]["native"]["build_dir"] = "."
|
context.config["tooling"]["targets"]["native"]["build_dir"] = "."
|
||||||
with self.assertRaisesRegex(modulus.config.config_error, "project and source roots"):
|
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):
|
def test_configure_and_lsp_refresh_project_editor_settings(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = pathlib.Path(directory)
|
root = pathlib.Path(directory)
|
||||||
@@ -84,9 +97,9 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
config = {"package": {"name": "sample"}, "tooling": {"editor": {"clangd": fragments}}}
|
config = {"package": {"name": "sample"}, "tooling": {"editor": {"clangd": fragments}}}
|
||||||
(root / "modulus.json").write_text(json.dumps(config), encoding="utf-8")
|
(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"):
|
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")
|
(root / ".clangd").write_text("stale", encoding="utf-8")
|
||||||
self.assertEqual(modulus.cli.main(command), 0)
|
self.assertEqual(modulus.cli.main(command), 0)
|
||||||
content = (root / ".clangd").read_text(encoding="utf-8").split("\n", 1)[1]
|
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)
|
context = self.context(root)
|
||||||
cache = root / "CMakeCache.txt"
|
cache = root / "CMakeCache.txt"
|
||||||
cache.write_text("keep", encoding="utf-8")
|
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"):
|
for build_dir in (".", "alias", "subproject"):
|
||||||
if build_dir == "alias":
|
if build_dir == "alias":
|
||||||
(root / "alias").symlink_to(root, target_is_directory=True)
|
(root / "alias").symlink_to(root, target_is_directory=True)
|
||||||
with self.subTest(build_dir=build_dir), self.assertRaises(modulus.config.config_error):
|
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()
|
clear.assert_not_called()
|
||||||
self.assertEqual(cache.read_text(encoding="utf-8"), "keep")
|
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.packaging, "upload_all_packages", return_value=37)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", 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):
|
def test_failed_upload_does_not_force_by_default(self, run, configure, upload):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(os.environ, {}, clear=True):
|
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)
|
self.assertEqual(status, 37)
|
||||||
configure.assert_called_once_with(context, authenticate=True)
|
configure.assert_called_once_with(context, authenticate=True)
|
||||||
upload.assert_called_once_with(context, "origin", force=False)
|
upload.assert_called_once_with(context, "origin", force=False)
|
||||||
run.assert_called_once_with(context, ["conan", "remote", "logout", "origin"])
|
run.assert_called_once_with(context, ["conan", "remote", "logout", "origin"])
|
||||||
@unittest.mock.patch.object(modulus.cli, "upload_all_packages")
|
@unittest.mock.patch.object(modulus.packaging, "upload_all_packages")
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote")
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote")
|
||||||
def test_replace_requires_guard_env(self, configure, upload):
|
def test_replace_requires_guard_env(self, configure, upload):
|
||||||
with unittest.mock.patch.dict(os.environ, {}, clear=True):
|
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)
|
self.assertEqual(status, 1)
|
||||||
configure.assert_not_called()
|
configure.assert_not_called()
|
||||||
upload.assert_not_called()
|
upload.assert_not_called()
|
||||||
@unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "upload_all_packages", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
def test_replace_forces_upload_when_guarded(self, run, configure, upload):
|
def test_replace_forces_upload_when_guarded(self, run, configure, upload):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -139,20 +152,20 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.upload_packages(context, replace=True)
|
status = modulus.packaging.upload_packages(context, replace=True)
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
configure.assert_called_once_with(context, authenticate=True)
|
configure.assert_called_once_with(context, authenticate=True)
|
||||||
upload.assert_called_once_with(context, "origin", force=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):
|
def test_upload_defaults_to_latest_reference(self, run):
|
||||||
with unittest.mock.patch.dict(os.environ, {}, clear=True):
|
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.assertEqual(status, 0)
|
||||||
self.assertIn("sample/latest:*", run.call_args.args[1])
|
self.assertIn("sample/latest:*", run.call_args.args[1])
|
||||||
self.assertNotIn("--force", 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):
|
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.assertEqual(status, 0)
|
||||||
self.assertIn("--force", run.call_args.args[1])
|
self.assertIn("--force", run.call_args.args[1])
|
||||||
def test_checked_in_ci_overwrites_latest_on_master(self):
|
def test_checked_in_ci_overwrites_latest_on_master(self):
|
||||||
@@ -187,11 +200,11 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(previous_cwd)
|
os.chdir(previous_cwd)
|
||||||
def test_docker_passthrough_includes_replace_env(self):
|
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)
|
self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", passthrough)
|
||||||
for name in ("CONAN_USER", "CONAN_TOKEN", "PACKAGE_REGISTRY_USER", "PACKAGE_REGISTRY_TOKEN"):
|
for name in ("CONAN_USER", "CONAN_TOKEN", "PACKAGE_REGISTRY_USER", "PACKAGE_REGISTRY_TOKEN"):
|
||||||
self.assertNotIn(name, passthrough)
|
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.assertIn("SAMPLE_PACKAGE_UPLOAD", passthrough)
|
||||||
self.assertNotIn("CONAN_PACKAGE_VERSION", passthrough)
|
self.assertNotIn("CONAN_PACKAGE_VERSION", passthrough)
|
||||||
self.assertNotIn("MODULUS_EXPORT_RUNTIME", 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):
|
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)
|
self.assertEqual(status, 1)
|
||||||
docker.assert_not_called()
|
docker.assert_not_called()
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@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_executes_configured_product_executable(self, docker, run):
|
def test_run_executes_configured_product_executable(self, docker, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
repo = pathlib.Path(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)
|
context.default_target.build_dir.mkdir(parents=True)
|
||||||
executable = context.default_target.build_dir / "sample-app"
|
executable = context.default_target.build_dir / "sample-app"
|
||||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
status = modulus.cli.product_run(
|
status = modulus.run.product_run(
|
||||||
context,
|
context,
|
||||||
[
|
[
|
||||||
"--",
|
"--",
|
||||||
@@ -271,15 +284,15 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(run.call_args.kwargs["env_overrides"], {"SAMPLE_WEB_ROOT": "build/web"})
|
self.assertEqual(run.call_args.kwargs["env_overrides"], {"SAMPLE_WEB_ROOT": "build/web"})
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "docker_run", return_value=0)
|
@unittest.mock.patch.object(modulus.container, "docker_run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=False)
|
@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):
|
def test_run_publishes_configured_ports_when_wrapped_by_docker(self, docker, docker_run):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
context.run_settings.update({
|
context.run_settings.update({
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"ports": ["127.0.0.1:8000:8000/tcp"],
|
"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)
|
self.assertEqual(status, 0)
|
||||||
docker_run.assert_called_once_with(
|
docker_run.assert_called_once_with(
|
||||||
context,
|
context,
|
||||||
@@ -294,7 +307,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
docker.assert_called_once()
|
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):
|
def test_build_image_honors_docker_platform_env(self, run):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -304,7 +317,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.build_image(context)
|
status = modulus.container.build_image(context)
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
run.call_args.args[1],
|
run.call_args.args[1],
|
||||||
@@ -318,15 +331,15 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"--tag",
|
"--tag",
|
||||||
"sample-build:dev",
|
"sample-build:dev",
|
||||||
"--label",
|
"--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",
|
"--build-arg",
|
||||||
"MODULUS_TOOLS=tools",
|
"MODULUS_TOOLS=tools",
|
||||||
".",
|
".",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0)
|
@unittest.mock.patch.object(modulus.container, "ensure_build_image", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_container", return_value=False)
|
@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):
|
def test_docker_run_honors_docker_platform_env(self, in_container, run, ensure_image):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -336,7 +349,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.docker_run(context, ["true"])
|
status = modulus.container.docker_run(context, ["true"])
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertIn("--platform", run.call_args.args[1])
|
self.assertIn("--platform", run.call_args.args[1])
|
||||||
self.assertIn("linux/arm64", run.call_args.args[1])
|
self.assertIn("linux/arm64", run.call_args.args[1])
|
||||||
@@ -344,7 +357,18 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
in_container.assert_called_once()
|
in_container.assert_called_once()
|
||||||
def test_conan_remote_url_uses_config_by_default(self):
|
def test_conan_remote_url_uses_config_by_default(self):
|
||||||
with unittest.mock.patch.dict(os.environ, {}, clear=True):
|
with unittest.mock.patch.dict(os.environ, {}, 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")
|
||||||
|
@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.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.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):
|
def test_conan_remote_url_uses_config_in_ci(self):
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -353,7 +377,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
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):
|
def test_conan_credentials_only_uses_conan_environment(self):
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -364,13 +388,13 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
user, token = modulus.cli.conan_credentials()
|
user, token = modulus.conan.conan_credentials()
|
||||||
self.assertEqual(user, "dev")
|
self.assertEqual(user, "dev")
|
||||||
self.assertEqual(token, "secret")
|
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):
|
def test_configure_remote_skips_login_without_conan_credentials(self, run):
|
||||||
with unittest.mock.patch.dict(os.environ, {}, clear=True):
|
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(status, 0)
|
||||||
self.assertEqual(len(run.call_args_list), 2)
|
self.assertEqual(len(run.call_args_list), 2)
|
||||||
self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
|
self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
|
||||||
@@ -385,13 +409,13 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"https://example.test/conan",
|
"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):
|
def test_configure_remote_ignores_partial_conan_credentials(self, run):
|
||||||
with unittest.mock.patch.dict(os.environ, {"CONAN_USER": "dev"}, clear=True):
|
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(status, 0)
|
||||||
self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
|
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):
|
def test_configure_remote_skips_login_until_authentication_is_requested(self, run):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -402,7 +426,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.configure_conan_remote(context)
|
status = modulus.conan.configure_conan_remote(context)
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
self.assertEqual(len(run.call_args_list), 2)
|
self.assertEqual(len(run.call_args_list), 2)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -416,7 +440,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"https://example.test/conan",
|
"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):
|
def test_configure_remote_logs_in_with_hidden_token_when_authenticating(self, run):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -427,7 +451,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
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(status, 0)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
run.call_args_list[0].args[1],
|
run.call_args_list[0].args[1],
|
||||||
@@ -515,7 +539,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"name": "sample",
|
"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):
|
def test_selected_targets_default_to_default_target_only(self):
|
||||||
context = self.multi_target_context()
|
context = self.multi_target_context()
|
||||||
selected = context.selected_targets(
|
selected = context.selected_targets(
|
||||||
@@ -530,8 +554,8 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
self.assertEqual([target.name for target in selected], ["debug", "release"])
|
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.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@unittest.mock.patch.object(modulus.process, "in_docker", return_value=True)
|
||||||
def test_build_can_target_one_cmake_target(self, docker, run):
|
def test_build_can_target_one_cmake_target(self, docker, run):
|
||||||
directory = tempfile.TemporaryDirectory()
|
directory = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(directory.cleanup)
|
self.addCleanup(directory.cleanup)
|
||||||
@@ -539,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.mkdir(parents=True, exist_ok=True)
|
||||||
(context.default_target.build_dir / "CMakeCache.txt").write_text("", encoding="utf-8")
|
(context.default_target.build_dir / "CMakeCache.txt").write_text("", encoding="utf-8")
|
||||||
(context.default_target.build_dir / "build.ninja").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,
|
context,
|
||||||
[
|
[
|
||||||
"--target",
|
"--target",
|
||||||
@@ -562,14 +586,14 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
|
@unittest.mock.patch.object(modulus.build, "configure_one", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_build_auto_configures_missing_target(self, docker, configure_remote, configure_one, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(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)
|
self.assertEqual(status, 0)
|
||||||
configure_remote.assert_called_once_with(context)
|
configure_remote.assert_called_once_with(context)
|
||||||
configure_one.assert_called_once_with(context, context.default_target, update=False)
|
configure_one.assert_called_once_with(context, context.default_target, update=False)
|
||||||
@@ -584,17 +608,17 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
|
@unittest.mock.patch.object(modulus.build, "configure_one", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_build_skips_configure_when_target_is_configured(self, docker, configure_remote, configure_one, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(repo_dir))
|
context = self.context(repo_root=pathlib.Path(repo_dir))
|
||||||
context.default_target.build_dir.mkdir(parents=True)
|
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 / "CMakeCache.txt").write_text("", encoding="utf-8")
|
||||||
(context.default_target.build_dir / "build.ninja").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)
|
self.assertEqual(status, 0)
|
||||||
configure_remote.assert_not_called()
|
configure_remote.assert_not_called()
|
||||||
configure_one.assert_not_called()
|
configure_one.assert_not_called()
|
||||||
@@ -609,10 +633,10 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
|
@unittest.mock.patch.object(modulus.build, "configure_one", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_build_reconfigures_when_target_cmake_definition_differs(self, docker, configure_remote, configure_one, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(repo_dir))
|
context = self.context(repo_root=pathlib.Path(repo_dir))
|
||||||
@@ -622,7 +646,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
context.default_target.build_dir.mkdir(parents=True)
|
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 / "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")
|
(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)
|
self.assertEqual(status, 0)
|
||||||
configure_remote.assert_called_once_with(context)
|
configure_remote.assert_called_once_with(context)
|
||||||
configure_one.assert_called_once_with(context, context.default_target, update=False)
|
configure_one.assert_called_once_with(context, context.default_target, update=False)
|
||||||
@@ -637,26 +661,26 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=37)
|
@unittest.mock.patch.object(modulus.build, "configure_one", return_value=37)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_build_stops_when_auto_configure_fails(self, docker, configure_remote, configure_one, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(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)
|
self.assertEqual(status, 37)
|
||||||
configure_remote.assert_called_once_with(context)
|
configure_remote.assert_called_once_with(context)
|
||||||
configure_one.assert_called_once_with(context, context.default_target, update=False)
|
configure_one.assert_called_once_with(context, context.default_target, update=False)
|
||||||
run.assert_not_called()
|
run.assert_not_called()
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
|
@unittest.mock.patch.object(modulus.build, "configure_one", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_configure_no_remote_skips_remote_setup(self, docker, configure_remote, configure_one):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(repo_dir))
|
context = self.context(repo_root=pathlib.Path(repo_dir))
|
||||||
status = modulus.cli.configure(
|
status = modulus.build.configure(
|
||||||
context,
|
context,
|
||||||
[
|
[
|
||||||
"--target",
|
"--target",
|
||||||
@@ -668,10 +692,10 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
configure_remote.assert_not_called()
|
configure_remote.assert_not_called()
|
||||||
configure_one.assert_called_once_with(context, context.default_target, False, no_remote=True)
|
configure_one.assert_called_once_with(context, context.default_target, False, no_remote=True)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "upload_packages", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_package_no_remote_skips_remote_setup_and_upload(self, docker, configure_remote, create_package, upload):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -681,7 +705,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.package(
|
status = modulus.packaging.package(
|
||||||
context,
|
context,
|
||||||
[
|
[
|
||||||
"--target",
|
"--target",
|
||||||
@@ -694,10 +718,10 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
create_package.assert_called_once_with(context, context.default_target, False, no_remote=True)
|
create_package.assert_called_once_with(context, context.default_target, False, no_remote=True)
|
||||||
upload.assert_not_called()
|
upload.assert_not_called()
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "upload_packages", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_package_configures_remote_once_before_upload(self, docker, configure_remote, create_package, upload):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -709,7 +733,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.package(
|
status = modulus.packaging.package(
|
||||||
context,
|
context,
|
||||||
[
|
[
|
||||||
"--target",
|
"--target",
|
||||||
@@ -721,10 +745,10 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
create_package.assert_called_once_with(context, context.default_target, False, no_remote=False)
|
create_package.assert_called_once_with(context, context.default_target, False, no_remote=False)
|
||||||
upload.assert_called_once_with(context, replace=False)
|
upload.assert_called_once_with(context, replace=False)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=1)
|
@unittest.mock.patch.object(modulus.packaging, "upload_packages", return_value=1)
|
||||||
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_package_defers_conan_upload_credentials_until_after_create(self, docker, configure_remote, create_package, upload):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -734,7 +758,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.package(
|
status = modulus.packaging.package(
|
||||||
context,
|
context,
|
||||||
[
|
[
|
||||||
"--target",
|
"--target",
|
||||||
@@ -746,9 +770,9 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
create_package.assert_called_once_with(context, context.default_target, False, no_remote=False)
|
create_package.assert_called_once_with(context, context.default_target, False, no_remote=False)
|
||||||
upload.assert_called_once_with(context, replace=False)
|
upload.assert_called_once_with(context, replace=False)
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
|
@unittest.mock.patch.object(modulus.packaging, "create_package", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@unittest.mock.patch.object(modulus.process, "in_docker", return_value=True)
|
||||||
def test_package_checks_credentials_only_at_upload(self, docker, run, create_package):
|
def test_package_checks_credentials_only_at_upload(self, docker, run, create_package):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
with unittest.mock.patch.dict(
|
with unittest.mock.patch.dict(
|
||||||
@@ -759,7 +783,7 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
status = modulus.cli.package(
|
status = modulus.packaging.package(
|
||||||
context,
|
context,
|
||||||
[
|
[
|
||||||
"--target",
|
"--target",
|
||||||
@@ -770,11 +794,11 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
create_package.assert_called_once()
|
create_package.assert_called_once()
|
||||||
self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
|
self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
|
||||||
docker.assert_called_once()
|
docker.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "clear_conan_deps")
|
@unittest.mock.patch.object(modulus.build, "clear_conan_deps")
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
def test_configure_one_passes_no_remote_to_conan_install(self, run, clear):
|
def test_configure_one_passes_no_remote_to_conan_install(self, run, clear):
|
||||||
context = self.context()
|
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)
|
self.assertEqual(status, 0)
|
||||||
conan_command = run.call_args_list[0].args[1]
|
conan_command = run.call_args_list[0].args[1]
|
||||||
self.assertIn("--no-remote", conan_command)
|
self.assertIn("--no-remote", conan_command)
|
||||||
@@ -785,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(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])
|
self.assertIn("-DCMAKE_BUILD_TYPE=Release", run.call_args_list[1].args[1])
|
||||||
clear.assert_called_once()
|
clear.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "clear_conan_deps")
|
@unittest.mock.patch.object(modulus.build, "clear_conan_deps")
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
def test_configure_one_passes_target_cmake_definitions(self, run, clear):
|
def test_configure_one_passes_target_cmake_definitions(self, run, clear):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
context.default_target.cmake_definitions = {
|
context.default_target.cmake_definitions = {
|
||||||
@@ -794,17 +818,17 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"SAMPLE_MODE": "dev",
|
"SAMPLE_MODE": "dev",
|
||||||
"SAMPLE_LIMIT": 2,
|
"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)
|
self.assertEqual(status, 0)
|
||||||
cmake_command = run.call_args_list[1].args[1]
|
cmake_command = run.call_args_list[1].args[1]
|
||||||
self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command)
|
self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command)
|
||||||
self.assertIn("-DSAMPLE_MODE=dev", cmake_command)
|
self.assertIn("-DSAMPLE_MODE=dev", cmake_command)
|
||||||
self.assertIn("-DSAMPLE_LIMIT=2", cmake_command)
|
self.assertIn("-DSAMPLE_LIMIT=2", cmake_command)
|
||||||
clear.assert_called_once()
|
clear.assert_called_once()
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_lsp_database", return_value=0)
|
@unittest.mock.patch.object(modulus.lsp, "configure_lsp_database", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
|
@unittest.mock.patch.object(modulus.build, "configure_one", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
|
@unittest.mock.patch.object(modulus.conan, "configure_conan_remote", return_value=0)
|
||||||
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
|
@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):
|
def test_lsp_configures_default_target_and_extra_databases(self, docker, configure_remote, configure_one, configure_lsp):
|
||||||
context = self.context()
|
context = self.context()
|
||||||
database = {
|
database = {
|
||||||
@@ -822,18 +846,18 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"compile_databases": [database],
|
"compile_databases": [database],
|
||||||
}
|
}
|
||||||
status = modulus.cli.lsp(context, [])
|
status = modulus.lsp.lsp(context, [])
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
configure_remote.assert_called_once_with(context)
|
configure_remote.assert_called_once_with(context)
|
||||||
configure_one.assert_called_once_with(context, context.default_target, False, no_remote=False)
|
configure_one.assert_called_once_with(context, context.default_target, False, no_remote=False)
|
||||||
configure_lsp.assert_called_once_with(context, database)
|
configure_lsp.assert_called_once_with(context, database)
|
||||||
docker.assert_called_once()
|
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):
|
def test_configure_lsp_database_passes_cmake_definitions_and_unsets_env(self, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
repo = pathlib.Path(repo_dir)
|
repo = pathlib.Path(repo_dir)
|
||||||
context = self.context(repo_root=repo)
|
context = self.context(repo_root=repo)
|
||||||
status = modulus.cli.configure_lsp_database(
|
status = modulus.lsp.configure_lsp_database(
|
||||||
context,
|
context,
|
||||||
{
|
{
|
||||||
"name": "pico",
|
"name": "pico",
|
||||||
@@ -854,30 +878,30 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
self.assertIn("-DVAMP_PICO_FIRMWARE_PROJECT=ON", command)
|
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_overrides"], {"PICO_SDK_PATH": "/opt/pico-sdk"})
|
||||||
self.assertEqual(run.call_args.kwargs["env_unset"], ["CC", "CXX"])
|
self.assertEqual(run.call_args.kwargs["env_unset"], ["CC", "CXX"])
|
||||||
@unittest.mock.patch.object(modulus.cli, "clear_conan_deps")
|
@unittest.mock.patch.object(modulus.build, "clear_conan_deps")
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "run", return_value=0)
|
||||||
def test_configure_one_allows_public_repos_to_build_missing_dependencies(self, run, clear):
|
def test_configure_one_allows_public_repos_to_build_missing_dependencies(self, run, clear):
|
||||||
context = self.context()
|
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)
|
self.assertEqual(status, 0)
|
||||||
conan_command = run.call_args_list[0].args[1]
|
conan_command = run.call_args_list[0].args[1]
|
||||||
self.assertIn("--build=missing", conan_command)
|
self.assertIn("--build=missing", conan_command)
|
||||||
clear.assert_called_once()
|
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):
|
def test_create_package_passes_no_remote_to_conan_create(self, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(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.assertEqual(status, 0)
|
||||||
self.assertIn("&:with_tests=False", run.call_args.args[1])
|
self.assertIn("&:with_tests=False", run.call_args.args[1])
|
||||||
self.assertIn("--build=sample/*", 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("--build=missing", run.call_args.args[1])
|
||||||
self.assertIn("--no-remote", 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):
|
def test_create_package_allows_public_repos_to_build_missing_dependencies(self, run):
|
||||||
with tempfile.TemporaryDirectory() as repo_dir:
|
with tempfile.TemporaryDirectory() as repo_dir:
|
||||||
context = self.context(repo_root=pathlib.Path(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.assertEqual(status, 0)
|
||||||
self.assertIn("--build=sample/*", 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("--build=missing", run.call_args.args[1])
|
||||||
@@ -898,20 +922,14 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
def fake_run(run_context, command, **kwargs):
|
def fake_run(run_context, command, **kwargs):
|
||||||
if command[:2] == ["cmake", "--install"]:
|
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").mkdir(parents=True)
|
||||||
(install_root / "bin" / "sample").write_text("binary\n", encoding="utf-8")
|
(install_root / "bin" / "sample").write_text("binary\n", encoding="utf-8")
|
||||||
return 0
|
return 0
|
||||||
with unittest.mock.patch.object(modulus.cli, "run", side_effect=fake_run) as run:
|
with unittest.mock.patch.object(modulus.process, "run", side_effect=fake_run) as run:
|
||||||
status, artifacts = modulus.cli.create_artifact_package(
|
status, artifacts = modulus.packaging.create_artifact_package(context, context.default_target, update=False, no_remote=True)
|
||||||
context,
|
|
||||||
context.default_target,
|
|
||||||
"abc123",
|
|
||||||
update=False,
|
|
||||||
no_remote=True,
|
|
||||||
)
|
|
||||||
self.assertEqual(status, 0)
|
self.assertEqual(status, 0)
|
||||||
archive = repo / "build" / "packages" / "sample-abc123-native.tar.gz"
|
archive = repo / "build" / "packages" / "sample_native.tar.gz"
|
||||||
checksum = archive.with_name(archive.name + ".sha256")
|
checksum = archive.with_name(archive.name + ".sha256")
|
||||||
self.assertEqual(artifacts, [archive, checksum])
|
self.assertEqual(artifacts, [archive, checksum])
|
||||||
self.assertTrue(archive.is_file())
|
self.assertTrue(archive.is_file())
|
||||||
@@ -928,38 +946,14 @@ class upload_packages_test(unittest.TestCase):
|
|||||||
cmake_command = run.call_args_list[1].args[1]
|
cmake_command = run.call_args_list[1].args[1]
|
||||||
self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command)
|
self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command)
|
||||||
self.assertIn(f"-DCMAKE_INSTALL_PREFIX={repo / 'build' / 'build_common' / 'artifacts' / 'native' / 'stage' / 'usr'}", cmake_command)
|
self.assertIn(f"-DCMAKE_INSTALL_PREFIX={repo / 'build' / 'build_common' / 'artifacts' / 'native' / 'stage' / 'usr'}", cmake_command)
|
||||||
@unittest.mock.patch.object(modulus.cli, "run", return_value=0)
|
@unittest.mock.patch.object(modulus.process, "in_docker", return_value=True)
|
||||||
def test_generic_artifact_upload_uses_package_registry_credentials(self, run):
|
@unittest.mock.patch.object(modulus.packaging, "create_artifact_package", return_value=(0, []))
|
||||||
context = self.context()
|
def test_artifact_command_needs_no_release_argument(self, create, docker):
|
||||||
archive = pathlib.Path("build/packages/sample-abc123-native.tar.gz")
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
with unittest.mock.patch.dict(
|
context = self.context(repo_root=pathlib.Path(temporary))
|
||||||
os.environ,
|
context.package["artifact"]["enabled"] = True
|
||||||
{
|
self.assertEqual(modulus.packaging.package_artifacts(context, ["--no-remote"]), 0)
|
||||||
"PACKAGE_REGISTRY_USER": "dev",
|
create.assert_called_once_with(context, context.default_target, False, no_remote=True)
|
||||||
"PACKAGE_REGISTRY_TOKEN": "secret",
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
):
|
|
||||||
status = modulus.cli.upload_generic_artifact(context, "abc123", archive)
|
|
||||||
self.assertEqual(status, 0)
|
|
||||||
self.assertEqual(
|
|
||||||
run.call_args.args[1],
|
|
||||||
[
|
|
||||||
"curl",
|
|
||||||
"--fail",
|
|
||||||
"--silent",
|
|
||||||
"--show-error",
|
|
||||||
"--user",
|
|
||||||
"dev:secret",
|
|
||||||
"--upload-file",
|
|
||||||
archive,
|
|
||||||
"https://example.test/api/packages/acme/generic/sample/abc123/sample-abc123-native.tar.gz",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
run.call_args.kwargs["display_command"][5],
|
|
||||||
"dev:<hidden>",
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+18
-5
@@ -47,11 +47,11 @@ class modulus_export_test(unittest.TestCase):
|
|||||||
(package / "tools" / "lsp.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" / "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" / "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" / "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 / "tools" / "docker" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
|
||||||
(package / "include" / "modulus" / "types.hpp").write_text("", encoding="utf-8")
|
(package / "include" / "modulus" / "types.hpp").write_text("", encoding="utf-8")
|
||||||
(package / "tests" / "test.cpp").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 / "lib" / "cmake" / "modulus" / "modulusConfig.cmake").write_text("", encoding="utf-8")
|
||||||
(package / "share" / "modulus" / "duplicate.py").write_text("", encoding="utf-8")
|
(package / "share" / "modulus" / "duplicate.py").write_text("", encoding="utf-8")
|
||||||
(package / "bin" / "modulus-tool").write_text("", encoding="utf-8")
|
(package / "bin" / "modulus-tool").write_text("", encoding="utf-8")
|
||||||
def test_boost_requirement_options_enable_configured_components(self):
|
def test_boost_requirement_options_enable_configured_components(self):
|
||||||
@@ -82,6 +82,19 @@ class modulus_export_test(unittest.TestCase):
|
|||||||
self.assertFalse(config["tooling"]["run"]["enabled"])
|
self.assertFalse(config["tooling"]["run"]["enabled"])
|
||||||
self.assertFalse(config["tooling"]["lsp"]["enabled"])
|
self.assertFalse(config["tooling"]["lsp"]["enabled"])
|
||||||
self.assertEqual(config["tooling"]["lsp"]["compile_databases"], [])
|
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):
|
def test_product_artifact_and_run_config_are_validated(self):
|
||||||
config = self.config()
|
config = self.config()
|
||||||
config["package"]["artifact"] = {
|
config["package"]["artifact"] = {
|
||||||
@@ -192,8 +205,8 @@ class modulus_export_test(unittest.TestCase):
|
|||||||
self.assertEqual(config["tooling"]["targets"]["x86_64_gcc12_release"]["profile"], "conan/profiles/x86_64_gcc12_release")
|
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):
|
def test_docker_defaults_use_shared_modulus_marker(self):
|
||||||
config = modulus.config.normalize(self.config())
|
config = modulus.config.normalize(self.config())
|
||||||
self.assertEqual(config["tooling"]["docker"]["image"], "modulus-build:dev")
|
self.assertEqual(config["tooling"]["docker"]["image"], "modulus_build:dev")
|
||||||
self.assertEqual(config["tooling"]["docker"]["marker"], "/opt/modulus-build-image")
|
self.assertEqual(config["tooling"]["docker"]["marker"], "/opt/modulus_build_image")
|
||||||
self.assertNotIn("apt_architectures", config["tooling"]["docker"])
|
self.assertNotIn("apt_architectures", config["tooling"]["docker"])
|
||||||
self.assertNotIn("apt_packages", config["tooling"]["docker"])
|
self.assertNotIn("apt_packages", config["tooling"]["docker"])
|
||||||
def test_export_project_copies_runtime_and_exported_tools(self):
|
def test_export_project_copies_runtime_and_exported_tools(self):
|
||||||
@@ -220,7 +233,7 @@ class modulus_export_test(unittest.TestCase):
|
|||||||
self.assertTrue((runtime / "modulus" / "formatting.py").is_file())
|
self.assertTrue((runtime / "modulus" / "formatting.py").is_file())
|
||||||
self.assertTrue((runtime / "modulus" / "format_layout.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/format.py").is_file())
|
||||||
self.assertTrue((repo / "build/build_tools/docker/format-requirements.txt").is_file())
|
self.assertTrue((repo / "build/build_tools/docker/format_requirements.txt").is_file())
|
||||||
self.assertFalse((runtime / "CMakeLists.txt").exists())
|
self.assertFalse((runtime / "CMakeLists.txt").exists())
|
||||||
self.assertFalse((runtime / "README.md").exists())
|
self.assertFalse((runtime / "README.md").exists())
|
||||||
self.assertFalse((runtime / "conanfile.py").exists())
|
self.assertFalse((runtime / "conanfile.py").exists())
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
import modulus.filesystem
|
||||||
|
|
||||||
|
class PublicationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.root = pathlib.Path(self.temporary.name)
|
||||||
|
self.source, self.target = self.root / 'new', self.root / 'installed'
|
||||||
|
for path, text in ((self.source, 'new'), (self.target, 'old')):
|
||||||
|
path.mkdir()
|
||||||
|
(path / 'content').write_text(text)
|
||||||
|
def test_publish_replaces_complete_directory(self):
|
||||||
|
with modulus.filesystem.publish_directories([(self.source, self.target)]):
|
||||||
|
self.assertEqual((self.target / 'content').read_text(), 'new')
|
||||||
|
self.assertFalse(self.source.exists())
|
||||||
|
self.assertEqual(list(self.root.iterdir()), [self.target])
|
||||||
|
def test_failure_restores_previous_directory(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'failed'):
|
||||||
|
with modulus.filesystem.publish_directories([(self.source, self.target)]):
|
||||||
|
raise RuntimeError('failed')
|
||||||
|
self.assertEqual((self.target / 'content').read_text(), 'old')
|
||||||
|
self.assertFalse(list(self.root.glob('.publish_*')))
|
||||||
|
def test_failed_second_replacement_rolls_back_first(self):
|
||||||
|
second = self.root / 'second'
|
||||||
|
second.mkdir()
|
||||||
|
rename = pathlib.Path.rename
|
||||||
|
def fail(path, destination):
|
||||||
|
if path == second:
|
||||||
|
raise OSError('failed')
|
||||||
|
return rename(path, destination)
|
||||||
|
with unittest.mock.patch.object(pathlib.Path, 'rename', fail), self.assertRaises(OSError):
|
||||||
|
with modulus.filesystem.publish_directories([(self.source, self.target), (second, self.root / 'last')]):
|
||||||
|
pass
|
||||||
|
self.assertEqual((self.target / 'content').read_text(), 'old')
|
||||||
|
def test_overlaps_and_symlinks_rejected_before_replacement(self):
|
||||||
|
link = self.root / 'link'
|
||||||
|
link.symlink_to(self.target, target_is_directory=True)
|
||||||
|
for target in (self.source, self.source / 'child', self.source / '..' / 'new', self.root, link, link / 'child'):
|
||||||
|
with self.subTest(target=target), self.assertRaises(ValueError):
|
||||||
|
with modulus.filesystem.publish_directories([(self.source, target)]):
|
||||||
|
pass
|
||||||
|
self.assertEqual((self.target / 'content').read_text(), 'old')
|
||||||
|
def test_failed_rollback_retains_the_previous_directory_for_recovery(self):
|
||||||
|
rename = pathlib.Path.rename
|
||||||
|
def fail(path, destination):
|
||||||
|
if path.name == 'previous':
|
||||||
|
raise OSError('restore failed')
|
||||||
|
return rename(path, destination)
|
||||||
|
with unittest.mock.patch.object(pathlib.Path, 'rename', fail), self.assertRaisesRegex(RuntimeError, 'recovery directories retained'):
|
||||||
|
with modulus.filesystem.publish_directories([(self.source, self.target)]):
|
||||||
|
raise RuntimeError('publication failed')
|
||||||
|
backup = next(self.root.glob('.publish_*/previous/content'))
|
||||||
|
self.assertEqual(backup.read_text(), 'old')
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import modulus.container
|
||||||
|
import modulus.build_context
|
||||||
|
import modulus.clangd
|
||||||
import ast
|
import ast
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
@@ -114,13 +117,13 @@ class formatting_test(unittest.TestCase):
|
|||||||
def test_stdin_failure_has_no_output_and_never_builds_image(self):
|
def test_stdin_failure_has_no_output_and_never_builds_image(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = pathlib.Path(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"
|
context.docker_marker = root / "not-in-container"
|
||||||
output, errors = io.StringIO(), io.StringIO()
|
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):
|
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(modulus.formatting.launch(context, ["--stdin-filename", "test.py"]), 1)
|
||||||
self.assertEqual(output.getvalue(), "")
|
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)
|
self.assertEqual(modulus.formatting.launch(context, ["--check"]), 1)
|
||||||
build.assert_not_called()
|
build.assert_not_called()
|
||||||
def test_discovery_excludes_generated_and_symlinks(self):
|
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'
|
source = 'def example(name):\n return f"""first {name}\n\nsecond\n\nthird"""\n'
|
||||||
self.assertEqual(self.formatted(source, ".py"), source)
|
self.assertEqual(self.formatted(source, ".py"), source)
|
||||||
def test_alias_check_distinguishes_module_aliases_from_saved_callbacks(self):
|
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])
|
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):
|
def test_exported_stdin_formatter_needs_no_compile_database(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import modulus.build_context
|
||||||
|
import modulus.process
|
||||||
|
import pathlib
|
||||||
|
import runpy
|
||||||
|
import types
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
import modulus.config
|
||||||
|
import modulus.python
|
||||||
|
|
||||||
|
class PythonTests(unittest.TestCase):
|
||||||
|
def context(self, root, groups):
|
||||||
|
return modulus.build_context.build_context(root, modulus.config.normalize({'package': {'name': 'example'}, 'tooling': {'python': {'groups': groups}}}))
|
||||||
|
def test_optional_packages_are_not_needed_by_default(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(subprocess, 'run') as run:
|
||||||
|
context = self.context(pathlib.Path(temporary), [])
|
||||||
|
self.assertEqual(modulus.python.interpreter(context, install=True), pathlib.Path(sys.executable))
|
||||||
|
run.assert_not_called()
|
||||||
|
def test_conan_reuses_the_project_environment_and_preserves_offline_mode(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
context = self.context(pathlib.Path(temporary), ['assets'])
|
||||||
|
environment = modulus.process.subprocess_environment(context, ['conan', 'install', '.', '--no-remote'])
|
||||||
|
self.assertEqual(environment['MODULUS_PYTHON_COMMON_DIR'], str(context.common_dir))
|
||||||
|
self.assertEqual(environment['MODULUS_PYTHON_OFFLINE'], '1')
|
||||||
|
environment = modulus.process.subprocess_environment(context, ['conan', 'install', '.'])
|
||||||
|
self.assertEqual(environment['MODULUS_PYTHON_OFFLINE'], '0')
|
||||||
|
def test_conan_helper_uses_build_context_module_and_preserves_contract(self):
|
||||||
|
helper = runpy.run_path(str(pathlib.Path(__file__).resolve().parents[1] / 'conanfile.py'))['python_cmake_variables']
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = pathlib.Path(temporary)
|
||||||
|
(root / 'modulus.json').write_text('{"package":{"name":"example"},"tooling":{"python":{"groups":["assets"]}}}')
|
||||||
|
package = root / 'package'
|
||||||
|
(package / 'modulus').mkdir(parents=True)
|
||||||
|
(package / 'modulus/cli.py').touch()
|
||||||
|
recipe = types.SimpleNamespace(source_folder=str(root), build_folder=str(root / 'build'),
|
||||||
|
dependencies={'modulus': types.SimpleNamespace(package_folder=str(package))})
|
||||||
|
executable = root / 'python/bin/python3'
|
||||||
|
for offline in ('0', '1'):
|
||||||
|
with unittest.mock.patch.dict(os.environ, {'MODULUS_PYTHON_COMMON_DIR': str(root / 'common'), 'MODULUS_PYTHON_OFFLINE': offline}), unittest.mock.patch.object(modulus.python, 'interpreter', return_value=executable) as interpreter:
|
||||||
|
variables = helper(recipe)
|
||||||
|
context = interpreter.call_args_list[0].args[0]
|
||||||
|
self.assertIsInstance(context, modulus.build_context.build_context)
|
||||||
|
self.assertEqual(context.common_dir, root / 'common')
|
||||||
|
self.assertEqual(context.tools_dir, package / 'tools')
|
||||||
|
self.assertEqual(interpreter.call_args_list[0].kwargs, {'install': True, 'offline': offline == '1'})
|
||||||
|
self.assertEqual(variables['Python3_EXECUTABLE'], str(executable))
|
||||||
|
self.assertEqual(variables['MODULUS_PYTHON_RUNNER'], str(package / 'tools/python_runner.py'))
|
||||||
|
self.assertEqual(variables['MODULUS_PYTHON_ROOT'], str(package))
|
||||||
|
self.assertTrue(variables['MODULUS_PYTHON_REQUIREMENTS'].endswith('assets_requirements.txt'))
|
||||||
|
with unittest.mock.patch.dict(os.environ, {}, clear=True), unittest.mock.patch.object(modulus.python, 'interpreter', return_value=executable) as interpreter:
|
||||||
|
helper(recipe)
|
||||||
|
self.assertEqual(interpreter.call_args_list[0].args[0].common_dir, root / 'build/build_python')
|
||||||
|
def test_missing_dependencies_require_configure_and_never_download_during_build(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(subprocess, 'run') as run:
|
||||||
|
context = self.context(pathlib.Path(temporary), ['assets'])
|
||||||
|
for options in ({}, {'install': True, 'offline': True}):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'configure'):
|
||||||
|
modulus.python.interpreter(context, **options)
|
||||||
|
run.assert_not_called()
|
||||||
|
def test_unknown_or_duplicate_groups_are_rejected(self):
|
||||||
|
for groups in (['unknown'], ['assets', 'assets'], 'assets'):
|
||||||
|
with self.assertRaises(modulus.config.config_error):
|
||||||
|
self.context(pathlib.Path('/tmp'), groups)
|
||||||
|
def test_matching_environment_is_reused_and_changed_pins_require_configure(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(subprocess, 'run') as run:
|
||||||
|
context = self.context(pathlib.Path(temporary), ['assets'])
|
||||||
|
executable = context.common_dir / 'python/bin/python3'
|
||||||
|
executable.parent.mkdir(parents=True)
|
||||||
|
executable.touch()
|
||||||
|
marker = executable.parents[1] / 'requirements.sha256'
|
||||||
|
marker.write_text(modulus.python.identity(modulus.python.requirement_files(context)))
|
||||||
|
self.assertEqual(modulus.python.interpreter(context, offline=True), executable)
|
||||||
|
with unittest.mock.patch.object(modulus.python, 'identity', return_value='changed'):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'configure'):
|
||||||
|
modulus.python.interpreter(context)
|
||||||
|
run.assert_not_called()
|
||||||
|
def test_runner_imports_exported_package_even_with_bootstrap_in_working_directory(self):
|
||||||
|
source = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = pathlib.Path(temporary)
|
||||||
|
(root / 'modulus.py').write_text("raise AssertionError('bootstrap shadowed package')")
|
||||||
|
result = subprocess.run([sys.executable, str(source / 'tools/python_runner.py'), str(source), '-c', 'import modulus.filesystem; print("loaded")'], cwd=root, text=True, capture_output=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), 'loaded')
|
||||||
@@ -11,4 +11,10 @@ build_type=Debug
|
|||||||
|
|
||||||
[conf]
|
[conf]
|
||||||
tools.cmake.cmaketoolchain:generator=Ninja
|
tools.cmake.cmaketoolchain:generator=Ninja
|
||||||
tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc", "cpp": "aarch64-linux-gnu-g++"}
|
tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}
|
||||||
|
# Exclude older internal binaries published with an unversioned cross compiler.
|
||||||
|
modulus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
ligo/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
noto/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
luma/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
sonus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
|||||||
@@ -11,4 +11,10 @@ build_type=Release
|
|||||||
|
|
||||||
[conf]
|
[conf]
|
||||||
tools.cmake.cmaketoolchain:generator=Ninja
|
tools.cmake.cmaketoolchain:generator=Ninja
|
||||||
tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc", "cpp": "aarch64-linux-gnu-g++"}
|
tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}
|
||||||
|
# Exclude older internal binaries published with an unversioned cross compiler.
|
||||||
|
modulus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
ligo/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
noto/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
luma/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
sonus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ RUN apt-get update \
|
|||||||
cmake \
|
cmake \
|
||||||
curl \
|
curl \
|
||||||
g++-12 \
|
g++-12 \
|
||||||
g++-aarch64-linux-gnu \
|
g++-12-aarch64-linux-gnu \
|
||||||
gcc-12 \
|
gcc-12 \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-12-aarch64-linux-gnu \
|
||||||
git \
|
git \
|
||||||
libdrm-dev \
|
libdrm-dev \
|
||||||
libdrm-dev:arm64 \
|
libdrm-dev:arm64 \
|
||||||
@@ -37,18 +37,18 @@ RUN apt-get update \
|
|||||||
ENV CC=/usr/bin/gcc-12
|
ENV CC=/usr/bin/gcc-12
|
||||||
ENV CXX=/usr/bin/g++-12
|
ENV CXX=/usr/bin/g++-12
|
||||||
|
|
||||||
RUN python3 -m venv /opt/modulus-python
|
RUN python3 -m venv /opt/modulus_python
|
||||||
|
|
||||||
ENV PATH=/opt/modulus-python/bin:${PATH}
|
ENV PATH=/opt/modulus_python/bin:${PATH}
|
||||||
|
|
||||||
RUN python -m pip install --no-cache-dir \
|
RUN python -m pip install --no-cache-dir \
|
||||||
conan==2.26.2
|
conan==2.26.2
|
||||||
|
|
||||||
# The path is shared by source builds and exported consumer tooling.
|
# The path is shared by source builds and exported consumer tooling.
|
||||||
ARG MODULUS_TOOLS=tools
|
ARG MODULUS_TOOLS=tools
|
||||||
COPY ${MODULUS_TOOLS}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt
|
COPY ${MODULUS_TOOLS}/docker/format_requirements.txt /tmp/modulus_format_requirements.txt
|
||||||
RUN python -m pip install --no-cache-dir -r /tmp/modulus-format-requirements.txt
|
RUN python -m pip install --no-cache-dir -r /tmp/modulus_format_requirements.txt
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
RUN mkdir -p /opt && touch /opt/modulus-build-image
|
RUN mkdir -p /opt && touch /opt/modulus_build_image
|
||||||
|
|||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import modulus_tool
|
||||||
|
|
||||||
|
modulus_tool.run('publish')
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import modulus_tool
|
||||||
|
|
||||||
|
modulus_tool.run('python')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Run build Python with the exported Modulus package ahead of the bootstrap."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import runpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root, *arguments = sys.argv[1:]
|
||||||
|
sys.path[:0] = [str(pathlib.Path(root).resolve()), str(pathlib.Path.cwd())]
|
||||||
|
if not arguments:
|
||||||
|
raise SystemExit('expected -m MODULE, -c CODE, or a Python script')
|
||||||
|
if arguments[0] == '-m' and len(arguments) >= 2:
|
||||||
|
sys.argv = arguments[1:]
|
||||||
|
runpy.run_module(arguments[1], run_name='__main__', alter_sys=True)
|
||||||
|
elif arguments[0] == '-c' and len(arguments) >= 2:
|
||||||
|
sys.argv = ['-c', *arguments[2:]]
|
||||||
|
exec(compile(arguments[1], '<string>', 'exec'), {'__name__': '__main__'})
|
||||||
|
else:
|
||||||
|
sys.argv = arguments
|
||||||
|
runpy.run_path(arguments[0], run_name='__main__')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user