rhiza 0.7.0__py3-none-any.whl → 0.8.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.
@@ -0,0 +1,2 @@
1
+ """{{ project_name }}."""
2
+
@@ -0,0 +1,23 @@
1
+ """Main module for {{ project_name }}."""
2
+
3
+
4
+ def say_hello(name: str) -> str:
5
+ """Say hello to the user.
6
+
7
+ Args:
8
+ name: The name of the user.
9
+
10
+ Returns:
11
+ A greeting string.
12
+ """
13
+ return f"Hello, {name}!"
14
+
15
+
16
+ def main() -> None:
17
+ """Execute the main function."""
18
+ print(say_hello("World"))
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
23
+
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "{{ project_name }}"
7
+ version = "0.1.0"
8
+ description = "Add your description here"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = []
12
+ {% if with_dev_dependencies %}
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest-cov>=7.0.0",
17
+ "pytest-html>=4.1.1",
18
+ "pytest==9.0.2",
19
+ "pre-commit==4.5.1",
20
+ "marimo==0.18.4",
21
+ "pdoc>=16.0.0",
22
+ ]
23
+ {% endif %}
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/{{ package_name }}"]
27
+ {% if with_dev_dependencies %}
28
+
29
+ [tool.deptry]
30
+ # see https://deptry.com/usage/#pep-621-dev-dependency-groups
31
+ pep621_dev_dependency_groups = ["dev"]
32
+ {% endif %}
rhiza/cli.py CHANGED
@@ -12,6 +12,8 @@ from rhiza import __version__
12
12
  from rhiza.commands import init as init_cmd
13
13
  from rhiza.commands import materialize as materialize_cmd
14
14
  from rhiza.commands import validate as validate_cmd
15
+ from rhiza.commands.migrate import migrate as migrate_cmd
16
+ from rhiza.commands.uninstall import uninstall as uninstall_cmd
15
17
  from rhiza.commands.welcome import welcome as welcome_cmd
16
18
 
17
19
  app = typer.Typer(
@@ -70,14 +72,27 @@ def init(
70
72
  dir_okay=True,
71
73
  help="Target directory (defaults to current directory)",
72
74
  ),
75
+ project_name: str = typer.Option(
76
+ None,
77
+ "--project-name",
78
+ help="Custom project name (defaults to directory name)",
79
+ ),
80
+ package_name: str = typer.Option(
81
+ None,
82
+ "--package-name",
83
+ help="Custom package name (defaults to normalized project name)",
84
+ ),
85
+ with_dev_dependencies: bool = typer.Option(
86
+ False,
87
+ "--with-dev-dependencies",
88
+ help="Include development dependencies in pyproject.toml",
89
+ ),
73
90
  ):
74
91
  r"""Initialize or validate .github/rhiza/template.yml.
75
92
 
76
- \b
77
93
  Creates a default `.github/rhiza/template.yml` configuration file if one
78
94
  doesn't exist, or validates the existing configuration.
79
95
 
80
- \b
81
96
  The default template includes common Python project files:
82
97
  - .github (workflows, actions, etc.)
83
98
  - .editorconfig
@@ -86,13 +101,17 @@ def init(
86
101
  - Makefile
87
102
  - pytest.ini
88
103
 
89
- \b
90
104
  Examples:
91
105
  rhiza init
92
106
  rhiza init /path/to/project
93
107
  rhiza init ..
94
108
  """
95
- init_cmd(target)
109
+ init_cmd(
110
+ target,
111
+ project_name=project_name,
112
+ package_name=package_name,
113
+ with_dev_dependencies=with_dev_dependencies,
114
+ )
96
115
 
97
116
 
98
117
  @app.command()
@@ -115,18 +134,15 @@ def materialize(
115
134
  ):
116
135
  r"""Inject Rhiza configuration templates into a target repository.
117
136
 
118
- \b
119
137
  Materializes configuration files from the template repository specified
120
138
  in .github/rhiza/template.yml into your project. This command:
121
139
 
122
- \b
123
140
  - Reads .github/rhiza/template.yml configuration
124
141
  - Performs a sparse clone of the template repository
125
142
  - Copies specified files/directories to your project
126
143
  - Respects exclusion patterns defined in the configuration
127
144
  - Files that already exist will NOT be overwritten unless --force is used.
128
145
 
129
- \b
130
146
  Examples:
131
147
  rhiza materialize
132
148
  rhiza materialize --branch develop
@@ -152,7 +168,6 @@ def validate(
152
168
  Validates the .github/rhiza/template.yml file to ensure it is syntactically
153
169
  correct and semantically valid.
154
170
 
155
- \b
156
171
  Performs comprehensive validation:
157
172
  - Checks if template.yml exists
158
173
  - Validates YAML syntax
@@ -164,7 +179,6 @@ def validate(
164
179
 
165
180
  Returns exit code 0 on success, 1 on validation failure.
166
181
 
167
- \b
168
182
  Examples:
169
183
  rhiza validate
170
184
  rhiza validate /path/to/project
@@ -174,6 +188,40 @@ def validate(
174
188
  raise typer.Exit(code=1)
175
189
 
176
190
 
191
+ @app.command()
192
+ def migrate(
193
+ target: Path = typer.Argument(
194
+ default=Path("."), # default to current directory
195
+ exists=True,
196
+ file_okay=False,
197
+ dir_okay=True,
198
+ help="Target git repository (defaults to current directory)",
199
+ ),
200
+ ):
201
+ r"""Migrate project to the new .rhiza folder structure.
202
+
203
+ This command helps transition projects to use the new `.rhiza/` folder
204
+ structure for storing Rhiza state and configuration files. It performs
205
+ the following migrations:
206
+
207
+ - Creates the `.rhiza/` directory in the project root
208
+ - Moves `.github/rhiza/template.yml` or `.github/template.yml` to `.rhiza/template.yml`
209
+ - Moves `.rhiza.history` to `.rhiza/history`
210
+
211
+ The new `.rhiza/` folder structure separates Rhiza's state and configuration
212
+ from the `.github/` directory, providing better organization.
213
+
214
+ If files already exist in `.rhiza/`, the migration will skip them and leave
215
+ the old files in place. You can manually remove old files after verifying
216
+ the migration was successful.
217
+
218
+ Examples:
219
+ rhiza migrate
220
+ rhiza migrate /path/to/project
221
+ """
222
+ migrate_cmd(target)
223
+
224
+
177
225
  @app.command()
178
226
  def welcome():
179
227
  r"""Display a friendly welcome message and explain what Rhiza is.
@@ -181,8 +229,48 @@ def welcome():
181
229
  Shows a welcome message, explains Rhiza's purpose, key features,
182
230
  and provides guidance on getting started with the tool.
183
231
 
184
- \b
185
232
  Examples:
186
233
  rhiza welcome
187
234
  """
188
235
  welcome_cmd()
236
+
237
+
238
+ @app.command()
239
+ def uninstall(
240
+ target: Path = typer.Argument(
241
+ default=Path("."), # default to current directory
242
+ exists=True,
243
+ file_okay=False,
244
+ dir_okay=True,
245
+ help="Target git repository (defaults to current directory)",
246
+ ),
247
+ force: bool = typer.Option(
248
+ False,
249
+ "--force",
250
+ "-y",
251
+ help="Skip confirmation prompt and proceed with deletion",
252
+ ),
253
+ ):
254
+ r"""Remove all Rhiza-managed files from the repository.
255
+
256
+ Reads the `.rhiza.history` file and removes all files that were
257
+ previously materialized by Rhiza templates. This provides a clean
258
+ way to uninstall all template-managed files from a project.
259
+
260
+ The command will:
261
+ - Read the list of files from `.rhiza.history`
262
+ - Prompt for confirmation (unless --force is used)
263
+ - Delete all listed files that exist
264
+ - Remove empty directories left behind
265
+ - Delete the `.rhiza.history` file itself
266
+
267
+ Use this command when you want to completely remove Rhiza templates
268
+ from your project.
269
+
270
+ Examples:
271
+ rhiza uninstall
272
+ rhiza uninstall --force
273
+ rhiza uninstall /path/to/project
274
+ rhiza uninstall /path/to/project -y
275
+ """
276
+ uninstall_cmd(target, force)
rhiza/commands/init.py CHANGED
@@ -5,16 +5,47 @@ This module provides the init command that creates or validates the
5
5
  and what paths are governed by Rhiza.
6
6
  """
7
7
 
8
- import shutil
8
+ import importlib.resources
9
+ import keyword
10
+ import re
9
11
  from pathlib import Path
10
12
 
13
+ from jinja2 import Template
11
14
  from loguru import logger
12
15
 
13
16
  from rhiza.commands.validate import validate
14
17
  from rhiza.models import RhizaTemplate
15
18
 
16
19
 
17
- def init(target: Path):
20
+ def _normalize_package_name(name: str) -> str:
21
+ """Normalize a string into a valid Python package name.
22
+
23
+ Args:
24
+ name: The input string (e.g., project name).
25
+
26
+ Returns:
27
+ A valid Python identifier safe for use as a package name.
28
+ """
29
+ # Replace any character that is not a letter, number, or underscore with an underscore
30
+ name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
31
+
32
+ # Ensure it doesn't start with a number
33
+ if name[0].isdigit():
34
+ name = f"_{name}"
35
+
36
+ # Ensure it's not a Python keyword
37
+ if keyword.iskeyword(name):
38
+ name = f"{name}_"
39
+
40
+ return name
41
+
42
+
43
+ def init(
44
+ target: Path,
45
+ project_name: str | None = None,
46
+ package_name: str | None = None,
47
+ with_dev_dependencies: bool = False,
48
+ ):
18
49
  """Initialize or validate .github/rhiza/template.yml in the target repository.
19
50
 
20
51
  Creates a default .github/rhiza/template.yml file if it doesn't exist,
@@ -22,6 +53,9 @@ def init(target: Path):
22
53
 
23
54
  Args:
24
55
  target: Path to the target directory. Defaults to the current working directory.
56
+ project_name: Custom project name. Defaults to target directory name.
57
+ package_name: Custom package name. Defaults to normalized project name.
58
+ with_dev_dependencies: Include development dependencies in pyproject.toml.
25
59
 
26
60
  Returns:
27
61
  bool: True if validation passes, False otherwise.
@@ -31,29 +65,18 @@ def init(target: Path):
31
65
 
32
66
  logger.info(f"Initializing Rhiza configuration in: {target}")
33
67
 
34
- # Create .github/rhiza directory structure if it doesn't exist
68
+ # Create .rhiza directory structure if it doesn't exist
35
69
  # This is where Rhiza stores its configuration
36
- github_dir = target / ".github"
37
- rhiza_dir = github_dir / "rhiza"
70
+ rhiza_dir = target / ".rhiza"
38
71
  logger.debug(f"Ensuring directory exists: {rhiza_dir}")
39
72
  rhiza_dir.mkdir(parents=True, exist_ok=True)
40
73
 
41
- # Check for old location and migrate if necessary
42
- # TODO: This migration logic can be removed in a future version
43
- # after users have had time to migrate
44
- template_file = github_dir / "template.yml"
45
- if template_file.exists():
46
- logger.warning(f"Found template.yml in old location: {template_file}")
47
- logger.info(f"Copying to new location: {rhiza_dir / 'template.yml'}")
48
- # Copy the file to the new location (not move, to preserve old one temporarily)
49
- shutil.copyfile(template_file, rhiza_dir / "template.yml")
50
-
51
- # Define the template file path (new location)
74
+ # Define the template file path
52
75
  template_file = rhiza_dir / "template.yml"
53
76
 
54
77
  if not template_file.exists():
55
78
  # Create default template.yml with sensible defaults
56
- logger.info("Creating default .github/rhiza/template.yml")
79
+ logger.info("Creating default .rhiza/template.yml")
57
80
  logger.debug("Using default template configuration")
58
81
 
59
82
  # Default template points to the jebel-quant/rhiza repository
@@ -66,6 +89,7 @@ def init(target: Path):
66
89
  ".editorconfig", # Editor configuration
67
90
  ".gitignore", # Git ignore patterns
68
91
  ".pre-commit-config.yaml", # Pre-commit hooks
92
+ "ruff.toml", # Ruff linter configuration
69
93
  "Makefile", # Build and development tasks
70
94
  "pytest.ini", # Pytest configuration
71
95
  "book", # Documentation book
@@ -78,21 +102,27 @@ def init(target: Path):
78
102
  logger.debug(f"Writing default template to: {template_file}")
79
103
  default_template.to_yaml(template_file)
80
104
 
81
- logger.success("✓ Created .github/rhiza/template.yml")
105
+ logger.success("✓ Created .rhiza/template.yml")
82
106
  logger.info("""
