rhiza 0.4.0__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rhiza/__init__.py CHANGED
@@ -3,3 +3,5 @@
3
3
  This package groups small, user-facing utilities that can be invoked from
4
4
  the command line or other automation scripts.
5
5
  """
6
+
7
+ __all__ = ["commands", "models"]
rhiza/cli.py CHANGED
@@ -8,16 +8,45 @@ from pathlib import Path
8
8
 
9
9
  import typer
10
10
 
11
- from rhiza.commands.hello import hello as hello_cmd
12
- from rhiza.commands.inject import inject as inject_cmd
11
+ from rhiza.commands import init as init_cmd
12
+ from rhiza.commands import materialize as materialize_cmd
13
+ from rhiza.commands import validate as validate_cmd
13
14
 
14
- app = typer.Typer(help="rhiza — configuration materialization tools")
15
+ app = typer.Typer(
16
+ help="Rhiza - Manage reusable configuration templates for Python projects",
17
+ add_completion=True,
18
+ )
15
19
 
16
20
 
17
21
  @app.command()
18
- def hello():
19
- """Sanity check command."""
20
- hello_cmd()
22
+ def init(
23
+ target: Path = typer.Argument(
24
+ default=Path("."), # default to current directory
25
+ exists=True,
26
+ file_okay=False,
27
+ dir_okay=True,
28
+ help="Target directory (defaults to current directory)",
29
+ ),
30
+ ):
31
+ """Initialize or validate .github/template.yml.
32
+
33
+ Creates a default .github/template.yml configuration file if one doesn't
34
+ exist, or validates the existing configuration.
35
+
36
+ The default template includes common Python project files:
37
+ - .github (workflows, actions, etc.)
38
+ - .editorconfig
39
+ - .gitignore
40
+ - .pre-commit-config.yaml
41
+ - Makefile
42
+ - pytest.ini
43
+
44
+ Examples:
45
+ rhiza init
46
+ rhiza init /path/to/project
47
+ rhiza init ..
48
+ """
49
+ init_cmd(target)
21
50
 
22
51
 
23
52
  @app.command()
@@ -32,16 +61,55 @@ def materialize(
32
61
  branch: str = typer.Option("main", "--branch", "-b", help="Rhiza branch to use"),
33
62
  force: bool = typer.Option(False, "--force", "-y", help="Overwrite existing files"),
34
63
  ):
35
- """Inject Rhiza configuration into a target repository.
36
-
37
- Parameters
38
- ----------
39
- target:
40
- Path to the target Git repository directory. Defaults to the
41
- current working directory.
42
- branch:
43
- Name of the Rhiza branch to use when sourcing templates.
44
- force:
45
- If True, overwrite existing files without prompting.
64
+ """Inject Rhiza configuration templates into a target repository.
65
+
66
+ Materializes configuration files from the template repository specified
67
+ in .github/template.yml into your project. This command:
68
+
69
+ 1. Reads .github/template.yml configuration
70
+ 2. Performs a sparse clone of the template repository
71
+ 3. Copies specified files/directories to your project
72
+ 4. Respects exclusion patterns defined in the configuration
73
+
74
+ Files that already exist will NOT be overwritten unless --force is used.
75
+
76
+ Examples:
77
+ rhiza materialize
78
+ rhiza materialize --branch develop
79
+ rhiza materialize --force
80
+ rhiza materialize /path/to/project -b v2.0 -y
81
+ """
82
+ materialize_cmd(target, branch, force)
83
+
84
+
85
+ @app.command()
86
+ def validate(
87
+ target: Path = typer.Argument(
88
+ default=Path("."), # default to current directory
89
+ exists=True,
90
+ file_okay=False,
91
+ dir_okay=True,
92
+ help="Target git repository (defaults to current directory)",
93
+ ),
94
+ ):
95
+ """Validate Rhiza template configuration.
96
+
97
+ Validates the .github/template.yml file to ensure it is syntactically
98
+ correct and semantically valid. Performs comprehensive validation:
99
+
100
+ - Checks if template.yml exists
101
+ - Validates YAML syntax
102
+ - Verifies required fields are present (template-repository, include)
103
+ - Validates field types and formats
104
+ - Ensures repository name follows owner/repo format
105
+ - Confirms include paths are not empty
106
+
107
+ Returns exit code 0 on success, 1 on validation failure.
108
+
109
+ Examples:
110
+ rhiza validate
111
+ rhiza validate /path/to/project
112
+ rhiza validate ..
46
113
  """
