rhiza 0.8.0__tar.gz → 0.8.2__tar.gz

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.
Files changed (87) hide show
  1. {rhiza-0.8.0 → rhiza-0.8.2}/PKG-INFO +1 -1
  2. {rhiza-0.8.0 → rhiza-0.8.2}/pyproject.toml +1 -1
  3. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/init.py +1 -0
  4. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/materialize.py +6 -19
  5. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/migrate.py +19 -0
  6. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_commands/test_materialize.py +302 -34
  7. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_commands/test_migrate.py +132 -1
  8. {rhiza-0.8.0 → rhiza-0.8.2}/uv.lock +1 -1
  9. {rhiza-0.8.0 → rhiza-0.8.2}/.editorconfig +0 -0
  10. {rhiza-0.8.0 → rhiza-0.8.2}/.github/dependabot.yml +0 -0
  11. {rhiza-0.8.0 → rhiza-0.8.2}/.github/rhiza/actions/setup-project/action.yml +0 -0
  12. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_book.yml +0 -0
  13. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_ci.yml +0 -0
  14. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_deptry.yml +0 -0
  15. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_devcontainer.yml +0 -0
  16. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_docker.yml +0 -0
  17. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_marimo.yml +0 -0
  18. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_pre-commit.yml +0 -0
  19. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_release.yml +0 -0
  20. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_sync.yml +0 -0
  21. {rhiza-0.8.0 → rhiza-0.8.2}/.github/workflows/rhiza_validate.yml +0 -0
  22. {rhiza-0.8.0 → rhiza-0.8.2}/.gitignore +0 -0
  23. {rhiza-0.8.0 → rhiza-0.8.2}/.pre-commit-config.yaml +0 -0
  24. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/CONFIG.md +0 -0
  25. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/TOKEN_SETUP.md +0 -0
  26. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/history +0 -0
  27. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/book.sh +0 -0
  28. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/bump.sh +0 -0
  29. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/customisations/build-extras.sh +0 -0
  30. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/customisations/post-release.sh +0 -0
  31. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/marimushka.sh +0 -0
  32. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/release.sh +0 -0
  33. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/scripts/update-readme-help.sh +0 -0
  34. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/template.yml +0 -0
  35. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/utils/version_matrix.py +0 -0
  36. {rhiza-0.8.0 → rhiza-0.8.2}/.rhiza/utils/version_max.py +0 -0
  37. {rhiza-0.8.0 → rhiza-0.8.2}/CLI.md +0 -0
  38. {rhiza-0.8.0 → rhiza-0.8.2}/CODE_OF_CONDUCT.md +0 -0
  39. {rhiza-0.8.0 → rhiza-0.8.2}/CONTRIBUTING.md +0 -0
  40. {rhiza-0.8.0 → rhiza-0.8.2}/GETTING_STARTED.md +0 -0
  41. {rhiza-0.8.0 → rhiza-0.8.2}/LICENSE +0 -0
  42. {rhiza-0.8.0 → rhiza-0.8.2}/Makefile +0 -0
  43. {rhiza-0.8.0 → rhiza-0.8.2}/README.md +0 -0
  44. {rhiza-0.8.0 → rhiza-0.8.2}/USAGE.md +0 -0
  45. {rhiza-0.8.0 → rhiza-0.8.2}/book/Makefile.book +0 -0
  46. {rhiza-0.8.0 → rhiza-0.8.2}/book/marimo/.gitkeep +0 -0
  47. {rhiza-0.8.0 → rhiza-0.8.2}/book/marimo/README.md +0 -0
  48. {rhiza-0.8.0 → rhiza-0.8.2}/book/marimo/rhiza.py +0 -0
  49. {rhiza-0.8.0 → rhiza-0.8.2}/book/minibook-templates/custom.html.jinja2 +0 -0
  50. {rhiza-0.8.0 → rhiza-0.8.2}/book/pdoc-templates/module.html.jinja2 +0 -0
  51. {rhiza-0.8.0 → rhiza-0.8.2}/presentation/Makefile.presentation +0 -0
  52. {rhiza-0.8.0 → rhiza-0.8.2}/presentation/README.md +0 -0
  53. {rhiza-0.8.0 → rhiza-0.8.2}/pytest.ini +0 -0
  54. {rhiza-0.8.0 → rhiza-0.8.2}/renovate.json +0 -0
  55. {rhiza-0.8.0 → rhiza-0.8.2}/ruff.toml +0 -0
  56. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/__init__.py +0 -0
  57. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/__main__.py +0 -0
  58. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/_templates/basic/__init__.py.jinja2 +0 -0
  59. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/_templates/basic/main.py.jinja2 +0 -0
  60. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/_templates/basic/pyproject.toml.jinja2 +0 -0
  61. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/cli.py +0 -0
  62. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/__init__.py +0 -0
  63. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/uninstall.py +0 -0
  64. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/validate.py +0 -0
  65. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/commands/welcome.py +0 -0
  66. {rhiza-0.8.0 → rhiza-0.8.2}/src/rhiza/models.py +0 -0
  67. {rhiza-0.8.0 → rhiza-0.8.2}/tests/Makefile.tests +0 -0
  68. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_cli_commands.py +0 -0
  69. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_commands/test_init.py +0 -0
  70. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_commands/test_uninstall.py +0 -0
  71. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_commands/test_validate.py +0 -0
  72. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_models.py +0 -0
  73. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_package.py +0 -0
  74. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/README.md +0 -0
  75. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/benchmarks/.gitignore +0 -0
  76. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/benchmarks/README.md +0 -0
  77. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/benchmarks/analyze_benchmarks.py +0 -0
  78. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/conftest.py +0 -0
  79. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_bump_script.py +0 -0
  80. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_docstrings.py +0 -0
  81. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_git_repo_fixture.py +0 -0
  82. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_makefile.py +0 -0
  83. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_marimushka_script.py +0 -0
  84. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_readme.py +0 -0
  85. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_release_script.py +0 -0
  86. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_structure.py +0 -0
  87. {rhiza-0.8.0 → rhiza-0.8.2}/tests/test_rhiza/test_updatereadme_script.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhiza
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rhiza"
7
- version = "0.8.0"
7
+ version = "0.8.2"
8
8
  description = "Reusable configuration templates for modern Python projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -85,6 +85,7 @@ def init(