83
107
  Next steps:
84
- 1. Review and customize .github/rhiza/template.yml to match your project needs
108
+ 1. Review and customize .rhiza/template.yml to match your project needs
85
109
  2. Run 'rhiza materialize' to inject templates into your repository
86
110
  """)
87
111
 
88
112
  # Bootstrap basic Python project structure if it doesn't exist
89
113
  # Get the name of the parent directory to use as package name
90
- parent = target.parent.name
91
- logger.debug(f"Parent directory name: {parent}")
114
+ if project_name is None:
115
+ project_name = target.name
116
+
117
+ if package_name is None:
118
+ package_name = _normalize_package_name(project_name)
119
+
120
+ logger.debug(f"Project name: {project_name}")
121
+ logger.debug(f"Package name: {package_name}")
92
122
 
93
- # Create src/{parent} directory structure following src-layout
94
- src_folder = target / "src" / parent
95
- if not src_folder.exists():
123
+ # Create src/{package_name} directory structure following src-layout
124
+ src_folder = target / "src" / package_name
125
+ if not (target / "src").exists():
96
126
  logger.info(f"Creating Python package structure: {src_folder}")
97
127
  src_folder.mkdir(parents=True)
98
128
 
@@ -101,22 +131,22 @@ Next steps:
101
131
  logger.debug(f"Creating {init_file}")
102
132
  init_file.touch()
103
133
 
134
+ template_content = (
135
+ importlib.resources.files("rhiza").joinpath("_templates/basic/__init__.py.jinja2").read_text()
136
+ )
137
+ template = Template(template_content)
138
+ code = template.render(project_name=project_name)
139
+ init_file.write_text(code)
140
+
104
141
  # Create main.py with a simple "Hello World" example
105
142
  main_file = src_folder / "main.py"
106
143
  logger.debug(f"Creating {main_file} with example code")
107
144
  main_file.touch()
108
145
 
109
146
  # Write example code to main.py
110
- code = """\
111
- def say_hello(name: str) -> str:
112
- return f"Hello, {name}!"
113
-
114
- def main():
115
- print(say_hello("World"))
116
-
117
- if __name__ == "__main__":
118
- main()
119
- """
147
+ template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/main.py.jinja2").read_text()
148
+ template = Template(template_content)
149
+ code = template.render(project_name=project_name)
120
150
  main_file.write_text(code)
121
151
  logger.success(f"Created Python package structure in {src_folder}")
122
152
 
@@ -128,15 +158,15 @@ Next steps:
128
158
  pyproject_file.touch()
129
159
 
130
160
  # Write minimal pyproject.toml content
131
- code = f'''\
132
- [project]
133
- name = "{parent}"
134
- version = "0.1.0"
135
- description = "Add your description here"
136
- readme = "README.md"
137
- requires-python = ">=3.11"
138
- dependencies = []
139
- '''
161
+ template_content = (
162
+ importlib.resources.files("rhiza").joinpath("_templates/basic/pyproject.toml.jinja2").read_text()
163
+ )
164
+ template = Template(template_content)
165
+ code = template.render(
166
+ project_name=project_name,
167
+ package_name=package_name,
168
+ with_dev_dependencies=with_dev_dependencies,
169
+ )
140
170
  pyproject_file.write_text(code)
141
171
  logger.success("Created pyproject.toml")
142
172
 
@@ -3,7 +3,7 @@
3
3
  This module implements the `materialize` command. It performs a sparse
4
4
  checkout of the configured template repository, copies the selected files
5
5
  into the target Git repository, and records managed files in
6
- `.rhiza.history`. Use this to take a one-shot snapshot of template files.
6
+ `.rhiza/history`. Use this to take a one-shot snapshot of template files.
7
7
  """
8
8
 
9
9
  import os
@@ -54,7 +54,7 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
54
54
 
55
55
  This performs a sparse checkout of the template repository and copies the
56
56
  selected files into the target repository, recording all files under
57
- template control in `.rhiza.history`.
57
+ template control in `.rhiza/history`.
58
58
 
59
59
  Args:
60
60
  target (Path): Path to the target repository.
@@ -128,8 +128,21 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
128
128
  sys.exit(1)
129
129
 
130
130
  # Load the template configuration from the validated file