47
- inject_cmd(target, branch, force)
114
+ if not validate_cmd(target):
115
+ raise typer.Exit(code=1)
@@ -3,3 +3,7 @@
3
3
  This package contains the functions that back Typer commands exposed by
4
4
  `rhiza.cli`, such as `hello` and `inject`.
5
5
  """
6
+
7
+ from .init import init # noqa: F401
8
+ from .materialize import materialize # noqa: F401
9
+ from .validate import validate # noqa: F401
rhiza/commands/init.py ADDED
@@ -0,0 +1,64 @@
1
+ """Command to initialize or validate .github/template.yml.
2
+
3
+ This module provides the init command that creates or validates the
4
+ .github/template.yml file, which defines where templates come from
5
+ and what paths are governed by Rhiza.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from loguru import logger
11
+
12
+ from rhiza.commands.validate import validate
13
+ from rhiza.models import RhizaTemplate
14
+
15
+
16
+ def init(target: Path):
17
+ """Initialize or validate .github/template.yml in the target repository.
18
+
19
+ Creates a default .github/template.yml file if it doesn't exist,
20
+ or validates an existing one.
21
+
22
+ Args:
23
+ target: Path to the target directory. Defaults to the current working directory.
24
+ """
25
+ # Convert to absolute path to avoid surprises
26
+ target = target.resolve()
27
+
28
+ logger.info(f"Initializing Rhiza configuration in: {target}")
29
+
30
+ # Create .github directory if it doesn't exist
31
+ github_dir = target / ".github"
32
+ github_dir.mkdir(parents=True, exist_ok=True)
33
+
34
+ # Define the template file path
35
+ template_file = github_dir / "template.yml"
36
+
37
+ if not template_file.exists():
38
+ # Create default template.yml
39
+ logger.info("Creating default .github/template.yml")
40
+
41
+ default_template = RhizaTemplate(
42
+ template_repository="jebel-quant/rhiza",
43
+ template_branch="main",
44
+ include=[
45
+ ".github",
46
+ ".editorconfig",
47
+ ".gitignore",
48
+ ".pre-commit-config.yaml",
49
+ "Makefile",
50
+ "pytest.ini",
51
+ ],
52
+ )
53
+
54
+ default_template.to_yaml(template_file)
55
+
56
+ logger.success("✓ Created .github/template.yml")
57
+ logger.info("""
58
+ Next steps:
59
+ 1. Review and customize .github/template.yml to match your project needs
60
+ 2. Run 'rhiza materialize' to inject templates into your repository
61
+ """)
62
+
63
+ # the template file exists, so validate it
64
+ validate(target)
@@ -12,9 +12,11 @@ import sys
12
12
  import tempfile
13
13
  from pathlib import Path
14
14
 
15
- import yaml
16
15
  from loguru import logger
17
16
 
17
+ from rhiza.commands.init import init
18
+ from rhiza.models import RhizaTemplate
19
+
18
20
 
19
21
  def expand_paths(base_dir: Path, paths: list[str]) -> list[Path]:
20
22
  """Expand files/directories relative to base_dir into a flat list of files.
@@ -35,16 +37,11 @@ def expand_paths(base_dir: Path, paths: list[str]) -> list[Path]:
35
37
  return all_files
36
38
 
37
39
 
38
- def inject(target: Path, branch: str, force: bool):
40
+ def materialize(target: Path, branch: str, force: bool):
39
41
  """Materialize rhiza templates into TARGET repository."""
40
42
  # Convert to absolute path to avoid surprises
41
43
  target = target.resolve()
42
44
 
43
- # Validate target is a git repository
44
- if not (target / ".git").is_dir():
45
- logger.error(f"Target directory is not a git repository: {target}")
46
- raise sys.exit(1)
47
-
48
45
  logger.info(f"Target repository: {target}")
49
46
  logger.info(f"Rhiza branch: {branch}")
50
47
 
@@ -52,38 +49,21 @@ def inject(target: Path, branch: str, force: bool):
52
49
  # Ensure template.yml
53
50
  # -----------------------
54
51
  template_file = target / ".github" / "template.yml"
55
- template_file.parent.mkdir(parents=True, exist_ok=True)
56
-
57
- if not template_file.exists():
58
- logger.info("Creating default .github/template.yml")
59
- template_content = {
60
- "template-repository": "jebel-quant/rhiza",
61
- "template-branch": branch,
62
- "include": [
63
- ".github",
64
- ".editorconfig",
65
- ".gitignore",
66
- ".pre-commit-config.yaml",
67
- "Makefile",
68
- "pytest.ini",
69
- ],
70
- }
71
- with open(template_file, "w") as f:
72
- yaml.dump(template_content, f)
73
- logger.success(".github/template.yml created")
74
- else:
75
- logger.info("Using existing .github/template.yml")
52
+ # template_file.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Initialize rhiza if not already initialized, e.g. construct a template.yml file
55
+ init(target)
76
56
 
77
57
  # -----------------------
78
58
  # Load template.yml
79
59
  # -----------------------
80
- with open(template_file) as f:
81
- config = yaml.safe_load(f)
60
+ template = RhizaTemplate.from_yaml(template_file)
82
61
 
83
- rhiza_repo = config.get("template-repository")
84
- rhiza_branch = config.get("template-branch", branch)
85
- include_paths = config.get("include", [])
86
- excluded_paths = config.get("exclude", [])
62
+ rhiza_repo = template.template_repository
63
+ # Use template branch if specified, otherwise fall back to CLI parameter
64
+ rhiza_branch = template.template_branch if template.template_branch else branch
65
+ include_paths = template.include
66
+ excluded_paths = template.exclude
87
67
 
88
68
  if not include_paths:
89
69
  logger.error("No include paths found in template.yml")
@@ -131,19 +111,38 @@ def inject(target: Path, branch: str, force: bool):
131
111
  # print(files_to_copy)
132
112
 
133
113
  # Copy loop
114
+ materialized_files = []
134
115
  for src_file in files_to_copy:
135
116
  dst_file = target / src_file.relative_to(tmp_dir)
117
+ relative_path = dst_file.relative_to(target)
118
+
119
+ # Track this file as being under template control
120
+ materialized_files.append(relative_path)
121
+
136
122
  if dst_file.exists() and not force:
137
- logger.warning(f"{dst_file.relative_to(target)} already exists — use force=True to overwrite")
123
+ logger.warning(f"{relative_path} already exists — use force=True to overwrite")
138
124
  continue
139
125
 
140
126
  dst_file.parent.mkdir(parents=True, exist_ok=True)
141
127
  shutil.copy2(src_file, dst_file)
142
- logger.success(f"[ADD] {dst_file.relative_to(target)}")
128
+ logger.success(f"[ADD] {relative_path}")
143
129
 
144
130
  finally:
145
131
  shutil.rmtree(tmp_dir)
146
132
 
133
+ # Write .rhiza.history file listing all files under template control
134
+ history_file = target / ".rhiza.history"
135
+ with open(history_file, "w") as f:
136
+ f.write("# Rhiza Template History\n")
137
+ f.write("# This file lists all files managed by the Rhiza template.\n")
138
+ f.write(f"# Template repository: {rhiza_repo}\n")
139
+ f.write(f"# Template branch: {rhiza_branch}\n")
140
+ f.write("#\n")
141
+ f.write("# Files under template control:\n")
142
+ for file_path in sorted(materialized_files):
143
+ f.write(f"{file_path}\n")
144
+ logger.info(f"Created {history_file.relative_to(target)} with {len(materialized_files)} files")
145
+
147
146
  logger.success("Rhiza templates materialized successfully")
148
147
  logger.info("""
149
148
  Next steps:
@@ -0,0 +1,136 @@
1
+ """Command for validating Rhiza template configuration.
2
+
3
+ This module provides functionality to validate .github/template.yml files
4
+ to ensure they are syntactically correct and semantically valid.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+ from loguru import logger
11
+
12
+
13
+ def validate(target: Path) -> bool:
14
+ """Validate template.yml configuration in the target repository.
15
+
16
+ Performs authoritative validation of the template configuration:
17
+ - Checks if template.yml exists
18
+ - Validates YAML syntax
19
+ - Validates required fields
20
+ - Validates field values are appropriate
21
+
22
+ Args:
23
+ target: Path to the target Git repository directory.
24
+
25
+ Returns:
26
+ True if validation passes, False otherwise.
27
+ """
28
+ # Convert to absolute path
29
+ target = target.resolve()
30
+
31
+ # Check if target is a git repository
32
+ if not (target / ".git").is_dir():
33
+ logger.error(f"Target directory is not a git repository: {target}")
34
+ return False
35
+
36
+ logger.info(f"Validating template configuration in: {target}")
37
+
38
+ # Check if template.yml exists
39
+ template_file = target / ".github" / "template.yml"
40
+ if not template_file.exists():
41
+ logger.error(f"Template file not found: {template_file}")
42
+ logger.info("Run 'rhiza materialize' or 'rhiza inject' to create a default template.yml")
43
+ return False
44
+
45
+ logger.success(f"Found template file: {template_file}")
46
+
47
+ # Validate YAML syntax
48
+ try:
49
+ with open(template_file) as f:
50
+ config = yaml.safe_load(f)
51
+ except yaml.YAMLError as e:
52
+ logger.error(f"Invalid YAML syntax in template.yml: {e}")
53
+ return False
54
+
55
+ if config is None:
56
+ logger.error("template.yml is empty")
57
+ return False
58
+
59
+ logger.success("YAML syntax is valid")
60
+
61
+ # Validate required fields
62
+ required_fields = {
63
+ "template-repository": str,
64
+ "include": list,
65
+ }
66
+
67
+ validation_passed = True
68
+
69
+ for field, expected_type in required_fields.items():
70
+ if field not in config:
71
+ logger.error(f"Missing required field: {field}")
72
+ validation_passed = False
73
+ elif not isinstance(config[field], expected_type):
74
+ logger.error(
75
+ f"Field '{field}' must be of type {expected_type.__name__}, got {type(config[field]).__name__}"
76
+ )
77
+ validation_passed = False
78
+ else:
79
+ logger.success(f"Field '{field}' is present and valid")
80
+
81
+ # Validate template-repository format
82
+ if "template-repository" in config:
83
+ repo = config["template-repository"]
84
+ if not isinstance(repo, str):
85
+ logger.error(f"template-repository must be a string, got {type(repo).__name__}")
86
+ validation_passed = False
87
+ elif "/" not in repo:
88
+ logger.error(f"template-repository must be in format 'owner/repo', got: {repo}")
89
+ validation_passed = False
90
+ else:
91
+ logger.success(f"template-repository format is valid: {repo}")
92
+
93
+ # Validate include paths
94
+ if "include" in config:
95
+ include = config["include"]
96
+ if not isinstance(include, list):
97
+ logger.error(f"include must be a list, got {type(include).__name__}")
98
+ validation_passed = False
99
+ elif len(include) == 0:
100
+ logger.error("include list cannot be empty")
101
+ validation_passed = False
102
+ else:
103
+ logger.success(f"include list has {len(include)} path(s)")
104
+ for path in include:
105
+ if not isinstance(path, str):
106
+ logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
107
+ else:
108
+ logger.info(f" - {path}")
109
+
110
+ # Validate optional fields
111
+ if "template-branch" in config:
112
+ branch = config["template-branch"]
113
+ if not isinstance(branch, str):
114
+ logger.warning(f"template-branch should be a string, got {type(branch).__name__}: {branch}")
115
+ else:
116
+ logger.success(f"template-branch is valid: {branch}")
117
+
118
+ if "exclude" in config:
119
+ exclude = config["exclude"]
120
+ if not isinstance(exclude, list):
121
+ logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
122
+ else:
123
+ logger.success(f"exclude list has {len(exclude)} path(s)")
124
+ for path in exclude:
125
+ if not isinstance(path, str):
126
+ logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
127
+ else:
128
+ logger.info(f" - {path}")
129
+
130
+ # Final verdict
131
+ if validation_passed:
132
+ logger.success("✓ Validation passed: template.yml is valid")
133
+ return True
134
+ else:
135
+ logger.error("✗ Validation failed: template.yml has errors")
136
+ return False
rhiza/models.py ADDED
@@ -0,0 +1,88 @@
1
+ """Data models for Rhiza configuration.
2
+
3
+ This module defines dataclasses that represent the structure of Rhiza
4
+ configuration files, making it easier to work with them without frequent
5
+ YAML parsing.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+
14
+ @dataclass
15
+ class RhizaTemplate:
16
+ """Represents the structure of .github/template.yml.
17
+
18
+ Attributes:
19
+ template_repository: The GitHub repository containing templates (e.g., "jebel-quant/rhiza").
20
+ Can be None if not specified in the template file.
21
+ template_branch: The branch to use from the template repository.
22
+ Can be None if not specified in the template file (defaults to "main" when creating).
23
+ include: List of paths to include from the template repository.
24
+ exclude: List of paths to exclude from the template repository (default: empty list).
25
+ """
26
+
27
+ template_repository: str | None = None
28
+ template_branch: str | None = None
29
+ include: list[str] = field(default_factory=list)
30
+ exclude: list[str] = field(default_factory=list)
31
+
32
+ @classmethod
33
+ def from_yaml(cls, file_path: Path) -> "RhizaTemplate":
34
+ """Load RhizaTemplate from a YAML file.
35
+
36
+ Args:
37
+ file_path: Path to the template.yml file.
38
+
39
+ Returns:
40
+ The loaded template configuration.
41
+
42
+ Raises:
43
+ FileNotFoundError: If the file does not exist.
44
+ yaml.YAMLError: If the YAML is malformed.
45
+ ValueError: If the file is empty.
46
+ """
47
+ with open(file_path) as f:
48
+ config = yaml.safe_load(f)
49
+
50
+ if not config:
51
+ raise ValueError("Template file is empty")
52
+
53
+ return cls(
54
+ template_repository=config.get("template-repository"),
55
+ template_branch=config.get("template-branch"),
56
+ include=config.get("include", []),
57
+ exclude=config.get("exclude", []),
58
+ )
59
+
60
+ def to_yaml(self, file_path: Path) -> None:
61
+ """Save RhizaTemplate to a YAML file.
62
+
63
+ Args:
64
+ file_path: Path where the template.yml file should be saved.
65
+ """
66
+ # Ensure parent directory exists
67
+ file_path.parent.mkdir(parents=True, exist_ok=True)
68
+
69
+ # Convert to dictionary with YAML-compatible keys
70
+ config = {}
71
+
72
+ # Only include template-repository if it's not None
73
+ if self.template_repository:
74
+ config["template-repository"] = self.template_repository
75
+
76
+ # Only include template-branch if it's not None
77
+ if self.template_branch:
78
+ config["template-branch"] = self.template_branch
79
+
80
+ # Include is always present as it's a required field for the config to be useful
81
+ config["include"] = self.include
82
+
83
+ # Only include exclude if it's not empty
84
+ if self.exclude:
85
+ config["exclude"] = self.exclude
86
+
87
+ with open(file_path, "w") as f:
88
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)