cola-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. cola_cli/__init__.py +3 -0
  2. cola_cli/__main__.py +3 -0
  3. cola_cli/builders.py +347 -0
  4. cola_cli/cli.py +177 -0
  5. cola_cli/common.py +98 -0
  6. cola_cli/environment.py +245 -0
  7. cola_cli/runtime.py +25 -0
  8. cola_cli/scripts/install.jl +10 -0
  9. cola_cli/scripts/locking.gradle +9 -0
  10. cola_cli/setup.py +163 -0
  11. cola_cli/sources.py +256 -0
  12. cola_cli/templates/common/.editorconfig.j2 +15 -0
  13. cola_cli/templates/common/.gitignore.j2 +20 -0
  14. cola_cli/templates/common/cola.toml.j2 +8 -0
  15. cola_cli/templates/cpp/.clang-format.j2 +6 -0
  16. cola_cli/templates/cpp/.clang-tidy.j2 +4 -0
  17. cola_cli/templates/cpp/CMakeLists.txt.j2 +51 -0
  18. cola_cli/templates/cpp/include/{{ name }}.hh.j2 +23 -0
  19. cola_cli/templates/cpp/src/{{ name }}.cc.j2 +3 -0
  20. cola_cli/templates/fortran/.fprettify.rc.j2 +2 -0
  21. cola_cli/templates/fortran/CMakeLists.txt.j2 +63 -0
  22. cola_cli/templates/fortran/src/{{ name }}.f90.j2 +73 -0
  23. cola_cli/templates/java/CMakeLists.txt.j2 +33 -0
  24. cola_cli/templates/java/build.gradle.kts.j2 +35 -0
  25. cola_cli/templates/java/config/checkstyle/checkstyle.xml.j2 +19 -0
  26. cola_cli/templates/java/settings.gradle.kts.j2 +1 -0
  27. cola_cli/templates/java/src/main/java/org/cola/generated/{{ package }}/{{ name }}Converter.java.j2 +9 -0
  28. cola_cli/templates/java/src/main/java/org/cola/generated/{{ package }}/{{ name }}Generator.java.j2 +9 -0
  29. cola_cli/templates/java/src/main/java/org/cola/generated/{{ package }}/{{ name }}Writer.java.j2 +9 -0
  30. cola_cli/templates/julia/.JuliaFormatter.toml.j2 +2 -0
  31. cola_cli/templates/julia/Project.toml.j2 +10 -0
  32. cola_cli/templates/julia/src/{{ name }}.jl.j2 +17 -0
  33. cola_cli/templates/python/pyproject.toml.j2 +23 -0
  34. cola_cli/templates/python/src/{{ package }}/__init__.py.j2 +1 -0
  35. cola_cli/templates/python/src/{{ package }}/filters.py.j2 +18 -0
  36. cola_cli/templates/quality/.cmake-format.yaml.j2 +13 -0
  37. cola_cli/templates/quality/CMakePresets.json.j2 +22 -0
  38. cola_cli-0.1.0.dist-info/METADATA +272 -0
  39. cola_cli-0.1.0.dist-info/RECORD +43 -0
  40. cola_cli-0.1.0.dist-info/WHEEL +5 -0
  41. cola_cli-0.1.0.dist-info/entry_points.txt +2 -0
  42. cola_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. cola_cli-0.1.0.dist-info/top_level.txt +1 -0
