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.
@@ -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")
@@ -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:
@@ -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(" .github/rhiza/template.yml → .rhiza/template.yml")
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 .github/rhiza/template.yml to match your needs
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
- return [item.strip() for item in value.strip().split("\n") if item.strip()]
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 .github/rhiza/template.yml.
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: