rhiza 0.6.0__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rhiza/commands/init.py +94 -13
- rhiza/commands/materialize.py +133 -14
- rhiza/commands/validate.py +56 -18
- rhiza/commands/welcome.py +7 -0
- {rhiza-0.6.0.dist-info → rhiza-0.7.0.dist-info}/METADATA +4 -1
- rhiza-0.7.0.dist-info/RECORD +14 -0
- rhiza-0.6.0.dist-info/RECORD +0 -14
- {rhiza-0.6.0.dist-info → rhiza-0.7.0.dist-info}/WHEEL +0 -0
- {rhiza-0.6.0.dist-info → rhiza-0.7.0.dist-info}/entry_points.txt +0 -0
- {rhiza-0.6.0.dist-info → rhiza-0.7.0.dist-info}/licenses/LICENSE +0 -0
rhiza/commands/init.py
CHANGED
|
@@ -22,44 +22,60 @@ def init(target: Path):
|
|
|
22
22
|
|
|
23
23
|
Args:
|
|
24
24
|
target: Path to the target directory. Defaults to the current working directory.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
bool: True if validation passes, False otherwise.
|
|
25
28
|
"""
|
|
26
29
|
# Convert to absolute path to avoid surprises
|
|
27
30
|
target = target.resolve()
|
|
28
31
|
|
|
29
32
|
logger.info(f"Initializing Rhiza configuration in: {target}")
|
|
30
33
|
|
|
31
|
-
# Create .github/rhiza directory if it doesn't exist
|
|
34
|
+
# Create .github/rhiza directory structure if it doesn't exist
|
|
35
|
+
# This is where Rhiza stores its configuration
|
|
32
36
|
github_dir = target / ".github"
|
|
33
37
|
rhiza_dir = github_dir / "rhiza"
|
|
38
|
+
logger.debug(f"Ensuring directory exists: {rhiza_dir}")
|
|
34
39
|
rhiza_dir.mkdir(parents=True, exist_ok=True)
|
|
35
40
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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
|
|
38
44
|
template_file = github_dir / "template.yml"
|
|
39
45
|
if template_file.exists():
|
|
40
|
-
|
|
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)
|
|
41
49
|
shutil.copyfile(template_file, rhiza_dir / "template.yml")
|
|
42
50
|
|
|
43
|
-
# Define the template file path
|
|
51
|
+
# Define the template file path (new location)
|
|
44
52
|
template_file = rhiza_dir / "template.yml"
|
|
45
53
|
|
|
46
54
|
if not template_file.exists():
|
|
47
|
-
# Create default template.yml
|
|
55
|
+
# Create default template.yml with sensible defaults
|
|
48
56
|
logger.info("Creating default .github/rhiza/template.yml")
|
|
57
|
+
logger.debug("Using default template configuration")
|
|
49
58
|
|
|
59
|
+
# Default template points to the jebel-quant/rhiza repository
|
|
60
|
+
# and includes common Python project configuration files
|
|
50
61
|
default_template = RhizaTemplate(
|
|
51
62
|
template_repository="jebel-quant/rhiza",
|
|
52
63
|
template_branch="main",
|
|
53
64
|
include=[
|
|
54
|
-
".github",
|
|
55
|
-
".editorconfig",
|
|
56
|
-
".gitignore",
|
|
57
|
-
".pre-commit-config.yaml",
|
|
58
|
-
"Makefile",
|
|
59
|
-
"pytest.ini",
|
|
65
|
+
".github", # GitHub configuration and workflows
|
|
66
|
+
".editorconfig", # Editor configuration
|
|
67
|
+
".gitignore", # Git ignore patterns
|
|
68
|
+
".pre-commit-config.yaml", # Pre-commit hooks
|
|
69
|
+
"Makefile", # Build and development tasks
|
|
70
|
+
"pytest.ini", # Pytest configuration
|
|
71
|
+
"book", # Documentation book
|
|
72
|
+
"presentation", # Presentation materials
|
|
73
|
+
"tests", # Test structure
|
|
60
74
|
],
|
|
61
75
|
)
|
|
62
76
|
|
|
77
|
+
# Write the default template to the file
|
|
78
|
+
logger.debug(f"Writing default template to: {template_file}")
|
|
63
79
|
default_template.to_yaml(template_file)
|
|
64
80
|
|
|
65
81
|
logger.success("✓ Created .github/rhiza/template.yml")
|
|
@@ -69,5 +85,70 @@ Next steps:
|
|
|
69
85
|
2. Run 'rhiza materialize' to inject templates into your repository
|
|
70
86
|
""")
|
|
71
87
|
|
|
72
|
-
#
|
|
88
|
+
# Bootstrap basic Python project structure if it doesn't exist
|
|
89
|
+
# 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}")
|
|
92
|
+
|
|
93
|
+
# Create src/{parent} directory structure following src-layout
|
|
94
|
+
src_folder = target / "src" / parent
|
|
95
|
+
if not src_folder.exists():
|
|
96
|
+
logger.info(f"Creating Python package structure: {src_folder}")
|
|
97
|
+
src_folder.mkdir(parents=True)
|
|
98
|
+
|
|
99
|
+
# Create __init__.py to make it a proper Python package
|
|
100
|
+
init_file = src_folder / "__init__.py"
|
|
101
|
+
logger.debug(f"Creating {init_file}")
|
|
102
|
+
init_file.touch()
|
|
103
|
+
|
|
104
|
+
# Create main.py with a simple "Hello World" example
|
|
105
|
+
main_file = src_folder / "main.py"
|
|
106
|
+
logger.debug(f"Creating {main_file} with example code")
|
|
107
|
+
main_file.touch()
|
|
108
|
+
|
|
109
|
+
# 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
|
+
"""
|
|
120
|
+
main_file.write_text(code)
|
|
121
|
+
logger.success(f"Created Python package structure in {src_folder}")
|
|
122
|
+
|
|
123
|
+
# Create pyproject.toml if it doesn't exist
|
|
124
|
+
# This is the standard Python package metadata file (PEP 621)
|
|
125
|
+
pyproject_file = target / "pyproject.toml"
|
|
126
|
+
if not pyproject_file.exists():
|
|
127
|
+
logger.info("Creating pyproject.toml with basic project metadata")
|
|
128
|
+
pyproject_file.touch()
|
|
129
|
+
|
|
130
|
+
# 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
|
+
'''
|
|
140
|
+
pyproject_file.write_text(code)
|
|
141
|
+
logger.success("Created pyproject.toml")
|
|
142
|
+
|
|
143
|
+
# Create README.md if it doesn't exist
|
|
144
|
+
# Every project should have a README
|
|
145
|
+
readme_file = target / "README.md"
|
|
146
|
+
if not readme_file.exists():
|
|
147
|
+
logger.info("Creating README.md")
|
|
148
|
+
readme_file.touch()
|
|
149
|
+
logger.success("Created README.md")
|
|
150
|
+
|
|
151
|
+
# Validate the template file to ensure it's correct
|
|
152
|
+
# This will catch any issues early
|
|
153
|
+
logger.debug("Validating template configuration")
|
|
73
154
|
return validate(target)
|
rhiza/commands/materialize.py
CHANGED
|
@@ -24,16 +24,27 @@ def __expand_paths(base_dir: Path, paths: list[str]) -> list[Path]:
|
|
|
24
24
|
|
|
25
25
|
Given a list of paths relative to ``base_dir``, return a flat list of all
|
|
26
26
|
individual files.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
base_dir: The base directory to resolve paths against.
|
|
30
|
+
paths: List of relative path strings (files or directories).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A flat list of Path objects representing all individual files found.
|
|
27
34
|
"""
|
|
28
35
|
all_files = []
|
|
29
36
|
for p in paths:
|
|
30
37
|
full_path = base_dir / p
|
|
38
|
+
# Check if the path is a regular file
|
|
31
39
|
if full_path.is_file():
|
|
32
40
|
all_files.append(full_path)
|
|
41
|
+
# If it's a directory, recursively find all files within it
|
|
33
42
|
elif full_path.is_dir():
|
|
34
43
|
all_files.extend([f for f in full_path.rglob("*") if f.is_file()])
|
|
35
44
|
else:
|
|
36
|
-
# Path does not exist
|
|
45
|
+
# Path does not exist in the cloned repository - skip it silently
|
|
46
|
+
# This can happen if the template repo doesn't have certain paths
|
|
47
|
+
logger.debug(f"Path not found in template repository: {p}")
|
|
37
48
|
continue
|
|
38
49
|
return all_files
|
|
39
50
|
|
|
@@ -52,22 +63,28 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
52
63
|
the target repository.
|
|
53
64
|
force (bool): Whether to overwrite existing files.
|
|
54
65
|
"""
|
|
66
|
+
# Resolve to absolute path to avoid any ambiguity
|
|
55
67
|
target = target.resolve()
|
|
56
68
|
|
|
57
69
|
logger.info(f"Target repository: {target}")
|
|
58
70
|
logger.info(f"Rhiza branch: {branch}")
|
|
59
71
|
|
|
60
72
|
# Set environment to prevent git from prompting for credentials
|
|
73
|
+
# This ensures non-interactive behavior during git operations
|
|
61
74
|
git_env = os.environ.copy()
|
|
62
75
|
git_env["GIT_TERMINAL_PROMPT"] = "0"
|
|
63
76
|
|
|
64
77
|
# -----------------------
|
|
65
78
|
# Handle target branch creation/checkout if specified
|
|
66
79
|
# -----------------------
|
|
80
|
+
# When a target branch is specified, we either checkout an existing branch
|
|
81
|
+
# or create a new one. This allows users to materialize templates onto a
|
|
82
|
+
# separate branch for review before merging to main.
|
|
67
83
|
if target_branch:
|
|
68
84
|
logger.info(f"Creating/checking out target branch: {target_branch}")
|
|
69
85
|
try:
|
|
70
|
-
# Check if branch already exists
|
|
86
|
+
# Check if branch already exists using git rev-parse
|
|
87
|
+
# Returns 0 if the branch exists, non-zero otherwise
|
|
71
88
|
result = subprocess.run(
|
|
72
89
|
["git", "rev-parse", "--verify", target_branch],
|
|
73
90
|
cwd=target,
|
|
@@ -77,7 +94,7 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
77
94
|
)
|
|
78
95
|
|
|
79
96
|
if result.returncode == 0:
|
|
80
|
-
# Branch exists,
|
|
97
|
+
# Branch exists, switch to it
|
|
81
98
|
logger.info(f"Branch '{target_branch}' exists, checking out...")
|
|
82
99
|
subprocess.run(
|
|
83
100
|
["git", "checkout", target_branch],
|
|
@@ -86,7 +103,7 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
86
103
|
env=git_env,
|
|
87
104
|
)
|
|
88
105
|
else:
|
|
89
|
-
# Branch doesn't exist, create
|
|
106
|
+
# Branch doesn't exist, create it from current HEAD
|
|
90
107
|
logger.info(f"Creating new branch '{target_branch}'...")
|
|
91
108
|
subprocess.run(
|
|
92
109
|
["git", "checkout", "-b", target_branch],
|
|
@@ -101,53 +118,81 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
101
118
|
# -----------------------
|
|
102
119
|
# Ensure Rhiza is initialized
|
|
103
120
|
# -----------------------
|
|
121
|
+
# The init function creates template.yml if missing and validates it
|
|
122
|
+
# Returns True if valid, False otherwise
|
|
104
123
|
valid = init(target)
|
|
105
124
|
|
|
106
|
-
# init will make sure the template_file exists at the new location!
|
|
107
|
-
|
|
108
125
|
if not valid:
|
|
109
|
-
logger.error(f"Rhiza template is invalid
|
|
126
|
+
logger.error(f"Rhiza template is invalid in: {target}")
|
|
127
|
+
logger.error("Please fix validation errors and try again")
|
|
110
128
|
sys.exit(1)
|
|
111
129
|
|
|
130
|
+
# Load the template configuration from the validated file
|
|
112
131
|
template_file = target / ".github" / "rhiza" / "template.yml"
|
|
132
|
+
logger.debug(f"Loading template configuration from: {template_file}")
|
|
113
133
|
template = RhizaTemplate.from_yaml(template_file)
|
|
114
134
|
|
|
115
|
-
#
|
|
116
|
-
|
|
135
|
+
# Extract template configuration settings
|
|
136
|
+
# These define where to clone from and what to materialize
|
|
117
137
|
rhiza_repo = template.template_repository
|
|
138
|
+
# Use CLI arg if template doesn't specify a branch
|
|
118
139
|
rhiza_branch = template.template_branch or branch
|
|
140
|
+
# Default to GitHub if not specified
|
|
119
141
|
rhiza_host = template.template_host or "github"
|
|
120
142
|
include_paths = template.include
|
|
121
143
|
excluded_paths = template.exclude
|
|
122
144
|
|
|
145
|
+
# Validate that we have paths to include
|
|
123
146
|
if not include_paths:
|
|
147
|
+
logger.error("No include paths found in template.yml")
|
|
148
|
+
logger.error("Add at least one path to the 'include' list in template.yml")
|
|
124
149
|
raise RuntimeError("No include paths found in template.yml")
|
|
125
150
|
|
|
151
|
+
# Log the paths we'll be including for transparency
|
|
126
152
|
logger.info("Include paths:")
|
|
127
153
|
for p in include_paths:
|
|
128
154
|
logger.info(f" - {p}")
|
|
129
155
|
|
|
156
|
+
# Log excluded paths if any are defined
|
|
157
|
+
if excluded_paths:
|
|
158
|
+
logger.info("Exclude paths:")
|
|
159
|
+
for p in excluded_paths:
|
|
160
|
+
logger.info(f" - {p}")
|
|
161
|
+
|
|
130
162
|
# -----------------------
|
|
131
163
|
# Construct git clone URL based on host
|
|
132
164
|
# -----------------------
|
|
165
|
+
# Support both GitHub and GitLab template repositories
|
|
133
166
|
if rhiza_host == "gitlab":
|
|
134
167
|
git_url = f"https://gitlab.com/{rhiza_repo}.git"
|
|
168
|
+
logger.debug(f"Using GitLab repository: {git_url}")
|
|
135
169
|
elif rhiza_host == "github":
|
|
136
170
|
git_url = f"https://github.com/{rhiza_repo}.git"
|
|
171
|
+
logger.debug(f"Using GitHub repository: {git_url}")
|
|
137
172
|
else:
|
|
173
|
+
logger.error(f"Unsupported template-host: {rhiza_host}")
|
|
174
|
+
logger.error("template-host must be 'github' or 'gitlab'")
|
|
138
175
|
raise ValueError(f"Unsupported template-host: {rhiza_host}. Must be 'github' or 'gitlab'.")
|
|
139
176
|
|
|
140
177
|
# -----------------------
|
|
141
178
|
# Sparse clone template repo
|
|
142
179
|
# -----------------------
|
|
180
|
+
# Create a temporary directory for the sparse clone
|
|
181
|
+
# This will be cleaned up in the finally block
|
|
143
182
|
tmp_dir = Path(tempfile.mkdtemp())
|
|
144
183
|
materialized_files: list[Path] = []
|
|
145
184
|
|
|
146
185
|
logger.info(f"Cloning {rhiza_repo}@{rhiza_branch} from {rhiza_host} into temporary directory")
|
|
186
|
+
logger.debug(f"Temporary directory: {tmp_dir}")
|
|
147
187
|
|
|
148
188
|
try:
|
|
149
|
-
# Clone the repository
|
|
189
|
+
# Clone the repository using sparse checkout for efficiency
|
|
190
|
+
# --depth 1: Only fetch the latest commit (shallow clone)
|
|
191
|
+
# --filter=blob:none: Don't download file contents initially
|
|
192
|
+
# --sparse: Enable sparse checkout mode
|
|
193
|
+
# This combination allows us to clone only the paths we need
|
|
150
194
|
try:
|
|
195
|
+
logger.debug("Executing git clone with sparse checkout")
|
|
151
196
|
subprocess.run(
|
|
152
197
|
[
|
|
153
198
|
"git",
|
|
@@ -166,14 +211,18 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
166
211
|
text=True,
|
|
167
212
|
env=git_env,
|
|
168
213
|
)
|
|
214
|
+
logger.debug("Git clone completed successfully")
|
|
169
215
|
except subprocess.CalledProcessError as e:
|
|
170
216
|
logger.error(f"Failed to clone repository: {e}")
|
|
171
217
|
if e.stderr:
|
|
172
218
|
logger.error(f"Git error: {e.stderr.strip()}")
|
|
219
|
+
logger.error(f"Check that the repository '{rhiza_repo}' exists and branch '{rhiza_branch}' is valid")
|
|
173
220
|
raise
|
|
174
221
|
|
|
175
|
-
# Initialize sparse checkout
|
|
222
|
+
# Initialize sparse checkout in cone mode
|
|
223
|
+
# Cone mode is more efficient and uses pattern matching
|
|
176
224
|
try:
|
|
225
|
+
logger.debug("Initializing sparse checkout")
|
|
177
226
|
subprocess.run(
|
|
178
227
|
["git", "sparse-checkout", "init", "--cone"],
|
|
179
228
|
cwd=tmp_dir,
|
|
@@ -182,14 +231,17 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
182
231
|
text=True,
|
|
183
232
|
env=git_env,
|
|
184
233
|
)
|
|
234
|
+
logger.debug("Sparse checkout initialized")
|
|
185
235
|
except subprocess.CalledProcessError as e:
|
|
186
236
|
logger.error(f"Failed to initialize sparse checkout: {e}")
|
|
187
237
|
if e.stderr:
|
|
188
238
|
logger.error(f"Git error: {e.stderr.strip()}")
|
|
189
239
|
raise
|
|
190
240
|
|
|
191
|
-
# Set sparse checkout paths
|
|
241
|
+
# Set sparse checkout paths to only checkout the files/directories we need
|
|
242
|
+
# --skip-checks: Don't validate that patterns match existing files
|
|
192
243
|
try:
|
|
244
|
+
logger.debug(f"Setting sparse checkout paths: {include_paths}")
|
|
193
245
|
subprocess.run(
|
|
194
246
|
["git", "sparse-checkout", "set", "--skip-checks", *include_paths],
|
|
195
247
|
cwd=tmp_dir,
|
|
@@ -198,6 +250,7 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
198
250
|
text=True,
|
|
199
251
|
env=git_env,
|
|
200
252
|
)
|
|
253
|
+
logger.debug("Sparse checkout paths configured")
|
|
201
254
|
except subprocess.CalledProcessError as e:
|
|
202
255
|
logger.error(f"Failed to set sparse checkout paths: {e}")
|
|
203
256
|
if e.stderr:
|
|
@@ -207,35 +260,57 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
207
260
|
# -----------------------
|
|
208
261
|
# Expand include/exclude paths
|
|
209
262
|
# -----------------------
|
|
263
|
+
# Convert directory paths to individual file paths for precise control
|
|
264
|
+
logger.debug("Expanding included paths to individual files")
|
|
210
265
|
all_files = __expand_paths(tmp_dir, include_paths)
|
|
266
|
+
logger.info(f"Found {len(all_files)} file(s) in included paths")
|
|
211
267
|
|
|
268
|
+
# Create a set of excluded files for fast lookup
|
|
269
|
+
logger.debug("Expanding excluded paths to individual files")
|
|
212
270
|
excluded_files = {f.resolve() for f in __expand_paths(tmp_dir, excluded_paths)}
|
|
271
|
+
if excluded_files:
|
|
272
|
+
logger.info(f"Excluding {len(excluded_files)} file(s) based on exclude patterns")
|
|
213
273
|
|
|
274
|
+
# Filter out excluded files from the list of files to copy
|
|
214
275
|
files_to_copy = [f for f in all_files if f.resolve() not in excluded_files]
|
|
276
|
+
logger.info(f"Will materialize {len(files_to_copy)} file(s) to target repository")
|
|
215
277
|
|
|
216
278
|
# -----------------------
|
|
217
279
|
# Copy files into target repo
|
|
218
280
|
# -----------------------
|
|
281
|
+
# Copy each file from the temporary clone to the target repository
|
|
282
|
+
# Preserve file metadata (timestamps, permissions) with copy2
|
|
283
|
+
logger.info("Copying files to target repository...")
|
|
219
284
|
for src_file in files_to_copy:
|
|
285
|
+
# Calculate destination path maintaining relative structure
|
|
220
286
|
dst_file = target / src_file.relative_to(tmp_dir)
|
|
221
287
|
relative_path = dst_file.relative_to(target)
|
|
222
288
|
|
|
289
|
+
# Track this file for .rhiza.history
|
|
223
290
|
materialized_files.append(relative_path)
|
|
224
291
|
|
|
292
|
+
# Check if file already exists and handle based on force flag
|
|
225
293
|
if dst_file.exists() and not force:
|
|
226
294
|
logger.warning(f"{relative_path} already exists — use --force to overwrite")
|
|
227
295
|
continue
|
|
228
296
|
|
|
297
|
+
# Create parent directories if they don't exist
|
|
229
298
|
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
|
|
300
|
+
# Copy file with metadata preservation
|
|
230
301
|
shutil.copy2(src_file, dst_file)
|
|
231
302
|
logger.success(f"[ADD] {relative_path}")
|
|
232
303
|
|
|
233
304
|
finally:
|
|
305
|
+
# Clean up the temporary directory
|
|
306
|
+
logger.debug(f"Cleaning up temporary directory: {tmp_dir}")
|
|
234
307
|
shutil.rmtree(tmp_dir)
|
|
235
308
|
|
|
236
309
|
# -----------------------
|
|
237
310
|
# Warn about workflow files
|
|
238
311
|
# -----------------------
|
|
312
|
+
# GitHub Actions workflow files require special permissions to modify
|
|
313
|
+
# Check if any of the materialized files are workflow files
|
|
239
314
|
workflow_files = [p for p in materialized_files if p.parts[:2] == (".github", "workflows")]
|
|
240
315
|
|
|
241
316
|
if workflow_files:
|
|
@@ -243,11 +318,54 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
243
318
|
"Workflow files were materialized. Updating these files requires "
|
|
244
319
|
"a token with the 'workflow' permission in GitHub Actions."
|
|
245
320
|
)
|
|
321
|
+
logger.info(f"Workflow files affected: {len(workflow_files)}")
|
|
246
322
|
|
|
247
323
|
# -----------------------
|
|
248
|
-
#
|
|
324
|
+
# Clean up orphaned files
|
|
249
325
|
# -----------------------
|
|
326
|
+
# Read the old .rhiza.history file to find files that are no longer
|
|
327
|
+
# part of the current materialization and should be deleted
|
|
250
328
|
history_file = target / ".rhiza.history"
|
|
329
|
+
previously_tracked_files: set[Path] = set()
|
|
330
|
+
|
|
331
|
+
if history_file.exists():
|
|
332
|
+
logger.debug("Reading existing .rhiza.history file")
|
|
333
|
+
with history_file.open("r", encoding="utf-8") as f:
|
|
334
|
+
for line in f:
|
|
335
|
+
line = line.strip()
|
|
336
|
+
# Skip comments and empty lines
|
|
337
|
+
if line and not line.startswith("#"):
|
|
338
|
+
previously_tracked_files.add(Path(line))
|
|
339
|
+
|
|
340
|
+
logger.debug(f"Found {len(previously_tracked_files)} file(s) in previous .rhiza.history")
|
|
341
|
+
|
|
342
|
+
# Convert materialized_files list to a set for comparison
|
|
343
|
+
currently_materialized_files = set(materialized_files)
|
|
344
|
+
|
|
345
|
+
# Find orphaned files (in old history but not in new materialization)
|
|
346
|
+
orphaned_files = previously_tracked_files - currently_materialized_files
|
|
347
|
+
|
|
348
|
+
if orphaned_files:
|
|
349
|
+
logger.info(f"Found {len(orphaned_files)} orphaned file(s) no longer maintained by template")
|
|
350
|
+
for file_path in sorted(orphaned_files):
|
|
351
|
+
full_path = target / file_path
|
|
352
|
+
if full_path.exists():
|
|
353
|
+
try:
|
|
354
|
+
full_path.unlink()
|
|
355
|
+
logger.success(f"[DEL] {file_path}")
|
|
356
|
+
except Exception as e:
|
|
357
|
+
logger.warning(f"Failed to delete {file_path}: {e}")
|
|
358
|
+
else:
|
|
359
|
+
logger.debug(f"Skipping {file_path} (already deleted)")
|
|
360
|
+
else:
|
|
361
|
+
logger.debug("No orphaned files to clean up")
|
|
362
|
+
|
|
363
|
+
# -----------------------
|
|
364
|
+
# Write .rhiza.history
|
|
365
|
+
# -----------------------
|
|
366
|
+
# This file tracks which files were materialized by Rhiza
|
|
367
|
+
# Useful for understanding which files came from the template
|
|
368
|
+
logger.debug("Writing .rhiza.history file")
|
|
251
369
|
with history_file.open("w", encoding="utf-8") as f:
|
|
252
370
|
f.write("# Rhiza Template History\n")
|
|
253
371
|
f.write("# This file lists all files managed by the Rhiza template.\n")
|
|
@@ -255,10 +373,11 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
|
|
|
255
373
|
f.write(f"# Template branch: {rhiza_branch}\n")
|
|
256
374
|
f.write("#\n")
|
|
257
375
|
f.write("# Files under template control:\n")
|
|
376
|
+
# Sort files for consistent ordering
|
|
258
377
|
for file_path in sorted(materialized_files):
|
|
259
378
|
f.write(f"{file_path}\n")
|
|
260
379
|
|
|
261
|
-
logger.info(f"
|
|
380
|
+
logger.info(f"Updated {history_file.relative_to(target)} with {len(materialized_files)} file(s)")
|
|
262
381
|
|
|
263
382
|
logger.success("Rhiza templates materialized successfully")
|
|
264
383
|
|
rhiza/commands/validate.py
CHANGED
|
@@ -25,49 +25,65 @@ def validate(target: Path) -> bool:
|
|
|
25
25
|
Returns:
|
|
26
26
|
True if validation passes, False otherwise.
|
|
27
27
|
"""
|
|
28
|
-
# Convert to absolute path
|
|
28
|
+
# Convert to absolute path to avoid path resolution issues
|
|
29
29
|
target = target.resolve()
|
|
30
30
|
|
|
31
|
-
# Check if target is a git repository
|
|
31
|
+
# Check if target is a git repository by looking for .git directory
|
|
32
|
+
# Rhiza only works with git repositories
|
|
32
33
|
if not (target / ".git").is_dir():
|
|
33
34
|
logger.error(f"Target directory is not a git repository: {target}")
|
|
35
|
+
logger.error("Initialize a git repository with 'git init' first")
|
|
34
36
|
return False
|
|
35
37
|
|
|
36
38
|
logger.info(f"Validating template configuration in: {target}")
|
|
37
39
|
|
|
38
|
-
# Check
|
|
39
|
-
|
|
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"
|
|
40
45
|
|
|
41
|
-
# Check
|
|
42
|
-
|
|
46
|
+
# Check which file(s) exist
|
|
47
|
+
new_exists = new_location.exists()
|
|
48
|
+
deprecated_exists = deprecated_location.exists()
|
|
43
49
|
|
|
44
|
-
if not
|
|
45
|
-
logger.error(f"No template file found: {
|
|
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}")
|
|
46
53
|
logger.info("Run 'rhiza init' to create a default template.yml")
|
|
47
54
|
return False
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
60
|
else:
|
|
53
|
-
logger.warning(f"Template file exists but in old location: {
|
|
54
|
-
|
|
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
|
|
55
64
|
|
|
56
|
-
# Validate YAML syntax
|
|
65
|
+
# Validate YAML syntax by attempting to parse the file
|
|
66
|
+
logger.debug(f"Parsing YAML file: {template_file}")
|
|
57
67
|
try:
|
|
58
68
|
with open(template_file) as f:
|
|
59
69
|
config = yaml.safe_load(f)
|
|
60
70
|
except yaml.YAMLError as e:
|
|
61
71
|
logger.error(f"Invalid YAML syntax in template.yml: {e}")
|
|
72
|
+
logger.error("Fix the YAML syntax errors and try again")
|
|
62
73
|
return False
|
|
63
74
|
|
|
75
|
+
# Check if the file is completely empty
|
|
64
76
|
if config is None:
|
|
65
77
|
logger.error("template.yml is empty")
|
|
78
|
+
logger.error("Add configuration to template.yml or run 'rhiza init' to generate defaults")
|
|
66
79
|
return False
|
|
67
80
|
|
|
68
81
|
logger.success("YAML syntax is valid")
|
|
69
82
|
|
|
70
|
-
# Validate required fields
|
|
83
|
+
# Validate required fields exist and have correct types
|
|
84
|
+
# template-repository: Must be a string in 'owner/repo' format
|
|
85
|
+
# include: Must be a non-empty list of paths
|
|
86
|
+
logger.debug("Validating required fields")
|
|
71
87
|
required_fields = {
|
|
72
88
|
"template-repository": str,
|
|
73
89
|
"include": list,
|
|
@@ -75,81 +91,103 @@ def validate(target: Path) -> bool:
|
|
|
75
91
|
|
|
76
92
|
validation_passed = True
|
|
77
93
|
|
|
94
|
+
# Check each required field
|
|
78
95
|
for field, expected_type in required_fields.items():
|
|
79
96
|
if field not in config:
|
|
80
97
|
logger.error(f"Missing required field: {field}")
|
|
98
|
+
logger.error(f"Add '{field}' to your template.yml")
|
|
81
99
|
validation_passed = False
|
|
82
100
|
elif not isinstance(config[field], expected_type):
|
|
83
101
|
logger.error(
|
|
84
102
|
f"Field '{field}' must be of type {expected_type.__name__}, got {type(config[field]).__name__}"
|
|
85
103
|
)
|
|
104
|
+
logger.error(f"Fix the type of '{field}' in template.yml")
|
|
86
105
|
validation_passed = False
|
|
87
106
|
else:
|
|
88
107
|
logger.success(f"Field '{field}' is present and valid")
|
|
89
108
|
|
|
90
109
|
# Validate template-repository format
|
|
110
|
+
# Must be in 'owner/repo' format (e.g., 'jebel-quant/rhiza')
|
|
111
|
+
logger.debug("Validating template-repository format")
|
|
91
112
|
if "template-repository" in config:
|
|
92
113
|
repo = config["template-repository"]
|
|
93
114
|
if not isinstance(repo, str):
|
|
94
115
|
logger.error(f"template-repository must be a string, got {type(repo).__name__}")
|
|
116
|
+
logger.error("Example: 'owner/repository'")
|
|
95
117
|
validation_passed = False
|
|
96
118
|
elif "/" not in repo:
|
|
97
119
|
logger.error(f"template-repository must be in format 'owner/repo', got: {repo}")
|
|
120
|
+
logger.error("Example: 'jebel-quant/rhiza'")
|
|
98
121
|
validation_passed = False
|
|
99
122
|
else:
|
|
100
123
|
logger.success(f"template-repository format is valid: {repo}")
|
|
101
124
|
|
|
102
125
|
# Validate include paths
|
|
126
|
+
# Must be a non-empty list of strings
|
|
127
|
+
logger.debug("Validating include paths")
|
|
103
128
|
if "include" in config:
|
|
104
129
|
include = config["include"]
|
|
105
130
|
if not isinstance(include, list):
|
|
106
131
|
logger.error(f"include must be a list, got {type(include).__name__}")
|
|
132
|
+
logger.error("Example: include: ['.github', '.gitignore']")
|
|
107
133
|
validation_passed = False
|
|
108
134
|
elif len(include) == 0:
|
|
109
135
|
logger.error("include list cannot be empty")
|
|
136
|
+
logger.error("Add at least one path to materialize")
|
|
110
137
|
validation_passed = False
|
|
111
138
|
else:
|
|
112
139
|
logger.success(f"include list has {len(include)} path(s)")
|
|
140
|
+
# Log each included path for transparency
|
|
113
141
|
for path in include:
|
|
114
142
|
if not isinstance(path, str):
|
|
115
143
|
logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
|
|
116
144
|
else:
|
|
117
145
|
logger.info(f" - {path}")
|
|
118
146
|
|
|
119
|
-
# Validate optional fields
|
|
147
|
+
# Validate optional fields if present
|
|
148
|
+
# template-branch: Branch name in the template repository
|
|
149
|
+
logger.debug("Validating optional fields")
|
|
120
150
|
if "template-branch" in config:
|
|
121
151
|
branch = config["template-branch"]
|
|
122
152
|
if not isinstance(branch, str):
|
|
123
153
|
logger.warning(f"template-branch should be a string, got {type(branch).__name__}: {branch}")
|
|
154
|
+
logger.warning("Example: 'main' or 'develop'")
|
|
124
155
|
else:
|
|
125
156
|
logger.success(f"template-branch is valid: {branch}")
|
|
126
157
|
|
|
158
|
+
# template-host: Git hosting platform (github or gitlab)
|
|
127
159
|
if "template-host" in config:
|
|
128
160
|
host = config["template-host"]
|
|
129
161
|
if not isinstance(host, str):
|
|
130
162
|
logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}")
|
|
163
|
+
logger.warning("Must be 'github' or 'gitlab'")
|
|
131
164
|
elif host not in ("github", "gitlab"):
|
|
132
165
|
logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}")
|
|
166
|
+
logger.warning("Other hosts are not currently supported")
|
|
133
167
|
else:
|
|
134
168
|
logger.success(f"template-host is valid: {host}")
|
|
135
169
|
|
|
170
|
+
# exclude: Optional list of paths to exclude from materialization
|
|
136
171
|
if "exclude" in config:
|
|
137
172
|
exclude = config["exclude"]
|
|
138
173
|
if not isinstance(exclude, list):
|
|
139
174
|
logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
|
|
175
|
+
logger.warning("Example: exclude: ['.github/workflows/ci.yml']")
|
|
140
176
|
else:
|
|
141
177
|
logger.success(f"exclude list has {len(exclude)} path(s)")
|
|
178
|
+
# Log each excluded path for transparency
|
|
142
179
|
for path in exclude:
|
|
143
180
|
if not isinstance(path, str):
|
|
144
181
|
logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
|
|
145
182
|
else:
|
|
146
183
|
logger.info(f" - {path}")
|
|
147
184
|
|
|
148
|
-
# Final verdict
|
|
185
|
+
# Final verdict on validation
|
|
186
|
+
logger.debug("Validation complete, determining final result")
|
|
149
187
|
if validation_passed:
|
|
150
188
|
logger.success("✓ Validation passed: template.yml is valid")
|
|
151
189
|
return True
|
|
152
190
|
else:
|
|
153
191
|
logger.error("✗ Validation failed: template.yml has errors")
|
|
154
|
-
|
|
192
|
+
logger.error("Fix the errors above and run 'rhiza validate' again")
|
|
155
193
|
return False
|
rhiza/commands/welcome.py
CHANGED
|
@@ -15,7 +15,13 @@ def welcome():
|
|
|
15
15
|
|
|
16
16
|
Shows a friendly greeting, explains Rhiza's purpose, and provides
|
|
17
17
|
next steps for getting started with the tool.
|
|
18
|
+
|
|
19
|
+
This command is useful for new users to understand what Rhiza does
|
|
20
|
+
and how to get started. It provides a high-level overview without
|
|
21
|
+
performing any operations on the file system.
|
|
18
22
|
"""
|
|
23
|
+
# Construct a nicely formatted welcome message with ASCII art border
|
|
24
|
+
# The version is dynamically inserted from the package metadata
|
|
19
25
|
welcome_message = f"""
|
|
20
26
|
╭───────────────────────────────────────────────────────────────╮
|
|
21
27
|
│ │
|
|
@@ -52,4 +58,5 @@ Python projects using reusable templates stored in a central repository.
|
|
|
52
58
|
Happy templating! 🎉
|
|
53
59
|
"""
|
|
54
60
|
|
|
61
|
+
# Print the welcome message to stdout
|
|
55
62
|
print(welcome_message)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rhiza
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -41,6 +41,8 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
Command-line interface for managing reusable configuration templates for modern Python projects.
|
|
43
43
|
|
|
44
|
+
**📖 New to Rhiza? Check out the [Getting Started Guide](GETTING_STARTED.md) for a beginner-friendly introduction!**
|
|
45
|
+
|
|
44
46
|
## Overview
|
|
45
47
|
|
|
46
48
|
Rhiza is a CLI tool that helps you maintain consistent configuration across multiple Python projects by using templates stored in a central repository. It allows you to:
|
|
@@ -68,6 +70,7 @@ Rhiza is a CLI tool that helps you maintain consistent configuration across mult
|
|
|
68
70
|
|
|
69
71
|
For more detailed information, see:
|
|
70
72
|
|
|
73
|
+
- **[Getting Started Guide](GETTING_STARTED.md)** - Beginner-friendly introduction and walkthrough
|
|
71
74
|
- **[CLI Quick Reference](CLI.md)** - Command syntax and quick examples
|
|
72
75
|
- **[Usage Guide](USAGE.md)** - Practical tutorials and workflows
|
|
73
76
|
- **[Contributing Guidelines](CONTRIBUTING.md)** - How to contribute to the project
|
|
@@ -0,0 +1,14 @@
|
|
|
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,,
|
rhiza-0.6.0.dist-info/RECORD
DELETED
|
@@ -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=1JFvT-Y8eYcjqhM7xaaRCBmelKlQNmxaVgyCOeDvydw,2310
|
|
7
|
-
rhiza/commands/materialize.py,sha256=aQcBp8VTBt5DPxyDC2VcgngqTdLDxqbYd5F_B2UF2_A,9534
|
|
8
|
-
rhiza/commands/validate.py,sha256=oYCZnTxr39l20jr6DhBpFtMURv7jULSV8Ii6it8KHxs,5628
|
|
9
|
-
rhiza/commands/welcome.py,sha256=gLgahbfBCBhmZPxWKywx7kYGBTMCVyyVVnly9uKdJv0,2007
|
|
10
|
-
rhiza-0.6.0.dist-info/METADATA,sha256=GrFAnXe3MUq0evyRHFQDE4Yv3mUziu2LKz0wrYFN1fQ,22523
|
|
11
|
-
rhiza-0.6.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
rhiza-0.6.0.dist-info/entry_points.txt,sha256=NAwZUpbXvfKv50a_Qq-PxMHl3lcjAyZO63IBeuUNgfY,45
|
|
13
|
-
rhiza-0.6.0.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
|
|
14
|
-
rhiza-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|