cola_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """The CLI never imports COLA's native Python bindings."""
2
+
3
+ __version__ = "0.1.0"
cola_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
cola_cli/builders.py ADDED
@@ -0,0 +1,347 @@
1
+ """Adapters for CMake, uv, Julia Pkg and Gradle. No custom dependency resolver."""
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import shlex
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from .common import ColaError, execute
13
+ from .sources import ordered, snapshot, toml
14
+
15
+ SCRIPTS = Path(__file__).with_name("scripts")
16
+
17
+
18
+ def managed_environment(root, generation, spec):
19
+ env = os.environ.copy()
20
+ for key in (
21
+ "COLA_DIR",
22
+ "COLA_ROOT",
23
+ "COLA_INSTALL_PREFIX",
24
+ "COLA_FETCH_MODE",
25
+ "CMAKE_PREFIX_PATH",
26
+ "DESTDIR",
27
+ "PYTHONPATH",
28
+ "PYTHONHOME",
29
+ "VIRTUAL_ENV",
30
+ "CONDA_PREFIX",
31
+ "JULIA_PROJECT",
32
+ "JULIA_LOAD_PATH",
33
+ "JULIA_DEPOT_PATH",
34
+ "CLASSPATH",
35
+ "LD_LIBRARY_PATH",
36
+ "DYLD_LIBRARY_PATH",
37
+ "CMAKE_ARGS",
38
+ ):
39
+ env.pop(key, None)
40
+ for key in list(env):
41
+ if key.startswith("UV_") and key not in {"UV_INDEX_URL", "UV_EXTRA_INDEX_URL", "UV_DEFAULT_INDEX", "UV_INDEX"}:
42
+ env.pop(key)
43
+ prefix = generation / "prefix"
44
+ env.update(
45
+ COLA_DIR=str(prefix),
46
+ COLA_INSTALL_PREFIX=str(prefix),
47
+ PYTHONNOUSERSITE="1",
48
+ CMAKE_PREFIX_PATH=os.pathsep.join([str(prefix), *spec.get("prefixes", [])]),
49
+ JULIA_PROJECT=str(generation / "julia"),
50
+ JULIA_LOAD_PATH="@:@stdlib",
51
+ JULIA_DEPOT_PATH=str(generation / "julia-depot"),
52
+ JULIA_PKG_PRECOMPILE_AUTO="0",
53
+ UV_CACHE_DIR=str(root / "cache" / "uv"),
54
+ UV_NO_CONFIG="1",
55
+ UV_PYTHON_DOWNLOADS="never",
56
+ COLA_BUILD_LOG=str(generation / "build.log"),
57
+ )
58
+ # PEP 517 backends may prefer clang++ from PATH while plain CMake chooses c++.
59
+ # Select one compiler before either tool is invoked (also avoids accidental WASI toolchains).
60
+ for variable, default in (("CC", "cc"), ("CXX", "c++"), ("FC", "gfortran")):
61
+ executable = shutil.which(env.get(variable, default))
62
+ if executable:
63
+ env[variable] = executable
64
+ return env
65
+
66
+
67
+ class Builder:
68
+ def __init__(self, root, generation, spec, modules, previous, jobs):
69
+ self.root, self.generation, self.spec = root, generation, spec
70
+ self.modules, self.previous, self.jobs = modules, previous, jobs
71
+ self.prefix = generation / "prefix"
72
+ self.env = managed_environment(root, generation, spec)
73
+ self.locks = {"native": {}, "gradle": {}}
74
+
75
+ def command(self, args, **kwargs):
76
+ return execute(args, env=self.env, **kwargs)
77
+
78
+ def uv(self, *args, **kwargs):
79
+ from uv import find_uv_bin
80
+
81
+ return self.command([find_uv_bin(), *args], **kwargs)
82
+
83
+ def source(self, name):
84
+ return self.generation / "src" / name
85
+
86
+ def toolchain(self):
87
+ from uv import find_uv_bin
88
+
89
+ from . import __version__
90
+
91
+ commands = {
92
+ "python": [self.spec["python"], "--version"],
93
+ "cmake": ["cmake", "--version"],
94
+ "cxx": [self.env.get("CXX", "c++"), "--version"],
95
+ "uv": [find_uv_bin(), "--version"],
96
+ }
97
+ if "fortran" in self.modules:
98
+ commands["fortran"] = [self.env.get("FC", "gfortran"), "--version"]
99
+ if "julia" in self.modules:
100
+ commands["julia"] = ["julia", "--version"]
101
+ if "java" in self.modules:
102
+ commands["java"] = ["javac", "--version"]
103
+ for name in self.modules:
104
+ source = self.source(name)
105
+ if any(source.glob("build.gradle*")):
106
+ gradle = source / "gradlew"
107
+ if gradle.is_file():
108
+ # Lock the wrapper's bytes through the source snapshot, not its transient path.
109
+ commands[f"gradle:{name}"] = [str(gradle), "--version"]
110
+ else:
111
+ commands[f"gradle:{name}"] = ["gradle", "--version"]
112
+ versions = {}
113
+ for key, command in commands.items():
114
+ versions[key] = self.command(command, capture=True).strip()
115
+ versions["system"] = platform.system()
116
+ versions["machine"] = platform.machine()
117
+ versions["cola-cli"] = __version__
118
+ versions["flags"] = {
119
+ key: os.environ.get(key, "")
120
+ for key in (
121
+ "CC",
122
+ "CXX",
123
+ "FC",
124
+ "CFLAGS",
125
+ "CXXFLAGS",
126
+ "FFLAGS",
127
+ "LDFLAGS",
128
+ "SDKROOT",
129
+ "MACOSX_DEPLOYMENT_TARGET",
130
+ "JAVA_HOME",
131
+ "CMAKE_TOOLCHAIN_FILE",
132
+ )
133
+ }
134
+ return versions
135
+
136
+ def native(self, name):
137
+ click.echo(f"Building {name} with CMake")
138
+ source = self.source(name)
139
+ if name == "core":
140
+ # Older COLA releases interpolate unquoted prefixes into their installed config.
141
+ # Apply this compatibility fix to the generation copy, never the user's checkout.
142
+ config = source / "data" / "COLAConfig.cmake.in"
143
+ if config.is_file():
144
+ text = config.read_text()
145
+ lines = []
146
+ for line in text.splitlines():
147
+ if line.startswith("include(@CMAKE_INSTALL_PREFIX@/"):
148
+ line = 'include("' + line[len("include(") : -1] + '")'
149
+ elif line.startswith("set_and_check(") and " @CMAKE_INSTALL_PREFIX@" in line:
150
+ key, path = line[:-1].split(" ", 1)
151
+ line = key + ' "' + path + '")'
152
+ lines.append(line)
153
+ config.write_text("\n".join(lines) + "\n")
154
+ build = self.generation / "build" / name
155
+ args = [
156
+ "cmake",
157
+ "-S",
158
+ source,
159
+ "-B",
160
+ build,
161
+ "-G",
162
+ "Unix Makefiles",
163
+ "-DCMAKE_BUILD_TYPE=Release",
164
+ f"-DCMAKE_INSTALL_PREFIX={self.prefix}",
165
+ "-DCMAKE_INSTALL_LIBDIR=lib",
166
+ "-DBUILD_TESTING=OFF",
167
+ "-Dtinyxml2_BUILD_TESTING=OFF",
168
+ "-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF",
169
+ "-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF",
170
+ *self.modules[name]["cmake_args"],
171
+ ]
172
+ if self.previous:
173
+ args.append("-DFETCHCONTENT_FULLY_DISCONNECTED=ON")
174
+ for dependency, identity in self.previous["locks"]["native"].get(name, {}).items():
175
+ cached = self.root / "sources" / identity["sha256"]
176
+ from .sources import tree_hash
177
+
178
+ if not cached.is_dir() or tree_hash(cached) != identity["sha256"]:
179
+ raise ColaError(f"Missing or modified native dependency snapshot: {cached}")
180
+ target = self.generation / "native-deps" / name / dependency
181
+ shutil.copytree(cached, target)
182
+ args.append(f"-DFETCHCONTENT_SOURCE_DIR_{dependency.upper()}={target}")
183
+ if any(source.glob("build.gradle*")):
184
+ args.append(f"-DGRADLE_EXECUTABLE={self.gradle(name)}")
185
+ self.command(args)
186
+ self.command(["cmake", "--build", build, "--parallel", self.jobs])
187
+ self.command(["cmake", "--install", build])
188
+ dependencies = {}
189
+ if self.previous:
190
+ dependencies = self.previous["locks"]["native"].get(name, {})
191
+ elif (build / "_deps").is_dir():
192
+ for dependency in sorted((build / "_deps").glob("*-src")):
193
+ dependencies[dependency.name.removesuffix("-src")] = snapshot(
194
+ self.root,
195
+ {"kind": "path", "path": str(dependency), "subdirectory": "."},
196
+ )
197
+ self.locks["native"][name] = dependencies
198
+ if any(source.glob("build.gradle*")):
199
+ files = {}
200
+ for path in source.rglob("*gradle.lockfile"):
201
+ files[path.relative_to(source).as_posix()] = path.read_text()
202
+ if not files:
203
+ raise ColaError(f"Gradle produced no dependency lock for {name}; cannot seal this environment.")
204
+ self.locks["gradle"][name] = files
205
+
206
+ def gradle(self, name):
207
+ source = self.source(name)
208
+ if self.previous:
209
+ for relative, text in self.previous["locks"]["gradle"].get(name, {}).items():
210
+ path = source / relative
211
+ path.parent.mkdir(parents=True, exist_ok=True)
212
+ path.write_text(text)
213
+ executable = source / "gradlew"
214
+ if not executable.is_file():
215
+ executable = shutil.which("gradle")
216
+ if not executable:
217
+ raise ColaError(f"{name} requires Gradle or a checked-in Gradle Wrapper.")
218
+ script = self.generation / "bin" / f"{name}-gradle"
219
+ script.parent.mkdir(parents=True, exist_ok=True)
220
+ args = [str(executable), "--no-daemon", "--init-script", str(SCRIPTS / "locking.gradle")]
221
+ if not self.previous:
222
+ args.append("--write-locks")
223
+ script.write_text("#!/bin/sh\nexec " + shlex.join(args) + ' "$@"\n')
224
+ script.chmod(0o755)
225
+ return script
226
+
227
+ def julia(self):
228
+ packages = [name for name in ordered(self.modules) if self.modules[name]["backend"] == "julia"]
229
+ if not packages:
230
+ return
231
+ click.echo("Installing Julia packages with Pkg")
232
+ project = self.generation / "julia"
233
+ project.mkdir()
234
+ if self.previous:
235
+ for filename, text in self.previous["locks"]["julia"].items():
236
+ (project / filename).write_text(text)
237
+ args = [
238
+ "julia",
239
+ "--startup-file=no",
240
+ f"--project={project}",
241
+ str(SCRIPTS / "install.jl"),
242
+ "locked" if self.previous else "resolve",
243
+ *[f"../src/{name}" for name in packages],
244
+ ]
245
+ self.command(args, cwd=project)
246
+ files = {filename: (project / filename).read_text() for filename in ("Project.toml", "Manifest.toml")}
247
+ if self.previous and files != self.previous["locks"]["julia"]:
248
+ raise ColaError("Julia Pkg changed the locked environment; run 'cola build' to accept a new resolution.")
249
+ self.locks["julia"] = files
250
+
251
+ def python(self):
252
+ click.echo("Installing Python packages with uv")
253
+ project = self.generation / "python"
254
+ project.mkdir()
255
+ packages, requirements = {}, set()
256
+ for name in ordered(self.modules):
257
+ if self.modules[name]["backend"] != "python":
258
+ continue
259
+ metadata = toml(self.source(name) / "pyproject.toml")
260
+ package_name = metadata.get("project", {}).get("name")
261
+ if not package_name:
262
+ raise ColaError(f"{name} must declare project.name in pyproject.toml.")
263
+ if package_name in packages:
264
+ raise ColaError(f"Two modules provide the Python distribution {package_name}.")
265
+ packages[package_name] = name
266
+ requirements.update(metadata.get("build-system", {}).get("requires", []))
267
+ dependencies = sorted(set(packages) | requirements)
268
+ quote = json.dumps
269
+ text = (
270
+ '[project]\nname = "cola-global-runtime"\nversion = "0.0.0"\nrequires-python = ">=3.11"\n'
271
+ + "dependencies = "
272
+ + json.dumps(dependencies)
273
+ + "\n\n[tool.uv]\npackage = false\n\n[tool.uv.sources]\n"
274
+ )
275
+ for package, name in sorted(packages.items()):
276
+ text += f"{quote(package)} = {{ path = {quote('../src/' + name)}, editable = false }}\n"
277
+ (project / "pyproject.toml").write_text(text)
278
+ if self.previous:
279
+ if text != self.previous["locks"]["python"]["pyproject.toml"]:
280
+ raise ColaError("Python package metadata differs from the active lock.")
281
+ (project / "uv.lock").write_text(self.previous["locks"]["python"]["uv.lock"])
282
+ else:
283
+ self.uv("lock", "--project", project, "--python", self.spec["python"])
284
+ # Install the locked build backends first, then build first-party packages in that environment.
285
+ # This keeps PEP 517 build requirements under the same uv lock.
286
+ common = ["sync", "--locked", "--project", project, "--python", self.spec["python"]]
287
+ skip = [arg for package in packages for arg in ("--no-install-package", package)]
288
+ self.uv(*common, *skip)
289
+ # COLA wheels embed a generation-specific native prefix; never reuse such a wheel from another build.
290
+ self.uv(*common, "--no-build-isolation", "--no-cache")
291
+ self.locks["python"] = {
292
+ filename: (project / filename).read_text() for filename in ("pyproject.toml", "uv.lock")
293
+ }
294
+
295
+ def build(self):
296
+ order = ordered(self.modules)
297
+ # The MVP has a deliberate build phase order. Reject unsupported cross-language
298
+ # build cycles instead of attempting to resolve them or silently ignoring depends.
299
+ phases = {}
300
+ for name in order:
301
+ module = self.modules[name]
302
+ phases[name] = (
303
+ 0
304
+ if module["role"] == "core"
305
+ else 1
306
+ if module["role"] in {"java", "fortran"}
307
+ else 2
308
+ if module["backend"] == "julia"
309
+ else 3
310
+ if module["backend"] == "cmake"
311
+ else 4
312
+ )
313
+ for name, module in self.modules.items():
314
+ for dependency in module["depends"]:
315
+ if phases[dependency] > phases[name]:
316
+ raise ColaError(
317
+ f"{name} requires {dependency} in an unsupported build phase. "
318
+ "MVP order: core, Java/Fortran bridges, Julia, native filters, Python packages."
319
+ )
320
+ self.prefix.mkdir()
321
+ # Julia's Pkg.build installs its CMake bridge after the core exists.
322
+ self.native("core")
323
+ for name in order:
324
+ if name != "core" and self.modules[name]["role"] in {"fortran", "java"}:
325
+ self.native(name)
326
+ self.julia()
327
+ for name in order:
328
+ if self.modules[name]["backend"] == "cmake" and not self.modules[name]["role"]:
329
+ self.native(name)
330
+ self.python()
331
+ return self.locks
332
+
333
+ def check(self):
334
+ click.echo("Checking the completed runtime and native modules")
335
+ libraries = list(
336
+ dict.fromkeys(library for name in ordered(self.modules) for library in self.modules[name]["libraries"])
337
+ )
338
+ self.command(
339
+ [
340
+ self.generation / "python" / ".venv" / "bin" / "python",
341
+ "-I",
342
+ Path(__file__).with_name("runtime.py"),
343
+ "--check",
344
+ "--libraries",
345
+ json.dumps(libraries),
346
+ ]
347
+ )
cola_cli/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """Global-only command interface. Importing it requires no native COLA installation."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ import click
10
+
11
+ from .common import cli
12
+ from .environment import GlobalEnvironment, fingerprint
13
+ from .setup import setup # noqa: F401 -- registers scaffold commands on cli
14
+ from .sources import BUILTINS, source_spec
15
+
16
+
17
+ @cli.command()
18
+ @click.option("--core", default=BUILTINS["core"], show_default=True, help="COLA core Git URL or directory.")
19
+ @click.option("--core-ref", help="Core Git tag, branch or commit.")
20
+ @click.option("--bindings", default=BUILTINS["python"], show_default=True, help="COLA-PY Git URL or directory.")
21
+ @click.option("--bindings-ref", help="COLA-PY Git tag, branch or commit.")
22
+ @click.option(
23
+ "--python",
24
+ default=getattr(sys, "_base_executable", sys.executable),
25
+ help="Existing Python 3.11+ executable for the runtime (defaults to the CLI's base interpreter).",
26
+ )
27
+ @click.option(
28
+ "--prefix",
29
+ multiple=True,
30
+ type=click.Path(exists=True, file_okay=False),
31
+ help="Explicit external native dependency prefix (repeatable).",
32
+ )
33
+ def init(core, core_ref, bindings, bindings_ref, python, prefix):
34
+ """Initialize the one global environment; do not build or install yet."""
35
+ environment = GlobalEnvironment()
36
+ with environment.writing():
37
+ environment.initialize(source_spec(core, core_ref), source_spec(bindings, bindings_ref), python, prefix)
38
+ click.echo(f"Initialized {environment.root}. Add modules, then run 'cola build'.")
39
+
40
+
41
+ @cli.command()
42
+ @click.argument("source")
43
+ @click.option("--ref", help="Git tag, branch or commit; resolved to a commit when added.")
44
+ @click.option("--subdirectory", default=".", help="Package directory inside the repository.")
45
+ @click.option("--name", help="Global module identifier.")
46
+ @click.option("--backend", type=click.Choice(["cmake", "python", "julia"]))
47
+ @click.option("--library", multiple=True, help="Native COLA module to load at runtime (repeatable).")
48
+ @click.option("--requires", multiple=True, help="Another explicitly selected module (repeatable).")
49
+ @click.option("--cmake-arg", multiple=True, help="Additional -D definition (use --cmake-arg=-DNAME=value).")
50
+ def add(source, ref, subdirectory, name, backend, library, requires, cmake_arg):
51
+ """Add a Git or local module to the desired global specification."""
52
+ environment = GlobalEnvironment()
53
+ with environment.writing():
54
+ identifier = environment.add(
55
+ source_spec(source, ref, subdirectory),
56
+ name=name,
57
+ backend=backend,
58
+ libraries=list(library),
59
+ depends=list(requires),
60
+ cmake_args=list(cmake_arg),
61
+ )
62
+ click.echo(f"Added {identifier}. Run 'cola build' to activate the change.")
63
+
64
+
65
+ @cli.command()
66
+ @click.argument("name")
67
+ def remove(name):
68
+ """Remove a module from the desired specification; keep the running build intact."""
69
+ environment = GlobalEnvironment()
70
+ with environment.writing():
71
+ environment.remove(name)
72
+ click.echo(f"Removed {name}. Run 'cola build' to activate the change.")
73
+
74
+
75
+ def build_options(function):
76
+ return click.option("--jobs", type=click.IntRange(min=1), default=2, show_default=True)(function)
77
+
78
+
79
+ @cli.command()
80
+ @click.option("--locked", is_flag=True, help="Restore the active source and language locks without re-resolving.")
81
+ @build_options
82
+ def build(locked, jobs):
83
+ """Build and atomically activate the complete global environment."""
84
+ environment = GlobalEnvironment()
85
+ with environment.writing():
86
+ generation = environment.build(locked=locked, jobs=jobs)
87
+ click.echo(f"Activated global environment: {generation}")
88
+
89
+
90
+ @cli.command()
91
+ @click.option("--locked", is_flag=True, default=True, help="Use the active lock (always enabled for sync).")
92
+ @build_options
93
+ def sync(locked, jobs):
94
+ """Restore the active lock into a fresh build; never update dependencies."""
95
+ environment = GlobalEnvironment()
96
+ with environment.writing():
97
+ generation = environment.build(locked=True, jobs=jobs)
98
+ click.echo(f"Restored global environment: {generation}")
99
+
100
+
101
+ @cli.command()
102
+ @click.argument("names", nargs=-1)
103
+ @build_options
104
+ def update(names, jobs):
105
+ """Re-resolve selected Git refs (all by default), rebuild and activate."""
106
+ environment = GlobalEnvironment()
107
+ with environment.writing():
108
+ names = names or tuple(environment.spec()["modules"])
109
+ generation = environment.build(update=names, jobs=jobs)
110
+ click.echo(f"Updated global environment: {generation}")
111
+
112
+
113
+ @cli.command("list")
114
+ @click.option("--json", "as_json", is_flag=True, help="Print desired and active state as JSON.")
115
+ def list_modules(as_json):
116
+ """Show desired modules and the currently active build."""
117
+ environment = GlobalEnvironment()
118
+ spec = environment.spec()
119
+ current = environment.root / "current"
120
+ active = environment.current()[1] if current.is_symlink() else None
121
+ if as_json:
122
+ click.echo(json.dumps({"home": str(environment.root), "desired": spec, "active": active}, indent=2))
123
+ return
124
+ click.echo(f"Global environment: {environment.root}")
125
+ click.echo(f"{'Module':24} {'Backend':8} {'Active rev.':12} Desired source")
126
+ for name, module in spec["modules"].items():
127
+ source = module["source"]
128
+ origin = source.get("path", source.get("url"))
129
+ locked = active["modules"].get(name) if active else None
130
+ revision = (locked["snapshot"]["commit"] or locked["snapshot"]["sha256"])[:12] if locked else "pending"
131
+ click.echo(f"{name:24} {module['backend']:8} {revision:12} {origin}")
132
+ click.echo("Active build: " + (str(current.resolve()) if active else "none; run cola build"))
133
+ if active and active["spec_digest"] != fingerprint(spec):
134
+ click.echo("Pending specification changes: run cola build to activate them.")
135
+
136
+
137
+ @cli.command()
138
+ def doctor():
139
+ """Report available tools and global runtime state without importing colapy."""
140
+ environment = GlobalEnvironment()
141
+ click.echo(f"Global environment: {environment.root}")
142
+ for executable in ("git", "cmake", "c++", "gfortran", "julia", "javac", "gradle"):
143
+ click.echo(f"{executable:12} {shutil.which(executable) or 'not found'}")
144
+ try:
145
+ click.echo(f"uv {version('uv')}")
146
+ except PackageNotFoundError:
147
+ click.echo("uv not installed; reinstall cola-cli with its dependencies")
148
+ click.echo(f"Specification: {'present' if environment.manifest.exists() else 'run cola init'}")
149
+ click.echo(
150
+ f"Active build: {environment.root / 'current' if (environment.root / 'current').is_symlink() else 'none'}"
151
+ )
152
+
153
+
154
+ @cli.command()
155
+ @click.option("--config", required=True, type=click.Path(exists=True, dir_okay=False))
156
+ @click.option("--library", "-l", multiple=True, help="Override the modules loaded from the global manifest.")
157
+ @click.option("--steps", "-n", type=click.IntRange(min=1), default=1, show_default=True)
158
+ @click.pass_context
159
+ def run(ctx, config, library, steps):
160
+ """Run a calculation in a fresh process belonging to the global environment."""
161
+ ctx.exit(GlobalEnvironment().run(config, library, steps))
162
+
163
+
164
+ @cli.command("exec", context_settings={"ignore_unknown_options": True})
165
+ @click.argument("command", nargs=-1, required=True, type=click.UNPROCESSED)
166
+ @click.pass_context
167
+ def exec_command(ctx, command):
168
+ """Execute a command in the active global environment: cola exec python ..."""
169
+ _, _, env = GlobalEnvironment().runtime_environment()
170
+ ctx.exit(subprocess.call(command, env=env))
171
+
172
+
173
+ def main():
174
+ try:
175
+ cli()
176
+ except OSError as error:
177
+ raise SystemExit(f"Error: {error}") from error
cola_cli/common.py ADDED
@@ -0,0 +1,98 @@
1
+ """Small, shared CLI utilities; no native runtime imports."""
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import tempfile
8
+ from collections import deque
9
+ from contextlib import nullcontext
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+ from . import __version__
15
+
16
+
17
+ @click.group()
18
+ @click.version_option(__version__)
19
+ def cli():
20
+ """Manage the single global COLA environment (Linux/macOS)."""
21
+
22
+
23
+ class ColaError(click.ClickException):
24
+ """An actionable environment or build error."""
25
+
26
+
27
+ def execute(args, *, cwd=None, env=None, capture=False):
28
+ """Run tools directly, without a shell. Preserve their diagnostics in build.log."""
29
+ args = [str(arg) for arg in args]
30
+ log_path = env.get("COLA_BUILD_LOG") if env else None
31
+ context = Path(log_path).open("a", encoding="utf-8") if log_path else nullcontext(None)
32
+ with context as log:
33
+ if log:
34
+ log.write(f"\n$ {args!r}\n")
35
+ log.flush()
36
+ try:
37
+ if capture:
38
+ result = subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True, check=False)
39
+ output = result.stdout + result.stderr
40
+ if log:
41
+ log.write(output + "\n")
42
+ if result.returncode:
43
+ raise ColaError(f"{args[0]} exited with status {result.returncode}:\n{output[-12000:]}")
44
+ return result.stdout
45
+ process = subprocess.Popen(
46
+ args,
47
+ cwd=cwd,
48
+ env=env,
49
+ text=True,
50
+ stdout=subprocess.PIPE,
51
+ stderr=subprocess.STDOUT,
52
+ start_new_session=True,
53
+ )
54
+ except OSError as error:
55
+ raise ColaError(f"Cannot execute {args[0]}: {error}") from error
56
+ tail = deque(maxlen=100)
57
+ try:
58
+ for line in process.stdout:
59
+ tail.append(line)
60
+ click.echo(line, nl=False)
61
+ if log:
62
+ log.write(line)
63
+ log.flush()
64
+ code = process.wait()
65
+ except BaseException:
66
+ if process.poll() is None:
67
+ os.killpg(process.pid, signal.SIGTERM)
68
+ try:
69
+ process.wait(timeout=5)
70
+ except subprocess.TimeoutExpired:
71
+ os.killpg(process.pid, signal.SIGKILL)
72
+ process.wait()
73
+ raise
74
+ finally:
75
+ process.stdout.close()
76
+ if code:
77
+ raise ColaError(f"{args[0]} exited with status {code}:\n{''.join(tail)[-12000:]}")
78
+ return ""
79
+
80
+
81
+ def read_json(path):
82
+ try:
83
+ return json.loads(path.read_text(encoding="utf-8"))
84
+ except (OSError, ValueError) as error:
85
+ raise ColaError(f"Cannot read {path}: {error}") from error
86
+
87
+
88
+ def write_json(path, value):
89
+ """Replace one state file atomically, on the same filesystem."""
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=".state-")
92
+ try:
93
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
94
+ json.dump(value, stream, indent=2, sort_keys=True)
95
+ stream.write("\n")
96
+ os.replace(temporary, path)
97
+ finally:
98
+ Path(temporary).unlink(missing_ok=True)