atheneum-forge 1.0.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.
- atheneum_forge/__init__.py +2 -0
- atheneum_forge/_version.py +24 -0
- atheneum_forge/copyright.j2 +2 -0
- atheneum_forge/core.py +510 -0
- atheneum_forge/forge.py +149 -0
- atheneum_forge/languages/cpp/.clang-format +235 -0
- atheneum_forge/languages/cpp/.clang-tidy.j2 +24 -0
- atheneum_forge/languages/cpp/.codecov.yml +6 -0
- atheneum_forge/languages/cpp/.github/pull_request_template.md +45 -0
- atheneum_forge/languages/cpp/.github/workflows/build-and-test.yml +76 -0
- atheneum_forge/languages/cpp/.github/workflows/clang-format-check.yml.j2 +27 -0
- atheneum_forge/languages/cpp/.github/workflows/clang-tidy-check.yml +17 -0
- atheneum_forge/languages/cpp/.github/workflows/doxygen-completeness-check.yml +15 -0
- atheneum_forge/languages/cpp/.gitignore +4 -0
- atheneum_forge/languages/cpp/.gitmodules +6 -0
- atheneum_forge/languages/cpp/CMakePresets.json +17 -0
- atheneum_forge/languages/cpp/Dockerfile.format.j2 +12 -0
- atheneum_forge/languages/cpp/LICENSE.txt +10 -0
- atheneum_forge/languages/cpp/README.md.j2 +79 -0
- atheneum_forge/languages/cpp/Taskfile.yml.j2 +44 -0
- atheneum_forge/languages/cpp/app/app_CMakeLists.txt.j2 +38 -0
- atheneum_forge/languages/cpp/cmake/FindGcov.cmake +158 -0
- atheneum_forge/languages/cpp/cmake/FindLcov.cmake +357 -0
- atheneum_forge/languages/cpp/cmake/Findcodecov.cmake +258 -0
- atheneum_forge/languages/cpp/cmake/build-options-interface.cmake +105 -0
- atheneum_forge/languages/cpp/cmake/get-git-hash.cmake +8 -0
- atheneum_forge/languages/cpp/cmake/git-versioning.cmake +127 -0
- atheneum_forge/languages/cpp/cmake/initialize-submodules.cmake +67 -0
- atheneum_forge/languages/cpp/cmake/llvm-cov-wrapper +56 -0
- atheneum_forge/languages/cpp/cmake/toolchain-windows.cmake +18 -0
- atheneum_forge/languages/cpp/doc/guidelines.md +191 -0
- atheneum_forge/languages/cpp/include/atheneum/atheneum.h +37 -0
- atheneum_forge/languages/cpp/main_CMakeLists.txt.j2 +102 -0
- atheneum_forge/languages/cpp/manifest.toml +87 -0
- atheneum_forge/languages/cpp/src/atheneum-private.h +16 -0
- atheneum_forge/languages/cpp/src/atheneum.cpp +29 -0
- atheneum_forge/languages/cpp/src/src_CMakeLists.txt.j2 +51 -0
- atheneum_forge/languages/cpp/test/atheneum_tests.cpp +18 -0
- atheneum_forge/languages/cpp/test/test_CMakeLists.txt.j2 +30 -0
- atheneum_forge/languages/cpp/vendor/vendor_CMakeLists.txt.j2 +22 -0
- atheneum_forge/languages/python/.github/workflows/build-and-test.yaml +35 -0
- atheneum_forge/languages/python/.github/workflows/check-formatting.yaml +31 -0
- atheneum_forge/languages/python/.gitignore +21 -0
- atheneum_forge/languages/python/.pre-commit-config.yaml +18 -0
- atheneum_forge/languages/python/.pylintrc +585 -0
- atheneum_forge/languages/python/.python-version +1 -0
- atheneum_forge/languages/python/LICENSE.txt +10 -0
- atheneum_forge/languages/python/README.md +6 -0
- atheneum_forge/languages/python/dodo.py +21 -0
- atheneum_forge/languages/python/manifest.toml +41 -0
- atheneum_forge/languages/python/pyproject.toml +60 -0
- atheneum_forge/languages/python/uv.lock.toml +383 -0
- atheneum_forge/logging_setup.py +83 -0
- atheneum_forge/main.py +182 -0
- atheneum_forge/main.tcss +35 -0
- atheneum_forge/main_cli.py +122 -0
- atheneum_forge/project_factory.py +513 -0
- atheneum_forge/update.py +176 -0
- atheneum_forge-1.0.0.dist-info/METADATA +100 -0
- atheneum_forge-1.0.0.dist-info/RECORD +63 -0
- atheneum_forge-1.0.0.dist-info/WHEEL +4 -0
- atheneum_forge-1.0.0.dist-info/entry_points.txt +3 -0
- atheneum_forge-1.0.0.dist-info/licenses/LICENSE.txt +11 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
atheneum_forge/core.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2025 Big Ladder Software <info@bigladdersoftware.com>
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path, PurePath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import tomli_w
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import tomllib
|
|
17
|
+
except ImportError:
|
|
18
|
+
import tomli as tomllib # type: ignore [no-redef] # for Python <3.11
|
|
19
|
+
from jinja2 import Environment, Template
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("forge")
|
|
22
|
+
|
|
23
|
+
UNDEFAULTED_PARAMETERS = {"project_name", "deps"}
|
|
24
|
+
RECOGNIZED_SRC_DIRS = {"src", "include", "test", "app"}
|
|
25
|
+
DEFAULT_LINE_COMMENTS_BY_EXT = {
|
|
26
|
+
"*.cpp": "// ",
|
|
27
|
+
"*.cpp.in": "// ",
|
|
28
|
+
"*.h": "// ",
|
|
29
|
+
"*.h.in": "// ",
|
|
30
|
+
"*.c": "// ",
|
|
31
|
+
"CMakeLists.txt": "# ",
|
|
32
|
+
"*.py": "# ",
|
|
33
|
+
}
|
|
34
|
+
LINE_COMMENTS_BY_EXT = defaultdict(lambda: "#", {".cpp": "//", ".h": "//", ".py": "#"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render(template: str, config: dict) -> str:
|
|
38
|
+
"""Render a template using the given data
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
template (str): Jinja2 template to render
|
|
42
|
+
config (dict): values to insert into template
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
str: the rendered template
|
|
46
|
+
"""
|
|
47
|
+
t = Template(template)
|
|
48
|
+
return t.render(config)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_manifest(toml_str: str) -> dict:
|
|
52
|
+
"""Read a TOML manifest from a string.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
toml_str (str):
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
dict: {
|
|
59
|
+
"static":[{"from": "path", "to": "path"},...],
|
|
60
|
+
"template":[{"from": "path", "to": "path"},...],
|
|
61
|
+
}
|
|
62
|
+
"""
|
|
63
|
+
return tomllib.loads(toml_str)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_path(starting_dir: Path, path_str: str) -> dict:
|
|
67
|
+
"""Build a new path from a starting_dir and path_str."""
|
|
68
|
+
result = starting_dir
|
|
69
|
+
is_glob = False
|
|
70
|
+
globs = []
|
|
71
|
+
for piece in path_str.split("/"):
|
|
72
|
+
if "*" in piece or is_glob:
|
|
73
|
+
is_glob = True
|
|
74
|
+
else:
|
|
75
|
+
result /= piece
|
|
76
|
+
if is_glob:
|
|
77
|
+
globs.append(piece)
|
|
78
|
+
|
|
79
|
+
glob = "/".join(globs)
|
|
80
|
+
return {"path": result, "glob": None if not glob else glob}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ProjectFile:
|
|
85
|
+
from_path: Path
|
|
86
|
+
to_path: Path
|
|
87
|
+
onetime: bool
|
|
88
|
+
add_owner_copyright: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def collect_source_files(source_directory: Path, target_directory: Path, file_directives: list) -> list[ProjectFile]:
|
|
92
|
+
"""Collect project files and folders; process them through generation engine.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
source_directory (Path):
|
|
96
|
+
tgt_dir (Path):
|
|
97
|
+
file_paths (list): [{"from": "path", "to": "path"},...]
|
|
98
|
+
config (None | dict): if a dict, try to render file as a template; else copy
|
|
99
|
+
dry_run (bool): If true, treat as a dry-run
|
|
100
|
+
status_list (List[str]): keeps a list of which actions were taken
|
|
101
|
+
"""
|
|
102
|
+
project_files: list[ProjectFile] = []
|
|
103
|
+
|
|
104
|
+
for f in file_directives:
|
|
105
|
+
onetime = f.get("onetime", False)
|
|
106
|
+
add_owner_copyright = f.get("add_owner_copyright", False)
|
|
107
|
+
|
|
108
|
+
to_path_with_glob = build_path(target_directory, f["to"])
|
|
109
|
+
if to_path_with_glob["glob"] is not None: # TODO: Errors in the manifest shouldn't read out to the user!
|
|
110
|
+
log.error("Glob not allowed in 'to' path. Path must be directory or file.")
|
|
111
|
+
log.error(f"... 'to' path : {to_path_with_glob['path']}")
|
|
112
|
+
log.error(f"... 'glob' path: {to_path_with_glob['glob']}")
|
|
113
|
+
raise FileNotFoundError
|
|
114
|
+
to_path = to_path_with_glob["path"]
|
|
115
|
+
if to_path.is_dir():
|
|
116
|
+
to_path.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
else:
|
|
118
|
+
to_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
from_path_with_glob = build_path(source_directory, f["from"])
|
|
121
|
+
from_path = from_path_with_glob["path"]
|
|
122
|
+
if from_path_with_glob["glob"] is None:
|
|
123
|
+
if from_path.is_dir():
|
|
124
|
+
# Directory name (no name -> source dir) in the "from" field is unused;
|
|
125
|
+
# "to" path will be resolved/created # TODO: What if to_path is "bad" / not a dir?
|
|
126
|
+
to_name = to_path
|
|
127
|
+
elif "oname" in f:
|
|
128
|
+
# Single file output, new name
|
|
129
|
+
to_name = to_path / f["oname"]
|
|
130
|
+
else:
|
|
131
|
+
# Single file output, same name
|
|
132
|
+
to_name = to_path / from_path.name
|
|
133
|
+
project_files.append(ProjectFile(from_path, to_name, onetime, add_owner_copyright))
|
|
134
|
+
else:
|
|
135
|
+
if not to_path.exists():
|
|
136
|
+
to_path.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
glob = from_path_with_glob["glob"]
|
|
138
|
+
for fpath in from_path.glob(glob):
|
|
139
|
+
if fpath.is_dir():
|
|
140
|
+
continue
|
|
141
|
+
project_files.append(ProjectFile(fpath, to_path / fpath.name, onetime, add_owner_copyright))
|
|
142
|
+
return project_files
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def derive_default_parameter(defaults: dict, key: str, all_files: set | None = None) -> Any:
|
|
146
|
+
"""Derive default parameters, running any computations.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
defaults (dict): the dictionary of default parameters
|
|
150
|
+
key (str): the key to fetch
|
|
151
|
+
all_files (set | None, optional): maps a relative dir path to files in that dir
|
|
152
|
+
- e.g., {".": ["README.md"], "src": ["main.cpp"], "test": ["test.cpp"]}
|
|
153
|
+
Raises:
|
|
154
|
+
TypeError:
|
|
155
|
+
RuntimeError:
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Any: the processed default
|
|
159
|
+
"""
|
|
160
|
+
if key not in defaults:
|
|
161
|
+
return None
|
|
162
|
+
d = defaults[key].get("default", None)
|
|
163
|
+
if isinstance(d, str):
|
|
164
|
+
if d.startswith("parameter:"):
|
|
165
|
+
d = defaults[re.sub("parameter:", "", d)]["default"]
|
|
166
|
+
if d.endswith("()"):
|
|
167
|
+
if d == "current_year()":
|
|
168
|
+
d = datetime.now().year
|
|
169
|
+
data_type = defaults[key].get("type", None)
|
|
170
|
+
if data_type == "str:glob" and all_files is not None:
|
|
171
|
+
matched = []
|
|
172
|
+
for file_path in all_files:
|
|
173
|
+
if PurePath(file_path).match(d):
|
|
174
|
+
matched.append(
|
|
175
|
+
{
|
|
176
|
+
"path": file_path,
|
|
177
|
+
"name": str(PurePath(file_path).name),
|
|
178
|
+
"code_path": re.sub("^include/", "", file_path),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
d = sorted(matched, key=lambda m: m["path"])
|
|
182
|
+
return d
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_config_toml(manifest: dict, project_name: str, all_files: set | None = None) -> str:
|
|
186
|
+
"""Create config TOML data from the given manifest."""
|
|
187
|
+
params = manifest.get("template-parameters", {})
|
|
188
|
+
params["project_name"] = {type: "str", "required": True, "default": project_name}
|
|
189
|
+
configuration_entries = []
|
|
190
|
+
for p in sorted(params.keys()):
|
|
191
|
+
is_private = params[p].get("private", False)
|
|
192
|
+
if is_private:
|
|
193
|
+
continue
|
|
194
|
+
if "default" in params[p]:
|
|
195
|
+
d = derive_default_parameter(params, p, all_files)
|
|
196
|
+
value_str = tomli_w.dumps({p: d}).strip()
|
|
197
|
+
if params[p].get("required", False):
|
|
198
|
+
configuration_entries.append(f"{value_str}")
|
|
199
|
+
else:
|
|
200
|
+
configuration_entries.append(f"# {value_str}")
|
|
201
|
+
else:
|
|
202
|
+
configuration_entries.append(f"{p} = # <-- {params[p]['type']}")
|
|
203
|
+
dependencies = manifest.get("deps", [])
|
|
204
|
+
dep_strings = []
|
|
205
|
+
if dependencies and dependencies[0]:
|
|
206
|
+
deps = sorted(dependencies, key=lambda d: d["name"])
|
|
207
|
+
for dep in deps:
|
|
208
|
+
dep_strings.append("[[deps]]")
|
|
209
|
+
dep_strings.append(f'name = "{dep["name"]}"')
|
|
210
|
+
dep_strings.append(f'git_url = "{dep["git_url"]}"')
|
|
211
|
+
dep_strings.append(f'git_checkout = "{dep["git_checkout"]}"')
|
|
212
|
+
if "add_to_cmake" in dep and dep["add_to_cmake"]:
|
|
213
|
+
dep_strings.append("add_to_cmake = true")
|
|
214
|
+
else:
|
|
215
|
+
dep_strings.append("add_to_cmake = false")
|
|
216
|
+
if "link_library_spec" in dep and len(dep["link_library_spec"]) > 0:
|
|
217
|
+
dep_strings.append(f'link_library_spec = "{dep["link_library_spec"]}"')
|
|
218
|
+
dep_strings.append(
|
|
219
|
+
"""
|
|
220
|
+
# [[deps]]
|
|
221
|
+
# name = "" # <- name of the dependency; vendor/<name>
|
|
222
|
+
# git_url = "" # <- add the url used to checkout this repository
|
|
223
|
+
# git_checkout = "" # <- add the branch, sha, or tag to check out
|
|
224
|
+
# add_to_cmake = true # <- if true, add to CMakeLists.txt files
|
|
225
|
+
# link_library_spec = "" # <- how library should appear in target_link_library(.); if blank, use project name
|
|
226
|
+
""".strip()
|
|
227
|
+
)
|
|
228
|
+
postfix = "\n".join(dep_strings)
|
|
229
|
+
return "\n".join(configuration_entries) + "\n" + postfix + "\n"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def merge_defaults_into_config(config: dict, manifest_defaults: dict, target_files: set | None = None) -> dict: # noqa: PLR0912
|
|
233
|
+
"""Collect all available configuration parameters and their correct values."""
|
|
234
|
+
result = {}
|
|
235
|
+
# For every attribute in the user-supplied configuration, check that the attribute is
|
|
236
|
+
# 1. available in the manifest and
|
|
237
|
+
# 2. of the type indicated by the manifest
|
|
238
|
+
for p in config.keys(): # noqa: PLC0206
|
|
239
|
+
if p not in manifest_defaults:
|
|
240
|
+
if p not in UNDEFAULTED_PARAMETERS:
|
|
241
|
+
log.warn(f"Unrecognized key '{p}' in config")
|
|
242
|
+
else:
|
|
243
|
+
result[p] = config[p]
|
|
244
|
+
else:
|
|
245
|
+
data_type = manifest_defaults[p].get("type", None)
|
|
246
|
+
v = config[p]
|
|
247
|
+
result[p] = v
|
|
248
|
+
if isinstance(data_type, str):
|
|
249
|
+
type_error = f"Type mismatch in attribute {p}\n... expected type: {data_type}\n... actual value : {v!r}"
|
|
250
|
+
if data_type.startswith("str") and not isinstance(v, str):
|
|
251
|
+
raise TypeError(type_error)
|
|
252
|
+
if data_type.startswith("int") and not isinstance(v, int):
|
|
253
|
+
raise TypeError(type_error)
|
|
254
|
+
if data_type.startswith("enum"):
|
|
255
|
+
if not isinstance(v, str):
|
|
256
|
+
raise TypeError(type_error)
|
|
257
|
+
options = manifest_defaults[p].get("options", [])
|
|
258
|
+
if v not in options:
|
|
259
|
+
raise TypeError(f"Enum error; {v} not in {options}")
|
|
260
|
+
# For every attribute in the manifest, if it isn't called out in the user-supplied
|
|
261
|
+
# config, populate its value with a correct default.
|
|
262
|
+
for k, v in manifest_defaults.items():
|
|
263
|
+
if k not in config:
|
|
264
|
+
if "default" not in v:
|
|
265
|
+
raise TypeError(f"Missing required config parameter '{k}'")
|
|
266
|
+
result[k] = derive_default_parameter(manifest_defaults, k, target_files)
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read_toml(input_file: Path) -> dict:
|
|
271
|
+
"""Read and return a dictionary from toml file.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
input_file (Path): Input .toml file path
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
RuntimeError: Badly configured input file.
|
|
278
|
+
FileNotFoundError: No input file.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
dict: Key-value pairs extracted from toml format.
|
|
282
|
+
"""
|
|
283
|
+
if input_file.is_file():
|
|
284
|
+
try:
|
|
285
|
+
with open(input_file, "rb") as fid:
|
|
286
|
+
output = tomllib.load(fid)
|
|
287
|
+
return output
|
|
288
|
+
except tomllib.TOMLDecodeError:
|
|
289
|
+
log.error("Incorrect input file format detected. (Check for invalid key-value pairs.)")
|
|
290
|
+
raise RuntimeError from None
|
|
291
|
+
else:
|
|
292
|
+
log.error(f"{input_file} does not exist.")
|
|
293
|
+
raise FileNotFoundError(f"{input_file} does not exist.")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# def read_config(config: dict, parameters: dict, all_files: set | None = None) -> dict:
|
|
297
|
+
# """Mix defaults from manifest's parameters section into the configuration toml data.
|
|
298
|
+
|
|
299
|
+
# Args:
|
|
300
|
+
# config (dict):
|
|
301
|
+
# parameters (dict):
|
|
302
|
+
# all_files (set | None, optional):
|
|
303
|
+
|
|
304
|
+
# Returns:
|
|
305
|
+
# dict: _description_
|
|
306
|
+
# """
|
|
307
|
+
# return merge_defaults_into_config(config, parameters, all_files)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def list_files_in(dir_path: Path) -> set:
|
|
311
|
+
"""List all files relative to a dir_path using relative path strings.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
dir_path (Path): Input path
|
|
315
|
+
Returns:
|
|
316
|
+
set: A set of relative paths in string form
|
|
317
|
+
"""
|
|
318
|
+
result = set()
|
|
319
|
+
for item in dir_path.glob("**/*"):
|
|
320
|
+
candidate = str(item.relative_to(dir_path))
|
|
321
|
+
is_src = False
|
|
322
|
+
for dir_name in RECOGNIZED_SRC_DIRS:
|
|
323
|
+
if candidate.startswith(dir_name):
|
|
324
|
+
is_src = True
|
|
325
|
+
break
|
|
326
|
+
if not is_src:
|
|
327
|
+
continue
|
|
328
|
+
result.add(str(item.relative_to(dir_path)))
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def setup_vendor(config: dict, target_directory: Path, dry_run: bool = False) -> list:
|
|
333
|
+
"""Return the list of commands necessary to set up vendor directory.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
config (dict): must contain the key "deps" which is a list:
|
|
337
|
+
[{"name": "dep_name", "git_url": "", "git_checkout": "branch/sha/tag name"}, ...]
|
|
338
|
+
tgt_dir (Path): Path to directory where setup should occur. (root path)
|
|
339
|
+
dry_run (bool, optional): if True doesn't touch the file system.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
list: list of commands where a command is {"dir": Path, "cmds": list(str)}
|
|
343
|
+
"""
|
|
344
|
+
cmds = []
|
|
345
|
+
for dep in sorted(config.get("deps", []), key=lambda d: d["name"]):
|
|
346
|
+
dep_name = dep["name"]
|
|
347
|
+
tgt_dep = target_directory / "vendor" / dep_name
|
|
348
|
+
if dry_run or not tgt_dep.exists():
|
|
349
|
+
cmd = f"git submodule add {dep['git_url']} vendor/{dep_name}"
|
|
350
|
+
cmds.append(cmd)
|
|
351
|
+
cmd = " && ".join(
|
|
352
|
+
[
|
|
353
|
+
f"cd vendor/{dep_name}",
|
|
354
|
+
"git fetch",
|
|
355
|
+
f"git checkout {dep['git_checkout']}",
|
|
356
|
+
"cd ../..",
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
cmds.append(cmd)
|
|
360
|
+
if len(cmds) == 0:
|
|
361
|
+
return []
|
|
362
|
+
return [{"dir": target_directory, "cmds": cmds}]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def init_git_repo(target_directory: Path | str) -> list:
|
|
366
|
+
"""
|
|
367
|
+
Return the list of commands required to initialize a git repo. See setup_vendor for structure.
|
|
368
|
+
"""
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
"dir": Path(target_directory),
|
|
372
|
+
"cmds": [
|
|
373
|
+
"git init --initial-branch=main",
|
|
374
|
+
],
|
|
375
|
+
}
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def init_pre_commit(target_directory: Path | str, type: str) -> list:
|
|
380
|
+
"""
|
|
381
|
+
Return the list of commands required to initialize the pre-commit tool.
|
|
382
|
+
"""
|
|
383
|
+
if type == "cpp":
|
|
384
|
+
return [
|
|
385
|
+
{
|
|
386
|
+
"dir": Path(target_directory),
|
|
387
|
+
"cmds": [
|
|
388
|
+
"uvx pre-commit install",
|
|
389
|
+
],
|
|
390
|
+
}
|
|
391
|
+
]
|
|
392
|
+
elif type == "python":
|
|
393
|
+
return [
|
|
394
|
+
{
|
|
395
|
+
"dir": Path(target_directory),
|
|
396
|
+
"cmds": [
|
|
397
|
+
"uv run pre-commit install", # uv run syncs the venv first, so pre-commit gets installed
|
|
398
|
+
],
|
|
399
|
+
}
|
|
400
|
+
]
|
|
401
|
+
return []
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def run_commands(commands: list) -> list[str]:
|
|
405
|
+
"""
|
|
406
|
+
Run a list of commands.
|
|
407
|
+
A command is documented as for setup_vendor.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
list[str]: the stdout of each command, in execution order.
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
CalledProcessError: The subprocess had a nonzero return code.
|
|
414
|
+
"""
|
|
415
|
+
responses: list[str] = []
|
|
416
|
+
for c in commands:
|
|
417
|
+
for cmd in c["cmds"]:
|
|
418
|
+
if not c["dir"].exists():
|
|
419
|
+
c["dir"].mkdir(parents=True)
|
|
420
|
+
log.info(cmd)
|
|
421
|
+
result = subprocess.run(cmd, cwd=c["dir"], shell=True, check=False, capture_output=True, encoding="utf8")
|
|
422
|
+
result.check_returncode()
|
|
423
|
+
log.info(result.stdout) # Only reached if success code (0) returned
|
|
424
|
+
responses.append(result.stdout)
|
|
425
|
+
return responses
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def gen_copyright(config: dict, copy_template: str, all_files: set[Path]) -> dict:
|
|
429
|
+
"""
|
|
430
|
+
Generate copyright headers for the file tree.
|
|
431
|
+
"""
|
|
432
|
+
copy = render(copy_template, config)
|
|
433
|
+
copy_lines = copy.splitlines()
|
|
434
|
+
result = {}
|
|
435
|
+
for file_name in all_files:
|
|
436
|
+
for match_str, prefix in DEFAULT_LINE_COMMENTS_BY_EXT.items():
|
|
437
|
+
if PurePath(file_name).match(match_str):
|
|
438
|
+
result[file_name] = list(map(lambda line: prefix + line, copy_lines))
|
|
439
|
+
return result
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def render_copyright_string(environment: Environment, config: dict, for_file: Path) -> str:
|
|
443
|
+
"""
|
|
444
|
+
Generate copyright headers for the single file.
|
|
445
|
+
"""
|
|
446
|
+
copyright_template_file = "copyright.j2"
|
|
447
|
+
template = environment.get_template(copyright_template_file)
|
|
448
|
+
config.update({"comment_characters": LINE_COMMENTS_BY_EXT[PurePath(for_file).suffix]})
|
|
449
|
+
return template.render(config).encode("utf-8").decode("utf-8")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def prepend_copyright_to_copy(from_path, copyright_text):
|
|
453
|
+
if copyright_text:
|
|
454
|
+
copyright_indicators = ["Copyright", "copyright", "(C)", "(c)", "©"]
|
|
455
|
+
already_copyrighted = False
|
|
456
|
+
try:
|
|
457
|
+
with open(from_path, "r", encoding="utf-8") as from_file:
|
|
458
|
+
# Allow copyright information from the first two lines.
|
|
459
|
+
# zip with range(2) stops at EOF, so short files don't raise StopIteration.
|
|
460
|
+
head = [line for _, line in zip(range(2), from_file)]
|
|
461
|
+
for line in head:
|
|
462
|
+
already_copyrighted = any(c in line for c in copyright_indicators)
|
|
463
|
+
if already_copyrighted:
|
|
464
|
+
break
|
|
465
|
+
except UnicodeDecodeError as u:
|
|
466
|
+
raise RuntimeError(f"{u} in file {from_path}")
|
|
467
|
+
with open(from_path, "r+", encoding="utf-8") as f:
|
|
468
|
+
contents = f.read()
|
|
469
|
+
f.seek(0)
|
|
470
|
+
if not already_copyrighted:
|
|
471
|
+
f.write(copyright_text)
|
|
472
|
+
f.write(contents)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def update_copyright(file_content: str, copy_lines: list) -> str:
|
|
476
|
+
"""
|
|
477
|
+
Update copyright for a file as lines, returning (possibly updated) content.
|
|
478
|
+
If the copy in the first N number of lines of file_content match the
|
|
479
|
+
copy lines substantially, then overwrite. Else, prepend.
|
|
480
|
+
"""
|
|
481
|
+
file_lines = file_content.splitlines()
|
|
482
|
+
lines_match_substantially = True
|
|
483
|
+
for line_idx, cline in enumerate(copy_lines):
|
|
484
|
+
if line_idx < len(file_lines):
|
|
485
|
+
line = file_lines[line_idx]
|
|
486
|
+
if cline != line:
|
|
487
|
+
copy_items = cline.split()
|
|
488
|
+
existing_items = line.split()
|
|
489
|
+
if len(copy_items) > 1 and len(existing_items) > 1:
|
|
490
|
+
if copy_items[1] != existing_items[1]:
|
|
491
|
+
lines_match_substantially = False
|
|
492
|
+
break
|
|
493
|
+
if copy_items[0] != existing_items[0]:
|
|
494
|
+
lines_match_substantially = False
|
|
495
|
+
break
|
|
496
|
+
continue
|
|
497
|
+
if len(copy_items) > 0 and len(existing_items) > 0:
|
|
498
|
+
if copy_items[0] != existing_items[0]:
|
|
499
|
+
lines_match_substantially = False
|
|
500
|
+
break
|
|
501
|
+
continue
|
|
502
|
+
lines_match_substantially = False
|
|
503
|
+
break
|
|
504
|
+
new_lines = []
|
|
505
|
+
new_lines.extend(copy_lines)
|
|
506
|
+
if lines_match_substantially:
|
|
507
|
+
new_lines.extend(file_lines[len(copy_lines) :])
|
|
508
|
+
else:
|
|
509
|
+
new_lines.extend(file_lines)
|
|
510
|
+
return "\n".join(new_lines)
|
atheneum_forge/forge.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2025 Big Ladder Software <info@bigladdersoftware.com>
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from subprocess import CalledProcessError
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
from atheneum_forge import core, project_factory
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("forge")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize(name):
|
|
17
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AtheneumForge:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.generator: project_factory.GeneratedProject | None = None
|
|
23
|
+
|
|
24
|
+
def initialize_configuration( # noqa: PLR0913, PLR0917
|
|
25
|
+
self,
|
|
26
|
+
project_path: Path,
|
|
27
|
+
project_name: str,
|
|
28
|
+
type: project_factory.ProjectType = project_factory.ProjectType.none,
|
|
29
|
+
generate: bool = True,
|
|
30
|
+
git_init: bool = True,
|
|
31
|
+
submodule_init: bool = True,
|
|
32
|
+
force: bool = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Generate a directory and empty config file for the given project type. (Existing
|
|
36
|
+
configuation files will not be overwritten without the --force flag.)
|
|
37
|
+
"""
|
|
38
|
+
ProjType = project_factory.ProjectType
|
|
39
|
+
|
|
40
|
+
if not project_name:
|
|
41
|
+
project_name = Path(project_path).resolve().name # normalized or raw?
|
|
42
|
+
|
|
43
|
+
# Deliberate control flow: invalid type is a usage error, raised directly (not wrapped).
|
|
44
|
+
if type == ProjType.none:
|
|
45
|
+
# We'd like 'type' to be specified as an optional argument, but a "valid" default could have
|
|
46
|
+
# unintended consequences.
|
|
47
|
+
logger.error("Please specify a valid type (use [red]--help[/red] for options).")
|
|
48
|
+
raise RuntimeError("No project type specified.")
|
|
49
|
+
|
|
50
|
+
# Step 1: construct the generator (creates/validates the config file).
|
|
51
|
+
try:
|
|
52
|
+
if type == ProjType.cpp:
|
|
53
|
+
self.generator = project_factory.GeneratedCPP(project_path, project_name, force)
|
|
54
|
+
else: # ProjType.python
|
|
55
|
+
self.generator = project_factory.GeneratedPython(project_path, project_name, force)
|
|
56
|
+
except FileNotFoundError as err: # missing sources, or no name and no existing config
|
|
57
|
+
logger.error(f"Could not locate project sources or configuration: {err}")
|
|
58
|
+
raise
|
|
59
|
+
except RuntimeError as err: # config exists without force, or config processing failed
|
|
60
|
+
logger.error(err)
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
if (git_init or submodule_init) and not generate:
|
|
64
|
+
# In a python project the pre-commit tool is only installed once pyproject.toml is read,
|
|
65
|
+
# so it can't be called without file generation.
|
|
66
|
+
logger.info(
|
|
67
|
+
"Git repository not initialized ([red]--no-git-init[/] applied). Please generate project files first."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not generate:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Step 2: generate project files (and optionally init git/submodules).
|
|
74
|
+
try:
|
|
75
|
+
self.generate_project_files(project_path, git_init=git_init, submodule_init=submodule_init)
|
|
76
|
+
except (FileNotFoundError, RuntimeError) as err: # _check_directories / collect_source_files / manifest
|
|
77
|
+
logger.error(f"Project files could not be generated: {err}")
|
|
78
|
+
raise
|
|
79
|
+
except CalledProcessError as err: # bubbled up from _git_init / _submodule_init
|
|
80
|
+
logger.error(f"Git/submodule initialization failed: {err}")
|
|
81
|
+
# files are already generated; the repo is usable, so don't abort
|
|
82
|
+
|
|
83
|
+
def generate_project_files( # type: ignore
|
|
84
|
+
self, project_path: Path, git_init: bool = True, submodule_init: bool = True
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
(Re-)Generate project from template files.
|
|
88
|
+
If initialize submodules is True, the config path must be within a git repo.
|
|
89
|
+
Initializing submodules will set up the vendor directory.
|
|
90
|
+
"""
|
|
91
|
+
if not self.generator:
|
|
92
|
+
type = project_factory.GeneratedProject.get_project_type(project_path)
|
|
93
|
+
if type == project_factory.ProjectType.cpp:
|
|
94
|
+
self.generator = project_factory.GeneratedCPP(project_path)
|
|
95
|
+
elif type == project_factory.ProjectType.python:
|
|
96
|
+
self.generator = project_factory.GeneratedPython(project_path)
|
|
97
|
+
else:
|
|
98
|
+
logger.error("Project type was not found.")
|
|
99
|
+
|
|
100
|
+
result = self.generator.generate(project_path) # type: ignore
|
|
101
|
+
for r in result:
|
|
102
|
+
logger.info(
|
|
103
|
+
f"- {r}",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if submodule_init:
|
|
107
|
+
self._git_init()
|
|
108
|
+
self._submodule_init()
|
|
109
|
+
elif git_init:
|
|
110
|
+
self._git_init()
|
|
111
|
+
|
|
112
|
+
def _submodule_init(self):
|
|
113
|
+
try:
|
|
114
|
+
core.run_commands(self.generator.init_submodules()) # type: ignore
|
|
115
|
+
except CalledProcessError as err:
|
|
116
|
+
logger.error(err)
|
|
117
|
+
raise err
|
|
118
|
+
|
|
119
|
+
def _git_init(self):
|
|
120
|
+
try:
|
|
121
|
+
core.run_commands(self.generator.init_git_repo() + self.generator.init_pre_commit()) # type: ignore
|
|
122
|
+
except CalledProcessError as err:
|
|
123
|
+
logger.error(err)
|
|
124
|
+
raise err
|
|
125
|
+
|
|
126
|
+
def update_project_files(
|
|
127
|
+
self,
|
|
128
|
+
project_path: Path,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Shorthand command that calls generate under the hood.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
project_path
|
|
134
|
+
"""
|
|
135
|
+
return self.generate_project_files(project_path, False, False)
|
|
136
|
+
|
|
137
|
+
def add_owner_copyright(self, source_path: Path) -> None:
|
|
138
|
+
env = Environment(loader=FileSystemLoader(Path(__file__).parent, encoding="utf-8"), keep_trailing_newline=True)
|
|
139
|
+
for file in source_path.iterdir():
|
|
140
|
+
if self.generator:
|
|
141
|
+
core.prepend_copyright_to_copy(
|
|
142
|
+
file, core.render_copyright_string(env, self.generator.configuration, file)
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
logger.info("Select a project type before adding copyright to files.")
|
|
146
|
+
|
|
147
|
+
def edit_config(self, edits: dict[str, str]) -> None:
|
|
148
|
+
if self.generator:
|
|
149
|
+
self.generator.edit_forge_config(edits)
|