rhiza 0.9.1__py3-none-any.whl → 0.10.1__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.
- rhiza/bundle_resolver.py +78 -0
- rhiza/commands/init.py +35 -8
- rhiza/commands/materialize.py +71 -12
- rhiza/commands/validate.py +87 -3
- rhiza/models.py +174 -3
- {rhiza-0.9.1.dist-info → rhiza-0.10.1.dist-info}/METADATA +2 -2
- {rhiza-0.9.1.dist-info → rhiza-0.10.1.dist-info}/RECORD +10 -9
- {rhiza-0.9.1.dist-info → rhiza-0.10.1.dist-info}/WHEEL +0 -0
- {rhiza-0.9.1.dist-info → rhiza-0.10.1.dist-info}/entry_points.txt +0 -0
- {rhiza-0.9.1.dist-info → rhiza-0.10.1.dist-info}/licenses/LICENSE +0 -0
rhiza/bundle_resolver.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Bundle resolution logic for template configuration.
|
|
2
|
+
|
|
3
|
+
This module provides functions to load and resolve bundle configurations
|
|
4
|
+
from the template repository's template_bundles.yml file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rhiza.models import RhizaBundles, RhizaTemplate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_bundles_from_clone(tmp_dir: Path) -> RhizaBundles | None:
|
|
13
|
+
"""Load .rhiza/template_bundles.yml from cloned template repo.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
tmp_dir: Path to the cloned template repository.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
RhizaBundles if template_bundles.yml exists, None otherwise.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
yaml.YAMLError: If template_bundles.yml is malformed.
|
|
23
|
+
ValueError: If template_bundles.yml is invalid.
|
|
24
|
+
"""
|
|
25
|
+
bundles_file = tmp_dir / ".rhiza" / "template_bundles.yml"
|
|
26
|
+
if not bundles_file.exists():
|
|
27
|
+
return None
|
|
28
|
+
return RhizaBundles.from_yaml(bundles_file)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_include_paths(
|
|
32
|
+
template: RhizaTemplate,
|
|
33
|
+
bundles_config: RhizaBundles | None,
|
|
34
|
+
) -> list[str]:
|
|
35
|
+
"""Resolve template configuration to file paths.
|
|
36
|
+
|
|
37
|
+
Supports:
|
|
38
|
+
- Template-based mode (templates field)
|
|
39
|
+
- Path-based mode (include field)
|
|
40
|
+
- Hybrid mode (both templates and include)
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
template: The template configuration.
|
|
44
|
+
bundles_config: The loaded bundles configuration, or None if not available.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of file paths to materialize.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If configuration is invalid or bundles.yml is missing.
|
|
51
|
+
"""
|
|
52
|
+
paths = []
|
|
53
|
+
|
|
54
|
+
# Resolve templates to paths if specified
|
|
55
|
+
if template.templates:
|
|
56
|
+
if not bundles_config:
|
|
57
|
+
msg = "Template uses templates but template_bundles.yml not found in template repository"
|
|
58
|
+
raise ValueError(msg)
|
|
59
|
+
paths.extend(bundles_config.resolve_to_paths(template.templates))
|
|
60
|
+
|
|
61
|
+
# Add include paths if specified
|
|
62
|
+
if template.include:
|
|
63
|
+
paths.extend(template.include)
|
|
64
|
+
|
|
65
|
+
# At least one must be specified
|
|
66
|
+
if not paths:
|
|
67
|
+
msg = "Template configuration must specify either 'templates' or 'include'"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
|
|
70
|
+
# Deduplicate while preserving order
|
|
71
|
+
seen = set()
|
|
72
|
+
deduplicated = []
|
|
73
|
+
for path in paths:
|
|
74
|
+
if path not in seen:
|
|
75
|
+
deduplicated.append(path)
|
|
76
|
+
seen.add(path)
|
|
77
|
+
|
|
78
|
+
return deduplicated
|
rhiza/commands/init.py
CHANGED
|
@@ -84,8 +84,24 @@ def _prompt_git_host() -> str:
|
|
|
84
84
|
return str(git_host)
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _get_default_templates_for_host(git_host: str) -> list[str]:
|
|
88
|
+
"""Get default templates based on git hosting platform.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
git_host: Git hosting platform.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of template names.
|
|
95
|
+
"""
|
|
96
|
+
common = ["core", "tests", "docs"]
|
|
97
|
+
if git_host == "gitlab":
|
|
98
|
+
return [*common, "gitlab"]
|
|
99
|
+
else:
|
|
100
|
+
return [*common, "github"]
|
|
101
|
+
|
|
102
|
+
|
|
87
103
|
def _get_include_paths_for_host(git_host: str) -> list[str]:
|
|
88
|
-
"""Get include paths based on git hosting platform.
|
|
104
|
+
"""Get include paths based on git hosting platform (legacy, path-based).
|
|
89
105
|
|
|
90
106
|
Args:
|
|
91
107
|
git_host: Git hosting platform.
|
|
@@ -129,6 +145,7 @@ def _create_template_file(
|
|
|
129
145
|
git_host: str,
|
|
130
146
|
template_repository: str | None = None,
|
|
131
147
|
template_branch: str | None = None,
|
|
148
|
+
use_templates: bool = True,
|
|
132
149
|
) -> None:
|
|
133
150
|
"""Create default template.yml file.
|
|
134
151
|
|
|
@@ -137,6 +154,7 @@ def _create_template_file(
|
|
|
137
154
|
git_host: Git hosting platform.
|
|
138
155
|
template_repository: Custom template repository (format: owner/repo).
|
|
139
156
|
template_branch: Custom template branch.
|
|
157
|
+
use_templates: Use template-based configuration if True, path-based if False.
|
|
140
158
|
"""
|
|
141
159
|
rhiza_dir = target / ".rhiza"
|
|
142
160
|
template_file = rhiza_dir / "template.yml"
|
|
@@ -147,8 +165,6 @@ def _create_template_file(
|
|
|
147
165
|
logger.info("Creating default .rhiza/template.yml")
|
|
148
166
|
logger.debug("Using default template configuration")
|
|
149
167
|
|
|
150
|
-
include_paths = _get_include_paths_for_host(git_host)
|
|
151
|
-
|
|
152
168
|
# Use custom template repository/branch if provided, otherwise use defaults
|
|
153
169
|
repo = template_repository or "jebel-quant/rhiza"
|
|
154
170
|
branch = template_branch or "main"
|
|
@@ -159,11 +175,22 @@ def _create_template_file(
|
|
|
159
175
|
if template_branch:
|
|
160
176
|
logger.info(f"Using custom template branch: {branch}")
|
|
161
177
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
if use_templates:
|
|
179
|
+
templates = _get_default_templates_for_host(git_host)
|
|
180
|
+
logger.info(f"Using template-based configuration with templates: {', '.join(templates)}")
|
|
181
|
+
default_template = RhizaTemplate(
|
|
182
|
+
template_repository=repo,
|
|
183
|
+
template_branch=branch,
|
|
184
|
+
templates=templates,
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
include_paths = _get_include_paths_for_host(git_host)
|
|
188
|
+
logger.info("Using path-based configuration")
|
|
189
|
+
default_template = RhizaTemplate(
|
|
190
|
+
template_repository=repo,
|
|
191
|
+
template_branch=branch,
|
|
192
|
+
include=include_paths,
|
|
193
|
+
)
|
|
167
194
|
|
|
168
195
|
logger.debug(f"Writing default template to: {template_file}")
|
|
169
196
|
default_template.to_yaml(template_file)
|
rhiza/commands/materialize.py
CHANGED
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
|
|
16
16
|
from loguru import logger
|
|
17
17
|
|
|
18
|
+
from rhiza.bundle_resolver import load_bundles_from_clone, resolve_include_paths
|
|
18
19
|
from rhiza.commands.validate import validate
|
|
19
20
|
from rhiza.models import RhizaTemplate
|
|
20
21
|
from rhiza.subprocess_utils import get_git_executable
|
|
@@ -117,19 +118,28 @@ def _validate_and_load_template(target: Path, branch: str) -> tuple[RhizaTemplat
|
|
|
117
118
|
logger.error("template-repository is not configured in template.yml")
|
|
118
119
|
raise RuntimeError("template-repository is required") # noqa: TRY003
|
|
119
120
|
rhiza_branch = template.template_branch or branch
|
|
120
|
-
include_paths = template.include
|
|
121
121
|
excluded_paths = template.exclude
|
|
122
122
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
logger.error("Add at least one path to the 'include' list in template.yml")
|
|
127
|
-
raise RuntimeError("No include paths found in template.yml") # noqa: TRY003
|
|
123
|
+
# Note: We'll resolve templates to paths after cloning the template repo,
|
|
124
|
+
# since we need access to template_bundles.yml from the template
|
|
125
|
+
include_paths = template.include
|
|
128
126
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
logger.
|
|
127
|
+
# Validate that we have either templates or include paths
|
|
128
|
+
if not template.templates and not include_paths:
|
|
129
|
+
logger.error("No templates or include paths found in template.yml")
|
|
130
|
+
logger.error("Add either 'templates' or 'include' list in template.yml")
|
|
131
|
+
raise RuntimeError("No templates or include paths found in template.yml") # noqa: TRY003
|
|
132
|
+
|
|
133
|
+
# Log what we'll be using
|
|
134
|
+
if template.templates:
|
|
135
|
+
logger.info("Templates:")
|
|
136
|
+
for t in template.templates:
|
|
137
|
+
logger.info(f" - {t}")
|
|
138
|
+
|
|
139
|
+
if include_paths:
|
|
140
|
+
logger.info("Include paths:")
|
|
141
|
+
for p in include_paths:
|
|
142
|
+
logger.info(f" - {p}")
|
|
133
143
|
|
|
134
144
|
if excluded_paths:
|
|
135
145
|
logger.info("Exclude paths:")
|
|
@@ -165,6 +175,37 @@ def _construct_git_url(rhiza_repo: str, rhiza_host: str) -> str:
|
|
|
165
175
|
return git_url
|
|
166
176
|
|
|
167
177
|
|
|
178
|
+
def _update_sparse_checkout(
|
|
179
|
+
tmp_dir: Path,
|
|
180
|
+
include_paths: list[str],
|
|
181
|
+
git_executable: str,
|
|
182
|
+
git_env: dict[str, str],
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Update sparse checkout paths in an already-cloned repository.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
tmp_dir: Temporary directory with cloned repository.
|
|
188
|
+
include_paths: Paths to include in sparse checkout.
|
|
189
|
+
git_executable: Path to git executable.
|
|
190
|
+
git_env: Environment variables for git commands.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
logger.debug(f"Updating sparse checkout paths: {include_paths}")
|
|
194
|
+
subprocess.run( # nosec B603
|
|
195
|
+
[git_executable, "sparse-checkout", "set", "--skip-checks", *include_paths],
|
|
196
|
+
cwd=tmp_dir,
|
|
197
|
+
check=True,
|
|
198
|
+
capture_output=True,
|
|
199
|
+
text=True,
|
|
200
|
+
env=git_env,
|
|
201
|
+
)
|
|
202
|
+
logger.debug("Sparse checkout paths updated")
|
|
203
|
+
except subprocess.CalledProcessError as e:
|
|
204
|
+
logger.error("Failed to update sparse checkout paths")
|
|
205
|
+
_log_git_stderr_errors(e.stderr)
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
|
|
208
|
+
|
|
168
209
|
def _clone_template_repository(
|
|
169
210
|
tmp_dir: Path,
|
|
170
211
|
git_url: str,
|
|
@@ -179,7 +220,7 @@ def _clone_template_repository(
|
|
|
179
220
|
tmp_dir: Temporary directory for cloning.
|
|
180
221
|
git_url: Git repository URL.
|
|
181
222
|
rhiza_branch: Branch to clone.
|
|
182
|
-
include_paths:
|
|
223
|
+
include_paths: Initial paths to include in sparse checkout.
|
|
183
224
|
git_executable: Path to git executable.
|
|
184
225
|
git_env: Environment variables for git commands.
|
|
185
226
|
"""
|
|
@@ -504,7 +545,25 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
504
545
|
logger.debug(f"Temporary directory: {tmp_dir}")
|
|
505
546
|
|
|
506
547
|
try:
|
|
507
|
-
|
|
548
|
+
# Clone with initial minimal checkout to load template_bundles.yml if needed
|
|
549
|
+
initial_paths = [".rhiza"] if template.templates else include_paths
|
|
550
|
+
_clone_template_repository(tmp_dir, git_url, rhiza_branch, initial_paths, git_executable, git_env)
|
|
551
|
+
|
|
552
|
+
# Load template_bundles.yml and resolve templates to paths if using template mode
|
|
553
|
+
if template.templates:
|
|
554
|
+
logger.info("Resolving templates to file paths...")
|
|
555
|
+
try:
|
|
556
|
+
bundles_config = load_bundles_from_clone(tmp_dir)
|
|
557
|
+
resolved_paths = resolve_include_paths(template, bundles_config)
|
|
558
|
+
logger.info(f"Resolved {len(template.templates)} template(s) to {len(resolved_paths)} path(s)")
|
|
559
|
+
logger.debug(f"Resolved paths: {resolved_paths}")
|
|
560
|
+
# Update sparse checkout with resolved paths
|
|
561
|
+
_update_sparse_checkout(tmp_dir, resolved_paths, git_executable, git_env)
|
|
562
|
+
include_paths = resolved_paths
|
|
563
|
+
except ValueError as e:
|
|
564
|
+
logger.error(f"Failed to resolve templates: {e}")
|
|
565
|
+
sys.exit(1)
|
|
566
|
+
|
|
508
567
|
materialized_files = _copy_files_to_target(tmp_dir, target, include_paths, excluded_paths, force)
|
|
509
568
|
finally:
|
|
510
569
|
logger.debug(f"Cleaning up temporary directory: {tmp_dir}")
|
rhiza/commands/validate.py
CHANGED
|
@@ -126,6 +126,78 @@ def _parse_yaml_file(template_file: Path) -> tuple[bool, dict[str, Any] | None]:
|
|
|
126
126
|
return True, config
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def _validate_configuration_mode(config: dict[str, Any]) -> bool:
|
|
130
|
+
"""Validate that at least one of templates or include is specified.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
config: Configuration dictionary.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if configuration mode is valid, False otherwise.
|
|
137
|
+
"""
|
|
138
|
+
logger.debug("Validating configuration mode")
|
|
139
|
+
has_templates = "templates" in config and config["templates"]
|
|
140
|
+
has_include = "include" in config and config["include"]
|
|
141
|
+
|
|
142
|
+
# Error if old "bundles" field is used
|
|
143
|
+
if "bundles" in config:
|
|
144
|
+
logger.error("Field 'bundles' has been renamed to 'templates'")
|
|
145
|
+
logger.error("Update your .rhiza/template.yml:")
|
|
146
|
+
logger.error(" bundles: [...] → templates: [...]")
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# Require at least one of templates or include
|
|
150
|
+
if not has_templates and not has_include:
|
|
151
|
+
logger.error("Must specify at least one of 'templates' or 'include' in template.yml")
|
|
152
|
+
logger.error("Options:")
|
|
153
|
+
logger.error(" • Template-based: templates: [core, tests, github]")
|
|
154
|
+
logger.error(" • Path-based: include: [.rhiza, .github, ...]")
|
|
155
|
+
logger.error(" • Hybrid: specify both templates and include")
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
# Log what mode is being used
|
|
159
|
+
if has_templates and has_include:
|
|
160
|
+
logger.success("Using hybrid mode (templates + include)")
|
|
161
|
+
elif has_templates:
|
|
162
|
+
logger.success("Using template-based mode")
|
|
163
|
+
else:
|
|
164
|
+
logger.success("Using path-based mode")
|
|
165
|
+
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_templates(config: dict[str, Any]) -> bool:
|
|
170
|
+
"""Validate templates field if present.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
config: Configuration dictionary.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if templates field is valid, False otherwise.
|
|
177
|
+
"""
|
|
178
|
+
logger.debug("Validating templates field")
|
|
179
|
+
if "templates" not in config:
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
templates = config["templates"]
|
|
183
|
+
if not isinstance(templates, list):
|
|
184
|
+
logger.error(f"templates must be a list, got {type(templates).__name__}")
|
|
185
|
+
logger.error("Example: templates: [core, tests, github]")
|
|
186
|
+
return False
|
|
187
|
+
elif len(templates) == 0:
|
|
188
|
+
logger.error("templates list cannot be empty")
|
|
189
|
+
logger.error("Add at least one template to materialize")
|
|
190
|
+
return False
|
|
191
|
+
else:
|
|
192
|
+
logger.success(f"templates list has {len(templates)} template(s)")
|
|
193
|
+
for template in templates:
|
|
194
|
+
if not isinstance(template, str):
|
|
195
|
+
logger.warning(f"template name should be a string, got {type(template).__name__}: {template}")
|
|
196
|
+
else:
|
|
197
|
+
logger.info(f" - {template}")
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
129
201
|
def _validate_required_fields(config: dict[str, Any]) -> bool:
|
|
130
202
|
"""Validate required fields exist and have correct types.
|
|
131
203
|
|
|
@@ -136,9 +208,10 @@ def _validate_required_fields(config: dict[str, Any]) -> bool:
|
|
|
136
208
|
True if all validations pass, False otherwise.
|
|
137
209
|
"""
|
|
138
210
|
logger.debug("Validating required fields")
|
|
211
|
+
# template-repository is required
|
|
212
|
+
# include or bundles is required (validated separately)
|
|
139
213
|
required_fields = {
|
|
140
214
|
"template-repository": str,
|
|
141
|
-
"include": list,
|
|
142
215
|
}
|
|
143
216
|
|
|
144
217
|
validation_passed = True
|
|
@@ -305,6 +378,10 @@ def validate(target: Path) -> bool:
|
|
|
305
378
|
if not success or config is None:
|
|
306
379
|
return False
|
|
307
380
|
|
|
381
|
+
# Validate configuration mode (templates OR include)
|
|
382
|
+
if not _validate_configuration_mode(config):
|
|
383
|
+
return False
|
|
384
|
+
|
|
308
385
|
# Validate required fields
|
|
309
386
|
validation_passed = _validate_required_fields(config)
|
|
310
387
|
|
|
@@ -312,8 +389,15 @@ def validate(target: Path) -> bool:
|
|
|
312
389
|
if not _validate_repository_format(config):
|
|
313
390
|
validation_passed = False
|
|
314
391
|
|
|
315
|
-
if
|
|
316
|
-
|
|
392
|
+
# Validate templates if present
|
|
393
|
+
if config.get("templates"):
|
|
394
|
+
if not _validate_templates(config):
|
|
395
|
+
validation_passed = False
|
|
396
|
+
|
|
397
|
+
# Validate include if present
|
|
398
|
+
if config.get("include"):
|
|
399
|
+
if not _validate_include_paths(config):
|
|
400
|
+
validation_passed = False
|
|
317
401
|
|
|
318
402
|
# Validate optional fields
|
|
319
403
|
_validate_optional_fields(config)
|
rhiza/models.py
CHANGED
|
@@ -11,6 +11,12 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
import yaml # type: ignore[import-untyped]
|
|
13
13
|
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BundleDefinition",
|
|
16
|
+
"RhizaBundles",
|
|
17
|
+
"RhizaTemplate",
|
|
18
|
+
]
|
|
19
|
+
|
|
14
20
|
|
|
15
21
|
def _normalize_to_list(value: str | list[str] | None) -> list[str]:
|
|
16
22
|
r"""Convert a value to a list of strings.
|
|
@@ -56,6 +62,162 @@ def _normalize_to_list(value: str | list[str] | None) -> list[str]:
|
|
|
56
62
|
return []
|
|
57
63
|
|
|
58
64
|
|
|
65
|
+
@dataclass
|
|
66
|
+
class BundleDefinition:
|
|
67
|
+
"""Represents a single bundle from template_bundles.yml.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
name: The bundle identifier (e.g., "core", "tests", "github").
|
|
71
|
+
description: Human-readable description of the bundle.
|
|
72
|
+
files: List of file paths included in this bundle.
|
|
73
|
+
workflows: List of workflow file paths included in this bundle.
|
|
74
|
+
depends_on: List of bundle names that this bundle depends on.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
description: str
|
|
79
|
+
files: list[str] = field(default_factory=list)
|
|
80
|
+
workflows: list[str] = field(default_factory=list)
|
|
81
|
+
depends_on: list[str] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def all_paths(self) -> list[str]:
|
|
84
|
+
"""Return combined files and workflows."""
|
|
85
|
+
return self.files + self.workflows
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class RhizaBundles:
|
|
90
|
+
"""Represents the structure of template_bundles.yml.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
version: Version string of the bundles configuration format.
|
|
94
|
+
bundles: Dictionary mapping bundle names to their definitions.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
version: str
|
|
98
|
+
bundles: dict[str, BundleDefinition] = field(default_factory=dict)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_yaml(cls, file_path: Path) -> "RhizaBundles":
|
|
102
|
+
"""Load RhizaBundles from a YAML file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
file_path: Path to the template_bundles.yml file.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The loaded bundles configuration.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
FileNotFoundError: If the file does not exist.
|
|
112
|
+
yaml.YAMLError: If the YAML is malformed.
|
|
113
|
+
ValueError: If the file is invalid or missing required fields.
|
|
114
|
+
TypeError: If bundle data has invalid types.
|
|
115
|
+
"""
|
|
116
|
+
with open(file_path) as f:
|
|
117
|
+
config = yaml.safe_load(f)
|
|
118
|
+
|
|
119
|
+
if not config:
|
|
120
|
+
raise ValueError("Bundles file is empty") # noqa: TRY003
|
|
121
|
+
|
|
122
|
+
version = config.get("version")
|
|
123
|
+
if not version:
|
|
124
|
+
raise ValueError("Bundles file missing required field: version") # noqa: TRY003
|
|
125
|
+
|
|
126
|
+
bundles_config = config.get("bundles", {})
|
|
127
|
+
if not isinstance(bundles_config, dict):
|
|
128
|
+
msg = "Bundles must be a dictionary"
|
|
129
|
+
raise TypeError(msg)
|
|
130
|
+
|
|
131
|
+
bundles: dict[str, BundleDefinition] = {}
|
|
132
|
+
for bundle_name, bundle_data in bundles_config.items():
|
|
133
|
+
if not isinstance(bundle_data, dict):
|
|
134
|
+
msg = f"Bundle '{bundle_name}' must be a dictionary"
|
|
135
|
+
raise TypeError(msg)
|
|
136
|
+
|
|
137
|
+
files = _normalize_to_list(bundle_data.get("files"))
|
|
138
|
+
workflows = _normalize_to_list(bundle_data.get("workflows"))
|
|
139
|
+
depends_on = _normalize_to_list(bundle_data.get("depends-on"))
|
|
140
|
+
|
|
141
|
+
bundles[bundle_name] = BundleDefinition(
|
|
142
|
+
name=bundle_name,
|
|
143
|
+
description=bundle_data.get("description", ""),
|
|
144
|
+
files=files,
|
|
145
|
+
workflows=workflows,
|
|
146
|
+
depends_on=depends_on,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return cls(version=version, bundles=bundles)
|
|
150
|
+
|
|
151
|
+
def resolve_dependencies(self, bundle_names: list[str]) -> list[str]:
|
|
152
|
+
"""Resolve bundle dependencies using topological sort.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
bundle_names: List of bundle names to resolve.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Ordered list of bundle names with dependencies first, no duplicates.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If a bundle doesn't exist or circular dependency detected.
|
|
162
|
+
"""
|
|
163
|
+
# Validate all bundles exist
|
|
164
|
+
for name in bundle_names:
|
|
165
|
+
if name not in self.bundles:
|
|
166
|
+
raise ValueError(f"Bundle '{name}' not found in template_bundles.yml") # noqa: TRY003
|
|
167
|
+
|
|
168
|
+
resolved: list[str] = []
|
|
169
|
+
visiting: set[str] = set()
|
|
170
|
+
visited: set[str] = set()
|
|
171
|
+
|
|
172
|
+
def visit(bundle_name: str) -> None:
|
|
173
|
+
if bundle_name in visited:
|
|
174
|
+
return
|
|
175
|
+
if bundle_name in visiting:
|
|
176
|
+
raise ValueError(f"Circular dependency detected involving '{bundle_name}'") # noqa: TRY003
|
|
177
|
+
|
|
178
|
+
visiting.add(bundle_name)
|
|
179
|
+
bundle = self.bundles[bundle_name]
|
|
180
|
+
|
|
181
|
+
for dep in bundle.depends_on:
|
|
182
|
+
if dep not in self.bundles:
|
|
183
|
+
raise ValueError(f"Bundle '{bundle_name}' depends on unknown bundle '{dep}'") # noqa: TRY003
|
|
184
|
+
visit(dep)
|
|
185
|
+
|
|
186
|
+
visiting.remove(bundle_name)
|
|
187
|
+
visited.add(bundle_name)
|
|
188
|
+
resolved.append(bundle_name)
|
|
189
|
+
|
|
190
|
+
for name in bundle_names:
|
|
191
|
+
visit(name)
|
|
192
|
+
|
|
193
|
+
return resolved
|
|
194
|
+
|
|
195
|
+
def resolve_to_paths(self, bundle_names: list[str]) -> list[str]:
|
|
196
|
+
"""Convert bundle names to deduplicated file paths.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
bundle_names: List of bundle names to resolve.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Deduplicated list of file paths from all bundles and their dependencies.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ValueError: If a bundle doesn't exist or circular dependency detected.
|
|
206
|
+
"""
|
|
207
|
+
resolved_bundles = self.resolve_dependencies(bundle_names)
|
|
208
|
+
paths: list[str] = []
|
|
209
|
+
seen: set[str] = set()
|
|
210
|
+
|
|
211
|
+
for bundle_name in resolved_bundles:
|
|
212
|
+
bundle = self.bundles[bundle_name]
|
|
213
|
+
for path in bundle.all_paths():
|
|
214
|
+
if path not in seen:
|
|
215
|
+
paths.append(path)
|
|
216
|
+
seen.add(path)
|
|
217
|
+
|
|
218
|
+
return paths
|
|
219
|
+
|
|
220
|
+
|
|
59
221
|
@dataclass
|
|
60
222
|
class RhizaTemplate:
|
|
61
223
|
"""Represents the structure of .rhiza/template.yml.
|
|
@@ -67,8 +229,10 @@ class RhizaTemplate:
|
|
|
67
229
|
Can be None if not specified in the template file (defaults to "main" when creating).
|
|
68
230
|
template_host: The git hosting platform ("github" or "gitlab").
|
|
69
231
|
Defaults to "github" if not specified in the template file.
|
|
70
|
-
include: List of paths to include from the template repository.
|
|
232
|
+
include: List of paths to include from the template repository (path-based mode).
|
|
71
233
|
exclude: List of paths to exclude from the template repository (default: empty list).
|
|
234
|
+
templates: List of template names to include (template-based mode).
|
|
235
|
+
Can be used together with include to merge paths.
|
|
72
236
|
"""
|
|
73
237
|
|
|
74
238
|
template_repository: str | None = None
|
|
@@ -76,6 +240,7 @@ class RhizaTemplate:
|
|
|
76
240
|
template_host: str = "github"
|
|
77
241
|
include: list[str] = field(default_factory=list)
|
|
78
242
|
exclude: list[str] = field(default_factory=list)
|
|
243
|
+
templates: list[str] = field(default_factory=list)
|
|
79
244
|
|
|
80
245
|
@classmethod
|
|
81
246
|
def from_yaml(cls, file_path: Path) -> "RhizaTemplate":
|
|
@@ -104,6 +269,7 @@ class RhizaTemplate:
|
|
|
104
269
|
template_host=config.get("template-host", "github"),
|
|
105
270
|
include=_normalize_to_list(config.get("include")),
|
|
106
271
|
exclude=_normalize_to_list(config.get("exclude")),
|
|
272
|
+
templates=_normalize_to_list(config.get("templates")),
|
|
107
273
|
)
|
|
108
274
|
|
|
109
275
|
def to_yaml(self, file_path: Path) -> None:
|
|
@@ -130,8 +296,13 @@ class RhizaTemplate:
|
|
|
130
296
|
if self.template_host and self.template_host != "github":
|
|
131
297
|
config["template-host"] = self.template_host
|
|
132
298
|
|
|
133
|
-
#
|
|
134
|
-
|
|
299
|
+
# Write templates if present
|
|
300
|
+
if self.templates:
|
|
301
|
+
config["templates"] = self.templates
|
|
302
|
+
|
|
303
|
+
# Write include if present (can coexist with templates)
|
|
304
|
+
if self.include:
|
|
305
|
+
config["include"] = self.include
|
|
135
306
|
|
|
136
307
|
# Only include exclude if it's not empty
|
|
137
308
|
if self.exclude:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rhiza
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
4
4
|
Summary: Reusable configuration templates for modern Python projects
|
|
5
5
|
Project-URL: Homepage, https://github.com/jebel-quant/rhiza-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/jebel-quant/rhiza-cli
|
|
@@ -29,7 +29,7 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
<div align="center">
|
|
31
31
|
|
|
32
|
-
# <img src="https://raw.githubusercontent.com/Jebel-Quant/rhiza/main/assets/rhiza-logo.svg" alt="Rhiza Logo" width="30" style="vertical-align: middle;"> rhiza-cli
|
|
32
|
+
# <img src="https://raw.githubusercontent.com/Jebel-Quant/rhiza/main/.rhiza/assets/rhiza-logo.svg" alt="Rhiza Logo" width="30" style="vertical-align: middle;"> rhiza-cli
|
|
33
33
|

|
|
34
34
|
|
|
35
35
|
[](https://www.python.org/downloads/)
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
rhiza/__init__.py,sha256=4-Dy7AKbJneQNBfv30WhSsUin4y-g4Yp4veO6-YdjVg,1926
|
|
2
2
|
rhiza/__main__.py,sha256=GlTahVC8F6fKEpMw_nZyhp4PB-fyC9UQ_RjTZcpD9P4,837
|
|
3
|
+
rhiza/bundle_resolver.py,sha256=ErIgZeoxhjL81aXPxMggqIi7-AD6HicPkOBMahVPN1o,2293
|
|
3
4
|
rhiza/cli.py,sha256=y2qzFbrd29-j5OXslM96hZB9vDLvLv5FBnR9SnDdQWE,10613
|
|
4
|
-
rhiza/models.py,sha256
|
|
5
|
+
rhiza/models.py,sha256=-gVzDQNl_-zWKvdGqx4gL3DSFjbbBHuKAUtyvBMJ13o,10839
|
|
5
6
|
rhiza/subprocess_utils.py,sha256=Pr5TysIKP76hc64fmqhTd6msMGn5DU43hOSR_v_GFb8,745
|
|
6
7
|
rhiza/_templates/basic/__init__.py.jinja2,sha256=gs8qN4LAKcdFd6iO9gZVLuVetODmZP_TGuEjWrbinC0,27
|
|
7
8
|
rhiza/_templates/basic/main.py.jinja2,sha256=uTCahxf9Bftao1IghHue4cSZ9YzBYmBEXeIhEmK9UXQ,362
|
|
8
9
|
rhiza/_templates/basic/pyproject.toml.jinja2,sha256=Mizpnnd_kFQd-pCWOxG-KWhvg4_ZhZaQppTt2pz0WOc,695
|
|
9
10
|
rhiza/commands/__init__.py,sha256=DV1nlcXxeeHXmHobVcfOsGeiZBZ55Xz1mud073AXDGc,1839
|
|
10
|
-
rhiza/commands/init.py,sha256=
|
|
11
|
-
rhiza/commands/materialize.py,sha256=
|
|
11
|
+
rhiza/commands/init.py,sha256=fD_Nc-EtNo9Fb0PVRLkN2RYMteVv_BUTinAa2jz0_Q4,10745
|
|
12
|
+
rhiza/commands/materialize.py,sha256=ZKliQu-_L89Y5j_tq2KF5sKmgUgzngcm70MRmKwuVf8,22256
|
|
12
13
|
rhiza/commands/migrate.py,sha256=A5t4nw7CrdtILCwuSoAqtmM0LpMK8KmX87gzlNgi7fQ,7522
|
|
13
14
|
rhiza/commands/summarise.py,sha256=vgc7M3dwuGjUuVs3XKQr2_g3qURnQH_R3eFoSSrnOro,11758
|
|
14
15
|
rhiza/commands/uninstall.py,sha256=6oO7kdv11Bq4JXjrBg9rsFtoRgttQ4m30zGr6NhZrkQ,7479
|
|
15
|
-
rhiza/commands/validate.py,sha256=
|
|
16
|
+
rhiza/commands/validate.py,sha256=_yGx7ARk5K4hqfwzPpJg_7JoG4un0lqHJYJU-4cmYvI,13948
|
|
16
17
|
rhiza/commands/welcome.py,sha256=VknKaUh-bD6dM-zcD1eP4H-D_npyezpfF3bTSl_0Dio,2383
|
|
17
|
-
rhiza-0.
|
|
18
|
-
rhiza-0.
|
|
19
|
-
rhiza-0.
|
|
20
|
-
rhiza-0.
|
|
21
|
-
rhiza-0.
|
|
18
|
+
rhiza-0.10.1.dist-info/METADATA,sha256=OGXKAYAT45X6_JOJZ53fMWlMRAO_sz5vrQgoF3o8GCE,26306
|
|
19
|
+
rhiza-0.10.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
20
|
+
rhiza-0.10.1.dist-info/entry_points.txt,sha256=NAwZUpbXvfKv50a_Qq-PxMHl3lcjAyZO63IBeuUNgfY,45
|
|
21
|
+
rhiza-0.10.1.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
|
|
22
|
+
rhiza-0.10.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|