85
85
  template_repository="jebel-quant/rhiza",
86
86
  template_branch="main",
87
87
  include=[
88
+ ".rhiza", # .rhiza folder
88
89
  ".github", # GitHub configuration and workflows
89
90
  ".editorconfig", # Editor configuration
90
91
  ".gitignore", # Git ignore patterns
@@ -15,7 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from loguru import logger
17
17
 
18
- from rhiza.commands import init
18
+ from rhiza.commands.validate import validate
19
19
  from rhiza.models import RhizaTemplate
20
20
 
21
21
 
@@ -116,11 +116,11 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
116
116
  sys.exit(1)
117
117
 
118
118
  # -----------------------
119
- # Ensure Rhiza is initialized
119
+ # Validate Rhiza configuration
120
120
  # -----------------------
121
- # The init function creates template.yml if missing and validates it
121
+ # The validate function checks if template.yml exists and is valid
122
122
  # Returns True if valid, False otherwise
123
- valid = init(target)
123
+ valid = validate(target)
124
124
 
125
125
  if not valid:
126
126
  logger.error(f"Rhiza template is invalid in: {target}")
@@ -128,21 +128,8 @@ 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
- # 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
-
131
+ # Validation ensures the file exists at .rhiza/template.yml
132
+ template_file = target / ".rhiza" / "template.yml"
146
133
  template = RhizaTemplate.from_yaml(template_file)
147
134
 
148
135
  # Extract template configuration settings
@@ -10,6 +10,8 @@ from pathlib import Path
10
10
 
11
11
  from loguru import logger
12
12
 
13
+ from rhiza.models import RhizaTemplate
14
+
13
15
 
14
16
  def migrate(target: Path) -> None:
15
17
  """Migrate project to use the new .rhiza folder structure.
@@ -81,6 +83,23 @@ def migrate(target: Path) -> None:
81
83
  logger.warning("No existing template.yml file found in .github")
82
84
  logger.info("You may need to run 'rhiza init' to create a template configuration")
83
85
 
86
+ # Ensure the .rhiza folder is included in template.yml include list (if template exists)
87
+ template_file = new_template_file
88
+ if template_file.exists():
89
+ # Load existing template configuration
90
+ template = RhizaTemplate.from_yaml(template_file)
91
+ template_include = template.include or []
92
+ if ".rhiza" not in template_include:
93
+ logger.warning("The .rhiza folder is not included in your template.yml")
94
+ template_include.append(".rhiza")
95
+ logger.info("The .rhiza folder is added to your template.yml to ensure it's included in your repository")
96
+
97
+ # Save the updated template.yml
98
+ template.include = template_include
99
+ template.to_yaml(template_file)
100
+ else:
101
+ logger.debug("No template.yml present in .rhiza; skipping include update")
102
+
84
103
  # Migrate .rhiza.history to .rhiza/history if it exists
85
104
  old_history_file = target / ".rhiza.history"
86
105
  new_history_file = rhiza_dir / "history"
@@ -5,6 +5,7 @@ underlying inject logic and that basic paths and options are handled.
5
5
  """
6
6
 
7
7
  import subprocess
8
+ from pathlib import Path
8
9
  from unittest.mock import Mock, patch
9
10
 
10
11
  import pytest
@@ -18,41 +19,20 @@ from rhiza.commands.materialize import materialize
18
19
  class TestInjectCommand:
19
20
  """Tests for the inject/materialize command."""
20
21
 
21
- @patch("rhiza.commands.materialize.subprocess.run")
22
- @patch("rhiza.commands.materialize.shutil.rmtree")
23
- @patch("rhiza.commands.materialize.shutil.copy2")
24
- @patch("rhiza.commands.materialize.tempfile.mkdtemp")
25
- def test_inject_creates_default_template_yml(
26
- self, mock_mkdtemp, mock_copy2, mock_rmtree, mock_subprocess, tmp_path
27
- ):
28
- """Test that inject creates a default template.yml when it doesn't exist."""
22
+ def test_inject_fails_without_template_yml(self, tmp_path):
23
+ """Test that materialize fails when template.yml doesn't exist."""
29
24
  # Setup git repo
30
25
  git_dir = tmp_path / ".git"
31
26
  git_dir.mkdir()
32
27
 
33
- # Mock tempfile to return a controlled temp directory
34
- temp_dir = tmp_path / "temp"
35
- temp_dir.mkdir()
36
- mock_mkdtemp.return_value = str(temp_dir)
37
-
38
- # Mock subprocess to succeed
39
- mock_subprocess.return_value = Mock(returncode=0)
28
+ # Create required pyproject.toml (needed for validation to not fail earlier)
29
+ pyproject_file = tmp_path / "pyproject.toml"
30
+ pyproject_file.write_text("[project]\nname = 'test'\n")
40
31
 
41
- # Run inject
42
- materialize(tmp_path, "main", None, False)
43
-
44
- # Verify template.yml was created
45
- template_file = tmp_path / ".rhiza" / "template.yml"
46
- assert template_file.exists()
47
-
48
- # Verify it contains expected content
49
-
50
- with open(template_file) as f:
51
- config = yaml.safe_load(f)
52
-
53
- assert config["template-repository"] == "jebel-quant/rhiza"
54
- assert config["template-branch"] == "main"
55
- assert ".github" in config["include"]
32
+ # Run materialize without creating template.yml first
33
+ # It should fail because template.yml doesn't exist
34
+ with pytest.raises(SystemExit):
35
+ materialize(tmp_path, "main", None, False)
56
36
 
57
37
  @patch("rhiza.commands.materialize.subprocess.run")
58
38
  @patch("rhiza.commands.materialize.shutil.rmtree")
@@ -64,6 +44,10 @@ class TestInjectCommand:
64
44
  git_dir = tmp_path / ".git"
65
45
  git_dir.mkdir()
66
46
 
47
+ # Create pyproject.toml for validation
48
+ pyproject_file = tmp_path / "pyproject.toml"
49
+ pyproject_file.write_text('[project]\nname = "test"\n')
50
+
67
51
  # Create existing template.yml
68
52
  rhiza_dir = tmp_path / ".rhiza"
69
53
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -99,6 +83,10 @@ class TestInjectCommand:
99
83
  git_dir = tmp_path / ".git"
100
84
  git_dir.mkdir()
101
85
 
86
+ # Create pyproject.toml for validation
87
+ pyproject_file = tmp_path / "pyproject.toml"
88
+ pyproject_file.write_text('[project]\nname = "test"\n')
89
+
102
90
  # Create template.yml with empty include
103
91
  rhiza_dir = tmp_path / ".rhiza"
104
92
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -121,6 +109,10 @@ class TestInjectCommand:
121
109
  git_dir = tmp_path / ".git"
122
110
  git_dir.mkdir()
123
111
 
112
+ # Create pyproject.toml for validation
113
+ pyproject_file = tmp_path / "pyproject.toml"
114
+ pyproject_file.write_text('[project]\nname = "test"\n')
115
+
124
116
  # Create template.yml
125
117
  rhiza_dir = tmp_path / ".rhiza"
126
118
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -159,6 +151,10 @@ class TestInjectCommand:
159
151
  git_dir = tmp_path / ".git"
160
152
  git_dir.mkdir()
161
153
 
154
+ # Create pyproject.toml for validation
155
+ pyproject_file = tmp_path / "pyproject.toml"
156
+ pyproject_file.write_text('[project]\nname = "test"\n')
157
+
162
158
  # Create existing file in target
163
159
  existing_file = tmp_path / "test.txt"
164
160
  existing_file.write_text("existing")
@@ -201,6 +197,10 @@ class TestInjectCommand:
201
197
  git_dir = tmp_path / ".git"
202
198
  git_dir.mkdir()
203
199
 
200
+ # Create pyproject.toml for validation
201
+ pyproject_file = tmp_path / "pyproject.toml"
202
+ pyproject_file.write_text('[project]\nname = "test"\n')
203
+
204
204
  # Create existing file in target
205
205
  existing_file = tmp_path / "test.txt"
206
206
  existing_file.write_text("existing")
@@ -241,6 +241,10 @@ class TestInjectCommand:
241
241
  git_dir = tmp_path / ".git"
242
242
  git_dir.mkdir()
243
243
 
244
+ # Create pyproject.toml for validation
245
+ pyproject_file = tmp_path / "pyproject.toml"
246
+ pyproject_file.write_text('[project]\nname = "test"\n')
247
+
244
248
  # Create template.yml with exclude
245
249
  rhiza_dir = tmp_path / ".rhiza"
246
250
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -289,6 +293,10 @@ class TestInjectCommand:
289
293
  git_dir = tmp_path / ".git"
290
294
  git_dir.mkdir()
291
295
 
296
+ # Create pyproject.toml for validation
297
+ pyproject_file = tmp_path / "pyproject.toml"
298
+ pyproject_file.write_text('[project]\nname = "test"\n')
299
+
292
300
  # Create minimal template.yml
293
301
  rhiza_dir = tmp_path / ".rhiza"
294
302
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -320,6 +328,10 @@ class TestInjectCommand:
320
328
  git_dir = tmp_path / ".git"
321
329
  git_dir.mkdir()
322
330
 
331
+ # Create pyproject.toml for validation
332
+ pyproject_file = tmp_path / "pyproject.toml"
333
+ pyproject_file.write_text('[project]\nname = "test"\n')
334
+
323
335
  # Create template.yml
324
336
  rhiza_dir = tmp_path / ".rhiza"
325
337
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -354,6 +366,10 @@ class TestInjectCommand:
354
366
  git_dir = tmp_path / ".git"
355
367
  git_dir.mkdir()
356
368
 
369
+ # Create pyproject.toml for validation
370
+ pyproject_file = tmp_path / "pyproject.toml"
371
+ pyproject_file.write_text('[project]\nname = "test"\n')
372
+
357
373
  # Create template.yml
358
374
  rhiza_dir = tmp_path / ".rhiza"
359
375
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -410,6 +426,10 @@ class TestInjectCommand:
410
426
  git_dir = tmp_path / ".git"
411
427
  git_dir.mkdir()
412
428
 
429
+ # Create pyproject.toml for validation
430
+ pyproject_file = tmp_path / "pyproject.toml"
431
+ pyproject_file.write_text('[project]\nname = "test"\n')
432
+
413
433
  # Create existing file that will be skipped
414
434
  existing_file = tmp_path / "existing.txt"
415
435
  existing_file.write_text("existing content")
@@ -460,6 +480,10 @@ class TestInjectCommand:
460
480
  git_dir = tmp_path / ".git"
461
481
  git_dir.mkdir()
462
482
 
483
+ # Create pyproject.toml for validation
484
+ pyproject_file = tmp_path / "pyproject.toml"
485
+ pyproject_file.write_text('[project]\nname = "test"\n')
486
+
463
487
  # Create template.yml with gitlab host
464
488
  rhiza_dir = tmp_path / ".rhiza"
465
489
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -509,6 +533,10 @@ class TestInjectCommand:
509
533
  git_dir = tmp_path / ".git"
510
534
  git_dir.mkdir()
511
535
 
536
+ # Create pyproject.toml for validation
537
+ pyproject_file = tmp_path / "pyproject.toml"
538
+ pyproject_file.write_text('[project]\nname = "test"\n')
539
+
512
540
  # Create template.yml with explicit github host
513
541
  rhiza_dir = tmp_path / ".rhiza"
514
542
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -550,6 +578,10 @@ class TestInjectCommand:
550
578
  git_dir = tmp_path / ".git"
551
579
  git_dir.mkdir()
552
580
 
581
+ # Create pyproject.toml for validation
582
+ pyproject_file = tmp_path / "pyproject.toml"
583
+ pyproject_file.write_text('[project]\nname = "test"\n')
584
+
553
585
  # Create template.yml with invalid host
554
586
  rhiza_dir = tmp_path / ".rhiza"
555
587
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -587,6 +619,10 @@ class TestInjectCommand:
587
619
  git_dir = tmp_path / ".git"
588
620
  git_dir.mkdir()
589
621
 
622
+ # Create pyproject.toml for validation
623
+ pyproject_file = tmp_path / "pyproject.toml"
624
+ pyproject_file.write_text('[project]\nname = "test"\n')
625
+
590
626
  # Create src and tests folders to avoid validation warnings
591
627
  (tmp_path / "src").mkdir()
592
628
  (tmp_path / "tests").mkdir()
@@ -630,13 +666,17 @@ class TestInjectCommand:
630
666
  assert "workflow" in call_args.lower()
631
667
  assert "permission" in call_args.lower()
632
668
 
633
- @patch("rhiza.commands.materialize.init")
634
- def test_materialize_empty_include_paths_raises_error(self, mock_init, tmp_path):
669
+ @patch("rhiza.commands.materialize.validate")
670
+ def test_materialize_raises_error_when_validate_bypassed_with_empty_include(self, mock_validate, tmp_path):
635
671
  """Test that materialize raises RuntimeError when include_paths is empty after validation."""
636
672
  # Setup git repo
637
673
  git_dir = tmp_path / ".git"
638
674
  git_dir.mkdir()
639
675
 
676
+ # Create pyproject.toml for validation
677
+ pyproject_file = tmp_path / "pyproject.toml"
678
+ pyproject_file.write_text('[project]\nname = "test"\n')
679
+
640
680
  # Create template.yml with empty include (bypassing normal validation)
641
681
  rhiza_dir = tmp_path / ".rhiza"
642
682
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -652,8 +692,10 @@ class TestInjectCommand:
652
692
  f,
653
693
  )
