rhiza 0.3.0__py3-none-any.whl → 0.5.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/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Utility tools and command-line helpers for the Rhiza project.
2
+
3
+ This package groups small, user-facing utilities that can be invoked from
4
+ the command line or other automation scripts.
5
+ """
6
+
7
+ from rhiza.models import RhizaTemplate
8
+
9
+ __all__ = ["RhizaTemplate"]
rhiza/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Rhiza module entry point.
2
+
3
+ This module allows running the Rhiza CLI with `python -m rhiza` by
4
+ delegating execution to the Typer application defined in `rhiza.cli`.
5
+ """
6
+
7
+ from rhiza.cli import app
8
+
9
+ if __name__ == "__main__":
10
+ app()
rhiza/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """Rhiza command-line interface (CLI).
2
+
3
+ This module defines the Typer application entry points exposed by Rhiza.
4
+ Commands are thin wrappers around implementations in `rhiza.commands.*`.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from rhiza.commands.init import init as init_cmd
12
+ from rhiza.commands.materialize import materialize as materialize_cmd
13
+ from rhiza.commands.validate import validate as validate_cmd
14
+
15
+ app = typer.Typer(
16
+ help="Rhiza - Manage reusable configuration templates for Python projects",
17
+ add_completion=True,
18
+ )
19
+
20
+
21
+ @app.command()
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)
50
+
51
+
52
+ @app.command()
53
+ def materialize(
54
+ target: Path = typer.Argument(
55
+ default=Path("."), # default to current directory
56
+ exists=True,
57
+ file_okay=False,
58
+ dir_okay=True,
59
+ help="Target git repository (defaults to current directory)",
60
+ ),
61
+ branch: str = typer.Option("main", "--branch", "-b", help="Rhiza branch to use"),
62
+ force: bool = typer.Option(False, "--force", "-y", help="Overwrite existing files"),
63
+ ):
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 ..
113
+ """
114
+ if not validate_cmd(target):
115
+ raise typer.Exit(code=1)
@@ -0,0 +1,5 @@
1
+ """Command implementations for the Rhiza CLI.
2
+
3
+ This package contains the functions that back Typer commands exposed by
4
+ `rhiza.cli`, such as `hello` and `inject`.
5
+ """
rhiza/commands/init.py ADDED
@@ -0,0 +1,66 @@
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
+ Parameters
23
+ ----------
24
+ target:
25
+ Path to the target directory. Defaults to the current working directory.
26
+ """
27
+ # Convert to absolute path to avoid surprises
28
+ target = target.resolve()
29
+
30
+ logger.info(f"Initializing Rhiza configuration in: {target}")
31
+
32
+ # Create .github directory if it doesn't exist
33
+ github_dir = target / ".github"
34
+ github_dir.mkdir(parents=True, exist_ok=True)
35
+
36
+ # Define the template file path
37
+ template_file = github_dir / "template.yml"
38
+
39
+ if not template_file.exists():
40
+ # Create default template.yml
41
+ logger.info("Creating default .github/template.yml")
42
+
43
+ default_template = RhizaTemplate(
44
+ template_repository="jebel-quant/rhiza",
45
+ template_branch="main",
46
+ include=[
47
+ ".github",
48
+ ".editorconfig",
49
+ ".gitignore",
50
+ ".pre-commit-config.yaml",
51
+ "Makefile",
52
+ "pytest.ini",
53
+ ],
54
+ )
55
+
56
+ default_template.to_yaml(template_file)
57
+
58
+ logger.success("✓ Created .github/template.yml")
59
+ logger.info("""
60
+ Next steps:
61
+ 1. Review and customize .github/template.yml to match your project needs
62
+ 2. Run 'rhiza materialize' to inject templates into your repository
63
+ """)
64
+
65
+ # the template file exists, so validate it
66
+ validate(target)
@@ -0,0 +1,140 @@
1
+ """Command-line helpers for working with Rhiza templates.
2
+
3
+ This module currently exposes a thin wrapper that shells out to the
4
+ `tools/inject_rhiza.sh` script. It exists so the functionality can be
5
+ invoked via a Python entry point while delegating the heavy lifting to
6
+ the maintained shell script.
7
+ """
8
+
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from loguru import logger
16
+
17
+ from rhiza.commands.init import init
18
+ from rhiza.models import RhizaTemplate
19
+
20
+
21
+ def expand_paths(base_dir: Path, paths: list[str]) -> list[Path]:
22
+ """Expand files/directories relative to base_dir into a flat list of files.
23
+
24
+ Given a list of paths relative to ``base_dir``, return a flat list of all
25
+ individual files.
26
+ """
27
+ all_files = []
28
+ for p in paths:
29
+ full_path = base_dir / p
30
+ if full_path.is_file():
31
+ all_files.append(full_path)
32
+ elif full_path.is_dir():
33
+ all_files.extend([f for f in full_path.rglob("*") if f.is_file()])
34
+ else:
35
+ # Path does not exist — could log a warning
36
+ continue
37
+ return all_files
38
+
39
+
40
+ def materialize(target: Path, branch: str, force: bool):
41
+ """Materialize rhiza templates into TARGET repository."""
42
+ # Convert to absolute path to avoid surprises
43
+ target = target.resolve()
44
+
45
+ logger.info(f"Target repository: {target}")
46
+ logger.info(f"Rhiza branch: {branch}")
47
+
48
+ # -----------------------
49
+ # Ensure template.yml
50
+ # -----------------------
51
+ template_file = target / ".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)
56
+
57
+ # -----------------------
58
+ # Load template.yml
59
+ # -----------------------
60
+ template = RhizaTemplate.from_yaml(template_file)
61
+
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
67
+
68
+ if not include_paths:
69
+ logger.error("No include paths found in template.yml")
70
+ raise sys.exit(1)
71
+
72
+ logger.info("Include paths:")
73
+ for p in include_paths:
74
+ logger.info(f" - {p}")
75
+
76
+ # -----------------------
77
+ # Sparse clone rhiza
78
+ # -----------------------
79
+ tmp_dir = Path(tempfile.mkdtemp())
80
+ logger.info(f"Cloning {rhiza_repo}@{rhiza_branch} into temporary directory")
81
+
82
+ try:
83
+ subprocess.run(
84
+ [
85
+ "git",
86
+ "clone",
87
+ "--depth",
88
+ "1",
89
+ "--filter=blob:none",
90
+ "--sparse",
91
+ "--branch",
92
+ rhiza_branch,
93
+ f"https://github.com/{rhiza_repo}.git",
94
+ str(tmp_dir),
95
+ ],
96
+ check=True,
97
+ stdout=subprocess.DEVNULL,
98
+ )
99
+
100
+ subprocess.run(["git", "sparse-checkout", "init"], cwd=tmp_dir, check=True)
101
+ subprocess.run(["git", "sparse-checkout", "set", "--skip-checks", *include_paths], cwd=tmp_dir, check=True)
102
+
103
+ # After sparse-checkout
104
+ all_files = expand_paths(tmp_dir, include_paths)
105
+
106
+ # Filter out excluded files
107
+ # excluded_set = {tmp_dir / e for e in excluded_paths}
108
+ excluded_files = expand_paths(tmp_dir, excluded_paths)
109
+
110
+ files_to_copy = [f for f in all_files if f not in excluded_files]
111
+ # print(files_to_copy)
112
+
113
+ # Copy loop
114
+ for src_file in files_to_copy:
115
+ dst_file = target / src_file.relative_to(tmp_dir)
116
+ if dst_file.exists() and not force:
117
+ logger.warning(f"{dst_file.relative_to(target)} already exists — use force=True to overwrite")
118
+ continue
119
+
120
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
121
+ shutil.copy2(src_file, dst_file)
122
+ logger.success(f"[ADD] {dst_file.relative_to(target)}")
123
+
124
+ finally:
125
+ shutil.rmtree(tmp_dir)
126
+
127
+ logger.success("Rhiza templates materialized successfully")
128
+ logger.info("""
129
+ Next steps:
130
+ 1. Review changes:
131
+ git status
132
+ git diff
133
+
134
+ 2. Commit:
135
+ git add .
136
+ git commit -m "chore: import rhiza templates"
137
+
138
+ This is a one-shot snapshot.
139
+ Re-run this script to update templates explicitly.
140
+ """)
@@ -0,0 +1,140 @@
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
+ Parameters
23
+ ----------
24
+ target:
25
+ Path to the target Git repository directory.
26
+
27
+ Returns:
28
+ -------
29
+ bool
30
+ True if validation passes, False otherwise.
31
+ """
32
+ # Convert to absolute path
33
+ target = target.resolve()
34
+
35
+ # Check if target is a git repository
36
+ if not (target / ".git").is_dir():
37
+ logger.error(f"Target directory is not a git repository: {target}")
38
+ return False
39
+
40
+ logger.info(f"Validating template configuration in: {target}")
41
+
42
+ # Check if template.yml exists
43
+ template_file = target / ".github" / "template.yml"
44
+ if not template_file.exists():
45
+ logger.error(f"Template file not found: {template_file}")
46
+ logger.info("Run 'rhiza materialize' or 'rhiza inject' to create a default template.yml")
47
+ return False
48
+
49
+ logger.success(f"Found template file: {template_file}")
50
+
51
+ # Validate YAML syntax
52
+ try:
53
+ with open(template_file) as f:
54
+ config = yaml.safe_load(f)
55
+ except yaml.YAMLError as e:
56
+ logger.error(f"Invalid YAML syntax in template.yml: {e}")
57
+ return False
58
+
59
+ if config is None:
60
+ logger.error("template.yml is empty")
61
+ return False
62
+
63
+ logger.success("YAML syntax is valid")
64
+
65
+ # Validate required fields
66
+ required_fields = {
67
+ "template-repository": str,
68
+ "include": list,
69
+ }
70
+
71
+ validation_passed = True
72
+
73
+ for field, expected_type in required_fields.items():
74
+ if field not in config:
75
+ logger.error(f"Missing required field: {field}")
76
+ validation_passed = False
77
+ elif not isinstance(config[field], expected_type):
78
+ logger.error(
79
+ f"Field '{field}' must be of type {expected_type.__name__}, got {type(config[field]).__name__}"
80
+ )
81
+ validation_passed = False
82
+ else:
83
+ logger.success(f"Field '{field}' is present and valid")
84
+
85
+ # Validate template-repository format
86
+ if "template-repository" in config:
87
+ repo = config["template-repository"]
88
+ if not isinstance(repo, str):
89
+ logger.error(f"template-repository must be a string, got {type(repo).__name__}")
90
+ validation_passed = False
91
+ elif "/" not in repo:
92
+ logger.error(f"template-repository must be in format 'owner/repo', got: {repo}")
93
+ validation_passed = False
94
+ else:
95
+ logger.success(f"template-repository format is valid: {repo}")
96
+
97
+ # Validate include paths
98
+ if "include" in config:
99
+ include = config["include"]
100
+ if not isinstance(include, list):
101
+ logger.error(f"include must be a list, got {type(include).__name__}")
102
+ validation_passed = False
103
+ elif len(include) == 0:
104
+ logger.error("include list cannot be empty")
105
+ validation_passed = False
106
+ else:
107
+ logger.success(f"include list has {len(include)} path(s)")
108
+ for path in include:
109
+ if not isinstance(path, str):
110
+ logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
111
+ else:
112
+ logger.info(f" - {path}")
113
+
114
+ # Validate optional fields
115
+ if "template-branch" in config:
116
+ branch = config["template-branch"]
117
+ if not isinstance(branch, str):
118
+ logger.warning(f"template-branch should be a string, got {type(branch).__name__}: {branch}")
119
+ else:
120
+ logger.success(f"template-branch is valid: {branch}")
121
+
122
+ if "exclude" in config:
123
+ exclude = config["exclude"]
124
+ if not isinstance(exclude, list):
125
+ logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
126
+ else:
127
+ logger.success(f"exclude list has {len(exclude)} path(s)")
128
+ for path in exclude:
129
+ if not isinstance(path, str):
130
+ logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
131
+ else:
132
+ logger.info(f" - {path}")
133
+
134
+ # Final verdict
135
+ if validation_passed:
136
+ logger.success("✓ Validation passed: template.yml is valid")
137
+ return True
138
+ else:
139
+ logger.error("✗ Validation failed: template.yml has errors")
140
+ return False
rhiza/models.py ADDED
@@ -0,0 +1,103 @@
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
+ ----------
20
+ template_repository : str | None
21
+ The GitHub repository containing templates (e.g., "jebel-quant/rhiza").
22
+ Can be None if not specified in the template file.
23
+ template_branch : str | None
24
+ The branch to use from the template repository.
25
+ Can be None if not specified in the template file (defaults to "main" when creating).
26
+ include : list[str]
27
+ List of paths to include from the template repository.
28
+ exclude : list[str]
29
+ List of paths to exclude from the template repository (default: empty list).
30
+ """
31
+
32
+ template_repository: str | None = None
33
+ template_branch: str | None = None
34
+ include: list[str] = field(default_factory=list)
35
+ exclude: list[str] = field(default_factory=list)
36
+
37
+ @classmethod
38
+ def from_yaml(cls, file_path: Path) -> "RhizaTemplate":
39
+ """Load RhizaTemplate from a YAML file.
40
+
41
+ Parameters
42
+ ----------
43
+ file_path : Path
44
+ Path to the template.yml file.
45
+
46
+ Returns:
47
+ -------
48
+ RhizaTemplate
49
+ The loaded template configuration.
50
+
51
+ Raises:
52
+ ------
53
+ FileNotFoundError
54
+ If the file does not exist.
55
+ yaml.YAMLError
56
+ If the YAML is malformed.
57
+ ValueError
58
+ If the file is empty.
59
+ """
60
+ with open(file_path) as f:
61
+ config = yaml.safe_load(f)
62
+
63
+ if not config:
64
+ raise ValueError("Template file is empty")
65
+
66
+ return cls(
67
+ template_repository=config.get("template-repository"),
68
+ template_branch=config.get("template-branch"),
69
+ include=config.get("include", []),
70
+ exclude=config.get("exclude", []),
71
+ )
72
+
73
+ def to_yaml(self, file_path: Path) -> None:
74
+ """Save RhizaTemplate to a YAML file.
75
+
76
+ Parameters
77
+ ----------
78
+ file_path : Path
79
+ Path where the template.yml file should be saved.
80
+ """
81
+ # Ensure parent directory exists
82
+ file_path.parent.mkdir(parents=True, exist_ok=True)
83
+
84
+ # Convert to dictionary with YAML-compatible keys
85
+ config = {}
86
+
87
+ # Only include template-repository if it's not None
88
+ if self.template_repository:
89
+ config["template-repository"] = self.template_repository
90
+
91
+ # Only include template-branch if it's not None
92
+ if self.template_branch:
93
+ config["template-branch"] = self.template_branch
94
+
95
+ # Include is always present as it's a required field for the config to be useful
96
+ config["include"] = self.include
97
+
98
+ # Only include exclude if it's not empty
99
+ if self.exclude:
100
+ config["exclude"] = self.exclude
101
+
102
+ with open(file_path, "w") as f:
103
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)