Cleanup
publish / publish (push) Successful in 10m36s

This commit is contained in:
khodowany
2026-09-24 19:40:03 -05:00
parent 097cb61d2d
commit 0c9ba1bff4
15 changed files with 628 additions and 53 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ jobs:
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'tools/conan/profiles/*') }} key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'tools/conan/profiles/*') }}
restore-keys: conan-${{ runner.os }}- restore-keys: conan-${{ runner.os }}-
- name: Configure native release - name: Configure native release
run: ./tools/configure.py --target x86_64_gcc12_release run: ./tools/configure.py --target x86_64_gcc12_release --update
- 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 - name: Check code style
@@ -65,4 +65,4 @@ jobs:
CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
MODULUS_CONAN_UPLOAD: "1" MODULUS_CONAN_UPLOAD: "1"
MODULUS_ALLOW_PACKAGE_REPLACE: "1" MODULUS_ALLOW_PACKAGE_REPLACE: "1"
run: ./tools/package.py --all --replace run: ./tools/package.py --all --replace --update
+4 -2
View File
@@ -25,7 +25,9 @@ MODULUS_DIRECT=1 ./tools/configure.py --target x86_64_gcc12_release
Outside that environment, downstream repos use `python3 modulus.py <command>` to fetch the public `modulus/latest` package from the khodo Conan registry and export the tool wrappers into `build/build_tools/`. Outside that environment, downstream repos use `python3 modulus.py <command>` to fetch the public `modulus/latest` package from the khodo Conan registry and export the tool wrappers into `build/build_tools/`.
`build` automatically configures a target when CMake metadata is missing. Run `configure` explicitly when you want to force a reconfigure or refresh dependencies. `build` automatically configures a target when CMake metadata is missing. `configure --update` refreshes dependencies and, in consumer repos, the exported Python tooling. Failed downloads/exports leave the previous tooling in place. `--no-remote` uses only the project-local cache, including initial bootstrap; it never downloads packages. Each consumer's tracked `modulus.py` is copied unchanged from `modulus/bootstrap.py`. Update that canonical source and synchronize consumer copies together.
Projects can select a tracked build recipe using `tooling.docker.dockerfile` and list additional build-context inputs in `tooling.docker.inputs`. Those files, the Dockerfile, and the shared formatting requirements determine the image fingerprint used by builds and clangd. Paths are project-relative. `tooling.docker.socket_commands` optionally permits host Docker socket access for `build` and/or `package`; the default is no socket access. Editor, configure, and test commands never receive the socket.
Refresh clangd compile databases with: Refresh clangd compile databases with:
@@ -129,7 +131,7 @@ Generic application artifacts default to:
https://git.thyme-labs.com/api/packages/khodo/generic https://git.thyme-labs.com/api/packages/khodo/generic
``` ```
Configure/build/test do not require package credentials for public packages. Configure/build/test do not require package credentials for public packages. Read operations log out only the configured public remote in the project-local Conan home and strip credential environment variables, including native Conan username/password variables. Authentication failures are noninteractive errors, not password prompts. Package contents and global Conan configuration are untouched. Credentials are validated only for publishing, redacted in command logs, and the remote is logged out in a `finally` cleanup after every upload attempt so CI caches do not retain the session.
## License ## License
+4 -1
View File
@@ -63,8 +63,11 @@ class modulus_recipe(conan.ConanFile):
"glfw/*:with_wayland": False, "glfw/*:with_wayland": False,
"glfw/*:with_x11": False, "glfw/*:with_x11": False,
} }
exports = "modulus/*.py", "modulus.json" exports = "modulus/*.py", "modulus.json", "!build", "!.git", "!test_package/build"
exports_sources = ( exports_sources = (
"!build",
"!.git",
"!test_package/build",
"CMakeLists.txt", "CMakeLists.txt",
"cmake/*.cmake", "cmake/*.cmake",
"cmake/*.in", "cmake/*.in",
+1
View File
@@ -39,6 +39,7 @@ auto required_string_field(boost::json::object const &object, std::string_view n
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 optional_float64_field(boost::json::object const &object, std::string_view name, std::float64_t fallback, std::float64_t min_value, std::float64_t max_value, modulus::json_config::options config = {}) -> std::expected<std::float64_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>
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
# Canonical consumer bootstrap. Copy this file unchanged to a project's modulus.py.
import json
import os
import pathlib
import shlex
import subprocess
import sys
PACKAGE_REF = "modulus/latest"
CONAN_VERSION = "2.26.2"
FETCH_IMAGE = "modulus-fetch:dev"
COMMANDS = (
"configure",
"build",
"test",
"package",
"lsp",
"clangd",
"format",
"run",
)
COMMON_DIR = pathlib.Path("build") / "build_common"
TOOLS_DIR = pathlib.Path("build") / "build_tools"
RUNTIME_DIR = COMMON_DIR / "modulus"
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
DEFAULT_REMOTE_NAME = "khodo"
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image"
def repo_root():
root = pathlib.Path(__file__).resolve().parent
if not (root / "modulus.json").is_file():
raise SystemExit("modulus.py must be run from a Modulus-managed repository root")
return root
def read_json(path):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise SystemExit(f"failed to read {path}: {error}") from error
if not isinstance(data, dict):
raise SystemExit(f"{path} must contain a JSON object")
return data
def remote_config(root):
config = read_json(root / "modulus.json")
remote = config.get(
"tooling",
{},
).get("remote", {})
if not isinstance(remote, dict):
remote = {}
return (
str(remote.get("name") or DEFAULT_REMOTE_NAME),
str(remote.get("url") or DEFAULT_REMOTE_URL),
)
def runtime_ready(root):
return (
(root / TOOLS_DIR / "configure.py").is_file()
and (root / TOOLS_DIR / "lsp.py").is_file()
and (root / TOOLS_DIR / "clangd.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "export.py").is_file()
and (root / TOOLS_DIR / "format.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
)
def env_flag(name):
return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"}
def docker_marker(root):
config = read_json(root / "modulus.json")
marker = config.get(
"tooling",
{},
).get("docker", {}).get("marker", DEFAULT_DOCKER_MARKER)
return pathlib.Path(str(marker))
def direct_mode(root):
return env_flag("MODULUS_DIRECT") or docker_marker(root).exists()
def run(command, input_text=None, capture=False, display_command=None, env=None, check=True):
visible = display_command if display_command is not None else command
print("+", " ".join(shlex.quote(str(part)) for part in visible))
completed = subprocess.run(
[str(part) for part in command],
input=input_text,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
text=True,
check=False,
env=env,
)
if check and completed.returncode != 0:
if capture and completed.stdout:
print(completed.stdout, end="")
if capture and completed.stderr:
print(completed.stderr, end="", file=sys.stderr)
raise SystemExit(completed.returncode)
return completed
def fetch_dockerfile():
return f"""FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
ca-certificates \\
python3 \\
python3-venv \\
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/modulus-python
ENV PATH=/opt/modulus-python/bin:$PATH
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
WORKDIR /workspace
"""
def ensure_fetch_image(offline=False):
inspected = subprocess.run(
[
"docker",
"image",
"inspect",
FETCH_IMAGE,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if inspected.returncode == 0 and (offline or not docker_platform_args()):
return
if offline:
raise SystemExit('offline bootstrap requires the existing Modulus fetch image or MODULUS_DIRECT=1 with Conan installed')
run(
[
"docker",
"build",
*docker_platform_args(),
"--tag",
FETCH_IMAGE,
"-",
],
input_text=fetch_dockerfile(),
)
def in_container():
return pathlib.Path("/.dockerenv").exists()
def docker_platform_args():
platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip()
return ["--platform", platform] if platform else []
def docker_workspace_args(root):
if in_container():
container = os.environ.get("HOSTNAME", "").strip()
if not container:
raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container")
return [
"--volumes-from",
container,
]
return [
"--volume",
f"{root}:{root}",
]
def fetch_env(root):
env = os.environ.copy()
for name in list(env):
if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")):
del env[name]
env["CONAN_HOME"] = str(root / CONAN_HOME_DIR)
env["CONAN_NON_INTERACTIVE"] = "1"
return env
def run_fetch(root, command, capture=False, display_command=None, check=True, offline=False):
if direct_mode(root):
return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
ensure_fetch_image(offline=offline)
docker_command = [
"docker",
"run",
"--rm",
"--interactive",
*(["--pull=never", "--network=none"] if offline else []),
*docker_platform_args(),
"--user",
f"{os.getuid()}:{os.getgid()}",
"--env",
"HOME=/tmp",
"--env",
"CONAN_HOME",
"--env",
"CONAN_NON_INTERACTIVE",
*docker_workspace_args(root),
"--workdir",
str(root),
FETCH_IMAGE,
*command,
]
return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
def configure_remote(root, remote_name, remote_url):
run_fetch(
root,
[
"conan",
"remote",
"add",
"-f",
remote_name,
remote_url,
],
)
run_fetch(root, ["conan", "remote", "logout", remote_name])
def package_refs(data):
refs = []
for location_data in data.values():
if not isinstance(location_data, dict):
continue
for ref, ref_data in location_data.items():
if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict):
continue
revisions = ref_data.get(
"revisions",
{},
)
if not isinstance(revisions, dict):
continue
for recipe_revision, revision_data in revisions.items():
if not isinstance(revision_data, dict):
continue
packages = revision_data.get(
"packages",
{},
)
if not isinstance(packages, dict):
continue
for package_id, package_data in packages.items():
package_revisions = package_data.get(
"revisions",
{},
)if isinstance(package_data, dict) else {}
if isinstance(package_revisions, dict) and package_revisions:
for package_revision in package_revisions:
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}")
else:
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}")
return sorted(refs)
def find_modulus(root, remote_name=None):
completed = run_fetch(
root,
[
"conan",
"list",
PACKAGE_REF + "#latest:*#latest",
*(["-r", remote_name] if remote_name else ["-c"]),
"-f",
"json",
],
capture=True,
offline=remote_name is None,
)
refs = package_refs(json.loads(completed.stdout))
if not refs:
raise SystemExit(f"no {PACKAGE_REF} package available in {remote_name or 'the local Conan cache'}")
package_ref = refs[-1]
if remote_name:
run_fetch(root, ["conan", "download", package_ref, "-r", remote_name])
completed = run_fetch(
root,
[
"conan",
"cache",
"path",
package_ref,
],
capture=True,
offline=remote_name is None,
)
package_root = pathlib.Path(completed.stdout.strip())
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir():
raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
return package_root
def materialize_runtime(root, no_remote=False):
remote_name, remote_url = remote_config(root)
if not no_remote:
configure_remote(root, remote_name, remote_url)
package_root = find_modulus(root, None if no_remote else remote_name)
sys.path.insert(0, str(package_root))
import modulus.export
modulus.export.sync_project(root, package_root)
def ensure_runtime(root, update=False, no_remote=False):
if update or not runtime_ready(root):
materialize_runtime(root, no_remote=no_remote)
def delegate(command, argv):
root = repo_root()
if command in {"clangd", "format"}:
if not runtime_ready(root):
print(f"Modulus {command} tooling is missing; run ./modulus.py configure first", file=sys.stderr)
return 1
else:
ensure_runtime(root, update=command == "configure" and "--update" in argv, no_remote="--no-remote" in argv)
sys.path.insert(0, str(root / RUNTIME_DIR))
import modulus.cli
previous_argv = sys.argv
previous_cwd = pathlib.Path.cwd()
sys.argv = [
str(root / "modulus.py"),
*argv,
]
os.chdir(root)
try:
return modulus.cli.main(command)
finally:
os.chdir(previous_cwd)
sys.argv = previous_argv
def main(argv):
if not argv or argv[0] in ("-h", "--help"):
print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...")
return 0 if argv else 2
command = argv[0]
if command not in COMMANDS:
raise SystemExit(f"unknown Modulus command: {command}")
return delegate(command, argv[1:])
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+2 -3
View File
@@ -22,9 +22,8 @@ def settings(context):
def recipe_digest(context): def recipe_digest(context):
digest = hashlib.sha256(context.dockerfile.read_bytes()) digest = hashlib.sha256(context.dockerfile.read_bytes())
requirements = context.dockerfile.parent / "format-requirements.txt" for path in getattr(context, "docker_inputs", [context.dockerfile.parent / "format-requirements.txt"]):
if requirements.is_file(): digest.update(path.read_bytes())
digest.update(requirements.read_bytes())
return digest.hexdigest() return digest.hexdigest()
def current_image(context, architecture=None): def current_image(context, architecture=None):
+42 -30
View File
@@ -77,7 +77,8 @@ class build_context:
self.default_target = self.targets[self.default_target_name] self.default_target = self.targets[self.default_target_name]
docker = self.tooling["docker"] docker = self.tooling["docker"]
self.docker_image = str(docker["image"]) self.docker_image = str(docker["image"])
self.dockerfile = self.tools_dir / "docker" / "Dockerfile" self.dockerfile = repo_root / docker["dockerfile"] if docker.get("dockerfile") else self.tools_dir / "docker" / "Dockerfile"
self.docker_inputs = [self.tools_dir / "docker" / "format-requirements.txt", *(repo_root / path for path in docker.get("inputs", []))]
self.docker_workdir = str(repo_root) self.docker_workdir = str(repo_root)
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"])
@@ -148,13 +149,6 @@ def conan_token():
def conan_credentials(): def conan_credentials():
return conan_user(), conan_token() return conan_user(), conan_token()
def has_conan_credentials():
user, token = conan_credentials()
if bool(user) != bool(token):
print("CONAN_USER and CONAN_TOKEN must either both be set or both be unset", file=sys.stderr)
return None
return bool(user)
def require_conan_credentials(operation): def require_conan_credentials(operation):
user, token = conan_credentials() user, token = conan_credentials()
if bool(user) != bool(token): if bool(user) != bool(token):
@@ -165,11 +159,20 @@ def require_conan_credentials(operation):
return None return None
return user, token return user, token
def subprocess_environment(context, command):
env = os.environ.copy()
env["CONAN_HOME"] = str(context.conan_cache_dir)
if str(command[0]) == "conan":
for name in list(env):
if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")):
del env[name]
env["CONAN_NON_INTERACTIVE"] = "1"
return env
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 = subprocess_environment(context, command)
env["CONAN_HOME"] = str(context.conan_cache_dir)
for name in env_unset or (): for name in env_unset or ():
env.pop(name, None) env.pop(name, None)
if env_overrides: if env_overrides:
@@ -179,8 +182,7 @@ def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_
def run_capture(context, command, cwd=None, env_overrides=None, display_command=None): def run_capture(context, command, cwd=None, env_overrides=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 = subprocess_environment(context, command)
env["CONAN_HOME"] = str(context.conan_cache_dir)
if env_overrides: if env_overrides:
env.update(env_overrides) env.update(env_overrides)
completed = subprocess.run( completed = subprocess.run(
@@ -201,7 +203,8 @@ def run_capture(context, command, cwd=None, env_overrides=None, display_command=
def configure_conan_remote(context, authenticate=False): def configure_conan_remote(context, authenticate=False):
remote = conan_remote(context) remote = conan_remote(context)
if has_conan_credentials() is None: credentials = require_conan_credentials("upload Conan packages") if authenticate else None
if authenticate and credentials is None:
return 1 return 1
status = run( status = run(
context, context,
@@ -217,10 +220,7 @@ def configure_conan_remote(context, authenticate=False):
if status != 0: if status != 0:
return status return status
if not authenticate: if not authenticate:
return 0 return run(context, ["conan", "remote", "logout", remote])
credentials = require_conan_credentials("upload Conan packages")
if credentials is None:
return 1
user, token = credentials user, token = credentials
return run( return run(
context, context,
@@ -244,19 +244,26 @@ def configure_conan_remote(context, authenticate=False):
], ],
) )
def docker_passthrough_env(context): def docker_passthrough_env(context, publishing=False):
return ( names = (
"CI", "CI",
"CONAN_HOME", "CONAN_HOME",
"CONAN_TOKEN",
"CONAN_USER",
"MODULUS_DIRECT", "MODULUS_DIRECT",
"MODULUS_ALLOW_PACKAGE_REPLACE", "MODULUS_ALLOW_PACKAGE_REPLACE",
"PACKAGE_REGISTRY_TOKEN",
"PACKAGE_REGISTRY_USER",
context.upload_env, context.upload_env,
context.package_upload_env, context.package_upload_env,
) )
if publishing:
names += ("CONAN_TOKEN", "CONAN_USER", "PACKAGE_REGISTRY_TOKEN", "PACKAGE_REGISTRY_USER")
return names
def docker_socket_args(context, command):
if command not in context.tooling["docker"].get("socket_commands", []):
return []
socket = pathlib.Path("/var/run/docker.sock")
if not socket.is_socket():
return []
return ["--volume", f"{socket}:{socket}", "--group-add", str(socket.stat().st_gid)]
def docker_platform_arch(): def docker_platform_arch():
platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip()
@@ -303,6 +310,8 @@ def docker_run(context, command, docker_args=None, env_overrides=None):
if status != 0: if status != 0:
return status return status
resolved_env_overrides = dict(env_overrides or {}) resolved_env_overrides = dict(env_overrides or {})
operation = pathlib.Path(command[0]).stem
publishing = operation == "package" and "--no-remote" not in command and (should_upload(context) or should_upload_artifacts(context))
docker_command = [ docker_command = [
"docker", "docker",
"run", "run",
@@ -313,10 +322,11 @@ def docker_run(context, command, docker_args=None, env_overrides=None):
f"{os.getuid()}:{os.getgid()}", f"{os.getuid()}:{os.getgid()}",
"--env", "--env",
"HOME=/tmp", "HOME=/tmp",
*docker_socket_args(context, operation),
] ]
if docker_args: if docker_args:
docker_command.extend(docker_args) docker_command.extend(docker_args)
for name in docker_passthrough_env(context): for name in docker_passthrough_env(context, publishing):
if name in os.environ: if name in os.environ:
docker_command.extend(["--env", name]) docker_command.extend(["--env", name])
if resolved_env_overrides: if resolved_env_overrides:
@@ -435,6 +445,7 @@ def configure_one(context, target, update, no_remote=False):
status = run(context, command) status = run(context, command)
if status != 0: if status != 0:
return status return status
clear_cmake_cache(target)
return run( return run(
context, context,
[ [
@@ -484,7 +495,6 @@ def configure(context, argv):
update = args.update update = args.update
for target in context.selected_targets(args.target, args.all): for target in context.selected_targets(args.target, args.all):
target.build_dir.mkdir(parents=True, exist_ok=True) target.build_dir.mkdir(parents=True, exist_ok=True)
clear_cmake_cache(target)
status = configure_one(context, target, update, no_remote=args.no_remote) status = configure_one(context, target, update, no_remote=args.no_remote)
if status != 0: if status != 0:
return status return status
@@ -668,7 +678,6 @@ def lsp(context, argv):
return status return status
for target in context.selected_targets(args.target, all_targets=False): for target in context.selected_targets(args.target, all_targets=False):
target.build_dir.mkdir(parents=True, exist_ok=True) target.build_dir.mkdir(parents=True, exist_ok=True)
clear_cmake_cache(target)
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
@@ -768,10 +777,13 @@ def upload_packages(context, replace=False):
print("--replace requires MODULUS_ALLOW_PACKAGE_REPLACE=1", file=sys.stderr) print("--replace requires MODULUS_ALLOW_PACKAGE_REPLACE=1", file=sys.stderr)
return 1 return 1
remote = conan_remote(context) remote = conan_remote(context)
status = configure_conan_remote(context, authenticate=True) try:
if status != 0: status = configure_conan_remote(context, authenticate=True)
return status if status == 0:
status = upload_all_packages(context, remote, force=replace) status = upload_all_packages(context, remote, force=replace)
finally:
logout_status = run(context, ["conan", "remote", "logout", remote])
status = status or logout_status
if status != 0 and not replace: if status != 0 and not replace:
print(f"upload failed for {package_name(context)}/latest. Use --replace with MODULUS_ALLOW_PACKAGE_REPLACE=1 to intentionally replace an existing version.", file=sys.stderr) print(f"upload failed for {package_name(context)}/latest. Use --replace with MODULUS_ALLOW_PACKAGE_REPLACE=1 to intentionally replace an existing version.", file=sys.stderr)
return status return status
+12
View File
@@ -123,6 +123,9 @@ lsp_compile_database_keys = {
docker_keys = { docker_keys = {
"image", "image",
"marker", "marker",
"dockerfile",
"inputs",
"socket_commands",
} }
remote_keys = { remote_keys = {
"name", "name",
@@ -240,6 +243,8 @@ def normalize(data):
) )
tooling["docker"].setdefault("image", "modulus-build:dev") tooling["docker"].setdefault("image", "modulus-build:dev")
tooling["docker"].setdefault("marker", "/opt/modulus-build-image") tooling["docker"].setdefault("marker", "/opt/modulus-build-image")
tooling["docker"].setdefault("inputs", [])
tooling["docker"].setdefault("socket_commands", [])
tooling.setdefault("upload_env", f"{environment_prefix(name)}_CONAN_UPLOAD") tooling.setdefault("upload_env", f"{environment_prefix(name)}_CONAN_UPLOAD")
tooling.setdefault( tooling.setdefault(
"remote", "remote",
@@ -428,6 +433,13 @@ def validate_tooling(tooling):
validate_keys(docker, docker_keys, "tooling.docker") validate_keys(docker, docker_keys, "tooling.docker")
validate_string(docker.get("image"), "tooling.docker.image", optional=True) validate_string(docker.get("image"), "tooling.docker.image", optional=True)
validate_absolute_path(docker.get("marker"), "tooling.docker.marker", optional=True) validate_absolute_path(docker.get("marker"), "tooling.docker.marker", optional=True)
validate_path(docker.get("dockerfile"), "tooling.docker.dockerfile", optional=True)
for key in ("inputs", "socket_commands"):
validate_string_list(docker.get(key, []), f"tooling.docker.{key}")
for path in docker.get("inputs", []):
validate_path(path, "tooling.docker.inputs")
if any(command not in {"build", "package"} for command in docker.get("socket_commands", [])):
raise config_error("tooling.docker.socket_commands may contain only build and package")
remote = tooling.get("remote") remote = tooling.get("remote")
if remote is not None: if remote is not None:
if not isinstance(remote, dict): if not isinstance(remote, dict):
+26 -3
View File
@@ -2,6 +2,7 @@ import json
import pathlib import pathlib
import shutil import shutil
import sys import sys
import tempfile
import modulus.config import modulus.config
@@ -45,9 +46,31 @@ def sync_project(repo_root, package_root):
repo_root = pathlib.Path(repo_root).resolve() repo_root = pathlib.Path(repo_root).resolve()
package_root = pathlib.Path(package_root).resolve() package_root = pathlib.Path(package_root).resolve()
config = modulus.config.load_repo_config(repo_root) config = modulus.config.load_repo_config(repo_root)
sync_runtime(repo_root, package_root) stage_parent = repo_root / common_dir
sync_tools(repo_root, package_root) stage_parent.mkdir(parents=True, exist_ok=True)
sync_editor_files(repo_root, config) with tempfile.TemporaryDirectory(prefix=".modulus-export-", dir=stage_parent) as directory:
staged = pathlib.Path(directory)
sync_runtime(staged, package_root)
sync_tools(staged, package_root)
installed = []
backups = []
try:
for index, relative in enumerate((runtime_dir, tools_dir)):
target = repo_root / relative
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
backup = staged / f"backup-{index}"
target.rename(backup)
backups.append((backup, target))
(staged / relative).rename(target)
installed.append(target)
sync_editor_files(repo_root, config)
except BaseException:
for target in installed:
shutil.rmtree(target)
for backup, target in backups:
backup.rename(target)
raise
def sync_runtime(repo_root, package_root): def sync_runtime(repo_root, package_root):
dst = repo_root / runtime_dir dst = repo_root / runtime_dir
+12
View File
@@ -176,6 +176,18 @@
"marker": { "marker": {
"type": "string", "type": "string",
"pattern": "^/" "pattern": "^/"
},
"dockerfile": {
"$ref": "#/$defs/relative_path"
},
"inputs": {
"type": "array",
"items": {"$ref": "#/$defs/relative_path"}
},
"socket_commands": {
"type": "array",
"uniqueItems": true,
"items": {"enum": ["build", "package"]}
} }
} }
}, },
+18
View File
@@ -1,6 +1,7 @@
#include <modulus/json_config.hpp> #include <modulus/json_config.hpp>
#include <cassert> #include <cassert>
#include <cmath>
#include <cstdint> #include <cstdint>
#include <fstream> #include <fstream>
#include <initializer_list> #include <initializer_list>
@@ -183,6 +184,23 @@ 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::optional_float64_field(boost::json::object const &object, std::string_view name, std::float64_t fallback, std::float64_t min_value, std::float64_t max_value, modulus::json_config::options config) -> std::expected<std::float64_t, modulus::error> {
auto found = object.find(name);
if (found == object.end()) {
return fallback;
}
auto const &value = found->value();
if (!value.is_number()) {
return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be a number"));
}
auto number = value.is_double() ? static_cast<std::float64_t>(value.as_double()) : value.is_uint64() ? static_cast<std::float64_t>(value.as_uint64())
: static_cast<std::float64_t>(value.as_int64());
if (!std::isfinite(number) || number < min_value || number > max_value) {
return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " is out of range"));
}
return 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) {
+20
View File
@@ -5,6 +5,7 @@
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <limits>
#include <set> #include <set>
#include <sstream> #include <sstream>
#include <string> #include <string>
@@ -270,3 +271,22 @@ BOOST_AUTO_TEST_CASE(modulus_load_config_handles_files) {
auto invalid = modulus::load_config(invalid_path.string()); auto invalid = modulus::load_config(invalid_path.string());
BOOST_REQUIRE(!invalid.has_value()); BOOST_REQUIRE(!invalid.has_value());
} }
BOOST_AUTO_TEST_CASE(modulus_json_float_fields_validate_numeric_types_and_bounds) {
auto object = boost::json::object{};
auto options = modulus::json_config::options{.category = "domain", .invalid_argument_code = 42};
auto read = [&] { return modulus::json_config::optional_float64_field(object, "gain", 0.5, 0.0, 1.0, options); };
BOOST_REQUIRE(read());
BOOST_TEST(static_cast<double>(*read()) == 0.5);
for (auto const &value : {boost::json::value{0}, boost::json::value{std::uint64_t{1}}, boost::json::value{0.25}}) {
object["gain"] = value;
BOOST_REQUIRE(read());
}
for (auto const &value : {boost::json::value{-1}, boost::json::value{2U}, boost::json::value{true}, boost::json::value{"0.5"}, boost::json::value{nullptr}, boost::json::value{std::numeric_limits<double>::infinity()}, boost::json::value{std::numeric_limits<double>::quiet_NaN()}}) {
object["gain"] = value;
auto result = read();
BOOST_REQUIRE(!result);
BOOST_TEST(std::string{result.error().category()} == "domain");
BOOST_TEST(result.error().code() == 42);
}
}
+125
View File
@@ -0,0 +1,125 @@
import ast
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import modulus.bootstrap
import modulus.cli
import modulus.config
import modulus.export
class BootstrapTests(unittest.TestCase):
def test_recipe_exports_prune_generated_trees(self):
workspace = Path(modulus.bootstrap.__file__).parents[2]
for name in ('modulus', 'ligo', 'noto', 'luma', 'sonus', 'product-vamp'):
recipe = workspace / name / 'conanfile.py'
if not recipe.exists():
continue
fields = {target.id: ast.literal_eval(node.value) for node in ast.walk(ast.parse(recipe.read_text())) if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) and target.id in {'exports', 'exports_sources'}}
for field in ('exports', 'exports_sources'):
self.assertTrue({'!build', '!.git', '!test_package/build'}.issubset(fields[field]), f'{name}: {field}')
def test_consumers_use_canonical_bootstrap(self):
canonical = Path(modulus.bootstrap.__file__)
for name in ('ligo', 'noto', 'luma', 'sonus', 'product-vamp'):
consumer = canonical.parents[2] / name / 'modulus.py'
if consumer.exists():
self.assertEqual(canonical.read_bytes(), consumer.read_bytes(), name)
def test_read_environments_strip_all_credential_forms(self):
credentials = dict.fromkeys(('CONAN_USER', 'CONAN_TOKEN', 'CONAN_LOGIN_USERNAME', 'CONAN_PASSWORD', 'CONAN_LOGIN_USERNAME_KHODO', 'CONAN_PASSWORD_KHODO'), 'secret')
root = Path('/tmp/project')
context = modulus.cli.build_context(root, modulus.config.normalize({'package': {'name': 'sample'}}))
with patch.dict(os.environ, credentials):
for env in (modulus.bootstrap.fetch_env(root), modulus.cli.subprocess_environment(context, ['conan', 'install'])):
self.assertFalse(set(credentials) & env.keys())
self.assertEqual(env['CONAN_NON_INTERACTIVE'], '1')
self.assertEqual(env['CONAN_HOME'], str(root / 'build/build_common/conan_cache'))
def test_update_refreshes_runtime_and_offline_stays_offline(self):
root = Path('/tmp/project')
with patch.object(modulus.bootstrap, 'runtime_ready', return_value=True), patch.object(modulus.bootstrap, 'materialize_runtime') as refresh:
modulus.bootstrap.ensure_runtime(root)
refresh.assert_not_called()
modulus.bootstrap.ensure_runtime(root, update=True, no_remote=True)
refresh.assert_called_once_with(root, no_remote=True)
with patch.object(modulus.bootstrap, 'run_fetch', return_value=subprocess.CompletedProcess([], 0, '{}')) as fetch:
with self.assertRaisesRegex(SystemExit, 'local Conan cache'):
modulus.bootstrap.find_modulus(root)
self.assertNotIn('-r', fetch.call_args.args[1])
def test_fetch_failure_preserves_runtime(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime = root / modulus.bootstrap.RUNTIME_DIR / 'modulus/cli.py'
runtime.parent.mkdir(parents=True)
runtime.write_text('old runtime')
with patch.object(modulus.bootstrap, 'remote_config', return_value=('khodo', 'https://example.test')), patch.object(modulus.bootstrap, 'configure_remote'), patch.object(modulus.bootstrap, 'find_modulus', side_effect=SystemExit(1)):
with self.assertRaises(SystemExit):
modulus.bootstrap.materialize_runtime(root)
self.assertEqual(runtime.read_text(), 'old runtime')
def test_offline_bootstrap_never_builds_a_fetch_image(self):
with patch.object(modulus.bootstrap.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1)), patch.object(modulus.bootstrap, 'run') as build:
with self.assertRaisesRegex(SystemExit, 'offline bootstrap requires'):
modulus.bootstrap.ensure_fetch_image(offline=True)
build.assert_not_called()
def test_export_rolls_back_when_editor_refresh_fails(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'modulus.json').write_text(json.dumps({'package': {'name': 'sample'}}))
runtime = root / modulus.export.runtime_dir / 'modulus/cli.py'
runtime.parent.mkdir(parents=True)
runtime.write_text('old runtime')
package = root / 'package'
(package / 'modulus').mkdir(parents=True)
(package / 'modulus/cli.py').write_text('new runtime')
(package / 'tools').mkdir()
with patch.object(modulus.export, 'sync_editor_files', side_effect=OSError('editor is read-only')):
with self.assertRaisesRegex(OSError, 'read-only'):
modulus.export.sync_project(root, package)
self.assertEqual(runtime.read_text(), 'old runtime')
self.assertFalse((root / modulus.export.tools_dir).exists())
def test_public_remote_error_is_noninteractive_and_does_not_delete_packages(self):
completed = subprocess.CompletedProcess([], 1, '', '401 Unauthorized')
with patch.object(modulus.bootstrap, 'direct_mode', return_value=True), patch.object(modulus.bootstrap.subprocess, 'run', return_value=completed) as run:
with self.assertRaises(SystemExit):
modulus.bootstrap.find_modulus(Path('/tmp/project'), 'khodo')
self.assertEqual(run.call_args.args[0][:2], ['conan', 'list'])
self.assertEqual(run.call_args.kwargs['env']['CONAN_NON_INTERACTIVE'], '1')
def test_export_stages_all_payloads_before_replacing_runtime(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'modulus.json').write_text(json.dumps({'package': {'name': 'sample'}}))
runtime = root / modulus.export.runtime_dir / 'modulus/cli.py'
runtime.parent.mkdir(parents=True)
runtime.write_text('old runtime')
package = root / 'package'
(package / 'modulus').mkdir(parents=True)
(package / 'modulus/cli.py').write_text('new runtime')
with self.assertRaisesRegex(RuntimeError, 'missing tools'):
modulus.export.sync_project(root, package)
self.assertEqual(runtime.read_text(), 'old runtime')
def test_upload_exception_still_logs_out(self):
context = modulus.cli.build_context(Path('/tmp/project'), modulus.config.normalize({'package': {'name': 'sample'}}))
with patch.object(modulus.cli, 'configure_conan_remote', return_value=0), patch.object(modulus.cli, 'upload_all_packages', side_effect=RuntimeError('upload failed')), patch.object(modulus.cli, 'run', return_value=0) as run:
with self.assertRaisesRegex(RuntimeError, 'upload failed'):
modulus.cli.upload_packages(context)
run.assert_called_once_with(context, ['conan', 'remote', 'logout', 'khodo'])
def test_socket_is_scoped_and_docker_inputs_change_fingerprint(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = modulus.config.normalize({'package': {'name': 'sample'}, 'tooling': {'docker': {'dockerfile': 'package/build/Dockerfile', 'inputs': ['requirements.txt'], 'socket_commands': ['build', 'package']}}})
context = modulus.cli.build_context(root, config)
context.dockerfile.parent.mkdir(parents=True)
context.dockerfile.write_text('FROM scratch')
context.docker_inputs[0].parent.mkdir(parents=True)
for path in context.docker_inputs:
path.write_text('old')
before = modulus.clangd.recipe_digest(context)
context.docker_inputs[-1].write_text('new')
self.assertNotEqual(before, modulus.clangd.recipe_digest(context))
with patch.object(Path, 'is_socket', return_value=True), patch.object(Path, 'stat') as stat:
stat.return_value.st_gid = 123
for command in ('configure', 'test', 'lsp', 'clangd', 'format'):
self.assertEqual(modulus.cli.docker_socket_args(context, command), [])
self.assertIn('/var/run/docker.sock:/var/run/docker.sock', modulus.cli.docker_socket_args(context, 'package'))
+1
View File
@@ -23,6 +23,7 @@ class clangd_test(unittest.TestCase):
self.context.docker_marker = self.root / "not-in-container" self.context.docker_marker = self.root / "not-in-container"
self.context.dockerfile.parent.mkdir(parents=True) self.context.dockerfile.parent.mkdir(parents=True)
self.context.dockerfile.write_text("FROM sample\n") self.context.dockerfile.write_text("FROM sample\n")
self.context.docker_inputs[0].write_text("autopep8==2.3.2\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(modulus.clangd.settings(self.context)["executable"], "/usr/bin/clangd-19") self.assertEqual(modulus.clangd.settings(self.context)["executable"], "/usr/bin/clangd-19")
+17 -12
View File
@@ -110,13 +110,15 @@ class upload_packages_test(unittest.TestCase):
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): @unittest.mock.patch.object(modulus.cli, "run", return_value=0)
def test_failed_upload_does_not_force_by_default(self, run, configure, upload):
context = self.context() context = self.context()
with unittest.mock.patch.dict(os.environ, {}, clear=True): with unittest.mock.patch.dict(os.environ, {}, clear=True):
status = modulus.cli.upload_packages(context) status = modulus.cli.upload_packages(context)
self.assertEqual(status, 37) self.assertEqual(status, 37)
configure.assert_called_once_with(context, authenticate=True) configure.assert_called_once_with(context, authenticate=True)
upload.assert_called_once_with(context, "origin", force=False) upload.assert_called_once_with(context, "origin", force=False)
run.assert_called_once_with(context, ["conan", "remote", "logout", "origin"])
@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):
@@ -127,7 +129,8 @@ class upload_packages_test(unittest.TestCase):
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): @unittest.mock.patch.object(modulus.cli, "run", return_value=0)
def test_replace_forces_upload_when_guarded(self, run, configure, upload):
context = self.context() context = self.context()
with unittest.mock.patch.dict( with unittest.mock.patch.dict(
os.environ, os.environ,
@@ -186,8 +189,9 @@ class upload_packages_test(unittest.TestCase):
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)
self.assertIn("PACKAGE_REGISTRY_USER", passthrough) for name in ("CONAN_USER", "CONAN_TOKEN", "PACKAGE_REGISTRY_USER", "PACKAGE_REGISTRY_TOKEN"):
self.assertIn("PACKAGE_REGISTRY_TOKEN", passthrough) self.assertNotIn(name, passthrough)
self.assertIn(name, modulus.cli.docker_passthrough_env(self.context(), publishing=True))
self.assertIn("SAMPLE_PACKAGE_UPLOAD", passthrough) self.assertIn("SAMPLE_PACKAGE_UPLOAD", passthrough)
self.assertNotIn("CONAN_PACKAGE_VERSION", passthrough) self.assertNotIn("CONAN_PACKAGE_VERSION", passthrough)
self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough) self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough)
@@ -368,7 +372,8 @@ class upload_packages_test(unittest.TestCase):
with unittest.mock.patch.dict(os.environ, {}, clear=True): with unittest.mock.patch.dict(os.environ, {}, clear=True):
status = modulus.cli.configure_conan_remote(self.context()) status = modulus.cli.configure_conan_remote(self.context())
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual(len(run.call_args_list), 1) self.assertEqual(len(run.call_args_list), 2)
self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
self.assertEqual( self.assertEqual(
run.call_args_list[0].args[1], run.call_args_list[0].args[1],
[ [
@@ -381,11 +386,11 @@ class upload_packages_test(unittest.TestCase):
], ],
) )
@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_ignores_partial_conan_credentials(self, run):
with unittest.mock.patch.dict(os.environ, {"CONAN_USER": "dev"}, clear=True): with unittest.mock.patch.dict(os.environ, {"CONAN_USER": "dev"}, clear=True):
status = modulus.cli.configure_conan_remote(self.context()) status = modulus.cli.configure_conan_remote(self.context())
self.assertEqual(status, 1) self.assertEqual(status, 0)
run.assert_not_called() self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.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()
@@ -399,7 +404,7 @@ class upload_packages_test(unittest.TestCase):
): ):
status = modulus.cli.configure_conan_remote(context) status = modulus.cli.configure_conan_remote(context)
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual(len(run.call_args_list), 1) self.assertEqual(len(run.call_args_list), 2)
self.assertEqual( self.assertEqual(
run.call_args_list[0].args[1], run.call_args_list[0].args[1],
[ [
@@ -744,7 +749,7 @@ class upload_packages_test(unittest.TestCase):
@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)
def test_package_rejects_partial_conan_credentials_before_create(self, docker, run, create_package): def test_package_checks_credentials_only_at_upload(self, docker, run, create_package):
context = self.context() context = self.context()
with unittest.mock.patch.dict( with unittest.mock.patch.dict(
os.environ, os.environ,
@@ -762,8 +767,8 @@ class upload_packages_test(unittest.TestCase):
], ],
) )
self.assertEqual(status, 1) self.assertEqual(status, 1)
create_package.assert_not_called() create_package.assert_called_once()
run.assert_not_called() self.assertEqual(run.call_args_list[-1].args[1], ["conan", "remote", "logout", "origin"])
docker.assert_called_once() docker.assert_called_once()
@unittest.mock.patch.object(modulus.cli, "clear_conan_deps") @unittest.mock.patch.object(modulus.cli, "clear_conan_deps")
@unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "run", return_value=0)