654
694
 
655
- # Mock init to return True (bypass validation that would catch this)
656
- mock_init.return_value = True
695
+ # Mock validate to return True to bypass normal validation that would catch empty include lists.
696
+ # This test validates materialize's runtime error handling for the theoretical edge case
697
+ # where validation passes but include_paths is still empty (e.g., validation logic gaps).
698
+ mock_validate.return_value = True
657
699
 
658
700
  # Run materialize and expect RuntimeError
659
701
  with pytest.raises(RuntimeError, match="No include paths found"):
@@ -669,6 +711,10 @@ class TestInjectCommand:
669
711
  git_dir = tmp_path / ".git"
670
712
  git_dir.mkdir()
671
713
 
714
+ # Create pyproject.toml for validation
715
+ pyproject_file = tmp_path / "pyproject.toml"
716
+ pyproject_file.write_text('[project]\nname = "test"\n')
717
+
672
718
  # Create template.yml
673
719
  rhiza_dir = tmp_path / ".rhiza"
674
720
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -726,6 +772,10 @@ class TestInjectCommand:
726
772
  git_dir = tmp_path / ".git"
727
773
  git_dir.mkdir()
728
774
 
775
+ # Create pyproject.toml for validation
776
+ pyproject_file = tmp_path / "pyproject.toml"
777
+ pyproject_file.write_text('[project]\nname = "test"\n')
778
+
729
779
  # Create template.yml
