rhiza 0.9.0__py3-none-any.whl → 0.10.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.
rhiza/__main__.py CHANGED
@@ -11,7 +11,7 @@ import typer
11
11
  from rhiza.cli import app
12
12
 
13
13
 
14
- def load_plugins(app: typer.Typer):
14
+ def load_plugins(app: typer.Typer) -> None:
15
15
  """Load plugins from entry points."""
16
16
  # 'rhiza.plugins' matches the group we defined in rhiza-tools
17
17
  plugin_entries = entry_points(group="rhiza.plugins")
@@ -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 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/bundles.yml from cloned template repo.
14
+
15
+ Args:
16
+ tmp_dir: Path to the cloned template repository.
17
+
18
+ Returns:
19
+ RhizaBundles if bundles.yml exists, None otherwise.
20
+
21
+ Raises:
22
+ yaml.YAMLError: If bundles.yml is malformed.
23
+ ValueError: If bundles.yml is invalid.
24
+ """
25
+ bundles_file = tmp_dir / ".rhiza" / "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 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/cli.py CHANGED
@@ -23,14 +23,14 @@ app = typer.Typer(
23
23
  """
24
24
  Rhiza - Manage reusable configuration templates for Python projects
25
25
 
26
- \x1b]8;;https://jebel-quant.github.io/rhiza-cli/\x1b\\https://jebel-quant.github.io/rhiza-cli/\x1b]8;;\x1b\\
26
+ https://jebel-quant.github.io/rhiza-cli/
27
27
  """
28
28
  ),
29
29
  add_completion=True,
30
30
  )
31
31
 
32
32
 
33
- def version_callback(value: bool):
33
+ def version_callback(value: bool) -> None:
34
34
  """Print version information and exit.
35
35
 
36
36
  Args:
@@ -54,7 +54,7 @@ def main(
54
54
  callback=version_callback,
55
55
  is_eager=True,
56
56
  ),
57
- ):
57
+ ) -> None:
58
58
  """Rhiza CLI main callback.
59
59
 
60
60
  This callback is executed before any command. It handles global options
@@ -107,7 +107,7 @@ def init(
107
107
  "--template-branch",
108
108
  help="Custom template branch. Defaults to 'main'.",
109
109
  ),
110
- ):
110
+ ) -> None:
111
111
  r"""Initialize or validate .rhiza/template.yml.
112
112
 
113
113
  Creates a default `.rhiza/template.yml` configuration file if one
@@ -157,7 +157,7 @@ def materialize(
157
157
  help="Create and checkout a new branch in the target repository for changes",
158
158
  ),
159
159
  force: bool = typer.Option(False, "--force", "-y", help="Overwrite existing files"),
160
- ):
160
+ ) -> None:
161
161
  r"""Inject Rhiza configuration templates into a target repository.
162
162
 
163
163
  Materializes configuration files from the template repository specified
@@ -190,7 +190,7 @@ def validate(
190
190
  help="Target git repository (defaults to current directory)",
191
191
  ),
192
192
  ] = Path("."),
193
- ):
193
+ ) -> None:
194
194
  r"""Validate Rhiza template configuration.
195
195
 
196
196
  Validates the .rhiza/template.yml file to ensure it is syntactically
@@ -227,7 +227,7 @@ def migrate(
227
227
  help="Target git repository (defaults to current directory)",
228
228
  ),
229
229
  ] = Path("."),
230
- ):
230
+ ) -> None:
231
231
  r"""Migrate project to the new .rhiza folder structure.
232
232
 
233
233
  This command helps transition projects to use the new `.rhiza/` folder
@@ -253,7 +253,7 @@ def migrate(
253
253
 
254
254
 
255
255
  @app.command()
256
- def welcome():
256
+ def welcome() -> None:
257
257
  r"""Display a friendly welcome message and explain what Rhiza is.
258
258
 
259
259
  Shows a welcome message, explains Rhiza's purpose, key features,
@@ -282,7 +282,7 @@ def uninstall(
282
282
  "-y",
283
283
  help="Skip confirmation prompt and proceed with deletion",
284
284
  ),