131
- template_file = target / ".github" / "rhiza" / "template.yml"
132
- logger.debug(f"Loading template configuration from: {template_file}")
131
+ # Check for template in new location first, then fall back to old location
132
+ migrated_template_file = target / ".rhiza" / "template.yml"
133
+ standard_template_file = target / ".github" / "rhiza" / "template.yml"
134
+
135
+ if migrated_template_file.exists():
136
+ template_file = migrated_template_file
137
+ logger.debug(f"Loading template configuration from migrated location: {template_file}")
138
+ elif standard_template_file.exists():
139
+ template_file = standard_template_file
140
+ logger.debug(f"Loading template configuration from standard location: {template_file}")
141
+ else:
142
+ logger.error("No template.yml file found")
143
+ logger.error("Run 'rhiza init' or 'rhiza migrate' to create one")
144
+ sys.exit(1)
145
+
133
146
  template = RhizaTemplate.from_yaml(template_file)
134
147
 
135
148
  # Extract template configuration settings
@@ -323,13 +336,26 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
323
336
  # -----------------------
324
337
  # Clean up orphaned files
325
338
  # -----------------------
326
- # Read the old .rhiza.history file to find files that are no longer
339
+ # Read the old history file to find files that are no longer
327
340
  # part of the current materialization and should be deleted
328
- history_file = target / ".rhiza.history"
341
+ # Check both new and old locations for backward compatibility
342
+ new_history_file = target / ".rhiza" / "history"
343
+ old_history_file = target / ".rhiza.history"
344
+
345
+ # Prefer new location, but check old location for migration
346
+ if new_history_file.exists():
347
+ history_file = new_history_file
348
+ logger.debug(f"Reading existing history file from new location: {history_file.relative_to(target)}")
349
+ elif old_history_file.exists():
350
+ history_file = old_history_file
351
+ logger.debug(f"Reading existing history file from old location: {history_file.relative_to(target)}")
352
+ else:
353
+ history_file = new_history_file # Default to new location for creation
354
+ logger.debug("No existing history file found, will create new one")
355
+
329
356
  previously_tracked_files: set[Path] = set()
330
357
 
331
358
  if history_file.exists():
332
- logger.debug("Reading existing .rhiza.history file")
333
359
  with history_file.open("r", encoding="utf-8") as f:
334
360
  for line in f:
335
361
  line = line.strip()
@@ -337,7 +363,7 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
337
363
  if line and not line.startswith("#"):
338
364
  previously_tracked_files.add(Path(line))
339
365
 
340
- logger.debug(f"Found {len(previously_tracked_files)} file(s) in previous .rhiza.history")
366
+ logger.debug(f"Found {len(previously_tracked_files)} file(s) in previous history")
341
367
 
342
368
  # Convert materialized_files list to a set for comparison
343
369
  currently_materialized_files = set(materialized_files)
@@ -361,11 +387,17 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
361
387
  logger.debug("No orphaned files to clean up")
362
388
 
363
389
  # -----------------------
364
- # Write .rhiza.history
390
+ # Write history file
365
391
  # -----------------------
366
392
  # This file tracks which files were materialized by Rhiza
367
393
  # Useful for understanding which files came from the template
368
- logger.debug("Writing .rhiza.history file")
394
+ # Always write to new location (.rhiza/history)
395
+ history_file = target / ".rhiza" / "history"
396
+
397
+ # Ensure .rhiza directory exists
398
+ history_file.parent.mkdir(parents=True, exist_ok=True)
399
+
400
+ logger.debug(f"Writing history file: {history_file.relative_to(target)}")
369
401
  with history_file.open("w", encoding="utf-8") as f:
370
402
  f.write("# Rhiza Template History\n")
371
403
  f.write("# This file lists all files managed by the Rhiza template.\n")
@@ -379,6 +411,15 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
379
411
 
380
412
  logger.info(f"Updated {history_file.relative_to(target)} with {len(materialized_files)} file(s)")
381
413
 
414
+ # Clean up old history file if it exists (migration)
415
+ old_history_file = target / ".rhiza.history"
416
+ if old_history_file.exists() and old_history_file != history_file:
417
+ try:
418
+ old_history_file.unlink()
419
+ logger.debug(f"Removed old history file: {old_history_file.relative_to(target)}")
420
+ except Exception as e:
421
+ logger.warning(f"Could not remove old history file: {e}")
422
+
382
423
  logger.success("Rhiza templates materialized successfully")
383
424
 