730
780
  rhiza_dir = tmp_path / ".rhiza"
731
781
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -780,6 +830,10 @@ class TestInjectCommand:
780
830
  git_dir = tmp_path / ".git"
781
831
  git_dir.mkdir()
782
832
 
833
+ # Create pyproject.toml for validation
834
+ pyproject_file = tmp_path / "pyproject.toml"
835
+ pyproject_file.write_text('[project]\nname = "test"\n')
836
+
783
837
  # Create template.yml
784
838
  rhiza_dir = tmp_path / ".rhiza"
785
839
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -825,6 +879,10 @@ class TestInjectCommand:
825
879
  git_dir = tmp_path / ".git"
826
880
  git_dir.mkdir()
827
881
 
882
+ # Create pyproject.toml for validation
883
+ pyproject_file = tmp_path / "pyproject.toml"
884
+ pyproject_file.write_text('[project]\nname = "test"\n')
885
+
828
886
  # Create template.yml
829
887
  rhiza_dir = tmp_path / ".rhiza"
830
888
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -864,6 +922,10 @@ class TestInjectCommand:
864
922
  git_dir = tmp_path / ".git"
865
923
  git_dir.mkdir()
866
924
 
925
+ # Create pyproject.toml for validation
926
+ pyproject_file = tmp_path / "pyproject.toml"
927
+ pyproject_file.write_text('[project]\nname = "test"\n')
928
+
867
929
  # Create template.yml