285
- ):
285
+ ) -> None:
286
286
  r"""Remove all Rhiza-managed files from the repository.
287
287
 
288
288
  Reads the `.rhiza.history` file and removes all files that were
@@ -327,7 +327,7 @@ def summarise(
327
327
  help="Output file path (defaults to stdout)",
328
328
  ),
329
329
  ] = None,
330
- ):
330
+ ) -> None:
331
331
  r"""Generate a summary of staged changes for PR descriptions.
332
332
 
333
333
  Analyzes staged git changes and generates a structured PR description
@@ -50,6 +50,8 @@ For more detailed usage examples and workflows, see the USAGE.md guide
50
50
  or try rhiza <command> --help
51
51
  """
52
52
 
53
- from .init import init # noqa: F401
54
- from .materialize import materialize # noqa: F401
55
- from .validate import validate # noqa: F401
53
+ from .init import init
54
+ from .materialize import materialize
55
+ from .validate import validate
56
+
57
+ __all__ = ["init", "materialize", "validate"]
rhiza/commands/init.py CHANGED
@@ -81,11 +81,27 @@ def _prompt_git_host() -> str:
81
81
  git_host = "github"
82
82
  logger.debug("Non-interactive mode detected, defaulting to github")
83
83
 
84
- return git_host
84
+ return str(git_host)
85
+
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"]
85
101
 
86
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
- default_template = RhizaTemplate(
163
- template_repository=repo,
164
- template_branch=branch,
165
- include=include_paths,
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)
@@ -263,7 +290,7 @@ def init(
263
290
  git_host: str | None = None,
264
291
  template_repository: str | None = None,
265
292
  template_branch: str | None = None,
266
- ):
293
+ ) -> bool:
267
294
  """Initialize or validate .rhiza/template.yml in the target repository.
268
295
 
269
296
  Creates a default .rhiza/template.yml file if it doesn't exist,
@@ -15,11 +15,28 @@ 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
21
22
 
22
23
 
24
+ def _log_git_stderr_errors(stderr: str | None) -> None:
25
+ """Extract and log only relevant error messages from git stderr.
26
+
27
+ Args:
28
+ stderr: Git command stderr output.
29
+ """
30
+ if stderr:
31
+ # Extract relevant error message from git stderr
32
+ stderr_lines = stderr.strip().split("\n")
33
+ # Show only the most relevant error lines, skip verbose git output
34
+ for line in stderr_lines:
35
+ line = line.strip()
36
+ if line and (line.startswith("fatal:") or line.startswith("error:")):
37
+ logger.error(line)
38
+
39
+
23
40
  def _handle_target_branch(
24
41
  target: Path, target_branch: str | None, git_executable: str, git_env: dict[str, str]
25
42
  ) -> None:
@@ -52,6 +69,8 @@ def _handle_target_branch(
52
69
  [git_executable, "checkout", target_branch],
53
70
  cwd=target,
54
71
  check=True,
72
+ capture_output=True,
73
+ text=True,
55
74
  env=git_env,
56
75
  )
57
76
  else:
@@ -61,10 +80,14 @@ def _handle_target_branch(
61
80
  [git_executable, "checkout", "-b", target_branch],
62
81
  cwd=target,
63
82
  check=True,
83
+ capture_output=True,
84
+ text=True,
64
85
  env=git_env,
65
86
  )
66
87
  except subprocess.CalledProcessError as e:
67
- logger.error(f"Failed to create/checkout branch '{target_branch}': {e}")
88
+ logger.error(f"Failed to create/checkout branch '{target_branch}'")
89
+ _log_git_stderr_errors(e.stderr)
90
+ logger.error("Please ensure you have no uncommitted changes or conflicts")
68
91
  sys.exit(1)
69
92
 
70
93
 
