Formatting
publish / publish (push) Successful in 11m55s

This commit is contained in:
khodowany
2026-09-14 23:54:30 -05:00
parent b9d30fb975
commit 097cb61d2d
33 changed files with 950 additions and 302 deletions
+5 -1
View File
@@ -38,7 +38,7 @@ jobs:
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 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
@@ -50,6 +50,10 @@ jobs:
run: ./tools/configure.py --target x86_64_gcc12_release run: ./tools/configure.py --target x86_64_gcc12_release
- name: Test tooling - name: Test tooling
run: python3 -m unittest discover -s tests -p 'test_*.py' run: python3 -m unittest discover -s tests -p 'test_*.py'
- name: Check code style
run: |
python3 -m pip install --no-cache-dir -r tools/docker/format-requirements.txt
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
- name: Test native release - name: Test native release
+14 -2
View File
@@ -47,14 +47,26 @@ Product repos can add typed extra CMake compile databases under `tooling.lsp.com
{"CompileFlags": {"CompilationDatabase": "build/native"}}, {"CompileFlags": {"CompilationDatabase": "build/native"}},
{"If": {"PathMatch": "src/firmware/.*"}, "CompileFlags": {"CompilationDatabase": "build/firmware"}} {"If": {"PathMatch": "src/firmware/.*"}, "CompileFlags": {"CompilationDatabase": "build/firmware"}}
], ],
"clang_format": {"DisableFormat": true}, "clang_format": {"BasedOnStyle": "LLVM", "IndentWidth": 2, "ColumnLimit": 0, "SortIncludes": "Never"},
"pyright": {"include": ["tools", "conanfile.py"]} "pyright": {"include": ["tools", "conanfile.py"]}
} }
} }
} }
``` ```
Each supplied entry replaces that file's entire configuration. Omitted entries use Modulus defaults: the default target's build directory for clangd, `-Wall`/`-Wextra`, disabled clang-tidy and formatting, and Python paths for the bootstrap, recipe, and exported tools. Entries use the editors' own configuration keys. LLVM files use JSON-compatible YAML, with separate documents for clangd fragments. Each supplied entry replaces that file's entire configuration. Omitted entries use Modulus defaults: the default target's build directory for clangd, `-Wall`/`-Wextra`, disabled clang-tidy, the compact formatting policy below, and Python paths for the bootstrap, recipe, and exported tools. Entries use the editors' own configuration keys. LLVM files use JSON-compatible YAML, with separate documents for clangd fragments.
### Shared Code Style
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.
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.
Discovery includes Git-tracked and nonignored untracked C/C++ and Python sources. Generated/build/vendor directories and symlinks are excluded. Add project-relative glob patterns with `tooling.format.exclude`; C++ settings remain configurable through `tooling.editor.clang_format`. A batch validates every input before it writes any output.
Neovim uses Conform for C++ and Python in registered Modulus projects, retaining clangd and Pyright for language features. LazyVim's manual command, format-on-save handling, and global/buffer toggles remain in control. Other projects retain their formatter selections. There is no editor-triggered download, image build, host formatter fallback, or compilation-database requirement. Missing/stale images fail on stderr without replacing the buffer. Configure first to prepare the image. `MODULUS_DIRECT=1` is for provisioned CI environments, which install the same pinned requirements; it is not set by the editor.
The authored `cmake/` directory belongs in Git. `CMakeFiles/`, compiler caches, and generated sources belong in build directories. Modulus rejects project/source-root output directories before clearing configure or LSP caches. Conan may still use its own isolated build folders. Existing build caches are retained; they are not source files. The authored `cmake/` directory belongs in Git. `CMakeFiles/`, compiler caches, and generated sources belong in build directories. Modulus rejects project/source-root output directories before clearing configure or LSP caches. Conan may still use its own isolated build folders. Existing build caches are retained; they are not source files.
+1 -21
View File
@@ -8,8 +8,6 @@ import conan.tools.files
import modulus.config import modulus.config
config = modulus.config
class modulus_recipe(conan.ConanFile): class modulus_recipe(conan.ConanFile):
name = "modulus" name = "modulus"
version = "latest" version = "latest"
@@ -83,26 +81,18 @@ class modulus_recipe(conan.ConanFile):
"tools/docker/*", "tools/docker/*",
"README.md", "README.md",
) )
def config_options(self): def config_options(self):
settings = self.settings settings = self.settings
options = self.options options = self.options
if settings.os == "Windows": if settings.os == "Windows":
del options.fPIC del options.fPIC
def configure(self): def configure(self):
options = self.options options = self.options
if options.shared: if options.shared:
del options.fPIC del options.fPIC
def requirements(self): def requirements(self):
config = modulus.config.load_recipe_config(self) config = modulus.config.load_recipe_config(self)
self.requires( self.requires(modulus.config.boost_ref(config), transitive_headers=True, transitive_libs=True, options=modulus.config.boost_requirement_options(config))
modulus.config.boost_ref(config),
transitive_headers=True,
transitive_libs=True,
options=modulus.config.boost_requirement_options(config),
)
if self.options.with_openssl: if self.options.with_openssl:
self.requires( self.requires(
"openssl/4.0.1", "openssl/4.0.1",
@@ -127,7 +117,6 @@ class modulus_recipe(conan.ConanFile):
transitive_headers=True, transitive_headers=True,
transitive_libs=True, transitive_libs=True,
) )
def generate(self): def generate(self):
options = self.options options = self.options
deps = conan.tools.cmake.CMakeDeps(self) deps = conan.tools.cmake.CMakeDeps(self)
@@ -142,7 +131,6 @@ class modulus_recipe(conan.ConanFile):
toolchain.variables["MODULUS_WITH_RAYLIB"] = bool(options.with_raylib) toolchain.variables["MODULUS_WITH_RAYLIB"] = bool(options.with_raylib)
toolchain.variables["MODULUS_WITH_MINIAUDIO"] = bool(options.with_miniaudio) toolchain.variables["MODULUS_WITH_MINIAUDIO"] = bool(options.with_miniaudio)
toolchain.generate() toolchain.generate()
def build(self): def build(self):
options = self.options options = self.options
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
@@ -150,17 +138,14 @@ class modulus_recipe(conan.ConanFile):
cmake.build() cmake.build()
if options.with_tests and conan.tools.build.can_run(self): if options.with_tests and conan.tools.build.can_run(self):
cmake.test(target="check") cmake.test(target="check")
def package_id(self): def package_id(self):
self.info.options.rm_safe("with_tests") self.info.options.rm_safe("with_tests")
def package(self): def package(self):
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
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, "*", 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
cpp_info.set_property("cmake_file_name", "modulus") cpp_info.set_property("cmake_file_name", "modulus")
@@ -173,18 +158,14 @@ class modulus_recipe(conan.ConanFile):
components["modulus"].set_property("cmake_target_name", "modulus::modulus") components["modulus"].set_property("cmake_target_name", "modulus::modulus")
components["modulus"].libs = ["modulus"] components["modulus"].libs = ["modulus"]
components["modulus"].requires = ["boost::json"] components["modulus"].requires = ["boost::json"]
components["boost_json"].set_property("cmake_target_name", "modulus::boost_json") components["boost_json"].set_property("cmake_target_name", "modulus::boost_json")
components["boost_json"].requires = ["boost::json"] components["boost_json"].requires = ["boost::json"]
if self.options.with_openssl: if self.options.with_openssl:
components["openssl"].set_property("cmake_target_name", "modulus::openssl") components["openssl"].set_property("cmake_target_name", "modulus::openssl")
components["openssl"].requires = ["openssl::ssl", "openssl::crypto"] components["openssl"].requires = ["openssl::ssl", "openssl::crypto"]
if self.options.with_sqlite: if self.options.with_sqlite:
components["sqlite"].set_property("cmake_target_name", "modulus::sqlite") components["sqlite"].set_property("cmake_target_name", "modulus::sqlite")
components["sqlite"].requires = ["sqlite3::sqlite"] components["sqlite"].requires = ["sqlite3::sqlite"]
if self.options.with_raylib: if self.options.with_raylib:
components["raylib"].set_property("cmake_target_name", "modulus::raylib") components["raylib"].set_property("cmake_target_name", "modulus::raylib")
components["raylib"].requires = ["raylib::raylib"] components["raylib"].requires = ["raylib::raylib"]
@@ -197,7 +178,6 @@ class modulus_recipe(conan.ConanFile):
"atomic", "atomic",
"dl", "dl",
] ]
if self.options.with_miniaudio: if self.options.with_miniaudio:
components["miniaudio"].set_property("cmake_target_name", "modulus::miniaudio") components["miniaudio"].set_property("cmake_target_name", "modulus::miniaudio")
components["miniaudio"].requires = ["miniaudio::miniaudio"] components["miniaudio"].requires = ["miniaudio::miniaudio"]
+10 -9
View File
@@ -8,6 +8,7 @@
#include <modulus/error.hpp> #include <modulus/error.hpp>
#include <modulus/log.hpp> #include <modulus/log.hpp>
namespace modulus { namespace modulus {
struct logging_config { struct logging_config {
@@ -19,14 +20,14 @@ struct config {
modulus::error_config errors{}; modulus::error_config errors{};
}; };
auto parse_logging_config(boost::json::value const& value) -> std::expected<modulus::logging_config, modulus::error>; auto parse_logging_config(boost::json::value const &value) -> std::expected<modulus::logging_config, modulus::error>;
auto parse_error_config(boost::json::value const& value) -> std::expected<modulus::error_config, modulus::error>; auto parse_error_config(boost::json::value const &value) -> std::expected<modulus::error_config, modulus::error>;
auto parse_config(boost::json::value const& value) -> std::expected<modulus::config, modulus::error>; auto parse_config(boost::json::value const &value) -> std::expected<modulus::config, modulus::error>;
auto load_config(std::string const& path) -> std::expected<modulus::config, modulus::error>; auto load_config(std::string const &path) -> std::expected<modulus::config, modulus::error>;
auto to_json(modulus::logging_config const& config) -> boost::json::value; auto to_json(modulus::logging_config const &config) -> boost::json::value;
auto to_json(modulus::error_config const& config) -> boost::json::value; auto to_json(modulus::error_config const &config) -> boost::json::value;
auto to_json(modulus::config const& config) -> boost::json::value; auto to_json(modulus::config const &config) -> boost::json::value;
auto configure_logger(modulus::logger& logger, modulus::logging_config const& config) -> void; auto configure_logger(modulus::logger &logger, modulus::logging_config const &config) -> void;
auto configure(modulus::config const& config) -> void; auto configure(modulus::config const &config) -> void;
} }
+5 -4
View File
@@ -7,6 +7,7 @@
#include <modulus/source_location.hpp> #include <modulus/source_location.hpp>
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace modulus { namespace modulus {
struct error_config { struct error_config {
@@ -19,12 +20,12 @@ struct error_config {
class error { class error {
public: public:
error() = default; error() = default;
error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())); error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION()));
auto category() const noexcept -> std::string_view; auto category() const noexcept -> std::string_view;
auto code() const noexcept -> std::int32_t; auto code() const noexcept -> std::int32_t;
auto message() const noexcept -> std::string_view; auto message() const noexcept -> std::string_view;
auto location() const noexcept -> const modulus::source_location&; auto location() const noexcept -> const modulus::source_location &;
auto describe(modulus::error_config const& config) const -> std::string; auto describe(modulus::error_config const &config) const -> std::string;
auto describe() const -> std::string; auto describe() const -> std::string;
private: private:
std::string category_{"generic"}; std::string category_{"generic"};
@@ -33,7 +34,7 @@ private:
modulus::source_location location_{modulus::source_location::unknown()}; modulus::source_location location_{modulus::source_location::unknown()};
}; };
auto make_error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; auto make_error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error;
auto set_default_error_config(modulus::error_config config) -> void; auto set_default_error_config(modulus::error_config config) -> void;
auto default_error_config() -> modulus::error_config; auto default_error_config() -> modulus::error_config;
+12 -12
View File
@@ -30,22 +30,22 @@ struct enum_mapping {
auto invalid(modulus::json_config::options config, std::string message) -> modulus::error; auto invalid(modulus::json_config::options config, std::string message) -> modulus::error;
auto not_found(modulus::json_config::options config, std::string message) -> modulus::error; auto not_found(modulus::json_config::options config, std::string message) -> modulus::error;
auto load_value(std::string const& path, modulus::json_config::options config = {}) -> std::expected<boost::json::value, modulus::error>; auto load_value(std::string const &path, modulus::json_config::options config = {}) -> std::expected<boost::json::value, modulus::error>;
auto require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config = {}) -> std::expected<boost::json::object const*, modulus::error>; auto require_object(boost::json::value const &value, std::string_view context, modulus::json_config::options config = {}) -> std::expected<boost::json::object const *, modulus::error>;
auto reject_unknown_fields(boost::json::object const& object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected<void, modulus::error>; auto reject_unknown_fields(boost::json::object const &object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected<void, modulus::error>;
auto optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected<std::bool_t, modulus::error>; auto optional_bool_field(boost::json::object const &object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected<std::bool_t, modulus::error>;
auto optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>; auto optional_string_field(boost::json::object const &object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>;
auto required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>; auto required_string_field(boost::json::object const &object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>;
auto optional_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected<std::uint64_t, modulus::error>; auto optional_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected<std::uint64_t, modulus::error>;
auto positive_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected<std::uint64_t, modulus::error>; auto positive_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected<std::uint64_t, modulus::error>;
auto optional_int32_field(boost::json::object const& object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config = {}) -> std::expected<std::int32_t, modulus::error>; auto optional_int32_field(boost::json::object const &object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config = {}) -> std::expected<std::int32_t, modulus::error>;
auto positive_milliseconds_field(boost::json::object const& object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value = static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max()), modulus::json_config::options config = {}) -> std::expected<std::chrono::milliseconds, modulus::error>; auto positive_milliseconds_field(boost::json::object const &object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value = static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max()), modulus::json_config::options config = {}) -> std::expected<std::chrono::milliseconds, modulus::error>;
template <typename ENUM> template <typename ENUM>
auto parse_enum(std::string_view value, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>; auto parse_enum(std::string_view value, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>;
template <typename ENUM> template <typename ENUM>
auto optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>; auto optional_enum_field(boost::json::object const &object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>;
} }
@@ -60,7 +60,7 @@ auto modulus::json_config::parse_enum(std::string_view value, std::initializer_l
} }
template <typename ENUM> template <typename ENUM>
auto modulus::json_config::optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config) -> std::expected<ENUM, modulus::error> { auto modulus::json_config::optional_enum_field(boost::json::object const &object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config) -> std::expected<ENUM, modulus::error> {
auto found = object.find(name); auto found = object.find(name);
if (found == object.end()) { if (found == object.end()) {
return fallback; return fallback;
+8 -7
View File
@@ -13,6 +13,7 @@
#include <modulus/source_location.hpp> #include <modulus/source_location.hpp>
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace modulus { namespace modulus {
enum class log_level : std::int32_t { enum class log_level : std::int32_t {
@@ -41,20 +42,20 @@ public:
auto set_level(modulus::log_level level) -> void; auto set_level(modulus::log_level level) -> void;
auto level() const -> modulus::log_level; auto level() const -> modulus::log_level;
auto enabled(modulus::log_level level) const -> std::bool_t; auto enabled(modulus::log_level level) const -> std::bool_t;
auto set_sink(std::function<void(const modulus::log_record&)> sink) -> void; auto set_sink(std::function<void(const modulus::log_record &)> sink) -> void;
auto write(modulus::log_record record) const -> void; auto write(modulus::log_record record) const -> void;
auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) const -> void; auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) const -> void;
private: private:
mutable std::mutex mutex_; mutable std::mutex mutex_;
mutable std::mutex sink_mutex_; mutable std::mutex sink_mutex_;
modulus::log_level level_{modulus::log_level::INFO}; modulus::log_level level_{modulus::log_level::INFO};
std::function<void(const modulus::log_record&)> sink_{}; std::function<void(const modulus::log_record &)> sink_{};
}; };
auto default_logger() -> modulus::logger&; auto default_logger() -> modulus::logger &;
auto set_default_log_level(modulus::log_level level) -> void; auto set_default_log_level(modulus::log_level level) -> void;
auto is_log_enabled(modulus::log_level level) -> std::bool_t; auto is_log_enabled(modulus::log_level level) -> std::bool_t;
auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void;
auto log_trace(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_trace(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void;
auto log_debug(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_debug(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void;
auto log_info(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_info(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void;
@@ -63,12 +64,12 @@ auto log_error(std::string message, modulus::source_location location = modulus:
auto log_critical(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_critical(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void;
template <typename MESSAGE> template <typename MESSAGE>
auto log_lazy(modulus::log_level level, MESSAGE&& message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_lazy(modulus::log_level level, MESSAGE &&message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void;
} }
template <typename MESSAGE> template <typename MESSAGE>
auto modulus::log_lazy(modulus::log_level level, MESSAGE&& message, modulus::source_location location) -> void { auto modulus::log_lazy(modulus::log_level level, MESSAGE &&message, modulus::source_location location) -> void {
assert(static_cast<std::int32_t>(level) >= static_cast<std::int32_t>(modulus::log_level::TRACE)); assert(static_cast<std::int32_t>(level) >= static_cast<std::int32_t>(modulus::log_level::TRACE));
assert(static_cast<std::int32_t>(level) <= static_cast<std::int32_t>(modulus::log_level::OFF)); assert(static_cast<std::int32_t>(level) <= static_cast<std::int32_t>(modulus::log_level::OFF));
assert(location.file_name() != nullptr); assert(location.file_name() != nullptr);
+3 -2
View File
@@ -7,10 +7,11 @@
#include <modulus/error.hpp> #include <modulus/error.hpp>
namespace modulus { namespace modulus {
template <typename VALUE> template <typename VALUE>
auto success(VALUE&& value) -> std::expected<std::decay_t<VALUE>, modulus::error>; auto success(VALUE &&value) -> std::expected<std::decay_t<VALUE>, modulus::error>;
template <typename VALUE> template <typename VALUE>
auto fail(modulus::error value) -> std::expected<VALUE, modulus::error>; auto fail(modulus::error value) -> std::expected<VALUE, modulus::error>;
@@ -18,7 +19,7 @@ auto fail(modulus::error value) -> std::expected<VALUE, modulus::error>;
} }
template <typename VALUE> template <typename VALUE>
auto modulus::success(VALUE&& value) -> std::expected<std::decay_t<VALUE>, modulus::error> { auto modulus::success(VALUE &&value) -> std::expected<std::decay_t<VALUE>, modulus::error> {
return std::forward<VALUE>(value); return std::forward<VALUE>(value);
} }
+12 -10
View File
@@ -5,31 +5,33 @@
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace modulus { namespace modulus {
class source_location { class source_location {
public: public:
constexpr source_location() noexcept = default; constexpr source_location() noexcept = default;
constexpr source_location(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept; constexpr source_location(std::char_t const *file, std::uint_least32_t line, std::char_t const *function) noexcept;
static consteval auto current(std::char_t const* file = __builtin_FILE(), std::uint_least32_t line = __builtin_LINE(), std::char_t const* function = __builtin_FUNCTION()) noexcept -> modulus::source_location; static consteval auto current(std::char_t const *file = __builtin_FILE(), std::uint_least32_t line = __builtin_LINE(), std::char_t const *function = __builtin_FUNCTION()) noexcept -> modulus::source_location;
static constexpr auto unknown() noexcept -> modulus::source_location; static constexpr auto unknown() noexcept -> modulus::source_location;
constexpr auto file_name() const noexcept -> std::char_t const*; constexpr auto file_name() const noexcept -> std::char_t const *;
constexpr auto line() const noexcept -> std::uint_least32_t; constexpr auto line() const noexcept -> std::uint_least32_t;
constexpr auto function_name() const noexcept -> std::char_t const*; constexpr auto function_name() const noexcept -> std::char_t const *;
private: private:
std::char_t const* file_{"<unknown>"}; std::char_t const *file_{"<unknown>"};
std::uint_least32_t line_{}; std::uint_least32_t line_{};
std::char_t const* function_{"<unknown>"}; std::char_t const *function_{"<unknown>"};
}; };
} }
constexpr modulus::source_location::source_location(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept : file_(file), line_(line), function_(function) { constexpr modulus::source_location::source_location(std::char_t const *file, std::uint_least32_t line, std::char_t const *function) noexcept
: file_(file), line_(line), function_(function) {
assert(file != nullptr); assert(file != nullptr);
assert(function != nullptr); assert(function != nullptr);
} }
consteval auto modulus::source_location::current(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept -> modulus::source_location { consteval auto modulus::source_location::current(std::char_t const *file, std::uint_least32_t line, std::char_t const *function) noexcept -> modulus::source_location {
assert(file != nullptr); assert(file != nullptr);
assert(function != nullptr); assert(function != nullptr);
return modulus::source_location(file, line, function); return modulus::source_location(file, line, function);
@@ -39,7 +41,7 @@ constexpr auto modulus::source_location::unknown() noexcept -> modulus::source_l
return modulus::source_location(); return modulus::source_location();
} }
constexpr auto modulus::source_location::file_name() const noexcept -> std::char_t const* { constexpr auto modulus::source_location::file_name() const noexcept -> std::char_t const * {
assert(file_ != nullptr); assert(file_ != nullptr);
return file_; return file_;
} }
@@ -49,7 +51,7 @@ constexpr auto modulus::source_location::line() const noexcept -> std::uint_leas
return line_; return line_;
} }
constexpr auto modulus::source_location::function_name() const noexcept -> std::char_t const* { constexpr auto modulus::source_location::function_name() const noexcept -> std::char_t const * {
assert(function_ != nullptr); assert(function_ != nullptr);
return function_; return function_;
} }
+4 -3
View File
@@ -3,10 +3,11 @@
#include <cstdint> #include <cstdint>
#if defined(__has_include) #if defined(__has_include)
# if __has_include(<stdfloat>) #if __has_include(<stdfloat>)
# include <stdfloat> #include <stdfloat>
# endif
#endif #endif
#endif
namespace std { namespace std {
-1
View File
@@ -1,2 +1 @@
__all__ = [] __all__ = []
+12 -7
View File
@@ -1,4 +1,3 @@
"""Prepare container headers separately from the stdout-clean LSP transport."""
import fcntl import fcntl
import hashlib import hashlib
import json import json
@@ -22,7 +21,11 @@ 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): def recipe_digest(context):
return hashlib.sha256(context.dockerfile.read_bytes()).hexdigest() digest = hashlib.sha256(context.dockerfile.read_bytes())
requirements = context.dockerfile.parent / "format-requirements.txt"
if requirements.is_file():
digest.update(requirements.read_bytes())
return digest.hexdigest()
def current_image(context, architecture=None): def current_image(context, architecture=None):
try: try:
@@ -106,7 +109,7 @@ def prepare(context):
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, [*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)
@@ -114,8 +117,8 @@ def prepare(context):
stage = pathlib.Path(temporary) / "cache" stage = pathlib.Path(temporary) / "cache"
stage.mkdir() stage.mkdir()
command = [*isolated_run(image), image, "tar", "--dereference", "--hard-dereference", command = [*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:
try: try:
extract_headers(process.stdout, stage / "rootfs", roots) extract_headers(process.stdout, stage / "rootfs", roots)
@@ -144,7 +147,7 @@ def path_mappings(cache, roots):
def server_arguments(context, argv): def server_arguments(context, argv):
config = settings(context) config = settings(context)
return [config["executable"], *config["arguments"], *argv, return [config["executable"], *config["arguments"], *argv,
"--query-driver=" + ",".join(config["query_drivers"])] "--query-driver=" + ",".join(config["query_drivers"])]
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
@@ -184,8 +187,10 @@ def launch(context, argv):
def run_server(command, name): def run_server(command, name):
# Inherit stdio verbatim; never send build logs or a terminal over the LSP pipe. # Inherit stdio verbatim; never send build logs or a terminal over the LSP pipe.
def interrupted(signum, frame): def interrupted(signum, frame):
raise SystemExit(128 + signum) raise SystemExit(128 + signum)
previous = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} previous = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
process = None process = None
try: try:
@@ -197,7 +202,7 @@ def run_server(command, name):
if process is not None: if process is not None:
try: try:
subprocess.run(["docker", "rm", "--force", name], stdout=subprocess.DEVNULL, subprocess.run(["docker", "rm", "--force", name], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=False, timeout=15) stderr=subprocess.DEVNULL, check=False, timeout=15)
except (OSError, subprocess.TimeoutExpired) as error: except (OSError, subprocess.TimeoutExpired) as error:
print(f"Modulus clangd cleanup: {error}", file=sys.stderr) print(f"Modulus clangd cleanup: {error}", file=sys.stderr)
try: try:
+6 -6
View File
@@ -12,6 +12,7 @@ import urllib.parse
import modulus.config import modulus.config
import modulus.export import modulus.export
import modulus.clangd import modulus.clangd
import modulus.formatting
build_root = pathlib.Path("build") build_root = pathlib.Path("build")
common_dir = build_root / "build_common" common_dir = build_root / "build_common"
@@ -30,6 +31,8 @@ def main(command):
repo_root = find_repo_root(pathlib.Path.cwd()) repo_root = find_repo_root(pathlib.Path.cwd())
config = modulus.config.load_repo_config(repo_root) config = modulus.config.load_repo_config(repo_root)
context = build_context(repo_root, config) context = build_context(repo_root, config)
if command == "format":
return modulus.formatting.launch(context, sys.argv[1:])
if command == "clangd": if command == "clangd":
return modulus.clangd.launch(context, sys.argv[1:]) return modulus.clangd.launch(context, sys.argv[1:])
if command in {"configure", "lsp"}: if command in {"configure", "lsp"}:
@@ -79,7 +82,6 @@ class build_context:
self.docker_marker = pathlib.Path(str(docker["marker"])) self.docker_marker = pathlib.Path(str(docker["marker"]))
self.upload_env = str(self.tooling["upload_env"]) self.upload_env = str(self.tooling["upload_env"])
self.package_upload_env = str(self.artifact["upload_env"]) self.package_upload_env = str(self.artifact["upload_env"])
def _targets(self): def _targets(self):
result = {} result = {}
targets = self.tooling["targets"] targets = self.tooling["targets"]
@@ -94,14 +96,12 @@ class build_context:
cmake_definitions=dict(target.get("cmake_definitions", {})), cmake_definitions=dict(target.get("cmake_definitions", {})),
) )
return result return result
def target(self, name): def target(self, name):
try: try:
return self.targets[name] return self.targets[name]
except KeyError: except KeyError:
valid = ", ".join(sorted(self.targets)) valid = ", ".join(sorted(self.targets))
raise SystemExit(f"unknown target '{name}'. Valid targets: {valid}") from None raise SystemExit(f"unknown target '{name}'. Valid targets: {valid}") from None
def selected_targets(self, names, all_targets): def selected_targets(self, names, all_targets):
if all_targets: if all_targets:
return list(self.targets.values()) return list(self.targets.values())
@@ -165,7 +165,7 @@ def require_conan_credentials(operation):
return None return None
return user, token return user, token
def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_command=None,): 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 visible = display_command if display_command is not None else command
print("+", " ".join(shlex.quote(str(part)) for part in visible)) print("+", " ".join(shlex.quote(str(part)) for part in visible))
env = os.environ.copy() env = os.environ.copy()
@@ -287,6 +287,8 @@ def build_image(context):
context.docker_image, context.docker_image,
"--label", "--label",
f"{modulus.clangd.image_label}={modulus.clangd.recipe_digest(context)}", f"{modulus.clangd.image_label}={modulus.clangd.recipe_digest(context)}",
"--build-arg",
f"MODULUS_TOOLS={context.tools_rel}",
".", ".",
], ],
) )
@@ -658,7 +660,6 @@ def lsp(context, argv):
parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") 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.") 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) args = parser.parse_args(argv)
if not args.no_build_targets: if not args.no_build_targets:
context.conan_cache_dir.mkdir(parents=True, exist_ok=True) context.conan_cache_dir.mkdir(parents=True, exist_ok=True)
if not args.no_remote: if not args.no_remote:
@@ -671,7 +672,6 @@ def lsp(context, argv):
status = configure_one(context, target, args.update, no_remote=args.no_remote) status = configure_one(context, target, args.update, no_remote=args.no_remote)
if status != 0: if status != 0:
return status return status
database_names = args.database or [str(database["name"]) for database in lsp_compile_databases(context)] database_names = args.database or [str(database["name"]) for database in lsp_compile_databases(context)]
for name in database_names: for name in database_names:
status = configure_lsp_database(context, database_map[name]) status = configure_lsp_database(context, database_map[name])
+9 -1
View File
@@ -4,6 +4,8 @@ import os
import re import re
import pathlib import pathlib
import modulus.format_layout
class config_error(RuntimeError): class config_error(RuntimeError):
pass pass
@@ -85,6 +87,7 @@ tooling_keys = {
"remote", "remote",
"run", "run",
"lsp", "lsp",
"format",
} }
run_keys = { run_keys = {
"enabled", "enabled",
@@ -224,6 +227,11 @@ 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")
formatting = tooling.setdefault("format", {})
if not isinstance(formatting, dict):
raise config_error("modulus.json tooling.format must be an object")
validate_keys(formatting, {"exclude"}, "tooling.format")
validate_string_list(formatting.setdefault("exclude", []), "tooling.format.exclude")
tooling.setdefault("default_target", "x86_64_gcc12_debug") tooling.setdefault("default_target", "x86_64_gcc12_debug")
tooling.setdefault("targets", default_targets()) tooling.setdefault("targets", default_targets())
tooling.setdefault( tooling.setdefault(
@@ -285,7 +293,7 @@ def default_editor_config(tooling):
}, },
"Diagnostics": {"ClangTidy": {"Remove": ["*"]}}, "Diagnostics": {"ClangTidy": {"Remove": ["*"]}},
}], }],
"clang_format": {"DisableFormat": True}, "clang_format": copy.deepcopy(modulus.format_layout.clang_style),
"pyright": { "pyright": {
"include": ["modulus.py", "conanfile.py", "build/build_tools"], "include": ["modulus.py", "conanfile.py", "build/build_tools"],
"extraPaths": ["build/build_common/modulus", "."], "extraPaths": ["build/build_common/modulus", "."],
+301
View File
@@ -0,0 +1,301 @@
"""Syntax-aware whitespace rules supplementing clang-format and autopep8."""
import ast
import io
import json
import re
import subprocess
import tokenize
clang_style = {
"BasedOnStyle": "LLVM",
"IndentWidth": 2,
"ContinuationIndentWidth": 2,
"ColumnLimit": 0,
"NamespaceIndentation": "None",
"BreakBeforeBraces": "Attach",
"AllowShortFunctionsOnASingleLine": "Empty",
"AllowShortIfStatementsOnASingleLine": "Never",
"AllowShortLoopsOnASingleLine": False,
"InsertBraces": True,
"SeparateDefinitionBlocks": "Always",
"MaxEmptyLinesToKeep": 1,
"KeepEmptyLinesAtTheStartOfBlocks": False,
"SortIncludes": "Never",
"IncludeBlocks": "Preserve",
"SortUsingDeclarations": "Never",
"BreakStringLiterals": False,
"ReflowComments": False,
"FixNamespaceComments": False,
}
def descendants(node):
yield node
for child in node.children:
yield from descendants(child)
def cpp_tree(source):
import tree_sitter
import tree_sitter_cpp
parser = tree_sitter.Parser(tree_sitter.Language(tree_sitter_cpp.language()))
return parser.parse(source).root_node
def protected_cpp(node):
return node.type in {"string_literal", "raw_string_literal", "char_literal", "comment", "preproc_def", "preproc_function_def", "preproc_call", "preproc_include", "preproc_arg"}
def apply_edits(source, edits):
disjoint = []
end = 0
for start, stop, replacement in sorted(edits, key=lambda edit: (edit[0], -edit[1])):
if start >= end:
disjoint.append((start, stop, replacement))
end = stop
for start, stop, replacement in reversed(disjoint):
source = source[:start] + replacement + source[stop:]
return source
def join_cpp(source):
edits = []
root = cpp_tree(source)
for node in descendants(root):
if node.type not in {"parameter_list", "argument_list", "template_argument_list", "template_parameter_list"}:
continue
children = list(descendants(node))
if node.has_error or any(child.type in {"comment", "lambda_expression", "initializer_list"} or (protected_cpp(child) and child.start_point.row != child.end_point.row) for child in children):
continue
parent = node.parent
if any(protected_cpp(ancestor) for ancestor in ancestors(parent)):
continue
value = source[node.start_byte:node.end_byte]
if b"\n" in value:
edits.append((node.start_byte, node.end_byte, re.sub(rb"[ \t]*\r?\n[ \t]*", b" ", value)))
return apply_edits(source, edits)
def ancestors(node):
while node is not None:
yield node
node = node.parent
def space_lines(source, compact, units, padding, protected, class_interiors=()):
lines = source.splitlines(keepends=True)
boundaries = set(padding)
for first, last in units:
if first > 0:
boundaries.add(first)
boundaries.add(last + 1)
# Class/struct members stay together even when a member is itself a definition.
boundaries.difference_update(class_interiors)
compact = compact.union(class_interiors)
output = []
pending = []
previous = None
for row, line in enumerate(lines):
if not line.strip() and row not in protected:
pending.append(row)
continue
if previous is not None:
gap = range(previous + 1, row + 1)
wanted = any(boundary in boundaries for boundary in gap)
inside = any(position in compact for position in gap)
if wanted or (pending and not inside):
output.append("\n")
output.append(line)
previous = row
pending = []
return "".join(output).rstrip("\n") + "\n"
def space_cpp(source):
root = cpp_tree(source.encode())
compact, protected, padding = set(), set(), set()
class_interiors = set()
units = []
for node in descendants(root):
first, last = node.start_point.row, node.end_point.row
if protected_cpp(node):
protected.update(range(first, last + 1))
if node.type == "compound_statement" and not node.has_error:
compact.update(range(first + 1, last + 1))
if node.type == "field_declaration_list" and len(node.children) >= 2:
opening, closing = node.children[0], node.children[-1]
if opening.type == "{" and closing.type == "}" and not opening.is_missing and not closing.is_missing:
class_interiors.update(range(first + 1, last + 1))
if node.type == "namespace_definition":
body = node.child_by_field_name("body")
if body and body.end_point.row > body.start_point.row + 1:
padding.update((body.start_point.row + 1, body.end_point.row))
if node.type in {"function_definition", "class_specifier", "struct_specifier", "enum_specifier", "namespace_definition"} and not node.has_error:
unit = node
while unit.parent and unit.parent.type in {"template_declaration", "declaration", "type_definition"}:
unit = unit.parent
before = source.splitlines()[unit.start_point.row][:unit.start_point.column]
if not before.strip():
units.append((unit.start_point.row, unit.end_point.row))
# Unit padding belongs between siblings, never between a block brace and a function.
lines = source.splitlines()
units = [(first, last) for first, last in units if first not in protected and last not in protected]
boundaries = set(padding)
for first, last in units:
previous = next((line.strip() for line in reversed(lines[:first]) if line.strip()), "")
following = next((line.strip() for line in lines[last + 1:] if line.strip()), "")
if previous and not previous.endswith("{"):
boundaries.add(first)
if following and not following.startswith("}"):
boundaries.add(last + 1)
return space_lines(source, compact, [], boundaries, protected, class_interiors)
def python_tokens(source):
return list(tokenize.generate_tokens(io.StringIO(source).readline))
def python_literal_lines(tree):
rows = set()
for node in ast.walk(tree):
if isinstance(node, (ast.JoinedStr, ast.Constant)) and hasattr(node, "end_lineno"):
if isinstance(node, ast.JoinedStr) or isinstance(node.value, (str, bytes)):
rows.update(range(node.lineno - 1, node.end_lineno))
return rows
def indent_python(source):
lines = source.splitlines(keepends=True)
indents = [len(line) - len(line.lstrip(" \t")) for line in lines]
tokens = python_tokens(source)
stack, handled = [], set()
statement_indent = None
literal_starts = []
for index, token in enumerate(tokens):
# Python 3.12+ tokenizes f-string expressions separately; their contents stay intact.
if token.type == getattr(tokenize, "FSTRING_END", None):
handled.update(range(literal_starts.pop(), token.end[0]))
continue
if literal_starts:
if token.type == getattr(tokenize, "FSTRING_START", None):
literal_starts.append(token.start[0])
continue
if token.type in {tokenize.INDENT, tokenize.DEDENT, tokenize.NL, tokenize.ENDMARKER}:
continue
if token.type == tokenize.NEWLINE:
statement_indent = None
continue
if token.type == tokenize.STRING:
handled.update(range(token.start[0], token.end[0]))
row = token.start[0] - 1
if row not in handled:
indent = indents[row]
if stack:
# Inline nested brackets share their opening line's indentation.
indent = stack[-1] + 2
closing = 0
for following_index in range(index, len(tokens)):
following = tokens[following_index]
if following.start[0] != token.start[0] or following.type != tokenize.OP or following.string not in ")]}":
break
closing += 1
if closing:
indent = stack[-closing]
elif statement_indent is not None:
indent = statement_indent + 2
lines[row] = " " * indent + lines[row].lstrip(" \t")
indents[row] = indent
handled.add(row)
if statement_indent is None and token.type != tokenize.COMMENT:
statement_indent = indents[row]
if token.type == getattr(tokenize, "FSTRING_START", None):
literal_starts.append(token.start[0])
if token.type == tokenize.OP:
if token.string in "([{":
stack.append(indents[row])
elif token.string in ")]}":
stack.pop()
return "".join(lines)
def join_python(source):
tokens = python_tokens(source)
literals = python_literal_lines(ast.parse(source))
lines = source.splitlines(keepends=True)
offsets = [0]
for line in lines:
offsets.append(offsets[-1] + len(line))
stack, edits = [], []
for index, token in enumerate(tokens):
if token.type != tokenize.OP:
continue
if token.string in "([{":
stack.append(index)
elif token.string in ")]}":
start = stack.pop()
opening = tokens[start]
prior = tokens[start - 1] if start else None
# Only calls and signatures. Structured literals and multiline strings stay intact.
if opening.string != "(" or not prior or prior.type != tokenize.NAME or prior.string == "import":
continue
content = tokens[start + 1:index]
if any(row in literals for row in range(opening.start[0], token.end[0] - 1)):
continue
if any(item.type == tokenize.COMMENT or (item.type == tokenize.STRING and item.start[0] != item.end[0]) or item.string in {"[", "{", "lambda", "for"} for item in content):
continue
first = offsets[opening.start[0] - 1] + opening.start[1]
last = offsets[token.end[0] - 1] + token.end[1]
value = re.sub(r"[ \t]*\r?\n[ \t]*", " ", source[first:last])
value = re.sub(r",[ \t]*\)$", ")", value)
if value != source[first:last]:
edits.append((first, last, value))
return apply_edits(source, edits)
def space_python(source):
tree = ast.parse(source)
compact, protected = set(), python_literal_lines(tree)
class_interiors = set()
units = []
for token in python_tokens(source):
if token.type in {tokenize.STRING, tokenize.COMMENT}:
protected.update(range(token.start[0] - 1, token.end[0]))
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
class_interiors.update(range(node.lineno, node.end_lineno))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
compact.update(range(node.lineno, node.end_lineno))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
first = min([node.lineno, *[decorator.lineno for decorator in node.decorator_list]]) - 1
last = node.end_lineno - 1
units.append((first, last))
return space_lines(source, compact, units, set(), protected, class_interiors)
def format_source(source, suffix, style=None):
if not source.strip():
return source
if suffix == ".py":
import autopep8
before = ast.dump(ast.parse(source))
result = autopep8.fix_code(join_python(source), options={"indent_size": 2, "ignore": ["E501", "E3", "E4", "W50", "W6"], "aggressive": 0})
result = space_python(indent_python(result))
if ast.dump(ast.parse(result)) != before:
raise ValueError("formatter changed Python syntax")
return result
result = subprocess.run(["clang-format", "--style=" + json.dumps(style or clang_style)], input=join_cpp(source.encode()).decode(), text=True, capture_output=True, check=True)
return space_cpp(result.stdout)
def alias_diagnostics(source, suffix, portability=False):
if suffix != ".py":
root = cpp_tree(source.encode())
allowed = {"bool_t", "char_t", "uchar_t", "float32_t", "float64_t"}
for node in descendants(root):
if node.type in {"alias_declaration", "namespace_alias_definition", "type_definition"}:
name = node.child_by_field_name("name")
if portability and name and name.text.decode() in allowed and any(parent.type == "namespace_definition" and parent.child_by_field_name("name").text == b"std" for parent in ancestors(node.parent)):
continue
yield node.start_point.row + 1, "use the original type or namespace, not an alias"
return
tree = ast.parse(source)
modules = {alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names}
modules.update(name.split(".")[0] for name in list(modules))
typing_names = {alias.name for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module in {"typing", "typing_extensions"} for alias in node.names}
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)) and any(alias.asname for alias in node.names):
yield node.lineno, "use imports without renaming"
if isinstance(node, getattr(ast, "TypeAlias", ())):
yield node.lineno, "write the type annotation directly"
if isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None:
value = node.value
explicit = isinstance(node, ast.AnnAssign) and ast.unparse(node.annotation).split(".")[-1] == "TypeAlias"
type_expression = isinstance(value, ast.Subscript) and (ast.unparse(value.value) in typing_names | {"list", "dict", "tuple", "set", "frozenset", "type"} or ast.unparse(value.value).startswith(("typing.", "typing_extensions.")))
module_reference = isinstance(value, (ast.Name, ast.Attribute)) and ast.unparse(value) in modules
if explicit or type_expression or module_reference:
yield node.lineno, "use the original module or inline type annotation"
+90
View File
@@ -0,0 +1,90 @@
"""Shared batch/check and stdout-clean editor formatting transport."""
import argparse
import fnmatch
import os
import pathlib
import subprocess
import sys
import modulus.clangd
import modulus.format_layout
suffixes = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".py"}
excluded_directories = {".git", ".venv", "venv", "build", "dist", "generated", "CMakeFiles", "__pycache__", ".cache", ".mypy_cache", ".pytest_cache", ".tox", "_deps", "vendor", "third_party", "third-party", "external", "node_modules"}
def eligible(root, path, excludes):
relative = path.relative_to(root)
if path.suffix not in suffixes or any(part in excluded_directories for part in relative.parts):
return False
if any(fnmatch.fnmatch(relative.as_posix(), pattern) for pattern in excludes):
return False
return path.resolve().is_relative_to(root.resolve()) and not any(parent.is_symlink() for parent in [path, *path.parents] if parent != root)
def source_files(root, requested, excludes):
result = subprocess.run(["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"], capture_output=True, check=True)
candidates = {root / os.fsdecode(name) for name in result.stdout.split(b"\0") if name}
selected = [pathlib.Path(name) for name in requested]
if any(path.is_absolute() or ".." in path.parts for path in selected):
raise ValueError("format paths must be relative to the repository")
return sorted(path for path in candidates if path.is_file() and eligible(root, path, excludes) and (not selected or any(path == root / name or path.is_relative_to(root / name) for name in selected)))
def local_format(context, args):
root = context.repo_root
settings = context.tooling.get("format", {})
excludes = settings.get("exclude", [])
style = context.tooling["editor"]["clang_format"]
if args.stdin_filename:
path = pathlib.Path(args.stdin_filename)
path = path if path.is_absolute() else root / path
if not path.is_relative_to(root) or not eligible(root, path, excludes):
raise ValueError("buffer is outside the project's formattable sources")
source = sys.stdin.read()
sys.stdout.write(modulus.format_layout.format_source(source, path.suffix, style))
return 0
changed = []
diagnostics = []
# Validate and format every input before changing any files.
for path in source_files(root, args.paths, excludes):
source = path.read_text(encoding="utf-8")
try:
result = modulus.format_layout.format_source(source, path.suffix, style)
except (ValueError, SyntaxError) as error:
raise ValueError(f"{path.relative_to(root)}: {error}") from error
if source != result:
changed.append((path, result))
if args.check:
portability = context.package["name"] == "modulus" and path.relative_to(root).as_posix() == "include/modulus/types.hpp"
diagnostics.extend(f"{path.relative_to(root)}:{line}: {message}" for line, message in modulus.format_layout.alias_diagnostics(source, path.suffix, portability))
if args.check:
for path, _ in changed:
print(f"{path.relative_to(root)}: formatting differs", file=sys.stderr)
for message in diagnostics:
print(message, file=sys.stderr)
return int(bool(changed or diagnostics))
for path, result in changed:
path.write_text(result, encoding="utf-8")
print(f"Formatted {len(changed)} files.", file=sys.stderr)
return 0
def launch(context, argv):
parser = argparse.ArgumentParser(description="Format project C/C++ and Python with the shared Modulus policy.")
parser.add_argument("--check", action="store_true", help="check layout and prohibited aliases without writing files")
parser.add_argument("--stdin-filename", help="format stdin using this buffer's path; output only formatted text")
parser.add_argument("paths", nargs="*", help="optional repository-relative files or directories")
args = parser.parse_args(argv)
if args.stdin_filename and (args.check or args.paths):
parser.error("--stdin-filename cannot be combined with --check or paths")
try:
if context.docker_marker.exists() or os.environ.get("MODULUS_DIRECT") == "1":
return local_format(context, args)
image = modulus.clangd.current_image(context)
if image is None:
raise RuntimeError("missing or stale build image; run configure first")
root = context.repo_root
mode = "ro" if args.check or args.stdin_filename else "rw"
command = [*modulus.clangd.isolated_run(image), "--interactive", "--volume", f"{root}:{root}:{mode}", "--env", "PYTHONDONTWRITEBYTECODE=1", "--workdir", str(root), image, "python3", str(context.tools_dir / "format.py"), *argv]
return subprocess.run(command, check=False).returncode
except (OSError, ValueError, SyntaxError, ImportError, RuntimeError, subprocess.SubprocessError) as error:
detail = error.stderr if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error)
print(f"Modulus format: {detail}", file=sys.stderr)
return 1
+7
View File
@@ -134,6 +134,13 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"format": {
"type": "object",
"additionalProperties": false,
"properties": {
"exclude": { "type": "array", "items": { "type": "string" } }
}
},
"default_target": { "default_target": {
"$ref": "#/$defs/cmake_name" "$ref": "#/$defs/cmake_name"
}, },
+24 -21
View File
@@ -17,6 +17,7 @@
#include <modulus/source_location.hpp> #include <modulus/source_location.hpp>
#include <cstdint> #include <cstdint>
namespace { namespace {
auto config_error(std::string message) -> modulus::error { auto config_error(std::string message) -> modulus::error {
@@ -74,7 +75,7 @@ auto log_level_name(modulus::log_level level) -> std::string_view {
} }
auto modulus::parse_logging_config(boost::json::value const& value) -> std::expected<modulus::logging_config, modulus::error> { auto modulus::parse_logging_config(boost::json::value const &value) -> std::expected<modulus::logging_config, modulus::error> {
auto object = modulus::json_config::require_object(value, "logging config"); auto object = modulus::json_config::require_object(value, "logging config");
if (!object) { if (!object) {
return modulus::fail<modulus::logging_config>(object.error()); return modulus::fail<modulus::logging_config>(object.error());
@@ -99,17 +100,18 @@ auto modulus::parse_logging_config(boost::json::value const& value) -> std::expe
return modulus::success(config); return modulus::success(config);
} }
auto modulus::parse_error_config(boost::json::value const& value) -> std::expected<modulus::error_config, modulus::error> { auto modulus::parse_error_config(boost::json::value const &value) -> std::expected<modulus::error_config, modulus::error> {
auto object = modulus::json_config::require_object(value, "error config"); auto object = modulus::json_config::require_object(value, "error config");
if (!object) { if (!object) {
return modulus::fail<modulus::error_config>(object.error()); return modulus::fail<modulus::error_config>(object.error());
} }
auto unknown = modulus::json_config::reject_unknown_fields(**object, { auto unknown = modulus::json_config::reject_unknown_fields(**object, {
"include_category", "include_category",
"include_code", "include_code",
"include_message", "include_message",
"include_location", "include_location",
}, "error config"); },
"error config");
if (!unknown) { if (!unknown) {
return modulus::fail<modulus::error_config>(unknown.error()); return modulus::fail<modulus::error_config>(unknown.error());
} }
@@ -137,15 +139,16 @@ auto modulus::parse_error_config(boost::json::value const& value) -> std::expect
return modulus::success(config); return modulus::success(config);
} }
auto modulus::parse_config(boost::json::value const& value) -> std::expected<modulus::config, modulus::error> { auto modulus::parse_config(boost::json::value const &value) -> std::expected<modulus::config, modulus::error> {
auto object = modulus::json_config::require_object(value, "config root"); auto object = modulus::json_config::require_object(value, "config root");
if (!object) { if (!object) {
return modulus::fail<modulus::config>(object.error()); return modulus::fail<modulus::config>(object.error());
} }
auto unknown = modulus::json_config::reject_unknown_fields(**object, { auto unknown = modulus::json_config::reject_unknown_fields(**object, {
"logging", "logging",
"errors", "errors",
}, "config root"); },
"config root");
if (!unknown) { if (!unknown) {
return modulus::fail<modulus::config>(unknown.error()); return modulus::fail<modulus::config>(unknown.error());
} }
@@ -167,19 +170,19 @@ auto modulus::parse_config(boost::json::value const& value) -> std::expected<mod
return modulus::success(config); return modulus::success(config);
} }
auto modulus::load_config(std::string const& path) -> std::expected<modulus::config, modulus::error> { auto modulus::load_config(std::string const &path) -> std::expected<modulus::config, modulus::error> {
auto value = modulus::json_config::load_value(path, { auto value = modulus::json_config::load_value(path, {
.category = "modulus.config", .category = "modulus.config",
.invalid_argument_code = 1, .invalid_argument_code = 1,
.not_found_code = 1, .not_found_code = 1,
}); });
if (!value) { if (!value) {
return modulus::fail<modulus::config>(value.error()); return modulus::fail<modulus::config>(value.error());
} }
return modulus::parse_config(*value); return modulus::parse_config(*value);
} }
auto modulus::to_json(modulus::logging_config const& config) -> boost::json::value { auto modulus::to_json(modulus::logging_config const &config) -> boost::json::value {
return boost::json::object{ return boost::json::object{
{ {
"level", "level",
@@ -188,7 +191,7 @@ auto modulus::to_json(modulus::logging_config const& config) -> boost::json::val
}; };
} }
auto modulus::to_json(modulus::error_config const& config) -> boost::json::value { auto modulus::to_json(modulus::error_config const &config) -> boost::json::value {
return boost::json::object{ return boost::json::object{
{ {
"include_category", "include_category",
@@ -209,7 +212,7 @@ auto modulus::to_json(modulus::error_config const& config) -> boost::json::value
}; };
} }
auto modulus::to_json(modulus::config const& config) -> boost::json::value { auto modulus::to_json(modulus::config const &config) -> boost::json::value {
return boost::json::object{ return boost::json::object{
{ {
"logging", "logging",
@@ -222,12 +225,12 @@ auto modulus::to_json(modulus::config const& config) -> boost::json::value {
}; };
} }
auto modulus::configure_logger(modulus::logger& logger, modulus::logging_config const& config) -> void { auto modulus::configure_logger(modulus::logger &logger, modulus::logging_config const &config) -> void {
logger.set_level(config.level); logger.set_level(config.level);
modulus::log_debug("modulus logger configured"); modulus::log_debug("modulus logger configured");
} }
auto modulus::configure(modulus::config const& config) -> void { auto modulus::configure(modulus::config const &config) -> void {
modulus::configure_logger(modulus::default_logger(), config.logging); modulus::configure_logger(modulus::default_logger(), config.logging);
modulus::set_default_error_config(config.errors); modulus::set_default_error_config(config.errors);
modulus::log_debug("modulus configuration applied"); modulus::log_debug("modulus configuration applied");
+7 -5
View File
@@ -6,21 +6,23 @@
#include <utility> #include <utility>
#include <cstdint> #include <cstdint>
namespace { namespace {
auto config_mutex() -> std::mutex& { auto config_mutex() -> std::mutex & {
static auto mutex = std::mutex{}; static auto mutex = std::mutex{};
return mutex; return mutex;
} }
auto config_storage() -> modulus::error_config& { auto config_storage() -> modulus::error_config & {
static auto config = modulus::error_config{}; static auto config = modulus::error_config{};
return config; return config;
} }
} }
modulus::error::error(std::string category, std::int32_t code, std::string message, modulus::source_location location) : category_(std::move(category)), code_(code), message_(std::move(message)), location_(location) { modulus::error::error(std::string category, std::int32_t code, std::string message, modulus::source_location location)
: category_(std::move(category)), code_(code), message_(std::move(message)), location_(location) {
assert(!category_.empty()); assert(!category_.empty());
assert(location_.file_name() != nullptr); assert(location_.file_name() != nullptr);
assert(location_.function_name() != nullptr); assert(location_.function_name() != nullptr);
@@ -41,12 +43,12 @@ auto modulus::error::message() const noexcept -> std::string_view {
return message_; return message_;
} }
auto modulus::error::location() const noexcept -> const modulus::source_location& { auto modulus::error::location() const noexcept -> const modulus::source_location & {
assert(location_.file_name() != nullptr); assert(location_.file_name() != nullptr);
return location_; return location_;
} }
auto modulus::error::describe(modulus::error_config const& config) const -> std::string { auto modulus::error::describe(modulus::error_config const &config) const -> std::string {
assert(!category_.empty()); assert(!category_.empty());
assert(location_.file_name() != nullptr); assert(location_.file_name() != nullptr);
auto out = std::ostringstream{}; auto out = std::ostringstream{};
+13 -12
View File
@@ -17,6 +17,7 @@
#include <modulus/error.hpp> #include <modulus/error.hpp>
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace { namespace {
auto make_config_error(modulus::json_config::options config, std::int32_t code, std::string message) -> modulus::error { auto make_config_error(modulus::json_config::options config, std::int32_t code, std::string message) -> modulus::error {
@@ -35,7 +36,7 @@ auto key_allowed(std::string_view key, std::initializer_list<std::string_view> a
return false; return false;
} }
auto integer_value(boost::json::value const& value, std::string_view name, modulus::json_config::options config) -> std::expected<std::int64_t, modulus::error> { auto integer_value(boost::json::value const &value, std::string_view name, modulus::json_config::options config) -> std::expected<std::int64_t, modulus::error> {
if (!value.is_number() || value.is_double()) { if (!value.is_number() || value.is_double()) {
return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be an integer")); return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be an integer"));
} }
@@ -58,7 +59,7 @@ auto modulus::json_config::not_found(modulus::json_config::options config, std::
return make_config_error(config, config.not_found_code, std::move(message)); return make_config_error(config, config.not_found_code, std::move(message));
} }
auto modulus::json_config::load_value(std::string const& path, modulus::json_config::options config) -> std::expected<boost::json::value, modulus::error> { auto modulus::json_config::load_value(std::string const &path, modulus::json_config::options config) -> std::expected<boost::json::value, modulus::error> {
if (path.empty()) { if (path.empty()) {
return std::unexpected(modulus::json_config::invalid(config, "config path must not be empty")); return std::unexpected(modulus::json_config::invalid(config, "config path must not be empty"));
} }
@@ -76,7 +77,7 @@ auto modulus::json_config::load_value(std::string const& path, modulus::json_con
return value; return value;
} }
auto modulus::json_config::require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config) -> std::expected<boost::json::object const*, modulus::error> { auto modulus::json_config::require_object(boost::json::value const &value, std::string_view context, modulus::json_config::options config) -> std::expected<boost::json::object const *, modulus::error> {
assert(context.data() != nullptr); assert(context.data() != nullptr);
if (!value.is_object()) { if (!value.is_object()) {
return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " must be an object")); return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " must be an object"));
@@ -84,9 +85,9 @@ auto modulus::json_config::require_object(boost::json::value const& value, std::
return &value.as_object(); return &value.as_object();
} }
auto modulus::json_config::reject_unknown_fields(boost::json::object const& object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config) -> std::expected<void, modulus::error> { auto modulus::json_config::reject_unknown_fields(boost::json::object const &object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config) -> std::expected<void, modulus::error> {
assert(context.data() != nullptr); assert(context.data() != nullptr);
for (auto const& field : object) { for (auto const &field : object) {
auto key = std::string_view{ auto key = std::string_view{
field.key().data(), field.key().data(),
field.key().size(), field.key().size(),
@@ -98,7 +99,7 @@ auto modulus::json_config::reject_unknown_fields(boost::json::object const& obje
return {}; return {};
} }
auto modulus::json_config::optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config) -> std::expected<std::bool_t, modulus::error> { auto modulus::json_config::optional_bool_field(boost::json::object const &object, std::string_view name, std::bool_t fallback, modulus::json_config::options config) -> std::expected<std::bool_t, modulus::error> {
assert(name.data() != nullptr); assert(name.data() != nullptr);
auto found = object.find(name); auto found = object.find(name);
if (found == object.end()) { if (found == object.end()) {
@@ -110,7 +111,7 @@ auto modulus::json_config::optional_bool_field(boost::json::object const& object
return found->value().as_bool(); return found->value().as_bool();
} }
auto modulus::json_config::optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config) -> std::expected<std::string, modulus::error> { auto modulus::json_config::optional_string_field(boost::json::object const &object, std::string_view name, std::string fallback, modulus::json_config::options config) -> std::expected<std::string, modulus::error> {
assert(name.data() != nullptr); assert(name.data() != nullptr);
auto found = object.find(name); auto found = object.find(name);
if (found == object.end()) { if (found == object.end()) {
@@ -122,7 +123,7 @@ auto modulus::json_config::optional_string_field(boost::json::object const& obje
return std::string{found->value().as_string()}; return std::string{found->value().as_string()};
} }
auto modulus::json_config::required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config) -> std::expected<std::string, modulus::error> { auto modulus::json_config::required_string_field(boost::json::object const &object, std::string_view name, std::string_view context, modulus::json_config::options config) -> std::expected<std::string, modulus::error> {
assert(name.data() != nullptr); assert(name.data() != nullptr);
assert(context.data() != nullptr); assert(context.data() != nullptr);
auto found = object.find(name); auto found = object.find(name);
@@ -136,7 +137,7 @@ auto modulus::json_config::required_string_field(boost::json::object const& obje
return value; return value;
} }
auto modulus::json_config::optional_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected<std::uint64_t, modulus::error> { auto modulus::json_config::optional_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected<std::uint64_t, modulus::error> {
assert(name.data() != nullptr); assert(name.data() != nullptr);
auto found = object.find(name); auto found = object.find(name);
if (found == object.end()) { if (found == object.end()) {
@@ -155,7 +156,7 @@ auto modulus::json_config::optional_uint64_field(boost::json::object const& obje
return number; return number;
} }
auto modulus::json_config::positive_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected<std::uint64_t, modulus::error> { auto modulus::json_config::positive_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected<std::uint64_t, modulus::error> {
auto parsed = modulus::json_config::optional_uint64_field(object, name, fallback, max_value, config); auto parsed = modulus::json_config::optional_uint64_field(object, name, fallback, max_value, config);
if (!parsed) { if (!parsed) {
return parsed; return parsed;
@@ -166,7 +167,7 @@ auto modulus::json_config::positive_uint64_field(boost::json::object const& obje
return parsed; return parsed;
} }
auto modulus::json_config::optional_int32_field(boost::json::object const& object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config) -> std::expected<std::int32_t, modulus::error> { auto modulus::json_config::optional_int32_field(boost::json::object const &object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config) -> std::expected<std::int32_t, modulus::error> {
assert(name.data() != nullptr); assert(name.data() != nullptr);
auto found = object.find(name); auto found = object.find(name);
if (found == object.end()) { if (found == object.end()) {
@@ -182,7 +183,7 @@ auto modulus::json_config::optional_int32_field(boost::json::object const& objec
return static_cast<std::int32_t>(*number); return static_cast<std::int32_t>(*number);
} }
auto modulus::json_config::positive_milliseconds_field(boost::json::object const& object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected<std::chrono::milliseconds, modulus::error> { auto modulus::json_config::positive_milliseconds_field(boost::json::object const &object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected<std::chrono::milliseconds, modulus::error> {
auto parsed = modulus::json_config::positive_uint64_field(object, name, static_cast<std::uint64_t>(fallback.count()), max_value, config); auto parsed = modulus::json_config::positive_uint64_field(object, name, static_cast<std::uint64_t>(fallback.count()), max_value, config);
if (!parsed) { if (!parsed) {
return std::unexpected(parsed.error()); return std::unexpected(parsed.error());
+22 -13
View File
@@ -7,6 +7,7 @@
#include <utility> #include <utility>
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace { namespace {
auto is_valid_log_level(modulus::log_level level) noexcept -> std::bool_t { auto is_valid_log_level(modulus::log_level level) noexcept -> std::bool_t {
@@ -19,13 +20,13 @@ auto should_log(modulus::log_level record_level, modulus::log_level configured_l
return configured_level != modulus::log_level::OFF && static_cast<std::int32_t>(record_level) >= static_cast<std::int32_t>(configured_level); return configured_level != modulus::log_level::OFF && static_cast<std::int32_t>(record_level) >= static_cast<std::int32_t>(configured_level);
} }
auto format_log_record(std::ostream& stream, const modulus::log_record& record) -> void { auto format_log_record(std::ostream &stream, const modulus::log_record &record) -> void {
assert(is_valid_log_level(record.level)); assert(is_valid_log_level(record.level));
assert(record.location.file_name() != nullptr); assert(record.location.file_name() != nullptr);
stream << '[' << modulus::to_string(record.level) << "] " << record.message << " (" << record.location.file_name() << ':' << record.location.line() << ")\n"; stream << '[' << modulus::to_string(record.level) << "] " << record.message << " (" << record.location.file_name() << ':' << record.location.line() << ")\n";
} }
auto stderr_sink(const modulus::log_record& record) -> void { auto stderr_sink(const modulus::log_record &record) -> void {
assert(is_valid_log_level(record.level)); assert(is_valid_log_level(record.level));
assert(record.location.file_name() != nullptr); assert(record.location.file_name() != nullptr);
static auto mutex = std::mutex{}; static auto mutex = std::mutex{};
@@ -38,18 +39,26 @@ auto stderr_sink(const modulus::log_record& record) -> void {
auto modulus::to_string(modulus::log_level level) noexcept -> std::string_view { auto modulus::to_string(modulus::log_level level) noexcept -> std::string_view {
assert(is_valid_log_level(level)); assert(is_valid_log_level(level));
switch (level) { switch (level) {
case modulus::log_level::TRACE: return "trace"; case modulus::log_level::TRACE:
case modulus::log_level::DEBUG: return "debug"; return "trace";
case modulus::log_level::INFO: return "info"; case modulus::log_level::DEBUG:
case modulus::log_level::WARN: return "warn"; return "debug";
case modulus::log_level::ERROR: return "error"; case modulus::log_level::INFO:
case modulus::log_level::CRITICAL: return "critical"; return "info";
case modulus::log_level::OFF: return "off"; case modulus::log_level::WARN:
return "warn";
case modulus::log_level::ERROR:
return "error";
case modulus::log_level::CRITICAL:
return "critical";
case modulus::log_level::OFF:
return "off";
} }
return "unknown"; return "unknown";
} }
modulus::logger::logger() : sink_(stderr_sink) {} modulus::logger::logger()
: sink_(stderr_sink) {}
auto modulus::logger::set_level(modulus::log_level level) -> void { auto modulus::logger::set_level(modulus::log_level level) -> void {
assert(is_valid_log_level(level)); assert(is_valid_log_level(level));
@@ -69,7 +78,7 @@ auto modulus::logger::enabled(modulus::log_level level) const -> std::bool_t {
return should_log(level, level_) && static_cast<std::bool_t>(sink_); return should_log(level, level_) && static_cast<std::bool_t>(sink_);
} }
auto modulus::logger::set_sink(std::function<void(const modulus::log_record&)> sink) -> void { auto modulus::logger::set_sink(std::function<void(const modulus::log_record &)> sink) -> void {
auto lock = std::lock_guard{mutex_}; auto lock = std::lock_guard{mutex_};
sink_ = std::move(sink); sink_ = std::move(sink);
} }
@@ -78,7 +87,7 @@ auto modulus::logger::write(modulus::log_record record) const -> void {
assert(is_valid_log_level(record.level)); assert(is_valid_log_level(record.level));
assert(record.location.file_name() != nullptr); assert(record.location.file_name() != nullptr);
assert(record.location.function_name() != nullptr); assert(record.location.function_name() != nullptr);
auto sink = std::function<void(const modulus::log_record&)>{}; auto sink = std::function<void(const modulus::log_record &)>{};
{ {
auto lock = std::lock_guard{mutex_}; auto lock = std::lock_guard{mutex_};
if (!should_log(record.level, level_) || !sink_) { if (!should_log(record.level, level_) || !sink_) {
@@ -102,7 +111,7 @@ auto modulus::logger::log(modulus::log_level level, std::string message, modulus
}); });
} }
auto modulus::default_logger() -> modulus::logger& { auto modulus::default_logger() -> modulus::logger & {
static auto instance = modulus::logger{}; static auto instance = modulus::logger{};
return instance; return instance;
} }
+55
View File
@@ -0,0 +1,55 @@
local root = assert(vim.env.MODULUS_FORMAT_REPO)
vim.opt.rtp:append(assert(vim.env.MODULUS_CONFORM_PATH))
local conform = require("conform")
conform.setup({ formatters_by_ft = { cpp = { "clang_format" }, _ = { "original_fallback" } } })
vim.lsp.config("clangd", { cmd = { "clangd" }, filetypes = { "c", "cpp" } })
vim.lsp.enable = function() end
dofile(assert(vim.env.MODULUS_FORMAT_HELPER)).setup(root)
local function buffer(name, ft, lines)
local buf = vim.api.nvim_create_buf(true, false)
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_buf_set_name(buf, name)
vim.bo[buf].filetype = ft
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
return buf
end
local function content(buf)
return table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n")
end
local function check()
local outside = buffer("/tmp/not-modulus.cpp", "cpp", { "int x;" })
assert(vim.deep_equal(conform.list_formatters_for_buffer(outside), { "clang_format" }))
local python_outside = buffer("/tmp/not_modulus.py", "python", { "pass" })
assert(vim.deep_equal(conform.list_formatters_for_buffer(python_outside), { "original_fallback" }))
local cpp = buffer(root .. "/tests/modulus-format-probe.cpp", "cpp", { "void example(){", "", "int a=1;", "", "a++;", "}" })
assert(vim.deep_equal(conform.list_formatters_for_buffer(cpp), { "modulus" }))
local failure
conform.format({ bufnr = cpp, timeout_ms = 10000 }, function(err) failure = err end)
assert(not failure, vim.inspect(failure))
assert(content(cpp) == "void example() {\n int a = 1;\n a++;\n}", content(cpp))
local py = buffer(root .. "/tests/modulus_format_probe.py", "python", { "def example( x ): return x+1" })
vim.g.autoformat = false
vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py })
assert(content(py) == "def example( x ): return x+1")
vim.g.autoformat = true
vim.b[py].autoformat = false
vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py })
assert(content(py) == "def example( x ): return x+1")
vim.b[py].autoformat = true
vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py })
assert(content(py) == "def example(x): return x + 1", content(py))
vim.api.nvim_buf_set_lines(py, 0, -1, false, { "patterns = (", ' ".git",', ' ".tmp",', ")" })
vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py })
assert(content(py) == 'patterns = (\n ".git",\n ".tmp",\n)', content(py))
vim.api.nvim_buf_set_lines(py, 0, -1, false, { "def broken(" })
conform.format({ bufnr = py, timeout_ms = 10000, quiet = true }, function(err) failure = err end)
assert(failure, "invalid Python unexpectedly succeeded")
assert(content(py) == "def broken(", "failed formatter replaced the buffer")
end
local ok, err = pcall(check)
if not ok then
print("MODULUS_FORMAT_FAILURE " .. tostring(err))
vim.cmd("cquit 1")
end
print("MODULUS_FORMAT_OK")
vim.cmd("qa!")
+10 -9
View File
@@ -15,11 +15,12 @@
#include <modulus.hpp> #include <modulus.hpp>
namespace { namespace {
struct stderr_capture { struct stderr_capture {
std::ostringstream stream{}; std::ostringstream stream{};
std::streambuf* previous{std::cerr.rdbuf(stream.rdbuf())}; std::streambuf *previous{std::cerr.rdbuf(stream.rdbuf())};
~stderr_capture(); ~stderr_capture();
}; };
@@ -71,7 +72,7 @@ BOOST_AUTO_TEST_CASE(modulus_logger_filters_and_reports_caller_location) {
auto records = std::vector<modulus::log_record>{}; auto records = std::vector<modulus::log_record>{};
auto logger = modulus::logger{}; auto logger = modulus::logger{};
logger.set_level(modulus::log_level::DEBUG); logger.set_level(modulus::log_level::DEBUG);
logger.set_sink([&](const modulus::log_record& record) { logger.set_sink([&](const modulus::log_record &record) {
records.push_back(record); records.push_back(record);
}); });
logger.log(modulus::log_level::TRACE, "hidden"); logger.log(modulus::log_level::TRACE, "hidden");
@@ -85,7 +86,7 @@ BOOST_AUTO_TEST_CASE(modulus_logger_serializes_custom_sink_writes) {
auto records = std::vector<modulus::log_record>{}; auto records = std::vector<modulus::log_record>{};
auto logger = modulus::logger{}; auto logger = modulus::logger{};
logger.set_level(modulus::log_level::INFO); logger.set_level(modulus::log_level::INFO);
logger.set_sink([&](const modulus::log_record& record) { logger.set_sink([&](const modulus::log_record &record) {
records.push_back(record); records.push_back(record);
}); });
auto threads = std::vector<std::thread>{}; auto threads = std::vector<std::thread>{};
@@ -96,12 +97,12 @@ BOOST_AUTO_TEST_CASE(modulus_logger_serializes_custom_sink_writes) {
} }
}); });
} }
for (auto& thread : threads) { for (auto &thread : threads) {
thread.join(); thread.join();
} }
BOOST_TEST(records.size() == std::size_t{200}); BOOST_TEST(records.size() == std::size_t{200});
auto messages = std::set<std::string>{}; auto messages = std::set<std::string>{};
for (const auto& record : records) { for (const auto &record : records) {
messages.insert(record.message); messages.insert(record.message);
} }
BOOST_TEST(messages.size() == std::size_t{200}); BOOST_TEST(messages.size() == std::size_t{200});
@@ -111,10 +112,10 @@ BOOST_AUTO_TEST_CASE(modulus_logger_tolerates_concurrent_configuration_changes)
auto records = std::vector<modulus::log_record>{}; auto records = std::vector<modulus::log_record>{};
auto logger = modulus::logger{}; auto logger = modulus::logger{};
logger.set_level(modulus::log_level::INFO); logger.set_level(modulus::log_level::INFO);
logger.set_sink([&](const modulus::log_record& record) { logger.set_sink([&](const modulus::log_record &record) {
records.push_back(record); records.push_back(record);
}); });
auto sink = [&](const modulus::log_record& record) -> void { auto sink = [&](const modulus::log_record &record) -> void {
records.push_back(record); records.push_back(record);
}; };
auto mutator = std::thread{[&]() -> void { auto mutator = std::thread{[&]() -> void {
@@ -132,7 +133,7 @@ BOOST_AUTO_TEST_CASE(modulus_logger_tolerates_concurrent_configuration_changes)
} }
}); });
} }
for (auto& thread : threads) { for (auto &thread : threads) {
thread.join(); thread.join();
} }
mutator.join(); mutator.join();
@@ -159,7 +160,7 @@ BOOST_AUTO_TEST_CASE(modulus_default_logger_serializes_stderr_lines) {
} }
}); });
} }
for (auto& thread : threads) { for (auto &thread : threads) {
thread.join(); thread.join();
} }
auto captured = std::istringstream{capture.stream.str()}; auto captured = std::istringstream{capture.stream.str()};
+57 -68
View File
@@ -9,80 +9,75 @@ import tempfile
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import modulus.clangd as clangd import modulus.clangd
import modulus.cli as cli import modulus.cli
import modulus.config as config import modulus.config
import modulus.export as export import modulus.export
class clangd_test(unittest.TestCase): class clangd_test(unittest.TestCase):
def setUp(self): def setUp(self):
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 = cli.build_context(self.root, config.normalize({"package": {"name": "sample"}})) self.context = modulus.cli.build_context(self.root, modulus.config.normalize({"package": {"name": "sample"}}))
self.context.docker_marker = self.root / "not-in-container"
self.context.dockerfile.parent.mkdir(parents=True) self.context.dockerfile.parent.mkdir(parents=True)
self.context.dockerfile.write_text("FROM sample\n") self.context.dockerfile.write_text("FROM sample\n")
self.image = "sha256:" + "1" * 64 self.image = "sha256:" + "1" * 64
def test_defaults_and_custom_settings(self): def test_defaults_and_custom_settings(self):
self.assertEqual(clangd.settings(self.context)["executable"], "/usr/bin/clangd-19") self.assertEqual(modulus.clangd.settings(self.context)["executable"], "/usr/bin/clangd-19")
self.assertIn("/usr/bin/g++-12", clangd.settings(self.context)["query_drivers"]) self.assertIn("/usr/bin/g++-12", modulus.clangd.settings(self.context)["query_drivers"])
custom = {"executable": "/opt/bin/clangd", "arguments": [], "query_drivers": ["/opt/bin/g++"], "header_roots": ["/opt/sdk"]} custom = {"executable": "/opt/bin/clangd", "arguments": [], "query_drivers": ["/opt/bin/g++"], "header_roots": ["/opt/sdk"]}
result = config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": custom}}}) result = modulus.config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": custom}}})
self.assertEqual(result["tooling"]["lsp"]["clangd"], custom) self.assertEqual(result["tooling"]["lsp"]["clangd"], custom)
def test_config_rejects_unsafe_or_unknown_settings(self): def test_config_rejects_unsafe_or_unknown_settings(self):
for invalid in ([], {"unknown": True}, {"executable": "clangd"}, {"arguments": "--background-index"}, for invalid in ([], {"unknown": True}, {"executable": "clangd"}, {"arguments": "--background-index"},
{"query_drivers": ["/usr/bin/*"]}, {"header_roots": ["/"]}, {"query_drivers": ["/usr/bin/*"]}, {"header_roots": ["/"]},
{"header_roots": ["/usr/../etc"]}, {"query_drivers": ["/bin/g++,/bin/gcc"]}, {"header_roots": ["/usr/../etc"]}, {"query_drivers": ["/bin/g++,/bin/gcc"]},
{"arguments": ["--query-driver=*"]}, {"arguments": ["--path-mappings=/=/"]}): {"arguments": ["--query-driver=*"]}, {"arguments": ["--path-mappings=/=/"]}):
with self.subTest(invalid=invalid), self.assertRaises(config.config_error): with self.subTest(invalid=invalid), self.assertRaises(modulus.config.config_error):
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": {clangd.image_label: clangd.recipe_digest(self.context)}}} image = {"Id": self.image, "Architecture": "amd64", "Config": {"Labels": {modulus.clangd.image_label: modulus.clangd.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(clangd.current_image(self.context), self.image) self.assertEqual(modulus.clangd.current_image(self.context), self.image)
self.assertIsNone(clangd.current_image(self.context, "arm64")) self.assertIsNone(modulus.clangd.current_image(self.context, "arm64"))
self.context.dockerfile.write_text("FROM changed\n") self.context.dockerfile.write_text("FROM changed\n")
self.assertIsNone(clangd.current_image(self.context)) self.assertIsNone(modulus.clangd.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 = clangd.cache_directory(self.context, self.image) cache = modulus.clangd.cache_directory(self.context, self.image)
other = cli.build_context(self.root / "other", self.context.config) other = modulus.cli.build_context(self.root / "other", self.context.config)
self.assertEqual(cache, 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, clangd.cache_directory(self.context, self.image)) self.assertEqual(cache, modulus.clangd.cache_directory(self.context, self.image))
self.context.lsp_settings["clangd"]["header_roots"].append("/opt/sdk") self.context.lsp_settings["clangd"]["header_roots"].append("/opt/sdk")
self.assertNotEqual(cache, clangd.cache_directory(self.context, self.image)) self.assertNotEqual(cache, modulus.clangd.cache_directory(self.context, self.image))
self.assertNotEqual(cache, clangd.cache_directory(other, "sha256:" + "2" * 64)) self.assertNotEqual(cache, modulus.clangd.cache_directory(other, "sha256:" + "2" * 64))
def test_stdio_launch_has_no_tty_socket_credentials_or_host_home(self): def test_stdio_launch_has_no_tty_socket_credentials_or_host_home(self):
command = clangd.launch_command(self.context, [], self.image, self.root / "cache", ["/usr/include"], "test-server") command = modulus.clangd.launch_command(self.context, [], self.image, self.root / "cache", ["/usr/include"], "test-server")
self.assertIn("--interactive", command) self.assertIn("--interactive", command)
self.assertIn("--read-only", command) self.assertIn("--read-only", command)
self.assertIn(f"{self.root}:{self.root}:ro", command) self.assertIn(f"{self.root}:{self.root}:ro", command)
self.assertIn(f"{self.context.build_root}:{self.context.build_root}:rw", command) self.assertIn(f"{self.context.build_root}:{self.context.build_root}:rw", command)
self.assertIn("--network", command) self.assertIn("--network", command)
self.assertNotIn("-t", command) self.assertNotIn("-t", command)
for unwanted in ("--tty", "--volumes-from", "CONAN_TOKEN", "/var/run/docker.sock", str(pathlib.Path.home()) + ":"): for unwanted in ("--tty", "--volumes-from", "CONAN_TOKEN", "/var/run/docker.sock"):
self.assertNotIn(unwanted, " ".join(command)) self.assertNotIn(unwanted, " ".join(command))
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.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,/usr/bin/aarch64-linux-gnu-g++", 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(clangd, "current_image", return_value=None), patch.object(clangd, "prepare") as prepare, \ with patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.clangd, "prepare") as prepare, \
patch.object(cli, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): patch.object(modulus.cli, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
self.assertEqual(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()
self.assertEqual(output.getvalue(), "") self.assertEqual(output.getvalue(), "")
self.assertIn("configure", errors.getvalue()) self.assertIn("configure", errors.getvalue())
def test_path_mapping_uses_client_to_server_direction_and_specificity(self): def test_path_mapping_uses_client_to_server_direction_and_specificity(self):
self.assertEqual(clangd.path_mappings(pathlib.Path("/cache"), ["/usr/include", "/opt/sdk"]), self.assertEqual(modulus.clangd.path_mappings(pathlib.Path("/cache"), ["/usr/include", "/opt/sdk"]),
"--path-mappings=/cache/rootfs/usr/include=/usr/include,/cache/rootfs/opt/sdk=/opt/sdk") "--path-mappings=/cache/rootfs/usr/include=/usr/include,/cache/rootfs/opt/sdk=/opt/sdk")
def archive(self, name, kind=tarfile.REGTYPE): def archive(self, name, kind=tarfile.REGTYPE):
stream = io.BytesIO() stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w") as archive: with tarfile.open(fileobj=stream, mode="w") as archive:
@@ -92,69 +87,63 @@ class clangd_test(unittest.TestCase):
archive.addfile(member) archive.addfile(member)
stream.seek(0) stream.seek(0)
return stream return stream
def test_header_extraction_rejects_escape_and_links(self): def test_header_extraction_rejects_escape_and_links(self):
for name, kind in (("../bad", tarfile.REGTYPE), ("/usr/include/bad", tarfile.REGTYPE), for name, kind in (("../bad", tarfile.REGTYPE), ("/usr/include/bad", tarfile.REGTYPE),
("usr/include/../bad", tarfile.REGTYPE), ("etc/passwd", tarfile.REGTYPE), ("usr/include/../bad", tarfile.REGTYPE), ("etc/passwd", tarfile.REGTYPE),
("usr/include/link", tarfile.SYMTYPE)): ("usr/include/link", tarfile.SYMTYPE)):
with self.subTest(name=name), self.assertRaises(ValueError): with self.subTest(name=name), self.assertRaises(ValueError):
clangd.extract_headers(self.archive(name, kind), self.root, ["/usr/include"]) modulus.clangd.extract_headers(self.archive(name, kind), self.root, ["/usr/include"])
clangd.extract_headers(self.archive("usr/include/header.h"), self.root, ["/usr/include"]) modulus.clangd.extract_headers(self.archive("usr/include/header.h"), self.root, ["/usr/include"])
self.assertEqual((self.root / "usr/include/header.h").stat().st_mode & 0o777, 0o444) self.assertEqual((self.root / "usr/include/header.h").stat().st_mode & 0o777, 0o444)
def test_completed_cache_never_invokes_container_again(self): def test_completed_cache_never_invokes_container_again(self):
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": clangd.cache_version, "roots": ["/usr/include"]})) (cache / "manifest.json").write_text(json.dumps({"version": modulus.clangd.cache_version, "roots": ["/usr/include"]}))
with patch.object(clangd, "cache_directory", return_value=cache), patch.object(clangd, "current_image", return_value=self.image), \ with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.clangd, "current_image", return_value=self.image), \
patch.object(subprocess, "run") as run: patch.object(subprocess, "run") as run:
self.assertEqual(clangd.prepare(self.context), 0) self.assertEqual(modulus.clangd.prepare(self.context), 0)
run.assert_not_called() run.assert_not_called()
def test_failed_extraction_never_publishes_partial_cache(self): def test_failed_extraction_never_publishes_partial_cache(self):
cache = self.root / "image/cache" cache = self.root / "image/cache"
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(clangd, "cache_directory", return_value=cache), patch.object(clangd, "current_image", return_value=self.image), \ with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.clangd, "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(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]
with patch.object(subprocess, "Popen", return_value=process), \ with patch.object(subprocess, "Popen", return_value=process), \
patch.object(subprocess, "run", side_effect=subprocess.TimeoutExpired("docker rm", 15)), \ patch.object(subprocess, "run", side_effect=subprocess.TimeoutExpired("docker rm", 15)), \
contextlib.redirect_stderr(io.StringIO()): contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(clangd.run_server(["docker"], "test-server"), 0) self.assertEqual(modulus.clangd.run_server(["docker"], "test-server"), 0)
process.kill.assert_called_once() process.kill.assert_called_once()
self.assertEqual(process.wait.call_count, 3) self.assertEqual(process.wait.call_count, 3)
def test_nvim_generation_is_stable_opt_out_and_respects_ownership(self): def test_nvim_generation_is_stable_opt_out_and_respects_ownership(self):
export.sync_nvim_file(self.root, {}) modulus.export.sync_nvim_file(self.root, {})
path = self.root / ".nvim.lua" path = self.root / ".nvim.lua"
before = path.stat().st_mtime_ns before = path.stat().st_mtime_ns
export.sync_nvim_file(self.root, {}) modulus.export.sync_nvim_file(self.root, {})
self.assertEqual(before, path.stat().st_mtime_ns) self.assertEqual(before, path.stat().st_mtime_ns)
export.sync_nvim_file(self.root, {"enabled": False}) modulus.export.sync_nvim_file(self.root, {"enabled": False})
self.assertFalse(path.exists()) self.assertFalse(path.exists())
path.write_text("-- user settings\n") path.write_text("-- user settings\n")
with contextlib.redirect_stderr(io.StringIO()): with contextlib.redirect_stderr(io.StringIO()):
export.sync_nvim_file(self.root, {}) modulus.export.sync_nvim_file(self.root, {})
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(cli, "in_docker", return_value=False), patch.object(cli, "docker_run", return_value=0) as run, \ with patch.object(modulus.cli, "in_docker", return_value=False), patch.object(modulus.cli, "docker_run", return_value=0) as run, \
patch.object(clangd, "prepare", return_value=0) as prepare: patch.object(modulus.clangd, "prepare", return_value=0) as prepare:
self.assertEqual(cli.lsp(self.context, []), 0) self.assertEqual(modulus.cli.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(cli.lsp(self.context, []), 1) self.assertEqual(modulus.cli.lsp(self.context, []), 1)
prepare.assert_not_called() prepare.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
+5 -15
View File
@@ -21,7 +21,6 @@ class lsp_session:
self.reader.start() self.reader.start()
self.next_id = 0 self.next_id = 0
self.pending = [] self.pending = []
def read_messages(self): def read_messages(self):
try: try:
while True: while True:
@@ -38,12 +37,10 @@ class lsp_session:
self.messages.put(json.loads(self.process.stdout.read(int(headers["content-length"])))) self.messages.put(json.loads(self.process.stdout.read(int(headers["content-length"]))))
except Exception as error: except Exception as error:
self.messages.put(error) self.messages.put(error)
def send(self, message): def send(self, message):
body = json.dumps({"jsonrpc": "2.0", **message}).encode() body = json.dumps({"jsonrpc": "2.0", **message}).encode()
self.process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body) self.process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
self.process.stdin.flush() self.process.stdin.flush()
def wait(self, predicate, timeout=90): def wait(self, predicate, timeout=90):
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while True: while True:
@@ -58,7 +55,6 @@ class lsp_session:
self.log.seek(0) self.log.seek(0)
raise AssertionError(f"{message}\n{self.log.read().decode(errors='replace')}") raise AssertionError(f"{message}\n{self.log.read().decode(errors='replace')}")
self.pending.append(message) self.pending.append(message)
def request(self, method, params): def request(self, method, params):
self.next_id += 1 self.next_id += 1
self.send({"id": self.next_id, "method": method, "params": params}) self.send({"id": self.next_id, "method": method, "params": params})
@@ -66,20 +62,17 @@ class lsp_session:
if "error" in response: if "error" in response:
raise AssertionError(response["error"]) raise AssertionError(response["error"])
return response.get("result") return response.get("result")
def initialize(self, root): def initialize(self, root):
result = self.request("initialize", {"processId": os.getpid(), "rootUri": root.as_uri(), result = self.request("initialize", {"processId": os.getpid(), "rootUri": root.as_uri(),
"capabilities": {"textDocument": {"publishDiagnostics": {"versionSupport": True}}}}) "capabilities": {"textDocument": {"publishDiagnostics": {"versionSupport": True}}}})
self.send({"method": "initialized", "params": {}}) self.send({"method": "initialized", "params": {}})
return result return result
def open(self, path, text): def open(self, path, text):
uri = path.as_uri() uri = path.as_uri()
self.send({"method": "textDocument/didOpen", "params": {"textDocument": { self.send({"method": "textDocument/didOpen", "params": {"textDocument": {
"uri": uri, "languageId": "cpp", "version": 1, "text": text}}}) "uri": uri, "languageId": "cpp", "version": 1, "text": text}}})
return self.wait(lambda message: message.get("method") == "textDocument/publishDiagnostics" return self.wait(lambda message: message.get("method") == "textDocument/publishDiagnostics"
and message["params"]["uri"] == uri)["params"]["diagnostics"] and message["params"]["uri"] == uri)["params"]["diagnostics"]
def close(self): def close(self):
if self.process.poll() is None: if self.process.poll() is None:
self.process.terminate() self.process.terminate()
@@ -93,26 +86,24 @@ class lsp_session:
class container_clangd_test(unittest.TestCase): class container_clangd_test(unittest.TestCase):
def roots(self): def roots(self):
return [pathlib.Path(root).resolve() for root in os.environ["MODULUS_LSP_TEST_ROOTS"].split(os.pathsep)] return [pathlib.Path(root).resolve() for root in os.environ["MODULUS_LSP_TEST_ROOTS"].split(os.pathsep)]
@unittest.skipUnless(shutil.which("nvim"), "Neovim is not installed") @unittest.skipUnless(shutil.which("nvim"), "Neovim is not installed")
def test_neovim_attachment_and_readonly_navigation(self): def test_neovim_attachment_and_readonly_navigation(self):
script = pathlib.Path(__file__).with_name("nvim_clangd.lua") script = pathlib.Path(__file__).with_name("nvim_clangd.lua")
for root in self.roots(): for root in self.roots():
database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text()) database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text())
source = next(pathlib.Path(entry["file"]) for entry in database source = next(pathlib.Path(entry["file"]) for entry in database
if pathlib.Path(entry["file"]).is_relative_to(root / "src")) if pathlib.Path(entry["file"]).is_relative_to(root / "src"))
sources = [(source, "cstdint")] sources = [(source, "cstdint")]
if (root / "src/vamp_pico/main.cpp").exists(): if (root / "src/vamp_pico/main.cpp").exists():
sources.append((root / "src/vamp_pico/main.cpp", "pico/stdlib.h")) sources.append((root / "src/vamp_pico/main.cpp", "pico/stdlib.h"))
for source, header in sources: for source, header in sources:
with self.subTest(repo=root.name, source=str(source)): with self.subTest(repo=root.name, source=str(source)):
env = {**os.environ, "MODULUS_LSP_REPO": str(root), "MODULUS_LSP_SOURCE": str(source.relative_to(root)), env = {**os.environ, "MODULUS_LSP_REPO": str(root), "MODULUS_LSP_SOURCE": str(source.relative_to(root)),
"MODULUS_LSP_HEADER": header} "MODULUS_LSP_HEADER": header}
result = subprocess.run(["nvim", "--headless", "-i", "NONE", "-u", str(script)], cwd=root, result = subprocess.run(["nvim", "--headless", "-i", "NONE", "-u", str(script)], cwd=root,
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180) env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180)
self.assertEqual(result.returncode, 0, result.stdout) self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("MODULUS_NVIM_OK", result.stdout) self.assertIn("MODULUS_NVIM_OK", result.stdout)
def test_native_and_pico_diagnostics_and_local_header_definitions(self): def test_native_and_pico_diagnostics_and_local_header_definitions(self):
for root in self.roots(): for root in self.roots():
with self.subTest(repo=root.name): with self.subTest(repo=root.name):
@@ -122,7 +113,7 @@ class container_clangd_test(unittest.TestCase):
self.assertIn("19.", initialized["serverInfo"]["version"]) self.assertIn("19.", initialized["serverInfo"]["version"])
database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text()) database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text())
source = next(pathlib.Path(entry["file"]) for entry in database source = next(pathlib.Path(entry["file"]) for entry in database
if pathlib.Path(entry["file"]).is_relative_to(root / "src")) if pathlib.Path(entry["file"]).is_relative_to(root / "src"))
sources = [(source, "cstdint")] sources = [(source, "cstdint")]
if (root / "src/vamp_pico").is_dir(): if (root / "src/vamp_pico").is_dir():
sources.extend((path, "pico/stdlib.h") for path in sorted((root / "src/vamp_pico").glob("*.cpp"))) sources.extend((path, "pico/stdlib.h") for path in sorted((root / "src/vamp_pico").glob("*.cpp")))
@@ -145,7 +136,6 @@ class container_clangd_test(unittest.TestCase):
self.assertEqual(session.process.wait(timeout=25), 0) self.assertEqual(session.process.wait(timeout=25), 0)
finally: finally:
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())
+2 -51
View File
@@ -59,7 +59,6 @@ class upload_packages_test(unittest.TestCase):
}, },
} }
return modulus.cli.build_context(repo_root or pathlib.Path(os.getcwd()), config) return modulus.cli.build_context(repo_root or pathlib.Path(os.getcwd()), 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 = [
@@ -69,7 +68,6 @@ 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_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)
@@ -77,7 +75,6 @@ class upload_packages_test(unittest.TestCase):
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.cli.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)
@@ -97,7 +94,6 @@ class upload_packages_test(unittest.TestCase):
before = {name: (root / name).stat().st_mtime_ns for name in modulus.export.editor_files.values()} before = {name: (root / name).stat().st_mtime_ns for name in modulus.export.editor_files.values()}
self.assertEqual(modulus.cli.main(command), 0) self.assertEqual(modulus.cli.main(command), 0)
self.assertEqual(before, {name: (root / name).stat().st_mtime_ns for name in before}) self.assertEqual(before, {name: (root / name).stat().st_mtime_ns for name in before})
def test_lsp_rejects_source_root_before_cache_cleanup(self): def test_lsp_rejects_source_root_before_cache_cleanup(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory) root = pathlib.Path(directory)
@@ -112,7 +108,6 @@ class upload_packages_test(unittest.TestCase):
modulus.cli.configure_lsp_database(context, {"source_dir": "subproject", "build_dir": build_dir}) modulus.cli.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.cli, "upload_all_packages", return_value=37)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
def test_failed_upload_does_not_force_by_default(self, configure, upload): def test_failed_upload_does_not_force_by_default(self, configure, upload):
@@ -122,7 +117,6 @@ class upload_packages_test(unittest.TestCase):
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)
@unittest.mock.patch.object(modulus.cli, "upload_all_packages") @unittest.mock.patch.object(modulus.cli, "upload_all_packages")
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote") @unittest.mock.patch.object(modulus.cli, "configure_conan_remote")
def test_replace_requires_guard_env(self, configure, upload): def test_replace_requires_guard_env(self, configure, upload):
@@ -131,7 +125,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "upload_all_packages", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
def test_replace_forces_upload_when_guarded(self, configure, upload): def test_replace_forces_upload_when_guarded(self, configure, upload):
@@ -147,7 +140,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "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):
@@ -155,13 +147,11 @@ class upload_packages_test(unittest.TestCase):
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.cli, "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.cli.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):
workflow = (pathlib.Path(__file__).resolve().parents[1] / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") workflow = (pathlib.Path(__file__).resolve().parents[1] / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
self.assertIn("gitea.ref == 'refs/heads/master'", workflow) self.assertIn("gitea.ref == 'refs/heads/master'", workflow)
@@ -178,7 +168,6 @@ class upload_packages_test(unittest.TestCase):
self.assertNotIn("--volume \"${PWD}:${PWD}\"", workflow) self.assertNotIn("--volume \"${PWD}:${PWD}\"", workflow)
self.assertNotIn("CONAN_PUBLIC_REMOTE_URL", workflow) self.assertNotIn("CONAN_PUBLIC_REMOTE_URL", workflow)
self.assertNotIn("docs.py", workflow) self.assertNotIn("docs.py", workflow)
def test_removed_public_commands_are_rejected(self): def test_removed_public_commands_are_rejected(self):
with tempfile.TemporaryDirectory() as repo_dir: with tempfile.TemporaryDirectory() as repo_dir:
repo = pathlib.Path(repo_dir) repo = pathlib.Path(repo_dir)
@@ -194,7 +183,6 @@ class upload_packages_test(unittest.TestCase):
modulus.cli.main(command) modulus.cli.main(command)
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.cli.docker_passthrough_env(self.context())
self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", passthrough) self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", passthrough)
@@ -205,7 +193,6 @@ class upload_packages_test(unittest.TestCase):
self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough) self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough)
self.assertNotIn("GITHUB_TOKEN", passthrough) self.assertNotIn("GITHUB_TOKEN", passthrough)
self.assertNotIn("GITEA_TOKEN", passthrough) self.assertNotIn("GITEA_TOKEN", passthrough)
def test_run_ports_default_to_empty_list(self): def test_run_ports_default_to_empty_list(self):
config = modulus.config.normalize({ config = modulus.config.normalize({
"package": { "package": {
@@ -213,7 +200,6 @@ class upload_packages_test(unittest.TestCase):
}, },
}) })
self.assertEqual(config["tooling"]["run"]["ports"], []) self.assertEqual(config["tooling"]["run"]["ports"], [])
def test_run_accepts_docker_publish_mapping(self): def test_run_accepts_docker_publish_mapping(self):
config = modulus.config.normalize({ config = modulus.config.normalize({
"package": { "package": {
@@ -226,7 +212,6 @@ class upload_packages_test(unittest.TestCase):
}, },
}) })
self.assertEqual(config["tooling"]["run"]["ports"], ["127.0.0.1:8000:8000/tcp"]) self.assertEqual(config["tooling"]["run"]["ports"], ["127.0.0.1:8000:8000/tcp"])
def test_run_rejects_invalid_docker_publish_mapping(self): def test_run_rejects_invalid_docker_publish_mapping(self):
with self.assertRaisesRegex(modulus.config.config_error, "tooling.run.ports\\[0\\]"): with self.assertRaisesRegex(modulus.config.config_error, "tooling.run.ports\\[0\\]"):
modulus.config.normalize({ modulus.config.normalize({
@@ -239,13 +224,11 @@ class upload_packages_test(unittest.TestCase):
}, },
}, },
}) })
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @unittest.mock.patch.object(modulus.cli, "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.cli.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.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @unittest.mock.patch.object(modulus.cli, "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):
@@ -284,7 +267,6 @@ 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.cli, "docker_run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=False) @unittest.mock.patch.object(modulus.cli, "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):
@@ -308,7 +290,6 @@ 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.cli, "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()
@@ -334,10 +315,11 @@ class upload_packages_test(unittest.TestCase):
"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.clangd.image_label}={modulus.cli.modulus.clangd.recipe_digest(context)}",
"--build-arg",
"MODULUS_TOOLS=tools",
".", ".",
], ],
) )
@unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0) @unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0)
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "in_container", return_value=False) @unittest.mock.patch.object(modulus.cli, "in_container", return_value=False)
@@ -356,11 +338,9 @@ class upload_packages_test(unittest.TestCase):
self.assertIn("linux/arm64", run.call_args.args[1]) self.assertIn("linux/arm64", run.call_args.args[1])
ensure_image.assert_called_once_with(context) ensure_image.assert_called_once_with(context)
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.cli.conan_remote_url(self.context()), "https://example.test/conan")
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,
@@ -370,7 +350,6 @@ 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.cli.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,
@@ -384,7 +363,6 @@ class upload_packages_test(unittest.TestCase):
user, token = modulus.cli.conan_credentials() user, token = modulus.cli.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.cli, "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):
@@ -402,14 +380,12 @@ 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.cli, "run", return_value=0)
def test_configure_remote_rejects_partial_conan_credentials(self, run): def test_configure_remote_rejects_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.cli.configure_conan_remote(self.context())
self.assertEqual(status, 1) self.assertEqual(status, 1)
run.assert_not_called() run.assert_not_called()
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "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()
@@ -435,7 +411,6 @@ 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.cli, "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()
@@ -484,7 +459,6 @@ class upload_packages_test(unittest.TestCase):
"<hidden>", "<hidden>",
], ],
) )
def multi_target_context(self): def multi_target_context(self):
config = { config = {
"package": { "package": {
@@ -537,7 +511,6 @@ class upload_packages_test(unittest.TestCase):
}, },
} }
return modulus.cli.build_context(pathlib.Path(os.getcwd()), config) return modulus.cli.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(
@@ -545,7 +518,6 @@ class upload_packages_test(unittest.TestCase):
False, False,
) )
self.assertEqual([target.name for target in selected], ["debug"]) self.assertEqual([target.name for target in selected], ["debug"])
def test_selected_targets_all_returns_every_target(self): def test_selected_targets_all_returns_every_target(self):
context = self.multi_target_context() context = self.multi_target_context()
selected = context.selected_targets( selected = context.selected_targets(
@@ -553,7 +525,6 @@ 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.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @unittest.mock.patch.object(modulus.cli, "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):
@@ -586,7 +557,6 @@ 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.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -609,7 +579,6 @@ 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.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -635,7 +604,6 @@ 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.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -664,7 +632,6 @@ 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.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=37) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=37)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -678,7 +645,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "configure_one", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
@@ -697,7 +663,6 @@ 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.cli, "upload_packages", return_value=0)
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -724,7 +689,6 @@ 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.cli, "upload_packages", return_value=0)
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -752,7 +716,6 @@ 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.cli, "upload_packages", return_value=1)
@unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -778,7 +741,6 @@ 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.cli, "create_package", return_value=0)
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "run", return_value=0)
@unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True)
@@ -803,7 +765,6 @@ class upload_packages_test(unittest.TestCase):
create_package.assert_not_called() create_package.assert_not_called()
run.assert_not_called() run.assert_not_called()
docker.assert_called_once() docker.assert_called_once()
@unittest.mock.patch.object(modulus.cli, "clear_conan_deps") @unittest.mock.patch.object(modulus.cli, "clear_conan_deps")
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "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):
@@ -819,7 +780,6 @@ 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.cli, "clear_conan_deps")
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "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):
@@ -836,7 +796,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "configure_lsp_database", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0)
@unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0)
@@ -864,7 +823,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "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:
@@ -891,7 +849,6 @@ 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.cli, "clear_conan_deps")
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "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):
@@ -901,7 +858,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "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:
@@ -912,7 +868,6 @@ class upload_packages_test(unittest.TestCase):
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.cli, "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:
@@ -921,7 +876,6 @@ class upload_packages_test(unittest.TestCase):
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])
def test_create_artifact_package_installs_archives_and_checksums_tree(self): def test_create_artifact_package_installs_archives_and_checksums_tree(self):
with tempfile.TemporaryDirectory() as repo_dir: with tempfile.TemporaryDirectory() as repo_dir:
repo = pathlib.Path(repo_dir) repo = pathlib.Path(repo_dir)
@@ -937,14 +891,12 @@ class upload_packages_test(unittest.TestCase):
"with_appliance_adapters": True, "with_appliance_adapters": True,
}, },
}) })
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.cli.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.cli, "run", side_effect=fake_run) as run:
status, artifacts = modulus.cli.create_artifact_package( status, artifacts = modulus.cli.create_artifact_package(
context, context,
@@ -971,7 +923,6 @@ 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.cli, "run", return_value=0)
def test_generic_artifact_upload_uses_package_registry_credentials(self, run): def test_generic_artifact_upload_uses_package_registry_credentials(self, run):
context = self.context() context = self.context()
+8 -20
View File
@@ -23,10 +23,8 @@ class modulus_export_test(unittest.TestCase):
"name": "sample", "name": "sample",
}, },
} }
def write_config(self, root, config): def write_config(self, root, config):
(root / "modulus.json").write_text(json.dumps(config), encoding="utf-8") (root / "modulus.json").write_text(json.dumps(config), encoding="utf-8")
def write_package(self, package): def write_package(self, package):
for directory in ( for directory in (
"modulus", "modulus",
@@ -43,16 +41,19 @@ class modulus_export_test(unittest.TestCase):
(package / "modulus" / "cli.py").write_text("", encoding="utf-8") (package / "modulus" / "cli.py").write_text("", encoding="utf-8")
(package / "modulus" / "config.py").write_text("", encoding="utf-8") (package / "modulus" / "config.py").write_text("", encoding="utf-8")
(package / "modulus" / "export.py").write_text("", encoding="utf-8") (package / "modulus" / "export.py").write_text("", encoding="utf-8")
(package / "modulus" / "formatting.py").write_text("", encoding="utf-8")
(package / "modulus" / "format_layout.py").write_text("", encoding="utf-8")
(package / "tools" / "configure.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "configure.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8")
(package / "tools" / "lsp.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "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" / "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" / "modulus-config.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):
config = modulus.config.normalize(self.config()) config = modulus.config.normalize(self.config())
boost_options = modulus.config.boost_requirement_options(config) boost_options = modulus.config.boost_requirement_options(config)
@@ -61,7 +62,6 @@ class modulus_export_test(unittest.TestCase):
self.assertIs(boost_options["without_container"], False) self.assertIs(boost_options["without_container"], False)
self.assertIs(boost_options["without_charconv"], False) self.assertIs(boost_options["without_charconv"], False)
self.assertIs(boost_options["without_atomic"], True) self.assertIs(boost_options["without_atomic"], True)
def test_default_targets_include_architecture_and_build_type_matrix(self): def test_default_targets_include_architecture_and_build_type_matrix(self):
config = modulus.config.normalize(self.config()) config = modulus.config.normalize(self.config())
targets = config["tooling"]["targets"] targets = config["tooling"]["targets"]
@@ -82,7 +82,6 @@ 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_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"] = {
@@ -111,11 +110,9 @@ class modulus_export_test(unittest.TestCase):
self.assertEqual(normalized["package"]["artifact"]["install_prefix"], "usr") self.assertEqual(normalized["package"]["artifact"]["install_prefix"], "usr")
self.assertEqual(normalized["package"]["artifact"]["registry_url"], "https://git.thyme-labs.com/api/packages/khodo/generic") self.assertEqual(normalized["package"]["artifact"]["registry_url"], "https://git.thyme-labs.com/api/packages/khodo/generic")
self.assertEqual(normalized["tooling"]["run"]["args"], []) self.assertEqual(normalized["tooling"]["run"]["args"], [])
config["package"]["artifact"]["targets"] = ["missing"] config["package"]["artifact"]["targets"] = ["missing"]
with self.assertRaisesRegex(modulus.config.config_error, "unknown target"): with self.assertRaisesRegex(modulus.config.config_error, "unknown target"):
modulus.config.normalize(config) modulus.config.normalize(config)
def test_target_cmake_definitions_are_validated_and_defaulted(self): def test_target_cmake_definitions_are_validated_and_defaulted(self):
config = self.config() config = self.config()
config["tooling"] = { config["tooling"] = {
@@ -148,7 +145,6 @@ class modulus_export_test(unittest.TestCase):
}, },
) )
self.assertEqual(normalized["tooling"]["targets"]["release"]["cmake_definitions"], {}) self.assertEqual(normalized["tooling"]["targets"]["release"]["cmake_definitions"], {})
config["tooling"]["targets"]["native"]["cmake_definitions"] = { config["tooling"]["targets"]["native"]["cmake_definitions"] = {
"SAMPLE_BAD": [ "SAMPLE_BAD": [
"invalid", "invalid",
@@ -156,7 +152,6 @@ class modulus_export_test(unittest.TestCase):
} }
with self.assertRaisesRegex(modulus.config.config_error, "tooling.targets.native.cmake_definitions"): with self.assertRaisesRegex(modulus.config.config_error, "tooling.targets.native.cmake_definitions"):
modulus.config.normalize(config) modulus.config.normalize(config)
def test_lsp_compile_databases_are_validated_and_defaulted(self): def test_lsp_compile_databases_are_validated_and_defaulted(self):
config = self.config() config = self.config()
config["tooling"] = { config["tooling"] = {
@@ -183,11 +178,9 @@ class modulus_export_test(unittest.TestCase):
self.assertEqual(database["generator"], "Ninja") self.assertEqual(database["generator"], "Ninja")
self.assertEqual(database["env"], {}) self.assertEqual(database["env"], {})
self.assertEqual(database["unset_env"], ["CC", "CXX"]) self.assertEqual(database["unset_env"], ["CC", "CXX"])
config["tooling"]["lsp"]["compile_databases"][0]["unset_env"] = ["bad-name"] config["tooling"]["lsp"]["compile_databases"][0]["unset_env"] = ["bad-name"]
with self.assertRaisesRegex(modulus.config.config_error, "tooling.lsp.compile_databases\\[0\\].unset_env"): with self.assertRaisesRegex(modulus.config.config_error, "tooling.lsp.compile_databases\\[0\\].unset_env"):
modulus.config.normalize(config) modulus.config.normalize(config)
def test_checked_in_profile_overrides_exported_default_profile(self): def test_checked_in_profile_overrides_exported_default_profile(self):
with tempfile.TemporaryDirectory() as repo_dir: with tempfile.TemporaryDirectory() as repo_dir:
repo = pathlib.Path(repo_dir) repo = pathlib.Path(repo_dir)
@@ -197,14 +190,12 @@ class modulus_export_test(unittest.TestCase):
profile.write_text("[settings]\n", encoding="utf-8") profile.write_text("[settings]\n", encoding="utf-8")
config = modulus.config.load_repo_config(repo) config = modulus.config.load_repo_config(repo)
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):
with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir:
repo = pathlib.Path(repo_dir) repo = pathlib.Path(repo_dir)
@@ -226,6 +217,10 @@ class modulus_export_test(unittest.TestCase):
self.assertTrue((runtime / "modulus" / "cli.py").is_file()) self.assertTrue((runtime / "modulus" / "cli.py").is_file())
self.assertTrue((runtime / "modulus" / "config.py").is_file()) self.assertTrue((runtime / "modulus" / "config.py").is_file())
self.assertTrue((runtime / "modulus" / "export.py").is_file()) self.assertTrue((runtime / "modulus" / "export.py").is_file())
self.assertTrue((runtime / "modulus" / "formatting.py").is_file())
self.assertTrue((runtime / "modulus" / "format_layout.py").is_file())
self.assertTrue((repo / "build/build_tools/format.py").is_file())
self.assertTrue((repo / "build/build_tools/docker/format-requirements.txt").is_file())
self.assertFalse((runtime / "CMakeLists.txt").exists()) self.assertFalse((runtime / "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())
@@ -239,7 +234,6 @@ class modulus_export_test(unittest.TestCase):
self.assertFalse((runtime / "lib").exists()) self.assertFalse((runtime / "lib").exists())
self.assertFalse((runtime / "share").exists()) self.assertFalse((runtime / "share").exists())
self.assertFalse((runtime / "bin").exists()) self.assertFalse((runtime / "bin").exists())
def test_editor_defaults_use_configured_target(self): def test_editor_defaults_use_configured_target(self):
config = modulus.config.normalize(self.config()) config = modulus.config.normalize(self.config())
config["tooling"].pop("editor") config["tooling"].pop("editor")
@@ -250,7 +244,6 @@ class modulus_export_test(unittest.TestCase):
self.assertEqual(editor["clangd"][0]["Diagnostics"]["ClangTidy"]["Remove"], ["*"]) self.assertEqual(editor["clangd"][0]["Diagnostics"]["ClangTidy"]["Remove"], ["*"])
self.assertIn("conanfile.py", editor["pyright"]["include"]) self.assertIn("conanfile.py", editor["pyright"]["include"])
self.assertNotIn("package/conanfile.py", editor["pyright"]["include"]) self.assertNotIn("package/conanfile.py", editor["pyright"]["include"])
def test_editor_replacements_and_refresh_are_idempotent(self): def test_editor_replacements_and_refresh_are_idempotent(self):
config = self.config() config = self.config()
editor = { editor = {
@@ -277,7 +270,6 @@ class modulus_export_test(unittest.TestCase):
before = {path.name: path.stat().st_mtime_ns for path in root.iterdir()} before = {path.name: path.stat().st_mtime_ns for path in root.iterdir()}
modulus.export.sync_editor_files(root, normalized) modulus.export.sync_editor_files(root, normalized)
self.assertEqual(before, {path.name: path.stat().st_mtime_ns for path in root.iterdir()}) self.assertEqual(before, {path.name: path.stat().st_mtime_ns for path in root.iterdir()})
def test_editor_config_rejects_invalid_shapes(self): def test_editor_config_rejects_invalid_shapes(self):
for editor in (None, [], {"unknown": {}}, {"clangd": {}}, {"clangd": []}, {"clangd": ["bad"]}, {"pyright": []}, {"clang_format": None}, {"pyright": {"value": float("nan")}}): for editor in (None, [], {"unknown": {}}, {"clangd": {}}, {"clangd": []}, {"clangd": ["bad"]}, {"pyright": []}, {"clang_format": None}, {"pyright": {"value": float("nan")}}):
with self.subTest(editor=editor): with self.subTest(editor=editor):
@@ -285,7 +277,6 @@ class modulus_export_test(unittest.TestCase):
config["tooling"] = {"editor": editor} config["tooling"] = {"editor": editor}
with self.assertRaises(modulus.config.config_error): with self.assertRaises(modulus.config.config_error):
modulus.config.normalize(config) modulus.config.normalize(config)
def test_export_requires_packaged_tools(self): def test_export_requires_packaged_tools(self):
with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir:
repo = pathlib.Path(repo_dir) repo = pathlib.Path(repo_dir)
@@ -295,7 +286,6 @@ class modulus_export_test(unittest.TestCase):
(package / "modulus" / "cli.py").write_text("", encoding="utf-8") (package / "modulus" / "cli.py").write_text("", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "missing tools directory"): with self.assertRaisesRegex(RuntimeError, "missing tools directory"):
modulus.export.sync_project(repo, package) modulus.export.sync_project(repo, package)
def test_normalize_rejects_invalid_cmake_values(self): def test_normalize_rejects_invalid_cmake_values(self):
config = self.config() config = self.config()
config["package"]["name"] = "BadName" config["package"]["name"] = "BadName"
@@ -305,13 +295,11 @@ class modulus_export_test(unittest.TestCase):
config["docs"] = {} config["docs"] = {}
with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: docs"): with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: docs"):
modulus.config.normalize(config) modulus.config.normalize(config)
def test_unknown_top_level_config_keys_are_rejected(self): def test_unknown_top_level_config_keys_are_rejected(self):
config = self.config() config = self.config()
config["old_config"] = {} config["old_config"] = {}
with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: old_config"): with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: old_config"):
modulus.config.normalize(config) modulus.config.normalize(config)
def test_removed_setup_generation_fields_are_rejected(self): def test_removed_setup_generation_fields_are_rejected(self):
config = self.config() config = self.config()
config["schema_version"] = 1 config["schema_version"] = 1
+165
View File
@@ -0,0 +1,165 @@
import ast
import contextlib
import io
import json
import os
import pathlib
import subprocess
import tempfile
import sys
import unittest
from unittest.mock import patch
import modulus.cli
import modulus.config
import modulus.export
import modulus.format_layout
import modulus.formatting
class formatting_test(unittest.TestCase):
def formatted(self, source, suffix):
result = modulus.format_layout.format_source(source, suffix)
self.assertEqual(modulus.format_layout.format_source(result, suffix), result)
return result
def test_cpp_units_namespaces_and_include_groups(self):
source = '#include <vector>\n#include <string>\n\n#include "local.hpp"\n\nnamespace sample {\nint one() {\n\nint x=1;\n\nx++;\nreturn x;\n}\nint two() {return 2;}\n}\n'
result = self.formatted(source, ".cpp")
self.assertTrue(result.startswith(source.split("namespace")[0]))
self.assertIn("namespace sample {\n\nint one()", result)
self.assertIn("int x = 1;\n x++;\n return x;", result)
self.assertIn("}\n\nint two()", result)
self.assertTrue(result.endswith("}\n\n}\n"))
def test_cpp_no_column_limit_and_simple_lists(self):
name = "argument_" + "a" * 180
result = self.formatted(f"void function(\nint {name},\nint b) {{\n call(\n{name},\nb);\n}}\n", ".cpp")
self.assertIn(f"void function(int {name}, int b)", result)
self.assertIn(f"call({name}, b);", result)
def test_cpp_class_and_struct_members_have_no_blank_lines(self):
for kind in ("class", "struct"):
with self.subTest(kind=kind):
source = f'{kind} source_location {{\npublic:\n\n source_location() = default;\n\n source_location(source_location const&) = delete;\n\n int line() const;\n\n int value() const {{\n\n return line_;\n\n }}\n\n struct nested {{\n\n int value;\n\n }};\n\nprivate:\n\n int line_;\n\n}};\n\nstruct next {{ int value; }};\n'
result = self.formatted(source, ".hpp")
body = result.split(f"{kind} source_location {{\n", 1)[1].split("\n};", 1)[0]
self.assertNotIn("\n\n", body)
self.assertIn("};\n\nstruct next", result)
def test_cpp_class_keeps_literal_and_comment_blank_lines(self):
literal = 'R"raw(first\n\nsecond)raw"'
comment = '/* first\n\nsecond */'
source = f'struct example {{\n\n const char* text = {literal};\n\n {comment}\n\n int value;\n}};\n'
result = self.formatted(source, ".hpp")
self.assertIn(literal, result)
self.assertIn(comment, result)
self.assertNotIn("\n\n", result.replace(literal, "literal").replace(comment, "comment"))
def test_cpp_literals_macros_templates_and_lambdas(self):
literal = 'R"raw(first\n\n second\n)raw"'
source = f'template<class T>\nvoid test(T v) {{\n const char* s = {literal};\n\n visit(v, [](auto a) {{\n\nreturn a;\n}});\n}}\nBOOST_AUTO_TEST_CASE(example) {{\n\nCHECK(true);\n\n}}\n'
result = self.formatted(source, ".cpp")
self.assertIn(literal, result)
self.assertIn("BOOST_AUTO_TEST_CASE(example) {\n CHECK(true);\n}", result)
self.assertIn("[](auto a) {\n", result)
def test_python_ast_literals_decorators_and_nested_units(self):
source = 'import os\n\nimport pathlib\n\n@decorator\ndef outer(\n first,\n second,\n):\n text = """first\n\n second\n"""\n\n first += 1\n def nested():\n return 1\n return call(\n first,\n second,\n )\n'
result = self.formatted(source, ".py")
self.assertEqual(ast.dump(ast.parse(source)), ast.dump(ast.parse(result)))
self.assertIn('"""first\n\n second\n"""', result)
self.assertIn("@decorator\ndef outer(first, second):", result)
self.assertIn("first += 1\n\n def nested()", result)
self.assertIn("return 1\n\n return call(first, second)", result)
self.assertTrue(result.startswith("import os\n\nimport pathlib\n"))
def test_python_no_column_limit_and_structured_literals(self):
name = "a" * 250
source = f"def test({name},\n other):\n return call({name},\nother)\n"
result = self.formatted(source, ".py")
self.assertIn(f"def test({name}, other):", result)
data = 'def test():\n return call({\n "a": 1,\n "b": 2,\n })\n'
self.assertIn('{\n', self.formatted(data, ".py"))
def test_python_classes_keep_members_together(self):
source = 'class Example:\n\n """first\n\nsecond"""\n\n field = 1\n\n @staticmethod\n def value():\n\n return 1\n\n class Nested:\n\n field = 2\n\n async def other(self):\n\n return 2\n\nclass Next:\n\n field = 3\n'
result = self.formatted(source, ".py")
self.assertEqual(ast.dump(ast.parse(source)), ast.dump(ast.parse(result)))
self.assertIn('"""first\n\nsecond"""', result)
self.assertNotIn("\n\n", result.split("\n\nclass Next", 1)[0].replace('"""first\n\nsecond"""', 'docstring'))
self.assertIn("return 2\n\nclass Next:\n field = 3", result)
def test_python_tuple_continuations_use_two_spaces(self):
source = 'runtime_ignore_patterns = (\n ".git",\n ".tmp",\n "__pycache__",\n)\n'
expected = 'runtime_ignore_patterns = (\n ".git",\n ".tmp",\n "__pycache__",\n)\n'
self.assertEqual(self.formatted(source, ".py"), expected)
def test_python_nested_collections_use_two_spaces_per_opening_line(self):
source = 'def example():\n result = build({\n "items": [\n (\n 1,\n 2,\n ),\n ],\n "names": {\n "a",\n "b",\n },\n })\n return result\n'
expected = 'def example():\n result = build({\n "items": [\n (\n 1,\n 2,\n ),\n ],\n "names": {\n "a",\n "b",\n },\n })\n return result\n'
self.assertEqual(self.formatted(source, ".py"), expected)
self.assertEqual(self.formatted("values = [[\n 1,\n 2,\n]]\n", ".py"), "values = [[\n 1,\n 2,\n]]\n")
def test_python_continued_imports_headers_and_comments(self):
source = 'from somewhere import (\n first,\n second,\n)\n\n@decorator(\n first, # comment\n second,\n)\ndef example(\n first, # comment\n second,\n):\n if (\n first # condition\n and second\n ):\n return first\n'
expected = 'from somewhere import (\n first,\n second,\n)\n\n@decorator(\n first, # comment\n second,\n)\ndef example(\n first, # comment\n second,\n):\n if (\n first # condition\n and second\n ):\n return first\n'
self.assertEqual(self.formatted(source, ".py"), expected)
def test_python_explicit_continuations_use_two_spaces(self):
source = 'def example():\n with first() as one, \\\n second() as two:\n return one + \\\n two\n'
expected = 'def example():\n with first() as one, \\\n second() as two:\n return one + \\\n two\n'
self.assertEqual(self.formatted(source, ".py"), expected)
def test_python_continuations_preserve_multiline_literal_contents(self):
source = 'def example():\n return [\n # first value\n f"{call({1: 2})}",\n """first\n second\n\n third""",\n f"""start {name}\n {value}\n\n end""",\n "next",\n ]\n'
expected = 'def example():\n return [\n # first value\n f"{call({1: 2})}",\n """first\n second\n\n third""",\n f"""start {name}\n {value}\n\n end""",\n "next",\n ]\n'
self.assertEqual(self.formatted(source, ".py"), expected)
def test_python_adjacent_literals_are_continuations_not_literal_contents(self):
source = 'def example():\n text = (\n "first"\n "second"\n f"{value}"\n f"{other}"\n )\n return text\n'
expected = 'def example():\n text = (\n "first"\n "second"\n f"{value}"\n f"{other}"\n )\n return text\n'
self.assertEqual(self.formatted(source, ".py"), expected)
def test_aliases_and_portability_exception(self):
cpp = "namespace std { using bool_t = decltype(true); }\nusing integer = int;\nnamespace fs = std::filesystem;\ntypedef int count;\n"
self.assertEqual(len(list(modulus.format_layout.alias_diagnostics(cpp, ".hpp"))), 4)
self.assertEqual(len(list(modulus.format_layout.alias_diagnostics(cpp, ".hpp", True))), 3)
python = "import pathlib as p\nfrom typing import Literal\nRole = Literal['a', 'b']\n"
self.assertEqual(len(list(modulus.format_layout.alias_diagnostics(python, ".py"))), 2)
def test_stdin_failure_has_no_output_and_never_builds_image(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
context = modulus.cli.build_context(root, modulus.config.normalize({"package": {"name": "sample"}}))
context.docker_marker = root / "not-in-container"
output, errors = io.StringIO(), io.StringIO()
with patch.dict(os.environ, {"MODULUS_DIRECT": "1"}), patch("sys.stdin", io.StringIO("def broken(")), contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
self.assertEqual(modulus.formatting.launch(context, ["--stdin-filename", "test.py"]), 1)
self.assertEqual(output.getvalue(), "")
with patch.dict(os.environ, {"MODULUS_DIRECT": "0"}), patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.cli, "ensure_build_image") as build, contextlib.redirect_stderr(errors):
self.assertEqual(modulus.formatting.launch(context, ["--check"]), 1)
build.assert_not_called()
def test_discovery_excludes_generated_and_symlinks(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True)
for name in ["keep.cpp", "skip.cpp", "build/generated.cpp", "vendor/library.cpp"]:
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
(root / "link.cpp").symlink_to(root / "keep.cpp")
self.assertEqual(modulus.formatting.source_files(root, [], ["skip.cpp"]), [root / "keep.cpp"])
def test_nested_lists_join_in_one_pass_without_changing_string_spaces(self):
cpp = 'void example() { outer(\n inner(\n 1,\n 2),\n "a b"); }\n'
self.assertIn('outer(inner(1, 2), "a b");', self.formatted(cpp, ".cpp"))
python = 'def example():\n return outer(\n inner(\n 1,\n 2),\n "a b")\n'
self.assertIn('outer(inner(1, 2), "a b")', self.formatted(python, ".py"))
def test_multiline_fstrings_preserve_blank_lines_on_new_python_tokenizers(self):
source = 'def example(name):\n return f"""first {name}\n\nsecond\n\nthird"""\n'
self.assertEqual(self.formatted(source, ".py"), source)
def test_alias_check_distinguishes_module_aliases_from_saved_callbacks(self):
source = 'import modulus.cli\nsaved = modulus.cli.docker_run\nconfig = modulus.cli\nItems = list[int]\n'
self.assertEqual([line for line, _ in modulus.format_layout.alias_diagnostics(source, ".py")], [3, 4])
def test_exported_stdin_formatter_needs_no_compile_database(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / "modulus.json").write_text(json.dumps({"package": {"name": "sample"}}))
modulus.export.sync_project(root, pathlib.Path(__file__).resolve().parent.parent)
result = subprocess.run([sys.executable, str(root / "build/build_tools/format.py"), "--stdin-filename", "sample.py"], cwd=root, env=dict(os.environ, MODULUS_DIRECT="1", PYTHONDONTWRITEBYTECODE="1"), input="def sample():\n values = (\n 1,\n 2,\n )\n return values\n", text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "def sample():\n values = (\n 1,\n 2,\n )\n return values\n")
self.assertEqual(result.stderr, "")
@unittest.skipUnless(os.environ.get("MODULUS_FORMAT_REPO") and os.environ.get("MODULUS_CONFORM_PATH"), "set MODULUS_FORMAT_REPO and MODULUS_CONFORM_PATH for the Neovim integration test")
def test_neovim_container_formatting(self):
root = pathlib.Path(__file__).resolve().parent.parent
env = dict(os.environ, MODULUS_FORMAT_HELPER=str(root / "tools/nvim/modulus.lua"))
result = subprocess.run(["nvim", "--headless", "-u", "NONE", "-l", str(root / "tests/nvim_format.lua")], env=env, text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("MODULUS_FORMAT_OK", result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -11,7 +11,7 @@ def header_roots(config):
for driver in config["query_drivers"]: for driver in config["query_drivers"]:
for language in ("c", "c++"): for language in ("c", "c++"):
result = subprocess.run([driver, "-E", "-v", "-x", language, "-"], input="", result = subprocess.run([driver, "-E", "-v", "-x", language, "-"], input="",
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
listing = False listing = False
for line in result.stderr.splitlines(): for line in result.stderr.splitlines():
if line == "#include <...> search starts here:": if line == "#include <...> search starts here:":
+5
View File
@@ -44,6 +44,11 @@ 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.
ARG MODULUS_TOOLS=tools
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
WORKDIR /workspace WORKDIR /workspace
RUN mkdir -p /opt && touch /opt/modulus-build-image RUN mkdir -p /opt && touch /opt/modulus-build-image
+5
View File
@@ -0,0 +1,5 @@
clang-format==19.1.7
autopep8==2.3.2
pycodestyle==2.14.0
tree-sitter==0.25.2
tree-sitter-cpp==0.23.4
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python3
import modulus_tool
if __name__ == "__main__":
modulus_tool.run("format")
+67 -1
View File
@@ -4,6 +4,71 @@ local function within(path, root)
return path == root or path:sub(1, #root + 1) == root .. "/" return path == root or path:sub(1, #root + 1) == root .. "/"
end end
local function setup_formatting(state)
if state.formatting then
return
end
local ok, conform = pcall(require, "conform")
if not ok then
vim.notify("Modulus formatting requires conform.nvim", vim.log.levels.WARN)
return
end
state.formatting = true
local function project(buf)
local ft = vim.bo[buf].filetype
if ft ~= "c" and ft ~= "cpp" and ft ~= "python" then
return nil
end
return state.project(vim.api.nvim_buf_get_name(buf))
end
local fallback = conform.formatters_by_ft["_"]
for _, ft in ipairs({ "c", "cpp", "python", "*" }) do
local original = conform.formatters_by_ft[ft]
if ft ~= "*" and (not original or (type(original) == "table" and vim.tbl_isempty(original))) then
original = fallback
end
conform.formatters_by_ft[ft] = function(buf)
if project(buf) then
if ft == "*" then
return {}
end
return { "modulus", lsp_format = "never", timeout_ms = 10000 }
end
if type(original) == "function" then
return original(buf) or {}
end
return original or {}
end
end
conform.formatters.modulus = {
command = "python3",
env = { MODULUS_DIRECT = "0" },
args = function(_, context)
local root = assert(project(context.buf))
local args = vim.uv.fs_stat(root .. "/modulus/cli.py")
and { root .. "/tools/format.py" }
or { root .. "/modulus.py", "format" }
vim.list_extend(args, { "--stdin-filename", context.filename })
return args
end,
cwd = function(_, context) return project(context.buf) end,
stdin = true,
}
-- LazyVim already owns save/manual formatting and its global/buffer toggles.
local has_save, save_hooks = pcall(vim.api.nvim_get_autocmds, { event = "BufWritePre", group = "Conform" })
if not rawget(_G, "LazyVim") and (not has_save or #save_hooks == 0) then
vim.api.nvim_create_autocmd("BufWritePre", {
group = vim.api.nvim_create_augroup("ModulusFormat", { clear = true }),
callback = function(event)
if project(event.buf) and vim.g.autoformat ~= false and vim.b[event.buf].autoformat ~= false
and not vim.g.disable_autoformat and not vim.b[event.buf].disable_autoformat then
conform.format({ bufnr = event.buf, lsp_format = "never", timeout_ms = 10000 })
end
end,
})
end
end
function M.setup(root) function M.setup(root)
if vim.fn.has("nvim-0.11") ~= 1 then if vim.fn.has("nvim-0.11") ~= 1 then
vim.notify("Modulus container clangd requires Neovim 0.11+", vim.log.levels.ERROR) vim.notify("Modulus container clangd requires Neovim 0.11+", vim.log.levels.ERROR)
@@ -13,7 +78,7 @@ function M.setup(root)
-- Finish LazyVim's server setup before changing only the command and root routing. -- Finish LazyVim's server setup before changing only the command and root routing.
local has_lazy, lazy = pcall(require, "lazy") local has_lazy, lazy = pcall(require, "lazy")
if has_lazy then if has_lazy then
lazy.load({ plugins = { "nvim-lspconfig" } }) lazy.load({ plugins = { "nvim-lspconfig", "conform.nvim" } })
end end
local state = rawget(_G, "modulus_clangd") local state = rawget(_G, "modulus_clangd")
if not state then if not state then
@@ -85,6 +150,7 @@ function M.setup(root)
command = { "python3", root .. "/modulus.py", "clangd" } command = { "python3", root .. "/modulus.py", "clangd" }
end end
state.roots[root] = command state.roots[root] = command
setup_formatting(state)
for _, client in ipairs(vim.lsp.get_clients({ name = "clangd" })) do for _, client in ipairs(vim.lsp.get_clients({ name = "clangd" })) do
if not client.config._modulus_container and client.config.root_dir and within(client.config.root_dir, root) then if not client.config._modulus_container and client.config.root_dir and within(client.config.root_dir, root) then
client:stop() client:stop()