868
930
  rhiza_dir = tmp_path / ".rhiza"
869
931
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -898,6 +960,10 @@ class TestInjectCommand:
898
960
  git_dir = tmp_path / ".git"
899
961
  git_dir.mkdir()
900
962
 
963
+ # Create pyproject.toml for validation
964
+ pyproject_file = tmp_path / "pyproject.toml"
965
+ pyproject_file.write_text('[project]\nname = "test"\n')
966
+
901
967
  # Create template.yml
902
968
  rhiza_dir = tmp_path / ".rhiza"
903
969
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -951,6 +1017,10 @@ class TestInjectCommand:
951
1017
  git_dir = tmp_path / ".git"
952
1018
  git_dir.mkdir()
953
1019
 
1020
+ # Create pyproject.toml for validation
1021
+ pyproject_file = tmp_path / "pyproject.toml"
1022
+ pyproject_file.write_text('[project]\nname = "test"\n')
1023
+
954
1024
  # Create template.yml
955
1025
  rhiza_dir = tmp_path / ".rhiza"
956
1026
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -1009,6 +1079,10 @@ class TestInjectCommand:
1009
1079
  git_dir = tmp_path / ".git"
1010
1080
  git_dir.mkdir()
1011
1081
 
1082
+ # Create pyproject.toml for validation
1083
+ pyproject_file = tmp_path / "pyproject.toml"
1084
+ pyproject_file.write_text('[project]\nname = "test"\n')
1085
+
1012
1086
  # Create an old .rhiza/history file with files that will become orphaned
1013
1087
  rhiza_dir = tmp_path / ".rhiza"
1014
1088
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -1082,6 +1156,10 @@ class TestInjectCommand:
1082
1156
  git_dir = tmp_path / ".git"
1083
1157
  git_dir.mkdir()
1084
1158
 
1159
+ # Create pyproject.toml for validation
1160
+ pyproject_file = tmp_path / "pyproject.toml"
1161
+ pyproject_file.write_text('[project]\nname = "test"\n')
1162
+
1085
1163
  # Create an old .rhiza/history file with a file that doesn't exist
1086
1164
  rhiza_dir = tmp_path / ".rhiza"
1087
1165
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -1143,6 +1221,10 @@ class TestInjectCommand:
1143
1221
  git_dir = tmp_path / ".git"
1144
1222
  git_dir.mkdir()
1145
1223
 
1224
+ # Create pyproject.toml for validation
1225
+ pyproject_file = tmp_path / "pyproject.toml"
1226
+ pyproject_file.write_text('[project]\nname = "test"\n')
1227
+
1146
1228
  # No old .rhiza/history file
1147
1229
 
1148
1230
  # Create template.yml
@@ -1193,6 +1275,10 @@ class TestInjectCommand:
1193
1275
  git_dir = tmp_path / ".git"
1194
1276
  git_dir.mkdir()
1195
1277
 
1278
+ # Create pyproject.toml for validation
1279
+ pyproject_file = tmp_path / "pyproject.toml"
1280
+ pyproject_file.write_text('[project]\nname = "test"\n')
1281
+
1196
1282
  # Create .rhiza/history with a file that will be orphaned
1197
1283
  rhiza_dir = tmp_path / ".rhiza"
1198
1284
  rhiza_dir.mkdir(parents=True, exist_ok=True)
