boilersync 1.2.14__tar.gz → 1.2.16__tar.gz
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.
- {boilersync-1.2.14 → boilersync-1.2.16}/.gitignore +7 -1
- {boilersync-1.2.14 → boilersync-1.2.16}/PKG-INFO +11 -2
- {boilersync-1.2.14 → boilersync-1.2.16}/README.md +10 -1
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/_version.py +2 -2
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/pull.py +8 -3
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/templates.py +133 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/interpolation_context.py +2 -1
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/names.py +9 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/template_processor.py +49 -1
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/template_sources.py +12 -2
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/variable_collector.py +25 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/docs/ensemble.md +3 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/docs/project-metadata.md +4 -1
- {boilersync-1.2.14 → boilersync-1.2.16}/docs/template-metadata.md +49 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/mkdocs.yml +2 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_init_runtime_features.py +47 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_interpolation_context.py +15 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_template_sources.py +11 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_templates_commands.py +99 -3
- {boilersync-1.2.14 → boilersync-1.2.16}/.cursor/rules/adding-commands.mdc +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.cursor/rules/project-description.mdc +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.cursor/rules/python-conventions.mdc +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.github/workflows/auto-tag.yml +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.github/workflows/deploy-docs.yml +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.github/workflows/publish.yml +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.vscode/launch.json +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.vscode/settings.json +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/.vscode/tasks.json +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/PROBLEMS.md +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/__init__.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/__main__.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/cli.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/cli_helpers.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/__init__.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/check_pull.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/init.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/commands/push.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/errors.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/logging.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/paths.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/project_metadata.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/boilersync/utils.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/docs/index.md +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/pyproject.toml +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/scripts/build.sh +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/__init__.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_check_pull.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_filename_interpolation.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_paths.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_push_inheritance.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_template_inheritance.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_templates_init.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_variable_collection.py +0 -0
- {boilersync-1.2.14 → boilersync-1.2.16}/tests/test_variable_type_conversion.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boilersync
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.16
|
|
4
4
|
Summary: BoilerSync
|
|
5
5
|
Project-URL: Repository, https://github.com/gabemontague/boilersync
|
|
6
6
|
Project-URL: Issues, https://github.com/gabemontague/boilersync/issues
|
|
@@ -48,6 +48,11 @@ boilersync templates init https://github.com/your-org/your-templates.git
|
|
|
48
48
|
# 2) Initialize a project from a source-qualified template ref
|
|
49
49
|
boilersync init your-org/your-templates#python/service-template
|
|
50
50
|
|
|
51
|
+
# Well-defaulted templates can be initialized without prompts
|
|
52
|
+
mkdir my-app-workspace
|
|
53
|
+
cd my-app-workspace
|
|
54
|
+
boilersync init your-org/your-templates#python/service-template --non-interactive
|
|
55
|
+
|
|
51
56
|
# 3) Pull template updates into the current project when needed
|
|
52
57
|
boilersync pull
|
|
53
58
|
|
|
@@ -58,6 +63,7 @@ boilersync push
|
|
|
58
63
|
## Command Overview
|
|
59
64
|
|
|
60
65
|
- `boilersync init TEMPLATE_REF`: create a project from a template (empty target directory).
|
|
66
|
+
- `boilersync init TEMPLATE_REF --non-interactive`: create a project without prompts when defaults and automatic values cover all inputs.
|
|
61
67
|
- `boilersync check-pull`: compare the project's recorded template repo commit against the current cached template repo HEAD.
|
|
62
68
|
- `boilersync pull [TEMPLATE_REF]`: apply template updates to an existing project.
|
|
63
69
|
- `boilersync push`: review and copy committed project changes back to the template.
|
|
@@ -99,8 +105,11 @@ For the field-by-field schema and validation rules, see [docs/project-metadata.m
|
|
|
99
105
|
|
|
100
106
|
- Files ending in `.boilersync` are rendered and emitted without that extension.
|
|
101
107
|
- Files with `.starter` as the first extension are starter-only files.
|
|
102
|
-
- Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), child templates, hooks, and optional GitHub repo creation.
|
|
108
|
+
- Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), defaults, child templates, hooks, and optional GitHub repo creation.
|
|
103
109
|
- Project naming is provided through standard template variables such as `name_snake` and `name_pretty`.
|
|
110
|
+
- `template.json` `defaults` can derive variables from naming values, such as `"api_package_name": "$${name_snake}_api"` or `"web_package_name": "$${name_kebab}-web"`.
|
|
111
|
+
- Default project-name inference strips a trailing `-workspace` / `_workspace` suffix from the target directory name.
|
|
112
|
+
- If a template references `github_user`, BoilerSync tries to populate it with `gh api user --jq .login` before prompting or failing in non-interactive mode.
|
|
104
113
|
|
|
105
114
|
## Documentation Policy
|
|
106
115
|
|
|
@@ -22,6 +22,11 @@ boilersync templates init https://github.com/your-org/your-templates.git
|
|
|
22
22
|
# 2) Initialize a project from a source-qualified template ref
|
|
23
23
|
boilersync init your-org/your-templates#python/service-template
|
|
24
24
|
|
|
25
|
+
# Well-defaulted templates can be initialized without prompts
|
|
26
|
+
mkdir my-app-workspace
|
|
27
|
+
cd my-app-workspace
|
|
28
|
+
boilersync init your-org/your-templates#python/service-template --non-interactive
|
|
29
|
+
|
|
25
30
|
# 3) Pull template updates into the current project when needed
|
|
26
31
|
boilersync pull
|
|
27
32
|
|
|
@@ -32,6 +37,7 @@ boilersync push
|
|
|
32
37
|
## Command Overview
|
|
33
38
|
|
|
34
39
|
- `boilersync init TEMPLATE_REF`: create a project from a template (empty target directory).
|
|
40
|
+
- `boilersync init TEMPLATE_REF --non-interactive`: create a project without prompts when defaults and automatic values cover all inputs.
|
|
35
41
|
- `boilersync check-pull`: compare the project's recorded template repo commit against the current cached template repo HEAD.
|
|
36
42
|
- `boilersync pull [TEMPLATE_REF]`: apply template updates to an existing project.
|
|
37
43
|
- `boilersync push`: review and copy committed project changes back to the template.
|
|
@@ -73,8 +79,11 @@ For the field-by-field schema and validation rules, see [docs/project-metadata.m
|
|
|
73
79
|
|
|
74
80
|
- Files ending in `.boilersync` are rendered and emitted without that extension.
|
|
75
81
|
- Files with `.starter` as the first extension are starter-only files.
|
|
76
|
-
- Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), child templates, hooks, and optional GitHub repo creation.
|
|
82
|
+
- Every template root must include `template.json`, which supports inheritance (`extends`/`parent`), defaults, child templates, hooks, and optional GitHub repo creation.
|
|
77
83
|
- Project naming is provided through standard template variables such as `name_snake` and `name_pretty`.
|
|
84
|
+
- `template.json` `defaults` can derive variables from naming values, such as `"api_package_name": "$${name_snake}_api"` or `"web_package_name": "$${name_kebab}-web"`.
|
|
85
|
+
- Default project-name inference strips a trailing `-workspace` / `_workspace` suffix from the target directory name.
|
|
86
|
+
- If a template references `github_user`, BoilerSync tries to populate it with `gh api user --jq .login` before prompting or failing in non-interactive mode.
|
|
78
87
|
|
|
79
88
|
## Documentation Policy
|
|
80
89
|
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 2,
|
|
21
|
+
__version__ = version = '1.2.16'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 2, 16)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -8,10 +8,13 @@ import click
|
|
|
8
8
|
from git import InvalidGitRepositoryError, Repo
|
|
9
9
|
|
|
10
10
|
from boilersync.interpolation_context import interpolation_context
|
|
11
|
-
from boilersync.names import
|
|
11
|
+
from boilersync.names import default_project_snake_from_directory_name, snake_to_pretty
|
|
12
12
|
from boilersync.paths import paths
|
|
13
13
|
from boilersync.project_metadata import write_project_metadata
|
|
14
|
-
from boilersync.template_processor import
|
|
14
|
+
from boilersync.template_processor import (
|
|
15
|
+
apply_template_defaults,
|
|
16
|
+
process_template_directory,
|
|
17
|
+
)
|
|
15
18
|
from boilersync.template_sources import (
|
|
16
19
|
TemplateSource,
|
|
17
20
|
resolve_source_from_boilersync,
|
|
@@ -174,6 +177,8 @@ def process_template_directory_excluding_starter(
|
|
|
174
177
|
template_dir: Source template directory
|
|
175
178
|
target_dir: Target directory to process into
|
|
176
179
|
"""
|
|
180
|
+
apply_template_defaults(template_dir)
|
|
181
|
+
|
|
177
182
|
# First, scan the template for all variables (excluding starter files)
|
|
178
183
|
template_variables = scan_template_for_variables_excluding_starter(template_dir)
|
|
179
184
|
|
|
@@ -293,7 +298,7 @@ def _resolve_name_variables(
|
|
|
293
298
|
) -> tuple[dict[str, Any], str, str]:
|
|
294
299
|
variables = dict(collected_variables or {})
|
|
295
300
|
|
|
296
|
-
default_snake_name =
|
|
301
|
+
default_snake_name = default_project_snake_from_directory_name(target_dir.name)
|
|
297
302
|
resolved_name_snake = str(
|
|
298
303
|
variables.get("name_snake")
|
|
299
304
|
or stored_name_snake
|
|
@@ -121,6 +121,90 @@ def list_local_templates() -> list[dict[str, Any]]:
|
|
|
121
121
|
return templates
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def _git_output(repo_dir: Path, *args: str) -> str | None:
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["git", "-C", str(repo_dir), *args],
|
|
128
|
+
check=False,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
)
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if result.returncode != 0:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
output = result.stdout.strip()
|
|
139
|
+
return output or None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def list_template_sources() -> dict[str, Any]:
|
|
143
|
+
template_root_dir = paths.template_root_dir
|
|
144
|
+
sources: list[dict[str, Any]] = []
|
|
145
|
+
|
|
146
|
+
if template_root_dir.exists():
|
|
147
|
+
for org_dir in sorted(template_root_dir.iterdir()):
|
|
148
|
+
if not org_dir.is_dir() or org_dir.name.startswith("."):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
for repo_dir in sorted(org_dir.iterdir()):
|
|
152
|
+
if not repo_dir.is_dir() or repo_dir.name.startswith("."):
|
|
153
|
+
continue
|
|
154
|
+
if not (repo_dir / ".git").exists():
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
remote_url = _git_output(repo_dir, "config", "--get", "remote.origin.url")
|
|
158
|
+
source = {
|
|
159
|
+
"org": org_dir.name,
|
|
160
|
+
"repo": repo_dir.name,
|
|
161
|
+
"path": str(repo_dir),
|
|
162
|
+
"remote_url": remote_url,
|
|
163
|
+
"branch": _git_output(repo_dir, "branch", "--show-current"),
|
|
164
|
+
"commit": _git_output(repo_dir, "rev-parse", "--short", "HEAD"),
|
|
165
|
+
"template_count": len(_iter_repo_template_subdirs(repo_dir)),
|
|
166
|
+
}
|
|
167
|
+
sources.append(source)
|
|
168
|
+
|
|
169
|
+
paths_by_remote: dict[str, list[str]] = {}
|
|
170
|
+
for source in sources:
|
|
171
|
+
remote_url = source.get("remote_url")
|
|
172
|
+
if not remote_url:
|
|
173
|
+
continue
|
|
174
|
+
paths_by_remote.setdefault(str(remote_url), []).append(str(source["path"]))
|
|
175
|
+
|
|
176
|
+
duplicate_remotes = [
|
|
177
|
+
{
|
|
178
|
+
"remote_url": remote_url,
|
|
179
|
+
"paths": sorted(source_paths),
|
|
180
|
+
}
|
|
181
|
+
for remote_url, source_paths in sorted(paths_by_remote.items())
|
|
182
|
+
if len(source_paths) > 1
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"template_root_dir": str(template_root_dir),
|
|
187
|
+
"source_count": len(sources),
|
|
188
|
+
"sources": sources,
|
|
189
|
+
"duplicate_remotes": duplicate_remotes,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _find_existing_source_for_remote(
|
|
194
|
+
remote_url: str,
|
|
195
|
+
*,
|
|
196
|
+
exclude_path: Path | None = None,
|
|
197
|
+
) -> dict[str, Any] | None:
|
|
198
|
+
excluded = exclude_path.resolve() if exclude_path is not None else None
|
|
199
|
+
for source in list_template_sources()["sources"]:
|
|
200
|
+
source_path = Path(str(source["path"])).resolve()
|
|
201
|
+
if excluded is not None and source_path == excluded:
|
|
202
|
+
continue
|
|
203
|
+
if source.get("remote_url") == remote_url:
|
|
204
|
+
return source
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
124
208
|
def _normalize_input_definition(
|
|
125
209
|
name: str,
|
|
126
210
|
raw_definition: Any,
|
|
@@ -284,6 +368,17 @@ def init_templates(
|
|
|
284
368
|
f"Template source directory already exists and is not empty: {target_dir}"
|
|
285
369
|
)
|
|
286
370
|
|
|
371
|
+
duplicate_source = _find_existing_source_for_remote(
|
|
372
|
+
canonical_repo_url,
|
|
373
|
+
exclude_path=target_dir,
|
|
374
|
+
)
|
|
375
|
+
if duplicate_source is not None:
|
|
376
|
+
raise click.ClickException(
|
|
377
|
+
"Template source remote is already initialized at "
|
|
378
|
+
f"{duplicate_source['path']}. Use that source instead of creating "
|
|
379
|
+
f"a duplicate checkout at {target_dir}."
|
|
380
|
+
)
|
|
381
|
+
|
|
287
382
|
click.echo(f"📦 Cloning template source into: {target_dir}")
|
|
288
383
|
try:
|
|
289
384
|
subprocess.run(["git", "clone", canonical_repo_url, str(target_dir)], check=True)
|
|
@@ -342,6 +437,43 @@ def templates_list_cmd(json_output: bool) -> None:
|
|
|
342
437
|
click.echo(f" path: {template['template_dir']}")
|
|
343
438
|
|
|
344
439
|
|
|
440
|
+
@click.command(name="sources")
|
|
441
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
|
|
442
|
+
def templates_sources_cmd(json_output: bool) -> None:
|
|
443
|
+
"""Show local template source checkouts and duplicate remotes."""
|
|
444
|
+
payload = list_template_sources()
|
|
445
|
+
|
|
446
|
+
if json_output:
|
|
447
|
+
click.echo(json.dumps(payload, indent=2))
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
click.echo(f"Template root: {payload['template_root_dir']}")
|
|
451
|
+
sources = payload["sources"]
|
|
452
|
+
if not sources:
|
|
453
|
+
click.echo("No template sources found.")
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
click.echo("")
|
|
457
|
+
for source in sources:
|
|
458
|
+
click.echo(f"- {source['org']}/{source['repo']}")
|
|
459
|
+
click.echo(f" path: {source['path']}")
|
|
460
|
+
click.echo(f" remote: {source['remote_url'] or '(none)'}")
|
|
461
|
+
click.echo(f" branch: {source['branch'] or '(unknown)'}")
|
|
462
|
+
click.echo(f" commit: {source['commit'] or '(unknown)'}")
|
|
463
|
+
click.echo(f" templates: {source['template_count']}")
|
|
464
|
+
|
|
465
|
+
duplicate_remotes = payload["duplicate_remotes"]
|
|
466
|
+
click.echo("")
|
|
467
|
+
if duplicate_remotes:
|
|
468
|
+
click.echo("Duplicate remotes:")
|
|
469
|
+
for duplicate in duplicate_remotes:
|
|
470
|
+
click.echo(f"- {duplicate['remote_url']}")
|
|
471
|
+
for source_path in duplicate["paths"]:
|
|
472
|
+
click.echo(f" - {source_path}")
|
|
473
|
+
else:
|
|
474
|
+
click.echo("No duplicate remotes found.")
|
|
475
|
+
|
|
476
|
+
|
|
345
477
|
@click.command(name="details")
|
|
346
478
|
@click.argument("template_ref")
|
|
347
479
|
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
|
|
@@ -382,4 +514,5 @@ def templates_cmd() -> None:
|
|
|
382
514
|
|
|
383
515
|
templates_cmd.add_command(templates_init_cmd)
|
|
384
516
|
templates_cmd.add_command(templates_list_cmd)
|
|
517
|
+
templates_cmd.add_command(templates_sources_cmd)
|
|
385
518
|
templates_cmd.add_command(templates_details_cmd)
|
|
@@ -4,6 +4,7 @@ from typing import Any, Dict
|
|
|
4
4
|
from boilersync.names import (
|
|
5
5
|
ProjectNames,
|
|
6
6
|
create_project_names,
|
|
7
|
+
default_project_snake_from_directory_name,
|
|
7
8
|
normalize_to_snake,
|
|
8
9
|
snake_to_kebab,
|
|
9
10
|
)
|
|
@@ -31,7 +32,7 @@ class InterpolationContext:
|
|
|
31
32
|
directory: Directory whose name will be used for the project
|
|
32
33
|
"""
|
|
33
34
|
project_name = directory.name
|
|
34
|
-
snake_name =
|
|
35
|
+
snake_name = default_project_snake_from_directory_name(project_name)
|
|
35
36
|
self._names = create_project_names(snake_name)
|
|
36
37
|
|
|
37
38
|
def set_project_names(self, snake_name: str, pretty_name: str) -> None:
|
|
@@ -53,6 +53,15 @@ def normalize_to_snake(name: str) -> str:
|
|
|
53
53
|
return snake
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def default_project_snake_from_directory_name(directory_name: str) -> str:
|
|
57
|
+
"""Infer a project name from a directory name."""
|
|
58
|
+
snake_name = normalize_to_snake(directory_name)
|
|
59
|
+
for suffix in ("_workspace",):
|
|
60
|
+
if snake_name.endswith(suffix) and len(snake_name) > len(suffix):
|
|
61
|
+
return snake_name[: -len(suffix)]
|
|
62
|
+
return snake_name
|
|
63
|
+
|
|
64
|
+
|
|
56
65
|
def snake_to_pascal(snake_name: str) -> str:
|
|
57
66
|
"""Convert snake_case to PascalCase."""
|
|
58
67
|
return "".join(word.capitalize() for word in snake_name.split("_"))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import re
|
|
2
3
|
import shutil
|
|
3
4
|
from pathlib import Path
|
|
@@ -12,7 +13,6 @@ from boilersync.variable_collector import (
|
|
|
12
13
|
extract_variables_from_template_content,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
|
-
|
|
16
16
|
PATH_NAME_VARIABLE_PATTERN = re.compile(r"\b(NAME_[A-Z0-9_]+)\b")
|
|
17
17
|
|
|
18
18
|
|
|
@@ -164,6 +164,52 @@ def process_template_file(file_path: Path, context: Dict[str, Any]) -> None:
|
|
|
164
164
|
pass
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
def render_template_value(value: Any, context: Dict[str, Any]) -> Any:
|
|
168
|
+
"""Render a template metadata value with the current interpolation context."""
|
|
169
|
+
if isinstance(value, str):
|
|
170
|
+
env = create_jinja_environment()
|
|
171
|
+
return env.from_string(value).render(context)
|
|
172
|
+
if isinstance(value, list):
|
|
173
|
+
return [render_template_value(item, context) for item in value]
|
|
174
|
+
if isinstance(value, dict):
|
|
175
|
+
return {
|
|
176
|
+
str(key): render_template_value(child_value, context)
|
|
177
|
+
for key, child_value in value.items()
|
|
178
|
+
}
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def apply_template_defaults(template_dir: Path) -> None:
|
|
183
|
+
"""Apply template.json defaults before missing-variable collection."""
|
|
184
|
+
template_json_path = template_dir / "template.json"
|
|
185
|
+
if not template_json_path.exists():
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
config = json.loads(template_json_path.read_text(encoding="utf-8"))
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
defaults = config.get("defaults", {})
|
|
194
|
+
if not defaults:
|
|
195
|
+
return
|
|
196
|
+
if not isinstance(defaults, dict):
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Template '{template_dir}' has invalid 'defaults' config: expected object"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for key, value in defaults.items():
|
|
202
|
+
key = str(key)
|
|
203
|
+
if interpolation_context.has_variable(key):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
context = interpolation_context.get_context()
|
|
207
|
+
interpolation_context.set_collected_variable(
|
|
208
|
+
key,
|
|
209
|
+
render_template_value(value, context),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
167
213
|
def copy_and_process_template(
|
|
168
214
|
source_dir: Path, target_dir: Path, context: Dict[str, Any]
|
|
169
215
|
) -> None:
|
|
@@ -222,6 +268,8 @@ def process_template_directory(
|
|
|
222
268
|
template_dir: Source template directory
|
|
223
269
|
target_dir: Target directory to process into
|
|
224
270
|
"""
|
|
271
|
+
apply_template_defaults(template_dir)
|
|
272
|
+
|
|
225
273
|
# First, scan the template for all variables
|
|
226
274
|
template_variables = scan_template_for_variables(template_dir)
|
|
227
275
|
|
|
@@ -8,6 +8,12 @@ from urllib.parse import urlparse
|
|
|
8
8
|
from boilersync.paths import paths
|
|
9
9
|
|
|
10
10
|
SOURCE_RESOLUTION = "source_ref"
|
|
11
|
+
REPO_RENAMES = {
|
|
12
|
+
("openbase-community", "openbase-boilerplate"): (
|
|
13
|
+
"openbase-community",
|
|
14
|
+
"templates",
|
|
15
|
+
),
|
|
16
|
+
}
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
@dataclass(frozen=True)
|
|
@@ -39,6 +45,10 @@ def _normalize_subdir(subdir: str) -> str:
|
|
|
39
45
|
return cleaned
|
|
40
46
|
|
|
41
47
|
|
|
48
|
+
def _canonical_repo_identity(org: str, repo: str) -> tuple[str, str]:
|
|
49
|
+
return REPO_RENAMES.get((org, repo), (org, repo))
|
|
50
|
+
|
|
51
|
+
|
|
42
52
|
def parse_repo_locator(repo_locator: str) -> tuple[str, str, str]:
|
|
43
53
|
locator = repo_locator.strip()
|
|
44
54
|
if not locator:
|
|
@@ -65,8 +75,7 @@ def parse_repo_locator(repo_locator: str) -> tuple[str, str, str]:
|
|
|
65
75
|
raise ValueError(
|
|
66
76
|
"Template source repo URL must be in the format https://github.com/org/repo(.git)."
|
|
67
77
|
)
|
|
68
|
-
org = parts[0]
|
|
69
|
-
repo = parts[1]
|
|
78
|
+
org, repo = _canonical_repo_identity(parts[0], parts[1])
|
|
70
79
|
clone_url = f"https://github.com/{org}/{repo}.git"
|
|
71
80
|
return org, repo, clone_url
|
|
72
81
|
|
|
@@ -76,6 +85,7 @@ def parse_repo_locator(repo_locator: str) -> tuple[str, str, str]:
|
|
|
76
85
|
repo = match.group("repo")
|
|
77
86
|
if repo.endswith(".git"):
|
|
78
87
|
repo = repo[: -len(".git")]
|
|
88
|
+
org, repo = _canonical_repo_identity(org, repo)
|
|
79
89
|
clone_url = f"https://github.com/{org}/{repo}.git"
|
|
80
90
|
return org, repo, clone_url
|
|
81
91
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import subprocess
|
|
1
2
|
from typing import Any, Set
|
|
2
3
|
|
|
3
4
|
import click
|
|
@@ -87,6 +88,8 @@ def collect_missing_variables(template_variables: Set[str], no_input: bool) -> N
|
|
|
87
88
|
template_variables: Variables found in template content
|
|
88
89
|
no_input: If True, raise an error for missing variables instead of prompting
|
|
89
90
|
"""
|
|
91
|
+
apply_automatic_variable_defaults(template_variables)
|
|
92
|
+
|
|
90
93
|
missing_variables = []
|
|
91
94
|
|
|
92
95
|
for var in template_variables:
|
|
@@ -126,3 +129,25 @@ def collect_missing_variables(template_variables: Set[str], no_input: bool) -> N
|
|
|
126
129
|
|
|
127
130
|
click.echo("=" * 50)
|
|
128
131
|
click.echo("✅ All variables collected!\n")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def apply_automatic_variable_defaults(template_variables: Set[str]) -> None:
|
|
135
|
+
"""Populate defaults that can be discovered from the local environment."""
|
|
136
|
+
if "github_user" not in template_variables:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if interpolation_context.has_variable("github_user"):
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
145
|
+
check=False,
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
148
|
+
)
|
|
149
|
+
except OSError:
|
|
150
|
+
return
|
|
151
|
+
github_user = result.stdout.strip()
|
|
152
|
+
if result.returncode == 0 and github_user:
|
|
153
|
+
interpolation_context.set_collected_variable("github_user", github_user)
|
|
@@ -67,6 +67,9 @@ Use for first-time project generation.
|
|
|
67
67
|
|
|
68
68
|
- Requires an empty target directory
|
|
69
69
|
- Supports `--var KEY=VALUE` for template inputs, including `name_snake` / `name_pretty`, and non-interactive mode (`--non-interactive`, alias `--no-input`)
|
|
70
|
+
- Uses `template.json` `defaults` before prompting, so well-defaulted templates can be bootstrapped with one command after creating and entering the target directory
|
|
71
|
+
- Infers `name_snake` from the target directory and strips a trailing `-workspace` / `_workspace` suffix
|
|
72
|
+
- Attempts to infer `github_user` from `gh api user --jq .login` when a template references `github_user`
|
|
70
73
|
- Resolves source-qualified refs
|
|
71
74
|
- Can run configured hooks and initialize child templates
|
|
72
75
|
|
|
@@ -57,7 +57,10 @@ This file is the source of truth for template provenance and saved interpolation
|
|
|
57
57
|
|
|
58
58
|
- Type: `object`
|
|
59
59
|
- Purpose: saved interpolation variables for repeatable `pull` and `push` flows.
|
|
60
|
-
- Notes:
|
|
60
|
+
- Notes:
|
|
61
|
+
- Keys/values are template-specific.
|
|
62
|
+
- Values populated from `template.json` `defaults` are stored here after init or pull.
|
|
63
|
+
- Explicit values such as `--var KEY=VALUE` take precedence over template defaults.
|
|
61
64
|
|
|
62
65
|
### `children` (optional)
|
|
63
66
|
|
|
@@ -8,6 +8,7 @@ BoilerSync templates must include a `template.json` file at the template root.
|
|
|
8
8
|
|
|
9
9
|
- Inheritance: `extends` (or legacy `parent`)
|
|
10
10
|
- Input metadata: `variables`, `options`
|
|
11
|
+
- Template input defaults: `defaults`
|
|
11
12
|
- Runtime behavior: `children`, `hooks`, `github`, `skip_git`
|
|
12
13
|
|
|
13
14
|
Unknown keys are ignored by current CLI commands.
|
|
@@ -37,6 +38,12 @@ Unknown keys are ignored by current CLI commands.
|
|
|
37
38
|
"default": true
|
|
38
39
|
}
|
|
39
40
|
},
|
|
41
|
+
"defaults": {
|
|
42
|
+
"api_package_name": "$${name_snake}_api",
|
|
43
|
+
"web_package_name": "$${name_kebab}-web",
|
|
44
|
+
"api_client_export_name": "$${name_camel}",
|
|
45
|
+
"with_frontend": true
|
|
46
|
+
},
|
|
40
47
|
"children": [
|
|
41
48
|
{
|
|
42
49
|
"template": "acme/templates#python/worker",
|
|
@@ -105,6 +112,8 @@ Notes:
|
|
|
105
112
|
|
|
106
113
|
- `variables` are also auto-discovered from `$${...}` usage in template files and `NAME_*` path placeholders.
|
|
107
114
|
- Built-in naming variables such as `name_snake` and `name_pretty` are exposed through the normal variable/input flow.
|
|
115
|
+
- If neither `--var name_snake=...` nor saved project metadata provides a name, BoilerSync infers `name_snake` from the target directory name.
|
|
116
|
+
- A trailing `-workspace` / `_workspace` suffix is stripped during default project-name inference. For example, `woo-score-workspace` defaults to `name_snake=woo_score`.
|
|
108
117
|
|
|
109
118
|
Relative `extends` values are resolved within the same template source repository. Example:
|
|
110
119
|
|
|
@@ -116,6 +125,46 @@ Relative `extends` values are resolved within the same template source repositor
|
|
|
116
125
|
|
|
117
126
|
When used from `acme/templates#cli`, this resolves to `acme/templates#pip-package`.
|
|
118
127
|
|
|
128
|
+
### `defaults`
|
|
129
|
+
|
|
130
|
+
- Type: object keyed by interpolation variable name.
|
|
131
|
+
- Purpose: Provide template-owned defaults before missing-variable collection.
|
|
132
|
+
- Values: Scalars, arrays, and objects. String values support `$${...}` interpolation.
|
|
133
|
+
- Precedence: Existing values win. BoilerSync does not overwrite values from explicit `--var` flags, saved `.boilersync` project metadata, or defaults already applied by an earlier template in the inheritance chain.
|
|
134
|
+
- Persistence: Applied defaults are saved in generated project `.boilersync` metadata under `variables`.
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"defaults": {
|
|
141
|
+
"api_package_name": "$${name_snake}_api",
|
|
142
|
+
"django_app_name": "$${name_snake}",
|
|
143
|
+
"web_package_name": "$${name_kebab}-web",
|
|
144
|
+
"api_client_package_name": "$${name_kebab}-api-client",
|
|
145
|
+
"api_client_export_name": "$${name_camel}",
|
|
146
|
+
"cdn_base_url": "https://cdn.openbase.app/$${name_kebab}/",
|
|
147
|
+
"with_frontend": true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Defaults are the main mechanism for making a template usable with one non-interactive command:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
mkdir my-app-workspace
|
|
156
|
+
cd my-app-workspace
|
|
157
|
+
boilersync init acme/templates#django-react-workspace --non-interactive
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Automatic Environment Defaults
|
|
161
|
+
|
|
162
|
+
BoilerSync can populate a small set of variables from the local environment before prompting:
|
|
163
|
+
|
|
164
|
+
- `github_user`: when a template references this variable and no value is already set, BoilerSync runs `gh api user --jq .login`.
|
|
165
|
+
|
|
166
|
+
If automatic lookup fails, the variable remains missing. Interactive init prompts for it, while `--non-interactive` fails and asks for an explicit `--var github_user=...`.
|
|
167
|
+
|
|
119
168
|
### `children`
|
|
120
169
|
|
|
121
170
|
- Type: list of objects.
|
|
@@ -188,6 +188,53 @@ class TestInitRuntimeFeatures(unittest.TestCase):
|
|
|
188
188
|
self.assertNotIn("source", root_boilersync_data)
|
|
189
189
|
self.assertEqual(root_boilersync_data["children"], ["demo-workspace-child"])
|
|
190
190
|
|
|
191
|
+
def test_template_defaults_are_applied_before_variable_collection(self) -> None:
|
|
192
|
+
target_dir = self.root / "defaulted-workspace"
|
|
193
|
+
target_dir.mkdir()
|
|
194
|
+
|
|
195
|
+
_write_template(
|
|
196
|
+
self.template_root_dir,
|
|
197
|
+
org=self.org,
|
|
198
|
+
repo=self.repo,
|
|
199
|
+
subdir="defaulted-template",
|
|
200
|
+
files={
|
|
201
|
+
"README.md.boilersync": (
|
|
202
|
+
"$${api_package_name} "
|
|
203
|
+
"$${web_package_name} "
|
|
204
|
+
"$${api_client_export_name} "
|
|
205
|
+
"$${with_frontend}\n"
|
|
206
|
+
)
|
|
207
|
+
},
|
|
208
|
+
config={
|
|
209
|
+
"defaults": {
|
|
210
|
+
"api_package_name": "$${name_snake}_api",
|
|
211
|
+
"web_package_name": "$${name_kebab}-web",
|
|
212
|
+
"api_client_export_name": "$${name_camel}",
|
|
213
|
+
"with_frontend": True,
|
|
214
|
+
},
|
|
215
|
+
"skip_git": True,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
init(
|
|
220
|
+
self._template_ref("defaulted-template"),
|
|
221
|
+
target_dir=target_dir,
|
|
222
|
+
no_input=True,
|
|
223
|
+
template_variables={"name_snake": "demo_workspace"},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
self.assertEqual(
|
|
227
|
+
(target_dir / "README.md").read_text(encoding="utf-8"),
|
|
228
|
+
"demo_workspace_api demo-workspace-web demoWorkspace True\n",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
boilersync_data = json.loads((target_dir / ".boilersync").read_text())
|
|
232
|
+
self.assertEqual(
|
|
233
|
+
boilersync_data["variables"]["api_package_name"],
|
|
234
|
+
"demo_workspace_api",
|
|
235
|
+
)
|
|
236
|
+
self.assertTrue(boilersync_data["variables"]["with_frontend"])
|
|
237
|
+
|
|
191
238
|
def test_init_with_local_child_template_name(self) -> None:
|
|
192
239
|
target_dir = self.root / "workspace-local-child"
|
|
193
240
|
target_dir.mkdir()
|
|
@@ -11,6 +11,7 @@ from git import Repo
|
|
|
11
11
|
|
|
12
12
|
from boilersync.commands.init import init
|
|
13
13
|
from boilersync.interpolation_context import interpolation_context
|
|
14
|
+
from boilersync.names import default_project_snake_from_directory_name
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _commit_template_repo(repo_dir: Path) -> None:
|
|
@@ -75,6 +76,20 @@ class TestInterpolationContextNameVariants(unittest.TestCase):
|
|
|
75
76
|
self.assertEqual(context["api_package_name_kebab"], "custom-kebab")
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
class TestDirectoryNameDefaults(unittest.TestCase):
|
|
80
|
+
def test_default_project_name_strips_workspace_suffix(self) -> None:
|
|
81
|
+
self.assertEqual(
|
|
82
|
+
default_project_snake_from_directory_name("woo-score-workspace"),
|
|
83
|
+
"woo_score",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def test_default_project_name_preserves_non_workspace_name(self) -> None:
|
|
87
|
+
self.assertEqual(
|
|
88
|
+
default_project_snake_from_directory_name("woo-score"),
|
|
89
|
+
"woo_score",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
78
93
|
class TestDerivedNameVariantsInInit(unittest.TestCase):
|
|
79
94
|
def setUp(self) -> None:
|
|
80
95
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
@@ -25,6 +25,17 @@ class TestTemplateSources(unittest.TestCase):
|
|
|
25
25
|
self.assertEqual(repo, "template-kit")
|
|
26
26
|
self.assertEqual(clone_url, "https://github.com/acme/template-kit.git")
|
|
27
27
|
|
|
28
|
+
def test_parse_repo_locator_uses_known_repo_renames(self):
|
|
29
|
+
org, repo, clone_url = parse_repo_locator(
|
|
30
|
+
"openbase-community/openbase-boilerplate"
|
|
31
|
+
)
|
|
32
|
+
self.assertEqual(org, "openbase-community")
|
|
33
|
+
self.assertEqual(repo, "templates")
|
|
34
|
+
self.assertEqual(
|
|
35
|
+
clone_url,
|
|
36
|
+
"https://github.com/openbase-community/templates.git",
|
|
37
|
+
)
|
|
38
|
+
|
|
28
39
|
def test_parse_repo_locator_rejects_non_github_host(self):
|
|
29
40
|
with self.assertRaises(ValueError):
|
|
30
41
|
parse_repo_locator("https://gitlab.com/acme/template-kit.git")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import subprocess
|
|
3
4
|
import tempfile
|
|
4
5
|
import unittest
|
|
5
6
|
from pathlib import Path
|
|
@@ -7,9 +8,13 @@ from unittest.mock import patch
|
|
|
7
8
|
|
|
8
9
|
from click.testing import CliRunner
|
|
9
10
|
|
|
10
|
-
from boilersync.commands.init import init_cmd
|
|
11
|
-
from boilersync.commands.
|
|
12
|
-
|
|
11
|
+
from boilersync.commands.init import init_cmd, parse_key_value_options
|
|
12
|
+
from boilersync.commands.templates import (
|
|
13
|
+
get_template_details,
|
|
14
|
+
list_local_templates,
|
|
15
|
+
list_template_sources,
|
|
16
|
+
templates_cmd,
|
|
17
|
+
)
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
def _write_template(
|
|
@@ -118,6 +123,97 @@ class TestTemplatesCommands(unittest.TestCase):
|
|
|
118
123
|
self.assertIn("acme/platform#python/service", refs)
|
|
119
124
|
self.assertNotIn("acme/platform#python/service/src", refs)
|
|
120
125
|
|
|
126
|
+
def test_list_template_sources_reports_duplicate_remotes(self) -> None:
|
|
127
|
+
for repo in ("old-name", "new-name"):
|
|
128
|
+
repo_dir = self.template_root_dir / "acme" / repo
|
|
129
|
+
repo_dir.mkdir(parents=True)
|
|
130
|
+
subprocess.run(
|
|
131
|
+
["git", "init"],
|
|
132
|
+
cwd=repo_dir,
|
|
133
|
+
check=True,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
)
|
|
137
|
+
subprocess.run(
|
|
138
|
+
[
|
|
139
|
+
"git",
|
|
140
|
+
"remote",
|
|
141
|
+
"add",
|
|
142
|
+
"origin",
|
|
143
|
+
"https://github.com/acme/templates.git",
|
|
144
|
+
],
|
|
145
|
+
cwd=repo_dir,
|
|
146
|
+
check=True,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
)
|
|
150
|
+
template_dir = repo_dir / "service"
|
|
151
|
+
template_dir.mkdir()
|
|
152
|
+
(template_dir / "template.json").write_text("{}", encoding="utf-8")
|
|
153
|
+
|
|
154
|
+
payload = list_template_sources()
|
|
155
|
+
|
|
156
|
+
self.assertEqual(payload["source_count"], 2)
|
|
157
|
+
self.assertEqual(
|
|
158
|
+
payload["duplicate_remotes"],
|
|
159
|
+
[
|
|
160
|
+
{
|
|
161
|
+
"remote_url": "https://github.com/acme/templates.git",
|
|
162
|
+
"paths": [
|
|
163
|
+
str(self.template_root_dir / "acme" / "new-name"),
|
|
164
|
+
str(self.template_root_dir / "acme" / "old-name"),
|
|
165
|
+
],
|
|
166
|
+
}
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def test_templates_sources_cmd_outputs_json(self) -> None:
|
|
171
|
+
_write_template(
|
|
172
|
+
self.template_root_dir,
|
|
173
|
+
org="acme",
|
|
174
|
+
repo="platform",
|
|
175
|
+
subdir="service",
|
|
176
|
+
files={"README.md.boilersync": "hello"},
|
|
177
|
+
config={},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
result = CliRunner().invoke(templates_cmd, ["sources", "--json"])
|
|
181
|
+
|
|
182
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
183
|
+
payload = json.loads(result.output)
|
|
184
|
+
self.assertEqual(payload["template_root_dir"], str(self.template_root_dir))
|
|
185
|
+
self.assertEqual(payload["source_count"], 1)
|
|
186
|
+
|
|
187
|
+
def test_templates_init_rejects_duplicate_remote(self) -> None:
|
|
188
|
+
existing_dir = self.template_root_dir / "acme" / "current-templates"
|
|
189
|
+
existing_dir.mkdir(parents=True)
|
|
190
|
+
subprocess.run(
|
|
191
|
+
["git", "init"],
|
|
192
|
+
cwd=existing_dir,
|
|
193
|
+
check=True,
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
)
|
|
197
|
+
subprocess.run(
|
|
198
|
+
[
|
|
199
|
+
"git",
|
|
200
|
+
"remote",
|
|
201
|
+
"add",
|
|
202
|
+
"origin",
|
|
203
|
+
"https://github.com/acme/old-templates.git",
|
|
204
|
+
],
|
|
205
|
+
cwd=existing_dir,
|
|
206
|
+
check=True,
|
|
207
|
+
capture_output=True,
|
|
208
|
+
text=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
result = CliRunner().invoke(templates_cmd, ["init", "acme/old-templates"])
|
|
212
|
+
|
|
213
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
214
|
+
self.assertIn("Template source remote is already initialized", result.output)
|
|
215
|
+
self.assertIn(str(existing_dir), result.output)
|
|
216
|
+
|
|
121
217
|
def test_get_template_details_returns_variables_and_options(self) -> None:
|
|
122
218
|
_write_template(
|
|
123
219
|
self.template_root_dir,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|