@@ -91,20 +114,32 @@ def _validate_and_load_template(target: Path, branch: str) -> tuple[RhizaTemplat
91
114
 
92
115
  # Extract template configuration settings
93
116
  rhiza_repo = template.template_repository
117
+ if not rhiza_repo:
118
+ logger.error("template-repository is not configured in template.yml")
119
+ raise RuntimeError("template-repository is required") # noqa: TRY003
94
120
  rhiza_branch = template.template_branch or branch
95
- include_paths = template.include
96
121
  excluded_paths = template.exclude
97
122
 
98
- # Validate that we have paths to include
99
- if not include_paths:
100
- logger.error("No include paths found in template.yml")
101
- logger.error("Add at least one path to the 'include' list in template.yml")
102
- 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 bundles.yml from the template
125
+ include_paths = template.include
103
126
 
104
- # Log the paths we'll be including
105
- logger.info("Include paths:")
106
- for p in include_paths:
107
- logger.info(f" - {p}")
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}")
108
143
 
109
144
  if excluded_paths:
110
145
  logger.info("Exclude paths:")
@@ -140,6 +175,37 @@ def _construct_git_url(rhiza_repo: str, rhiza_host: str) -> str:
140
175
  return git_url
141
176
 
142
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
+
143
209
  def _clone_template_repository(
144
210
  tmp_dir: Path,
145
211
  git_url: str,
@@ -154,7 +220,7 @@ def _clone_template_repository(
154
220
  tmp_dir: Temporary directory for cloning.
155
221
  git_url: Git repository URL.
156
222
  rhiza_branch: Branch to clone.
157
- include_paths: Paths to include in sparse checkout.
223
+ include_paths: Initial paths to include in sparse checkout.
158
224
  git_executable: Path to git executable.
159
225
  git_env: Environment variables for git commands.
160
226
  """
@@ -181,11 +247,13 @@ def _clone_template_repository(
181
247
  )
182
248
  logger.debug("Git clone completed successfully")
183
249
  except subprocess.CalledProcessError as e:
184
- logger.error(f"Failed to clone repository: {e}")
185
- if e.stderr:
186
- logger.error(f"Git error: {e.stderr.strip()}")
187
- logger.error(f"Check that the repository exists and branch '{rhiza_branch}' is valid")
188
- raise
250
+ logger.error(f"Failed to clone repository from {git_url}")
251
+ _log_git_stderr_errors(e.stderr)
252
+ logger.error("Please check that:")
253
+ logger.error(" - The repository exists and is accessible")
254
+ logger.error(f" - Branch '{rhiza_branch}' exists in the repository")
255
+ logger.error(" - You have network access to the git hosting service")
256
+ sys.exit(1)
189
257
 
190
258
  # Initialize sparse checkout in cone mode
191
259
  try:
@@ -200,10 +268,9 @@ def _clone_template_repository(
200
268
  )
201
269
  logger.debug("Sparse checkout initialized")
202
270
  except subprocess.CalledProcessError as e:
203
- logger.error(f"Failed to initialize sparse checkout: {e}")
204
- if e.stderr:
205
- logger.error(f"Git error: {e.stderr.strip()}")
206
- raise
271
+ logger.error("Failed to initialize sparse checkout")
272
+ _log_git_stderr_errors(e.stderr)
273
+ sys.exit(1)
207
274
 
208
275
  # Set sparse checkout paths
209
276
  try:
@@ -218,10 +285,9 @@ def _clone_template_repository(
218
285
  )
219
286
  logger.debug("Sparse checkout paths configured")
220
287
  except subprocess.CalledProcessError as e:
221
- logger.error(f"Failed to set sparse checkout paths: {e}")
222
- if e.stderr:
223
- logger.error(f"Git error: {e.stderr.strip()}")
224
- raise
288
+ logger.error("Failed to configure sparse checkout paths")
289
+ _log_git_stderr_errors(e.stderr)
290
+ sys.exit(1)
225
291
 
226
292
 
227
293
  def _copy_files_to_target(
@@ -479,7 +545,25 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
479
545
  logger.debug(f"Temporary directory: {tmp_dir}")
480
546
 
481
547
  try:
482
- _clone_template_repository(tmp_dir, git_url, rhiza_branch, include_paths, git_executable, git_env)
548
+ # Clone with initial minimal checkout to load 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 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
+
483
567
  materialized_files = _copy_files_to_target(tmp_dir, target, include_paths, excluded_paths, force)
484
568
  finally:
485
569
  logger.debug(f"Cleaning up temporary directory: {tmp_dir}")
@@ -46,7 +46,7 @@ def get_staged_changes(repo_path: Path) -> dict[str, list[str]]:
46
46
  Returns:
47
47
  Dictionary with keys 'added', 'modified', 'deleted' containing file lists
48
48
  """
49
- changes = {
49
+ changes: dict[str, list[str]] = {
50
50
  "added": [],
51
51
  "modified": [],
52
52
  "deleted": [],
@@ -5,8 +5,9 @@ This module provides functionality to validate template.yml files in the
5
5
  """
6
6
 
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
 
9
- import yaml
10
+ import yaml # type: ignore[import-untyped]
10
11
  from loguru import logger
11
12
 
12
13
 
@@ -98,7 +99,7 @@ def _check_template_file_exists(target: Path) -> tuple[bool, Path]:
98
99
  return True, template_file
99
100
 
100
101
 
101
- def _parse_yaml_file(template_file: Path) -> tuple[bool, dict | None]:
102
+ def _parse_yaml_file(template_file: Path) -> tuple[bool, dict[str, Any] | None]:
102
103
  """Parse YAML file and return configuration.
103
104
 
104
105
  Args:
@@ -125,7 +126,79 @@ def _parse_yaml_file(template_file: Path) -> tuple[bool, dict | None]:
125
126
  return True, config
126
127
 
127
128
 
128
- def _validate_required_fields(config: dict) -> bool:
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
+
201
+ def _validate_required_fields(config: dict[str, Any]) -> bool:
129
202
  """Validate required fields exist and have correct types.
130
203
 
131
204
  Args:
@@ -135,9 +208,10 @@ def _validate_required_fields(config: dict) -> bool:
135
208
  True if all validations pass, False otherwise.
136
209
  """
137
210
  logger.debug("Validating required fields")
211
+ # template-repository is required
212
+ # include or bundles is required (validated separately)
138
213
  required_fields = {
139
214
  "template-repository": str,
140
- "include": list,
141
215
  }
142
216
 
143
217
  validation_passed = True
@@ -159,7 +233,7 @@ def _validate_required_fields(config: dict) -> bool:
159
233
  return validation_passed
160
234
 
161
235
 
162
- def _validate_repository_format(config: dict) -> bool:
236
+ def _validate_repository_format(config: dict[str, Any]) -> bool:
163
237
  """Validate template-repository format.
164
238
 
165
239
  Args:
@@ -186,7 +260,7 @@ def _validate_repository_format(config: dict) -> bool:
186
260
  return True
187
261
 
188
262
 
189
- def _validate_include_paths(config: dict) -> bool:
263
+ def _validate_include_paths(config: dict[str, Any]) -> bool:
190
264
  """Validate include paths.
191
265
 
192
266
  Args:
@@ -218,7 +292,7 @@ def _validate_include_paths(config: dict) -> bool:
218
292
  return True
219
293
 
220
294
 
221
- def _validate_optional_fields(config: dict) -> None:
295
+ def _validate_optional_fields(config: dict[str, Any]) -> None:
222
296
  """Validate optional fields if present.
223
297
 
224
298
  Args:
@@ -304,6 +378,10 @@ def validate(target: Path) -> bool:
304
378
  if not success or config is None:
305
379
  return False
306
380
 
381
+ # Validate configuration mode (templates OR include)
382
+ if not _validate_configuration_mode(config):
383
+ return False
384
+
307
385
  # Validate required fields
308
386
  validation_passed = _validate_required_fields(config)
309
387
 
@@ -311,8 +389,15 @@ def validate(target: Path) -> bool:
311
389
  if not _validate_repository_format(config):
312
390
  validation_passed = False
313
391
 
314
- if not _validate_include_paths(config):
315
- validation_passed = False
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
316
401
 
317
402
  # Validate optional fields
318
403
  _validate_optional_fields(config)
rhiza/commands/welcome.py CHANGED
@@ -10,7 +10,7 @@ and explains what Rhiza is and how it can help manage configuration templates.
10
10
  from rhiza import __version__
11
11
 
12
12
 
13
- def welcome():
13
+ def welcome() -> None:
14
14
  """Display a welcome message and explain what Rhiza is.
15
15
 
16
16
  Shows a friendly greeting, explains Rhiza's purpose, and provides
rhiza/models.py CHANGED
@@ -7,12 +7,19 @@ YAML parsing.
7
7
 
8
8
  from dataclasses import dataclass, field
9
9
  from pathlib import Path
10
+ from typing import Any
10
11
 
11
- import yaml
12
+ import yaml # type: ignore[import-untyped]
13
+
14
+ __all__ = [
15
+ "BundleDefinition",
16
+ "RhizaBundles",
17
+ "RhizaTemplate",
18
+ ]
12
19
 
13
20
 
14
21
  def _normalize_to_list(value: str | list[str] | None) -> list[str]:
15
- """Convert a value to a list of strings.
22
+ r"""Convert a value to a list of strings.
16
23
 
17
24
  Handles the case where YAML multi-line strings (using |) are parsed as
18
25
  a single string instead of a list. Splits the string by newlines and
@@ -23,6 +30,20 @@ def _normalize_to_list(value: str | list[str] | None) -> list[str]:
23
30
 
24
31
  Returns:
25
32
  A list of strings. Empty list if value is None or empty.
33
+
34
+ Examples:
35
+ >>> _normalize_to_list(None)
36
+ []
37
+ >>> _normalize_to_list([])
38
+ []
39
+ >>> _normalize_to_list(['a', 'b', 'c'])
40
+ ['a', 'b', 'c']
41
+ >>> _normalize_to_list('single line')
42
+ ['single line']
43
+ >>> _normalize_to_list('line1\\n' + 'line2\\n' + 'line3')
44
+ ['line1', 'line2', 'line3']
45
+ >>> _normalize_to_list(' item1 \\n' + ' item2 ')
46
+ ['item1', 'item2']
26
47
  """
27
48
  if value is None:
28
49
  return []
@@ -30,10 +51,173 @@ def _normalize_to_list(value: str | list[str] | None) -> list[str]:
30
51
  return value
31
52
  if isinstance(value, str):
32
53
  # Split by newlines and filter out empty strings
33
- return [item.strip() for item in value.strip().split("\n") if item.strip()]
54
+ # Handle both actual newlines (\n) and literal backslash-n (\\n)
55
+ if "\\n" in value and "\n" not in value:
56
+ # Contains literal \n but not actual newlines
57
+ items = value.split("\\n")
58
+ else:
59
+ # Contains actual newlines or neither
60
+ items = value.split("\n")
61
+ return [item.strip() for item in items if item.strip()]
34
62
  return []
35
63
 
36
64
 
65
+ @dataclass
66
+ class BundleDefinition:
67
+ """Represents a single bundle from 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 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 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 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
+
37
221
  @dataclass
38
222
  class RhizaTemplate:
39
223
  """Represents the structure of .rhiza/template.yml.
@@ -45,8 +229,10 @@ class RhizaTemplate:
45
229
  Can be None if not specified in the template file (defaults to "main" when creating).
46
230
  template_host: The git hosting platform ("github" or "gitlab").
47
231
  Defaults to "github" if not specified in the template file.
48
- include: List of paths to include from the template repository.
232
+ include: List of paths to include from the template repository (path-based mode).
49
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.
50
236
  """
51
237
 
52
238
  template_repository: str | None = None
@@ -54,6 +240,7 @@ class RhizaTemplate:
54
240
  template_host: str = "github"
55
241
  include: list[str] = field(default_factory=list)
56
242
  exclude: list[str] = field(default_factory=list)
243
+ templates: list[str] = field(default_factory=list)
57
244
 
58
245
  @classmethod
59
246
  def from_yaml(cls, file_path: Path) -> "RhizaTemplate":
@@ -82,6 +269,7 @@ class RhizaTemplate:
82
269
  template_host=config.get("template-host", "github"),
83
270
  include=_normalize_to_list(config.get("include")),
84
271
  exclude=_normalize_to_list(config.get("exclude")),
272
+ templates=_normalize_to_list(config.get("templates")),
85
273
  )
86
274
 
87
275
  def to_yaml(self, file_path: Path) -> None:
@@ -94,7 +282,7 @@ class RhizaTemplate:
94
282
  file_path.parent.mkdir(parents=True, exist_ok=True)
95
283
 
96
284
  # Convert to dictionary with YAML-compatible keys
97
- config = {}
285
+ config: dict[str, Any] = {}
98
286
 
99
287
  # Only include template-repository if it's not None
100
288
  if self.template_repository:
@@ -108,8 +296,13 @@ class RhizaTemplate:
108
296
  if self.template_host and self.template_host != "github":
109
297
  config["template-host"] = self.template_host
110
298
 
111
- # Include is always present as it's a required field for the config to be useful
112
- config["include"] = self.include
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
113
306
 
114
307
  # Only include exclude if it's not empty
115
308
  if self.exclude:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhiza
3
- Version: 0.9.0
3
+ Version: 0.10.0
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
  ![Synced with Rhiza](https://img.shields.io/badge/synced%20with-rhiza-2FA4A9?color=2FA4A9)
34
34
 
35
35
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
@@ -0,0 +1,22 @@
1
+ rhiza/__init__.py,sha256=4-Dy7AKbJneQNBfv30WhSsUin4y-g4Yp4veO6-YdjVg,1926
2
+ rhiza/__main__.py,sha256=GlTahVC8F6fKEpMw_nZyhp4PB-fyC9UQ_RjTZcpD9P4,837
3
+ rhiza/bundle_resolver.py,sha256=_gF2LQqvYfwMCbMKBvlyPr6vfo92463LB6zu948jPu4,2230
4
+ rhiza/cli.py,sha256=y2qzFbrd29-j5OXslM96hZB9vDLvLv5FBnR9SnDdQWE,10613
5
+ rhiza/models.py,sha256=kyrrcbVm5XeceQIY1Ug83TLd2Dd5R725ulOf-VB86yI,10803
6
+ rhiza/subprocess_utils.py,sha256=Pr5TysIKP76hc64fmqhTd6msMGn5DU43hOSR_v_GFb8,745
7
+ rhiza/_templates/basic/__init__.py.jinja2,sha256=gs8qN4LAKcdFd6iO9gZVLuVetODmZP_TGuEjWrbinC0,27
8
+ rhiza/_templates/basic/main.py.jinja2,sha256=uTCahxf9Bftao1IghHue4cSZ9YzBYmBEXeIhEmK9UXQ,362
9
+ rhiza/_templates/basic/pyproject.toml.jinja2,sha256=Mizpnnd_kFQd-pCWOxG-KWhvg4_ZhZaQppTt2pz0WOc,695
10
+ rhiza/commands/__init__.py,sha256=DV1nlcXxeeHXmHobVcfOsGeiZBZ55Xz1mud073AXDGc,1839
11
+ rhiza/commands/init.py,sha256=fD_Nc-EtNo9Fb0PVRLkN2RYMteVv_BUTinAa2jz0_Q4,10745
12
+ rhiza/commands/materialize.py,sha256=oR3OP6vhLlbKPqtFahCyLFCFDksbumNSy6C8KZ2bD44,22229
13
+ rhiza/commands/migrate.py,sha256=A5t4nw7CrdtILCwuSoAqtmM0LpMK8KmX87gzlNgi7fQ,7522
14
+ rhiza/commands/summarise.py,sha256=vgc7M3dwuGjUuVs3XKQr2_g3qURnQH_R3eFoSSrnOro,11758
15
+ rhiza/commands/uninstall.py,sha256=6oO7kdv11Bq4JXjrBg9rsFtoRgttQ4m30zGr6NhZrkQ,7479
16
+ rhiza/commands/validate.py,sha256=_yGx7ARk5K4hqfwzPpJg_7JoG4un0lqHJYJU-4cmYvI,13948
17
+ rhiza/commands/welcome.py,sha256=VknKaUh-bD6dM-zcD1eP4H-D_npyezpfF3bTSl_0Dio,2383
18
+ rhiza-0.10.0.dist-info/METADATA,sha256=T96351Csc2Tu1s75Z57aRJ1JDqZqax5PXr4xZ3BDWCQ,26306
19
+ rhiza-0.10.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
+ rhiza-0.10.0.dist-info/entry_points.txt,sha256=NAwZUpbXvfKv50a_Qq-PxMHl3lcjAyZO63IBeuUNgfY,45
21
+ rhiza-0.10.0.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
22
+ rhiza-0.10.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- rhiza/__init__.py,sha256=4-Dy7AKbJneQNBfv30WhSsUin4y-g4Yp4veO6-YdjVg,1926
2
- rhiza/__main__.py,sha256=Q02upTGaJceknkDABdCwq5_vdMdGY8Cg3ej6WZIHs_s,829
3
- rhiza/cli.py,sha256=uU0tenEgTe0Wz5lIj0SZIeXy5n_9-8xtUMMP_i71CWA,10609
4
- rhiza/models.py,sha256=f4TT3XPMEE7ciyrpsXllje4eGJRkYOgpZOoFeRC-rVI,4225
5
- rhiza/subprocess_utils.py,sha256=Pr5TysIKP76hc64fmqhTd6msMGn5DU43hOSR_v_GFb8,745
6
- rhiza/_templates/basic/__init__.py.jinja2,sha256=gs8qN4LAKcdFd6iO9gZVLuVetODmZP_TGuEjWrbinC0,27
7
- rhiza/_templates/basic/main.py.jinja2,sha256=uTCahxf9Bftao1IghHue4cSZ9YzBYmBEXeIhEmK9UXQ,362
8
- rhiza/_templates/basic/pyproject.toml.jinja2,sha256=Mizpnnd_kFQd-pCWOxG-KWhvg4_ZhZaQppTt2pz0WOc,695
9
- rhiza/commands/__init__.py,sha256=QWEEVvdW3gKV-FpKgHRJL_H8FpQqvfck9JvnFMDz3gY,1834
10
- rhiza/commands/init.py,sha256=dpLwFbURyndkgw-v4O6gD0gg6ov6q023uOmaWGup2oA,9785
11
- rhiza/commands/materialize.py,sha256=1YmzlPz9-IxnV4znDZQijZ3cjfbBXdepmps963z4Hsg,18668
12
- rhiza/commands/migrate.py,sha256=A5t4nw7CrdtILCwuSoAqtmM0LpMK8KmX87gzlNgi7fQ,7522
13
- rhiza/commands/summarise.py,sha256=KJ8v4lHSbn8AIt3tJM_lSCizzZ2Bv2mqTfI0z7-kyxI,11736
14
- rhiza/commands/uninstall.py,sha256=6oO7kdv11Bq4JXjrBg9rsFtoRgttQ4m30zGr6NhZrkQ,7479
15
- rhiza/commands/validate.py,sha256=VriXJxyuhvGJajw0qiAUmXYKRMbpgKiZ5_MMtE6WlSo,10801
16
- rhiza/commands/welcome.py,sha256=u197cIlY1tXm-CN6YpyUX4Eq06pLeV0hGyNvT35tE8U,2375
17
- rhiza-0.9.0.dist-info/METADATA,sha256=6AIK6bVC54vxBE0NB9jdLkBuTUcn9W0jod6ixm1-EB0,26298
18
- rhiza-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
- rhiza-0.9.0.dist-info/entry_points.txt,sha256=NAwZUpbXvfKv50a_Qq-PxMHl3lcjAyZO63IBeuUNgfY,45
20
- rhiza-0.9.0.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
21
- rhiza-0.9.0.dist-info/RECORD,,
File without changes