@@ -1235,3 +1321,185 @@ class TestInjectCommand:
1235
1321
 
1236
1322
  # Verify the file still exists (deletion failed but was handled)
1237
1323
  assert old_file.exists()
1324
+
1325
+ @patch("rhiza.commands.materialize.subprocess.run")
1326
+ @patch("rhiza.commands.materialize.shutil.rmtree")
1327
+ @patch("rhiza.commands.materialize.shutil.copy2")
1328
+ @patch("rhiza.commands.materialize.tempfile.mkdtemp")
1329
+ def test_materialize_with_legacy_history_location(
1330
+ self, mock_mkdtemp, mock_copy2, mock_rmtree, mock_subprocess, tmp_path
1331
+ ):
1332
+ """Test that materialize reads history from legacy .rhiza.history location."""
1333
+ # Setup git repo
1334
+ git_dir = tmp_path / ".git"
1335
+ git_dir.mkdir()
1336
+
1337
+ # Create pyproject.toml for validation
1338
+ pyproject_file = tmp_path / "pyproject.toml"
1339
+ pyproject_file.write_text('[project]\nname = "test"\n')
1340
+
1341
+ # Create old .rhiza.history file (legacy location at root)
1342
+ old_history_file = tmp_path / ".rhiza.history"
1343
+ old_history_file.write_text("old_file.txt\n")
1344
+
1345
+ # Create the file that was in history
1346
+ old_file = tmp_path / "old_file.txt"
1347
+ old_file.write_text("old content")
1348
+
1349
+ # Create template.yml
1350
+ rhiza_dir = tmp_path / ".rhiza"
1351
+ rhiza_dir.mkdir(parents=True, exist_ok=True)
1352
+ template_file = rhiza_dir / "template.yml"
1353
+
1354
+ with open(template_file, "w") as f:
1355
+ yaml.dump(
1356
+ {
1357
+ "template-repository": "jebel-quant/rhiza",
1358
+ "template-branch": "main",
1359
+ "include": ["new_file.txt"],
1360
+ },
1361
+ f,
1362
+ )
1363
+
1364
+ # Mock tempfile
1365
+ temp_dir = tmp_path / "temp"
1366
+ temp_dir.mkdir()
1367
+ new_file = temp_dir / "new_file.txt"
1368
+ new_file.write_text("new content")
1369
+ mock_mkdtemp.return_value = str(temp_dir)
1370
+
1371
+ # Mock subprocess to succeed
1372
+ mock_subprocess.return_value = Mock(returncode=0)
1373
+
1374
+ # Run materialize - should read from legacy location and delete orphaned file
1375
+ materialize(tmp_path, "main", None, False)
1376
+
1377
+ # Verify old file was deleted (it was in old history but not in new template)
1378
+ assert not old_file.exists()
1379
+
1380
+ # Verify new history file was created in new location
1381
+ new_history_file = tmp_path / ".rhiza" / "history"
1382
+ assert new_history_file.exists()
1383
+
1384
+ @patch("rhiza.commands.materialize.subprocess.run")
1385
+ @patch("rhiza.commands.materialize.shutil.rmtree")
1386
+ @patch("rhiza.commands.materialize.shutil.copy2")
1387
+ @patch("rhiza.commands.materialize.tempfile.mkdtemp")
1388
+ def test_materialize_cleans_up_legacy_history_file(
1389
+ self, mock_mkdtemp, mock_copy2, mock_rmtree, mock_subprocess, tmp_path
1390
+ ):
1391
+ """Test that materialize removes old .rhiza.history after migration."""
1392
+ # Setup git repo
1393
+ git_dir = tmp_path / ".git"
1394
+ git_dir.mkdir()
1395
+
1396
+ # Create pyproject.toml for validation
1397
+ pyproject_file = tmp_path / "pyproject.toml"
1398
+ pyproject_file.write_text('[project]\nname = "test"\n')
1399
+
1400
+ # Create both old and new history files
1401
+ old_history_file = tmp_path / ".rhiza.history"
1402
+ old_history_file.write_text("file1.txt\n")
1403
+
1404
+ rhiza_dir = tmp_path / ".rhiza"
1405
+ rhiza_dir.mkdir(parents=True, exist_ok=True)
1406
+ new_history_file = rhiza_dir / "history"
1407
+ new_history_file.write_text("file1.txt\n")
1408
+
1409
+ # Create template.yml
1410
+ template_file = rhiza_dir / "template.yml"
1411
+
1412
+ with open(template_file, "w") as f:
1413
+ yaml.dump(
1414
+ {
1415
+ "template-repository": "jebel-quant/rhiza",
1416
+ "template-branch": "main",
1417
+ "include": ["file1.txt"],
1418
+ },
1419
+ f,
1420
+ )
1421
+
1422
+ # Mock tempfile
1423
+ temp_dir = tmp_path / "temp"
1424
+ temp_dir.mkdir()
1425
+ file1 = temp_dir / "file1.txt"
1426
+ file1.write_text("content1")
1427
+ mock_mkdtemp.return_value = str(temp_dir)
1428
+
1429
+ # Mock subprocess to succeed
1430
+ mock_subprocess.return_value = Mock(returncode=0)
1431
+
1432
+ # Run materialize
1433
+ materialize(tmp_path, "main", None, False)
1434
+
1435
+ # Verify old history file was removed
1436
+ assert not old_history_file.exists()
1437
+
1438
+ # Verify new history file still exists
1439
+ assert new_history_file.exists()
1440
+
1441
+ @patch("rhiza.commands.materialize.subprocess.run")
1442
+ @patch("rhiza.commands.materialize.shutil.rmtree")
1443
+ @patch("rhiza.commands.materialize.shutil.copy2")
1444
+ @patch("rhiza.commands.materialize.tempfile.mkdtemp")
1445
+ def test_materialize_handles_legacy_history_cleanup_failure(
1446
+ self, mock_mkdtemp, mock_copy2, mock_rmtree, mock_subprocess, tmp_path
1447
+ ):
1448
+ """Test that materialize handles failure to remove old .rhiza.history gracefully."""
1449
+ # Setup git repo
1450
+ git_dir = tmp_path / ".git"
1451
+ git_dir.mkdir()
1452
+
1453
+ # Create pyproject.toml for validation
1454
+ pyproject_file = tmp_path / "pyproject.toml"
1455
+ pyproject_file.write_text('[project]\nname = "test"\n')
1456
+
1457
+ # Create both old and new history files
1458
+ old_history_file = tmp_path / ".rhiza.history"
1459
+ old_history_file.write_text("file1.txt\n")
1460
+
1461
+ rhiza_dir = tmp_path / ".rhiza"
1462
+ rhiza_dir.mkdir(parents=True, exist_ok=True)
1463
+ new_history_file = rhiza_dir / "history"
1464
+ new_history_file.write_text("file1.txt\n")
1465
+
1466
+ # Create template.yml
1467
+ template_file = rhiza_dir / "template.yml"
1468
+
1469
+ with open(template_file, "w") as f:
1470
+ yaml.dump(
1471
+ {
1472
+ "template-repository": "jebel-quant/rhiza",
1473
+ "template-branch": "main",
1474
+ "include": ["file1.txt"],
1475
+ },
1476
+ f,
1477
+ )
1478
+
1479
+ # Mock tempfile
1480
+ temp_dir = tmp_path / "temp"
1481
+ temp_dir.mkdir()
1482
+ file1 = temp_dir / "file1.txt"
1483
+ file1.write_text("content1")
1484
+ mock_mkdtemp.return_value = str(temp_dir)
1485
+
1486
+ # Mock subprocess to succeed
1487
+ mock_subprocess.return_value = Mock(returncode=0)
1488
+
1489
+ # Mock unlink to fail for the old history file
1490
+ original_unlink = Path.unlink
1491
+
1492
+ def selective_unlink(self, *args, **kwargs):
1493
+ if self.name == ".rhiza.history":
1494
+ raise PermissionError("Cannot delete old history file")
1495
+ return original_unlink(self, *args, **kwargs)
1496
+
1497
+ with patch.object(Path, "unlink", selective_unlink):
1498
+ # Run materialize - should handle cleanup failure gracefully
1499
+ materialize(tmp_path, "main", None, False)
1500
+
1501
+ # Verify old history file still exists (cleanup failed but was handled)
1502
+ assert old_history_file.exists()
1503
+
1504
+ # Verify new history file was still created
1505
+ assert new_history_file.exists()
@@ -52,7 +52,8 @@ class TestMigrateCommand:
52
52
 
