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.
Files changed (63) hide show
  1. atheneum_forge/__init__.py +2 -0
  2. atheneum_forge/_version.py +24 -0
  3. atheneum_forge/copyright.j2 +2 -0
  4. atheneum_forge/core.py +510 -0
  5. atheneum_forge/forge.py +149 -0
  6. atheneum_forge/languages/cpp/.clang-format +235 -0
  7. atheneum_forge/languages/cpp/.clang-tidy.j2 +24 -0
  8. atheneum_forge/languages/cpp/.codecov.yml +6 -0
  9. atheneum_forge/languages/cpp/.github/pull_request_template.md +45 -0
  10. atheneum_forge/languages/cpp/.github/workflows/build-and-test.yml +76 -0
  11. atheneum_forge/languages/cpp/.github/workflows/clang-format-check.yml.j2 +27 -0
  12. atheneum_forge/languages/cpp/.github/workflows/clang-tidy-check.yml +17 -0
  13. atheneum_forge/languages/cpp/.github/workflows/doxygen-completeness-check.yml +15 -0
  14. atheneum_forge/languages/cpp/.gitignore +4 -0
  15. atheneum_forge/languages/cpp/.gitmodules +6 -0
  16. atheneum_forge/languages/cpp/CMakePresets.json +17 -0
  17. atheneum_forge/languages/cpp/Dockerfile.format.j2 +12 -0
  18. atheneum_forge/languages/cpp/LICENSE.txt +10 -0
  19. atheneum_forge/languages/cpp/README.md.j2 +79 -0
  20. atheneum_forge/languages/cpp/Taskfile.yml.j2 +44 -0
  21. atheneum_forge/languages/cpp/app/app_CMakeLists.txt.j2 +38 -0
  22. atheneum_forge/languages/cpp/cmake/FindGcov.cmake +158 -0
  23. atheneum_forge/languages/cpp/cmake/FindLcov.cmake +357 -0
  24. atheneum_forge/languages/cpp/cmake/Findcodecov.cmake +258 -0
  25. atheneum_forge/languages/cpp/cmake/build-options-interface.cmake +105 -0
  26. atheneum_forge/languages/cpp/cmake/get-git-hash.cmake +8 -0
  27. atheneum_forge/languages/cpp/cmake/git-versioning.cmake +127 -0
  28. atheneum_forge/languages/cpp/cmake/initialize-submodules.cmake +67 -0
  29. atheneum_forge/languages/cpp/cmake/llvm-cov-wrapper +56 -0
  30. atheneum_forge/languages/cpp/cmake/toolchain-windows.cmake +18 -0
  31. atheneum_forge/languages/cpp/doc/guidelines.md +191 -0
  32. atheneum_forge/languages/cpp/include/atheneum/atheneum.h +37 -0
  33. atheneum_forge/languages/cpp/main_CMakeLists.txt.j2 +102 -0
  34. atheneum_forge/languages/cpp/manifest.toml +87 -0
  35. atheneum_forge/languages/cpp/src/atheneum-private.h +16 -0
  36. atheneum_forge/languages/cpp/src/atheneum.cpp +29 -0
  37. atheneum_forge/languages/cpp/src/src_CMakeLists.txt.j2 +51 -0
  38. atheneum_forge/languages/cpp/test/atheneum_tests.cpp +18 -0
  39. atheneum_forge/languages/cpp/test/test_CMakeLists.txt.j2 +30 -0
  40. atheneum_forge/languages/cpp/vendor/vendor_CMakeLists.txt.j2 +22 -0
  41. atheneum_forge/languages/python/.github/workflows/build-and-test.yaml +35 -0
  42. atheneum_forge/languages/python/.github/workflows/check-formatting.yaml +31 -0
  43. atheneum_forge/languages/python/.gitignore +21 -0
  44. atheneum_forge/languages/python/.pre-commit-config.yaml +18 -0
  45. atheneum_forge/languages/python/.pylintrc +585 -0
  46. atheneum_forge/languages/python/.python-version +1 -0
  47. atheneum_forge/languages/python/LICENSE.txt +10 -0
  48. atheneum_forge/languages/python/README.md +6 -0
  49. atheneum_forge/languages/python/dodo.py +21 -0
  50. atheneum_forge/languages/python/manifest.toml +41 -0
  51. atheneum_forge/languages/python/pyproject.toml +60 -0
  52. atheneum_forge/languages/python/uv.lock.toml +383 -0
  53. atheneum_forge/logging_setup.py +83 -0
  54. atheneum_forge/main.py +182 -0
  55. atheneum_forge/main.tcss +35 -0
  56. atheneum_forge/main_cli.py +122 -0
  57. atheneum_forge/project_factory.py +513 -0
  58. atheneum_forge/update.py +176 -0
  59. atheneum_forge-1.0.0.dist-info/METADATA +100 -0
  60. atheneum_forge-1.0.0.dist-info/RECORD +63 -0
  61. atheneum_forge-1.0.0.dist-info/WHEEL +4 -0
  62. atheneum_forge-1.0.0.dist-info/entry_points.txt +3 -0
  63. atheneum_forge-1.0.0.dist-info/licenses/LICENSE.txt +11 -0
@@ -0,0 +1,2 @@
1
+ from atheneum_forge import logging_setup
2
+ from atheneum_forge._version import __version__
@@ -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
@@ -0,0 +1,2 @@
1
+ {{ comment_characters }} SPDX-FileCopyrightText: © {{ start_year }} {{ name_of_copyright_holder }} <{{ contact_email }}>
2
+ {{ comment_characters }} SPDX-License-Identifier: {{ SPDX_license_name }}
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)
@@ -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)