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
@@ -0,0 +1,245 @@
1
+ """A single per-user environment, with atomic activation of completed builds."""
2
+
3
+ import contextlib
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ from .common import ColaError, read_json, write_json
15
+ from .sources import BUILTINS, describe, ordered, snapshot, source_spec, tree_hash
16
+
17
+
18
+ def fingerprint(value):
19
+ return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
20
+
21
+
22
+ def home():
23
+ if sys.platform not in {"linux", "darwin"}:
24
+ raise ColaError("cola-cli supports Linux and macOS only.")
25
+ # Intentionally never inspect the current directory for an environment.
26
+ path = Path(os.environ.get("COLA_HOME", "~/.local/share/cola")).expanduser()
27
+ if not path.is_absolute():
28
+ raise ColaError("COLA_HOME must be an absolute path to the global store.")
29
+ return path.resolve()
30
+
31
+
32
+ class GlobalEnvironment:
33
+ def __init__(self, root=None):
34
+ self.root = Path(root) if root else home()
35
+ self.manifest = self.root / "global.json"
36
+
37
+ @contextlib.contextmanager
38
+ def writing(self):
39
+ import fcntl
40
+
41
+ self.root.mkdir(parents=True, exist_ok=True)
42
+ with (self.root / ".write.lock").open("a") as stream:
43
+ try:
44
+ fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
45
+ except BlockingIOError as error:
46
+ raise ColaError(
47
+ "Another cola command is modifying the global environment. Try again when it finishes."
48
+ ) from error
49
+ try:
50
+ yield
51
+ finally:
52
+ fcntl.flock(stream, fcntl.LOCK_UN)
53
+
54
+ def spec(self):
55
+ if not self.manifest.exists():
56
+ raise ColaError("Global environment is not initialized. Run 'cola init' first.")
57
+ spec = read_json(self.manifest)
58
+ if spec.get("schema") != 1:
59
+ raise ColaError("Unsupported global environment schema.")
60
+ return spec
61
+
62
+ def initialize(self, core, bindings, python, prefixes=()):
63
+ if self.manifest.exists():
64
+ raise ColaError(f"The global environment already exists at {self.root}.")
65
+ modules = {}
66
+ for role, source in (("core", core), ("python", bindings)):
67
+ modules[role] = self.builtin(role, source)
68
+ interpreter = shutil.which(python)
69
+ if interpreter is None:
70
+ raise ColaError(f"Python executable not found: {python}")
71
+ write_json(
72
+ self.manifest,
73
+ {
74
+ "schema": 1,
75
+ "python": str(Path(interpreter).absolute()),
76
+ "prefixes": [str(Path(path).expanduser().resolve()) for path in prefixes],
77
+ "modules": modules,
78
+ },
79
+ )
80
+
81
+ @staticmethod
82
+ def builtin(role, source=None):
83
+ libraries = {"core": [], "python": ["COLA-Py"], "fortran": [], "java": ["COLA_JVM"], "julia": ["COLA_Julia"]}
84
+ return {
85
+ "name": role,
86
+ "role": role,
87
+ "backend": {"python": "python", "julia": "julia"}.get(role, "cmake"),
88
+ "depends": [] if role == "core" else ["core"],
89
+ "libraries": libraries[role],
90
+ "cmake_args": [],
91
+ "source": source or source_spec(BUILTINS[role]),
92
+ "options": {},
93
+ }
94
+
95
+ def add(self, source, **options):
96
+ spec = self.spec()
97
+ frozen = snapshot(self.root, source)
98
+ module = describe(self.root / "sources" / frozen["sha256"], **options)
99
+ module.update(source=source, snapshot=frozen, options=options)
100
+ name = module["name"]
101
+ spec["modules"][name] = module
102
+ for dependency in module["depends"]:
103
+ if dependency in BUILTINS and dependency not in spec["modules"]:
104
+ spec["modules"][dependency] = self.builtin(dependency)
105
+ ordered(spec["modules"])
106
+ write_json(self.manifest, spec)
107
+ return name
108
+
109
+ def remove(self, name):
110
+ spec = self.spec()
111
+ if name in {"core", "python"}:
112
+ raise ColaError("The core and Python runtime are required by cola run.")
113
+ if name not in spec["modules"]:
114
+ raise ColaError(f"Module {name!r} is not installed in the global specification.")
115
+ dependents = [key for key, module in spec["modules"].items() if name in module["depends"]]
116
+ if dependents:
117
+ raise ColaError(f"{name} is required by: {', '.join(dependents)}")
118
+ del spec["modules"][name]
119
+ write_json(self.manifest, spec)
120
+
121
+ def current(self):
122
+ current = self.root / "current"
123
+ if not current.is_symlink():
124
+ raise ColaError("No completed global build. Run 'cola build' first.")
125
+ generation = current.resolve()
126
+ if not generation.is_relative_to((self.root / "generations").resolve()):
127
+ raise ColaError("Global environment pointer refers outside its generation directory.")
128
+ return generation, read_json(generation / "cola.lock")
129
+
130
+ def prepare(self, spec, *, locked=False, update=()):
131
+ if locked:
132
+ _, previous = self.current()
133
+ if previous["spec_digest"] != fingerprint(spec):
134
+ raise ColaError("The global specification changed. Run 'cola build' to create a new lock.")
135
+ modules = previous["modules"]
136
+ else:
137
+ modules = {}
138
+ for name in ordered(spec["modules"]):
139
+ wanted = spec["modules"][name]
140
+ source = wanted["source"]
141
+ frozen = wanted.get("snapshot")
142
+ if source["kind"] == "path" or name in update or not frozen:
143
+ frozen = snapshot(self.root, source)
144
+ cached = self.root / "sources" / frozen["sha256"]
145
+ if not cached.exists() or tree_hash(cached) != frozen["sha256"]:
146
+ raise ColaError(f"Missing or modified source snapshot for {name}. Use 'cola update {name}'.")
147
+ module = describe(cached, **wanted.get("options", {}))
148
+ if module["name"] != name:
149
+ raise ColaError(f"Source for {name} identifies itself as {module['name']}.")
150
+ module.update(source=source, snapshot=frozen, options=wanted.get("options", {}))
151
+ modules[name] = module
152
+ ordered(modules)
153
+ for name, module in modules.items():
154
+ cached = self.root / "sources" / module["snapshot"]["sha256"]
155
+ if not cached.is_dir() or tree_hash(cached) != module["snapshot"]["sha256"]:
156
+ raise ColaError(f"Missing or modified locked snapshot for {name}: {cached}")
157
+ return modules
158
+
159
+ def build(self, *, locked=False, update=(), jobs=2):
160
+ from .builders import Builder
161
+
162
+ spec = self.spec()
163
+ unknown = set(update) - set(spec["modules"])
164
+ if unknown:
165
+ raise ColaError(f"Unknown modules: {', '.join(sorted(unknown))}")
166
+ modules = self.prepare(spec, locked=locked, update=update)
167
+ previous = self.current()[1] if locked else None
168
+ generation = self.root / "generations" / uuid.uuid4().hex
169
+ generation.mkdir(parents=True)
170
+ for name, module in modules.items():
171
+ shutil.copytree(self.root / "sources" / module["snapshot"]["sha256"], generation / "src" / name)
172
+ builder = Builder(self.root, generation, spec, modules, previous, jobs)
173
+ try:
174
+ toolchain = builder.toolchain()
175
+ if previous and toolchain != previous["toolchain"]:
176
+ raise ColaError(
177
+ "Toolchain differs from the active lock. Run 'cola build' to accept and rebuild with it."
178
+ )
179
+ locks = builder.build()
180
+ result = {
181
+ "schema": 1,
182
+ "spec_digest": fingerprint(spec),
183
+ "modules": modules,
184
+ "toolchain": toolchain,
185
+ "locks": locks,
186
+ "platform": {"system": platform.system(), "machine": platform.machine()},
187
+ }
188
+ builder.check()
189
+ # Resolve the desired specification before publishing the completed generation.
190
+ # A failure here can leave pending desired edits, but cannot replace the active runtime.
191
+ for name, module in modules.items():
192
+ spec["modules"][name]["snapshot"] = module["snapshot"]
193
+ result["spec_digest"] = fingerprint(spec)
194
+ result["spec"] = spec
195
+ write_json(generation / "cola.lock", result)
196
+ write_json(self.manifest, spec)
197
+ # All absolute prefixes remain valid: activate a pointer, never move an installed tree.
198
+ pointer = self.root / (".current-" + uuid.uuid4().hex)
199
+ pointer.symlink_to(generation.relative_to(self.root), target_is_directory=True)
200
+ os.replace(pointer, self.root / "current")
201
+ return generation
202
+ except BaseException as error:
203
+ # Failed generations remain inspectable but can never become current.
204
+ if isinstance(error, ColaError):
205
+ raise ColaError(f"{error}\nBuild was not activated. Log: {generation / 'build.log'}") from error
206
+ raise
207
+
208
+ def runtime_environment(self):
209
+ from .builders import managed_environment
210
+
211
+ generation, lock = self.current()
212
+ env = managed_environment(self.root, generation, lock["spec"])
213
+ # The active build owns its native stack even when the desired spec has pending edits.
214
+ env["PATH"] = os.pathsep.join(
215
+ [
216
+ str(generation / "python" / ".venv" / "bin"),
217
+ str(generation / "prefix" / "bin"),
218
+ env["PATH"],
219
+ ]
220
+ )
221
+ env.pop("COLA_BUILD_LOG", None)
222
+ return generation, lock, env
223
+
224
+ def run(self, config, libraries, steps):
225
+ generation, lock, env = self.runtime_environment()
226
+ selected = list(libraries) or list(
227
+ dict.fromkeys(
228
+ library for name in ordered(lock["modules"]) for library in lock["modules"][name]["libraries"]
229
+ )
230
+ )
231
+ runtime = Path(__file__).with_name("runtime.py")
232
+ return subprocess.call(
233
+ [
234
+ generation / "python" / ".venv" / "bin" / "python",
235
+ "-I",
236
+ runtime,
237
+ "--config",
238
+ Path(config).expanduser().resolve(),
239
+ "--steps",
240
+ str(steps),
241
+ "--libraries",
242
+ json.dumps(selected),
243
+ ],
244
+ env=env,
245
+ )
cola_cli/runtime.py ADDED
@@ -0,0 +1,25 @@
1
+ """Executed by the managed Python, never imported by the CLI interpreter."""
2
+
3
+ import argparse
4
+ import json
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser()
9
+ parser.add_argument("--config")
10
+ parser.add_argument("--steps", type=int, default=1)
11
+ parser.add_argument("--libraries", required=True)
12
+ parser.add_argument("--check", action="store_true")
13
+ args = parser.parse_args()
14
+ from colapy import RunManager
15
+
16
+ manager = RunManager()
17
+ for library in json.loads(args.libraries):
18
+ manager.load_module(library)
19
+ if not args.check:
20
+ manager.load_config(file=args.config)
21
+ manager.run(args.steps)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1,10 @@
1
+ using Pkg
2
+
3
+ # Relative paths remain valid when restoring a lock into a fresh build generation.
4
+ if ARGS[1] == "resolve"
5
+ Pkg.develop([PackageSpec(path=path) for path in ARGS[2:end]])
6
+ end
7
+ Pkg.instantiate()
8
+ Pkg.build("COLA")
9
+ Pkg.precompile()
10
+
@@ -0,0 +1,9 @@
1
+ import org.gradle.api.artifacts.dsl.LockMode
2
+
3
+ allprojects {
4
+ dependencyLocking {
5
+ lockAllConfigurations()
6
+ lockMode = LockMode.STRICT
7
+ }
8
+ }
9
+
cola_cli/setup.py ADDED
@@ -0,0 +1,163 @@
1
+ """Generate COLA project scaffolds using the shipped language templates."""
2
+
3
+ import re
4
+ import uuid
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from jinja2 import Environment, StrictUndefined
9
+
10
+ from .common import cli
11
+
12
+ TEMPLATES = Path(__file__).with_name("templates")
13
+ LANGUAGES = click.Choice(["cpp", "python", "fortran", "java", "julia"], case_sensitive=False)
14
+ JULIA_RESERVED_NAMES = set(
15
+ "baremodule begin break catch const continue do else elseif end export false finally for function "
16
+ "global if import let local macro module quote return struct true try using while "
17
+ "abstract mutable primitive type where in isa public outer as COLA Core Base".split()
18
+ )
19
+
20
+
21
+ def project_name(ctx, param, value):
22
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,31}", value):
23
+ raise click.BadParameter("use 1–32 ASCII letters, digits or underscores, starting with a letter")
24
+ return value
25
+
26
+
27
+ def project_version(ctx, param, value):
28
+ if not re.fullmatch(r"\d+\.\d+\.\d+", value):
29
+ raise click.BadParameter("use a numeric major.minor.patch version, e.g. 1.0.0")
30
+ return value
31
+
32
+
33
+ def render_project(name, language, version):
34
+ environment = Environment(undefined=StrictUndefined, keep_trailing_newline=True, autoescape=False)
35
+ context = {
36
+ "name": name,
37
+ "package": name.lower() + "_filters",
38
+ "version": version,
39
+ "language": language,
40
+ "uuid": str(uuid.uuid4()),
41
+ }
42
+ groups = ["common"]
43
+ if language in ("cpp", "fortran", "java"):
44
+ groups.append("quality")
45
+ groups.append(language)
46
+ files = {}
47
+ for group in groups:
48
+ directory = TEMPLATES / group
49
+ for template in sorted(directory.rglob("*.j2")):
50
+ path = environment.from_string(template.relative_to(directory).as_posix()[:-3]).render(context)
51
+ files[path] = environment.from_string(template.read_text(encoding="utf-8")).render(context)
52
+ return files
53
+
54
+
55
+ def write_project(name, prefix, version, language, force, section=None):
56
+ if language == "julia" and name in JULIA_RESERVED_NAMES:
57
+ raise click.ClickException(f"Reserved Julia module name: {name}")
58
+ files = render_project(name, language, version)
59
+ if section == "cmake":
60
+ if language in ("python", "julia"):
61
+ build_file = "pyproject.toml" if language == "python" else "Project.toml"
62
+ raise click.ClickException(
63
+ f"{language} projects use {build_file}; use 'setup project --language {language}'."
64
+ )
65
+ files = {p: text for p, text in files.items() if "cmake" in p.lower() or p == "CMakeLists.txt"}
66
+ elif section == "sources":
67
+ files = {p: text for p, text in files.items() if p.startswith(("src/", "include/"))}
68
+ elif section == "git":
69
+ files = {".gitignore": files[".gitignore"]}
70
+ root = Path(prefix).expanduser() / name
71
+ if root.is_symlink() or (root.exists() and not root.is_dir()):
72
+ raise click.ClickException(f"Project path is not a regular directory: {root}")
73
+ if section is None and root.exists() and any(root.iterdir()) and not force:
74
+ raise click.ClickException(f"Directory is not empty: {root}. Use --force to overwrite generated files.")
75
+ # Check all destinations before writing, including symlinks inside an existing project.
76
+ for relative in files:
77
+ target = root / relative
78
+ for component in (target, *target.parents):
79
+ if component == root.parent:
80
+ break
81
+ if component.is_symlink():
82
+ raise click.ClickException(f"Refusing to write through a symlink: {component}")
83
+ if component != target and component.exists() and not component.is_dir():
84
+ raise click.ClickException(f"Not a directory: {component}")
85
+ if target.exists() and (target.is_dir() or not force):
86
+ raise click.ClickException(f"File already exists: {target}. Use --force to overwrite generated files.")
87
+ try:
88
+ if language == "julia" and "Project.toml" in files and (root / "Project.toml").is_file():
89
+ header = (root / "Project.toml").read_text(encoding="utf-8").split("[", 1)[0]
90
+ match = re.search(r'^uuid\s*=\s*"([^"]+)"', header, re.MULTILINE)
91
+ if match:
92
+ try:
93
+ project_uuid = str(uuid.UUID(match[1]))
94
+ except ValueError as error:
95
+ raise click.ClickException("Invalid UUID in existing Project.toml") from error
96
+ files["Project.toml"] = re.sub(
97
+ r'^uuid = ".*"', f'uuid = "{project_uuid}"', files["Project.toml"], flags=re.MULTILINE
98
+ )
99
+ for relative, content in files.items():
100
+ target = root / relative
101
+ target.parent.mkdir(parents=True, exist_ok=True)
102
+ target.write_text(content, encoding="utf-8")
103
+ except OSError as error:
104
+ raise click.ClickException(str(error)) from error
105
+ click.echo(f"Created {language} project files in {root}")
106
+
107
+
108
+ def options(function):
109
+ for decorator in (
110
+ click.option("--force", is_flag=True, help="Overwrite generated files; keep unrelated files."),
111
+ click.option("--version", default="1.0.0", callback=project_version, show_default=True),
112
+ click.option(
113
+ "--prefix",
114
+ default="./",
115
+ type=click.Path(file_okay=False),
116
+ show_default=True,
117
+ help="Parent directory for the new project.",
118
+ ),
119
+ click.option("--language", "-l", type=LANGUAGES, default="cpp", show_default=True),
120
+ click.option("--name", required=True, callback=project_name, help="Project/module name."),
121
+ ):
122
+ function = decorator(function)
123
+ return function
124
+
125
+
126
+ @cli.group()
127
+ def setup():
128
+ """Create COLA projects and individual project components."""
129
+
130
+
131
+ @setup.command()
132
+ @options
133
+ def project(**kwargs):
134
+ """Create a C++, Python, Fortran, Java or Julia project scaffold."""
135
+ write_project(**kwargs)
136
+
137
+
138
+ @setup.command()
139
+ @options
140
+ def cmake(**kwargs):
141
+ """Generate just the native build files (legacy command)."""
142
+ write_project(**kwargs, section="cmake")
143
+
144
+
145
+ @setup.command()
146
+ @options
147
+ def sources(**kwargs):
148
+ """Generate just the imports and filter declarations."""
149
+ write_project(**kwargs, section="sources")
150
+
151
+
152
+ @setup.command()
153
+ @options
154
+ def git(**kwargs):
155
+ """Generate .gitignore without initializing a repository."""
156
+ write_project(**kwargs, section="git")
157
+
158
+
159
+ @cli.command()
160
+ def version():
161
+ from cola_cli import __version__
162
+
163
+ click.echo(f"COLA CLI {__version__}")