53
53
  assert migrated_content["template-repository"] == "test/repo"
54
54
  assert migrated_content["template-branch"] == "main"
55
- assert migrated_content["include"] == [".github", "Makefile"]
55
+ # After migration, .rhiza should be automatically added to include list
56
+ assert migrated_content["include"] == [".github", "Makefile", ".rhiza"]
56
57
 
57
58
  # Verify old file was removed
58
59
  assert not old_template_file.exists()
@@ -167,6 +168,50 @@ Makefile
167
168
  new_history_file = rhiza_dir / "history"
168
169
  assert not new_history_file.exists()
169
170
 
171
+ def test_migrate_skips_history_when_both_exist(self, tmp_path):
172
+ """Test that migrate skips history migration when both old and new exist."""
173
+ # Create existing .rhiza.history
174
+ old_history_file = tmp_path / ".rhiza.history"
175
+ old_content = "# Old history content\nold_file.txt\n"
176
+ old_history_file.write_text(old_content)
177
+
178
+ # Create existing .rhiza/history (already migrated)
179
+ rhiza_dir = tmp_path / ".rhiza"
180
+ rhiza_dir.mkdir(parents=True)
181
+ new_history_file = rhiza_dir / "history"
182
+ new_content = "# New history content\nnew_file.txt\n"
183
+ new_history_file.write_text(new_content)
184
+
185
+ # Run migrate
186
+ migrate(tmp_path)
187
+
188
+ # Verify new history file was NOT overwritten
189
+ assert new_history_file.read_text() == new_content
190
+
191
+ # Verify old file still exists (not removed since target exists)
192
+ assert old_history_file.exists()
193
+ assert old_history_file.read_text() == old_content
194
+
195
+ def test_migrate_handles_existing_rhiza_history(self, tmp_path):
196
+ """Test that migrate handles when .rhiza/history already exists but .rhiza.history doesn't."""
197
+ # Create existing .rhiza/history (no old file)
198
+ rhiza_dir = tmp_path / ".rhiza"
199
+ rhiza_dir.mkdir(parents=True)
200
+ new_history_file = rhiza_dir / "history"
201
+ existing_content = "# Existing history\nfile.txt\n"
202
+ new_history_file.write_text(existing_content)
203
+
204
+ # Run migrate without creating .rhiza.history
205
+ migrate(tmp_path)
206
+
207
+ # Verify .rhiza/history is unchanged
208
+ assert new_history_file.exists()
209
+ assert new_history_file.read_text() == existing_content
210
+
211
+ # Verify no old file was created
212
+ old_history_file = tmp_path / ".rhiza.history"
213
+ assert not old_history_file.exists()
214
+
170
215
  def test_migrate_skips_existing_files(self, tmp_path):