384
425
  logger.info(
@@ -0,0 +1,128 @@
1
+ """Command for migrating to the new .rhiza folder structure.
2
+
3
+ This module implements the `migrate` command. It helps transition projects to use
4
+ the new `.rhiza/` folder structure for storing Rhiza state and configuration files,
5
+ separate from `.github/rhiza/` which contains template configuration.
6
+ """
7
+
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from loguru import logger
12
+
13
+
14
+ def migrate(target: Path) -> None:
15
+ """Migrate project to use the new .rhiza folder structure.
16
+
17
+ This command performs the following actions:
18
+ 1. Creates the `.rhiza/` directory in the project root
19
+ 2. Moves template.yml from `.github/rhiza/` or `.github/` to `.rhiza/template.yml`
20
+ 3. Moves `.rhiza.history` to `.rhiza/history` if it exists
21
+ 4. Provides instructions for next steps
22
+
23
+ The `.rhiza/` folder will contain:
24
+ - `template.yml` - Template configuration (replaces `.github/rhiza/template.yml`)
25
+ - `history` - List of files managed by Rhiza templates (replaces `.rhiza.history`)
26
+ - Future: Additional state, cache, or metadata files
27
+
28
+ Args:
29
+ target (Path): Path to the target repository.
30
+ """
31
+ # Resolve to absolute path
32
+ target = target.resolve()
33
+
34
+ logger.info(f"Migrating Rhiza structure in: {target}")
35
+ logger.info("This will create the .rhiza folder and migrate configuration files")
36
+
37
+ # Create .rhiza directory
38
+ rhiza_dir = target / ".rhiza"
39
+ if not rhiza_dir.exists():
40
+ logger.info(f"Creating .rhiza directory at: {rhiza_dir.relative_to(target)}")
41
+ rhiza_dir.mkdir(exist_ok=True)
42
+ logger.success(f"✓ Created {rhiza_dir.relative_to(target)}")
43
+ else:
44
+ logger.debug(f".rhiza directory already exists at: {rhiza_dir.relative_to(target)}")
45
+
46
+ # Track what was migrated for summary
47
+ migrations_performed = []
48
+
49
+ # Migrate template.yml from .github to .rhiza if it exists
50
+ github_dir = target / ".github"
51
+ new_template_file = rhiza_dir / "template.yml"
52
+
53
+ # Check possible locations for template.yml in .github
54
+ possible_template_locations = [
55
+ github_dir / "rhiza" / "template.yml",
56
+ github_dir / "template.yml",
57
+ ]
58
+
59
+ template_migrated = False
60
+ for old_template_file in possible_template_locations:
61
+ if old_template_file.exists():
62
+ if new_template_file.exists():
63
+ logger.info(".rhiza/template.yml already exists")
64
+ logger.info(f"Skipping migration of {old_template_file.relative_to(target)}")
65
+ logger.info(f"Note: Old file at {old_template_file.relative_to(target)} still exists")
66
+ else:
67
+ logger.info(f"Found template.yml at: {old_template_file.relative_to(target)}")
68
+ logger.info(f"Moving to new location: {new_template_file.relative_to(target)}")
69
+
70
+ # Move the template file to new location (not copy)
71
+ shutil.move(str(old_template_file), str(new_template_file))
72
+ logger.success("✓ Moved template.yml to .rhiza/template.yml")
73
+ migrations_performed.append("Moved template.yml to .rhiza/template.yml")
74
+ template_migrated = True
75
+ break
76
+
77
+ if not template_migrated:
78
+ if new_template_file.exists():
79
+ logger.info(".rhiza/template.yml already exists (no migration needed)")
80
+ else:
81
+ logger.warning("No existing template.yml file found in .github")
82
+ logger.info("You may need to run 'rhiza init' to create a template configuration")
83
+
84
+ # Migrate .rhiza.history to .rhiza/history if it exists
85
+ old_history_file = target / ".rhiza.history"
86
+ new_history_file = rhiza_dir / "history"
87
+
88
+ if old_history_file.exists():
89
+ if new_history_file.exists():
90
+ logger.info(".rhiza/history already exists")
91
+ logger.info(f"Skipping migration of {old_history_file.relative_to(target)}")
92
+ logger.info(f"Note: Old file at {old_history_file.relative_to(target)} still exists")
93
+ else:
94
+ logger.info("Found existing .rhiza.history file")
95
+ logger.info(f"Moving to new location: {new_history_file.relative_to(target)}")
96
+
97
+ # Move the history file to new location
98
+ shutil.move(str(old_history_file), str(new_history_file))
99
+ logger.success("✓ Moved history file to .rhiza/history")
100
+ migrations_performed.append("Moved history tracking to .rhiza/history")
101
+ else:
102
+ if new_history_file.exists():
103
+ logger.debug(".rhiza/history already exists (no migration needed)")
104
+ else:
105
+ logger.debug("No existing .rhiza.history file to migrate")
106
+
107
+ # Summary
108
+ logger.success("✓ Migration completed successfully")
109
+
110
+ if migrations_performed:
111
+ logger.info("\nMigration Summary:")
112
+ logger.info(" - Created .rhiza/ folder")
113
+ for migration in migrations_performed:
114
+ logger.info(f" - {migration}")
115
+ else:
116
+ logger.info("\nNo files needed migration (already using .rhiza structure)")
117
+
118
+ logger.info(
119
+ "\nNext steps:\n"
120
+ " 1. Review changes:\n"
121
+ " git status\n"
122
+ " git diff\n\n"
123
+ " 2. Update other commands to use new .rhiza/ location\n"
124
+ " (Future rhiza versions will automatically use .rhiza/)\n\n"
125
+ " 3. Commit the migration:\n"
126
+ " git add .\n"
127
+ ' git commit -m "chore: migrate to .rhiza folder structure"\n'
128
+ )
@@ -0,0 +1,151 @@
1
+ """Command for uninstalling Rhiza template files from a repository.
2
+
3
+ This module implements the `uninstall` command. It reads the `.rhiza/history`
4
+ file and removes all files that were previously materialized by Rhiza templates.
5
+ This provides a clean way to remove all template-managed files from a project.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from loguru import logger
12
+
13
+
14
+ def uninstall(target: Path, force: bool) -> None:
15
+ """Uninstall Rhiza templates from the target repository.
16
+
17
+ Reads the `.rhiza/history` file and removes all files listed in it.
18
+ This effectively removes all files that were materialized by Rhiza.
19
+
20
+ Args:
21
+ target (Path): Path to the target repository.
22
+ force (bool): If True, skip confirmation prompt and proceed with deletion.
23
+ """
24
+ # Resolve to absolute path to avoid any ambiguity
25
+ target = target.resolve()
26
+
27
+ logger.info(f"Target repository: {target}")
28
+
29
+ # Check for history file in new location only
30
+ history_file = target / ".rhiza" / "history"
31
+
32
+ if not history_file.exists():
33
+ logger.warning(f"No history file found at: {history_file.relative_to(target)}")
34
+ logger.info("Nothing to uninstall. This repository may not have Rhiza templates materialized.")
35
+ logger.info("If you haven't migrated yet, run 'rhiza migrate' first.")
36
+ return
37
+
38
+ # Read the history file
39
+ logger.debug(f"Reading history file: {history_file.relative_to(target)}")
40
+ files_to_remove: list[Path] = []
41
+
42
+ with history_file.open("r", encoding="utf-8") as f:
43
+ for line in f:
44
+ line = line.strip()
45
+ # Skip comments and empty lines
46
+ if line and not line.startswith("#"):
47
+ file_path = Path(line)
48
+ files_to_remove.append(file_path)
49
+
50
+ if not files_to_remove:
51
+ logger.warning("History file is empty (only contains comments)")
52
+ logger.info("Nothing to uninstall.")
53
+ return
54
+
55
+ logger.info(f"Found {len(files_to_remove)} file(s) to remove")
56
+
57
+ # Show confirmation prompt unless --force is used
58
+ if not force:
59
+ logger.warning("This will remove the following files from your repository:")
60
+ for file_path in sorted(files_to_remove):
61
+ full_path = target / file_path
62
+ if full_path.exists():
63
+ logger.warning(f" - {file_path}")
64
+ else:
65
+ logger.debug(f" - {file_path} (already deleted)")
66
+
67
+ # Prompt for confirmation
68
+ try:
69
+ response = input("\nAre you sure you want to proceed? [y/N]: ").strip().lower()
70
+ if response not in ("y", "yes"):
71
+ logger.info("Uninstall cancelled by user")
72
+ return
73
+ except (KeyboardInterrupt, EOFError):
74
+ logger.info("\nUninstall cancelled by user")
75
+ return
76
+
77
+ # Remove files
78
+ logger.info("Removing files...")
79
+ removed_count = 0
80
+ skipped_count = 0
81
+ error_count = 0
82
+
83
+ for file_path in sorted(files_to_remove):
84
+ full_path = target / file_path
85
+
86
+ if not full_path.exists():
87
+ logger.debug(f"[SKIP] {file_path} (already deleted)")
88
+ skipped_count += 1
89
+ continue
90
+
91
+ try:
92
+ full_path.unlink()
93
+ logger.success(f"[DEL] {file_path}")
94
+ removed_count += 1
95
+ except Exception as e:
96
+ logger.error(f"Failed to delete {file_path}: {e}")
97
+ error_count += 1
98
+
99
+ # Clean up empty directories
100
+ logger.debug("Cleaning up empty directories...")
101
+ empty_dirs_removed = 0
102
+ for file_path in sorted(files_to_remove, reverse=True):
103
+ full_path = target / file_path
104
+ parent = full_path.parent
105
+
106
+ # Try to remove parent directories if they're empty
107
+ # Walk up the directory tree
108
+ while parent != target and parent.exists():
109
+ try:
110
+ # Only remove if directory is empty
111
+ if parent.is_dir() and not any(parent.iterdir()):
112
+ parent.rmdir()
113
+ logger.debug(f"[DEL] {parent.relative_to(target)}/ (empty directory)")
114
+ empty_dirs_removed += 1
115
+ parent = parent.parent
116
+ else:
117
+ break
118
+ except Exception:
119
+ # Directory not empty or other error, stop walking up
120
+ break
121
+
122
+ # Remove history file itself
123
+ try:
124
+ history_file.unlink()
125
+ logger.success(f"[DEL] {history_file.relative_to(target)}")
126
+ removed_count += 1
127
+ except Exception as e:
128
+ logger.error(f"Failed to delete {history_file.relative_to(target)}: {e}")
129
+ error_count += 1
130
+
131
+ # Summary
132
+ logger.info("\nUninstall summary:")
133
+ logger.info(f" Files removed: {removed_count}")
134
+ if skipped_count > 0:
135
+ logger.info(f" Files skipped (already deleted): {skipped_count}")
136
+ if empty_dirs_removed > 0:
137
+ logger.info(f" Empty directories removed: {empty_dirs_removed}")
138
+ if error_count > 0:
139
+ logger.error(f" Errors encountered: {error_count}")
140
+ sys.exit(1)
141
+
142
+ logger.success("Rhiza templates uninstalled successfully")
143
+ logger.info(
144
+ "Next steps:\n"
145
+ " Review changes:\n"
146
+ " git status\n"
147
+ " git diff\n\n"
148
+ " Commit:\n"
149
+ " git add .\n"
150
+ ' git commit -m "chore: remove rhiza templates"'
151
+ )
@@ -1,7 +1,7 @@
1
1
  """Command for validating Rhiza template configuration.
2
2
 
3
- This module provides functionality to validate .github/rhiza/template.yml files
4
- to ensure they are syntactically correct and semantically valid.
3
+ This module provides functionality to validate template.yml files in the
4
+ .rhiza/template.yml location (new standard location after migration).
5
5
  """
6
6
 
7
7
  from pathlib import Path
@@ -14,6 +14,9 @@ def validate(target: Path) -> bool:
14
14
  """Validate template.yml configuration in the target repository.
15
15
 
16
16
  Performs authoritative validation of the template configuration:
17
+ - Checks if target is a git repository
18
+ - Checks for standard project structure (src and tests folders)
19
+ - Checks for pyproject.toml (required)
17
20
  - Checks if template.yml exists
18
21
  - Validates YAML syntax
19
22
  - Validates required fields
@@ -37,30 +40,51 @@ def validate(target: Path) -> bool:
37
40
 
38
41
  logger.info(f"Validating template configuration in: {target}")
39
42
 
40
- # Check for template.yml in both new and old locations
41
- # New location: .github/rhiza/template.yml
42
- # Old location: .github/template.yml (deprecated but still supported)
43
- new_location = target / ".github" / "rhiza" / "template.yml"
44
- deprecated_location = target / ".github" / "template.yml"
43
+ # Check for standard project structure (src and tests folders)
44
+ logger.debug("Validating project structure")
45
+ src_dir = target / "src"
46
+ tests_dir = target / "tests"
45
47
 
46
- # Check which file(s) exist
47
- new_exists = new_location.exists()
48
- deprecated_exists = deprecated_location.exists()
48
+ if not src_dir.exists():
49
+ logger.warning(f"Standard 'src' folder not found: {src_dir}")
50
+ logger.warning("Consider creating a 'src' directory for source code")
51
+ else:
52
+ logger.success(f"'src' folder exists: {src_dir}")
49
53
 
50
- if not (new_exists or deprecated_exists):
51
- logger.error(f"No template file found at: {new_location}")
52
- logger.error(f"Also checked deprecated location: {deprecated_location}")
53
- logger.info("Run 'rhiza init' to create a default template.yml")
54
- return False
54
+ if not tests_dir.exists():
55
+ logger.warning(f"Standard 'tests' folder not found: {tests_dir}")
56
+ logger.warning("Consider creating a 'tests' directory for test files")
57
+ else:
58
+ logger.success(f"'tests' folder exists: {tests_dir}")
59
+
60
+ # Check for pyproject.toml - this is always required
61
+ logger.debug("Validating pyproject.toml")
62
+ pyproject_file = target / "pyproject.toml"
55
63
 
56
- # Prefer the new location but support the old one with a warning
57
- if new_exists:
58
- logger.success(f"Template file exists: {new_location}")
59
- template_file = new_location
64
+ if not pyproject_file.exists():
65
+ logger.error(f"pyproject.toml not found: {pyproject_file}")
66
+ logger.error("pyproject.toml is required for Python projects")
67
+ logger.info("Run 'rhiza init' to create a default pyproject.toml")
68
+ return False
60
69
  else:
61
- logger.warning(f"Template file exists but in old location: {deprecated_location}")
62
- logger.warning("Consider moving it to .github/rhiza/template.yml")
63
- template_file = deprecated_location
70
+ logger.success(f"pyproject.toml exists: {pyproject_file}")
71
+
72
+ # Check for template.yml in new location only
73
+ template_file = target / ".rhiza" / "template.yml"
74
+
75
+ if not template_file.exists():
76
+ logger.error(f"No template file found at: {template_file.relative_to(target)}")
77
+ logger.error("The template configuration must be in the .rhiza folder.")
78
+ logger.info("")
79
+ logger.info("To fix this:")
80
+ logger.info(" • If you're starting fresh, run: rhiza init")
81
+ logger.info(" • If you have an existing configuration, run: rhiza migrate")
82
+ logger.info("")
83
+ logger.info("The 'rhiza migrate' command will move your configuration from")
84
+ logger.info(" .github/rhiza/template.yml → .rhiza/template.yml")
85
+ return False
86
+
87
+ logger.success(f"Template file exists: {template_file.relative_to(target)}")
64
88
 
65
89
  # Validate YAML syntax by attempting to parse the file
66
90
  logger.debug(f"Parsing YAML file: {template_file}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhiza
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Software Development :: Build Tools
21
21
  Requires-Python: >=3.11
22
+ Requires-Dist: jinja2>=3.1.0
22
23
  Requires-Dist: loguru>=0.7.3
23
24
  Requires-Dist: pyyaml==6.0.3
24
25
  Requires-Dist: typer>=0.20.0
@@ -60,6 +61,7 @@ Rhiza is a CLI tool that helps you maintain consistent configuration across mult
60
61
  - [Commands](#commands)
61
62
  - [init](#rhiza-init)
62
63
  - [materialize](#rhiza-materialize)
64
+ - [migrate](#rhiza-migrate)
63
65
  - [validate](#rhiza-validate)
64
66
  - [Configuration](#configuration)
65
67
  - [Examples](#examples)
@@ -316,6 +318,86 @@ Re-run this script to update templates explicitly.
316
318
 
317
319
  ---
318
320
 
321
+ ### `rhiza migrate`
322
+
323
+ Migrate project to the new `.rhiza` folder structure.
324
+
325
+ **Usage:**
326
+
327
+ ```bash
328
+ rhiza migrate [OPTIONS] [TARGET]
329
+ ```
330
+
331
+ **Arguments:**
332
+
333
+ - `TARGET` - Target git repository directory (defaults to current directory)
334
+
335
+ **Arguments:**
336
+
337
+ - `TARGET` - Target git repository directory (defaults to current directory)
338
+
339
+ **Description:**
340
+
341
+ Migrates your project to use the new `.rhiza/` folder structure for storing Rhiza state and configuration files. This command helps transition from the old structure where configuration was stored in `.github/rhiza/` and `.rhiza.history` in the project root.
342
+
343
+ The migration performs the following actions:
344
+
345
+ 1. Creates the `.rhiza/` directory in the project root
346
+ 2. Moves `template.yml` from `.github/rhiza/` or `.github/` to `.rhiza/template.yml`
347
+ 3. Moves `.rhiza.history` to `.rhiza/history`
348
+ 4. Provides instructions for next steps
349
+
350
+ The new `.rhiza/` folder structure provides better organization by separating Rhiza's state and configuration from the `.github/` directory.
351
+
352
+ **Examples:**
353
+
354
+ ```bash
355
+ # Migrate current directory
356
+ rhiza migrate
357
+
358
+ # Migrate a specific directory
359
+ rhiza migrate /path/to/project
360
+ ```
361
+
362
+ **Output:**
363
+
364
+ ```
365
+ [INFO] Migrating Rhiza structure in: /path/to/project
366
+ [INFO] This will create the .rhiza folder and migrate configuration files
367
+ [INFO] Creating .rhiza directory at: .rhiza
368
+ ✓ Created .rhiza
369
+ [INFO] Found template.yml at: .github/rhiza/template.yml
370
+ [INFO] Moving to new location: .rhiza/template.yml
371
+ ✓ Moved template.yml to .rhiza/template.yml
372
+ ✓ Migration completed successfully
373
+
374
+ Migration Summary:
375
+ - Created .rhiza/ folder
376
+ - Moved template.yml to .rhiza/template.yml
377
+ - Moved history tracking to .rhiza/history
378
+
379
+ Next steps:
380
+ 1. Review changes:
381
+ git status
382
+ git diff
383
+
384
+ 2. Update other commands to use new .rhiza/ location
385
+ (Future rhiza versions will automatically use .rhiza/)
386
+
387
+ 3. Commit the migration:
388
+ git add .
389
+ git commit -m "chore: migrate to .rhiza folder structure"
390
+ ```
391
+
392
+ **Notes:**
393
+
394
+ - If files already exist in `.rhiza/`, the migration will skip them and leave the old files in place
395
+ - You can manually remove old files after verifying the migration was successful
396
+ - The old `.rhiza.history` file is removed after successful migration to `.rhiza/history`
397
+ - The original template file in `.github/` is moved (removed from old location)
398
+
399
+ ---
400
+
319
401
  ### `rhiza validate`
320
402
 
321
403
  Validate Rhiza template configuration.
@@ -0,0 +1,19 @@
1
+ rhiza/__init__.py,sha256=iW3niLBjwRKxcMhIV_1eb78putjUTo2tbZsadofluJk,1939
2
+ rhiza/__main__.py,sha256=Lx0GqVZo6ymm0f18_uYB6E7_SOWwJNYjb73Vr31oLoM,236
3
+ rhiza/cli.py,sha256=I5A5d1-3xrL2gdh5H9Itm9uiQjoPiGEbHYyxXddHEOk,8196
4
+ rhiza/models.py,sha256=fW9lofkkid-bghk2bXEgBdGbZ4scSqG726fMrVfKX_M,3454
5
+ rhiza/_templates/basic/__init__.py.jinja2,sha256=gs8qN4LAKcdFd6iO9gZVLuVetODmZP_TGuEjWrbinC0,27
6
+ rhiza/_templates/basic/main.py.jinja2,sha256=uTCahxf9Bftao1IghHue4cSZ9YzBYmBEXeIhEmK9UXQ,362
7
+ rhiza/_templates/basic/pyproject.toml.jinja2,sha256=Mizpnnd_kFQd-pCWOxG-KWhvg4_ZhZaQppTt2pz0WOc,695
8
+ rhiza/commands/__init__.py,sha256=Z5CeMh7ylX27H6dvwqRbEKzYo5pwQq-5TyTxABUSaQg,1848
9
+ rhiza/commands/init.py,sha256=RsYmomOq-00b4bAjseiHR7ljgJQ_XSjDX-ZWKYTPPN8,6787
10
+ rhiza/commands/materialize.py,sha256=kzcpiKDN0HZQylZW-TZVlrJJctHnmBxq2JQQcNGyOV8,17951
11
+ rhiza/commands/migrate.py,sha256=Zlu-Oq4MJCW3jcKqB2v1-wV3zEr2s6AqYV_btvGOjCE,5425
12
+ rhiza/commands/uninstall.py,sha256=z95xqamV7wGPr8PBveWzaRmtD5bPhOrTLI0GcOvpnAo,5371
13
+ rhiza/commands/validate.py,sha256=1HMQWF9Syv7JKC31AkaPYMTnqy8HOvAMxMLrYty36FQ,9112
14
+ rhiza/commands/welcome.py,sha256=w3BziR042o6oYincd3EqDsFzF6qqInU7iYhWjF3yJqY,2382
15
+ rhiza-0.8.0.dist-info/METADATA,sha256=k_wVNzEknv4GiXwVCxmeUvQ8ex0OXDVsfOkiWELyI9g,25156
16
+ rhiza-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
17
+ rhiza-0.8.0.dist-info/entry_points.txt,sha256=NAwZUpbXvfKv50a_Qq-PxMHl3lcjAyZO63IBeuUNgfY,45
18
+ rhiza-0.8.0.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
19
+ rhiza-0.8.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- rhiza/__init__.py,sha256=iW3niLBjwRKxcMhIV_1eb78putjUTo2tbZsadofluJk,1939
2
- rhiza/__main__.py,sha256=Lx0GqVZo6ymm0f18_uYB6E7_SOWwJNYjb73Vr31oLoM,236
3
- rhiza/cli.py,sha256=faCIOKDzEDRvL4doLZhiIAyHUUGESGrwWLtjLjimCUY,5111
4
- rhiza/models.py,sha256=fW9lofkkid-bghk2bXEgBdGbZ4scSqG726fMrVfKX_M,3454
5
- rhiza/commands/__init__.py,sha256=Z5CeMh7ylX27H6dvwqRbEKzYo5pwQq-5TyTxABUSaQg,1848
6
- rhiza/commands/init.py,sha256=Hrox_o8hnyWMkx4SuE0rd4jqGIBEl_V_wh7BAiW9IFU,5726
7
- rhiza/commands/materialize.py,sha256=jmXH9Fb3lkktxgdWtZ2cQk0wyURleWzHvQN6n_DNZ7U,16049
8
- rhiza/commands/validate.py,sha256=cxStfXbY_ifsc_yRDCg0TOnv8jG05hxE9rteta-X9hQ,8093
9
- rhiza/commands/welcome.py,sha256=w3BziR042o6oYincd3EqDsFzF6qqInU7iYhWjF3yJqY,2382
10
- rhiza-0.7.0.dist-info/METADATA,sha256=00UAERh_SGq5WoD8xZsbl83SdNZBO8QjBCP0Al-qQKM,22742
11
- rhiza-0.7.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- rhiza-0.7.0.dist-info/entry_points.txt,sha256=NAwZUpbXvfKv50a_Qq-PxMHl3lcjAyZO63IBeuUNgfY,45
13
- rhiza-0.7.0.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
14
- rhiza-0.7.0.dist-info/RECORD,,
File without changes