rhiza 0.8.8__py3-none-any.whl → 0.9.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/__init__.py +4 -4
- rhiza/__main__.py +1 -1
- rhiza/cli.py +122 -49
- rhiza/commands/__init__.py +7 -5
- rhiza/commands/init.py +34 -11
- rhiza/commands/materialize.py +48 -23
- rhiza/commands/migrate.py +1 -1
- rhiza/commands/summarise.py +416 -0
- rhiza/commands/uninstall.py +2 -1
- rhiza/commands/validate.py +8 -7
- rhiza/commands/welcome.py +2 -2
- rhiza/models.py +28 -6
- {rhiza-0.8.8.dist-info → rhiza-0.9.1.dist-info}/METADATA +46 -26
- rhiza-0.9.1.dist-info/RECORD +21 -0
- rhiza-0.8.8.dist-info/RECORD +0 -20
- {rhiza-0.8.8.dist-info → rhiza-0.9.1.dist-info}/WHEEL +0 -0
- {rhiza-0.8.8.dist-info → rhiza-0.9.1.dist-info}/entry_points.txt +0 -0
- {rhiza-0.8.8.dist-info → rhiza-0.9.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Command for generating PR descriptions from staged changes.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to analyze staged git changes and generate
|
|
4
|
+
structured PR descriptions for rhiza sync operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess # nosec B404
|
|
8
|
+
import sys
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_git_command(args: list[str], cwd: Path | None = None) -> str:
|
|
17
|
+
"""Run a git command and return the output.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
args: Git command arguments (without 'git' prefix)
|
|
21
|
+
cwd: Working directory for the command
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Command output as string
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run( # nosec B603 B607
|
|
28
|
+
["git", *args],
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
check=True,
|
|
33
|
+
)
|
|
34
|
+
return result.stdout.strip()
|
|
35
|
+
except subprocess.CalledProcessError as e:
|
|
36
|
+
logger.error(f"Error running git {' '.join(args)}: {e.stderr}")
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_staged_changes(repo_path: Path) -> dict[str, list[str]]:
|
|
41
|
+
"""Get list of staged changes categorized by type.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
repo_path: Path to the repository
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dictionary with keys 'added', 'modified', 'deleted' containing file lists
|
|
48
|
+
"""
|
|
49
|
+
changes: dict[str, list[str]] = {
|
|
50
|
+
"added": [],
|
|
51
|
+
"modified": [],
|
|
52
|
+
"deleted": [],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Get staged changes
|
|
56
|
+
output = run_git_command(["diff", "--cached", "--name-status"], cwd=repo_path)
|
|
57
|
+
|
|
58
|
+
for line in output.split("\n"):
|
|
59
|
+
if not line:
|
|
60
|
+
continue
|
|
61
|
+
parts = line.split("\t", 1)
|
|
62
|
+
if len(parts) != 2:
|
|
63
|
+
continue
|
|
64
|
+
status, filepath = parts
|
|
65
|
+
|
|
66
|
+
if status == "A":
|
|
67
|
+
changes["added"].append(filepath)
|
|
68
|
+
elif status == "M":
|
|
69
|
+
changes["modified"].append(filepath)
|
|
70
|
+
elif status == "D":
|
|
71
|
+
changes["deleted"].append(filepath)
|
|
72
|
+
elif status.startswith("R"):
|
|
73
|
+
# Renamed file - treat as modified
|
|
74
|
+
changes["modified"].append(filepath)
|
|
75
|
+
|
|
76
|
+
return changes
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_config_files() -> set[str]:
|
|
80
|
+
"""Get set of known configuration files.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Set of configuration file names
|
|
84
|
+
"""
|
|
85
|
+
return {
|
|
86
|
+
"Makefile",
|
|
87
|
+
"ruff.toml",
|
|
88
|
+
"pytest.ini",
|
|
89
|
+
".editorconfig",
|
|
90
|
+
".gitignore",
|
|
91
|
+
".pre-commit-config.yaml",
|
|
92
|
+
"renovate.json",
|
|
93
|
+
".python-version",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _categorize_by_directory(first_dir: str, filepath: str) -> str | None:
|
|
98
|
+
"""Categorize file based on its first directory.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
first_dir: First directory in the path
|
|
102
|
+
filepath: Full file path
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Category name or None if no match
|
|
106
|
+
"""
|
|
107
|
+
if first_dir == ".github":
|
|
108
|
+
path_parts = Path(filepath).parts
|
|
109
|
+
if len(path_parts) > 1 and path_parts[1] == "workflows":
|
|
110
|
+
return "GitHub Actions Workflows"
|
|
111
|
+
return "GitHub Configuration"
|
|
112
|
+
|
|
113
|
+
if first_dir == ".rhiza":
|
|
114
|
+
if "script" in filepath.lower():
|
|
115
|
+
return "Rhiza Scripts"
|
|
116
|
+
if "Makefile" in filepath:
|
|
117
|
+
return "Makefiles"
|
|
118
|
+
return "Rhiza Configuration"
|
|
119
|
+
|
|
120
|
+
if first_dir == "tests":
|
|
121
|
+
return "Tests"
|
|
122
|
+
|
|
123
|
+
if first_dir == "book":
|
|
124
|
+
return "Documentation"
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _categorize_single_file(filepath: str) -> str:
|
|
130
|
+
"""Categorize a single file path.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
filepath: File path to categorize
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Category name
|
|
137
|
+
"""
|
|
138
|
+
path_parts = Path(filepath).parts
|
|
139
|
+
|
|
140
|
+
if not path_parts:
|
|
141
|
+
return "Other"
|
|
142
|
+
|
|
143
|
+
# Try directory-based categorization first
|
|
144
|
+
category = _categorize_by_directory(path_parts[0], filepath)
|
|
145
|
+
if category:
|
|
146
|
+
return category
|
|
147
|
+
|
|
148
|
+
# Check file-based categories
|
|
149
|
+
if filepath.endswith(".md"):
|
|
150
|
+
return "Documentation"
|
|
151
|
+
|
|
152
|
+
if filepath in _get_config_files():
|
|
153
|
+
return "Configuration Files"
|
|
154
|
+
|
|
155
|
+
return "Other"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def categorize_files(files: list[str]) -> dict[str, list[str]]:
|
|
159
|
+
"""Categorize files by type.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
files: List of file paths
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Dictionary mapping category names to file lists
|
|
166
|
+
"""
|
|
167
|
+
categories = defaultdict(list)
|
|
168
|
+
|
|
169
|
+
for filepath in files:
|
|
170
|
+
category = _categorize_single_file(filepath)
|
|
171
|
+
categories[category].append(filepath)
|
|
172
|
+
|
|
173
|
+
return dict(categories)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_template_info(repo_path: Path) -> tuple[str, str]:
|
|
177
|
+
"""Get template repository and branch from template.yml.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
repo_path: Path to the repository
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Tuple of (template_repo, template_branch)
|
|
184
|
+
"""
|
|
185
|
+
template_file = repo_path / ".rhiza" / "template.yml"
|
|
186
|
+
|
|
187
|
+
if not template_file.exists():
|
|
188
|
+
return ("jebel-quant/rhiza", "main")
|
|
189
|
+
|
|
190
|
+
template_repo = "jebel-quant/rhiza"
|
|
191
|
+
template_branch = "main"
|
|
192
|
+
|
|
193
|
+
with open(template_file) as f:
|
|
194
|
+
for line in f:
|
|
195
|
+
line = line.strip()
|
|
196
|
+
if line.startswith("template-repository:"):
|
|
197
|
+
template_repo = line.split(":", 1)[1].strip().strip('"')
|
|
198
|
+
elif line.startswith("template-branch:"):
|
|
199
|
+
template_branch = line.split(":", 1)[1].strip().strip('"')
|
|
200
|
+
|
|
201
|
+
return template_repo, template_branch
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_last_sync_date(repo_path: Path) -> str | None:
|
|
205
|
+
"""Get the date of the last sync commit.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
repo_path: Path to the repository
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
ISO format date string or None if not found
|
|
212
|
+
"""
|
|
213
|
+
# Look for the most recent commit with "rhiza" in the message
|
|
214
|
+
output = run_git_command(
|
|
215
|
+
["log", "--grep=rhiza", "--grep=Sync", "--grep=template", "-i", "--format=%cI", "-1"], cwd=repo_path
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if output:
|
|
219
|
+
return output
|
|
220
|
+
|
|
221
|
+
# Fallback: try to get date from history file if it exists
|
|
222
|
+
history_file = repo_path / ".rhiza" / "history"
|
|
223
|
+
if history_file.exists():
|
|
224
|
+
# Get the file modification time
|
|
225
|
+
stat = history_file.stat()
|
|
226
|
+
return datetime.fromtimestamp(stat.st_mtime).isoformat()
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _format_file_list(files: list[str], status_emoji: str) -> list[str]:
|
|
232
|
+
"""Format a list of files with the given status emoji.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
files: List of file paths
|
|
236
|
+
status_emoji: Emoji to use (✅ for added, 📝 for modified, ❌ for deleted)
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of formatted lines
|
|
240
|
+
"""
|
|
241
|
+
lines = []
|
|
242
|
+
for f in sorted(files):
|
|
243
|
+
lines.append(f"- {status_emoji} `{f}`")
|
|
244
|
+
return lines
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _add_category_section(lines: list[str], title: str, count: int, files: list[str], emoji: str) -> None:
|
|
248
|
+
"""Add a collapsible section for a category and change type.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
lines: List to append lines to
|
|
252
|
+
title: Section title (e.g., "Added", "Modified")
|
|
253
|
+
count: Number of files
|
|
254
|
+
files: List of file paths
|
|
255
|
+
emoji: Status emoji
|
|
256
|
+
"""
|
|
257
|
+
if not files:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
lines.append("<details>")
|
|
261
|
+
lines.append(f"<summary>{title} ({count})</summary>")
|
|
262
|
+
lines.append("")
|
|
263
|
+
lines.extend(_format_file_list(files, emoji))
|
|
264
|
+
lines.append("")
|
|
265
|
+
lines.append("</details>")
|
|
266
|
+
lines.append("")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _build_header(template_repo: str) -> list[str]:
|
|
270
|
+
"""Build the PR description header.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
template_repo: Template repository name
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of header lines
|
|
277
|
+
"""
|
|
278
|
+
return [
|
|
279
|
+
"## 🔄 Template Synchronization",
|
|
280
|
+
"",
|
|
281
|
+
f"This PR synchronizes the repository with the [{template_repo}](https://github.com/{template_repo}) template.",
|
|
282
|
+
"",
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _build_summary(changes: dict[str, list[str]]) -> list[str]:
|
|
287
|
+
"""Build the change summary section.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
changes: Dictionary of changes by type
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of summary lines
|
|
294
|
+
"""
|
|
295
|
+
return [
|
|
296
|
+
"### 📊 Change Summary",
|
|
297
|
+
"",
|
|
298
|
+
f"- **{len(changes['added'])}** files added",
|
|
299
|
+
f"- **{len(changes['modified'])}** files modified",
|
|
300
|
+
f"- **{len(changes['deleted'])}** files deleted",
|
|
301
|
+
"",
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _build_footer(template_repo: str, template_branch: str, last_sync: str | None) -> list[str]:
|
|
306
|
+
"""Build the PR description footer with metadata.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
template_repo: Template repository name
|
|
310
|
+
template_branch: Template branch name
|
|
311
|
+
last_sync: Last sync date string or None
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
List of footer lines
|
|
315
|
+
"""
|
|
316
|
+
lines = [
|
|
317
|
+
"---",
|
|
318
|
+
"",
|
|
319
|
+
"**🤖 Generated by [rhiza](https://github.com/jebel-quant/rhiza-cli)**",
|
|
320
|
+
"",
|
|
321
|
+
f"- Template: `{template_repo}@{template_branch}`",
|
|
322
|
+
]
|
|
323
|
+
if last_sync:
|
|
324
|
+
lines.append(f"- Last sync: {last_sync}")
|
|
325
|
+
lines.append(f"- Sync date: {datetime.now().astimezone().isoformat()}")
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def generate_pr_description(repo_path: Path) -> str:
|
|
330
|
+
"""Generate PR description based on staged changes.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
repo_path: Path to the repository
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Formatted PR description
|
|
337
|
+
"""
|
|
338
|
+
changes = get_staged_changes(repo_path)
|
|
339
|
+
template_repo, template_branch = get_template_info(repo_path)
|
|
340
|
+
last_sync = get_last_sync_date(repo_path)
|
|
341
|
+
|
|
342
|
+
# Build header
|
|
343
|
+
lines = _build_header(template_repo)
|
|
344
|
+
|
|
345
|
+
# Check if there are any changes
|
|
346
|
+
total_changes = sum(len(files) for files in changes.values())
|
|
347
|
+
if total_changes == 0:
|
|
348
|
+
lines.append("No changes detected.")
|
|
349
|
+
return "\n".join(lines)
|
|
350
|
+
|
|
351
|
+
# Add summary
|
|
352
|
+
lines.extend(_build_summary(changes))
|
|
353
|
+
|
|
354
|
+
# Add detailed changes by category
|
|
355
|
+
all_changed_files = changes["added"] + changes["modified"] + changes["deleted"]
|
|
356
|
+
categories = categorize_files(all_changed_files)
|
|
357
|
+
|
|
358
|
+
if categories:
|
|
359
|
+
lines.append("### 📁 Changes by Category")
|
|
360
|
+
lines.append("")
|
|
361
|
+
|
|
362
|
+
for category, files in sorted(categories.items()):
|
|
363
|
+
lines.append(f"#### {category}")
|
|
364
|
+
lines.append("")
|
|
365
|
+
|
|
366
|
+
# Group files by change type
|
|
367
|
+
category_added = [f for f in files if f in changes["added"]]
|
|
368
|
+
category_modified = [f for f in files if f in changes["modified"]]
|
|
369
|
+
category_deleted = [f for f in files if f in changes["deleted"]]
|
|
370
|
+
|
|
371
|
+
_add_category_section(lines, "Added", len(category_added), category_added, "✅")
|
|
372
|
+
_add_category_section(lines, "Modified", len(category_modified), category_modified, "📝")
|
|
373
|
+
_add_category_section(lines, "Deleted", len(category_deleted), category_deleted, "❌")
|
|
374
|
+
|
|
375
|
+
# Add footer
|
|
376
|
+
lines.extend(_build_footer(template_repo, template_branch, last_sync))
|
|
377
|
+
|
|
378
|
+
return "\n".join(lines)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def summarise(target: Path, output: Path | None = None) -> None:
|
|
382
|
+
"""Generate a summary of staged changes for rhiza sync operations.
|
|
383
|
+
|
|
384
|
+
This command analyzes staged git changes and generates a structured
|
|
385
|
+
PR description with:
|
|
386
|
+
- Summary statistics (files added/modified/deleted)
|
|
387
|
+
- Changes categorized by type (workflows, configs, docs, tests, etc.)
|
|
388
|
+
- Template repository information
|
|
389
|
+
- Last sync date
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
target: Path to the target repository.
|
|
393
|
+
output: Optional output file path. If not provided, prints to stdout.
|
|
394
|
+
"""
|
|
395
|
+
target = target.resolve()
|
|
396
|
+
logger.info(f"Target repository: {target}")
|
|
397
|
+
|
|
398
|
+
# Check if target is a git repository
|
|
399
|
+
if not (target / ".git").is_dir():
|
|
400
|
+
logger.error(f"Target directory is not a git repository: {target}")
|
|
401
|
+
logger.error("Initialize a git repository with 'git init' first")
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
# Generate the PR description
|
|
405
|
+
description = generate_pr_description(target)
|
|
406
|
+
|
|
407
|
+
# Output the description
|
|
408
|
+
if output:
|
|
409
|
+
output_path = output.resolve()
|
|
410
|
+
output_path.write_text(description)
|
|
411
|
+
logger.success(f"PR description written to {output_path}")
|
|
412
|
+
else:
|
|
413
|
+
# Print to stdout
|
|
414
|
+
print(description)
|
|
415
|
+
|
|
416
|
+
logger.success("Summary generated successfully")
|
rhiza/commands/uninstall.py
CHANGED
|
@@ -143,10 +143,11 @@ def _remove_history_file(history_file: Path, target: Path) -> tuple[int, int]:
|
|
|
143
143
|
try:
|
|
144
144
|
history_file.unlink()
|
|
145
145
|
logger.success(f"[DEL] {history_file.relative_to(target)}")
|
|
146
|
-
return 1, 0
|
|
147
146
|
except Exception as e:
|
|
148
147
|
logger.error(f"Failed to delete {history_file.relative_to(target)}: {e}")
|
|
149
148
|
return 0, 1
|
|
149
|
+
else:
|
|
150
|
+
return 1, 0
|
|
150
151
|
|
|
151
152
|
|
|
152
153
|
def _print_summary(removed_count: int, skipped_count: int, empty_dirs_removed: int, error_count: int) -> None:
|
rhiza/commands/validate.py
CHANGED
|
@@ -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
|
|
|
@@ -91,14 +92,14 @@ def _check_template_file_exists(target: Path) -> tuple[bool, Path]:
|
|
|
91
92
|
logger.info(" • If you have an existing configuration, run: rhiza migrate")
|
|
92
93
|
logger.info("")
|
|
93
94
|
logger.info("The 'rhiza migrate' command will move your configuration from")
|
|
94
|
-
logger.info("
|
|
95
|
+
logger.info(" the old location → .rhiza/template.yml")
|
|
95
96
|
return False, template_file
|
|
96
97
|
|
|
97
98
|
logger.success(f"Template file exists: {template_file.relative_to(target)}")
|
|
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,7 @@ 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_required_fields(config: dict[str, Any]) -> bool:
|
|
129
130
|
"""Validate required fields exist and have correct types.
|
|
130
131
|
|
|
131
132
|
Args:
|
|
@@ -159,7 +160,7 @@ def _validate_required_fields(config: dict) -> bool:
|
|
|
159
160
|
return validation_passed
|
|
160
161
|
|
|
161
162
|
|
|
162
|
-
def _validate_repository_format(config: dict) -> bool:
|
|
163
|
+
def _validate_repository_format(config: dict[str, Any]) -> bool:
|
|
163
164
|
"""Validate template-repository format.
|
|
164
165
|
|
|
165
166
|
Args:
|
|
@@ -186,7 +187,7 @@ def _validate_repository_format(config: dict) -> bool:
|
|
|
186
187
|
return True
|
|
187
188
|
|
|
188
189
|
|
|
189
|
-
def _validate_include_paths(config: dict) -> bool:
|
|
190
|
+
def _validate_include_paths(config: dict[str, Any]) -> bool:
|
|
190
191
|
"""Validate include paths.
|
|
191
192
|
|
|
192
193
|
Args:
|
|
@@ -218,7 +219,7 @@ def _validate_include_paths(config: dict) -> bool:
|
|
|
218
219
|
return True
|
|
219
220
|
|
|
220
221
|
|
|
221
|
-
def _validate_optional_fields(config: dict) -> None:
|
|
222
|
+
def _validate_optional_fields(config: dict[str, Any]) -> None:
|
|
222
223
|
"""Validate optional fields if present.
|
|
223
224
|
|
|
224
225
|
Args:
|
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
|
|
@@ -44,7 +44,7 @@ Python projects using reusable templates stored in a central repository.
|
|
|
44
44
|
1. Initialize a project:
|
|
45
45
|
$ rhiza init
|
|
46
46
|
|
|
47
|
-
2. Customize .
|
|
47
|
+
2. Customize .rhiza/template.yml to match your needs
|
|
48
48
|
|
|
49
49
|
3. Materialize templates into your project:
|
|
50
50
|
$ rhiza materialize
|
rhiza/models.py
CHANGED
|
@@ -7,12 +7,13 @@ 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]
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def _normalize_to_list(value: str | list[str] | None) -> list[str]:
|
|
15
|
-
"""Convert a value to a list of strings.
|
|
16
|
+
r"""Convert a value to a list of strings.
|
|
16
17
|
|
|
17
18
|
Handles the case where YAML multi-line strings (using |) are parsed as
|
|
18
19
|
a single string instead of a list. Splits the string by newlines and
|
|
@@ -23,6 +24,20 @@ def _normalize_to_list(value: str | list[str] | None) -> list[str]:
|
|
|
23
24
|
|
|
24
25
|
Returns:
|
|
25
26
|
A list of strings. Empty list if value is None or empty.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> _normalize_to_list(None)
|
|
30
|
+
[]
|
|
31
|
+
>>> _normalize_to_list([])
|
|
32
|
+
[]
|
|
33
|
+
>>> _normalize_to_list(['a', 'b', 'c'])
|
|
34
|
+
['a', 'b', 'c']
|
|
35
|
+
>>> _normalize_to_list('single line')
|
|
36
|
+
['single line']
|
|
37
|
+
>>> _normalize_to_list('line1\\n' + 'line2\\n' + 'line3')
|
|
38
|
+
['line1', 'line2', 'line3']
|
|
39
|
+
>>> _normalize_to_list(' item1 \\n' + ' item2 ')
|
|
40
|
+
['item1', 'item2']
|
|
26
41
|
"""
|
|
27
42
|
if value is None:
|
|
28
43
|
return []
|
|
@@ -30,13 +45,20 @@ def _normalize_to_list(value: str | list[str] | None) -> list[str]:
|
|
|
30
45
|
return value
|
|
31
46
|
if isinstance(value, str):
|
|
32
47
|
# Split by newlines and filter out empty strings
|
|
33
|
-
|
|
48
|
+
# Handle both actual newlines (\n) and literal backslash-n (\\n)
|
|
49
|
+
if "\\n" in value and "\n" not in value:
|
|
50
|
+
# Contains literal \n but not actual newlines
|
|
51
|
+
items = value.split("\\n")
|
|
52
|
+
else:
|
|
53
|
+
# Contains actual newlines or neither
|
|
54
|
+
items = value.split("\n")
|
|
55
|
+
return [item.strip() for item in items if item.strip()]
|
|
34
56
|
return []
|
|
35
57
|
|
|
36
58
|
|
|
37
59
|
@dataclass
|
|
38
60
|
class RhizaTemplate:
|
|
39
|
-
"""Represents the structure of .
|
|
61
|
+
"""Represents the structure of .rhiza/template.yml.
|
|
40
62
|
|
|
41
63
|
Attributes:
|
|
42
64
|
template_repository: The GitHub or GitLab repository containing templates (e.g., "jebel-quant/rhiza").
|
|
@@ -74,7 +96,7 @@ class RhizaTemplate:
|
|
|
74
96
|
config = yaml.safe_load(f)
|
|
75
97
|
|
|
76
98
|
if not config:
|
|
77
|
-
raise ValueError("Template file is empty")
|
|
99
|
+
raise ValueError("Template file is empty") # noqa: TRY003
|
|
78
100
|
|
|
79
101
|
return cls(
|
|
80
102
|
template_repository=config.get("template-repository"),
|
|
@@ -94,7 +116,7 @@ class RhizaTemplate:
|
|
|
94
116
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
117
|
|
|
96
118
|
# Convert to dictionary with YAML-compatible keys
|
|
97
|
-
config = {}
|
|
119
|
+
config: dict[str, Any] = {}
|
|
98
120
|
|
|
99
121
|
# Only include template-repository if it's not None
|
|
100
122
|
if self.template_repository:
|