171
216
  """Test that migrate skips existing files in .rhiza."""
172
217
  # Create existing .rhiza/template.yml
@@ -195,6 +240,92 @@ Makefile
195
240
 
196
241
  assert content["template-repository"] == "existing/repo"
197
242
 
243
+ def test_migrate_adds_rhiza_to_include_list(self, tmp_path):
244
+ """Test that migrate adds .rhiza to include list if not present."""
245
+ # Create existing template.yml in .github/rhiza/ without .rhiza in include
246
+ github_rhiza_dir = tmp_path / ".github" / "rhiza"
247
+ github_rhiza_dir.mkdir(parents=True)
248
+ old_template_file = github_rhiza_dir / "template.yml"
249
+
250
+ template_content = {
251
+ "template-repository": "test/repo",
252
+ "template-branch": "main",
253
+ "include": [".github", "Makefile"],
254
+ }
255
+
256
+ with open(old_template_file, "w") as f:
257
+ yaml.dump(template_content, f)
258
+
259
+ # Run migrate
260
+ migrate(tmp_path)
261
+
262
+ # Verify new template.yml was created
263
+ new_template_file = tmp_path / ".rhiza" / "template.yml"
264
+ assert new_template_file.exists()
265
+
266
+ # Verify .rhiza was added to include list
267
+ with open(new_template_file) as f:
268
+ migrated_content = yaml.safe_load(f)
269
+
270
+ assert ".rhiza" in migrated_content["include"]
271
+ assert migrated_content["include"] == [".github", "Makefile", ".rhiza"]
272
+
273
+ def test_migrate_does_not_duplicate_rhiza_in_include_list(self, tmp_path):
274
+ """Test that migrate does not duplicate .rhiza if already in include list."""
275
+ # Create existing template.yml in .github/rhiza/ with .rhiza already in include
276
+ github_rhiza_dir = tmp_path / ".github" / "rhiza"
277
+ github_rhiza_dir.mkdir(parents=True)
278
+ old_template_file = github_rhiza_dir / "template.yml"
279
+
280
+ template_content = {
281
+ "template-repository": "test/repo",
282
+ "template-branch": "main",
283
+ "include": [".github", ".rhiza", "Makefile"],
284
+ }
285
+
286
+ with open(old_template_file, "w") as f:
287
+ yaml.dump(template_content, f)
288
+
289
+ # Run migrate
290
+ migrate(tmp_path)
291
+
292
+ # Verify new template.yml was created
293
+ new_template_file = tmp_path / ".rhiza" / "template.yml"
294
+ assert new_template_file.exists()
295
+
296
+ # Verify .rhiza appears only once in include list
297
+ with open(new_template_file) as f:
298
+ migrated_content = yaml.safe_load(f)
299
+
300
+ assert migrated_content["include"].count(".rhiza") == 1
301
+ assert migrated_content["include"] == [".github", ".rhiza", "Makefile"]
302
+
303
+ def test_migrate_adds_rhiza_to_existing_template(self, tmp_path):
304
+ """Test that migrate adds .rhiza to include list for existing .rhiza/template.yml."""
305
+ # Create existing template.yml directly in .rhiza/ without .rhiza in include
306
+ rhiza_dir = tmp_path / ".rhiza"
307
+ rhiza_dir.mkdir(parents=True)
308
+ template_file = rhiza_dir / "template.yml"
309
+
310
+ template_content = {
311
+ "template-repository": "test/repo",
312
+ "template-branch": "main",
313
+ "include": ["src", "tests"],
314
+ }
315
+
316
+ with open(template_file, "w") as f:
317
+ yaml.dump(template_content, f)
318
+
319
+ # Run migrate
320
+ migrate(tmp_path)
321
+
322
+ # Verify .rhiza was added to include list
323
+ with open(template_file) as f:
324
+ updated_content = yaml.safe_load(f)
325
+
326
+ assert ".rhiza" in updated_content["include"]
327
+ assert updated_content["include"] == ["src", "tests", ".rhiza"]
328
+
198
329
 
199
330
  class TestMigrateCLI:
200
331
  """Tests for the migrate CLI command."""
@@ -771,7 +771,7 @@ wheels = [
771
771
 
772
772
  [[package]]
773
773
  name = "rhiza"
774
- version = "0.8.0"
774
+ version = "0.8.2"
775
775
  source = { editable = "." }
776
776
  dependencies = [
777
777
  { name = "jinja2" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes