From d571732526bdea80ffcce653f4a43b92da87f1af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:42:12 +0000 Subject: [PATCH] chore: Update via rhiza --- .devcontainer/README.md | 108 ----- .devcontainer/bootstrap.sh | 6 - .devcontainer/devcontainer.json | 14 +- .github/actions/configure-git-auth/README.md | 80 ++++ .github/actions/configure-git-auth/action.yml | 21 + .github/agents/analyser.md | 1 + .github/agents/summarise.md | 1 + .github/copilot-instructions.md | 120 +++++- .github/github.mk | 34 -- .github/hooks/hooks.json | 21 + .github/hooks/session-end.sh | 21 + .github/hooks/session-start.sh | 27 ++ .github/workflows/copilot-setup-steps.yml | 50 +++ .github/workflows/renovate_rhiza_sync.yml | 83 ++++ .github/workflows/rhiza_benchmarks.yml | 18 +- .github/workflows/rhiza_book.yml | 21 +- .github/workflows/rhiza_ci.yml | 53 ++- .github/workflows/rhiza_codeql.yml | 10 +- .github/workflows/rhiza_deptry.yml | 11 +- .github/workflows/rhiza_devcontainer.yml | 4 +- .github/workflows/rhiza_docker.yml | 38 +- .github/workflows/rhiza_marimo.yml | 15 +- .github/workflows/rhiza_mypy.yml | 34 -- .github/workflows/rhiza_pre-commit.yml | 19 +- .github/workflows/rhiza_release.yml | 116 ++++-- .github/workflows/rhiza_security.yml | 17 +- .github/workflows/rhiza_sync.yml | 6 +- .github/workflows/rhiza_validate.yml | 25 +- .pre-commit-config.yaml | 36 +- .rhiza/.cfg.toml | 11 +- .rhiza/.env | 8 +- .rhiza/.rhiza-version | 2 +- .rhiza/INDEX.md | 154 ++++++++ .rhiza/assets/rhiza-logo.svg | 81 ++++ .rhiza/completions/README.md | 263 +++++++++++++ .rhiza/completions/rhiza-completion.bash | 47 +++ .rhiza/completions/rhiza-completion.zsh | 88 +++++ .rhiza/docs/ASSETS.md | 14 + .rhiza/docs/CONFIG.md | 6 + .rhiza/docs/LFS.md | 161 ++++++++ .rhiza/docs/PRIVATE_PACKAGES.md | 233 +++++++++++ .rhiza/docs/RELEASING.md | 99 +++++ .rhiza/docs/WORKFLOWS.md | 248 ++++++++++++ .rhiza/history | 98 +++-- .rhiza/make.d/README.md | 68 +++- {.github/agents => .rhiza/make.d}/agentic.mk | 28 +- {book => .rhiza/make.d}/book.mk | 89 ++--- .rhiza/make.d/bootstrap.mk | 107 +++++ .../{01-custom-env.mk => custom-env.mk} | 2 +- .../{10-custom-task.mk => custom-task.mk} | 2 +- .rhiza/make.d/docker.mk | 31 ++ .rhiza/make.d/docs.mk | 96 +++++ .rhiza/make.d/github.mk | 70 ++++ .rhiza/make.d/lfs.mk | 76 ++++ {book/marimo => .rhiza/make.d}/marimo.mk | 0 .../make.d}/presentation.mk | 0 .rhiza/make.d/quality.mk | 24 ++ .rhiza/make.d/releasing.mk | 50 +++ tests/tests.mk => .rhiza/make.d/test.mk | 89 +++-- .rhiza/make.d/tutorial.mk | 101 +++++ .rhiza/requirements/tests.txt | 3 + .rhiza/requirements/tools.txt | 3 +- .rhiza/rhiza.mk | 174 +-------- .rhiza/scripts/.gitkeep | 0 .rhiza/scripts/check_workflow_names.py | 73 ---- .rhiza/scripts/release.sh | 276 ------------- .rhiza/template-bundles.yml | 278 +++++++++++++ .rhiza/templates/minibook/custom.html.jinja2 | 210 ++++++++++ .rhiza/tests/README.md | 126 ++++++ .rhiza/tests/api/conftest.py | 91 +++++ .rhiza/tests/api/test_github_targets.py | 55 +++ .rhiza/tests/api/test_makefile_api.py | 369 ++++++++++++++++++ .../tests/api/test_makefile_targets.py | 221 ++--------- .../test_rhiza => .rhiza/tests}/conftest.py | 91 ++--- .rhiza/tests/deps/test_dependency_health.py | 111 ++++++ .rhiza/tests/integration/test_book_targets.py | 150 +++++++ .rhiza/tests/integration/test_lfs.py | 182 +++++++++ .../tests/integration/test_marimushka.py | 6 +- .../integration/test_notebook_execution.py | 10 +- .rhiza/tests/integration/test_sbom.py | 159 ++++++++ .rhiza/tests/integration/test_test_mk.py | 53 +++ .../integration/test_virtual_env_unexport.py | 37 ++ .rhiza/tests/structure/test_lfs_structure.py | 135 +++++++ .rhiza/tests/structure/test_project_layout.py | 57 +++ .../tests/structure/test_requirements.py | 8 +- .../tests/structure/test_template_bundles.py | 89 +++++ .rhiza/tests/sync/conftest.py | 95 +++++ .../tests/sync}/test_docstrings.py | 50 ++- .../tests/sync/test_readme_validation.py | 13 +- .../tests/sync/test_rhiza_version.py | 117 +----- .rhiza/tests/test_utils.py | 63 +++ .../tests/utils}/test_git_repo_fixture.py | 12 - .rhiza/utils/version_matrix.py | 152 -------- Makefile | 4 + book/README.md | 67 ---- book/marimo/README.md | 142 ------- presentation/README.md | 325 --------------- pytest.ini | 5 + ruff.toml | 37 +- tests/benchmarks/conftest.py | 5 + tests/benchmarks/test_benchmarks.py | 59 +++ tests/property/test_makefile_properties.py | 26 ++ tests/test_rhiza/README.md | 71 ---- tests/test_rhiza/benchmarks/.gitignore | 3 - tests/test_rhiza/benchmarks/README.md | 69 ---- .../benchmarks/analyze_benchmarks.py | 85 ---- tests/test_rhiza/test_book.py | 66 ---- tests/test_rhiza/test_check_workflow_names.py | 115 ------ tests/test_rhiza/test_makefile_api.py | 267 ------------- tests/test_rhiza/test_makefile_gh.py | 124 ------ tests/test_rhiza/test_release_script.py | 230 ----------- tests/test_rhiza/test_structure.py | 58 --- tests/test_rhiza/test_version_matrix.py | 264 ------------- 113 files changed, 5437 insertions(+), 3410 deletions(-) delete mode 100644 .devcontainer/README.md create mode 100644 .github/actions/configure-git-auth/README.md create mode 100644 .github/actions/configure-git-auth/action.yml delete mode 100644 .github/github.mk create mode 100644 .github/hooks/hooks.json create mode 100755 .github/hooks/session-end.sh create mode 100755 .github/hooks/session-start.sh create mode 100644 .github/workflows/copilot-setup-steps.yml create mode 100644 .github/workflows/renovate_rhiza_sync.yml delete mode 100644 .github/workflows/rhiza_mypy.yml create mode 100644 .rhiza/INDEX.md create mode 100644 .rhiza/assets/rhiza-logo.svg create mode 100644 .rhiza/completions/README.md create mode 100644 .rhiza/completions/rhiza-completion.bash create mode 100644 .rhiza/completions/rhiza-completion.zsh create mode 100644 .rhiza/docs/ASSETS.md create mode 100644 .rhiza/docs/LFS.md create mode 100644 .rhiza/docs/PRIVATE_PACKAGES.md create mode 100644 .rhiza/docs/RELEASING.md create mode 100644 .rhiza/docs/WORKFLOWS.md rename {.github/agents => .rhiza/make.d}/agentic.mk (63%) rename {book => .rhiza/make.d}/book.mk (56%) create mode 100644 .rhiza/make.d/bootstrap.mk rename .rhiza/make.d/{01-custom-env.mk => custom-env.mk} (76%) rename .rhiza/make.d/{10-custom-task.mk => custom-task.mk} (85%) create mode 100644 .rhiza/make.d/docker.mk create mode 100644 .rhiza/make.d/docs.mk create mode 100644 .rhiza/make.d/github.mk create mode 100644 .rhiza/make.d/lfs.mk rename {book/marimo => .rhiza/make.d}/marimo.mk (100%) rename {presentation => .rhiza/make.d}/presentation.mk (100%) create mode 100644 .rhiza/make.d/quality.mk create mode 100644 .rhiza/make.d/releasing.mk rename tests/tests.mk => .rhiza/make.d/test.mk (51%) create mode 100644 .rhiza/make.d/tutorial.mk create mode 100644 .rhiza/scripts/.gitkeep delete mode 100644 .rhiza/scripts/check_workflow_names.py delete mode 100755 .rhiza/scripts/release.sh create mode 100644 .rhiza/template-bundles.yml create mode 100644 .rhiza/templates/minibook/custom.html.jinja2 create mode 100644 .rhiza/tests/README.md create mode 100644 .rhiza/tests/api/conftest.py create mode 100644 .rhiza/tests/api/test_github_targets.py create mode 100644 .rhiza/tests/api/test_makefile_api.py rename tests/test_rhiza/test_makefile.py => .rhiza/tests/api/test_makefile_targets.py (57%) rename {tests/test_rhiza => .rhiza/tests}/conftest.py (70%) create mode 100644 .rhiza/tests/deps/test_dependency_health.py create mode 100644 .rhiza/tests/integration/test_book_targets.py create mode 100644 .rhiza/tests/integration/test_lfs.py rename tests/test_rhiza/test_marimushka_target.py => .rhiza/tests/integration/test_marimushka.py (96%) rename tests/test_rhiza/test_notebooks.py => .rhiza/tests/integration/test_notebook_execution.py (91%) create mode 100644 .rhiza/tests/integration/test_sbom.py create mode 100644 .rhiza/tests/integration/test_test_mk.py create mode 100644 .rhiza/tests/integration/test_virtual_env_unexport.py create mode 100644 .rhiza/tests/structure/test_lfs_structure.py create mode 100644 .rhiza/tests/structure/test_project_layout.py rename tests/test_rhiza/test_requirements_folder.py => .rhiza/tests/structure/test_requirements.py (90%) create mode 100644 .rhiza/tests/structure/test_template_bundles.py create mode 100644 .rhiza/tests/sync/conftest.py rename {tests/test_rhiza => .rhiza/tests/sync}/test_docstrings.py (66%) rename tests/test_rhiza/test_readme.py => .rhiza/tests/sync/test_readme_validation.py (91%) rename tests/test_rhiza/test_rhiza_workflows.py => .rhiza/tests/sync/test_rhiza_version.py (56%) create mode 100644 .rhiza/tests/test_utils.py rename {tests/test_rhiza => .rhiza/tests/utils}/test_git_repo_fixture.py (89%) delete mode 100755 .rhiza/utils/version_matrix.py delete mode 100644 book/README.md delete mode 100644 book/marimo/README.md delete mode 100644 presentation/README.md create mode 100644 tests/benchmarks/conftest.py create mode 100644 tests/benchmarks/test_benchmarks.py create mode 100644 tests/property/test_makefile_properties.py delete mode 100644 tests/test_rhiza/README.md delete mode 100644 tests/test_rhiza/benchmarks/.gitignore delete mode 100644 tests/test_rhiza/benchmarks/README.md delete mode 100644 tests/test_rhiza/benchmarks/analyze_benchmarks.py delete mode 100644 tests/test_rhiza/test_book.py delete mode 100644 tests/test_rhiza/test_check_workflow_names.py delete mode 100644 tests/test_rhiza/test_makefile_api.py delete mode 100644 tests/test_rhiza/test_makefile_gh.py delete mode 100644 tests/test_rhiza/test_release_script.py delete mode 100644 tests/test_rhiza/test_structure.py delete mode 100644 tests/test_rhiza/test_version_matrix.py diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 9eac77f..0000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# DevContainer Configuration - -This directory contains the configuration for [GitHub Codespaces](https://github.com/features/codespaces) and [VS Code Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers). - -## Contents - -- `devcontainer.json`: The primary configuration file defining the development environment. -- `bootstrap.sh`: Post-create script that initializes the environment (installing dependencies, setting up tools). - -## Python Version - -The Python version is controlled by the `.python-version` file in the repository root (single source of truth). - -**How it works:** -1. The devcontainer uses a base Python image (3.12) -2. `bootstrap.sh` reads `.python-version` and exports `PYTHON_VERSION` -3. `make install` uses UV to create a venv with the exact Python version specified -4. UV automatically downloads the correct Python version if needed - -No manual setup required - UV handles Python version management! - -## What's Configured - -The `.devcontainer` setup provides: - -- 🐍 **Python** runtime environment -- πŸ”§ **UV Package Manager** - Fast Python package installer and resolver -- ⚑ **Makefile** - For running project workflows -- πŸ§ͺ **Pre-commit Hooks** - Automated code quality checks -- πŸ“Š **Marimo Integration** - Interactive notebook support with VS Code extension -- πŸ” **Python Development Tools** - Pylance, Python extension, and optimized settings -- πŸš€ **Port Forwarding** - Port 8080 for development servers -- πŸ” **SSH Agent Forwarding** - Full Git functionality with your host SSH keys - -## Usage - -### In VS Code - -1. Install the "Dev Containers" extension -2. Open the repository in VS Code -3. Click "Reopen in Container" when prompted -4. The environment will automatically set up with all dependencies - -### In GitHub Codespaces - -1. Navigate to the repository on GitHub -2. Click the green "Code" button -3. Select "Codespaces" tab -4. Click "Create codespace on main" (or your branch) -5. Your development environment will be ready in minutes - -The dev container automatically runs the initialization script that: - -- Installs UV package manager -- Configures the Python virtual environment -- Installs project dependencies -- Sets up pre-commit hooks - -## Publishing Devcontainer Images - -The repository includes workflows for building and publishing devcontainer images: - -### CI Validation - -The **DEVCONTAINER** workflow automatically validates that your devcontainer builds successfully: -- Triggers on changes to `.devcontainer/**` files or the workflow itself -- Builds the image without publishing (`push: never`) -- Works on pushes to any branch and pull requests -- Gracefully skips if no `.devcontainer/devcontainer.json` exists - -## VS Code Dev Container SSH Agent Forwarding - -Dev containers launched locally via VS code -are configured with SSH agent forwarding -to enable seamless Git operations: - -- **Mounts your SSH directory** - Your `~/.ssh` folder is mounted into the container -- **Forwards SSH agent** - Your host's SSH agent is available inside the container -- **Enables Git operations** - Push, pull, and clone using your existing SSH keys -- **Works transparently** - No additional setup required in VS Code dev containers - -## Troubleshooting - -Common issues and solutions when using this configuration template. - ---- - -### SSH authentication fails on macOS when using devcontainer - -**Symptom**: When building or using the devcontainer on macOS, Git operations (pull, push, clone) fail with SSH authentication errors, even though your SSH keys work fine on the host. - -**Cause**: macOS SSH config often includes `UseKeychain yes`, which is a macOS-specific directive. When the devcontainer mounts your `~/.ssh` directory, other platforms (Linux containers) don't recognize this directive and fail to parse the SSH config. - -**Solution**: Add `IgnoreUnknown UseKeychain` to the top of your `~/.ssh/config` file on your Mac: - -```ssh-config -# At the top of ~/.ssh/config -IgnoreUnknown UseKeychain - -Host * - AddKeysToAgent yes - UseKeychain yes - IdentityFile ~/.ssh/id_rsa -``` - -This tells SSH clients on non-macOS platforms to ignore the `UseKeychain` directive instead of failing. - -**Reference**: [Stack Overflow solution](https://stackoverflow.com/questions/75613632/trying-to-ssh-to-my-server-from-the-terminal-ends-with-error-line-x-bad-configu/75616369#75616369) diff --git a/.devcontainer/bootstrap.sh b/.devcontainer/bootstrap.sh index 7e94b6d..9239d2d 100755 --- a/.devcontainer/bootstrap.sh +++ b/.devcontainer/bootstrap.sh @@ -36,9 +36,3 @@ make install # Install Marimo tool for notebook editing "$UV_BIN" tool install marimo - -# Initialize pre-commit hooks if configured -if [ -f .pre-commit-config.yaml ]; then - # uvx runs tools without requiring them in the project deps - "$UVX_BIN" pre-commit install -fi diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b7ac66e..5601a66 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,19 +5,20 @@ "cpus": 4 }, "features": { - "ghcr.io/devcontainers/features/common-utils:2": {}, - "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/copilot-cli:1": {} + "ghcr.io/devcontainers/features/copilot-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": false + } }, "mounts": [ "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" ], "containerEnv": { - "SSH_AUTH_SOCK": "${localEnv:SSH_AUTH_SOCK}", "INSTALL_DIR": "/home/vscode/.local/bin" }, - "forwardPorts": [8080], + "forwardPorts": [8080, 2718], "customizations": { "vscode": { "settings": { @@ -43,7 +44,8 @@ "ms-vscode.makefile-tools", // AI Assistance "github.copilot-chat", - "github.copilot" + "github.copilot", + "anthropic.claude-code" ] } }, diff --git a/.github/actions/configure-git-auth/README.md b/.github/actions/configure-git-auth/README.md new file mode 100644 index 0000000..4b6faeb --- /dev/null +++ b/.github/actions/configure-git-auth/README.md @@ -0,0 +1,80 @@ +# Configure Git Auth for Private Packages + +This composite action configures git to use token authentication for private GitHub packages. + +## Usage + +Add this step before installing dependencies that include private GitHub packages: + +```yaml +- name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} +``` + +The `GH_PAT` secret should be a Personal Access Token with `repo` scope. + +## What It Does + +This action runs: + +```bash +git config --global url."https://@github.com/".insteadOf "https://github.com/" +``` + +This tells git to automatically inject the token into all HTTPS GitHub URLs, enabling access to private repositories. + +## When to Use + +Use this action when your project has dependencies defined in `pyproject.toml` like: + +```toml +[tool.uv.sources] +private-package = { git = "https://github.com/your-org/private-package.git", rev = "v1.0.0" } +``` + +## Token Requirements + +By default, this action will use the workflow’s built-in `GITHUB_TOKEN` (`github.token`) if no `token` input is provided or if the provided value is empty (it uses `inputs.token || github.token` internally). + +The `GITHUB_TOKEN` is usually sufficient when: + +- installing dependencies hosted in the **same repository** as the workflow, or +- accessing **public** repositories. + +The default `GITHUB_TOKEN` typically does **not** have permission to read other private repositories, even within the same organization. For that scenario, you should create a Personal Access Token (PAT) with `repo` scope and store it as `secrets.GH_PAT`, then pass it to the action via the `token` input. + +If you configure the step as in the example (`token: ${{ secrets.GH_PAT }}`) and `secrets.GH_PAT` is not defined, GitHub Actions passes an empty string to the action. The composite action then falls back to `github.token`, so the configuration step itself still succeeds. However, any subsequent step that tries to access private repositories that are not covered by the permissions of `GITHUB_TOKEN` will fail with an authentication error. +## Example Workflow + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} + + - name: Install dependencies + run: uv sync --frozen + + - name: Run tests + run: uv run pytest +``` + +## See Also + +- [PRIVATE_PACKAGES.md](../../../.rhiza/docs/PRIVATE_PACKAGES.md) - Complete guide to using private packages +- [TOKEN_SETUP.md](../../../.rhiza/docs/TOKEN_SETUP.md) - Setting up Personal Access Tokens diff --git a/.github/actions/configure-git-auth/action.yml b/.github/actions/configure-git-auth/action.yml new file mode 100644 index 0000000..d4d898f --- /dev/null +++ b/.github/actions/configure-git-auth/action.yml @@ -0,0 +1,21 @@ +name: 'Configure Git Auth for Private Packages' +description: 'Configure git to use token authentication for private GitHub packages' + +inputs: + token: + description: 'GitHub token to use for authentication' + required: false + +runs: + using: composite + steps: + - name: Configure git authentication + shell: bash + env: + GH_TOKEN: ${{ inputs.token || github.token }} + run: | + # Configure git to use token authentication for GitHub URLs + # This allows uv/pip to install private packages from GitHub + git config --global url."https://${GH_TOKEN}@github.com/".insteadOf "https://github.com/" + + echo "βœ“ Git configured to use token authentication for GitHub" diff --git a/.github/agents/analyser.md b/.github/agents/analyser.md index 8682aa0..440726e 100644 --- a/.github/agents/analyser.md +++ b/.github/agents/analyser.md @@ -1,6 +1,7 @@ --- name: analyser description: Ongoing technical journal for repository analysis +model: claude-sonnet-4.5 --- You are a senior software architect performing a critical, journal-style review of this repository. diff --git a/.github/agents/summarise.md b/.github/agents/summarise.md index ec21324..49424c7 100644 --- a/.github/agents/summarise.md +++ b/.github/agents/summarise.md @@ -1,6 +1,7 @@ --- name: summarise description: Agent for summarizing changes since the last release/tag +model: claude-sonnet-4.5 --- You are a software development assistant tasked with summarizing repository changes since the most recent release or tag. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 04b1363..22bae65 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,14 +7,124 @@ As a Rhiza-based project, this workspace adheres to specific conventions for str ## Development Environment -The project uses `make` and `uv` for development tasks. +The project uses `make` and `uv` for development tasks. UV handles all dependency and Python version management automatically. -- **Install Dependencies**: `make install` (installs `uv`, creates `.venv`, installs dependencies) +### Prerequisites + +- **Git**: Required for version control +- **Make**: Command runner for all development tasks +- **curl**: Required for installing uv (usually pre-installed on most systems) + +**Note**: Python is NOT a prerequisite. UV will automatically download and install the correct Python version (specified in `.python-version`) when you run `make install`. + +### Environment Setup + +Setting up your environment is simple: + +```bash +make install +``` + +This single command handles everything: +1. Installs `uv` package manager (to `./bin/uv` if not already in PATH) +2. Downloads and installs the correct Python version from `.python-version` (currently 3.13) +3. Creates a `.venv` virtual environment with that Python version +4. Installs all project dependencies from `pyproject.toml` + +### Verifying Installation + +After installation completes, verify everything works: + +```bash +make test # Should run successfully +``` + +### Environment Variables + +UV automatically uses these environment variables (set by the bootstrap process): +- `UV_LINK_MODE=copy`: Ensures proper dependency linking across filesystems +- `UV_VENV_CLEAR=1`: Clears existing venv on reinstall to avoid conflicts + +### Common Development Commands + +- **Install Dependencies**: `make install` (full setup: uv, Python, venv, dependencies) - **Run Tests**: `make test` (runs `pytest` with coverage) - **Format Code**: `make fmt` (runs `ruff format` and `ruff check --fix`) -- **Check Dependencies**: `make deptry` (runs `deptry` to check for missing/unused dependencies) -- **Marimo Notebooks**: `make marimo` (starts the Marimo server) +- **Check Dependencies**: `make deptry` (checks for missing/unused dependencies) +- **Marimo Notebooks**: `make marimo` (starts the Marimo notebook server) - **Build Documentation**: `make book` (builds the documentation book) +- **Clean Environment**: `make clean` (removes build artifacts and stale branches) + +### Troubleshooting + +- **Installation fails**: Check internet connectivity (UV needs to download Python and packages) +- **Python version issues**: The `.python-version` file is the single source of truth. UV uses this automatically. +- **Pre-commit failures**: Run `make fmt` to auto-fix most formatting issues +- **Stale environment**: Run `make clean` followed by `make install` to start fresh + +### Important Notes for Agents + +- **Virtual Environment Activation**: Most `make` commands automatically handle virtual environment activation. Manual activation is rarely needed. +- **Python Version**: The repository specifies Python 3.13 in `.python-version`. UV installs this automatically. +- **All Commands Through Make**: Always use `make` targets rather than running tools directly to ensure consistency. +- **When a `make` target exists, use it**: Do not replace `make test`, `make fmt`, `make deptry`, etc. with direct tool commands. +- **For Python commands without a `make` target, use `uv run`**: Run Python and Python tooling via `uv run `. +- **Never call the interpreter directly from `.venv`**: Do **not** use `.venv/bin/python`, `.venv/bin/pytest`, etc. + +### Command Execution Policy (Strict) + +Use these rules in order: + +1. If there is an appropriate `make` target, use the `make` target. +2. If no `make` target exists and you must run Python code/tooling, use `uv run ...`. +3. Do not invoke binaries from `.venv/bin` directly. + +Examples: + +- βœ… `make test` +- βœ… `make fmt` +- βœ… `uv run pytest` +- βœ… `uv run python -m pytest tests/property/test_makefile_properties.py` +- βœ… `uv run python scripts/some_script.py` +- ❌ `.venv/bin/python -m pytest` +- ❌ `.venv/bin/pytest` + +### Customizing Setup with Hooks + +The Makefile provides hooks for customizing the setup process. Add these to the root `Makefile`: + +```makefile +# Run before make install +pre-install:: + @echo "Installing system dependencies..." + @command -v graphviz || brew install graphviz + +# Run after make install +post-install:: + @echo "Running custom setup..." + @./scripts/custom-setup.sh +``` + +**Available hooks:** +- `pre-install` / `post-install`: Runs around `make install` +- `pre-sync` / `post-sync`: Runs around template synchronization +- `pre-validate` / `post-validate`: Runs around validation +- `pre-release` / `post-release`: Runs around releases + +**Note**: Use double-colon syntax (`::`) for hooks to allow multiple definitions. See `.rhiza/make.d/README.md` for more details. + +### Cloud/CI Environment Setup + +The Copilot coding agent environment is automatically configured via official GitHub mechanisms: + +- **`.github/workflows/copilot-setup-steps.yml`**: Runs before the agent starts. Installs uv, configures git auth for private packages, and runs `make install` to set up a deterministic environment. +- **`.github/hooks/hooks.json`**: Defines session lifecycle hooks: + - `sessionStart`: Validates the environment is correctly set up (uv available, .venv exists) + - `sessionEnd`: Runs `make fmt` and `make test` as quality gates after the agent finishes work + +These files must exist on the default branch. The agent does not need to run any setup commands manually. + +For DevContainers and Codespaces, the `.devcontainer/` configuration and `bootstrap.sh` handle setup automatically. See `docs/DEVCONTAINER.md` for details. ## Project Structure @@ -46,3 +156,5 @@ The project uses `make` and `uv` for development tasks. - `Makefile`: Main entry point for tasks. - `pyproject.toml`: Project configuration and dependencies. - `.devcontainer/bootstrap.sh`: Bootstrap script for dev containers. +- `.github/workflows/copilot-setup-steps.yml`: Agent environment setup (runs before agent starts). +- `.github/hooks/hooks.json`: Agent session hooks (quality gates). diff --git a/.github/github.mk b/.github/github.mk deleted file mode 100644 index 47262da..0000000 --- a/.github/github.mk +++ /dev/null @@ -1,34 +0,0 @@ -## github.mk - github repo maintenance and helpers -# This file is included by the main Makefile - -# Declare phony targets -.PHONY: gh-install view-prs view-issues failed-workflows whoami print-logo - -##@ GitHub Helpers -gh-install: ## check for gh cli existence and install extensions - @if ! command -v gh >/dev/null 2>&1; then \ - printf "${YELLOW}[WARN] gh cli not found.${RESET}\n"; \ - printf "${BLUE}[INFO] Please install it from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ - else \ - printf "${GREEN}[INFO] gh cli is installed.${RESET}\n"; \ - fi - -view-prs: gh-install ## list open pull requests - @printf "${BLUE}[INFO] Open Pull Requests:${RESET}\n" - @gh pr list --json number,title,author,headRefName,updatedAt --template \ - '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "BRANCH" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (.headRefName | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' - -view-issues: gh-install ## list open issues - @printf "${BLUE}[INFO] Open Issues:${RESET}\n" - @gh issue list --json number,title,author,labels,updatedAt --template \ - '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "LABELS" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (pluck "name" .labels | join ", " | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' - -failed-workflows: gh-install ## list recent failing workflow runs - @printf "${BLUE}[INFO] Recent Failing Workflow Runs:${RESET}\n" - @gh run list --limit 10 --status failure --json conclusion,name,headBranch,event,createdAt --template \ - '{{tablerow (printf "STATUS" | color "bold") (printf "NAME" | color "bold") (printf "BRANCH" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .conclusion | color "red") .name (.headBranch | color "cyan") (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}' - -whoami: gh-install ## check github auth status - @printf "${BLUE}[INFO] GitHub Authentication Status:${RESET}\n" - @gh auth status --hostname github.com --json hosts --template \ - '{{range $$host, $$accounts := .hosts}}{{range $$accounts}}{{if .active}} {{printf "βœ“" | color "green"}} Logged in to {{$$host}} account {{.login | color "bold"}} ({{.tokenSource}}){{"\n"}} Active account: {{printf "true" | color "green"}}{{"\n"}} Git operations protocol: {{.gitProtocol | color "yellow"}}{{"\n"}} Token scopes: {{.scopes | color "yellow"}}{{"\n"}}{{end}}{{end}}{{end}}' diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json new file mode 100644 index 0000000..6afa944 --- /dev/null +++ b/.github/hooks/hooks.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": ".github/hooks/session-start.sh", + "cwd": ".", + "timeoutSec": 30 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/session-end.sh", + "cwd": ".", + "timeoutSec": 120 + } + ] + } +} diff --git a/.github/hooks/session-end.sh b/.github/hooks/session-end.sh new file mode 100755 index 0000000..ce9f0ed --- /dev/null +++ b/.github/hooks/session-end.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# Session End Hook +# Runs quality gates after the agent finishes work. + +echo "[copilot-hook] Running post-work quality gates..." + +echo "[copilot-hook] Formatting code..." +make fmt || { + echo "[copilot-hook] WARNING: Formatting check failed." + exit 1 +} + +echo "[copilot-hook] Running tests..." +make test || { + echo "[copilot-hook] WARNING: Tests failed." + exit 1 +} + +echo "[copilot-hook] All quality gates passed." diff --git a/.github/hooks/session-start.sh b/.github/hooks/session-start.sh new file mode 100755 index 0000000..13700cd --- /dev/null +++ b/.github/hooks/session-start.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +# Session Start Hook +# Validates that the environment is correctly set up before the agent begins work. +# The virtual environment should already be activated via copilot-setup-steps.yml. + +echo "[copilot-hook] Validating environment..." + +# Verify uv is available +if ! command -v uv >/dev/null 2>&1 && [ ! -x "./bin/uv" ]; then + echo "[copilot-hook] ERROR: uv not found. Run 'make install' to set up the environment." + exit 1 +fi + +# Verify virtual environment exists +if [ ! -d ".venv" ]; then + echo "[copilot-hook] ERROR: .venv not found. Run 'make install' to set up the environment." + exit 1 +fi + +# Verify virtual environment is on PATH (activated via copilot-setup-steps.yml) +if ! command -v python >/dev/null 2>&1 || [[ "$(command -v python)" != *".venv"* ]]; then + echo "[copilot-hook] WARNING: .venv/bin is not on PATH. The agent may not use the correct Python." +fi + +echo "[copilot-hook] Environment validated successfully." diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..3164c3a --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,50 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Copilot Setup Steps +# +# Purpose: Preconfigure the development environment before the Copilot +# coding agent begins working. This ensures the agent always +# has a deterministic, fully working environment. +# +# Reference: https://docs.github.com/en/copilot/customizing-copilot/customizing-the-development-environment-for-copilot-coding-agent + +name: "(RHIZA) AGENT SETUP" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6.0.2 + with: + lfs: true + + - name: Install uv + uses: astral-sh/setup-uv@v7.3.0 + with: + version: "0.10.2" + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} + + - name: Install dependencies + env: + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} + run: make install + + - name: Activate virtual environment + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/renovate_rhiza_sync.yml b/.github/workflows/renovate_rhiza_sync.yml new file mode 100644 index 0000000..34cbe76 --- /dev/null +++ b/.github/workflows/renovate_rhiza_sync.yml @@ -0,0 +1,83 @@ +name: Renovate Rhiza Template Sync +# Automatically sync rhiza template files when Renovate updates .rhiza/template.yml + +on: + push: + branches: + - 'renovate/jebel-quant-rhiza-**' + paths: + - '.rhiza/template.yml' + +permissions: + contents: write + +jobs: + sync-template: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + with: + ref: ${{ github.ref }} + token: ${{ secrets.PAT_TOKEN || github.token }} + + - name: Check PAT_TOKEN configuration + shell: bash + env: + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + run: | + if [ -z "$PAT_TOKEN" ]; then + echo "::warning::PAT_TOKEN secret is not configured." + echo "::warning::If this sync modifies workflow files, the push will fail." + echo "::warning::See .rhiza/docs/TOKEN_SETUP.md for setup instructions." + else + echo "βœ“ PAT_TOKEN is configured." + fi + + - name: Install uv + uses: astral-sh/setup-uv@v7.3.0 + + - name: Get Rhiza version + id: rhiza-version + run: | + VERSION=$(cat .rhiza/.rhiza-version 2>/dev/null || echo "0.9.0") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Sync rhiza template + id: sync + run: | + set -euo pipefail + + RHIZA_VERSION="${{ steps.rhiza-version.outputs.version }}" + + echo "Running rhiza materialize with version >=${RHIZA_VERSION}" + uvx "rhiza>=${RHIZA_VERSION}" materialize --force . + + if git diff --quiet; then + echo "No changes detected after template sync" + echo "changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Template changes detected" + echo "changes=true" >> "$GITHUB_OUTPUT" + + - name: Commit and push changes + if: steps.sync.outputs.changes == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global url."https://x-access-token:${{ secrets.PAT_TOKEN || github.token }}@github.com/".insteadOf "https://github.com/" + + git add -A + git commit -m "$(cat <<'EOF' + chore: sync rhiza template files + + Automatically synced template files after updating .rhiza/template.yml + + Co-Authored-By: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + EOF + )" + + git push diff --git a/.github/workflows/rhiza_benchmarks.yml b/.github/workflows/rhiza_benchmarks.yml index 501f2e4..c207b2a 100644 --- a/.github/workflows/rhiza_benchmarks.yml +++ b/.github/workflows/rhiza_benchmarks.yml @@ -13,7 +13,7 @@ # - PRs will show a warning comment but not fail # - Main branch updates the baseline for future comparisons -name: (RHIZA) Benchmarks +name: "(RHIZA) BENCHMARKS" permissions: contents: write @@ -32,24 +32,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: lfs: true - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" - python-version: "3.12" + version: "0.10.2" + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} - name: Run benchmarks env: - UV_EXTRA_INDEX_URL: ${{ secrets.uv-extra-index-url }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: | make benchmark - name: Upload benchmark results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v6.0.0 if: always() with: name: benchmark-results diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index bc75e8e..3546de6 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -36,33 +36,38 @@ jobs: steps: # Check out the repository code - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: lfs: true - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" + version: "0.10.2" + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} - name: "Sync the virtual environment for ${{ github.repository }}" shell: bash env: - UV_EXTRA_INDEX_URL: ${{ secrets.uv-extra-index-url }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: | # will just use .python-version? uv sync --all-extras --all-groups --frozen - name: "Make the book" env: - UV_EXTRA_INDEX_URL: ${{ secrets.uv-extra-index-url }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: | - make -f .rhiza/rhiza.mk book + make book # Step 5: Package all artifacts for GitHub Pages deployment # This prepares the combined outputs for deployment by creating a single artifact - name: Upload static files as artifact - uses: actions/upload-pages-artifact@v4 # Official GitHub Pages artifact upload action + uses: actions/upload-pages-artifact@v4.0.0 # Official GitHub Pages artifact upload action with: path: _book/ # Path to the directory containing all artifacts to deploy @@ -73,5 +78,5 @@ jobs: # If PUBLISH_COMPANION_BOOK is not set, it defaults to allowing deployment - name: Deploy to GitHub Pages if: ${{ !github.event.repository.fork && (vars.PUBLISH_COMPANION_BOOK == 'true' || vars.PUBLISH_COMPANION_BOOK == '') }} - uses: actions/deploy-pages@v4 # Official GitHub Pages deployment action + uses: actions/deploy-pages@v4.0.5 # Official GitHub Pages deployment action continue-on-error: true diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index 1d6cc5e..d51368e 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -24,16 +24,23 @@ jobs: outputs: matrix: ${{ steps.versions.outputs.list }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 + with: + lfs: true - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" + version: "0.10.2" + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} + - id: versions env: - UV_EXTRA_INDEX_URL: ${{ secrets.uv-extra-index-url }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: | # Generate Python versions JSON from the script JSON=$(make -f .rhiza/rhiza.mk -s version-matrix) @@ -53,18 +60,46 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: lfs: true - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" + version: "0.10.2" python-version: ${{ matrix.python-version }} + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} + - name: Run tests env: - UV_EXTRA_INDEX_URL: ${{ secrets.uv-extra-index-url }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} + run: | + make test + + + docs-coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@v7.3.0 + with: + version: "0.10.2" + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} + + - name: Check docs coverage + env: + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: | - make -f .rhiza/rhiza.mk test + make docs-coverage diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index aca6b23..0473d9d 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -81,8 +81,12 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` # or others). This is typically only required for manual builds. @@ -91,7 +95,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.32.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -120,6 +124,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.32.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/rhiza_deptry.yml b/.github/workflows/rhiza_deptry.yml index e949af7..30220da 100644 --- a/.github/workflows/rhiza_deptry.yml +++ b/.github/workflows/rhiza_deptry.yml @@ -27,13 +27,18 @@ jobs: name: Check dependencies with deptry runs-on: ubuntu-latest container: - image: ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie + image: ghcr.io/astral-sh/uv:0.9.30-bookworm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} - name: Run deptry - run: make -f .rhiza/rhiza.mk deptry + run: make deptry # NOTE: make deptry is good style because it encapsulates the folders to check # (e.g. src and book/marimo) and keeps CI in sync with local development. # Since we use a 'uv' container, the Makefile is optimised to use the diff --git a/.github/workflows/rhiza_devcontainer.yml b/.github/workflows/rhiza_devcontainer.yml index 53c9916..1beda4e 100644 --- a/.github/workflows/rhiza_devcontainer.yml +++ b/.github/workflows/rhiza_devcontainer.yml @@ -58,7 +58,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 - name: Set registry id: registry @@ -70,7 +70,7 @@ jobs: echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT" - name: Login to Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v3.7.0 with: registry: ${{ steps.registry.outputs.registry }} username: ${{ github.repository_owner }} diff --git a/.github/workflows/rhiza_docker.yml b/.github/workflows/rhiza_docker.yml index 699882e..8e4ba9a 100644 --- a/.github/workflows/rhiza_docker.yml +++ b/.github/workflows/rhiza_docker.yml @@ -1,16 +1,19 @@ # GitHub Actions workflow: Lint Dockerfile with hadolint and build the image # -# This workflow runs on pushes and pull requests. It performs two main tasks: +# This workflow runs on pushes and pull requests. It performs three main tasks: # - Lints docker/Dockerfile using hadolint (fails the job on lint errors) # - Builds the container image with Docker Buildx to ensure the Dockerfile is valid +# - Scans the built image for security vulnerabilities using Trivy # # Notes: # - The image is built locally for validation only; it is not pushed to any registry. -# - Permissions are minimized (read-only) since no repository writes occur. +# - Vulnerability scanning fails the build on CRITICAL or HIGH severity issues. +# - Scan results are uploaded to GitHub Security and as artifacts. name: (RHIZA) DOCKER permissions: contents: read + security-events: write on: push: @@ -24,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 - name: Detect docker/Dockerfile presence id: check_dockerfile @@ -49,7 +52,7 @@ jobs: - name: Set up Docker Buildx if: ${{ steps.check_dockerfile.outputs.docker_present == 'true' }} - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v3.12.0 - name: Read Python version from .python-version if: ${{ steps.check_dockerfile.outputs.docker_present == 'true' }} @@ -65,6 +68,7 @@ jobs: fi - name: Build container image with Docker Buildx (validation only) + id: docker_build if: ${{ steps.check_dockerfile.outputs.docker_present == 'true' }} run: | # Derive image tag from repository name and sanitize (remove '.' and '_') @@ -76,3 +80,29 @@ jobs: --tag "${REPO_NAME}:ci" \ --load \ . + echo "image_name=${REPO_NAME}:ci" >> "$GITHUB_OUTPUT" + + - name: Scan Docker Image with Trivy + uses: aquasecurity/trivy-action@0.34.0 + if: ${{ steps.check_dockerfile.outputs.docker_present == 'true' }} + with: + image-ref: '${{ steps.docker_build.outputs.image_name }}' + format: 'sarif' + output: 'trivy-results.sarif' + exit-code: '0' # Don't fail on vulnerabilities - rely on SARIF upload to GitHub Security + ignore-unfixed: true + vuln-type: 'os,library' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy scan results to GitHub Security + uses: github/codeql-action/upload-sarif@v4 + if: always() && steps.check_dockerfile.outputs.docker_present == 'true' + with: + sarif_file: 'trivy-results.sarif' + + - name: Upload Trivy scan results as artifact + uses: actions/upload-artifact@v6.0.0 + if: always() && steps.check_dockerfile.outputs.docker_present == 'true' + with: + name: trivy-docker-report + path: trivy-results.sarif diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index 4a47193..9405e14 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -34,7 +34,7 @@ jobs: notebook-list: ${{ steps.notebooks.outputs.matrix }} steps: # Check out the repository code - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 # Find all Python files in the marimo folder and create a matrix for parallel execution - name: Find notebooks and build matrix @@ -75,18 +75,25 @@ jobs: name: Run notebook ${{ matrix.notebook }} steps: # Check out the repository code - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 with: lfs: true # Install uv/uvx - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" + version: "0.10.2" + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} # Execute the notebook with the appropriate runner based on its content - name: Run notebook + env: + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: | uvx uv run "${{ matrix.notebook }}" # uvx β†’ creates a fresh ephemeral environment diff --git a/.github/workflows/rhiza_mypy.yml b/.github/workflows/rhiza_mypy.yml deleted file mode 100644 index 886676e..0000000 --- a/.github/workflows/rhiza_mypy.yml +++ /dev/null @@ -1,34 +0,0 @@ -# This file is part of the jebel-quant/rhiza repository -# (https://github.com/jebel-quant/rhiza). -# -# Workflow: Mypy -# -# Purpose: Run static type checking with mypy in strict mode to ensure -# type safety across the codebase. -# -# Trigger: On push and pull requests to main/master branches. - -name: "(RHIZA) MYPY" - -permissions: - contents: read - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - -jobs: - mypy: - name: Static type checking with mypy - runs-on: ubuntu-latest - container: - image: ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie - - steps: - - uses: actions/checkout@v6 - - # to brutal for now - # - name: Run mypy - # run: make -f .rhiza/rhiza.mk mypy diff --git a/.github/workflows/rhiza_pre-commit.yml b/.github/workflows/rhiza_pre-commit.yml index 4190d4d..df38fa0 100644 --- a/.github/workflows/rhiza_pre-commit.yml +++ b/.github/workflows/rhiza_pre-commit.yml @@ -13,6 +13,7 @@ # # Components: # - πŸ” Run pre-commit checks using reusable action +# - πŸ’Ύ Cache pre-commit environments to speed up runs name: "(RHIZA) PRE-COMMIT" permissions: @@ -29,9 +30,23 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} + + # Cache pre-commit environments and hooks + - name: Cache pre-commit environments + uses: actions/cache@v5 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + pre-commit-${{ runner.os }}- # Run pre-commit - name: Run pre-commit run: | - make -f .rhiza/rhiza.mk fmt + make fmt diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 4f2906c..735bad5 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -7,11 +7,19 @@ # # πŸ“‹ Pipeline Phases: # 1. πŸ” Validate Tag - Check tag format and ensure release doesn't already exist -# 2. πŸ—οΈ Build - Build Python package with Hatch (if [build-system] is defined in pyproject.toml -# 3. πŸ“ Draft Release - Create draft GitHub release with build artifacts -# 4. πŸš€ Publish to PyPI - Publish package using OIDC or custom feed -# 5. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) -# 6. βœ… Finalize Release - Publish the GitHub release with links +# 2. πŸ—οΈ Build - Build Python package with Hatch (if [build-system] is defined in pyproject.toml) +# 3. πŸ“¦ Generate SBOM - Create Software Bill of Materials (CycloneDX format) +# 4. πŸ“ Draft Release - Create draft GitHub release with build artifacts and SBOM +# 5. πŸš€ Publish to PyPI - Publish package using OIDC or custom feed +# 6. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) +# 7. βœ… Finalize Release - Publish the GitHub release with links +# +# πŸ“¦ SBOM Generation: +# - Generated using CycloneDX format (industry standard for software supply chain security) +# - Creates both JSON and XML formats for maximum compatibility +# - SBOM attestations are created and stored (public repos only) +# - Attached to GitHub releases for transparency and compliance +# - Skipped if pyproject.toml doesn't exist # # 🐳 Devcontainer Publishing: # - Only occurs when PUBLISH_DEVCONTAINER repository variable is set to "true" @@ -33,7 +41,8 @@ # - No PyPI credentials stored; relies on Trusted Publishing via GitHub OIDC # - For custom feeds, PYPI_TOKEN secret is used with default username __token__ # - Container registry uses GITHUB_TOKEN for authentication -# - SLSA provenance attestations generated for build artifacts (supply chain security) +# - SLSA provenance attestations generated for build artifacts (public repos only) +# - SBOM attestations generated for supply chain transparency (public repos only) # # πŸ“„ Requirements: # - pyproject.toml with top-level version field (for Python packages) @@ -65,7 +74,7 @@ permissions: contents: write # Needed to create releases id-token: write # Needed for OIDC authentication with PyPI packages: write # Needed to publish devcontainer image - attestations: write # Needed for SLSA provenance attestations + attestations: write # Needed for SLSA provenance attestations (public repos only) jobs: tag: @@ -75,7 +84,7 @@ jobs: tag: ${{ steps.set_tag.outputs.tag }} steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 @@ -106,21 +115,19 @@ jobs: needs: tag steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" + version: "0.10.2" - - name: "Sync the virtual environment for ${{ github.repository }}" - shell: bash - run: | - export UV_EXTRA_INDEX_URL="${{ secrets.uv-extra-index-url }}" - # will just use .python-version? - uv sync --all-extras --all-groups --frozen + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} - name: Verify version matches tag if: hashFiles('pyproject.toml') != '' @@ -150,15 +157,56 @@ jobs: printf "[INFO] Building package...\n" uvx hatch build + - name: Install Python for SBOM generation + if: hashFiles('pyproject.toml') != '' + uses: actions/setup-python@v6.2.0 + with: + python-version-file: .python-version + + - name: Sync environment for SBOM generation + if: hashFiles('pyproject.toml') != '' + run: | + export UV_EXTRA_INDEX_URL="${{ secrets.UV_EXTRA_INDEX_URL }}" + uv sync --all-extras --all-groups --frozen + + - name: Generate SBOM (CycloneDX) + if: hashFiles('pyproject.toml') != '' + run: | + printf "[INFO] Generating SBOM in CycloneDX format...\n" + # Note: uvx caches the tool environment, so the second call is fast + uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --of JSON -o sbom.cdx.json + uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --of XML -o sbom.cdx.xml + printf "[INFO] SBOM generation complete\n" + printf "Generated files:\n" + ls -lh sbom.cdx.* + + - name: Attest SBOM + # Attest only the JSON format as it's the canonical machine-readable format. + # The XML format is provided for compatibility but doesn't need separate attestation. + if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false + uses: actions/attest-sbom@v3 + with: + subject-path: sbom.cdx.json + sbom-path: sbom.cdx.json + + - name: Upload SBOM artifacts + if: hashFiles('pyproject.toml') != '' + uses: actions/upload-artifact@v6.0.0 + with: + name: sbom + path: | + sbom.cdx.json + sbom.cdx.xml + - name: Generate SLSA provenance attestations - if: steps.buildable.outputs.buildable == 'true' + if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false uses: actions/attest-build-provenance@v3 with: subject-path: dist/* - name: Upload dist artifact if: steps.buildable.outputs.buildable == 'true' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v6.0.0 with: name: dist path: dist @@ -170,6 +218,14 @@ jobs: needs: [tag, build] steps: + - name: Download SBOM artifact + # Downloads sbom.cdx.json and sbom.cdx.xml into sbom/ directory + uses: actions/download-artifact@v6.0.0 + with: + name: sbom + path: sbom + continue-on-error: true + - name: Create GitHub Release with artifacts uses: softprops/action-gh-release@v2.5.0 with: @@ -177,6 +233,8 @@ jobs: name: ${{ needs.tag.outputs.tag }} generate_release_notes: true draft: true + files: | + sbom/* # Decide at step-level whether to publish pypi: @@ -189,12 +247,12 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - name: Download dist artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v6.0.0 with: name: dist path: dist @@ -237,7 +295,7 @@ jobs: image_name: ${{ steps.image_name.outputs.image_name }} steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 @@ -266,7 +324,7 @@ jobs: - name: Login to Container Registry if: steps.check_publish.outputs.should_publish == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v3.7.0 with: registry: ${{ steps.registry.outputs.registry }} username: ${{ github.repository_owner }} @@ -322,24 +380,24 @@ jobs: if: needs.pypi.result == 'success' || needs.devcontainer.result == 'success' steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 with: - version: "0.9.26" + version: "0.10.2" - name: "Sync the virtual environment for ${{ github.repository }}" shell: bash run: | - export UV_EXTRA_INDEX_URL="${{ secrets.uv-extra-index-url }}" + export UV_EXTRA_INDEX_URL="${{ secrets.uv_extra_index_url }}" # will just use .python-version? uv sync --all-extras --all-groups --frozen - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v6.2.0 - name: Generate Devcontainer Link id: devcontainer_link @@ -358,7 +416,7 @@ jobs: id: pypi_link if: needs.pypi.outputs.should_publish == 'true' && needs.pypi.result == 'success' run: | - PACKAGE_NAME=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])") + PACKAGE_NAME=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])") VERSION="${{ needs.tag.outputs.tag }}" VERSION=${VERSION#v} diff --git a/.github/workflows/rhiza_security.yml b/.github/workflows/rhiza_security.yml index 0effa48..7ded477 100644 --- a/.github/workflows/rhiza_security.yml +++ b/.github/workflows/rhiza_security.yml @@ -27,12 +27,23 @@ jobs: name: Security scanning runs-on: ubuntu-latest container: - image: ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie + image: ghcr.io/astral-sh/uv:0.9.30-bookworm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} - name: Run security scans env: - UV_EXTRA_INDEX_URL: ${{ secrets.uv-extra-index-url }} + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} run: make security + + + - name: Run typecheck + env: + UV_EXTRA_INDEX_URL: ${{ secrets.UV_EXTRA_INDEX_URL }} + run: make typecheck diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml index ea218ea..37f3712 100644 --- a/.github/workflows/rhiza_sync.yml +++ b/.github/workflows/rhiza_sync.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 with: token: ${{ secrets.PAT_TOKEN || github.token }} fetch-depth: 0 @@ -50,7 +50,7 @@ jobs: fi - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v7.3.0 - name: Get Rhiza version id: rhiza-version @@ -90,7 +90,7 @@ jobs: if: > (github.event_name == 'schedule' || inputs.create-pr == true) && steps.sync.outputs.changes_detected == 'true' - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@v8.1.0 with: token: ${{ secrets.PAT_TOKEN || github.token }} base: ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/rhiza_validate.yml b/.github/workflows/rhiza_validate.yml index 7190db0..f7ff097 100644 --- a/.github/workflows/rhiza_validate.yml +++ b/.github/workflows/rhiza_validate.yml @@ -12,16 +12,31 @@ on: jobs: validation: runs-on: ubuntu-latest - # don't run this in rhiza itself. Rhiza has no template.yml file. - if: ${{ github.repository != 'jebel-quant/rhiza' }} - container: - image: ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.0.2 + with: + lfs: true + + - name: Install uv + uses: astral-sh/setup-uv@v7.3.0 + with: + version: "0.10.2" + + - name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth + with: + token: ${{ secrets.GH_PAT }} - name: Validate Rhiza config + # don't run this in rhiza itself. Rhiza has no template.yml file. + if: ${{ github.repository != 'jebel-quant/rhiza' }} shell: bash run: | uvx "rhiza>=0.8.0" validate . + + - name: Run Rhiza Tests + shell: bash + run: | + make rhiza-test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad857a2..fdacbf6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,9 +7,10 @@ repos: hooks: - id: check-toml - id: check-yaml + args: ['--unsafe'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.14.14' + rev: 'v0.15.1' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] @@ -24,7 +25,7 @@ repos: args: ["--disable", "MD013"] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.36.0 + rev: 0.36.1 hooks: - id: check-renovate args: [ "--verbose" ] @@ -33,12 +34,12 @@ repos: args: ["--verbose"] - repo: https://github.com/rhysd/actionlint - rev: v1.7.10 + rev: v1.7.11 hooks: - id: actionlint - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.24.1 + rev: v0.25 hooks: - id: validate-pyproject @@ -46,20 +47,21 @@ repos: rev: 1.9.3 hooks: - id: bandit - args: ["--skip", "B101", "--exclude", ".venv,tests,.git,.pytest_cache"] + args: ["--skip", "B101", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache", "-c", "pyproject.toml"] - - repo: local + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.10.2 hooks: - - id: update-readme-help - name: Update README with Makefile help output - entry: make readme - language: system - files: '^Makefile$' - pass_filenames: false + - id: uv-lock + - repo: https://github.com/Jebel-Quant/rhiza-hooks + rev: v0.3.0 # Use the latest release + hooks: + # Migrated from rhiza - id: check-rhiza-workflow-names - name: Check and Fix Rhiza workflow names - entry: python .rhiza/scripts/check_workflow_names.py - language: python - additional_dependencies: [PyYAML] - files: ^\.github/workflows/rhiza_.*\.ya?ml$ + - id: update-readme-help + # Additional utility hooks + - id: check-rhiza-config + - id: check-makefile-targets + - id: check-python-version-consistency + # - id: check-template-bundles diff --git a/.rhiza/.cfg.toml b/.rhiza/.cfg.toml index 65660d1..e084738 100644 --- a/.rhiza/.cfg.toml +++ b/.rhiza/.cfg.toml @@ -6,7 +6,7 @@ replace = "{new_version}" regex = false ignore_missing_version = false ignore_missing_files = false -tag = false +tag = true sign_tags = false tag_name = "v{new_version}" tag_message = "Bump version: {current_version} β†’ {new_version}" @@ -14,6 +14,7 @@ allow_dirty = false commit = true message = "Chore: bump version {current_version} β†’ {new_version}" commit_args = "" +pre_commit_hooks = ["uv sync", "git add uv.lock"] # Ensure uv.lock is updated [tool.bumpversion.parts.release] optional_value = "prod" @@ -30,7 +31,7 @@ filename = "pyproject.toml" search = 'version = "{current_version}"' replace = 'version = "{new_version}"' -[[tool.bumpversion.files]] -filename = "uv.lock" -search = 'version = "{current_version}"' -replace = 'version = "{new_version}"' \ No newline at end of file +# [[tool.bumpversion.files]] +# filename = ".rhiza/template-bundles.yml" +# search = 'version: "{current_version}"' +# replace = 'version: "{new_version}"' diff --git a/.rhiza/.env b/.rhiza/.env index 4775326..2c7edd5 100644 --- a/.rhiza/.env +++ b/.rhiza/.env @@ -5,9 +5,5 @@ SCRIPTS_FOLDER=.rhiza/scripts # Book-specific variables BOOK_TITLE=Project Documentation BOOK_SUBTITLE=Generated by minibook -PDOC_TEMPLATE_DIR=book/pdoc-templates -BOOK_TEMPLATE=book/minibook-templates/custom.html.jinja2 -DOCFORMAT=google - -# Agentic-specific variables -DEFAULT_AI_MODEL=gpt-4.1 +BOOK_TEMPLATE=.rhiza/templates/minibook/custom.html.jinja2 +# PDOC_TEMPLATE_DIR is now defined in .rhiza/make.d/08-docs.mk with a default value diff --git a/.rhiza/.rhiza-version b/.rhiza/.rhiza-version index 899f24f..142464b 100644 --- a/.rhiza/.rhiza-version +++ b/.rhiza/.rhiza-version @@ -1 +1 @@ -0.9.0 \ No newline at end of file +0.11.0 \ No newline at end of file diff --git a/.rhiza/INDEX.md b/.rhiza/INDEX.md new file mode 100644 index 0000000..32abfee --- /dev/null +++ b/.rhiza/INDEX.md @@ -0,0 +1,154 @@ +# Rhiza Index + +Quick reference to all utilities, makefiles, and resources in the `.rhiza/` directory. + +## πŸ“ Directory Structure + +``` +.rhiza/ +β”œβ”€β”€ rhiza.mk # Core makefile logic (153 lines) +β”œβ”€β”€ .rhiza-version # Current Rhiza version +β”œβ”€β”€ .cfg.toml # Configuration file +β”œβ”€β”€ .env # Environment variables +β”œβ”€β”€ template-bundles.yml # Template bundle definitions +β”œβ”€β”€ make.d/ # Makefile extensions (auto-loaded) +β”œβ”€β”€ requirements/ # Python dependencies +β”œβ”€β”€ scripts/ # Shell scripts and utilities +β”œβ”€β”€ templates/ # Project templates +β”œβ”€β”€ tests/ # Test suite +β”œβ”€β”€ docs/ # Internal documentation +└── assets/ # Static assets +``` + +## πŸ”§ Makefiles (`.rhiza/make.d/`) + +| File | Size | Purpose | Section | +|------|------|---------|---------| +| `agentic.mk` | 3.1K | AI agent integrations (copilot, claude) | Agentic Workflows | +| `book.mk` | 4.7K | Documentation book generation | Book | +| `bootstrap.mk` | 4.3K | Installation and environment setup | Bootstrap | +| `custom-env.mk` | 290B | Example environment customizations | - | +| `custom-task.mk` | 423B | Example custom tasks | Custom Tasks | +| `docker.mk` | 1.1K | Docker build and run targets | Docker | +| `docs.mk` | 3.9K | Documentation generation (pdoc) | Documentation | +| `github.mk` | 6.0K | GitHub CLI integrations | GitHub Helpers | +| `lfs.mk` | 3.0K | Git LFS management | Git LFS | +| `marimo.mk` | 2.9K | Marimo notebook support | Marimo Notebooks | +| `presentation.mk` | 3.3K | Presentation building (Marp) | Presentation | +| `quality.mk` | 860B | Code quality and formatting | Quality and Formatting | +| `releasing.mk` | 2.0K | Release and versioning | Releasing and Versioning | +| `test.mk` | 5.1K | Testing infrastructure | Development and Testing | + +**Total**: 14 makefiles, ~41KB + +## πŸ“¦ Requirements (`.rhiza/requirements/`) + +| File | Purpose | +|------|---------| +| `docs.txt` | Documentation generation dependencies (pdoc) | +| `marimo.txt` | Marimo notebook dependencies | +| `tests.txt` | Testing dependencies (pytest, coverage) | +| `tools.txt` | Development tools (pre-commit, python-dotenv) | + +See [requirements/README.md](requirements/README.md) for details. + +## πŸ§ͺ Test Suite (`.rhiza/tests/`) + +| Directory | Purpose | +|-----------|---------| +| `api/` | Makefile target validation (dry-run tests) | +| `deps/` | Dependency health checks | +| `integration/` | End-to-end workflow tests | +| `structure/` | Static project structure assertions | +| `sync/` | Template sync and content validation | +| `utils/` | Test infrastructure utilities | + +**Total**: 23 Python test files + +See [tests/README.md](tests/README.md) for details. + +## πŸ“š Documentation (`.rhiza/docs/`) + +| File | Purpose | +|------|---------| +| `ASSETS.md` | Asset management documentation | +| `CONFIG.md` | Configuration file documentation | +| `LFS.md` | Git LFS setup and usage | +| `PRIVATE_PACKAGES.md` | Private package authentication | +| `RELEASING.md` | Release process documentation | +| `TOKEN_SETUP.md` | GitHub token setup | +| `WORKFLOWS.md` | GitHub Actions workflows | + +## 🎨 Assets (`.rhiza/assets/`) + +- `rhiza-logo.svg` - Rhiza logo graphic + +## πŸ“‹ Templates (`.rhiza/templates/`) + +- `minibook/` - Minimal documentation book template + +## πŸ”Œ Template Bundles + +Defined in `template-bundles.yml`: + +| Bundle | Description | Files | +|--------|-------------|-------| +| `core` | Core Rhiza infrastructure | 43 files | +| `github` | GitHub Actions workflows | CI/CD | +| `tests` | Testing infrastructure | pytest, coverage | +| `marimo` | Interactive notebooks | Marimo support | +| `book` | Documentation generation | Book building | +| `docker` | Docker containerization | Dockerfile | +| `lfs` | Git LFS support | Large files | +| `presentation` | Presentation building | reveal.js | +| `gitlab` | GitLab CI/CD | GitLab workflows | +| `devcontainer` | VS Code DevContainer | Dev environment | +| `legal` | Legal documentation | LICENSE, CODE_OF_CONDUCT | + +## 🎯 Key Make Targets + +### Bootstrap +- `make install` - Install dependencies +- `make install-uv` - Ensure uv/uvx is installed +- `make clean` - Clean artifacts and stale branches + +### Development +- `make test` - Run test suite +- `make fmt` - Format code +- `make docs` - Generate documentation + +### AI Agents +- `make copilot` - GitHub Copilot interactive prompt +- `make claude` - Claude Code interactive prompt +- `make analyse-repo` - Update REPOSITORY_ANALYSIS.md + +### Documentation +- `make book` - Build documentation book +- `make marimo` - Start Marimo server +- `make presentation` - Generate presentation slides + +### Docker +- `make docker-build` - Build Docker image +- `make docker-run` - Run container + +### GitHub +- `make view-prs` - List open pull requests +- `make view-issues` - List open issues +- `make failed-workflows` - List failing workflows + +### Quality +- `make fmt` - Format code with ruff +- `make lint` - Lint code +- `make deptry` - Check dependencies + +### Releasing +- `make release` - Create a release +- `make bump` - Bump version + +## πŸ”— Related Documentation + +- [Architecture Diagrams & Naming Conventions](../docs/ARCHITECTURE.md) - Visual architecture overview and detailed naming conventions +- [Makefile Cookbook](make.d/README.md) - Common patterns and recipes +- [Test Suite Guide](tests/README.md) - Testing conventions +- [Customization Guide](../docs/CUSTOMIZATION.md) - How to customize Rhiza +- [Quick Reference](../docs/QUICK_REFERENCE.md) - Common commands diff --git a/.rhiza/assets/rhiza-logo.svg b/.rhiza/assets/rhiza-logo.svg new file mode 100644 index 0000000..ff1c9f5 --- /dev/null +++ b/.rhiza/assets/rhiza-logo.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.rhiza/completions/README.md b/.rhiza/completions/README.md new file mode 100644 index 0000000..cb99e27 --- /dev/null +++ b/.rhiza/completions/README.md @@ -0,0 +1,263 @@ +# Shell Completion for Rhiza Make Targets + +This directory contains shell completion scripts for Bash and Zsh that provide tab-completion for make targets in Rhiza-based projects. + +## Features + +- βœ… Tab-complete all available make targets +- βœ… Show target descriptions in Zsh +- βœ… Complete common make variables (DRY_RUN, BUMP, ENV, etc.) +- βœ… Works with any Rhiza-based project +- βœ… Auto-discovers targets from Makefile and included .mk files + +## Installation + +### Bash + +#### Method 1: Source in your shell config + +Add to your `~/.bashrc` or `~/.bash_profile`: + +```bash +# Rhiza make completion +if [ -f /path/to/project/.rhiza/completions/rhiza-completion.bash ]; then + source /path/to/project/.rhiza/completions/rhiza-completion.bash +fi +``` + +Replace `/path/to/project` with the actual path to your Rhiza project. + +#### Method 2: System-wide installation + +```bash +# Copy to bash completion directory +sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza + +# Reload completions +source /etc/bash_completion.d/rhiza +``` + +#### Method 3: User-local installation + +```bash +# Create local completion directory +mkdir -p ~/.local/share/bash-completion/completions + +# Copy completion script +cp .rhiza/completions/rhiza-completion.bash ~/.local/share/bash-completion/completions/make + +# Reload bash +source ~/.bashrc +``` + +### Zsh + +#### Method 1: User-local installation (Recommended) + +```bash +# Create completion directory +mkdir -p ~/.zsh/completion + +# Copy completion script +cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make + +# Add to ~/.zshrc (if not already present) +echo 'fpath=(~/.zsh/completion $fpath)' >> ~/.zshrc +echo 'autoload -U compinit && compinit' >> ~/.zshrc + +# Reload zsh +source ~/.zshrc +``` + +#### Method 2: Source directly + +Add to your `~/.zshrc`: + +```zsh +# Rhiza make completion +if [ -f /path/to/project/.rhiza/completions/rhiza-completion.zsh ]; then + source /path/to/project/.rhiza/completions/rhiza-completion.zsh +fi +``` + +#### Method 3: System-wide installation + +```bash +# Copy to system completion directory +sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make + +# Reload zsh +exec zsh +``` + +## Usage + +Once installed, you can tab-complete make targets: + +```bash +# Tab-complete targets +make + +# Complete with prefix +make te # Expands to: make test + +# Complete variables +make BUMP= # Shows: patch, minor, major + +# Works with any target +make doc # Shows: docs, docker-build, docker-run, etc. +``` + +### Zsh Benefits + +In Zsh, you'll also see descriptions for targets: + +```bash +make +# Shows: +# test -- run all tests +# fmt -- check the pre-commit hooks and the linting +# install -- install +# docs -- create documentation with pdoc +# ... +``` + +## Common Variables + +The completion scripts understand these common variables: + +| Variable | Values | Description | +|----------|--------|-------------| +| `DRY_RUN` | `1` | Preview mode without making changes | +| `BUMP` | `patch`, `minor`, `major` | Version bump type | +| `ENV` | `dev`, `staging`, `prod` | Target environment | +| `COVERAGE_FAIL_UNDER` | (number) | Minimum coverage threshold | +| `PYTHON_VERSION` | (version) | Override Python version | + +Example usage: + +```bash +# Tab-complete after typing DRY_ +make DRY_ # Expands to: make DRY_RUN=1 + +# Tab-complete variable values +make BUMP= # Shows: patch minor major + +# Combine with targets +make bump BUMP= +``` + +## Troubleshooting + +### Bash: Completions not working + +1. Check if bash-completion is installed: + ```bash + # Debian/Ubuntu + sudo apt-get install bash-completion + + # macOS + brew install bash-completion@2 + ``` + +2. Ensure completion is enabled in your shell: + ```bash + # Add to ~/.bashrc if not present + if [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi + ``` + +3. Reload your shell configuration: + ```bash + source ~/.bashrc + ``` + +### Zsh: Completions not working + +1. Check if compinit is called in your `~/.zshrc`: + ```zsh + autoload -U compinit && compinit + ``` + +2. Clear the completion cache: + ```bash + rm -f ~/.zcompdump + compinit + ``` + +3. Ensure the script is in your fpath: + ```zsh + echo $fpath + ``` + +4. Reload your shell configuration: + ```zsh + source ~/.zshrc + ``` + +### No targets appearing + +1. Ensure you're in a directory with a Makefile: + ```bash + ls -la Makefile + ``` + +2. Test that make can parse the Makefile: + ```bash + make -qp 2>/dev/null | head + ``` + +3. Manually source the completion script to test: + ```bash + # Bash + source .rhiza/completions/rhiza-completion.bash + + # Zsh + source .rhiza/completions/rhiza-completion.zsh + ``` + +## Optional Aliases + +You can add shortcuts in your shell config: + +```bash +# Add to ~/.bashrc or ~/.zshrc +alias m='make' + +# For bash: +complete -F _rhiza_make_completion m + +# For zsh: +compdef _rhiza_make m +``` + +Then use: +```bash +m te # Expands to: m test +``` + +## Technical Details + +### How it works + +1. **Target Discovery**: Parses `make -qp` output to find all targets +2. **Description Extraction**: Looks for `##` comments after target names +3. **Variable Detection**: Includes common Makefile variables +4. **Dynamic Completion**: Regenerates list each time you tab + +### Performance + +- Completions are generated on-demand (when you press Tab) +- For large Makefiles (100+ targets), there may be a small delay +- Results are not cached to ensure targets are always current + +## See Also + +- [Tools Reference](../../docs/TOOLS_REFERENCE.md) - Complete command reference +- [Quick Reference](../../docs/QUICK_REFERENCE.md) - Quick command reference +- [Extending Rhiza](../../docs/EXTENDING_RHIZA.md) - How to add custom targets + +--- + +*Last updated: 2026-02-15* diff --git a/.rhiza/completions/rhiza-completion.bash b/.rhiza/completions/rhiza-completion.bash new file mode 100644 index 0000000..0f860da --- /dev/null +++ b/.rhiza/completions/rhiza-completion.bash @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Bash completion for Rhiza make targets +# +# Installation: +# Source this file in your ~/.bashrc or ~/.bash_profile: +# source /path/to/.rhiza/completions/rhiza-completion.bash +# +# Or copy to bash completion directory: +# sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza +# + +_rhiza_make_completion() { + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Check if we're in a directory with a Makefile + if [[ ! -f "Makefile" ]]; then + return 0 + fi + + # Extract make targets from Makefile and all included .mk files + # Looks for lines like: target: ## description + opts=$(make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}' | \ + grep -v '^Makefile$' | \ + sort -u) + + # Add common make variables that can be overridden + local vars="DRY_RUN=1 BUMP=patch BUMP=minor BUMP=major ENV=dev ENV=staging ENV=prod" + opts="$opts $vars" + + # Generate completions + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} + +# Register the completion function for make command +complete -F _rhiza_make_completion make + +# Also complete for direct make invocation with path +complete -F _rhiza_make_completion ./Makefile + +# Helpful aliases (optional - uncomment if desired) +# alias m='make' +# complete -F _rhiza_make_completion m diff --git a/.rhiza/completions/rhiza-completion.zsh b/.rhiza/completions/rhiza-completion.zsh new file mode 100644 index 0000000..03931c9 --- /dev/null +++ b/.rhiza/completions/rhiza-completion.zsh @@ -0,0 +1,88 @@ +#compdef make +# Zsh completion for Rhiza make targets +# +# Installation: +# Add this file to your fpath and ensure compinit is called: +# +# Method 1 (User-local): +# mkdir -p ~/.zsh/completion +# cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make +# Add to ~/.zshrc: +# fpath=(~/.zsh/completion $fpath) +# autoload -U compinit && compinit +# +# Method 2 (Source directly): +# Add to ~/.zshrc: +# source /path/to/.rhiza/completions/rhiza-completion.zsh +# +# Method 3 (System-wide): +# sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make +# + +_rhiza_make() { + local -a targets variables + + # Check if we're in a directory with a Makefile + if [[ ! -f "Makefile" ]]; then + return 0 + fi + + # Extract make targets with descriptions + # Format: target:description + targets=(${(f)"$( + make -qp 2>/dev/null | \ + awk -F':' ' + /^# Files/,/^# Finished Make data base/ { + if (/^[a-zA-Z0-9_-]+:.*##/) { + target=$1 + desc=$0 + sub(/^[^#]*## */, "", desc) + gsub(/^[ \t]+/, "", target) + print target ":" desc + } + } + ' | \ + grep -v '^Makefile:' | \ + sort -u + )"}) + + # Also get targets without descriptions + local -a plain_targets + plain_targets=(${(f)"$( + make -qp 2>/dev/null | \ + awk -F':' '/^[a-zA-Z0-9_-]+:([^=]|$)/ { + split($1,A,/ /) + for(i in A) print A[i] + }' | \ + grep -v '^Makefile$' | \ + sort -u + )"}) + + # Common make variables + variables=( + 'DRY_RUN=1:preview mode without making changes' + 'BUMP=patch:bump patch version' + 'BUMP=minor:bump minor version' + 'BUMP=major:bump major version' + 'ENV=dev:development environment' + 'ENV=staging:staging environment' + 'ENV=prod:production environment' + 'COVERAGE_FAIL_UNDER=:minimum coverage threshold' + 'PYTHON_VERSION=:override Python version' + ) + + # Combine all completions + local -a all_completions + all_completions=($targets $plain_targets $variables) + + # Show completions with descriptions + _describe 'make targets' all_completions +} + +# Register the completion function +compdef _rhiza_make make + +# Optional: Add completion for common aliases +# Uncomment these if you use these aliases +# alias m='make' +# compdef _rhiza_make m diff --git a/.rhiza/docs/ASSETS.md b/.rhiza/docs/ASSETS.md new file mode 100644 index 0000000..44090a1 --- /dev/null +++ b/.rhiza/docs/ASSETS.md @@ -0,0 +1,14 @@ +# Assets + +The `.rhiza/assets/` directory contains static assets used in the Rhiza project, such as logos, images, and other media files. + +## Contents + +- `rhiza-logo.svg`: The official Rhiza project logo. + +## Usage + +These assets are primarily used in: +- The main `README.md` file. +- Generated documentation and the companion book. +- Project presentations. diff --git a/.rhiza/docs/CONFIG.md b/.rhiza/docs/CONFIG.md index c3943b4..205deb5 100644 --- a/.rhiza/docs/CONFIG.md +++ b/.rhiza/docs/CONFIG.md @@ -4,7 +4,13 @@ This directory contains platform-agnostic scripts and utilities for the reposito ## Important Documentation +### CI/CD & Infrastructure - **[TOKEN_SETUP.md](TOKEN_SETUP.md)** - Instructions for setting up the `PAT_TOKEN` secret required for the SYNC workflow +- **[PRIVATE_PACKAGES.md](PRIVATE_PACKAGES.md)** - Guide for using private GitHub packages as dependencies +- **[WORKFLOWS.md](WORKFLOWS.md)** - Development workflows and dependency management +- **[RELEASING.md](RELEASING.md)** - Release process and version management +- **[LFS.md](LFS.md)** - Git LFS configuration and make targets +- **[ASSETS.md](ASSETS.md)** - Information about `.rhiza/assets/` directory ## Structure diff --git a/.rhiza/docs/LFS.md b/.rhiza/docs/LFS.md new file mode 100644 index 0000000..9427c67 --- /dev/null +++ b/.rhiza/docs/LFS.md @@ -0,0 +1,161 @@ +# Git LFS (Large File Storage) Configuration + +This document describes the Git LFS integration in the Rhiza framework. + +## Overview + +Git LFS (Large File Storage) is an extension to Git that allows you to version large files efficiently. Instead of storing large binary files directly in the Git repository, LFS stores them on a remote server and keeps only small pointer files in the repository. + +## Available Make Targets + +### `make lfs-install` + +Installs Git LFS and configures it for the current repository. + +**Features:** +- **Cross-platform support**: Works on macOS (both Intel and ARM) and Linux +- **macOS**: Downloads and installs the latest git-lfs binary to `.local/bin/` +- **Linux**: Installs git-lfs via apt-get package manager +- **Automatic configuration**: Runs `git lfs install` to set up LFS hooks + +**Usage:** +```bash +make lfs-install +``` + +**Note for macOS users:** The git-lfs binary is installed locally in `.local/bin/` and added to PATH for the installation. This approach avoids requiring system-level package managers like Homebrew. + +### `make lfs-pull` + +Downloads all Git LFS files for the current branch. + +**Usage:** +```bash +make lfs-pull +``` + +This is useful after cloning a repository or checking out a branch that contains LFS-tracked files. + +### `make lfs-track` + +Lists all file patterns currently tracked by Git LFS. + +**Usage:** +```bash +make lfs-track +``` + +### `make lfs-status` + +Shows the status of Git LFS files in the repository. + +**Usage:** +```bash +make lfs-status +``` + +## Typical Workflow + +1. **Initial setup** (first time only): + ```bash + make lfs-install + ``` + +2. **Track large files** (configure which files to store in LFS): + ```bash + git lfs track "*.psd" + git lfs track "*.zip" + git lfs track "data/*.csv" + ``` + +3. **Check tracking status**: + ```bash + make lfs-track + ``` + +4. **Pull LFS files** (after cloning or checking out): + ```bash + make lfs-pull + ``` + +5. **Check LFS status**: + ```bash + make lfs-status + ``` + +## CI/CD Integration + +### GitHub Actions + +When using Git LFS with GitHub Actions, add the `lfs: true` option to your checkout step: + +```yaml +- uses: actions/checkout@v4 + with: + lfs: true +``` + +### GitLab CI + +For GitLab CI, install and pull LFS files in your before_script: + +```yaml +before_script: + - apt-get update && apt-get install -y git-lfs || exit 1 + - git lfs pull +``` + +## Configuration Files + +Git LFS uses `.gitattributes` to track which files should be managed by LFS. Example: + +``` +# .gitattributes +*.psd filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +data/*.csv filter=lfs diff=lfs merge=lfs -text +``` + +## Resources + +- [Git LFS Official Documentation](https://git-lfs.github.com/) +- [Git LFS Tutorial](https://github.com/git-lfs/git-lfs/wiki/Tutorial) +- [Git LFS GitHub Repository](https://github.com/git-lfs/git-lfs) + +## Troubleshooting + +### Permission denied during installation (Linux) + +If you encounter permission errors on Linux during `make lfs-install`, the installation requires elevated privileges. The command will prompt for sudo access automatically. If it fails, you can run: + +```bash +sudo apt-get update && sudo apt-get install -y git-lfs +git lfs install +``` + +Alternatively, if you don't have sudo access, git-lfs can be installed manually by downloading the binary from the [releases page](https://github.com/git-lfs/git-lfs/releases). + +### Failed to detect git-lfs version (macOS) + +If the installation fails with "Failed to detect git-lfs version", ensure you have internet connectivity and can access the GitHub API: + +```bash +curl -s https://api.github.com/repos/git-lfs/git-lfs/releases/latest +``` + +If the GitHub API is blocked, you can manually download and install git-lfs from [git-lfs.github.com](https://git-lfs.github.com/). + +### LFS files not downloading + +If LFS files are not downloading, ensure: +1. Git LFS is installed: `git lfs version` +2. LFS is initialized: `git lfs install` +3. Pull LFS files explicitly: `make lfs-pull` + +### Checking LFS storage usage + +To see how much storage your LFS files are using: + +```bash +git lfs ls-files --size +``` diff --git a/.rhiza/docs/PRIVATE_PACKAGES.md b/.rhiza/docs/PRIVATE_PACKAGES.md new file mode 100644 index 0000000..f7a98da --- /dev/null +++ b/.rhiza/docs/PRIVATE_PACKAGES.md @@ -0,0 +1,233 @@ +# Using Private GitHub Packages + +This document explains how to configure your project to use private GitHub packages from the same organization as dependencies. + +## Quick Start + +If you're using Rhiza's template workflows, git authentication for private packages is **already configured**! All Rhiza workflows automatically include the necessary git configuration to access private repositories in the same organization. + +Simply add your private package to `pyproject.toml`: + +```toml +[tool.uv.sources] +my-package = { git = "https://github.com/jebel-quant/my-package.git", rev = "v1.0.0" } +``` + +The workflows will handle authentication automatically using `GITHUB_TOKEN`. + +## Detailed Guide + +### Problem + +When your project depends on private GitHub repositories, you need to authenticate to access them. SSH keys work locally but are complex to set up in CI/CD environments. HTTPS with tokens is simpler and more secure for automated workflows. + +## Solution + +Use HTTPS URLs with token authentication instead of SSH for git dependencies. + +### 1. Configure Dependencies in pyproject.toml + +Instead of using SSH URLs like `git@github.com:org/repo.git`, use HTTPS URLs: + +```toml +[tool.uv.sources] +my-package = { git = "https://github.com/jebel-quant/my-package.git", rev = "v1.0.0" } +another-package = { git = "https://github.com/jebel-quant/another-package.git", tag = "v2.0.0" } +``` + +**Key points:** +- Use `https://github.com/` instead of `git@github.com:` +- Specify version using `rev`, `tag`, or `branch` parameter +- No token is included in the URL itself (git config handles authentication) + +### 2. Git Authentication in CI (Already Configured!) + +**If you're using Rhiza's template workflows, this is already set up for you.** All Rhiza workflows (CI, book, release, etc.) automatically include git authentication steps. + +You can verify this by checking any Rhiza workflow file (e.g., `.github/workflows/rhiza_ci.yml`): + +```yaml +- name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth +``` + +Or for container-based workflows: + +```yaml +- name: Configure git auth for private packages + run: | + git config --global url."https://${{ github.token }}@github.com/".insteadOf "https://github.com/" +``` + +**For custom workflows** (not synced from Rhiza), add the git authentication step yourself: + +```yaml +- name: Configure git auth for private packages + run: | + git config --global url."https://${{ github.token }}@github.com/".insteadOf "https://github.com/" +``` + +This configuration tells git to automatically inject the `GITHUB_TOKEN` into all HTTPS GitHub URLs. + +### 3. Using the Composite Action (Custom Workflows) + +For custom workflows, you can use Rhiza's composite action instead of inline commands: + +```yaml +- name: Configure git auth for private packages + uses: ./.github/actions/configure-git-auth +``` + +This is cleaner and more maintainable than inline git config commands. + +### 4. Complete Workflow Example + +Here's a complete example of a GitHub Actions workflow that uses private packages: + +```yaml +name: CI with Private Packages + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.9.28" + + - name: Configure git auth for private packages + run: | + git config --global url."https://${{ github.token }}@github.com/".insteadOf "https://github.com/" + + - name: Install dependencies + run: | + uv sync --frozen + + - name: Run tests + run: | + uv run pytest +``` + +## Token Scopes + +### Same Repository + +The default `GITHUB_TOKEN` automatically has access to the **same repository** where the workflow runs: +- βœ… Is automatically provided by GitHub Actions +- βœ… Is scoped to the workflow run (secure) +- βœ… No manual token management required + +This is sufficient if your private packages are defined within the same repository. + +### Same Organization (Requires PAT) + +**Important:** The default `GITHUB_TOKEN` typically does **not** have permission to read other private repositories, even within the same organization. This is GitHub's default security behavior. + +To access private packages in other repositories within your organization, you need a Personal Access Token (PAT): + +1. Create a PAT with `repo` scope (see [TOKEN_SETUP.md](TOKEN_SETUP.md) for instructions) +2. Add it as a repository secret (e.g., `PRIVATE_PACKAGES_TOKEN`) +3. Use it in the git config + +**Note:** Some organizations configure settings to allow `GITHUB_TOKEN` cross-repository access, but this is not the default and should not be assumed. Using a PAT is the recommended approach for reliability. + +### Different Organization + +If your private packages are in a **different organization**, you need a Personal Access Token (PAT): + +1. Create a PAT with `repo` scope (see [TOKEN_SETUP.md](TOKEN_SETUP.md) for instructions) +2. Add it as a repository secret (e.g., `PRIVATE_PACKAGES_TOKEN`) +3. Use it in the git config: + +```yaml +- name: Configure git auth for private packages + run: | + git config --global url."https://${{ secrets.PRIVATE_PACKAGES_TOKEN }}@github.com/".insteadOf "https://github.com/" +``` + +## Local Development + +For local development, you have several options: + +### Option 1: Use GitHub CLI (Recommended) + +```bash +# Install gh CLI +brew install gh # macOS +# or: apt install gh # Ubuntu/Debian + +# Authenticate +gh auth login + +# Configure git +gh auth setup-git +``` + +The GitHub CLI automatically handles git authentication for private repositories. + +### Option 2: Use Personal Access Token + +```bash +# Create a PAT with 'repo' scope at: +# https://github.com/settings/tokens + +# Configure git +git config --global url."https://YOUR_TOKEN@github.com/".insteadOf "https://github.com/" +``` + +**Security Note:** Be careful not to commit this configuration. It's better to use `gh` CLI or SSH keys for local development. + +### Option 3: Use SSH (Local Only) + +For local development, you can continue using SSH: + +```toml +[tool.uv.sources] +my-package = { git = "ssh://git@github.com/jebel-quant/my-package.git", rev = "v1.0.0" } +``` + +However, this won't work in CI without additional SSH key setup. + +## Troubleshooting + +### Error: "fatal: could not read Username" + +This means git cannot find authentication credentials. Ensure: +1. The git config step runs **before** `uv sync` +2. The token has proper permissions +3. The repository URL uses HTTPS format + +### Error: "Repository not found" or "403 Forbidden" + +This means the token doesn't have access to the repository. Check: +1. The repository is in the same organization (for `GITHUB_TOKEN`) +2. Or use a PAT with `repo` scope (for different organizations) +3. The token hasn't expired + +### Error: "Couldn't resolve host 'github.com'" + +This is a network issue, not authentication. Check your network connection. + +## Best Practices + +1. **Use HTTPS URLs** in `pyproject.toml` for better CI/CD compatibility +2. **Rely on `GITHUB_TOKEN`** for same-org packages (automatic and secure) +3. **Pin versions** using `rev`, `tag`, or specific commit SHA for reproducibility +4. **Use `gh` CLI** for local development (easier than managing tokens) +5. **Keep tokens secure** - never commit them to the repository + +## Related Documentation + +- [TOKEN_SETUP.md](TOKEN_SETUP.md) - Setting up Personal Access Tokens +- [GitHub Actions: Automatic token authentication](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) +- [uv: Git dependencies](https://docs.astral.sh/uv/concepts/dependencies/#git-dependencies) diff --git a/.rhiza/docs/RELEASING.md b/.rhiza/docs/RELEASING.md new file mode 100644 index 0000000..5f4c51f --- /dev/null +++ b/.rhiza/docs/RELEASING.md @@ -0,0 +1,99 @@ +# Release Guide + +This guide covers the release process for Rhiza-based projects. + +## πŸš€ The Release Process + +The release process can be done in two separate steps (**Bump** then **Release**), or in a single step using **Publish**. + +### Option A: One-Step Publish (Recommended) + +Bump the version and release in a single flow: + +```bash +make publish +``` + +This combines the bump and release steps below into one interactive command. + +### Option B: Two-Step Process + +#### 1. Bump Version + +First, update the version in `pyproject.toml`: + +```bash +make bump +``` + +This command will interactively guide you through: +1. Selecting a bump type (patch, minor, major) or entering a specific version +2. Warning you if you're not on the default branch +3. Showing the current and new version +4. Prompting whether to commit the changes +5. Prompting whether to push the changes + +The script ensures safety by: +- Checking for uncommitted changes before bumping +- Validating that the tag doesn't already exist +- Verifying the version format + +#### 2. Release + +Once the version is bumped and committed, run the release command: + +```bash +make release +``` + +This command will interactively guide you through: +1. Checking if your branch is up-to-date with the remote +2. If your local branch is ahead, showing the unpushed commits and prompting you to push them +3. Creating a git tag (e.g., `v1.2.4`) +4. Pushing the tag to the remote, which triggers the GitHub Actions release workflow + +The script provides safety checks by: +- Warning if you're not on the default branch +- Verifying no uncommitted changes exist +- Checking if the tag already exists locally or on remote +- Showing the number of commits since the last tag + +### Checking Release Status + +After releasing, you can check the status of the release workflow and the latest release: + +```bash +make release-status +``` + +This will display: +- The last 5 release workflow runs with their status and conclusion +- The latest GitHub release details (tag, author, published time, status, URL) + +> **Note:** `release-status` is currently supported for GitHub repositories only. GitLab support is planned for a future release. + +## What Happens After Release + +The release workflow (`.github/workflows/rhiza_release.yml`) triggers on the tag push and: + +1. **Validates** - Checks the tag format and ensures no duplicate releases +2. **Builds** - Builds the Python package (if `pyproject.toml` exists) +3. **Drafts** - Creates a draft GitHub release with artifacts +4. **PyPI** - Publishes to PyPI (if not marked private) +5. **Devcontainer** - Publishes devcontainer image (if `PUBLISH_DEVCONTAINER=true`) +6. **Finalizes** - Publishes the GitHub release with links to PyPI and container images + +## Configuration Options + +### PyPI Publishing + +- Automatic if package is registered as a Trusted Publisher +- Use `PYPI_REPOSITORY_URL` and `PYPI_TOKEN` for custom feeds +- Mark as private with `Private :: Do Not Upload` in `pyproject.toml` + +### Devcontainer Publishing + +- Set repository variable `PUBLISH_DEVCONTAINER=true` to enable +- Override registry with `DEVCONTAINER_REGISTRY` variable (defaults to ghcr.io) +- Requires `.devcontainer/devcontainer.json` to exist +- Image published as `{registry}/{owner}/{repository}/devcontainer:vX.Y.Z` diff --git a/.rhiza/docs/WORKFLOWS.md b/.rhiza/docs/WORKFLOWS.md new file mode 100644 index 0000000..9025fe2 --- /dev/null +++ b/.rhiza/docs/WORKFLOWS.md @@ -0,0 +1,248 @@ +# Development Workflows + +This guide covers recommended day-to-day development workflows for Rhiza projects. + +## Dependency Management + +Rhiza uses [uv](https://docs.astral.sh/uv/) for fast, reliable Python dependency management. + +> πŸ“š **For detailed information about dependency version constraints and rationale**, see [docs/DEPENDENCIES.md](../../docs/DEPENDENCIES.md) + +### Adding Dependencies + +**Recommended: Use `uv add`** β€” handles everything in one step: + +```bash +# Add a runtime dependency +uv add requests + +# Add a development dependency +uv add --dev pytest-xdist + +# Add with version constraint +uv add "pandas>=2.0" +``` + +This command: +1. Updates `pyproject.toml` +2. Resolves and updates `uv.lock` +3. Installs the package into your active venv + +### Manual Editing + +If you prefer to edit `pyproject.toml` directly: + +```bash +# After editing pyproject.toml, sync your environment +uv sync +``` + +> ⚠️ **Important:** Editing `pyproject.toml` alone does **not** update `uv.lock` or your venv. You must run `uv sync` afterward. + +**Safety nets:** +- `make install` checks if `uv.lock` is in sync with `pyproject.toml` and fails with a helpful message if not +- A pre-commit hook runs `uv lock` to ensure the lock file is updated before committing +- CI will fail if you forget to update the lock file + +### Removing Dependencies + +```bash +uv remove requests +``` + +### Command Reference + +| Goal | Command | +|------|---------| +| Add a runtime dependency | `uv add ` | +| Add a dev dependency | `uv add --dev ` | +| Remove a dependency | `uv remove ` | +| Sync after manual edits | `uv sync` | +| Update lock file only | `uv lock` | +| Upgrade a package | `uv lock --upgrade-package ` | +| Upgrade all packages | `uv lock --upgrade` | + +## Development Cycle + +### Starting Work + +```bash +# Ensure your environment is up to date +make install + +# Create a feature branch +git checkout -b feature/my-feature +``` + +### Making Changes + +1. **Write code** in `src/` +2. **Write tests** in `tests/` +3. **Run tests frequently:** + ```bash + make test + ``` +4. **Format before committing:** + ```bash + make fmt + ``` + +### Pre-Commit Checklist + +Before committing, run these checks: + +```bash +make fmt # Format and lint +make test # Run all tests +make deptry # Check for dependency issues +``` + +Or run all pre-commit hooks at once: + +```bash +make pre-commit +``` + +### Committing Changes + +Use [Conventional Commits](https://www.conventionalcommits.org/) format: + +```bash +git commit -m "feat: add new widget component" +git commit -m "fix: resolve null pointer in parser" +git commit -m "docs: update API reference" +git commit -m "chore: update dependencies" +``` + +Common prefixes: +- `feat:` β€” New feature +- `fix:` β€” Bug fix +- `docs:` β€” Documentation only +- `test:` β€” Adding/updating tests +- `chore:` β€” Maintenance tasks +- `refactor:` β€” Code refactoring + +### Skipping CI + +For documentation-only or trivial changes: + +```bash +git commit -m "docs: fix typo [skip ci]" +``` + +## Running Python Code + +Always use `uv run` to ensure the correct environment: + +```bash +# Run a script +uv run python scripts/my_script.py + +# Run a module +uv run python -m mymodule + +# Run tests directly +uv run pytest tests/test_specific.py -v + +# Interactive Python +uv run python +``` + +## Testing Workflows + +### Run All Tests + +```bash +make test +``` + +### Run Specific Tests + +```bash +# Single file +uv run pytest tests/test_rhiza/test_makefile.py -v + +# Single test function +uv run pytest tests/test_rhiza/test_makefile.py::test_specific_function -v + +# Tests matching a pattern +uv run pytest -k "test_pattern" -v + +# With print output +uv run pytest -v -s +``` + +### Run with Coverage + +```bash +make test # Coverage is included by default +``` + +## Releasing + +See [RELEASING.md](RELEASING.md) for the complete release workflow. + +Quick reference: + +```bash +# Bump version and release in one step (recommended) +make publish + +# Bump version (interactive) +make bump + +# Bump specific version +make bump BUMP=patch # 1.0.0 β†’ 1.0.1 +make bump BUMP=minor # 1.0.0 β†’ 1.1.0 +make bump BUMP=major # 1.0.0 β†’ 2.0.0 + +# Create and push release tag (without bump) +make release + +# Check release workflow status and latest release +make release-status +``` + +## Template Synchronization + +Keep your project in sync with upstream Rhiza templates: + +```bash +make sync +``` + +This updates shared configurations while preserving your customizations in `local.mk`. + +## Troubleshooting + +### Environment Out of Sync + +If your environment seems broken or out of date: + +```bash +# Full reinstall +rm -rf .venv +make install +``` + +### Lock File Conflicts + +If `uv.lock` has merge conflicts: + +```bash +# Accept current pyproject.toml as source of truth +git checkout --theirs uv.lock # or --ours depending on your situation +uv lock +``` + +### Dependency Check Failures + +If `make deptry` reports issues: + +```bash +# Missing dependencies β€” add them +uv add + +# Unused dependencies β€” remove them +uv remove +``` diff --git a/.rhiza/history b/.rhiza/history index b15951b..0cecbe6 100644 --- a/.rhiza/history +++ b/.rhiza/history @@ -4,16 +4,20 @@ # Template branch: main # # Files under template control: -.devcontainer/README.md .devcontainer/bootstrap.sh .devcontainer/devcontainer.json .editorconfig -.github/agents/agentic.mk +.github/actions/configure-git-auth/README.md +.github/actions/configure-git-auth/action.yml .github/agents/analyser.md .github/agents/summarise.md .github/copilot-instructions.md .github/dependabot.yml -.github/github.mk +.github/hooks/hooks.json +.github/hooks/session-end.sh +.github/hooks/session-start.sh +.github/workflows/copilot-setup-steps.yml +.github/workflows/renovate_rhiza_sync.yml .github/workflows/rhiza_benchmarks.yml .github/workflows/rhiza_book.yml .github/workflows/rhiza_ci.yml @@ -22,7 +26,6 @@ .github/workflows/rhiza_devcontainer.yml .github/workflows/rhiza_docker.yml .github/workflows/rhiza_marimo.yml -.github/workflows/rhiza_mypy.yml .github/workflows/rhiza_pre-commit.yml .github/workflows/rhiza_release.yml .github/workflows/rhiza_security.yml @@ -33,51 +36,74 @@ .rhiza/.env .rhiza/.gitignore .rhiza/.rhiza-version +.rhiza/INDEX.md +.rhiza/assets/rhiza-logo.svg +.rhiza/completions/README.md +.rhiza/completions/rhiza-completion.bash +.rhiza/completions/rhiza-completion.zsh +.rhiza/docs/ASSETS.md .rhiza/docs/CONFIG.md +.rhiza/docs/LFS.md +.rhiza/docs/PRIVATE_PACKAGES.md +.rhiza/docs/RELEASING.md .rhiza/docs/TOKEN_SETUP.md -.rhiza/make.d/01-custom-env.mk -.rhiza/make.d/10-custom-task.mk +.rhiza/docs/WORKFLOWS.md .rhiza/make.d/README.md +.rhiza/make.d/agentic.mk +.rhiza/make.d/book.mk +.rhiza/make.d/bootstrap.mk +.rhiza/make.d/custom-env.mk +.rhiza/make.d/custom-task.mk +.rhiza/make.d/docker.mk +.rhiza/make.d/docs.mk +.rhiza/make.d/github.mk +.rhiza/make.d/lfs.mk +.rhiza/make.d/marimo.mk +.rhiza/make.d/presentation.mk +.rhiza/make.d/quality.mk +.rhiza/make.d/releasing.mk +.rhiza/make.d/test.mk +.rhiza/make.d/tutorial.mk .rhiza/requirements/README.md .rhiza/requirements/docs.txt .rhiza/requirements/marimo.txt .rhiza/requirements/tests.txt .rhiza/requirements/tools.txt .rhiza/rhiza.mk -.rhiza/scripts/check_workflow_names.py -.rhiza/scripts/release.sh -.rhiza/utils/version_matrix.py +.rhiza/scripts/.gitkeep +.rhiza/template-bundles.yml +.rhiza/templates/minibook/custom.html.jinja2 +.rhiza/tests/README.md +.rhiza/tests/api/conftest.py +.rhiza/tests/api/test_github_targets.py +.rhiza/tests/api/test_makefile_api.py +.rhiza/tests/api/test_makefile_targets.py +.rhiza/tests/conftest.py +.rhiza/tests/deps/test_dependency_health.py +.rhiza/tests/integration/test_book_targets.py +.rhiza/tests/integration/test_lfs.py +.rhiza/tests/integration/test_marimushka.py +.rhiza/tests/integration/test_notebook_execution.py +.rhiza/tests/integration/test_sbom.py +.rhiza/tests/integration/test_test_mk.py +.rhiza/tests/integration/test_virtual_env_unexport.py +.rhiza/tests/structure/test_lfs_structure.py +.rhiza/tests/structure/test_project_layout.py +.rhiza/tests/structure/test_requirements.py +.rhiza/tests/structure/test_template_bundles.py +.rhiza/tests/sync/conftest.py +.rhiza/tests/sync/test_docstrings.py +.rhiza/tests/sync/test_readme_validation.py +.rhiza/tests/sync/test_rhiza_version.py +.rhiza/tests/test_utils.py +.rhiza/tests/utils/test_git_repo_fixture.py CODE_OF_CONDUCT.md CONTRIBUTING.md LICENSE Makefile -book/README.md -book/book.mk -book/marimo/README.md -book/marimo/marimo.mk book/marimo/notebooks/rhiza.py -presentation/README.md -presentation/presentation.mk pytest.ini ruff.toml -tests/test_rhiza/README.md -tests/test_rhiza/benchmarks/.gitignore -tests/test_rhiza/benchmarks/README.md -tests/test_rhiza/benchmarks/analyze_benchmarks.py -tests/test_rhiza/conftest.py -tests/test_rhiza/test_book.py -tests/test_rhiza/test_check_workflow_names.py -tests/test_rhiza/test_docstrings.py -tests/test_rhiza/test_git_repo_fixture.py -tests/test_rhiza/test_makefile.py -tests/test_rhiza/test_makefile_api.py -tests/test_rhiza/test_makefile_gh.py -tests/test_rhiza/test_marimushka_target.py -tests/test_rhiza/test_notebooks.py -tests/test_rhiza/test_readme.py -tests/test_rhiza/test_release_script.py -tests/test_rhiza/test_requirements_folder.py -tests/test_rhiza/test_rhiza_workflows.py -tests/test_rhiza/test_structure.py -tests/test_rhiza/test_version_matrix.py -tests/tests.mk +tests/benchmarks/conftest.py +tests/benchmarks/test_benchmarks.py +tests/property/test_makefile_properties.py diff --git a/.rhiza/make.d/README.md b/.rhiza/make.d/README.md index 6f80875..daf4ee7 100644 --- a/.rhiza/make.d/README.md +++ b/.rhiza/make.d/README.md @@ -1,6 +1,8 @@ # Makefile Cookbook -This directory (`.rhiza/make.d/`) is the designated place for **repository-specific build logic**. Any `.mk` file added here is automatically absorbed by the main Makefile. +This directory (`.rhiza/make.d/`) contains **template-managed build logic**. Files here are synced from the Rhiza template and should not be modified directly. + +**For project-specific customizations, use your root `Makefile`** (before the `include .rhiza/rhiza.mk` line). Use this cookbook to find copy-paste patterns for common development needs. @@ -9,37 +11,43 @@ Use this cookbook to find copy-paste patterns for common development needs. ### 1. Add a Simple Task **Goal**: Run a script with `make train-model`. -Create `.rhiza/make.d/50-model.mk`: +Add to your root `Makefile`: ```makefile ##@ Machine Learning train: ## Train the model using local data @echo "Training model..." @uv run python scripts/train.py + +# Include the Rhiza API (template-managed) +include .rhiza/rhiza.mk ``` ### 2. Inject Code into Standard Workflows (Hooks) **Goal**: Apply task after `make sync`. -Create `.rhiza/make.d/90-hooks.mk`: +Add to your root `Makefile`: ```makefile post-sync:: @echo "Applying something..." ``` -*Note: Use double-colons (`::`) for hooks to avoid conflicts.* +*Note: Use double-colons (`::`) for hooks to allow accumulation.* ### 3. Define Global Variables **Goal**: Set a default timeout for all test runs. -Create `.rhiza/make.d/01-config.mk`: +Add to your root `Makefile` (before the include line): ```makefile # Override default timeout (defaults to 60s) export TEST_TIMEOUT := 120 + +# Include the Rhiza API (template-managed) +include .rhiza/rhiza.mk ``` ### 4. Create a Private Shortcut **Goal**: Create a command that only exists on my machine (not committed). -Do not use `.rhiza/make.d/`. Instead, create a `local.mk` in the project root: +Create a `local.mk` in the project root: ```makefile deploy-dev: @./scripts/deploy-to-my-sandbox.sh @@ -48,7 +56,7 @@ deploy-dev: ### 5. Install System Dependencies **Goal**: Ensure `graphviz` is installed for Marimo notebooks using a hook. -Create `.rhiza/make.d/20-dependencies.mk`: +Add to your root `Makefile`: ```makefile pre-install:: @if ! command -v dot >/dev/null 2>&1; then \ @@ -68,13 +76,49 @@ pre-install:: ## ℹ️ Reference -### Execution Order -Files are loaded alphabetically. We use numeric prefixes to ensure dependencies resolve correctly: -- `00-19`: Configuration & Variables -- `20-79`: Custom Tasks & Rules -- `80-99`: Hooks & Lifecycle logic +### File Organization +- **`.rhiza/make.d/`**: Template-managed files (do not edit) +- **Root `Makefile`**: Project-specific customizations (variables, hooks, custom targets) +- **`local.mk`**: Developer-local shortcuts (not committed) + +### Makefile Files in `.rhiza/make.d/` + +| File | Purpose | +|------|---------| +| `agentic.mk` | AI agent integrations (copilot, claude) | +| `book.mk` | Documentation book generation | +| `bootstrap.mk` | Installation and environment setup | +| `custom-env.mk` | Example environment customizations | +| `custom-task.mk` | Example custom tasks | +| `docker.mk` | Docker build and run targets | +| `docs.mk` | Documentation generation (pdoc) | +| `github.mk` | GitHub CLI integrations | +| `lfs.mk` | Git LFS management | +| `marimo.mk` | Marimo notebook support | +| `presentation.mk` | Presentation building (Marp) | +| `quality.mk` | Code quality and formatting | +| `releasing.mk` | Release and versioning | +| `test.mk` | Testing infrastructure | + +Files prefixed with `custom-` are **examples** showing how to customize Rhiza. Don't edit them directly; instead, add your customizations to the root `Makefile`. + +### Naming Conventions + +**Targets**: Lowercase with hyphens, verb-noun format +- βœ… `install-uv`, `docker-build`, `view-prs` +- ❌ `installUv`, `docker_build` + +**Variables**: SCREAMING_SNAKE_CASE +- βœ… `INSTALL_DIR`, `UV_BIN`, `PYTHON_VERSION` +- ❌ `installDir`, `uvBin` + +**Section Headers**: Title Case with `##@` +- `##@ Bootstrap`, `##@ GitHub Helpers` + +See [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md#naming-conventions-and-organization-patterns) for comprehensive naming guidelines. ### Available Hooks +Add these to your root `Makefile` using double-colon syntax (`::`): - `pre-install` / `post-install`: Runs around `make install`. - `pre-sync` / `post-sync`: Runs around repository synchronization. - `pre-validate` / `post-validate`: Runs around validation checks. diff --git a/.github/agents/agentic.mk b/.rhiza/make.d/agentic.mk similarity index 63% rename from .github/agents/agentic.mk rename to .rhiza/make.d/agentic.mk index d5fc0c7..f8374b8 100644 --- a/.github/agents/agentic.mk +++ b/.rhiza/make.d/agentic.mk @@ -2,14 +2,20 @@ # This file is included by the main Makefile # Declare phony targets -.PHONY: install-copilot analyse-repo summarise-changes +.PHONY: install-copilot install-claude analyse-repo summarise-changes COPILOT_BIN ?= $(shell command -v copilot 2>/dev/null || echo "$(INSTALL_DIR)/copilot") +CLAUDE_BIN ?= $(shell command -v claude 2>/dev/null || echo "$(HOME)/.local/bin/claude") +DEFAULT_AI_MODEL ?= gpt-4.1 + ##@ Agentic Workflows copilot: install-copilot ## open interactive prompt for copilot @"$(COPILOT_BIN)" --model "$(DEFAULT_AI_MODEL)" +claude: install-claude ## open interactive prompt for claude code + @"$(CLAUDE_BIN)" + analyse-repo: install-copilot ## run the analyser agent to update REPOSITORY_ANALYSIS.md @"$(COPILOT_BIN)" --agent analyser \ --model "$(DEFAULT_AI_MODEL)" \ @@ -43,3 +49,23 @@ install-copilot: ## checks for copilot and prompts to install fi; \ fi +install-claude: ## checks for claude and prompts to install + @if command -v claude >/dev/null 2>&1; then \ + printf "${GREEN}[INFO] claude already installed in PATH, skipping install.${RESET}\n"; \ + else \ + printf "${YELLOW}[WARN] Claude Code CLI not found in PATH.${RESET}\n"; \ + printf "${BLUE}Do you want to install Claude Code CLI? [y/N] ${RESET}"; \ + read -r response; \ + if [ "$$response" = "y" ] || [ "$$response" = "Y" ]; then \ + printf "${BLUE}[INFO] Installing Claude Code CLI to default location (~/.local/bin/claude)...${RESET}\n"; \ + if curl -fsSL https://claude.ai/install.sh | bash; then \ + printf "${GREEN}[INFO] Claude Code CLI installed successfully.${RESET}\n"; \ + else \ + printf "${RED}[ERROR] Failed to install Claude Code CLI.${RESET}\n"; \ + exit 1; \ + fi; \ + else \ + printf "${BLUE}[INFO] Skipping installation.${RESET}\n"; \ + fi; \ + fi + diff --git a/book/book.mk b/.rhiza/make.d/book.mk similarity index 56% rename from book/book.mk rename to .rhiza/make.d/book.mk index 66bdf1b..715db7f 100644 --- a/book/book.mk +++ b/.rhiza/make.d/book.mk @@ -1,11 +1,10 @@ -## book.mk - Documentation and book-building targets +## book.mk - Book-building targets # This file is included by the main Makefile. -# It provides targets for generating API documentation (pdoc), -# exporting Marimo notebooks to HTML (marimushka), and compiling -# a companion book (minibook). +# It provides targets for exporting Marimo notebooks to HTML (marimushka) +# and compiling a companion book (minibook). # Declare phony targets (they don't produce files) -.PHONY: docs marimushka book +.PHONY: marimushka mkdocs-build book # Define a default no-op marimushka target that will be used # when book/marimo/marimo.mk doesn't exist or doesn't define marimushka @@ -18,14 +17,18 @@ marimushka:: install-uv > "${MARIMUSHKA_OUTPUT}/index.html"; \ fi +# Define a default no-op mkdocs-build target that will be used +# when .rhiza/make.d/docs.mk doesn't exist or doesn't define mkdocs-build +mkdocs-build:: install-uv + @if [ ! -f "docs/mkdocs.yml" ]; then \ + printf "${BLUE}[INFO] No mkdocs.yml found, skipping MkDocs${RESET}\n"; \ + fi + # Default output directory for Marimushka (HTML exports of notebooks) MARIMUSHKA_OUTPUT ?= _marimushka -# Logo file for pdoc (relative to project root). -# 1. Defaults to the Rhiza logo if present. -# 2. Can be overridden in Makefile or local.mk (e.g. LOGO_FILE := my-logo.png) -# 3. If set to empty string, no logo will be used. -LOGO_FILE ?= assets/rhiza-logo.svg +# Default output directory for MkDocs +MKDOCS_OUTPUT ?= _mkdocs # ---------------------------- # Book sections (declarative) @@ -37,64 +40,16 @@ BOOK_SECTIONS := \ "API|_pdoc/index.html|pdoc/index.html|_pdoc|pdoc" \ "Coverage|_tests/html-coverage/index.html|tests/html-coverage/index.html|_tests/html-coverage|tests/html-coverage" \ "Test Report|_tests/html-report/report.html|tests/html-report/report.html|_tests/html-report|tests/html-report" \ - "Notebooks|_marimushka/index.html|marimushka/index.html|_marimushka|marimushka" - - -##@ Documentation + "Notebooks|_marimushka/index.html|marimushka/index.html|_marimushka|marimushka" \ + "Official Documentation|_mkdocs/index.html|docs/index.html|_mkdocs|docs" -# The 'docs' target generates API documentation using pdoc. -# 1. Identifies Python packages within the source folder. -# 2. Detects the docformat (google, numpy, or sphinx) from ruff.toml or defaults to google. -# 3. Installs pdoc and generates HTML documentation in _pdoc. -docs:: install ## create documentation with pdoc - # Clean up previous docs - rm -rf _pdoc; - - @if [ -d "${SOURCE_FOLDER}" ]; then \ - PKGS=""; for d in "${SOURCE_FOLDER}"/*; do [ -d "$$d" ] && PKGS="$$PKGS $$(basename "$$d")"; done; \ - if [ -z "$$PKGS" ]; then \ - printf "${YELLOW}[WARN] No packages found under ${SOURCE_FOLDER}, skipping docs${RESET}\n"; \ - else \ - TEMPLATE_ARG=""; \ - if [ -d "${PDOC_TEMPLATE_DIR}" ]; then \ - TEMPLATE_ARG="-t ${PDOC_TEMPLATE_DIR}"; \ - printf "${BLUE}[INFO] Using pdoc templates from ${PDOC_TEMPLATE_DIR}${RESET}\n"; \ - fi; \ - DOCFORMAT="$(DOCFORMAT)"; \ - if [ -z "$$DOCFORMAT" ]; then \ - if [ -f "ruff.toml" ]; then \ - DOCFORMAT=$$(${UV_BIN} run python -c "import tomllib; print(tomllib.load(open('ruff.toml', 'rb')).get('lint', {}).get('pydocstyle', {}).get('convention', ''))"); \ - fi; \ - if [ -z "$$DOCFORMAT" ]; then \ - DOCFORMAT="google"; \ - fi; \ - printf "${BLUE}[INFO] Detected docformat: $$DOCFORMAT${RESET}\n"; \ - else \ - printf "${BLUE}[INFO] Using provided docformat: $$DOCFORMAT${RESET}\n"; \ - fi; \ - LOGO_ARG=""; \ - if [ -n "$(LOGO_FILE)" ]; then \ - if [ -f "$(LOGO_FILE)" ]; then \ - MIME=$$(file --mime-type -b "$(LOGO_FILE)"); \ - DATA=$$(base64 < "$(LOGO_FILE)" | tr -d '\n'); \ - LOGO_ARG="--logo data:$$MIME;base64,$$DATA"; \ - printf "${BLUE}[INFO] Embedding logo: $(LOGO_FILE)${RESET}\n"; \ - else \ - printf "${YELLOW}[WARN] Logo file $(LOGO_FILE) not found, skipping${RESET}\n"; \ - fi; \ - fi; \ - ${UV_BIN} pip install pdoc && \ - PYTHONPATH="${SOURCE_FOLDER}" ${UV_BIN} run pdoc --docformat $$DOCFORMAT --output-dir _pdoc $$TEMPLATE_ARG $$LOGO_ARG $$PKGS; \ - fi; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs${RESET}\n"; \ - fi +##@ Book # The 'book' target assembles the final documentation book. -# 1. Aggregates API docs, coverage, test reports, and notebooks into _book. +# 1. Aggregates API docs, coverage, test reports, notebooks, and MkDocs site into _book. # 2. Generates links.json to define the book structure. # 3. Uses 'minibook' to compile the final HTML site. -book:: test docs marimushka ## compile the companion book +book:: test docs marimushka mkdocs-build ## compile the companion book @printf "${BLUE}[INFO] Building combined documentation...${RESET}\n" @rm -rf _book && mkdir -p _book @@ -146,6 +101,14 @@ json.dump(badge, open('_book/tests/coverage-badge.json', 'w'))"; \ TEMPLATE_ARG="--template $(BOOK_TEMPLATE)"; \ printf "${BLUE}[INFO] Using book template $(BOOK_TEMPLATE)${RESET}\n"; \ fi; \ + if [ -n "$(LOGO_FILE)" ]; then \ + if [ -f "$(LOGO_FILE)" ]; then \ + cp "$(LOGO_FILE)" "_book/logo$$(echo $(LOGO_FILE) | sed 's/.*\./\./')"; \ + printf "${BLUE}[INFO] Copying logo: $(LOGO_FILE)${RESET}\n"; \ + else \ + printf "${YELLOW}[WARN] Logo file $(LOGO_FILE) not found, skipping${RESET}\n"; \ + fi; \ + fi; \ "$(UVX_BIN)" minibook \ --title "$(BOOK_TITLE)" \ --subtitle "$(BOOK_SUBTITLE)" \ diff --git a/.rhiza/make.d/bootstrap.mk b/.rhiza/make.d/bootstrap.mk new file mode 100644 index 0000000..374c433 --- /dev/null +++ b/.rhiza/make.d/bootstrap.mk @@ -0,0 +1,107 @@ +## .rhiza/make.d/bootstrap.mk - Bootstrap and Installation +# This file provides targets for setting up the development environment, +# installing dependencies, and cleaning project artifacts. + +# Declare phony targets (they don't produce files) +.PHONY: install-uv install clean pre-install post-install + +# Hook targets (double-colon rules allow multiple definitions) +pre-install:: ; @: +post-install:: ; @: + +##@ Bootstrap +install-uv: ## ensure uv/uvx is installed + # Ensure the ${INSTALL_DIR} folder exists + @mkdir -p ${INSTALL_DIR} + + # Install uv/uvx only if they are not already present in PATH or in the install dir + @if command -v uv >/dev/null 2>&1 && command -v uvx >/dev/null 2>&1; then \ + :; \ + elif [ -x "${INSTALL_DIR}/uv" ] && [ -x "${INSTALL_DIR}/uvx" ]; then \ + printf "${BLUE}[INFO] uv and uvx already installed in ${INSTALL_DIR}, skipping.${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Installing uv and uvx into ${INSTALL_DIR}...${RESET}\n"; \ + if ! curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="${INSTALL_DIR}" sh >/dev/null 2>&1; then \ + printf "${RED}[ERROR] Failed to install uv${RESET}\n"; \ + exit 1; \ + fi; \ + fi + +install: pre-install install-uv ## install + # Create the virtual environment only if it doesn't exist + @if [ ! -d "${VENV}" ]; then \ + ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ + else \ + printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ + fi + + # Install the dependencies from pyproject.toml (if it exists) + @if [ -f "pyproject.toml" ]; then \ + if [ -f "uv.lock" ]; then \ + if ! ${UV_BIN} lock --check >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] uv.lock is out of sync with pyproject.toml${RESET}\n"; \ + printf "${YELLOW} Run 'uv sync' to update your lock file and environment${RESET}\n"; \ + printf "${YELLOW} Or run 'uv lock' to update only the lock file${RESET}\n"; \ + exit 1; \ + fi; \ + printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ + ${UV_BIN} sync --all-extras --all-groups --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + else \ + printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ + ${UV_BIN} sync --all-extras || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ + fi + + # Install dev dependencies from .rhiza/requirements/*.txt files + @if [ -d ".rhiza/requirements" ] && ls .rhiza/requirements/*.txt >/dev/null 2>&1; then \ + for req_file in .rhiza/requirements/*.txt; do \ + if [ -f "$$req_file" ]; then \ + printf "${BLUE}[INFO] Installing requirements from $$req_file${RESET}\n"; \ + ${UV_BIN} pip install -r "$$req_file" || { printf "${RED}[ERROR] Failed to install requirements from $$req_file${RESET}\n"; exit 1; }; \ + fi; \ + done; \ + fi + + # Check if there is requirements.txt file in the tests folder (legacy support) + @if [ -f "tests/requirements.txt" ]; then \ + printf "${BLUE}[INFO] Installing requirements from tests/requirements.txt${RESET}\n"; \ + ${UV_BIN} pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; }; \ + fi + + # Install pre-commit hooks + @if [ -f ".pre-commit-config.yaml" ]; then \ + printf "${BLUE}[INFO] Installing pre-commit hooks...${RESET}\n"; \ + ${UVX_BIN} -p ${PYTHON_VERSION} pre-commit install || { printf "${YELLOW}[WARN] Failed to install pre-commit hooks${RESET}\n"; }; \ + fi + + @$(MAKE) post-install + + # Display success message with activation instructions + @printf "\n${GREEN}[SUCCESS] Installation complete!${RESET}\n\n" + @printf "${BLUE}To activate the virtual environment, run:${RESET}\n" + @printf "${YELLOW} source ${VENV}/bin/activate${RESET}\n\n" + +clean: ## Clean project artifacts and stale local branches + @printf "%bCleaning project...%b\n" "$(BLUE)" "$(RESET)" + + # Remove ignored files/directories, but keep .env files, tested with futures project + @git clean -d -X -f \ + -e '!.env' \ + -e '!.env.*' + + # Remove build & test artifacts + @rm -rf \ + dist \ + build \ + *.egg-info \ + .coverage \ + .pytest_cache \ + .benchmarks + + @printf "%bRemoving local branches with no remote counterpart...%b\n" "$(BLUE)" "$(RESET)" + + @git fetch --prune + + @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D diff --git a/.rhiza/make.d/01-custom-env.mk b/.rhiza/make.d/custom-env.mk similarity index 76% rename from .rhiza/make.d/01-custom-env.mk rename to .rhiza/make.d/custom-env.mk index 69c34c6..f5ec964 100644 --- a/.rhiza/make.d/01-custom-env.mk +++ b/.rhiza/make.d/custom-env.mk @@ -1,4 +1,4 @@ -## .rhiza/make.d/01-custom-env.mk - Custom Environment Configuration +## .rhiza/make.d/custom-env.mk - Custom Environment Configuration # This file example shows how to set variables for the project. # Custom variables for this repository diff --git a/.rhiza/make.d/10-custom-task.mk b/.rhiza/make.d/custom-task.mk similarity index 85% rename from .rhiza/make.d/10-custom-task.mk rename to .rhiza/make.d/custom-task.mk index 7c0ae03..086a8e7 100644 --- a/.rhiza/make.d/10-custom-task.mk +++ b/.rhiza/make.d/custom-task.mk @@ -1,4 +1,4 @@ -## .rhiza/make.d/10-custom-task.mk - Custom Repository Tasks +## .rhiza/make.d/custom-task.mk - Custom Repository Tasks # This file example shows how to add new targets. .PHONY: hello-rhiza diff --git a/.rhiza/make.d/docker.mk b/.rhiza/make.d/docker.mk new file mode 100644 index 0000000..655bcee --- /dev/null +++ b/.rhiza/make.d/docker.mk @@ -0,0 +1,31 @@ +## docker.mk - Docker build targets +# This file is included by the main Makefile + +# Declare phony targets (they don't produce files) +.PHONY: docker-build docker-run docker-clean + +# Docker-specific variables +DOCKER_FOLDER := docker +DOCKER_IMAGE_NAME ?= $(shell basename $(CURDIR)) + +##@ Docker +docker-build: ## build Docker image + @if [ ! -f "${DOCKER_FOLDER}/Dockerfile" ]; then \ + printf "${YELLOW}[WARN] No ${DOCKER_FOLDER}/Dockerfile found, skipping build${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Building Docker image with Python ${PYTHON_VERSION}${RESET}\n"; \ + docker buildx build \ + --file ${DOCKER_FOLDER}/Dockerfile \ + --build-arg PYTHON_VERSION=${PYTHON_VERSION} \ + --tag ${DOCKER_IMAGE_NAME}:latest \ + --load \ + .; \ + fi + +docker-run: docker-build ## run the Docker container + @printf "${BLUE}[INFO] Running Docker container ${DOCKER_IMAGE_NAME}${RESET}\n" + @docker run --rm -it ${DOCKER_IMAGE_NAME}:latest + +docker-clean: ## remove Docker image + @printf "${BLUE}[INFO] Removing Docker image ${DOCKER_IMAGE_NAME}${RESET}\n" + @docker rmi ${DOCKER_IMAGE_NAME}:latest 2>/dev/null || true diff --git a/.rhiza/make.d/docs.mk b/.rhiza/make.d/docs.mk new file mode 100644 index 0000000..ad44503 --- /dev/null +++ b/.rhiza/make.d/docs.mk @@ -0,0 +1,96 @@ +## docs.mk - Documentation generation targets +# This file is included by the main Makefile. +# It provides targets for generating API documentation using pdoc +# and building/serving MkDocs documentation sites. + +# Declare phony targets (they don't produce files) +.PHONY: docs mkdocs mkdocs-serve mkdocs-build + +# Default output directory for MkDocs (HTML site) +MKDOCS_OUTPUT ?= _mkdocs + +# MkDocs config file location +MKDOCS_CONFIG ?= docs/mkdocs.yml + +# Default pdoc template directory (can be overridden) +PDOC_TEMPLATE_DIR ?= book/pdoc-templates + +##@ Documentation + +# The 'docs' target generates API documentation using pdoc. +# 1. Identifies Python packages within the source folder. +# 2. Detects the docformat (google, numpy, or sphinx) from ruff.toml or defaults to google. +# 3. Installs pdoc and generates HTML documentation in _pdoc. +docs:: install ## create documentation with pdoc + # Clean up previous docs + rm -rf _pdoc; + + @if [ -d "${SOURCE_FOLDER}" ]; then \ + PKGS=""; for d in "${SOURCE_FOLDER}"/*; do [ -d "$$d" ] && PKGS="$$PKGS $$(basename "$$d")"; done; \ + if [ -z "$$PKGS" ]; then \ + printf "${YELLOW}[WARN] No packages found under ${SOURCE_FOLDER}, skipping docs${RESET}\n"; \ + else \ + TEMPLATE_ARG=""; \ + if [ -d "$(PDOC_TEMPLATE_DIR)" ]; then \ + TEMPLATE_ARG="-t $(PDOC_TEMPLATE_DIR)"; \ + printf "$(BLUE)[INFO] Using pdoc templates from $(PDOC_TEMPLATE_DIR)$(RESET)\n"; \ + fi; \ + DOCFORMAT="$(DOCFORMAT)"; \ + if [ -z "$$DOCFORMAT" ]; then \ + if [ -f "ruff.toml" ]; then \ + DOCFORMAT=$$(${UV_BIN} run python -c "import tomllib; print(tomllib.load(open('ruff.toml', 'rb')).get('lint', {}).get('pydocstyle', {}).get('convention', ''))"); \ + fi; \ + if [ -z "$$DOCFORMAT" ]; then \ + DOCFORMAT="google"; \ + fi; \ + printf "${BLUE}[INFO] Detected docformat: $$DOCFORMAT${RESET}\n"; \ + else \ + printf "${BLUE}[INFO] Using provided docformat: $$DOCFORMAT${RESET}\n"; \ + fi; \ + LOGO_ARG=""; \ + if [ -n "$(LOGO_FILE)" ]; then \ + if [ -f "$(LOGO_FILE)" ]; then \ + MIME=$$(file --mime-type -b "$(LOGO_FILE)"); \ + DATA=$$(base64 < "$(LOGO_FILE)" | tr -d '\n'); \ + LOGO_ARG="--logo data:$$MIME;base64,$$DATA"; \ + printf "${BLUE}[INFO] Embedding logo: $(LOGO_FILE)${RESET}\n"; \ + else \ + printf "${YELLOW}[WARN] Logo file $(LOGO_FILE) not found, skipping${RESET}\n"; \ + fi; \ + fi; \ + ${UV_BIN} pip install pdoc && \ + PYTHONPATH="${SOURCE_FOLDER}" ${UV_BIN} run pdoc --docformat $$DOCFORMAT --output-dir _pdoc $$TEMPLATE_ARG $$LOGO_ARG $$PKGS; \ + fi; \ + else \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs${RESET}\n"; \ + fi + +# The 'mkdocs-build' target builds the MkDocs documentation site. +# 1. Checks if the mkdocs.yml config file exists. +# 2. Cleans up any previous output. +# 3. Builds the static site using mkdocs with material theme. +mkdocs-build:: install-uv ## build MkDocs documentation site + @printf "${BLUE}[INFO] Building MkDocs site...${RESET}\n" + @if [ -f "$(MKDOCS_CONFIG)" ]; then \ + rm -rf "$(MKDOCS_OUTPUT)"; \ + MKDOCS_OUTPUT_ABS="$$(pwd)/$(MKDOCS_OUTPUT)"; \ + ${UVX_BIN} --with mkdocs-material --with "pymdown-extensions>=10.0" mkdocs build \ + -f "$(MKDOCS_CONFIG)" \ + -d "$$MKDOCS_OUTPUT_ABS"; \ + else \ + printf "${YELLOW}[WARN] $(MKDOCS_CONFIG) not found, skipping MkDocs build${RESET}\n"; \ + fi + +# The 'mkdocs-serve' target serves the documentation with live reload. +# Useful for local development and previewing changes. +mkdocs-serve: install-uv ## serve MkDocs site with live reload + @if [ -f "$(MKDOCS_CONFIG)" ]; then \ + ${UVX_BIN} --with mkdocs-material --with "pymdown-extensions>=10.0" mkdocs serve \ + -f "$(MKDOCS_CONFIG)"; \ + else \ + printf "${RED}[ERROR] $(MKDOCS_CONFIG) not found${RESET}\n"; \ + exit 1; \ + fi + +# Convenience alias +mkdocs: mkdocs-serve ## alias for mkdocs-serve diff --git a/.rhiza/make.d/github.mk b/.rhiza/make.d/github.mk new file mode 100644 index 0000000..346b617 --- /dev/null +++ b/.rhiza/make.d/github.mk @@ -0,0 +1,70 @@ +## github.mk - github repo maintenance and helpers +# This file is included by the main Makefile + +# ── Forge Detection ────────────────────────────────────────────────────────── +# FORGE_TYPE is set once and reused by any target that needs to know the forge. +# Priority: .github/workflows/ β†’ .gitlab-ci.yml / .gitlab/ β†’ unknown +FORGE_TYPE := $(if $(wildcard .github/workflows/),github,$(if $(or $(wildcard .gitlab-ci.yml),$(wildcard .gitlab/)),gitlab,unknown)) + +# Declare phony targets +.PHONY: gh-install require-gh view-prs view-issues failed-workflows workflow-status latest-release whoami print-logo + +# ── Internal guard ─────────────────────────────────────────────────────────── +# Require the gh CLI; hard-fail if missing so downstream targets can depend on it. +require-gh: + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${RED}[ERROR] gh cli not found. Install from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + exit 1; \ + fi + +##@ GitHub Helpers +gh-install: ## check for gh cli existence and install extensions + @if ! command -v gh >/dev/null 2>&1; then \ + printf "${YELLOW}[WARN] gh cli not found.${RESET}\n"; \ + printf "${BLUE}[INFO] Please install it from: https://github.com/cli/cli?tab=readme-ov-file#installation${RESET}\n"; \ + else \ + printf "${GREEN}[INFO] gh cli is installed.${RESET}\n"; \ + fi + +view-prs: gh-install ## list open pull requests + @printf "${BLUE}[INFO] Open Pull Requests:${RESET}\n" + @gh pr list --json number,title,author,headRefName,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "BRANCH" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (.headRefName | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +view-issues: gh-install ## list open issues + @printf "${BLUE}[INFO] Open Issues:${RESET}\n" + @gh issue list --json number,title,author,labels,updatedAt --template \ + '{{tablerow (printf "NUM" | color "bold") (printf "TITLE" | color "bold") (printf "AUTHOR" | color "bold") (printf "LABELS" | color "bold") (printf "UPDATED" | color "bold")}}{{range .}}{{tablerow (printf "#%v" .number | color "green") .title (.author.login | color "cyan") (pluck "name" .labels | join ", " | color "yellow") (timeago .updatedAt | color "white")}}{{end}}' + +failed-workflows: gh-install ## list recent failing workflow runs + @printf "${BLUE}[INFO] Recent Failing Workflow Runs:${RESET}\n" + @gh run list --limit 10 --status failure --json conclusion,name,headBranch,event,createdAt --template \ + '{{tablerow (printf "STATUS" | color "bold") (printf "NAME" | color "bold") (printf "BRANCH" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .conclusion | color "red") .name (.headBranch | color "cyan") (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}' + +whoami: gh-install ## check github auth status + @printf "${BLUE}[INFO] GitHub Authentication Status:${RESET}\n" + @gh auth status --hostname github.com --json hosts --template \ + '{{range $$host, $$accounts := .hosts}}{{range $$accounts}}{{if .active}} {{printf "βœ“" | color "green"}} Logged in to {{$$host}} account {{.login | color "bold"}} ({{.tokenSource}}){{"\n"}} Active account: {{printf "true" | color "green"}}{{"\n"}} Git operations protocol: {{.gitProtocol | color "yellow"}}{{"\n"}} Token scopes: {{.scopes | color "yellow"}}{{"\n"}}{{end}}{{end}}{{end}}' + +workflow-status: require-gh ## show recent runs for the release workflow + @printf "${BOLD}Release Workflow Status${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @RELEASE_WF=$$(gh workflow list --json name,id --jq '.[] | select(.name | test("release";"i")) | .name' 2>/dev/null | head -1); \ + if [ -n "$$RELEASE_WF" ]; then \ + printf "${BLUE}[INFO] Workflow: ${GREEN}$$RELEASE_WF${RESET}\n\n"; \ + gh run list --workflow "$$RELEASE_WF" --limit 5 \ + --json status,conclusion,headBranch,event,createdAt,displayTitle,url \ + --template '{{tablerow (printf "STATUS" | color "bold") (printf "CONCLUSION" | color "bold") (printf "TITLE" | color "bold") (printf "EVENT" | color "bold") (printf "TIME" | color "bold")}}{{range .}}{{tablerow (printf "%s" .status | color "cyan") (printf "%s" (or .conclusion "β€”") | color (or (and (eq .conclusion "success") "green") (and (eq .conclusion "failure") "red") "yellow")) .displayTitle (.event | color "yellow") (timeago .createdAt | color "white")}}{{end}}'; \ + else \ + printf "${YELLOW}[WARN] No release workflow found in this repository${RESET}\n"; \ + fi + +latest-release: require-gh ## show information about the latest GitHub release + @printf "${BOLD}Latest Release${RESET}\n" + @printf "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" + @if gh release view --json tagName --jq '.tagName' >/dev/null 2>&1; then \ + gh release view --json tagName,name,publishedAt,url,isDraft,isPrerelease,author \ + --template ' Tag: {{.tagName | color "green"}}{{"\n"}} Name: {{.name}}{{"\n"}} Author: {{.author.login}}{{"\n"}} Published: {{timeago .publishedAt}}{{"\n"}} Status: {{if .isDraft}}{{printf "Draft" | color "yellow"}}{{else if .isPrerelease}}{{printf "Pre-release" | color "yellow"}}{{else}}{{printf "Published" | color "green"}}{{end}}{{"\n"}} URL: {{.url}}{{"\n"}}'; \ + else \ + printf "${YELLOW}[WARN] No releases found in this repository${RESET}\n"; \ + fi diff --git a/.rhiza/make.d/lfs.mk b/.rhiza/make.d/lfs.mk new file mode 100644 index 0000000..6def5fc --- /dev/null +++ b/.rhiza/make.d/lfs.mk @@ -0,0 +1,76 @@ +## lfs.mk - Git LFS (Large File Storage) setup +# This file is included by the main Makefile + +# Declare phony targets +.PHONY: lfs-install lfs-pull lfs-track lfs-status + +##@ Git LFS +lfs-install: ## install git-lfs and configure it for this repository + @# ----------------------------- + @# Git LFS install (cross-platform) + @# ----------------------------- + @UNAME_S=$$(uname -s); \ + UNAME_M=$$(uname -m); \ + if [ "$$UNAME_S" = "Darwin" ]; then \ + printf "${BLUE}[INFO] macOS detected ($$UNAME_M)${RESET}\n"; \ + mkdir -p .local/bin .local/tmp; \ + GIT_LFS_VERSION=$$(curl -s https://api.github.com/repos/git-lfs/git-lfs/releases/latest | grep '"tag_name"' | sed 's/.*"v//;s/".*//'); \ + if [ -z "$$GIT_LFS_VERSION" ]; then \ + printf "${RED}[ERROR] Failed to detect git-lfs version${RESET}\n"; \ + exit 1; \ + fi; \ + printf "${BLUE}[INFO] Installing git-lfs v$$GIT_LFS_VERSION${RESET}\n"; \ + if [ "$$UNAME_M" = "arm64" ]; then \ + ARCH_SUFFIX="darwin-arm64"; \ + else \ + ARCH_SUFFIX="darwin-amd64"; \ + fi; \ + DOWNLOAD_URL="https://github.com/git-lfs/git-lfs/releases/download/v$$GIT_LFS_VERSION/git-lfs-$$ARCH_SUFFIX-v$$GIT_LFS_VERSION.zip"; \ + if ! curl -fL -o .local/tmp/git-lfs.zip "$$DOWNLOAD_URL"; then \ + printf "${RED}[ERROR] Failed to download git-lfs v$$GIT_LFS_VERSION for $$ARCH_SUFFIX${RESET}\n"; \ + exit 1; \ + fi; \ + unzip -o -q .local/tmp/git-lfs.zip -d .local/tmp; \ + LFS_BINARY=$$(find .local/tmp -maxdepth 2 -type f -name "git-lfs" -perm +111 2>/dev/null | head -n 1); \ + if [ -z "$$LFS_BINARY" ]; then \ + printf "${RED}[ERROR] Failed to extract git-lfs binary from archive${RESET}\n"; \ + exit 1; \ + fi; \ + cp "$$LFS_BINARY" .local/bin/; \ + chmod +x .local/bin/git-lfs; \ + PATH=$$PWD/.local/bin:$$PATH git-lfs install; \ + rm -rf .local/tmp; \ + elif [ "$$UNAME_S" = "Linux" ]; then \ + printf "${BLUE}[INFO] Linux detected${RESET}\n"; \ + if ! command -v git-lfs >/dev/null 2>&1; then \ + printf "${BLUE}[INFO] Installing git-lfs via apt...${RESET}\n"; \ + if [ "$$(id -u)" -ne 0 ]; then \ + printf "${YELLOW}[WARN] This requires sudo privileges. You may be prompted for your password.${RESET}\n"; \ + sudo apt-get update && sudo apt-get install -y git-lfs || { \ + printf "${RED}[ERROR] Failed to install git-lfs with sudo.${RESET}\n"; \ + exit 1; \ + }; \ + else \ + apt-get update && apt-get install -y git-lfs || { \ + printf "${RED}[ERROR] Failed to install git-lfs.${RESET}\n"; \ + exit 1; \ + }; \ + fi; \ + fi; \ + git lfs install; \ + else \ + printf "${RED}[ERROR] Unsupported OS: $$UNAME_S${RESET}\n"; \ + exit 1; \ + fi + +lfs-pull: ## download all git-lfs files for the current branch + @printf "${BLUE}[INFO] Pulling Git LFS files...${RESET}\n" + @git lfs pull + +lfs-track: ## list all file patterns tracked by git-lfs + @printf "${BLUE}[INFO] Git LFS tracked patterns:${RESET}\n" + @git lfs track + +lfs-status: ## show git-lfs file status + @printf "${BLUE}[INFO] Git LFS status:${RESET}\n" + @git lfs status diff --git a/book/marimo/marimo.mk b/.rhiza/make.d/marimo.mk similarity index 100% rename from book/marimo/marimo.mk rename to .rhiza/make.d/marimo.mk diff --git a/presentation/presentation.mk b/.rhiza/make.d/presentation.mk similarity index 100% rename from presentation/presentation.mk rename to .rhiza/make.d/presentation.mk diff --git a/.rhiza/make.d/quality.mk b/.rhiza/make.d/quality.mk new file mode 100644 index 0000000..6271309 --- /dev/null +++ b/.rhiza/make.d/quality.mk @@ -0,0 +1,24 @@ +## .rhiza/make.d/quality.mk - Quality and Formatting +# This file provides targets for code quality checks, linting, and formatting. + +# Declare phony targets (they don't produce files) +.PHONY: all deptry fmt + +##@ Quality and Formatting +all: fmt deptry test docs-coverage security typecheck rhiza-test ## run all CI targets locally + +deptry: install-uv ## Run deptry + @if [ -d ${SOURCE_FOLDER} ]; then \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${SOURCE_FOLDER}; \ + fi + + @if [ -d ${MARIMO_FOLDER} ]; then \ + if [ -d ${SOURCE_FOLDER} ]; then \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} ${SOURCE_FOLDER} --ignore DEP004; \ + else \ + $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} --ignore DEP004; \ + fi \ + fi + +fmt: install-uv ## check the pre-commit hooks and the linting + @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk new file mode 100644 index 0000000..fc6d57a --- /dev/null +++ b/.rhiza/make.d/releasing.mk @@ -0,0 +1,50 @@ +## .rhiza/make.d/releasing.mk - Releasing and Versioning +# This file provides targets for version bumping and release management. + +# Declare phony targets (they don't produce files) +.PHONY: bump release publish release-status pre-bump post-bump pre-release post-release + +# Hook targets (double-colon rules allow multiple definitions) +pre-bump:: ; @: +post-bump:: ; @: +pre-release:: ; @: +post-release:: ; @: + +# DRY_RUN support: pass DRY_RUN=1 to preview changes without applying them +_DRY_RUN_FLAG := $(if $(DRY_RUN),--dry-run,) +_VERSION=0.3.3 + +##@ Releasing and Versioning +bump: pre-bump ## bump version of the project (supports DRY_RUN=1) + @if [ -f "pyproject.toml" ]; then \ + $(MAKE) install; \ + PATH="$(abspath ${VENV})/bin:$$PATH" ${UVX_BIN} "rhiza-tools>=$(_VERSION)" bump $(_DRY_RUN_FLAG); \ + if [ -z "$(DRY_RUN)" ]; then \ + printf "${BLUE}[INFO] Checking uv.lock file...${RESET}\n"; \ + ${UV_BIN} lock; \ + fi; \ + else \ + printf "${YELLOW}[WARN] No pyproject.toml found, skipping bump${RESET}\n"; \ + fi + @$(MAKE) post-bump + +release: pre-release install-uv ## create tag and push to remote repository triggering release workflow (supports DRY_RUN=1) + ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release $(_DRY_RUN_FLAG); + @$(MAKE) post-release + +publish: pre-release install-uv ## bump version, create tag and push in one step (supports DRY_RUN=1) + ${UVX_BIN} "rhiza-tools>=$(_VERSION)" release --with-bump $(_DRY_RUN_FLAG); + @$(MAKE) post-release + +release-status: ## show release workflow status and latest release information +ifeq ($(FORGE_TYPE),github) + @{ $(MAKE) --no-print-directory workflow-status; printf "\n"; $(MAKE) --no-print-directory latest-release; } 2>&1 | $${PAGER:-less -R} +else ifeq ($(FORGE_TYPE),gitlab) + @printf "${YELLOW}[WARN] GitLab detected β€” release-status is not yet supported for GitLab repositories.${RESET}\n" + @printf "${BLUE}[INFO] Please check your pipeline status in the GitLab UI.${RESET}\n" +else + @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" +endif + + + diff --git a/tests/tests.mk b/.rhiza/make.d/test.mk similarity index 51% rename from tests/tests.mk rename to .rhiza/make.d/test.mk index f3d1908..945af34 100644 --- a/tests/tests.mk +++ b/.rhiza/make.d/test.mk @@ -4,7 +4,7 @@ # executing performance benchmarks. # Declare phony targets (they don't produce files) -.PHONY: test benchmark typecheck security mutate docs-coverage +.PHONY: test benchmark typecheck security docs-coverage hypothesis-test # Default directory for tests TESTS_FOLDER := tests @@ -23,34 +23,34 @@ COVERAGE_FAIL_UNDER ?= 90 test: install ## run all tests @rm -rf _tests; - @if [ -d ${TESTS_FOLDER} ]; then \ - mkdir -p _tests/html-coverage _tests/html-report; \ - if [ -d ${SOURCE_FOLDER} ]; then \ - ${VENV}/bin/python -m pytest ${TESTS_FOLDER} \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --cov=${SOURCE_FOLDER} \ - --cov-report=term \ - --cov-report=html:_tests/html-coverage \ - --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ - --cov-report=json:_tests/coverage.json \ - --html=_tests/html-report/report.html; \ - else \ - printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ - ${VENV}/bin/python -m pytest ${TESTS_FOLDER} \ - --ignore=${TESTS_FOLDER}/benchmarks \ - --html=_tests/html-report/report.html; \ - fi \ + if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping tests.${RESET}\n"; \ + exit 0; \ + fi; \ + mkdir -p _tests/html-coverage _tests/html-report; \ + if [ -d ${SOURCE_FOLDER} ]; then \ + ${UV_BIN} run pytest \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --cov=${SOURCE_FOLDER} \ + --cov-report=term \ + --cov-report=html:_tests/html-coverage \ + --cov-fail-under=$(COVERAGE_FAIL_UNDER) \ + --cov-report=json:_tests/coverage.json \ + --html=_tests/html-report/report.html; \ else \ - printf "${YELLOW}[WARN] Test folder ${TESTS_FOLDER} not found, skipping tests${RESET}\n"; \ + printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, running tests without coverage${RESET}\n"; \ + ${UV_BIN} run pytest \ + --ignore=${TESTS_FOLDER}/benchmarks \ + --html=_tests/html-report/report.html; \ fi -# The 'typecheck' target runs static type analysis using mypy. +# The 'typecheck' target runs static type analysis using ty. # 1. Checks if the source directory exists. -# 2. Runs mypy on the source folder using the configuration in pyproject.toml. -typecheck: install ## run mypy type checking +# 2. Runs ty on the source folder. +typecheck: install ## run ty type checking @if [ -d ${SOURCE_FOLDER} ]; then \ - printf "${BLUE}[INFO] Running mypy type checking...${RESET}\n"; \ - ${UVX_BIN} mypy ${SOURCE_FOLDER} --config-file pyproject.toml; \ + printf "${BLUE}[INFO] Running ty type checking...${RESET}\n"; \ + ${UV_BIN} run ty check ${SOURCE_FOLDER}; \ else \ printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping typecheck${RESET}\n"; \ fi @@ -62,16 +62,7 @@ security: install ## run security scans (pip-audit and bandit) @printf "${BLUE}[INFO] Running pip-audit for dependency vulnerabilities...${RESET}\n" @${UVX_BIN} pip-audit @printf "${BLUE}[INFO] Running bandit security scan...${RESET}\n" - @${UVX_BIN} bandit -r ${SOURCE_FOLDER} -ll -q - -# The 'mutate' target performs mutation testing using mutmut. -# 1. Runs mutmut to apply mutations to the source code and check if tests fail. -# 2. Displays the results of the mutation testing. -mutate: install ## run mutation testing with mutmut (slow, for CI or thorough testing) - @printf "${BLUE}[INFO] Running mutation testing with mutmut...${RESET}\n" - @printf "${YELLOW}[WARN] This may take a while...${RESET}\n" - @${UVX_BIN} mutmut run --paths-to-mutate=${SOURCE_FOLDER} - @${UVX_BIN} mutmut results + @${UVX_BIN} bandit -r ${SOURCE_FOLDER} -ll -q -c pyproject.toml # The 'benchmark' target runs performance benchmarks using pytest-benchmark. # 1. Installs benchmarking dependencies (pytest-benchmark, pygal). @@ -82,11 +73,12 @@ benchmark: install ## run performance benchmarks @if [ -d "${TESTS_FOLDER}/benchmarks" ]; then \ printf "${BLUE}[INFO] Running performance benchmarks...${RESET}\n"; \ ${UV_BIN} pip install pytest-benchmark==5.2.3 pygal==3.1.0; \ - ${VENV}/bin/python -m pytest "${TESTS_FOLDER}/benchmarks/" \ + mkdir -p _tests/benchmarks; \ + ${UV_BIN} run pytest "${TESTS_FOLDER}/benchmarks/" \ --benchmark-only \ - --benchmark-histogram=tests/test_rhiza/benchmarks/benchmarks \ - --benchmark-json=tests/test_rhiza/benchmarks/benchmarks.json; \ - ${VENV}/bin/python tests/test_rhiza/benchmarks/analyze_benchmarks.py ; \ + --benchmark-histogram=_tests/benchmarks/histogram \ + --benchmark-json=_tests/benchmarks/results.json; \ + ${UVX_BIN} "rhiza-tools>=0.2.3" analyze-benchmarks --benchmarks-json _tests/benchmarks/results.json --output-html _tests/benchmarks/report.html; \ else \ printf "${YELLOW}[WARN] Benchmarks folder not found, skipping benchmarks${RESET}\n"; \ fi @@ -97,8 +89,27 @@ benchmark: install ## run performance benchmarks docs-coverage: install ## check documentation coverage with interrogate @if [ -d "${SOURCE_FOLDER}" ]; then \ printf "${BLUE}[INFO] Checking documentation coverage in ${SOURCE_FOLDER}...${RESET}\n"; \ - ${VENV}/bin/python -m interrogate -vv ${SOURCE_FOLDER}; \ + ${UV_BIN} run interrogate -vv ${SOURCE_FOLDER}; \ else \ printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs-coverage${RESET}\n"; \ fi +# The 'hypothesis-test' target runs property-based tests using Hypothesis. +# 1. Checks if hypothesis tests exist in the tests directory. +# 2. Runs pytest with hypothesis-specific settings and statistics. +# 3. Generates detailed hypothesis examples and statistics. +hypothesis-test: install ## run property-based tests with Hypothesis + @if [ -z "$$(find ${TESTS_FOLDER} -name 'test_*.py' -o -name '*_test.py' 2>/dev/null)" ]; then \ + printf "${YELLOW}[WARN] No test files found in ${TESTS_FOLDER}, skipping hypothesis tests.${RESET}\n"; \ + exit 0; \ + fi; \ + printf "${BLUE}[INFO] Running Hypothesis property-based tests...${RESET}\n"; \ + mkdir -p _tests/hypothesis; \ + ${UV_BIN} run pytest \ + --ignore=${TESTS_FOLDER}/benchmarks \ + -v \ + --hypothesis-show-statistics \ + --hypothesis-seed=0 \ + -m "hypothesis or property" \ + --tb=short \ + --html=_tests/hypothesis/report.html \ No newline at end of file diff --git a/.rhiza/make.d/tutorial.mk b/.rhiza/make.d/tutorial.mk new file mode 100644 index 0000000..73d5d93 --- /dev/null +++ b/.rhiza/make.d/tutorial.mk @@ -0,0 +1,101 @@ +## .rhiza/make.d/tutorial.mk - Interactive Tutorial + +.PHONY: tutorial + +##@ Learning and Onboarding +tutorial: ## interactive tutorial with guided walkthrough of key features + @printf "${BLUE}" + @printf "╔════════════════════════════════════════════════════════════════════════════╗\n" + @printf "β•‘ Welcome to the Rhiza Interactive Tutorial! β•‘\n" + @printf "β•‘ β•‘\n" + @printf "β•‘ This tutorial will guide you through the key features of Rhiza. β•‘\n" + @printf "β•‘ Follow along and try the commands as we go! β•‘\n" + @printf "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n" + @printf "${RESET}\n" + @sleep 2 + @printf "${BOLD}πŸ“š Lesson 1: Understanding Rhiza${RESET}\n" + @printf "Rhiza is a living template system for Python projects.\n" + @printf "Unlike traditional templates, Rhiza keeps your project synchronized\n" + @printf "with best practices through continuous updates.\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ”§ Lesson 2: Essential Commands${RESET}\n" + @printf "Let's explore the most important make targets:\n\n" + @printf "${GREEN} make help${RESET} - Show all available commands\n" + @printf "${GREEN} make install${RESET} - Install dependencies and set up environment\n" + @printf "${GREEN} make test${RESET} - Run all tests with coverage\n" + @printf "${GREEN} make fmt${RESET} - Format and lint your code\n\n" + @printf "${YELLOW}Try running '${GREEN}make help${YELLOW}' now to see all commands.${RESET}\n" + @printf "${YELLOW}Press Enter when done...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ—οΈ Lesson 3: Project Structure${RESET}\n" + @printf "Key directories in a Rhiza project:\n\n" + @printf "${GREEN}.rhiza/${RESET} - Template-managed files (do not edit directly)\n" + @printf "${GREEN}.rhiza/make.d/${RESET} - Modular Makefile components (auto-loaded)\n" + @printf "${GREEN}Makefile${RESET} - Your project-specific customizations\n" + @printf "${GREEN}local.mk${RESET} - Developer-local overrides (not committed)\n" + @printf "${GREEN}pyproject.toml${RESET} - Python project configuration\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ”„ Lesson 4: Template Synchronization${RESET}\n" + @printf "Rhiza keeps your project up-to-date with template changes:\n\n" + @printf "${GREEN} make sync${RESET} - Sync with upstream template\n" + @printf "${GREEN} make summarise-sync${RESET} - Preview sync changes without applying\n" + @printf "${GREEN} make validate${RESET} - Validate project structure\n\n" + @printf "Configuration is in ${GREEN}.rhiza/template.yml${RESET}\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}🎨 Lesson 5: Customization${RESET}\n" + @printf "You can extend Rhiza without breaking template sync:\n\n" + @printf "1. ${BOLD}Add custom targets${RESET} in your root Makefile:\n" + @printf " ${GREEN}my-task: ## Description\n" + @printf " @echo 'Custom task'${RESET}\n\n" + @printf "2. ${BOLD}Use hooks${RESET} to inject logic into standard workflows:\n" + @printf " ${GREEN}post-install::\n" + @printf " @echo 'Additional setup'${RESET}\n\n" + @printf "3. ${BOLD}Create local overrides${RESET} in local.mk (not committed)\n\n" + @printf "${YELLOW}See docs/CUSTOMIZATION.md and docs/EXTENDING_RHIZA.md for details.${RESET}\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ§ͺ Lesson 6: Development Workflow${RESET}\n" + @printf "Typical development cycle:\n\n" + @printf "1. ${GREEN}make install${RESET} - Set up your environment (first time)\n" + @printf "2. ${GREEN}# Write code...${RESET}\n" + @printf "3. ${GREEN}make test${RESET} - Run tests to verify changes\n" + @printf "4. ${GREEN}make fmt${RESET} - Format code before committing\n" + @printf "5. ${GREEN}git commit${RESET} - Commit your changes\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ“¦ Lesson 7: Dependency Management${RESET}\n" + @printf "Rhiza uses uv for fast, reliable dependency management:\n\n" + @printf "${GREEN} uv add package-name${RESET} - Add a new dependency\n" + @printf "${GREEN} uv remove package-name${RESET} - Remove a dependency\n" + @printf "${GREEN} uv sync${RESET} - Sync dependencies with lock file\n" + @printf "${GREEN} uv run python${RESET} - Run Python in virtual environment\n" + @printf "${GREEN} uv run pytest${RESET} - Run pytest\n\n" + @printf "${YELLOW}Never call .venv/bin/python directly - always use 'uv run'!${RESET}\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸš€ Lesson 8: Releasing${RESET}\n" + @printf "Streamlined version management and releases:\n\n" + @printf "${GREEN} make publish${RESET} - Bump version, create tag, and push (all-in-one)\n" + @printf "${GREEN} make bump${RESET} - Bump version (prompts for major/minor/patch)\n" + @printf "${GREEN} make release${RESET} - Create and push release tag\n" + @printf "${GREEN} make release-status${RESET} - Show release workflow status\n\n" + @printf "Version is in ${GREEN}pyproject.toml${RESET} and tags are prefixed with 'v'\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ“š Lesson 9: Documentation${RESET}\n" + @printf "Generate and view documentation:\n\n" + @printf "${GREEN} make docs${RESET} - Generate API documentation with pdoc\n" + @printf "${GREEN} make book${RESET} - Build companion book\n" + @printf "${GREEN} make mkdocs-serve${RESET} - Serve MkDocs site with live reload\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}πŸ€– Lesson 10: AI-Powered Workflows${RESET}\n" + @printf "Rhiza includes AI-assisted development tools:\n\n" + @printf "${GREEN} make copilot${RESET} - Open GitHub Copilot CLI\n" + @printf "${GREEN} make claude${RESET} - Open Claude Code interactive prompt\n" + @printf "${GREEN} make analyse-repo${RESET} - AI analysis of repository structure\n" + @printf "${GREEN} make summarise-changes${RESET} - Summarize changes since last release\n\n" + @printf "${YELLOW}Press Enter to continue...${RESET}\n" && read -r + @printf "\n${BOLD}✨ Tutorial Complete!${RESET}\n\n" + @printf "You've learned the essentials of working with Rhiza.\n\n" + @printf "${BOLD}Next steps:${RESET}\n" + @printf " β€’ Read ${GREEN}docs/TOOLS_REFERENCE.md${RESET} for command quick reference\n" + @printf " β€’ Read ${GREEN}docs/EXTENDING_RHIZA.md${RESET} for customization patterns\n" + @printf " β€’ Read ${GREEN}docs/CUSTOMIZATION.md${RESET} for advanced topics\n" + @printf " β€’ Try ${GREEN}make help${RESET} to see all available commands\n" + @printf " β€’ Check ${GREEN}README.md${RESET} for project-specific information\n\n" + @printf "${BLUE}Happy coding with Rhiza! 🌱${RESET}\n\n" diff --git a/.rhiza/requirements/tests.txt b/.rhiza/requirements/tests.txt index fe03710..b475152 100644 --- a/.rhiza/requirements/tests.txt +++ b/.rhiza/requirements/tests.txt @@ -1,8 +1,11 @@ # Test dependencies for rhiza pytest>=8.0 +python-dotenv>=1.0 pytest-cov>=6.0 pytest-html>=4.0 pytest-mock>=3.0 +PyYAML>=6.0 +defusedxml>=0.7.0 # For property-based testing hypothesis>=6.150.0 diff --git a/.rhiza/requirements/tools.txt b/.rhiza/requirements/tools.txt index cb101c4..ed3e3ca 100644 --- a/.rhiza/requirements/tools.txt +++ b/.rhiza/requirements/tools.txt @@ -1,6 +1,7 @@ # Development tool dependencies for rhiza pre-commit==4.5.1 python-dotenv==1.2.1 + # for now needed until rhiza-tools is finished typer==0.21.1 -mypy==1.19.1 +ty==0.0.17 diff --git a/.rhiza/rhiza.mk b/.rhiza/rhiza.mk index a03538b..68dd38a 100644 --- a/.rhiza/rhiza.mk +++ b/.rhiza/rhiza.mk @@ -18,14 +18,7 @@ RESET := \033[0m # Declare phony targets (they don't produce files) .PHONY: \ - bump \ - clean \ - deptry \ - fmt \ - mypy \ help \ - install \ - install-uv \ post-bump \ post-install \ post-release \ @@ -36,10 +29,10 @@ RESET := \033[0m pre-release \ pre-sync \ pre-validate \ - release \ - sync \ + print-logo \ + readme \ summarise-sync \ - update-readme \ + sync \ validate \ version-matrix @@ -54,29 +47,21 @@ PYTHON_VERSION ?= $(shell cat .python-version 2>/dev/null || echo "3.13") export PYTHON_VERSION # Read Rhiza version from .rhiza/.rhiza-version (single source of truth for rhiza-tools) -RHIZA_VERSION ?= $(shell cat .rhiza/.rhiza-version 2>/dev/null || echo "0.9.0") +RHIZA_VERSION ?= $(shell cat .rhiza/.rhiza-version 2>/dev/null || echo "0.10.2") export RHIZA_VERSION export UV_NO_MODIFY_PATH := 1 export UV_VENV_CLEAR := 1 +# Unset VIRTUAL_ENV to prevent uv from warning about path mismatches +# when a virtual environment is already activated in the shell +unexport VIRTUAL_ENV + # Load .rhiza/.env (if present) and export its variables so recipes see them. -include .rhiza/.env -# Include split Makefiles --include tests/tests.mk --include book/book.mk --include book/marimo/marimo.mk --include presentation/presentation.mk --include docker/docker.mk --include .github/agents/agentic.mk -# .rhiza/rhiza.mk is INLINED below --include .github/github.mk - - - # ============================================================================== -# Rhiza Core Actions (formerly .rhiza/rhiza.mk) +# Rhiza Core # ============================================================================== # RHIZA_LOGO definition @@ -94,16 +79,12 @@ export RHIZA_LOGO .PHONY: print-logo sync validate readme pre-sync post-sync pre-validate post-validate # Hook targets (double-colon rules allow multiple definitions) +# Note: pre-install/post-install are defined in bootstrap.mk +# Note: pre-bump/post-bump/pre-release/post-release are defined in releasing.mk pre-sync:: ; @: post-sync:: ; @: pre-validate:: ; @: post-validate:: ; @: -pre-install:: ; @: -post-install:: ; @: -pre-release:: ; @: -post-release:: ; @: -pre-bump:: ; @: -post-bump:: ; @: ##@ Rhiza Workflows @@ -128,7 +109,14 @@ summarise-sync: install-uv ## summarise differences created by sync with templat ${UVX_BIN} "rhiza>=$(RHIZA_VERSION)" summarise .; \ fi -validate: pre-validate ## validate project structure against template repository as defined in .rhiza/template.yml +rhiza-test: install ## run rhiza's own tests (if any) + @if [ -d ".rhiza/tests" ]; then \ + ${UV_BIN} run pytest .rhiza/tests; \ + else \ + printf "${YELLOW}[WARN] No .rhiza/tests directory found, skipping rhiza-tests${RESET}\n"; \ + fi + +validate: pre-validate rhiza-test ## validate project structure against template repository as defined in .rhiza/template.yml @if git remote get-url origin 2>/dev/null | grep -iqE 'jebel-quant/rhiza(\.git)?$$'; then \ printf "${BLUE}[INFO] Skipping validate in rhiza repository (no template.yml by design)${RESET}\n"; \ else \ @@ -140,128 +128,6 @@ validate: pre-validate ## validate project structure against template repository readme: install-uv ## update README.md with current Makefile help output @${UVX_BIN} "rhiza-tools>=0.2.0" update-readme -# ============================================================================== -# End Rhiza Core Actions -# ============================================================================== - -##@ Bootstrap -install-uv: ## ensure uv/uvx is installed - # Ensure the ${INSTALL_DIR} folder exists - @mkdir -p ${INSTALL_DIR} - - # Install uv/uvx only if they are not already present in PATH or in the install dir - @if command -v uv >/dev/null 2>&1 && command -v uvx >/dev/null 2>&1; then \ - :; \ - elif [ -x "${INSTALL_DIR}/uv" ] && [ -x "${INSTALL_DIR}/uvx" ]; then \ - printf "${BLUE}[INFO] uv and uvx already installed in ${INSTALL_DIR}, skipping.${RESET}\n"; \ - else \ - printf "${BLUE}[INFO] Installing uv and uvx into ${INSTALL_DIR}...${RESET}\n"; \ - if ! curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="${INSTALL_DIR}" sh >/dev/null 2>&1; then \ - printf "${RED}[ERROR] Failed to install uv${RESET}\n"; \ - exit 1; \ - fi; \ - fi - -install: pre-install install-uv ## install - # Create the virtual environment only if it doesn't exist - @if [ ! -d "${VENV}" ]; then \ - ${UV_BIN} venv $(if $(PYTHON_VERSION),--python $(PYTHON_VERSION)) ${VENV} || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; }; \ - else \ - printf "${BLUE}[INFO] Using existing virtual environment at ${VENV}, skipping creation${RESET}\n"; \ - fi - - # Install the dependencies from pyproject.toml (if it exists) - @if [ -f "pyproject.toml" ]; then \ - if [ -f "uv.lock" ]; then \ - printf "${BLUE}[INFO] Installing dependencies from lock file${RESET}\n"; \ - ${UV_BIN} sync --all-extras --all-groups --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - else \ - printf "${YELLOW}[WARN] uv.lock not found. Generating lock file and installing dependencies...${RESET}\n"; \ - ${UV_BIN} sync --all-extras || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }; \ - fi; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n"; \ - fi - - # Install dev dependencies from .rhiza/requirements/*.txt files - @if [ -d ".rhiza/requirements" ] && ls .rhiza/requirements/*.txt >/dev/null 2>&1; then \ - for req_file in .rhiza/requirements/*.txt; do \ - if [ -f "$$req_file" ]; then \ - printf "${BLUE}[INFO] Installing requirements from $$req_file${RESET}\n"; \ - ${UV_BIN} pip install -r "$$req_file" || { printf "${RED}[ERROR] Failed to install requirements from $$req_file${RESET}\n"; exit 1; }; \ - fi; \ - done; \ - fi - - # Check if there is requirements.txt file in the tests folder (legacy support) - @if [ -f "tests/requirements.txt" ]; then \ - printf "${BLUE}[INFO] Installing requirements from tests/requirements.txt${RESET}\n"; \ - ${UV_BIN} pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; }; \ - fi - @$(MAKE) post-install - -clean: ## Clean project artifacts and stale local branches - @printf "%bCleaning project...%b\n" "$(BLUE)" "$(RESET)" - - # Remove ignored files/directories, but keep .env files, tested with futures project - @git clean -d -X -f \ - -e '!.env' \ - -e '!.env.*' - - # Remove build & test artifacts - @rm -rf \ - dist \ - build \ - *.egg-info \ - .coverage \ - .pytest_cache \ - .benchmarks - - @printf "%bRemoving local branches with no remote counterpart...%b\n" "$(BLUE)" "$(RESET)" - - @git fetch --prune - - @git branch -vv | awk '/: gone]/{print $$1}' | xargs -r git branch -D - -##@ Quality and Formatting -deptry: install-uv ## Run deptry - @if [ -d ${SOURCE_FOLDER} ]; then \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${SOURCE_FOLDER}; \ - fi - - @if [ -d ${MARIMO_FOLDER} ]; then \ - if [ -d ${SOURCE_FOLDER} ]; then \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} ${SOURCE_FOLDER} --ignore DEP004; \ - else \ - $(UVX_BIN) -p ${PYTHON_VERSION} deptry ${MARIMO_FOLDER} --ignore DEP004; \ - fi \ - fi - -fmt: install-uv ## check the pre-commit hooks and the linting - @${UVX_BIN} -p ${PYTHON_VERSION} pre-commit run --all-files - -mypy: install-uv ## run mypy analysis - @if [ -d ${SOURCE_FOLDER} ]; then \ - ${UVX_BIN} -p ${PYTHON_VERSION} mypy ${SOURCE_FOLDER} --strict --config-file=pyproject.toml; \ - fi - -##@ Releasing and Versioning -bump: pre-bump ## bump version - @if [ -f "pyproject.toml" ]; then \ - $(MAKE) install; \ - ${UVX_BIN} "rhiza[tools]>=0.8.6" tools bump; \ - printf "${BLUE}[INFO] Updating uv.lock file...${RESET}\n"; \ - ${UV_BIN} lock; \ - else \ - printf "${YELLOW}[WARN] No pyproject.toml found, skipping bump${RESET}\n"; \ - fi - @$(MAKE) post-bump - -release: pre-release install-uv ## create tag and push to remote with prompts - @UV_BIN="${UV_BIN}" /bin/sh ".rhiza/scripts/release.sh" - @$(MAKE) post-release - - ##@ Meta help: print-logo ## Display this help message @@ -272,7 +138,7 @@ help: print-logo ## Display this help message +@printf "\n" version-matrix: install-uv ## Emit the list of supported Python versions from pyproject.toml - @${UV_BIN} run .rhiza/utils/version_matrix.py + @${UVX_BIN} "rhiza-tools>=0.2.2" version-matrix print-% : ## print the value of a variable (usage: make print-VARIABLE) @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" diff --git a/.rhiza/scripts/.gitkeep b/.rhiza/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.rhiza/scripts/check_workflow_names.py b/.rhiza/scripts/check_workflow_names.py deleted file mode 100644 index e4a838d..0000000 --- a/.rhiza/scripts/check_workflow_names.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -"""Script to ensure GitHub Actions workflows have the (RHIZA) prefix.""" - -import sys - -import yaml - - -def check_file(filepath): - """Check if the workflow file has the correct name prefix and update if needed. - - Args: - filepath: Path to the workflow file. - - Returns: - bool: True if file is correct, False if it was updated or has errors. - """ - with open(filepath) as f: - try: - content = yaml.safe_load(f) - except yaml.YAMLError as exc: - print(f"Error parsing YAML {filepath}: {exc}") - return False - - if not isinstance(content, dict): - # Empty file or not a dict - return True - - name = content.get("name") - if not name: - print(f"Error: {filepath} missing 'name' field.") - return False - - if not name.startswith("(RHIZA) "): - print(f"Updating {filepath}: name '{name}' -> '(RHIZA) {name}'") - - # Read file lines to perform replacement while preserving comments - with open(filepath) as f_read: - lines = f_read.readlines() - - with open(filepath, "w") as f_write: - replaced = False - for line in lines: - # Replace only the top-level name field (assumes it starts at beginning of line) - if not replaced and line.startswith("name:"): - # Check if this line corresponds to the extracted name. - # Simple check: does it contain reasonable parts of the name? - # Or just blinding replace top-level name: - # We'll use quotes to be safe - f_write.write(f'name: "(RHIZA) {name}"\n') - replaced = True - else: - f_write.write(line) - - return False # Fail so pre-commit knows files were modified - - return True - - -def main(): - """Execute the script.""" - files = sys.argv[1:] - failed = False - for f in files: - if not check_file(f): - failed = True - - if failed: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.rhiza/scripts/release.sh b/.rhiza/scripts/release.sh deleted file mode 100755 index 6ba0f21..0000000 --- a/.rhiza/scripts/release.sh +++ /dev/null @@ -1,276 +0,0 @@ -#!/bin/sh -# Release script -# - Creates a git tag based on the current version in pyproject.toml -# - Pushes the tag to remote to trigger the release workflow -# - Performs checks (branch, upstream status, clean working tree) -# -# This script is POSIX-sh compatible and follows the style of other scripts -# in this repository. It uses uv to read the current version. - -set -eu - -UV_BIN=${UV_BIN:-./bin/uv} -DRY_RUN="" - -BLUE="\033[36m" -RED="\033[31m" -GREEN="\033[32m" -YELLOW="\033[33m" -RESET="\033[0m" - -# Parse command-line arguments -show_usage() { - printf "Usage: %s [OPTIONS]\n\n" "$0" - printf "Description:\n" - printf " Create tag and push to remote (with prompts)\n\n" - printf "Options:\n" - printf " -n, --dry-run Show what would be done without making changes\n" - printf " -h, --help Show this help message\n\n" - printf "Examples:\n" - printf " %s (create tag and push with prompts)\n" "$0" - printf " %s --dry-run (simulate release without changes)\n" "$0" -} - -while [ $# -gt 0 ]; do - case "$1" in - -n|--dry-run) - DRY_RUN="true" - shift - ;; - -h|--help) - show_usage - exit 0 - ;; - -*) - printf "%b[ERROR] Unknown option: %s%b\n" "$RED" "$1" "$RESET" - show_usage - exit 1 - ;; - *) - printf "%b[ERROR] Unknown argument: %s%b\n" "$RED" "$1" "$RESET" - show_usage - exit 1 - ;; - esac -done - -# Check if pyproject.toml exists -if [ ! -f "pyproject.toml" ]; then - printf "%b[ERROR] pyproject.toml not found in current directory%b\n" "$RED" "$RESET" - exit 1 -fi - -# Check if uv is available -if [ ! -x "$UV_BIN" ]; then - printf "%b[ERROR] uv not found at %s. Run 'make install-uv' first.%b\n" "$RED" "$UV_BIN" "$RESET" - exit 1 -fi - -# Helper function to prompt user to continue -# In dry-run mode, automatically continues without prompting -prompt_continue() { - _pc_message="$1" - if [ -n "$DRY_RUN" ]; then - printf "\n%b[DRY-RUN] %s Would prompt to continue%b\n" "$YELLOW" "$_pc_message" "$RESET" - return 0 - fi - printf "\n%b[PROMPT] %s Continue? [y/N] %b" "$YELLOW" "$_pc_message" "$RESET" - read -r _pc_answer - case "$_pc_answer" in - [Yy]*) - return 0 - ;; - *) - printf "%b[INFO] Aborted by user%b\n" "$YELLOW" "$RESET" - exit 0 - ;; - esac -} - -# Helper function to prompt user for yes/no -# In dry-run mode, automatically returns yes -prompt_yes_no() { - _pyn_message="$1" - if [ -n "$DRY_RUN" ]; then - printf "\n%b[DRY-RUN] %s Would prompt yes/no%b\n" "$YELLOW" "$_pyn_message" "$RESET" - return 0 - fi - printf "\n%b[PROMPT] %s [y/N] %b" "$YELLOW" "$_pyn_message" "$RESET" - read -r _pyn_answer - case "$_pyn_answer" in - [Yy]*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -# Function: Release - create tag and push (with prompts) -do_release() { - # Get the current version from pyproject.toml - CURRENT_VERSION=$("$UV_BIN" version --short 2>/dev/null) - if [ -z "$CURRENT_VERSION" ]; then - printf "%b[ERROR] Could not determine version from pyproject.toml%b\n" "$RED" "$RESET" - exit 1 - fi - - TAG="v$CURRENT_VERSION" - - # Get current branch - CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - if [ -z "$CURRENT_BRANCH" ]; then - printf "%b[ERROR] Could not determine current branch%b\n" "$RED" "$RESET" - exit 1 - fi - - # Determine default branch - DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5) - if [ -z "$DEFAULT_BRANCH" ]; then - printf "%b[ERROR] Could not determine default branch from remote%b\n" "$RED" "$RESET" - exit 1 - fi - - # Warn if not on default branch - if [ "$CURRENT_BRANCH" != "$DEFAULT_BRANCH" ]; then - printf "%b[WARN] You are on branch '%s' but the default branch is '%s'%b\n" "$YELLOW" "$CURRENT_BRANCH" "$DEFAULT_BRANCH" "$RESET" - printf "%b[WARN] Releases are typically created from the default branch.%b\n" "$YELLOW" "$RESET" - prompt_continue "Proceed with release from '$CURRENT_BRANCH'?" - fi - - printf "%b[INFO] Current version: %s%b\n" "$BLUE" "$CURRENT_VERSION" "$RESET" - printf "%b[INFO] Tag to create: %s%b\n" "$BLUE" "$TAG" "$RESET" - - # Check if there are uncommitted changes - if [ -n "$(git status --porcelain)" ]; then - printf "%b[ERROR] You have uncommitted changes:%b\n" "$RED" "$RESET" - git status --short - printf "\n%b[ERROR] Please commit or stash your changes before releasing.%b\n" "$RED" "$RESET" - exit 1 - fi - - # Check if branch is up-to-date with remote - # This prevents releasing from an out-of-sync branch which could miss commits or conflict - printf "%b[INFO] Checking remote status...%b\n" "$BLUE" "$RESET" - git fetch origin >/dev/null 2>&1 - # Get the upstream tracking branch (e.g., origin/main) - UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) - if [ -z "$UPSTREAM" ]; then - printf "%b[ERROR] No upstream branch configured for %s%b\n" "$RED" "$CURRENT_BRANCH" "$RESET" - exit 1 - fi - - # Compare local, remote, and merge-base commits to determine sync status - # LOCAL: current commit on local branch - # REMOTE: current commit on remote tracking branch - # BASE: most recent common ancestor between local and remote - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - # Use git revision comparison to detect branch status - if [ "$LOCAL" != "$REMOTE" ]; then - if [ "$LOCAL" = "$BASE" ]; then - # Local is behind remote (need to pull) - printf "%b[ERROR] Your branch is behind '%s'. Please pull changes.%b\n" "$RED" "$UPSTREAM" "$RESET" - exit 1 - elif [ "$REMOTE" = "$BASE" ]; then - # Local is ahead of remote (need to push) - printf "%b[WARN] Your branch is ahead of '%s'.%b\n" "$YELLOW" "$UPSTREAM" "$RESET" - printf "Unpushed commits:\n" - git log --oneline --graph --decorate "$UPSTREAM..HEAD" - prompt_continue "Push changes to remote before releasing?" - if [ -n "$DRY_RUN" ]; then - printf "%b[DRY-RUN] Would run: git push origin %s%b\n" "$YELLOW" "$CURRENT_BRANCH" "$RESET" - else - git push origin "$CURRENT_BRANCH" - fi - else - # Branches have diverged (need to merge or rebase) - printf "%b[ERROR] Your branch has diverged from '%s'. Please reconcile.%b\n" "$RED" "$UPSTREAM" "$RESET" - exit 1 - fi - fi - - # Check if tag already exists locally - SKIP_TAG_CREATE="" - if git rev-parse "$TAG" >/dev/null 2>&1; then - printf "%b[WARN] Tag '%s' already exists locally%b\n" "$YELLOW" "$TAG" "$RESET" - prompt_continue "Tag exists. Skip tag creation and proceed to push?" - SKIP_TAG_CREATE="true" - fi - - # Check if tag already exists on remote - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - printf "%b[ERROR] Tag '%s' already exists on remote%b\n" "$RED" "$TAG" "$RESET" - printf "The release for version %s has already been published.\n" "$CURRENT_VERSION" - exit 1 - fi - - # Step 1: Create the tag (if it doesn't exist) - if [ -z "$SKIP_TAG_CREATE" ]; then - printf "\n%b=== Step 1: Create Tag ===%b\n" "$BLUE" "$RESET" - printf "Creating tag '%s' for version %s\n" "$TAG" "$CURRENT_VERSION" - prompt_continue "" - - # Check if GPG signing is configured for git commits/tags - # If user.signingkey is set or commit.gpgsign is true, create a signed tag - # Signed tags provide cryptographic verification of release authenticity - if git config --get user.signingkey >/dev/null 2>&1 || [ "$(git config --get commit.gpgsign)" = "true" ]; then - printf "%b[INFO] GPG signing is enabled. Creating signed tag.%b\n" "$BLUE" "$RESET" - if [ -n "$DRY_RUN" ]; then - printf "%b[DRY-RUN] Would run: git tag -s %s -m \"Release %s\"%b\n" "$YELLOW" "$TAG" "$TAG" "$RESET" - else - git tag -s "$TAG" -m "Release $TAG" - fi - else - printf "%b[INFO] GPG signing is not enabled. Creating unsigned tag.%b\n" "$BLUE" "$RESET" - if [ -n "$DRY_RUN" ]; then - printf "%b[DRY-RUN] Would run: git tag -a %s -m \"Release %s\"%b\n" "$YELLOW" "$TAG" "$TAG" "$RESET" - else - git tag -a "$TAG" -m "Release $TAG" - fi - fi - if [ -n "$DRY_RUN" ]; then - printf "%b[DRY-RUN] Tag '%s' would be created locally%b\n" "$YELLOW" "$TAG" "$RESET" - else - printf "%b[SUCCESS] Tag '%s' created locally%b\n" "$GREEN" "$TAG" "$RESET" - fi - fi - - # Step 2: Push the tag to remote - printf "\n%b=== Step 2: Push Tag to Remote ===%b\n" "$BLUE" "$RESET" - printf "Pushing tag '%s' to origin will trigger the release workflow.\n" "$TAG" - - # Show what commits are in this tag compared to the last tag - # This helps users understand what changes are included in the release - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -n "$LAST_TAG" ] && [ "$LAST_TAG" != "$TAG" ]; then - # Count commits between last tag and current tag - COMMIT_COUNT=$(git rev-list "$LAST_TAG..$TAG" --count 2>/dev/null || echo "0") - printf "Commits since %s: %s\n" "$LAST_TAG" "$COMMIT_COUNT" - fi - - prompt_continue "" - - # Push only the specific tag (not all tags) to trigger the release workflow - # Extract repository name from remote URL for constructing GitHub Actions link - # Converts git@github.com:user/repo.git or https://github.com/user/repo.git to user/repo - REPO_URL=$(git remote get-url origin | sed 's/.*github.com[:/]\(.*\)\.git/\1/') - - if [ -n "$DRY_RUN" ]; then - printf "%b[DRY-RUN] Would run: git push origin refs/tags/%s%b\n" "$YELLOW" "$TAG" "$RESET" - printf "\n%b[DRY-RUN] Release tag %s would be pushed to remote%b\n" "$YELLOW" "$TAG" "$RESET" - printf "%b[DRY-RUN] This would trigger the release workflow%b\n" "$YELLOW" "$RESET" - printf "%b[INFO] Monitor progress at: https://github.com/%s/actions%b\n" "$BLUE" "$REPO_URL" "$RESET" - else - git push origin "refs/tags/$TAG" - printf "\n%b[SUCCESS] Release tag %s pushed to remote!%b\n" "$GREEN" "$TAG" "$RESET" - printf "%b[INFO] The release workflow will now be triggered automatically.%b\n" "$BLUE" "$RESET" - printf "%b[INFO] Monitor progress at: https://github.com/%s/actions%b\n" "$BLUE" "$REPO_URL" "$RESET" - fi -} - -# Main execution logic -do_release diff --git a/.rhiza/template-bundles.yml b/.rhiza/template-bundles.yml new file mode 100644 index 0000000..737c5ec --- /dev/null +++ b/.rhiza/template-bundles.yml @@ -0,0 +1,278 @@ +# Rhiza Template Bundle Definitions +# +# This file defines template bundles - pre-configured sets of files that can be +# included in downstream projects by selecting templates instead of listing +# individual file paths. +# +# Usage in downstream projects (.rhiza/template.yml): +# +# templates: +# - tests +# - docker +# - marimo +# +# Instead of manually listing: +# +# include: | +# .rhiza/make.d/test.mk +# pytest.ini +# docker/Dockerfile +# ... + +# Schema version for this bundles file format +version: "0.7.1" + +# Bundle Definitions +bundles: + # ============================================================================ + # CORE - Required infrastructure + # ============================================================================ + core: + description: "Core Rhiza infrastructure" + required: true + standalone: true + files: + # Core Rhiza files + - .rhiza/rhiza.mk + - .rhiza/.cfg.toml + - .rhiza/.env + - .rhiza/.gitignore + - .rhiza/.rhiza-version + - .rhiza/make.d/custom-env.mk + - .rhiza/make.d/docs.mk + - .rhiza/make.d/custom-task.mk + - .rhiza/make.d/quality.mk + - .rhiza/make.d/bootstrap.mk + - .rhiza/make.d/releasing.mk + - .rhiza/make.d/README.md + - .rhiza/scripts + - .rhiza/docs + - .rhiza/assets + - .rhiza/requirements/README.md + - .rhiza/requirements/docs.txt + - .rhiza/requirements/tools.txt + + # Root configuration files + - Makefile + - .pre-commit-config.yaml + - .editorconfig + - .gitignore + - .python-version + - ruff.toml + - renovate.json + + # Documentation files (user-facing docs) + - docs/SECURITY.md + - docs/ARCHITECTURE.md + - docs/CUSTOMIZATION.md + - docs/GLOSSARY.md + - docs/QUICK_REFERENCE.md + - docs/DEMO.md + + github: + description: "GitHub Actions workflows for CI/CD" + standalone: true + requires: [core] + files: + - .rhiza/make.d/github.mk + - .rhiza/make.d/agentic.mk + # Core GitHub Actions workflows + - .github/workflows/copilot-setup-steps.yml + - .github/workflows/rhiza_validate.yml + - .github/workflows/rhiza_sync.yml + - .github/workflows/rhiza_pre-commit.yml + - .github/workflows/rhiza_deptry.yml + - .github/workflows/rhiza_release.yml + - .github/actions/configure-git-auth + - .github/dependabot.yml + - .github/copilot-instructions.md + - .github/agents + - .github/hooks + + # ============================================================================ + # LEGAL - Legal and community files + # ============================================================================ + legal: + description: "Legal and community documentation files" + standalone: true + requires: [] + files: + # Legal files + - LICENSE + + # Community files + - CONTRIBUTING.md + - CODE_OF_CONDUCT.md + + # ============================================================================ + # DEVCONTAINER - VS Code DevContainer configuration + # ============================================================================ + devcontainer: + description: "VS Code DevContainer configuration for consistent development environments" + standalone: true + requires: [] + files: + # DevContainer configuration + - .devcontainer/devcontainer.json + - .devcontainer/bootstrap.sh + + # Documentation + - docs/DEVCONTAINER.md + + # GitHub Actions workflows + - .github/workflows/rhiza_devcontainer.yml + + # ============================================================================ + # DOCKER - Docker containerization support + # ============================================================================ + docker: + description: "Docker containerization support for building and running containers" + standalone: true + requires: [] + files: + # Docker configuration and files + - docker/Dockerfile + - docker/Dockerfile.dockerignore + + # Make targets + - .rhiza/make.d/docker.mk + + # Documentation + - docs/DOCKER.md + + # GitHub Actions workflows + - .github/workflows/rhiza_docker.yml + + # ============================================================================ + # LFS - Git Large File Storage support + # ============================================================================ + lfs: + description: "Git LFS (Large File Storage) installation and management" + standalone: true + requires: [] + files: + # Make targets for LFS + - .rhiza/make.d/lfs.mk + + # Documentation + - .rhiza/docs/LFS.md + + # ============================================================================ + # PRESENTATION - Presentation building with reveal.js + # ============================================================================ + presentation: + description: "Presentation building using reveal.js and Marimo" + standalone: true + requires: [] + recommends: + - marimo # Presentations often use Marimo for interactive slides + files: + - .rhiza/make.d/presentation.mk + - docs/PRESENTATION.md + + # ============================================================================ + # GITLAB - GitLab CI/CD pipeline configuration + # ============================================================================ + gitlab: + description: "GitLab CI/CD pipeline configuration and workflows" + standalone: true + requires: [core] + notes: | + GitLab workflows provide similar functionality to GitHub Actions. + Some workflows (like book, ci) may benefit from having their + corresponding feature templates (book, tests) also enabled. + files: + # Main GitLab CI configuration + - .gitlab-ci.yml + + # GitLab workflow files + - .gitlab/workflows/rhiza_book.yml + - .gitlab/workflows/rhiza_ci.yml + - .gitlab/workflows/rhiza_deptry.yml + - .gitlab/workflows/rhiza_pre-commit.yml + - .gitlab/workflows/rhiza_release.yml + - .gitlab/workflows/rhiza_renovate.yml + - .gitlab/workflows/rhiza_sync.yml + - .gitlab/workflows/rhiza_validate.yml + + # GitLab templates + - .gitlab/template + + # GitLab documentation + - .gitlab/COMPARISON.md + - .gitlab/README.md + - .gitlab/SUMMARY.md + - .gitlab/TESTING.md + + # ============================================================================ + # TESTS - Testing infrastructure with pytest, coverage, and type checking + # ============================================================================ + tests: + description: "Testing infrastructure with pytest, coverage, and type checking" + standalone: true + requires: [] + files: + # Make targets and configuration + - .rhiza/make.d/test.mk + - .rhiza/requirements/tests.txt + - pytest.ini + + # Core/generic test files + - .rhiza/tests + + # Benchmark and stress test infrastructure + - tests/benchmarks + - docs/TESTS.md + + # GitHub Actions workflows + - .github/workflows/rhiza_ci.yml + - .github/workflows/rhiza_security.yml + - .github/workflows/rhiza_codeql.yml + - .github/workflows/rhiza_benchmarks.yml + + # ============================================================================ + # MARIMO - Interactive Marimo notebooks + # ============================================================================ + marimo: + description: "Interactive Marimo notebooks for data exploration and documentation" + standalone: true + requires: [] + files: + # Marimo configuration + - .rhiza/make.d/marimo.mk + - .rhiza/requirements/marimo.txt + + # Marimo notebooks directory + - book/marimo + + # Documentation + - docs/MARIMO.md + + # GitHub Actions workflows + - .github/workflows/rhiza_marimo.yml + + # ============================================================================ + # BOOK - Documentation book generation + # ============================================================================ + book: + description: | + Comprehensive documentation book generation combining: + - API documentation (pdoc) + - Test coverage reports + - Test results + - Interactive notebooks (if marimo is enabled) + standalone: false + requires: + - tests # Required: book needs test coverage and reports + recommends: + - marimo # Optional: book works better with notebook exports + files: + # Book building configuration + - .rhiza/make.d/book.mk + - .rhiza/templates/minibook + + # Documentation + - docs/BOOK.md + + # GitHub Actions workflows + - .github/workflows/rhiza_book.yml diff --git a/.rhiza/templates/minibook/custom.html.jinja2 b/.rhiza/templates/minibook/custom.html.jinja2 new file mode 100644 index 0000000..c7864f6 --- /dev/null +++ b/.rhiza/templates/minibook/custom.html.jinja2 @@ -0,0 +1,210 @@ + + + + + + {{ title }} + + + + + + +
+
+ + +
+ Logo +
+ + +
+
+

{{ title }}

+ {% if description %} +
{{ description }}
+ {% endif %} +
+
+ + + + + + Home Repo + +
+
+ +
+ {% for name, url in links %} + + {% endfor %} +
+ + +
+
+ + \ No newline at end of file diff --git a/.rhiza/tests/README.md b/.rhiza/tests/README.md new file mode 100644 index 0000000..697bdad --- /dev/null +++ b/.rhiza/tests/README.md @@ -0,0 +1,126 @@ +# Rhiza Test Suite + +This directory contains the comprehensive test suite for the Rhiza project. + +## Test Organization + +Tests are organized into purpose-driven subdirectories: + +### `structure/` +Static assertions about file and directory presence. These tests verify that the repository contains the expected files, directories, and configuration structure without executing any subprocesses. + +- `test_project_layout.py` β€” Validates root-level files and directories +- `test_requirements.py` β€” Validates `.rhiza/requirements/` structure + +### `api/` +Makefile target validation via dry-runs. These tests verify that Makefile targets are properly defined and would execute the expected commands. + +- `test_makefile_targets.py` β€” Core Makefile targets (install, test, fmt, etc.) +- `test_makefile_api.py` β€” Makefile API (delegation, extension, hooks, overrides) +- `test_github_targets.py` β€” GitHub-specific Makefile targets + +### `integration/` +Tests requiring sandboxed git repositories or subprocess execution. These tests verify end-to-end workflows. + +- `test_release.py` β€” Release script functionality +- `test_book_targets.py` β€” Documentation book build targets +- `test_marimushka.py` β€” Marimushka target execution +- `test_notebook_execution.py` β€” Marimo notebook execution validation + +### `sync/` +Template sync, workflows, versioning, and content validation tests. These tests ensure that template synchronization and content validation work correctly. + +- `test_rhiza_version.py` β€” Version reading and workflow validation +- `test_readme_validation.py` β€” README code block execution and validation +- `test_docstrings.py` β€” Doctest validation across source modules + +### `utils/` +Tests for utility code and test infrastructure. These tests validate the testing framework itself and utility scripts. + +- `test_git_repo_fixture.py` β€” Validates the `git_repo` fixture + +### `deps/` +Dependency validation tests. These tests ensure that project dependencies are correctly specified and healthy. + +- `test_dependency_health.py` β€” Validates pyproject.toml and requirements files + +## Running Tests + +### Run all tests +```bash +uv run pytest .rhiza/tests/ +# or +make test +``` + +### Run tests from a specific category +```bash +uv run pytest .rhiza/tests/structure/ +uv run pytest .rhiza/tests/api/ +uv run pytest .rhiza/tests/integration/ +uv run pytest .rhiza/tests/sync/ +uv run pytest .rhiza/tests/utils/ +uv run pytest .rhiza/tests/deps/ +``` + +### Run a specific test file +```bash +uv run pytest .rhiza/tests/structure/test_project_layout.py +``` + +### Run with verbose output +```bash +uv run pytest .rhiza/tests/ -v +``` + +### Run with coverage +```bash +uv run pytest .rhiza/tests/ --cov +``` + +## Fixtures + +### Root-level fixtures (`conftest.py`) +- `root` β€” Repository root path (session-scoped) +- `logger` β€” Configured logger instance (session-scoped) +- `git_repo` β€” Sandboxed git repository (function-scoped) + +### Category-specific fixtures +- `api/conftest.py` β€” `setup_tmp_makefile`, `run_make`, `setup_rhiza_git_repo` +- `sync/conftest.py` β€” `setup_sync_env` + +## Writing Tests + +### Conventions +- Use descriptive test names that explain what is being tested +- Group related tests in classes when appropriate +- Use appropriate fixtures for setup/teardown +- Add docstrings to test modules and complex test functions +- Use `pytest.mark.skip` for tests that depend on optional features + +### Import Patterns +```python +# Import shared helpers from test_utils +from test_utils import strip_ansi, run_make, setup_rhiza_git_repo + +# Import from local category conftest (for fixtures and category-specific helpers) +from api.conftest import SPLIT_MAKEFILES, setup_tmp_makefile + +# Note: Fixtures defined in conftest.py are automatically available in tests +# and don't need to be explicitly imported +``` + +## Test Coverage + +The test suite aims for high coverage across: +- Configuration validation (structure, dependencies) +- Makefile target correctness (api) +- End-to-end workflows (integration) +- Template synchronization (sync) +- Utility code (utils) + +## Notes + +- Benchmarks are located in `tests/benchmarks/` and run via `make benchmark` +- Integration tests use sandboxed git repositories to avoid affecting the working tree +- All Makefile tests use dry-run mode (`make -n`) to avoid side effects diff --git a/.rhiza/tests/api/conftest.py b/.rhiza/tests/api/conftest.py new file mode 100644 index 0000000..9cb5aef --- /dev/null +++ b/.rhiza/tests/api/conftest.py @@ -0,0 +1,91 @@ +"""Shared fixtures for Makefile API tests. + +This conftest provides: +- setup_tmp_makefile: Copies Makefile and split files to temp dir for isolated testing +- run_make: Helper to execute make commands with dry-run support (imported from test_utils) +- setup_rhiza_git_repo: Initialize a git repo configured as rhiza origin (imported from test_utils) +- SPLIT_MAKEFILES: List of split Makefile paths +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +tests_root = Path(__file__).resolve().parents[1] +if str(tests_root) not in sys.path: + sys.path.insert(0, str(tests_root)) + +from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 + +# Split Makefile paths that are included in the main Makefile +# These are now located in .rhiza/make.d/ directory +SPLIT_MAKEFILES = [ + ".rhiza/rhiza.mk", + ".rhiza/make.d/bootstrap.mk", + ".rhiza/make.d/quality.mk", + ".rhiza/make.d/releasing.mk", + ".rhiza/make.d/test.mk", + ".rhiza/make.d/book.mk", + ".rhiza/make.d/marimo.mk", + ".rhiza/make.d/presentation.mk", + ".rhiza/make.d/github.mk", + ".rhiza/make.d/agentic.mk", + ".rhiza/make.d/docker.mk", + ".rhiza/make.d/docs.mk", +] + + +@pytest.fixture(autouse=True) +def setup_tmp_makefile(logger, root, tmp_path: Path): + """Copy the Makefile and split Makefiles into a temp directory and chdir there. + + We rely on `make -n` so that no real commands are executed. + This fixture consolidates setup for both basic Makefile tests and GitHub targets. + """ + logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) + + # Copy the main Makefile into the temporary working directory + shutil.copy(root / "Makefile", tmp_path / "Makefile") + + # Copy core Rhiza Makefiles + (tmp_path / ".rhiza").mkdir(exist_ok=True) + shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") + + # Copy .python-version file for PYTHON_VERSION variable + if (root / ".python-version").exists(): + shutil.copy(root / ".python-version", tmp_path / ".python-version") + + # Copy .rhiza/.env if it exists (needed for GitHub targets and other configuration) + if (root / ".rhiza" / ".env").exists(): + shutil.copy(root / ".rhiza" / ".env", tmp_path / ".rhiza" / ".env") + else: + # Create a minimal, deterministic .rhiza/.env for tests so they don't + # depend on the developer's local configuration which may vary. + env_content = "SCRIPTS_FOLDER=.rhiza/scripts\nCUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" + (tmp_path / ".rhiza" / ".env").write_text(env_content) + + logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") + + # Copy split Makefiles if they exist (maintaining directory structure) + for split_file in SPLIT_MAKEFILES: + source_path = root / split_file + if source_path.exists(): + dest_path = tmp_path / split_file + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(source_path, dest_path) + logger.debug("Copied %s to %s", source_path, dest_path) + + # Move into tmp directory for isolation + old_cwd = Path.cwd() + os.chdir(tmp_path) + logger.debug("Changed working directory to %s", tmp_path) + try: + yield + finally: + os.chdir(old_cwd) + logger.debug("Restored working directory to %s", old_cwd) diff --git a/.rhiza/tests/api/test_github_targets.py b/.rhiza/tests/api/test_github_targets.py new file mode 100644 index 0000000..c3b1941 --- /dev/null +++ b/.rhiza/tests/api/test_github_targets.py @@ -0,0 +1,55 @@ +"""Tests for the GitHub Makefile targets using safe dry-runs. + +These tests validate that the .github/github.mk targets are correctly exposed +and emit the expected commands without actually executing them. +""" + +from __future__ import annotations + +# Import run_make from local conftest (setup_tmp_makefile is autouse) +from api.conftest import run_make + + +def test_gh_targets_exist(logger): + """Verify that GitHub targets are listed in help.""" + result = run_make(logger, ["help"], dry_run=False) + output = result.stdout + + expected_targets = ["gh-install", "view-prs", "view-issues", "failed-workflows", "whoami"] + + for target in expected_targets: + assert target in output, f"Target {target} not found in help output" + + +def test_gh_install_dry_run(logger): + """Verify gh-install target dry-run.""" + result = run_make(logger, ["gh-install"]) + # In dry-run, we expect to see the shell commands that would be executed. + # Since the recipe uses @if, make -n might verify the syntax or show the command if not silenced. + # However, with -s (silent), make -n might not show much for @ commands unless they are echoed. + # But we mainly want to ensure it runs without error. + assert result.returncode == 0 + + +def test_view_prs_dry_run(logger): + """Verify view-prs target dry-run.""" + result = run_make(logger, ["view-prs"]) + assert result.returncode == 0 + + +def test_view_issues_dry_run(logger): + """Verify view-issues target dry-run.""" + result = run_make(logger, ["view-issues"]) + assert result.returncode == 0 + + +def test_failed_workflows_dry_run(logger): + """Verify failed-workflows target dry-run.""" + result = run_make(logger, ["failed-workflows"]) + assert result.returncode == 0 + + +def test_whoami_dry_run(logger): + """Verify whoami target dry-run.""" + result = run_make(logger, ["whoami"]) + assert result.returncode == 0 diff --git a/.rhiza/tests/api/test_makefile_api.py b/.rhiza/tests/api/test_makefile_api.py new file mode 100644 index 0000000..8096006 --- /dev/null +++ b/.rhiza/tests/api/test_makefile_api.py @@ -0,0 +1,369 @@ +"""Tests for the new Makefile API structure (Wrapper + Makefile.rhiza).""" + +import os +import shutil +import subprocess # nosec +from pathlib import Path + +import pytest + +# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit +GIT = shutil.which("git") or "/usr/bin/git" + +# Files required for the API test environment +REQUIRED_FILES = [ + "Makefile", + "pyproject.toml", + "README.md", # is needed to do uv sync, etc. +] + +# Folders to copy recursively +REQUIRED_FOLDERS = [ + ".rhiza", +] + +OPTIONAL_FOLDERS = [ + "tests", # for tests/tests.mk + "docker", # for docker/docker.mk, if referenced + "book", + "presentation", +] + + +@pytest.fixture +def setup_api_env(logger, root, tmp_path: Path): + """Set up the Makefile API test environment in a temp folder.""" + logger.debug("Setting up Makefile API test env in: %s", tmp_path) + + # Copy files + for filename in REQUIRED_FILES: + src = root / filename + if src.exists(): + shutil.copy(src, tmp_path / filename) + else: + pytest.fail(f"Required file {filename} not found in root") + + # Copy required directories + for folder in REQUIRED_FOLDERS: + src = root / folder + if src.exists(): + dest = tmp_path / folder + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + else: + pytest.fail(f"Required folder {folder} not found in root") + + # Copy optional directories + for folder in OPTIONAL_FOLDERS: + src = root / folder + if src.exists(): + dest = tmp_path / folder + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + + # Create .rhiza/make.d and ensure no local.mk exists initially + (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) + if (tmp_path / "local.mk").exists(): + (tmp_path / "local.mk").unlink() + + # Initialize git repo for rhiza tools (required for sync/validate) + subprocess.run([GIT, "init"], cwd=tmp_path, check=True, capture_output=True) # nosec + # Configure git user for commits if needed (some rhiza checks might need commits) + subprocess.run([GIT, "config", "user.email", "you@example.com"], cwd=tmp_path, check=True, capture_output=True) # nosec + subprocess.run([GIT, "config", "user.name", "Rhiza Test"], cwd=tmp_path, check=True, capture_output=True) # nosec + # Add origin remote to simulate being in the rhiza repo (triggers the skip logic in rhiza.mk) + subprocess.run( + [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza.git"], + cwd=tmp_path, + check=True, + capture_output=True, + ) # nosec + + # Move to tmp dir + old_cwd = Path.cwd() + os.chdir(tmp_path) + try: + yield tmp_path + finally: + os.chdir(old_cwd) + + +# Import run_make from local conftest +from api.conftest import run_make # noqa: E402 + + +def test_api_delegation(logger, setup_api_env): + """Test that 'make help' works and delegates to .rhiza/rhiza.mk.""" + result = run_make(logger, ["help"], dry_run=False) + assert result.returncode == 0 + # "Rhiza Workflows" is a section in .rhiza/rhiza.mk + assert "Rhiza Workflows" in result.stdout + + # Core targets from .rhiza/make.d/ should be available + assert "test" in result.stdout or "install" in result.stdout + + +def test_minimal_setup_works(logger, setup_api_env): + """Test that make works even if optional folders (tests, docker, etc.) are missing.""" + # Remove optional folders + for folder in OPTIONAL_FOLDERS: + p = setup_api_env / folder + if p.exists(): + shutil.rmtree(p) + + # Also remove files that might be copied if they were in the root? + # Just mainly folders. + + # Run make help + result = run_make(logger, ["help"], dry_run=False) + assert result.returncode == 0 + + # Check that core rhiza targets exist + assert "Rhiza Workflows" in result.stdout + assert "sync" in result.stdout + + # Note: docker-build and other targets from .rhiza/make.d/ are always present + # but they gracefully skip if their respective folders/files don't exist. + # This is by design - targets are always available but handle missing resources. + + +def test_extension_mechanism(logger, setup_api_env): + """Test that custom targets can be added in the root Makefile.""" + # Add a custom target to the root Makefile (before include line) + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + # Insert custom target before the include line + new_content = ( + """.PHONY: custom-target +custom-target: + @echo "Running custom target" + +""" + + original + ) + makefile.write_text(new_content) + + result = run_make(logger, ["custom-target"], dry_run=False) + assert result.returncode == 0 + assert "Running custom target" in result.stdout + + +def test_local_override(logger, setup_api_env): + """Test that local.mk is included and can match targets.""" + local_file = setup_api_env / "local.mk" + local_file.write_text(""" +.PHONY: local-target +local-target: + @echo "Running local target" +""") + + result = run_make(logger, ["local-target"], dry_run=False) + assert result.returncode == 0 + assert "Running local target" in result.stdout + + +def test_local_override_pre_hook(logger, setup_api_env): + """Test using local.mk to override a pre-hook.""" + local_file = setup_api_env / "local.mk" + # We override pre-sync to print a marker (using double-colon to match rhiza.mk) + local_file.write_text(""" +pre-sync:: + @echo "[[LOCAL_PRE_SYNC]]" +""") + + # Run sync in dry-run. + # Note: Makefile.rhiza defines pre-sync as empty rule (or with @:). + # Make warns if we redefine a target unless it's a double-colon rule or we are careful. + # But usually the last one loaded wins or they merge if double-colon. + # The current definition in Makefile.rhiza is `pre-sync: ; @echo ...` or similar. + # Wait, I defined it as `pre-sync: ; @:` (single colon). + # So redefining it in local.mk (which is included AFTER) might trigger a warning but should work. + + result = run_make(logger, ["sync"], dry_run=False) + # We might expect a warning about overriding commands for target `pre-sync` + # checking stdout/stderr for the marker + + assert "[[LOCAL_PRE_SYNC]]" in result.stdout + + +def test_hook_execution_order(logger, setup_api_env): + """Define hooks in root Makefile and verify execution order.""" + # Add hooks to root Makefile (before include line) + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + """pre-sync:: + @echo "STARTING_SYNC" + +post-sync:: + @echo "FINISHED_SYNC" + +""" + + original + ) + makefile.write_text(new_content) + + result = run_make(logger, ["sync"], dry_run=False) + assert result.returncode == 0 + output = result.stdout + + # Check that markers are present + assert "STARTING_SYNC" in output + assert "FINISHED_SYNC" in output + + # Check order: STARTING_SYNC comes before FINISHED_SYNC + start_index = output.find("STARTING_SYNC") + finish_index = output.find("FINISHED_SYNC") + assert start_index < finish_index + + +def test_override_core_target(logger, setup_api_env): + """Verify that the root Makefile can override a core target (with warning).""" + # Override 'fmt' which is defined in quality.mk + # Add override AFTER the include line so it takes precedence + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + original + + """ +fmt: + @echo "CUSTOM_FMT" +""" + ) + makefile.write_text(new_content) + + result = run_make(logger, ["fmt"], dry_run=False) + assert result.returncode == 0 + # It should run the custom one because it's defined after the include + assert "CUSTOM_FMT" in result.stdout + + # We expect a warning on stderr about overriding + assert "warning: overriding" in result.stderr.lower() + assert "fmt" in result.stderr.lower() + + +def test_global_variable_override(logger, setup_api_env): + """Test that global variables can be overridden in the root Makefile. + + This tests the pattern documented in CUSTOMIZATION.md: + Set variables before the include line to override defaults. + """ + # Add variable override to root Makefile (before include line) + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + """# Override default coverage threshold (defaults to 90) +COVERAGE_FAIL_UNDER := 42 +export COVERAGE_FAIL_UNDER + +""" + + original + ) + makefile.write_text(new_content) + + result = run_make(logger, ["print-COVERAGE_FAIL_UNDER"], dry_run=False) + assert result.returncode == 0 + assert "42" in result.stdout + + +def test_pre_install_hook(logger, setup_api_env): + """Test that pre-install hooks are executed before install. + + This tests the hook pattern documented in CUSTOMIZATION.md. + """ + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + """pre-install:: + @echo "[[PRE_INSTALL_HOOK]]" + +""" + + original + ) + makefile.write_text(new_content) + + # Run install in dry-run mode to avoid actual installation + result = run_make(logger, ["install"], dry_run=True) + assert result.returncode == 0 + # In dry-run mode, the echo command is printed (not executed) + assert "PRE_INSTALL_HOOK" in result.stdout + + +def test_post_install_hook(logger, setup_api_env): + """Test that post-install hooks are executed after install. + + This tests the hook pattern documented in CUSTOMIZATION.md. + """ + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + """post-install:: + @echo "[[POST_INSTALL_HOOK]]" + +""" + + original + ) + makefile.write_text(new_content) + + # Run install in dry-run mode + result = run_make(logger, ["install"], dry_run=True) + assert result.returncode == 0 + assert "POST_INSTALL_HOOK" in result.stdout + + +def test_multiple_hooks_accumulate(logger, setup_api_env): + """Test that multiple hook definitions accumulate rather than override. + + This is a key feature of double-colon rules: the root Makefile and + local.mk can both add to the same hook without conflicts. + """ + # Add hook in root Makefile + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + """pre-sync:: + @echo "[[HOOK_A]]" + +""" + + original + ) + makefile.write_text(new_content) + + # Add another hook in local.mk + (setup_api_env / "local.mk").write_text("""pre-sync:: + @echo "[[HOOK_B]]" +""") + + result = run_make(logger, ["sync"], dry_run=False) + assert result.returncode == 0 + # Both hooks should be present + assert "[[HOOK_A]]" in result.stdout + assert "[[HOOK_B]]" in result.stdout + + +def test_variable_override_before_include(logger, setup_api_env): + """Test that variables set before include take precedence. + + Variables defined in the root Makefile before the include line + should be available throughout the build. + """ + # Set a variable and use it in a target (before include) + makefile = setup_api_env / "Makefile" + original = makefile.read_text() + new_content = ( + """MY_CUSTOM_VAR := hello + +.PHONY: show-var +show-var: + @echo "MY_VAR=$(MY_CUSTOM_VAR)" + +""" + + original + ) + makefile.write_text(new_content) + + result = run_make(logger, ["show-var"], dry_run=False) + assert result.returncode == 0 + assert "MY_VAR=hello" in result.stdout diff --git a/tests/test_rhiza/test_makefile.py b/.rhiza/tests/api/test_makefile_targets.py similarity index 57% rename from tests/test_rhiza/test_makefile.py rename to .rhiza/tests/api/test_makefile_targets.py index ca8704a..302aea2 100644 --- a/tests/test_rhiza/test_makefile.py +++ b/.rhiza/tests/api/test_makefile_targets.py @@ -1,4 +1,4 @@ -"""Tests for the Makefile targets and help output using safe dry‑runs. +"""Tests for the Makefile targets and help output using safe dry-runs. This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). @@ -13,121 +13,20 @@ from __future__ import annotations import os -import re -import shutil -import subprocess -from pathlib import Path import pytest +from api.conftest import SPLIT_MAKEFILES, run_make, setup_rhiza_git_repo, strip_ansi -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -MAKE = shutil.which("make") or "/usr/bin/make" - -# Split Makefile paths that are included in the main Makefile -SPLIT_MAKEFILES = [ - ".rhiza/rhiza.mk", - "tests/tests.mk", - "book/book.mk", - "presentation/presentation.mk", -] - - -def strip_ansi(text: str) -> str: - """Strip ANSI escape sequences from text.""" - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - return ansi_escape.sub("", text) - - -@pytest.fixture(autouse=True) -def setup_tmp_makefile(logger, root, tmp_path: Path): - """Copy the Makefile and split Makefiles into a temp directory and chdir there. - - We rely on `make -n` so that no real commands are executed. - """ - logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy .python-version file for PYTHON_VERSION variable - if (root / ".python-version").exists(): - shutil.copy(root / ".python-version", tmp_path / ".python-version") - - # Create a minimal, deterministic .rhiza/.env for tests so they don't - # depend on the developer's local configuration which may vary. - env_content = "SCRIPTS_FOLDER=.rhiza/scripts\nCUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Copy split Makefiles if they exist (maintaining directory structure) - for split_file in SPLIT_MAKEFILES: - source_path = root / split_file - if source_path.exists(): - dest_path = tmp_path / split_file - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(source_path, dest_path) - logger.debug("Copied %s to %s", source_path, dest_path) - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) - - -def run_make( - logger, - args: list[str] | None = None, - check: bool = True, - dry_run: bool = True, - env: dict[str, str] | None = None, -) -> subprocess.CompletedProcess: - """Run `make` with optional arguments and return the completed process. - - Args: - logger: Logger used to emit diagnostic messages during the run - args: Additional arguments for make - check: If True, raise on non-zero return code - dry_run: If True, use -n to avoid executing commands - env: Optional environment variables to pass to the subprocess - """ - cmd = [MAKE] - if args: - cmd.extend(args) - # Use -s to reduce noise, -n to avoid executing commands - flags = "-sn" if dry_run else "-s" - cmd.insert(1, flags) - logger.info("Running command: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, env=env) - logger.debug("make exited with code %d", result.returncode) - if result.stdout: - logger.debug("make stdout (truncated to 500 chars):\n%s", result.stdout[:500]) - if result.stderr: - logger.debug("make stderr (truncated to 500 chars):\n%s", result.stderr[:500]) - if check and result.returncode != 0: - msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" - raise AssertionError(msg) - return result - - -def setup_rhiza_git_repo(): - """Initialize a git repository and set remote to rhiza.""" - git = shutil.which("git") or "/usr/bin/git" - subprocess.run([git, "init"], check=True, capture_output=True) - subprocess.run( - [git, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza"], - check=True, - capture_output=True, - ) + +def assert_uvx_command_uses_version(output: str, tmp_path, command_fragment: str): + """Assert uvx command uses .python-version when present, else fallback checks.""" + python_version_file = tmp_path / ".python-version" + if python_version_file.exists(): + python_version = python_version_file.read_text().strip() + assert f"uvx -p {python_version} {command_fragment}" in output + else: + assert "uvx -p" in output + assert command_fragment in output class TestMakefile: @@ -153,18 +52,13 @@ def test_help_target(self, logger): def test_fmt_target_dry_run(self, logger, tmp_path): """Fmt target should invoke pre-commit via uvx with Python version in dry-run output.""" - proc = run_make(logger, ["fmt"]) + # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version + env = os.environ.copy() + env.pop("PYTHON_VERSION", None) + + proc = run_make(logger, ["fmt"], env=env) out = proc.stdout - # Check for uvx command with the Python version flag - # The PYTHON_VERSION should be read from .python-version file (e.g., "3.12") - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version = python_version_file.read_text().strip() - assert f"uvx -p {python_version} pre-commit run --all-files" in out - else: - # Fallback check if .python-version doesn't exist - assert "uvx -p" in out - assert "pre-commit run --all-files" in out + assert_uvx_command_uses_version(out, tmp_path, "pre-commit run --all-files") def test_deptry_target_dry_run(self, logger, tmp_path): """Deptry target should invoke deptry via uvx with Python version in dry-run output.""" @@ -178,21 +72,18 @@ def test_deptry_target_dry_run(self, logger, tmp_path): env_content += "\nSOURCE_FOLDER=src\n" env_file.write_text(env_content) - proc = run_make(logger, ["deptry"]) + # Create clean environment without PYTHON_VERSION so Makefile reads from .python-version + env = os.environ.copy() + env.pop("PYTHON_VERSION", None) + + proc = run_make(logger, ["deptry"], env=env) + out = proc.stdout - # Check for uvx command with the Python version flag - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version = python_version_file.read_text().strip() - assert f"uvx -p {python_version} deptry src" in out - else: - # Fallback check if .python-version doesn't exist - assert "uvx -p" in out - assert "deptry src" in out - - def test_mypy_target_dry_run(self, logger, tmp_path): - """Mypy target should invoke mypy via uvx with Python version in dry-run output.""" - # Create a mock SOURCE_FOLDER directory so the mypy command runs + assert_uvx_command_uses_version(out, tmp_path, "deptry src") + + def test_typecheck_target_dry_run(self, logger, tmp_path): + """Typecheck target should invoke ty via uv run in dry-run output.""" + # Create a mock SOURCE_FOLDER directory so the typecheck command runs source_folder = tmp_path / "src" source_folder.mkdir(exist_ok=True) @@ -202,17 +93,10 @@ def test_mypy_target_dry_run(self, logger, tmp_path): env_content += "\nSOURCE_FOLDER=src\n" env_file.write_text(env_content) - proc = run_make(logger, ["mypy"]) + proc = run_make(logger, ["typecheck"]) out = proc.stdout - # Check for uvx command with the Python version flag - python_version_file = tmp_path / ".python-version" - if python_version_file.exists(): - python_version = python_version_file.read_text().strip() - assert f"uvx -p {python_version} mypy src --strict --config-file=pyproject.toml" in out - else: - # Fallback check if .python-version doesn't exist - assert "uvx -p" in out - assert "mypy src --strict --config-file=pyproject.toml" in out + # Check for uv run command + assert "uv run ty check src" in out def test_test_target_dry_run(self, logger): """Test target should invoke pytest via uv with coverage and HTML outputs in dry-run output.""" @@ -220,7 +104,8 @@ def test_test_target_dry_run(self, logger): out = proc.stdout # Expect key steps assert "mkdir -p _tests/html-coverage _tests/html-report" in out - # Check for uv command with the configured path + # Check for uv command running pytest + assert "uv run pytest" in out def test_test_target_without_source_folder(self, logger, tmp_path): """Test target should run without coverage when SOURCE_FOLDER doesn't exist.""" @@ -239,7 +124,7 @@ def test_test_target_without_source_folder(self, logger, tmp_path): # Should see warning about missing source folder assert "if [ -d nonexistent_src ]" in out # Should still run pytest but without coverage flags - assert "pytest tests" in out + assert "uv run pytest" in out assert "--html=_tests/html-report/report.html" in out def test_python_version_defaults_to_3_13_if_missing(self, logger, tmp_path): @@ -271,14 +156,13 @@ def test_script_folder_is_github_scripts(self, logger): def test_that_target_coverage_is_configurable(self, logger): """Test target should respond to COVERAGE_FAIL_UNDER variable.""" - # Default case (90%) + # Default case: ensure the flag is present proc = run_make(logger, ["test"]) - assert "--cov-fail-under=90" in proc.stdout + assert "--cov-fail-under=" in proc.stdout - # Override case (80%) - # Note: We pass the variable as an argument to make - proc_override = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=80"]) - assert "--cov-fail-under=80" in proc_override.stdout + # Override case: ensure the flag takes the specific value + proc_override = run_make(logger, ["test", "COVERAGE_FAIL_UNDER=42"]) + assert "--cov-fail-under=42" in proc_override.stdout class TestMakefileRootFixture: @@ -290,12 +174,6 @@ def test_makefile_exists_at_root(self, root): assert makefile.exists() assert makefile.is_file() - def test_makefile_is_readable(self, root): - """Makefile should be readable.""" - makefile = root / "Makefile" - content = makefile.read_text() - assert len(content) > 0 - def test_makefile_contains_targets(self, root): """Makefile should contain expected targets (including split files).""" makefile = root / "Makefile" @@ -311,19 +189,6 @@ def test_makefile_contains_targets(self, root): for target in expected_targets: assert f"{target}:" in content or f".PHONY: {target}" in content - def test_makefile_has_uv_variables(self, root): - """Makefile should define UV-related variables.""" - makefile = root / "Makefile" - content = makefile.read_text() - - # Read split Makefiles as well - for split_file in SPLIT_MAKEFILES: - split_path = root / split_file - if split_path.exists(): - content += "\n" + split_path.read_text() - - assert "UV_BIN" in content or "uv" in content.lower() - def test_validate_target_skips_in_rhiza_repo(self, logger): """Validate target should skip execution in rhiza repository.""" setup_rhiza_git_repo() @@ -366,8 +231,8 @@ def mock_bin(self, tmp_path): args = sys.argv[1:] print(f"[MOCK] uvx {' '.join(args)}") -# Check if this is the bump command: "rhiza[tools]>=0.8.6" tools bump -if "tools" in args and "bump" in args: +# Check if this is the bump command: "rhiza-tools>=0.3.3" bump +if "bump" in args: # Simulate bumping version in pyproject.toml pyproject = Path("pyproject.toml") if pyproject.exists(): @@ -396,7 +261,7 @@ def test_bump_execution(self, logger, mock_bin, tmp_path): result = run_make(logger, ["bump", f"UV_BIN={uv_bin}", f"UVX_BIN={uvx_bin}"], dry_run=False) # Verify that the mock tools were called - assert "[MOCK] uvx rhiza[tools]>=0.8.6 tools bump" in result.stdout + assert "[MOCK] uvx rhiza-tools>=0.3.3 bump" in result.stdout assert "[MOCK] uv lock" in result.stdout # Verify that 'make install' was called (which calls uv sync) diff --git a/tests/test_rhiza/conftest.py b/.rhiza/tests/conftest.py similarity index 70% rename from tests/test_rhiza/conftest.py rename to .rhiza/tests/conftest.py index a7fad3a..b2a1c02 100644 --- a/tests/test_rhiza/conftest.py +++ b/.rhiza/tests/conftest.py @@ -9,62 +9,17 @@ import logging import os import pathlib -import re import shutil -import subprocess +import subprocess # nosec B404 +import sys import pytest -# Get absolute paths for executables to avoid S607 warnings -GIT = shutil.which("git") or "/usr/bin/git" -MAKE = shutil.which("make") or "/usr/bin/make" - - -def strip_ansi(text: str) -> str: - """Strip ANSI escape sequences from text.""" - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - return ansi_escape.sub("", text) - - -def run_make( - logger, args: list[str] | None = None, check: bool = True, dry_run: bool = True -) -> subprocess.CompletedProcess: - """Run `make` with optional arguments and return the completed process. - - Args: - logger: Logger used to emit diagnostic messages during the run - args: Additional arguments for make - check: If True, raise on non-zero return code - dry_run: If True, use -n to avoid executing commands - """ - cmd = [MAKE] - if args: - cmd.extend(args) - # Use -s to reduce noise, -n to avoid executing commands - flags = "-sn" if dry_run else "-s" - cmd.insert(1, flags) - logger.info("Running command: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True) - logger.debug("make exited with code %d", result.returncode) - if result.stdout: - logger.debug("make stdout (truncated to 500 chars):\n%s", result.stdout[:500]) - if result.stderr: - logger.debug("make stderr (truncated to 500 chars):\n%s", result.stderr[:500]) - if check and result.returncode != 0: - msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" - raise AssertionError(msg) - return result - - -def setup_rhiza_git_repo(): - """Initialize a git repository and set remote to rhiza.""" - subprocess.run([GIT, "init"], check=True, capture_output=True) - subprocess.run( - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza"], - check=True, - capture_output=True, - ) +tests_root = pathlib.Path(__file__).resolve().parent +if str(tests_root) not in sys.path: + sys.path.insert(0, str(tests_root)) +from test_utils import GIT # noqa: E402 MOCK_MAKE_SCRIPT = """#!/usr/bin/env python3 import sys @@ -109,7 +64,6 @@ def bump_version(current, bump_type): def main(): args = sys.argv[1:] - # Expected invocations from release.sh start with 'version' if not args: sys.exit(1) @@ -192,18 +146,18 @@ def git_repo(root, tmp_path, monkeypatch): # 1. Create bare remote remote_dir.mkdir() - subprocess.run([GIT, "init", "--bare", str(remote_dir)], check=True) + subprocess.run([GIT, "init", "--bare", str(remote_dir)], check=True) # nosec B603 # Ensure the remote's default HEAD points to master for predictable behavior - subprocess.run([GIT, "symbolic-ref", "HEAD", "refs/heads/master"], cwd=remote_dir, check=True) + subprocess.run([GIT, "symbolic-ref", "HEAD", "refs/heads/master"], cwd=remote_dir, check=True) # nosec B603 # 2. Clone to local - subprocess.run([GIT, "clone", str(remote_dir), str(local_dir)], check=True) + subprocess.run([GIT, "clone", str(remote_dir), str(local_dir)], check=True) # nosec B603 # Use monkeypatch to safely change cwd for the duration of the test monkeypatch.chdir(local_dir) # Ensure local default branch is 'master' to match test expectations - subprocess.run([GIT, "checkout", "-b", "master"], check=True) + subprocess.run([GIT, "checkout", "-b", "master"], check=True) # nosec B603 # Create pyproject.toml with open("pyproject.toml", "w") as f: @@ -230,26 +184,27 @@ def git_repo(root, tmp_path, monkeypatch): # Ensure our bin comes first on PATH so 'uv' resolves to mock monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}") - # Copy scripts and core Rhiza Makefiles - script_dir = local_dir / ".rhiza" / "scripts" - script_dir.mkdir(parents=True) - - shutil.copy(root / ".rhiza" / "scripts" / "release.sh", script_dir / "release.sh") + # Copy core Rhiza Makefiles + (local_dir / ".rhiza").mkdir(parents=True, exist_ok=True) shutil.copy(root / ".rhiza" / "rhiza.mk", local_dir / ".rhiza" / "rhiza.mk") shutil.copy(root / "Makefile", local_dir / "Makefile") + # Copy .rhiza/make.d/ directory (contains split makefiles) + make_d_src = root / ".rhiza" / "make.d" + if make_d_src.is_dir(): + make_d_dst = local_dir / ".rhiza" / "make.d" + shutil.copytree(make_d_src, make_d_dst, dirs_exist_ok=True) + book_src = root / "book" book_dst = local_dir / "book" if book_src.is_dir(): shutil.copytree(book_src, book_dst, dirs_exist_ok=True) - (script_dir / "release.sh").chmod(0o755) - # Commit and push initial state - subprocess.run([GIT, "config", "user.email", "test@example.com"], check=True) - subprocess.run([GIT, "config", "user.name", "Test User"], check=True) - subprocess.run([GIT, "add", "."], check=True) - subprocess.run([GIT, "commit", "-m", "Initial commit"], check=True) - subprocess.run([GIT, "push", "origin", "master"], check=True) + subprocess.run([GIT, "config", "user.email", "test@example.com"], check=True) # nosec B603 + subprocess.run([GIT, "config", "user.name", "Test User"], check=True) # nosec B603 + subprocess.run([GIT, "add", "."], check=True) # nosec B603 + subprocess.run([GIT, "commit", "-m", "Initial commit"], check=True) # nosec B603 + subprocess.run([GIT, "push", "origin", "master"], check=True) # nosec B603 return local_dir diff --git a/.rhiza/tests/deps/test_dependency_health.py b/.rhiza/tests/deps/test_dependency_health.py new file mode 100644 index 0000000..e3de07d --- /dev/null +++ b/.rhiza/tests/deps/test_dependency_health.py @@ -0,0 +1,111 @@ +"""Dependency health tests β€” validate requirements files and pyproject.toml content.""" + +import re +import tomllib + + +def test_pyproject_has_requires_python(root): + """Verify that pyproject.toml declares requires-python in [project].""" + pyproject_path = root / "pyproject.toml" + assert pyproject_path.exists(), "pyproject.toml not found" + + with pyproject_path.open("rb") as f: + pyproject = tomllib.load(f) + + assert "project" in pyproject, "[project] section missing from pyproject.toml" + assert "requires-python" in pyproject["project"], "requires-python missing from [project] section" + + requires_python = pyproject["project"]["requires-python"] + assert isinstance(requires_python, str), "requires-python must be a string" + assert requires_python.strip(), "requires-python cannot be empty" + + +def test_requirements_files_are_valid_pip_specifiers(root): + """Verify that all lines in requirements files are valid pip requirement specifiers.""" + requirements_dir = root / ".rhiza" / "requirements" + assert requirements_dir.exists(), ".rhiza/requirements directory not found" + + # Pattern for valid requirement specifier (simplified check) + # Matches: package, package>=1.0, package[extra], git+https://... + valid_specifier_pattern = re.compile( + r"^([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?|git\+https?://)", + re.IGNORECASE, + ) + + for req_file in requirements_dir.glob("*.txt"): + if req_file.name == "README.md": + continue + + with req_file.open() as f: + for line_num, line in enumerate(f, start=1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + + # Basic validation: line should start with a valid package name or git URL + assert valid_specifier_pattern.match(line), ( + f"{req_file.name}:{line_num} - Invalid requirement specifier: {line}" + ) + + +def test_no_duplicate_packages_across_requirements(root): + """Verify that no package appears in multiple requirements files.""" + requirements_dir = root / ".rhiza" / "requirements" + assert requirements_dir.exists(), ".rhiza/requirements directory not found" + + # Known packages that intentionally appear in multiple files + # python-dotenv is used by both test infrastructure and development tools + allowed_duplicates = {"python-dotenv"} + + # Map of package name (lowercase) to list of files it appears in + package_locations = {} + + # Pattern to extract package name from requirement line + # Matches the package name before any version specifier, extra, or URL fragment + package_name_pattern = re.compile(r"^([a-zA-Z0-9][a-zA-Z0-9._-]*)", re.IGNORECASE) + + for req_file in requirements_dir.glob("*.txt"): + if req_file.name == "README.md": + continue + + with req_file.open() as f: + for line in f: + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + + # Extract package name + match = package_name_pattern.match(line) + if match: + package_name = match.group(1).lower() + + if package_name not in package_locations: + package_locations[package_name] = [] + + package_locations[package_name].append(req_file.name) + + # Find duplicates (excluding allowed ones) + duplicates = { + pkg: files for pkg, files in package_locations.items() if len(files) > 1 and pkg not in allowed_duplicates + } + + if duplicates: + duplicate_list = [f"{pkg} ({', '.join(files)})" for pkg, files in duplicates.items()] + msg = f"Packages found in multiple requirements files: {', '.join(duplicate_list)}" + raise AssertionError(msg) + + +def test_dotenv_in_test_requirements(root): + """Verify that python-dotenv is listed in tests.txt (test suite depends on it).""" + tests_req_path = root / ".rhiza" / "requirements" / "tests.txt" + assert tests_req_path.exists(), "tests.txt not found" + + with tests_req_path.open() as f: + content = f.read().lower() + + # Check for python-dotenv (case-insensitive) + assert "python-dotenv" in content, "python-dotenv not found in tests.txt (required by test suite)" diff --git a/.rhiza/tests/integration/test_book_targets.py b/.rhiza/tests/integration/test_book_targets.py new file mode 100644 index 0000000..be875c0 --- /dev/null +++ b/.rhiza/tests/integration/test_book_targets.py @@ -0,0 +1,150 @@ +"""Tests for book-related Makefile targets and their resilience.""" + +import shutil +import subprocess # nosec + +import pytest + +MAKE = shutil.which("make") or "/usr/bin/make" + + +@pytest.fixture +def book_makefile(git_repo): + """Return the book.mk path or skip tests if missing.""" + makefile = git_repo / ".rhiza" / "make.d" / "book.mk" + if not makefile.exists(): + pytest.skip("book.mk not found, skipping test") + return makefile + + +def test_no_book_folder(git_repo, book_makefile): + """Test that make targets work gracefully when book folder is missing. + + Now that book-related targets are defined in .rhiza/make.d/, they are always + available but check internally for the existence of the book folder. + Using dry-run (-n) to test the target logic without actually executing. + """ + if (git_repo / "book").exists(): + shutil.rmtree(git_repo / "book") + assert not (git_repo / "book").exists() + + # Targets are now always defined via .rhiza/make.d/ + # Use dry-run to verify they exist and can be parsed + for target in ["book", "docs", "marimushka"]: + result = subprocess.run([MAKE, "-n", target], cwd=git_repo, capture_output=True, text=True) # nosec + # Target should exist (not "no rule to make target") + assert "no rule to make target" not in result.stderr.lower(), ( + f"Target {target} should be defined in .rhiza/make.d/" + ) + + +def test_book_folder_but_no_mk(git_repo, book_makefile): + """Test behavior when book folder exists but is empty. + + With the new architecture, targets are always defined in .rhiza/make.d/book.mk, + so they should exist regardless of the book folder contents. + """ + # ensure book folder exists but is empty + if (git_repo / "book").exists(): + shutil.rmtree(git_repo / "book") + # create an empty book folder + (git_repo / "book").mkdir() + + # assert the book folder exists + assert (git_repo / "book").exists() + # assert the git_repo / "book" folder is empty + assert not list((git_repo / "book").iterdir()) + + # Targets are now always defined via .rhiza/make.d/ + # Use dry-run to verify they exist and can be parsed + for target in ["book", "docs", "marimushka"]: + result = subprocess.run([MAKE, "-n", target], cwd=git_repo, capture_output=True, text=True) # nosec + # Target should exist (not "no rule to make target") + assert "no rule to make target" not in result.stderr.lower(), ( + f"Target {target} should be defined in .rhiza/make.d/" + ) + + +def test_book_folder(git_repo, book_makefile): + """Test that .rhiza/make.d/book.mk defines the expected phony targets.""" + content = book_makefile.read_text() + + # get the list of phony targets from the Makefile + phony_targets = [line.strip() for line in content.splitlines() if line.startswith(".PHONY:")] + if not phony_targets: + pytest.skip("No .PHONY targets found in book.mk") + + # Collect all targets from all .PHONY lines + all_targets = set() + for phony_line in phony_targets: + targets = phony_line.split(":")[1].strip().split() + all_targets.update(targets) + + expected_targets = {"book", "marimushka", "mkdocs-build"} + assert expected_targets.issubset(all_targets), ( + f"Expected phony targets to include {expected_targets}, got {all_targets}" + ) + + +def test_book_without_logo_file(git_repo, book_makefile): + """Test that book target works when LOGO_FILE is not set or empty. + + The build should succeed gracefully without a logo, and the generated + HTML template should hide the logo element via onerror handler. + """ + makefile = git_repo / "Makefile" + if not makefile.exists(): + pytest.skip("Makefile not found") + + # Read current Makefile content + content = makefile.read_text() + + # Remove or comment out LOGO_FILE if present + lines = content.splitlines() + new_lines = [] + for line in lines: + if line.strip().startswith("LOGO_FILE"): + # Comment out the line + new_lines.append(f"# {line}") + else: + new_lines.append(line) + makefile.write_text("\n".join(new_lines)) + + # Dry-run the book target - it should still be valid + result = subprocess.run([MAKE, "-n", "book"], cwd=git_repo, capture_output=True, text=True) # nosec + assert "no rule to make target" not in result.stderr.lower(), "book target should work without LOGO_FILE" + # Should not have errors about missing logo variable + assert result.returncode == 0, f"Dry-run failed: {result.stderr}" + + +def test_book_with_missing_logo_file(git_repo, book_makefile): + """Test that book target warns when LOGO_FILE points to non-existent file. + + The build should succeed but emit a warning about the missing logo. + """ + makefile = git_repo / "Makefile" + if not makefile.exists(): + pytest.skip("Makefile not found") + + # Read current Makefile content and set LOGO_FILE to non-existent path + content = makefile.read_text() + lines = content.splitlines() + new_lines = [] + logo_set = False + for line in lines: + if line.strip().startswith("LOGO_FILE"): + new_lines.append("LOGO_FILE=nonexistent/path/logo.svg") + logo_set = True + else: + new_lines.append(line) + if not logo_set: + # Insert LOGO_FILE before the include line + for i, line in enumerate(new_lines): + if line.strip().startswith("include"): + new_lines.insert(i, "LOGO_FILE=nonexistent/path/logo.svg") + break + makefile.write_text("\n".join(new_lines)) + + # Dry-run should still succeed + result = subprocess.run([MAKE, "-n", "book"], cwd=git_repo, capture_output=True, text=True) # nosec + assert result.returncode == 0, f"Dry-run failed with missing logo: {result.stderr}" diff --git a/.rhiza/tests/integration/test_lfs.py b/.rhiza/tests/integration/test_lfs.py new file mode 100644 index 0000000..b722a6b --- /dev/null +++ b/.rhiza/tests/integration/test_lfs.py @@ -0,0 +1,182 @@ +"""Tests for Git LFS Makefile targets. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +Tests the lfs-install, lfs-pull, lfs-track, and lfs-status targets. +""" + +import shutil +import subprocess # nosec + +import pytest + +# Get make command once at module level +MAKE = shutil.which("make") or "/usr/bin/make" + + +@pytest.fixture +def lfs_makefile(git_repo): + """Return the lfs.mk path or skip tests if missing.""" + makefile = git_repo / ".rhiza" / "make.d" / "lfs.mk" + if not makefile.exists(): + pytest.skip("lfs.mk not found, skipping test") + return makefile + + +@pytest.fixture +def lfs_install_dry_run(git_repo, lfs_makefile): + """Run lfs-install in dry-run mode and return the result.""" + return subprocess.run( # nosec + [MAKE, "-n", "lfs-install"], + cwd=git_repo, + capture_output=True, + text=True, + ) + + +def test_lfs_targets_exist(git_repo, logger, lfs_makefile): + """Test that all LFS targets are defined in the Makefile.""" + result = subprocess.run( + [MAKE, "help"], + cwd=git_repo, + capture_output=True, + text=True, + ) # nosec + + assert result.returncode == 0 + assert "lfs-install" in result.stdout + assert "lfs-pull" in result.stdout + assert "lfs-track" in result.stdout + assert "lfs-status" in result.stdout + assert "Git LFS" in result.stdout + + +def test_lfs_install_dry_run(lfs_install_dry_run): + """Test lfs-install target in dry-run mode.""" + assert lfs_install_dry_run.returncode == 0 + # Check that the command includes OS detection + assert "uname -s" in lfs_install_dry_run.stdout + assert "uname -m" in lfs_install_dry_run.stdout + + +def test_lfs_install_macos_logic(lfs_install_dry_run): + """Test that lfs-install generates correct logic for macOS.""" + assert lfs_install_dry_run.returncode == 0 + # Verify macOS installation logic is present + assert "Darwin" in lfs_install_dry_run.stdout + assert "darwin-arm64" in lfs_install_dry_run.stdout + assert "darwin-amd64" in lfs_install_dry_run.stdout + assert ".local/bin" in lfs_install_dry_run.stdout + assert "curl" in lfs_install_dry_run.stdout + assert "github.com/git-lfs/git-lfs/releases" in lfs_install_dry_run.stdout + + +def test_lfs_install_linux_logic(lfs_install_dry_run): + """Test that lfs-install generates correct logic for Linux.""" + assert lfs_install_dry_run.returncode == 0 + # Verify Linux installation logic is present + assert "Linux" in lfs_install_dry_run.stdout + assert "apt-get update" in lfs_install_dry_run.stdout + assert "apt-get install" in lfs_install_dry_run.stdout + assert "git-lfs" in lfs_install_dry_run.stdout + + +def test_lfs_pull_target(git_repo, logger, lfs_makefile): + """Test lfs-pull target in dry-run mode.""" + result = subprocess.run( + [MAKE, "-n", "lfs-pull"], + cwd=git_repo, + capture_output=True, + text=True, + ) # nosec + + assert result.returncode == 0 + assert "git lfs pull" in result.stdout + + +def test_lfs_track_target(git_repo, logger, lfs_makefile): + """Test lfs-track target in dry-run mode.""" + result = subprocess.run( + [MAKE, "-n", "lfs-track"], + cwd=git_repo, + capture_output=True, + text=True, + ) # nosec + + assert result.returncode == 0 + assert "git lfs track" in result.stdout + + +def test_lfs_status_target(git_repo, logger, lfs_makefile): + """Test lfs-status target in dry-run mode.""" + result = subprocess.run( + [MAKE, "-n", "lfs-status"], + cwd=git_repo, + capture_output=True, + text=True, + ) # nosec + + assert result.returncode == 0 + assert "git lfs status" in result.stdout + + +def test_lfs_install_error_handling(lfs_install_dry_run): + """Test that lfs-install includes error handling.""" + assert lfs_install_dry_run.returncode == 0 + # Verify error handling is present + assert "ERROR" in lfs_install_dry_run.stdout + assert "exit 1" in lfs_install_dry_run.stdout + + +def test_lfs_install_uses_github_api(lfs_install_dry_run): + """Test that lfs-install uses GitHub API for version detection.""" + assert lfs_install_dry_run.returncode == 0 + + +def test_lfs_install_sudo_handling(lfs_install_dry_run): + """Test that lfs-install handles sudo correctly on Linux.""" + assert lfs_install_dry_run.returncode == 0 + # Verify sudo logic is present + assert "sudo" in lfs_install_dry_run.stdout + assert "id -u" in lfs_install_dry_run.stdout + + +@pytest.mark.skipif( + not shutil.which("git-lfs"), + reason="git-lfs not installed", +) +def test_lfs_actual_execution_status(git_repo, logger, lfs_makefile): + """Test actual execution of lfs-status (requires git-lfs to be installed).""" + # Initialize git-lfs in the test repo + subprocess.run(["git", "lfs", "install"], cwd=git_repo, capture_output=True) # nosec + + result = subprocess.run( + [MAKE, "lfs-status"], + cwd=git_repo, + capture_output=True, + text=True, + ) # nosec + + # Should succeed even if no LFS files are tracked + assert result.returncode == 0 + + +@pytest.mark.skipif( + not shutil.which("git-lfs"), + reason="git-lfs not installed", +) +def test_lfs_actual_execution_track(git_repo, logger, lfs_makefile): + """Test actual execution of lfs-track (requires git-lfs to be installed).""" + # Initialize git-lfs in the test repo + subprocess.run(["git", "lfs", "install"], cwd=git_repo, capture_output=True) # nosec + + result = subprocess.run( + [MAKE, "lfs-track"], + cwd=git_repo, + capture_output=True, + text=True, + ) # nosec + + # Should succeed even if no patterns are tracked + assert result.returncode == 0 diff --git a/tests/test_rhiza/test_marimushka_target.py b/.rhiza/tests/integration/test_marimushka.py similarity index 96% rename from tests/test_rhiza/test_marimushka_target.py rename to .rhiza/tests/integration/test_marimushka.py index 074dde4..6abdaa9 100644 --- a/tests/test_rhiza/test_marimushka_target.py +++ b/.rhiza/tests/integration/test_marimushka.py @@ -8,7 +8,7 @@ import os import shutil -import subprocess +import subprocess # nosec import pytest @@ -59,7 +59,7 @@ def test_marimushka_target_success(git_repo): # Override UVX_BIN to use our mock marimushka CLI env["UVX_BIN"] = str(git_repo / "bin" / "marimushka") - result = subprocess.run([MAKE, "marimushka"], env=env, cwd=git_repo, capture_output=True, text=True) + result = subprocess.run([MAKE, "marimushka"], env=env, cwd=git_repo, capture_output=True, text=True) # nosec assert result.returncode == 0 assert "Exporting notebooks" in result.stdout @@ -87,7 +87,7 @@ def test_marimushka_no_python_files(git_repo): env["MARIMO_FOLDER"] = "book/marimo/notebooks" env["MARIMUSHKA_OUTPUT"] = "_marimushka" - result = subprocess.run([MAKE, "marimushka"], env=env, cwd=git_repo, capture_output=True, text=True) + result = subprocess.run([MAKE, "marimushka"], env=env, cwd=git_repo, capture_output=True, text=True) # nosec assert result.returncode == 0 assert (output_folder / "index.html").exists() diff --git a/tests/test_rhiza/test_notebooks.py b/.rhiza/tests/integration/test_notebook_execution.py similarity index 91% rename from tests/test_rhiza/test_notebooks.py rename to .rhiza/tests/integration/test_notebook_execution.py index f21d176..12036f9 100644 --- a/tests/test_rhiza/test_notebooks.py +++ b/.rhiza/tests/integration/test_notebook_execution.py @@ -1,7 +1,7 @@ """Tests for Marimo notebooks.""" import shutil -import subprocess +import subprocess # nosec from pathlib import Path import pytest @@ -36,6 +36,12 @@ def collect_marimo_notebooks(env_path: Path = RHIZA_ENV_PATH): NOTEBOOK_PATHS = collect_marimo_notebooks() +def test_notebooks_discovered(): + """At least one notebook should be discovered for parametrized tests to run.""" + if not NOTEBOOK_PATHS: + pytest.skip("No Marimo notebooks found β€” check MARIMO_FOLDER in .rhiza/.env") + + @pytest.mark.parametrize("notebook_path", NOTEBOOK_PATHS, ids=lambda p: p.name) def test_notebook_execution(notebook_path: Path): """Test if a Marimo notebook can be executed without errors. @@ -66,7 +72,7 @@ def test_notebook_execution(notebook_path: Path): "/dev/null", # We don't need the actual HTML output ] - result = subprocess.run(cmd, capture_output=True, text=True, cwd=notebook_path.parent) + result = subprocess.run(cmd, capture_output=True, text=True, cwd=notebook_path.parent) # nosec # Ensure process exit code indicates success assert result.returncode == 0, ( diff --git a/.rhiza/tests/integration/test_sbom.py b/.rhiza/tests/integration/test_sbom.py new file mode 100644 index 0000000..46b2a6f --- /dev/null +++ b/.rhiza/tests/integration/test_sbom.py @@ -0,0 +1,159 @@ +"""Integration test for SBOM generation using cyclonedx-bom. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +Tests the SBOM (Software Bill of Materials) generation workflow to ensure +the cyclonedx-bom tool works correctly with uvx. +""" + +import subprocess # nosec B404 + + +def test_sbom_generation_json(git_repo, logger): + """Test that SBOM generation works in JSON format.""" + # Run the SBOM generation command for JSON + result = subprocess.run( # nosec B603 + [ + "uvx", + "--from", + "cyclonedx-bom>=7.0.0", + "cyclonedx-py", + "environment", + "--of", + "JSON", + "-o", + "sbom.cdx.json", + ], + cwd=git_repo, + capture_output=True, + text=True, + check=False, + ) + + logger.info("SBOM JSON stdout: %s", result.stdout) + logger.info("SBOM JSON stderr: %s", result.stderr) + + # Verify command succeeded + assert result.returncode == 0, f"SBOM JSON generation failed: {result.stderr}" + + # Verify output file exists + sbom_file = git_repo / "sbom.cdx.json" + assert sbom_file.exists(), "SBOM JSON file was not created" + assert sbom_file.stat().st_size > 0, "SBOM JSON file is empty" + + # Verify it's valid JSON + import json + + with open(sbom_file) as f: + sbom_data = json.load(f) + + # Basic CycloneDX structure validation + assert "bomFormat" in sbom_data, "SBOM missing bomFormat field" + assert sbom_data["bomFormat"] == "CycloneDX", "SBOM has incorrect bomFormat" + assert "components" in sbom_data, "SBOM missing components field" + + +def test_sbom_generation_xml(git_repo, logger): + """Test that SBOM generation works in XML format.""" + # Run the SBOM generation command for XML + result = subprocess.run( # nosec B603 + [ + "uvx", + "--from", + "cyclonedx-bom>=7.0.0", + "cyclonedx-py", + "environment", + "--of", + "XML", + "-o", + "sbom.cdx.xml", + ], + cwd=git_repo, + capture_output=True, + text=True, + check=False, + ) + + logger.info("SBOM XML stdout: %s", result.stdout) + logger.info("SBOM XML stderr: %s", result.stderr) + + # Verify command succeeded + assert result.returncode == 0, f"SBOM XML generation failed: {result.stderr}" + + # Verify output file exists + sbom_file = git_repo / "sbom.cdx.xml" + assert sbom_file.exists(), "SBOM XML file was not created" + assert sbom_file.stat().st_size > 0, "SBOM XML file is empty" + + # Verify it's valid XML with CycloneDX structure + import defusedxml.ElementTree + + tree = defusedxml.ElementTree.parse(sbom_file) + root = tree.getroot() + + # Check for CycloneDX namespace + assert "cyclonedx" in root.tag.lower(), "SBOM XML root is not CycloneDX" + # Check for components element + components = root.find(".//{*}components") + assert components is not None, "SBOM XML missing components element" + + +def test_sbom_command_syntax(git_repo, logger): + """Test that the uvx command syntax is correct (no npm-style @^version).""" + # This test verifies that we're using the correct syntax + # Bad: uvx cyclonedx-bom@^7.0.0 + # Good: uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py + + # Try the old (incorrect) syntax - should fail + result_bad = subprocess.run( # nosec B603 + [ + "uvx", + "cyclonedx-bom@^7.0.0", + "environment", + "--of", + "JSON", + "-o", + "sbom.test.json", + ], + cwd=git_repo, + capture_output=True, + text=True, + check=False, + ) + + logger.info("Bad syntax stdout: %s", result_bad.stdout) + logger.info("Bad syntax stderr: %s", result_bad.stderr) + + # Old syntax should fail + assert result_bad.returncode != 0, "Old npm-style syntax should not work" + + # Try the new (correct) syntax - should succeed + result_good = subprocess.run( # nosec B603 + [ + "uvx", + "--from", + "cyclonedx-bom>=7.0.0", + "cyclonedx-py", + "environment", + "--of", + "JSON", + "-o", + "sbom.test.json", + ], + cwd=git_repo, + capture_output=True, + text=True, + check=False, + ) + + logger.info("Good syntax stdout: %s", result_good.stdout) + logger.info("Good syntax stderr: %s", result_good.stderr) + + # New syntax should succeed + assert result_good.returncode == 0, f"Correct syntax failed: {result_good.stderr}" + + # Cleanup + test_file = git_repo / "sbom.test.json" + if test_file.exists(): + test_file.unlink() diff --git a/.rhiza/tests/integration/test_test_mk.py b/.rhiza/tests/integration/test_test_mk.py new file mode 100644 index 0000000..83102bd --- /dev/null +++ b/.rhiza/tests/integration/test_test_mk.py @@ -0,0 +1,53 @@ +"""Integration test for .rhiza/make.d/test.mk to verify that it handles the case of missing test files correctly.""" + +from test_utils import run_make + + +def test_missing_tests_warning(git_repo, logger): + """Test that missing tests trigger a warning but do not fail (exit 0).""" + # 1. Setup a minimal Makefile in the test repo + # We include .rhiza/make.d/test.mk but mock the 'install' dependency + # and provide color variables used in the script. + makefile_content = r""" +YELLOW := \033[33m +RED := \033[31m +RESET := \033[0m + +# Define folders expected by test.mk +TESTS_FOLDER := tests +SOURCE_FOLDER := src +VENV := .venv + +# Mock install to avoid actual installation in test +install: + @echo "Mock install" + +# Include the target under test +include .rhiza/make.d/test.mk +""" + (git_repo / "Makefile").write_text(makefile_content, encoding="utf-8") + + # 2. Ensure 'tests' folder exists but is empty/has no python test files + tests_dir = git_repo / "tests" + if tests_dir.exists(): + import shutil + + shutil.rmtree(tests_dir) + tests_dir.mkdir() + + # 3. Run 'make test' + # We use dry_run=False so the shell commands in the recipe actually execute. + # The 'check=False' allows us to assert the return code ourselves, + # though we expect 0 now. + result = run_make(logger, ["test"], check=False, dry_run=False) + + # 4. output for debugging + logger.info("make stdout: %s", result.stdout) + logger.info("make stderr: %s", result.stderr) + + # 5. Verify results + assert result.returncode == 0, "make test should exit with 0 when no tests found" + + # The warning message matches what we put in test.mk + # "No test files found in {TESTS_FOLDER}, skipping tests" + assert "No test files found in tests, skipping tests" in result.stdout diff --git a/.rhiza/tests/integration/test_virtual_env_unexport.py b/.rhiza/tests/integration/test_virtual_env_unexport.py new file mode 100644 index 0000000..fae30bb --- /dev/null +++ b/.rhiza/tests/integration/test_virtual_env_unexport.py @@ -0,0 +1,37 @@ +"""Integration test to verify VIRTUAL_ENV is unset for uv commands.""" + +import os + +from test_utils import run_make + + +def test_virtual_env_not_exported(git_repo, logger): + """Test that VIRTUAL_ENV is not exported to child processes when set in the environment.""" + # 1. Setup a minimal Makefile that includes rhiza.mk + makefile_content = r""" +# Include rhiza.mk which has 'unexport VIRTUAL_ENV' +include .rhiza/rhiza.mk + +# Create a test target that checks if VIRTUAL_ENV is exported +.PHONY: test-env +test-env: + @echo "VIRTUAL_ENV in shell: '$$VIRTUAL_ENV'" +""" + (git_repo / "Makefile").write_text(makefile_content, encoding="utf-8") + + # 2. Set VIRTUAL_ENV in the environment (simulating an activated venv) + env = os.environ.copy() + env["VIRTUAL_ENV"] = "/some/absolute/path/.venv" + + # 3. Run 'make test-env' with VIRTUAL_ENV set + result = run_make(logger, ["test-env"], check=True, dry_run=False, env=env) + + # 4. Output for debugging + logger.info("make stdout: %s", result.stdout) + logger.info("make stderr: %s", result.stderr) + + # 5. Verify that VIRTUAL_ENV is empty in the shell (not exported) + # The output should contain "VIRTUAL_ENV in shell: ''" + assert "VIRTUAL_ENV in shell: ''" in result.stdout, ( + f"VIRTUAL_ENV should be empty in shell commands, but got: {result.stdout}" + ) diff --git a/.rhiza/tests/structure/test_lfs_structure.py b/.rhiza/tests/structure/test_lfs_structure.py new file mode 100644 index 0000000..038ccc4 --- /dev/null +++ b/.rhiza/tests/structure/test_lfs_structure.py @@ -0,0 +1,135 @@ +"""Tests for Git LFS template structure and files. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +Verifies that LFS-related files and configurations are present. +""" + +import pytest + + +@pytest.fixture +def lfs_makefile(root): + """Fixture that returns the path to lfs.mk if it exists, else skips.""" + path = root / ".rhiza" / "make.d" / "lfs.mk" + if not path.exists(): + pytest.skip("lfs.mk not found, skipping LFS tests") + return path + + +class TestLFSTemplateStructure: + """Tests for LFS template file structure.""" + + def test_lfs_makefile_exists(self, lfs_makefile): + """LFS makefile should exist in make.d directory.""" + assert lfs_makefile.exists() + + def test_lfs_documentation_exists(self, root, lfs_makefile): + """LFS documentation should exist.""" + lfs_doc = root / ".rhiza" / "docs" / "LFS.md" + assert lfs_doc.exists(), "LFS.md documentation not found" + + def test_lfs_makefile_has_targets(self, lfs_makefile): + """LFS makefile should define all expected targets.""" + content = lfs_makefile.read_text() + + required_targets = [ + "lfs-install:", + "lfs-pull:", + "lfs-track:", + "lfs-status:", + ] + + for target in required_targets: + assert target in content, f"Target {target} not found in lfs.mk" + + def test_lfs_makefile_has_phony_declarations(self, lfs_makefile): + """LFS makefile should declare targets as phony.""" + content = lfs_makefile.read_text() + + assert ".PHONY:" in content + assert "lfs-install" in content + assert "lfs-pull" in content + assert "lfs-track" in content + assert "lfs-status" in content + + def test_lfs_makefile_has_help_comments(self, lfs_makefile): + """LFS makefile should have help comments for targets.""" + content = lfs_makefile.read_text() + + # Check for ##@ section header + assert "##@ Git LFS" in content + + # Check for target descriptions + assert "##" in content + + def test_lfs_documentation_has_sections(self, root, lfs_makefile): + """LFS documentation should have all expected sections.""" + lfs_doc = root / ".rhiza" / "docs" / "LFS.md" + # Since test_lfs_documentation_exists checks existence, we assume it exists if passed + if not lfs_doc.exists(): + pytest.skip("LFS.md not found") + + content = lfs_doc.read_text() + + expected_sections = [ + "# Git LFS", + "## Overview", + "## Available Make Targets", + "## Typical Workflow", + "## CI/CD Integration", + "## Troubleshooting", + ] + + for section in expected_sections: + assert section in content, f"Section '{section}' not found in LFS.md" + + def test_lfs_documentation_describes_all_targets(self, root, lfs_makefile): + """LFS documentation should describe all make targets.""" + lfs_doc = root / ".rhiza" / "docs" / "LFS.md" + if not lfs_doc.exists(): + pytest.skip("LFS.md not found") + + content = lfs_doc.read_text() + + targets = [ + "lfs-install", + "lfs-pull", + "lfs-track", + "lfs-status", + ] + + for target in targets: + assert target in content, f"Target {target} not documented in LFS.md" + + def test_lfs_makefile_cross_platform_support(self, lfs_makefile): + """LFS makefile should support multiple platforms.""" + content = lfs_makefile.read_text() + + # Check for OS detection + assert "uname -s" in content + assert "Darwin" in content + assert "Linux" in content + + # Check for architecture detection (macOS) + assert "uname -m" in content + assert "arm64" in content + assert "amd64" in content + + def test_lfs_makefile_error_handling(self, lfs_makefile): + """LFS makefile should include error handling.""" + content = lfs_makefile.read_text() + + # Check for error messages + assert "ERROR" in content + assert "exit 1" in content + + def test_lfs_makefile_uses_color_variables(self, lfs_makefile): + """LFS makefile should use standard color variables.""" + content = lfs_makefile.read_text() + + # Check for color variable usage + assert "BLUE" in content + assert "RED" in content + assert "RESET" in content diff --git a/.rhiza/tests/structure/test_project_layout.py b/.rhiza/tests/structure/test_project_layout.py new file mode 100644 index 0000000..1ceac5f --- /dev/null +++ b/.rhiza/tests/structure/test_project_layout.py @@ -0,0 +1,57 @@ +"""Tests for the root pytest fixture that yields the repository root Path. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +This module ensures the fixture resolves to the true project root and that +expected files/directories exist, enabling other tests to locate resources +reliably. +""" + +import pytest + + +class TestRootFixture: + """Tests for the root fixture that provides repository root path.""" + + def test_root_resolves_correctly_from_nested_location(self, root): + """Root should correctly resolve to repository root from .rhiza/tests/.""" + conftest_path = root / ".rhiza" / "tests" / "conftest.py" + assert conftest_path.exists() + + def test_root_contains_expected_directories(self, root): + """Root should contain all expected project directories.""" + required_dirs = [".rhiza"] + optional_dirs = ["src", "tests", "book"] # src/ is optional (rhiza itself doesn't have one) + + for dirname in required_dirs: + assert (root / dirname).exists(), f"Required directory {dirname} not found" + + # Check that at least one CI directory exists (.github or .gitlab) + ci_dirs = [".github", ".gitlab"] + if not any((root / ci_dir).exists() for ci_dir in ci_dirs): + pytest.fail(f"At least one CI directory from {ci_dirs} must exist") + + for dirname in optional_dirs: + if not (root / dirname).exists(): + pytest.skip(f"Optional directory {dirname} not present in this project") + + def test_root_contains_expected_files(self, root): + """Root should contain all expected configuration files.""" + required_files = [ + "pyproject.toml", + "README.md", + "Makefile", + ] + optional_files = [ + "ruff.toml", + ".gitignore", + ".editorconfig", + ] + + for filename in required_files: + assert (root / filename).exists(), f"Required file {filename} not found" + + for filename in optional_files: + if not (root / filename).exists(): + pytest.skip(f"Optional file {filename} not present in this project") diff --git a/tests/test_rhiza/test_requirements_folder.py b/.rhiza/tests/structure/test_requirements.py similarity index 90% rename from tests/test_rhiza/test_requirements_folder.py rename to .rhiza/tests/structure/test_requirements.py index e9450b8..1bf9d04 100644 --- a/tests/test_rhiza/test_requirements_folder.py +++ b/.rhiza/tests/structure/test_requirements.py @@ -4,14 +4,16 @@ requirement files for development dependencies. """ +from typing import ClassVar + class TestRequirementsFolder: """Tests for the .rhiza/requirements folder structure.""" # Expected requirements files - EXPECTED_REQUIREMENTS_FILES = [ - "tests.txt", - "marimo.txt", + EXPECTED_REQUIREMENTS_FILES: ClassVar[list[str]] = [ + # "tests.txt", # may not be present in all repositories + # "marimo.txt", # may not be present in all repositories "docs.txt", "tools.txt", ] diff --git a/.rhiza/tests/structure/test_template_bundles.py b/.rhiza/tests/structure/test_template_bundles.py new file mode 100644 index 0000000..a25f662 --- /dev/null +++ b/.rhiza/tests/structure/test_template_bundles.py @@ -0,0 +1,89 @@ +"""Tests to validate that all files/folders referenced in template-bundles.yml exist. + +This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +This module ensures that template bundle definitions in .rhiza/template-bundles.yml +reference only files and folders that actually exist in the repository. +""" + +import pytest +import yaml + + +class TestTemplateBundles: + """Tests for template-bundles.yml validation.""" + + @pytest.fixture + def bundles_file(self, root): + """Return the path to template-bundles.yml.""" + return root / ".rhiza" / "template-bundles.yml" + + @pytest.fixture + def bundles_data(self, bundles_file): + """Load and parse the template-bundles.yml file.""" + if not bundles_file.exists(): + pytest.skip("template-bundles.yml does not exist in this project") + + with open(bundles_file) as f: + data = yaml.safe_load(f) + + if not data or "bundles" not in data: + pytest.fail("Invalid template-bundles.yml format - missing 'bundles' key") + + return data + + def test_bundles_file_exists_or_skip(self, bundles_file): + """Test that template-bundles.yml exists, or skip if not present.""" + if not bundles_file.exists(): + pytest.skip("template-bundles.yml does not exist in this project") + + def test_all_bundle_files_exist(self, root, bundles_data): + """Test that all files referenced in template-bundles.yml exist.""" + bundles = bundles_data["bundles"] + all_missing = [] + total_files = 0 + + # Check each bundle + for bundle_name, bundle_config in bundles.items(): + if "files" not in bundle_config: + continue + + files = bundle_config["files"] + + for file_path in files: + total_files += 1 + path = root / file_path + + if not path.exists(): + all_missing.append((bundle_name, file_path)) + + # Report results + if all_missing: + error_msg = f"\nValidation failed: {len(all_missing)} of {total_files} files/folders are missing:\n\n" + for bundle_name, file_path in all_missing: + error_msg += f" [{bundle_name}] {file_path}\n" + pytest.fail(error_msg) + + def test_each_bundle_files_exist(self, root, bundles_data): + """Test that files exist for each individual bundle.""" + bundles = bundles_data["bundles"] + + for bundle_name, bundle_config in bundles.items(): + if "files" not in bundle_config: + continue + + files = bundle_config["files"] + missing_in_bundle = [] + + for file_path in files: + path = root / file_path + + if not path.exists(): + missing_in_bundle.append(file_path) + + if missing_in_bundle: + error_msg = f"\nBundle '{bundle_name}' has {len(missing_in_bundle)} missing path(s):\n" + for missing in missing_in_bundle: + error_msg += f" - {missing}\n" + pytest.fail(error_msg) diff --git a/.rhiza/tests/sync/conftest.py b/.rhiza/tests/sync/conftest.py new file mode 100644 index 0000000..383c8b2 --- /dev/null +++ b/.rhiza/tests/sync/conftest.py @@ -0,0 +1,95 @@ +"""Shared fixtures and helpers for sync tests. + +Provides environment setup for template sync, workflow versioning, +and content validation tests. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +tests_root = Path(__file__).resolve().parents[1] +if str(tests_root) not in sys.path: + sys.path.insert(0, str(tests_root)) + +from test_utils import run_make, setup_rhiza_git_repo, strip_ansi # noqa: E402, F401 + + +@pytest.fixture(autouse=True) +def setup_sync_env(logger, root, tmp_path: Path): + """Set up a temporary environment for sync tests with Makefile, templates, and git. + + This fixture creates a complete test environment with: + - Makefile and rhiza.mk configuration + - .rhiza-version file and .env configuration + - template.yml and pyproject.toml + - Initialized git repository (configured as rhiza origin) + - src/ and tests/ directories to satisfy validate target + """ + logger.debug("Setting up sync test environment: %s", tmp_path) + + # Copy the main Makefile into the temporary working directory + shutil.copy(root / "Makefile", tmp_path / "Makefile") + + # Copy core Rhiza Makefiles and version file + (tmp_path / ".rhiza").mkdir(exist_ok=True) + shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") + + # Copy split Makefiles from make.d directory + split_makefiles = [ + "bootstrap.mk", + "quality.mk", + "releasing.mk", + "test.mk", + "book.mk", + "marimo.mk", + "presentation.mk", + "github.mk", + "agentic.mk", + "docker.mk", + "docs.mk", + ] + (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) + for mk_file in split_makefiles: + source_path = root / ".rhiza" / "make.d" / mk_file + if source_path.exists(): + shutil.copy(source_path, tmp_path / ".rhiza" / "make.d" / mk_file) + + # Copy .rhiza-version if it exists + if (root / ".rhiza" / ".rhiza-version").exists(): + shutil.copy(root / ".rhiza" / ".rhiza-version", tmp_path / ".rhiza" / ".rhiza-version") + + # Create a minimal, deterministic .rhiza/.env for tests + env_content = "SCRIPTS_FOLDER=.rhiza/scripts\nCUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" + (tmp_path / ".rhiza" / ".env").write_text(env_content) + + logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") + + # Create a minimal .rhiza/template.yml + (tmp_path / ".rhiza" / "template.yml").write_text("repository: Jebel-Quant/rhiza\nref: v0.7.1\n") + + # Sort out pyproject.toml + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test-project"\nversion = "0.1.0"\n') + + # Move into tmp directory for isolation + old_cwd = Path.cwd() + os.chdir(tmp_path) + logger.debug("Changed working directory to %s", tmp_path) + + # Initialize a git repo so that commands checking for it (like materialize) don't fail validation + setup_rhiza_git_repo() + + # Create src and tests directories to satisfy validate + (tmp_path / "src").mkdir(exist_ok=True) + (tmp_path / "tests").mkdir(exist_ok=True) + + try: + yield + finally: + os.chdir(old_cwd) + logger.debug("Restored working directory to %s", old_cwd) diff --git a/tests/test_rhiza/test_docstrings.py b/.rhiza/tests/sync/test_docstrings.py similarity index 66% rename from tests/test_rhiza/test_docstrings.py rename to .rhiza/tests/sync/test_docstrings.py index c7e6f4d..231e57b 100644 --- a/tests/test_rhiza/test_docstrings.py +++ b/.rhiza/tests/sync/test_docstrings.py @@ -3,7 +3,7 @@ This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). -Automatically discovers all packages under `src/` and runs doctests for each. +Automatically discovers all packages and runs doctests for each. """ from __future__ import annotations @@ -14,15 +14,19 @@ from pathlib import Path import pytest +from dotenv import dotenv_values +# Read .rhiza/.env at collection time (no environment side-effects). +RHIZA_ENV_PATH = Path(".rhiza/.env") -def _iter_modules_from_path(logger, package_path: Path): + +def _iter_modules_from_path(logger, package_path: Path, src_path: Path): """Recursively find all Python modules in a directory.""" for path in package_path.rglob("*.py"): if path.name == "__init__.py": - module_path = path.parent.relative_to(package_path.parent) + module_path = path.parent.relative_to(src_path) else: - module_path = path.relative_to(package_path.parent).with_suffix("") + module_path = path.relative_to(src_path).with_suffix("") # Convert path to module name in an OS-independent way module_name = ".".join(module_path.parts) @@ -35,16 +39,28 @@ def _iter_modules_from_path(logger, package_path: Path): continue -def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch): - """Run doctests for each package directory under src/.""" - src_path = root / "src" +def _find_packages(src_path: Path): + """Find all packages in the source path, including those nested under namespace packages.""" + for init_file in src_path.rglob("__init__.py"): + package_dir = init_file.parent + # Only yield top-level packages (those whose parent doesn't have __init__.py or is src_path) + parent = package_dir.parent + if parent == src_path or not (parent / "__init__.py").exists(): + yield package_dir + + +def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): + """Run doctests for each package directory.""" + values = dotenv_values(root / RHIZA_ENV_PATH) if (root / RHIZA_ENV_PATH).exists() else {} + source_folder = values.get("SOURCE_FOLDER", "src") + src_path = root / source_folder logger.info("Starting doctest discovery in: %s", src_path) if not src_path.exists(): logger.info("Source directory not found: %s β€” skipping doctests", src_path) pytest.skip(f"Source directory not found: {src_path}") - # Add src to sys.path with automatic cleanup + # Add source path to sys.path with automatic cleanup monkeypatch.syspath_prepend(str(src_path)) logger.debug("Prepended to sys.path: %s", src_path) @@ -52,23 +68,25 @@ def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch): total_failures = 0 failed_modules = [] - # Find all packages in src - for package_dir in src_path.iterdir(): + # Find all packages in the source path (supports namespace packages) + for package_dir in _find_packages(src_path): if package_dir.is_dir() and (package_dir / "__init__.py").exists(): # Import the package package_name = package_dir.name logger.info("Discovered package: %s", package_name) try: - modules = list(_iter_modules_from_path(logger, package_dir)) + modules = list(_iter_modules_from_path(logger, package_dir, src_path)) logger.debug("%d module(s) found in package %s", len(modules), package_name) for module in modules: logger.debug("Running doctests for module: %s", module.__name__) - results = doctest.testmod( - module, - verbose=False, - optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE), - ) + # Disable pytest's stdout capture during doctest to avoid interference + with capsys.disabled(): + results = doctest.testmod( + module, + verbose=False, + optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE), + ) total_tests += results.attempted if results.failed: diff --git a/tests/test_rhiza/test_readme.py b/.rhiza/tests/sync/test_readme_validation.py similarity index 91% rename from tests/test_rhiza/test_readme.py rename to .rhiza/tests/sync/test_readme_validation.py index 6150b07..29f1b0e 100644 --- a/tests/test_rhiza/test_readme.py +++ b/.rhiza/tests/sync/test_readme_validation.py @@ -40,7 +40,7 @@ def test_readme_runs(logger, root): # Trust boundary: we execute Python snippets sourced from README.md in this repo. # The README is part of the trusted repository content and reviewed in PRs. logger.debug("Executing README code via %s -c ...", sys.executable) - result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=root) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=root) # nosec stdout = result.stdout logger.debug("Execution finished with return code %d", result.returncode) @@ -86,15 +86,6 @@ def test_readme_code_is_syntactically_valid(self, root): class TestReadmeBashFragments: """Tests for bash code fragments in README.""" - def test_bash_blocks_are_non_empty(self, root): - """Bash code blocks in README should not be empty.""" - readme = root / "README.md" - content = readme.read_text(encoding="utf-8") - bash_blocks = BASH_BLOCK.findall(content) - - for i, code in enumerate(bash_blocks): - assert code.strip(), f"Bash block {i} should not be empty" - def test_bash_blocks_basic_syntax(self, root, logger): """Bash code blocks should have basic valid syntax (can be parsed by bash -n).""" readme = root / "README.md" @@ -120,7 +111,7 @@ def test_bash_blocks_basic_syntax(self, root, logger): # Use bash -n to check syntax without executing # Trust boundary: we use bash -n which only parses without executing - result = subprocess.run( + result = subprocess.run( # nosec [BASH, "-n"], input=code, capture_output=True, diff --git a/tests/test_rhiza/test_rhiza_workflows.py b/.rhiza/tests/sync/test_rhiza_version.py similarity index 56% rename from tests/test_rhiza/test_rhiza_workflows.py rename to .rhiza/tests/sync/test_rhiza_version.py index bc3cb24..4f139dd 100644 --- a/tests/test_rhiza/test_rhiza_workflows.py +++ b/.rhiza/tests/sync/test_rhiza_version.py @@ -11,63 +11,8 @@ from __future__ import annotations -import os -import shutil -import subprocess -from pathlib import Path - -import pytest -from conftest import run_make, setup_rhiza_git_repo, strip_ansi - - -@pytest.fixture(autouse=True) -def setup_tmp_makefile(logger, root, tmp_path: Path): - """Copy the Makefile and necessary files into a temp directory and chdir there. - - We rely on `make -n` so that no real commands are executed. - """ - logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) - - # Copy the main Makefile into the temporary working directory - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles and version file - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - # Copy .rhiza-version if it exists - if (root / ".rhiza" / ".rhiza-version").exists(): - shutil.copy(root / ".rhiza" / ".rhiza-version", tmp_path / ".rhiza" / ".rhiza-version") - - # Create a minimal, deterministic .rhiza/.env for tests - env_content = "SCRIPTS_FOLDER=.rhiza/scripts\nCUSTOM_SCRIPTS_FOLDER=.rhiza/customisations/scripts\n" - (tmp_path / ".rhiza" / ".env").write_text(env_content) - - logger.debug("Copied Makefile from %s to %s", root / "Makefile", tmp_path / "Makefile") - - # Create a minimal .rhiza/template.yml - (tmp_path / ".rhiza" / "template.yml").write_text("repository: Jebel-Quant/rhiza\nref: main\n") - - # Sort out pyproject.toml - (tmp_path / "pyproject.toml").write_text('[project]\nname = "test-project"\nversion = "0.1.0"\n') - - # Move into tmp directory for isolation - old_cwd = Path.cwd() - os.chdir(tmp_path) - logger.debug("Changed working directory to %s", tmp_path) - - # Initialize a git repo so that commands checking for it (like materialize) don't fail validation - setup_rhiza_git_repo() - - # Create src and tests directories to satisfy validate - (tmp_path / "src").mkdir(exist_ok=True) - (tmp_path / "tests").mkdir(exist_ok=True) - - try: - yield - finally: - os.chdir(old_cwd) - logger.debug("Restored working directory to %s", old_cwd) +# Import from local conftest +from sync.conftest import run_make, strip_ansi class TestRhizaVersion: @@ -94,15 +39,24 @@ def test_rhiza_version_exported_in_makefile(self, logger): assert any(char.isdigit() for char in out) def test_rhiza_version_defaults_to_0_9_0_without_file(self, logger, tmp_path): - """RHIZA_VERSION should default to 0.9.0 if .rhiza-version doesn't exist.""" + """RHIZA_VERSION should default to 0.10.2 if .rhiza-version doesn't exist.""" # Remove the .rhiza-version file version_file = tmp_path / ".rhiza" / ".rhiza-version" if version_file.exists(): version_file.unlink() - proc = run_make(logger, ["print-RHIZA_VERSION"], dry_run=False) + # Clear RHIZA_VERSION from environment to test the default value + import os + import subprocess + + env = os.environ.copy() + env.pop("RHIZA_VERSION", None) + + cmd = ["/usr/bin/make", "-s", "print-RHIZA_VERSION"] + logger.info("Running command: %s", " ".join(cmd)) + proc = subprocess.run(cmd, capture_output=True, text=True, env=env) out = strip_ansi(proc.stdout) - assert "Value of RHIZA_VERSION:\n0.9.0" in out + assert "Value of RHIZA_VERSION:\n0.10.2" in out def test_rhiza_version_used_in_sync_target(self, logger): """Sync target should use RHIZA_VERSION from .rhiza-version.""" @@ -163,49 +117,6 @@ def test_summarise_sync_requires_install_uv(self, logger): # This might be implicit via the dependency chain assert "rhiza" in out - -class TestWorkflowSync: - """Tests to validate the workflow pattern used in .github/workflows/rhiza_sync.yml.""" - - def test_workflow_version_reading_pattern(self, logger, tmp_path): - """Test the pattern used in workflow to read Rhiza version.""" - # Create .rhiza-version file - version_file = tmp_path / ".rhiza" / ".rhiza-version" - version_file.write_text("0.9.5\n") - - # Simulate the workflow's version reading step - result = subprocess.run( - [shutil.which("cat") or "cat", str(version_file)], - capture_output=True, - text=True, - check=True, - ) - version = result.stdout.strip() - - assert version == "0.9.5" - - def test_workflow_version_fallback_pattern(self, logger, tmp_path): - """Test the fallback pattern when .rhiza-version doesn't exist.""" - # Ensure .rhiza-version doesn't exist - version_file = tmp_path / ".rhiza" / ".rhiza-version" - if version_file.exists(): - version_file.unlink() - - # Simulate the workflow's version reading with fallback using proper subprocess - try: - result = subprocess.run( - [shutil.which("cat") or "cat", str(version_file)], - capture_output=True, - text=True, - check=True, - ) - version = result.stdout.strip() - except subprocess.CalledProcessError: - # File doesn't exist, use fallback - version = "0.9.0" - - assert version == "0.9.0" - def test_workflow_uvx_command_format(self, logger): """Test that the uvx command format matches workflow expectations.""" # This test validates the command format used in both Makefile and workflow diff --git a/.rhiza/tests/test_utils.py b/.rhiza/tests/test_utils.py new file mode 100644 index 0000000..d90db5e --- /dev/null +++ b/.rhiza/tests/test_utils.py @@ -0,0 +1,63 @@ +"""Shared test utilities. + +Helper functions used across the test suite. Extracted from conftest.py to avoid +relative imports and __init__.py requirements in test directories. + +This file and its associated utilities flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). +""" + +import re +import shutil +import subprocess # nosec B404 + +# Get absolute paths for executables to avoid S607 warnings +GIT = shutil.which("git") or "/usr/bin/git" +MAKE = shutil.which("make") or "/usr/bin/make" + + +def strip_ansi(text: str) -> str: + """Strip ANSI escape sequences from text.""" + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + +def run_make( + logger, args: list[str] | None = None, check: bool = True, dry_run: bool = True, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess: + """Run `make` with optional arguments and return the completed process. + + Args: + logger: Logger used to emit diagnostic messages during the run + args: Additional arguments for make + check: If True, raise on non-zero return code + dry_run: If True, use -n to avoid executing commands + env: Optional environment variables to pass to the subprocess + """ + cmd = [MAKE] + if args: + cmd.extend(args) + # Use -s to reduce noise, -n to avoid executing commands + flags = "-sn" if dry_run else "-s" + cmd.insert(1, flags) + logger.info("Running command: %s", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True, env=env) # nosec B603 + logger.debug("make exited with code %d", result.returncode) + if result.stdout: + logger.debug("make stdout (truncated to 500 chars):\n%s", result.stdout[:500]) + if result.stderr: + logger.debug("make stderr (truncated to 500 chars):\n%s", result.stderr[:500]) + if check and result.returncode != 0: + msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + raise AssertionError(msg) + return result + + +def setup_rhiza_git_repo(): + """Initialize a git repository and set remote to rhiza.""" + subprocess.run([GIT, "init"], check=True, capture_output=True) # nosec B603 + subprocess.run( # nosec B603 + [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza"], + check=True, + capture_output=True, + ) diff --git a/tests/test_rhiza/test_git_repo_fixture.py b/.rhiza/tests/utils/test_git_repo_fixture.py similarity index 89% rename from tests/test_rhiza/test_git_repo_fixture.py rename to .rhiza/tests/utils/test_git_repo_fixture.py index 67b5c76..f8165b3 100644 --- a/tests/test_rhiza/test_git_repo_fixture.py +++ b/.rhiza/tests/utils/test_git_repo_fixture.py @@ -49,18 +49,6 @@ def test_git_repo_mock_tools_are_executable(self, git_repo): tool_path = git_repo / "bin" / tool assert os.access(tool_path, os.X_OK), f"{tool} is not executable" - def test_git_repo_has_github_scripts_directory(self, git_repo): - """Git repo should have .github/rhiza/scripts directory.""" - scripts_dir = git_repo / ".rhiza" / "scripts" - assert scripts_dir.exists() - assert (scripts_dir / "release.sh").exists() - - def test_git_repo_scripts_are_executable(self, git_repo): - """GitHub scripts should be executable.""" - for script in ["release.sh"]: - script_path = git_repo / ".rhiza" / "scripts" / script - assert os.access(script_path, os.X_OK), f"{script} is not executable" - def test_git_repo_is_initialized(self, git_repo): """Git repo should be properly initialized.""" result = subprocess.run( diff --git a/.rhiza/utils/version_matrix.py b/.rhiza/utils/version_matrix.py deleted file mode 100755 index 08ef655..0000000 --- a/.rhiza/utils/version_matrix.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -"""Emit the list of supported Python versions from pyproject.toml. - -This helper is used in GitHub Actions to compute the test matrix. -""" - -import json -import re -import tomllib -from pathlib import Path - -PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" -CANDIDATES = ["3.11", "3.12", "3.13", "3.14"] # extend as needed - - -class RhizaError(Exception): - """Base exception for Rhiza-related errors.""" - - -class VersionSpecifierError(RhizaError): - """Raised when a version string or specifier is invalid.""" - - -class PyProjectError(RhizaError): - """Raised when there are issues with pyproject.toml configuration.""" - - -def parse_version(v: str) -> tuple[int, ...]: - """Parse a version string into a tuple of integers. - - This is intentionally simple and only supports numeric components. - If a component contains non-numeric suffixes (e.g. '3.11.0rc1'), - the leading numeric portion will be used (e.g. '0rc1' -> 0). If a - component has no leading digits at all, a VersionSpecifierError is raised. - - Args: - v: Version string to parse (e.g., "3.11", "3.11.0rc1"). - - Returns: - Tuple of integers representing the version. - - Raises: - VersionSpecifierError: If a version component has no numeric prefix. - """ - parts: list[int] = [] - for part in v.split("."): - match = re.match(r"\d+", part) - if not match: - msg = f"Invalid version component {part!r} in version {v!r}; expected a numeric prefix." - raise VersionSpecifierError(msg) - parts.append(int(match.group(0))) - return tuple(parts) - - -def _check_operator(version_tuple: tuple[int, ...], op: str, spec_v_tuple: tuple[int, ...]) -> bool: - """Check if a version tuple satisfies an operator constraint.""" - operators = { - ">=": lambda v, s: v >= s, - "<=": lambda v, s: v <= s, - ">": lambda v, s: v > s, - "<": lambda v, s: v < s, - "==": lambda v, s: v == s, - "!=": lambda v, s: v != s, - } - return operators[op](version_tuple, spec_v_tuple) - - -def satisfies(version: str, specifier: str) -> bool: - """Check if a version satisfies a comma-separated list of specifiers. - - This is a simplified version of packaging.specifiers.SpecifierSet. - Supported operators: >=, <=, >, <, ==, != - - Args: - version: Version string to check (e.g., "3.11"). - specifier: Comma-separated specifier string (e.g., ">=3.11,<3.14"). - - Returns: - True if the version satisfies all specifiers, False otherwise. - - Raises: - VersionSpecifierError: If the specifier format is invalid. - """ - version_tuple = parse_version(version) - - # Split by comma for multiple constraints - for spec in specifier.split(","): - spec = spec.strip() - # Match operator and version part - match = re.match(r"(>=|<=|>|<|==|!=)\s*([\d.]+)", spec) - if not match: - # If no operator, assume == - if re.match(r"[\d.]+", spec): - if version_tuple != parse_version(spec): - return False - continue - msg = f"Invalid specifier {spec!r}; expected format like '>=3.11' or '3.11'" - raise VersionSpecifierError(msg) - - op, spec_v = match.groups() - spec_v_tuple = parse_version(spec_v) - - if not _check_operator(version_tuple, op, spec_v_tuple): - return False - - return True - - -def supported_versions() -> list[str]: - """Return all supported Python versions declared in pyproject.toml. - - Reads project.requires-python, evaluates candidate versions against the - specifier, and returns the subset that satisfy the constraint, in ascending order. - - Returns: - list[str]: The supported versions (e.g., ["3.11", "3.12"]). - - Raises: - PyProjectError: If requires-python is missing or no candidates match. - """ - # Load pyproject.toml using the tomllib standard library (Python 3.11+) - with PYPROJECT.open("rb") as f: - data = tomllib.load(f) - - # Extract the requires-python field from project metadata - # This specifies the Python version constraint (e.g., ">=3.11") - spec_str = data.get("project", {}).get("requires-python") - if not spec_str: - msg = "pyproject.toml: missing 'project.requires-python'" - raise PyProjectError(msg) - - # Filter candidate versions to find which ones satisfy the constraint - versions: list[str] = [] - for v in CANDIDATES: - if satisfies(v, spec_str): - versions.append(v) - - if not versions: - msg = f"pyproject.toml: no supported Python versions match '{spec_str}'. Evaluated candidates: {CANDIDATES}" - raise PyProjectError(msg) - - return versions - - -if __name__ == "__main__": - # Check if pyproject.toml exists in the expected location - # If it exists, use it to determine supported versions - # Otherwise, fall back to returning all candidates (for edge cases) - if PYPROJECT.exists(): - print(json.dumps(supported_versions())) - else: - print(json.dumps(CANDIDATES)) diff --git a/Makefile b/Makefile index 0a1aac7..52d4794 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ ## Makefile (repo-owned) # Keep this file small. It can be edited without breaking template sync. +DOCFORMAT=google +DEFAULT_AI_MODEL=claude-sonnet-4.5 +LOGO_FILE=.rhiza/assets/rhiza-logo.svg + # Always include the Rhiza API (template-managed) include .rhiza/rhiza.mk diff --git a/book/README.md b/book/README.md deleted file mode 100644 index 81273af..0000000 --- a/book/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Project Book and Documentation - -This directory contains the source and templates for generating the Rhiza companion book and API documentation. - -## Structure - -- `marimo/`: Interactive [Marimo](https://marimo.io/) notebooks that are included in the book. -- `minibook-templates/`: Jinja2 templates for the minibook generation. -- `pdoc-templates/`: Custom templates for [pdoc](https://pdoc.dev/) API documentation. -- `book.mk`: Specialised Makefile for building the book and documentation. - -## Building the Book - -You can build the complete documentation book using the main project Makefile: - -```bash -make book -``` - -This process involves: -1. Exporting Marimo notebooks to HTML. -2. Generating API documentation from the source code. -3. Combining them into a cohesive "book" structure. - -## Documentation Customisation - -You can customise the look and feel of your documentation by providing your own templates. - -### API Documentation (pdoc) - -The `make docs` command checks for a directory at `book/pdoc-templates`. If found, it uses the templates within that directory to generate the API documentation. - -To customise the API docs: -1. Create the directory: `mkdir -p book/pdoc-templates` -2. Add your Jinja2 templates (e.g., `module.html.jinja2`) to this directory. - -See the [pdoc documentation](https://pdoc.dev/docs/pdoc.html#templates) for more details on templating. - -### Project Logo - -The documentation generation supports embedding a project logo in the sidebar. - -**Default Behavior:** -By default, the build looks for `assets/rhiza-logo.svg`. - -**Customization:** -You can change the logo by setting the `LOGO_FILE` variable in your project's `Makefile` or `local.mk`. - -```makefile -# Example: Use a custom PNG logo -LOGO_FILE := assets/my-company-logo.png -``` - -To disable the logo entirely, set the variable to an empty string: - -```makefile -# Example: Disable logo -LOGO_FILE := -``` - -### Companion Book (minibook) - -The `make book` command checks for a template at `book/minibook-templates/custom.html.jinja2`. If found, it uses this template for the minibook generation. - -To customise the book: -1. Create the directory: `mkdir -p book/minibook-templates` -2. Create your custom template at `book/minibook-templates/custom.html.jinja2`. diff --git a/book/marimo/README.md b/book/marimo/README.md deleted file mode 100644 index d9eb024..0000000 --- a/book/marimo/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# Marimo Notebooks - -This directory contains interactive [Marimo](https://marimo.io/) notebooks for the Rhiza project. - -## Available Notebooks - -### πŸ“Š rhiza.py - Marimo Feature Showcase - -A comprehensive demonstration of Marimo's most useful features, including: - -- **Interactive UI Elements**: Sliders, dropdowns, text inputs, checkboxes, and multiselect -- **Reactive Programming**: Automatic cell updates when dependencies change -- **Data Visualisation**: Interactive plots using Plotly -- **DataFrames**: Working with Pandas data -- **Layout Components**: Columns, tabs, and accordions for organised content -- **Forms**: Dictionary-based forms for collecting user input -- **Rich Text**: Markdown and LaTeX support for documentation -- **Advanced Features**: Callouts, collapsible accordions, and more - -This notebook is perfect for: -- Learning Marimo's capabilities -- Understanding reactive programming in notebooks -- Seeing real examples of interactive UI components -- Getting started with Marimo in your own projects - -## Running the Notebooks - -### Using the Makefile - -From the repository root: - -```bash -make marimo -``` - -This will start the Marimo server and open all notebooks in the `book/marimo` directory. - -### Running a Specific Notebook - -To run a single notebook: - -```bash -marimo edit book/marimo/rhiza.py -``` - -### Using uv (Recommended) - -The notebooks include inline dependency metadata, making them self-contained: - -```bash -uv run book/marimo/rhiza.py -``` - -This will automatically install the required dependencies and run the notebook. - -## Notebook Structure - -Marimo notebooks are **pure Python files** (`.py`), not JSON. This means: - -- βœ… Easy version control with Git -- βœ… Standard code review workflows -- βœ… No hidden metadata -- βœ… Compatible with all Python tools - -Each notebook includes inline metadata that specifies its dependencies: - -```python -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo==0.18.4", -# "numpy>=1.24.0", -# ] -# /// -``` - -## Configuration - -Marimo is configured in `pyproject.toml` to properly import the local package: - -```toml -[tool.marimo.runtime] -pythonpath = ["src"] -``` - -## CI/CD Integration - -The `.github/workflows/marimo.yml` workflow automatically: - -1. Discovers all `.py` files in this directory -2. Runs each notebook in a fresh environment -3. Verifies that notebooks can bootstrap themselves -4. Ensures reproducibility - -This guarantees that all notebooks remain functional and up-to-date. - -## Creating New Notebooks - -To create a new Marimo notebook: - -1. Create a new `.py` file in this directory: - ```bash - marimo edit book/marimo/my_notebook.py - ``` - -2. Add inline metadata at the top: - ```python - # /// script - # requires-python = ">=3.11" - # dependencies = [ - # "marimo==0.18.4", - # # ... other dependencies - # ] - # /// - ``` - -3. Start building your notebook with cells - -4. Test it runs in a clean environment: - ```bash - uv run book/marimo/my_notebook.py - ``` - -5. Commit and push - the CI will validate it automatically - -## Learn More - -- **Marimo Documentation**: [https://docs.marimo.io/](https://docs.marimo.io/) -- **Example Gallery**: [https://marimo.io/examples](https://marimo.io/examples) -- **Community Discord**: [https://discord.gg/JE7nhX6mD8](https://discord.gg/JE7nhX6mD8) - -## Tips - -- **Reactivity**: Remember that cells automatically re-run when their dependencies change -- **Pure Python**: Edit notebooks in any text editor, not just Marimo's UI -- **Git-Friendly**: Notebooks diff and merge like regular Python files -- **Self-Contained**: Use inline metadata to make notebooks reproducible -- **Interactive**: Take advantage of Marimo's rich UI components for better user experience - ---- - -*Happy exploring with Marimo! πŸš€* diff --git a/presentation/README.md b/presentation/README.md deleted file mode 100644 index d121bfe..0000000 --- a/presentation/README.md +++ /dev/null @@ -1,325 +0,0 @@ -# Presentation Generation with Marp - -This directory contains the presentation generation system for Rhiza. -The project uses [Marp](https://marp.app/) to convert Markdown files into beautiful presentation slides. - -## Overview - -The presentation system consists of: -- **PRESENTATION.md** β€” The main presentation source file (located in the repository root) -- **Makefile.presentation** β€” Make targets for generating and serving presentations (in this directory) -- **Marp CLI** β€” The tool that converts Markdown to HTML/PDF slides - -## Prerequisites - -### Required Tools - -1. **Node.js and npm** β€” Required to install Marp CLI - - Download from: [https://nodejs.org/](https://nodejs.org/) - - Check installation: `node --version` and `npm --version` - -2. **Marp CLI** β€” The presentation generator - - The Makefile will automatically install it if not present - - Manual installation: `npm install -g @marp-team/marp-cli` - - Check installation: `marp --version` - -### Optional Tools - -For PDF generation, you may need additional dependencies: -- **Google Chrome/Chromium** β€” Used by Marp for PDF rendering -- On most systems, this is automatically detected if installed - -## Available Commands - -The presentation system provides three main commands via the Makefile: - -### 1. Generate HTML Presentation - -Run from the repository root: - -```bash -make presentation -``` - -This command: -- Checks if Marp CLI is installed (installs it automatically if needed) -- Converts `PRESENTATION.md` to `presentation.html` -- Creates an HTML file that can be opened in any web browser - -**Output**: `presentation.html` in the repository root - -### 2. Generate PDF Presentation - -Run from the repository root: - -```bash -make presentation-pdf -``` - -This command: -- Checks if Marp CLI is installed (installs it automatically if needed) -- Converts `PRESENTATION.md` to `presentation.pdf` -- Creates a PDF file suitable for distribution - -**Output**: `presentation.pdf` in the repository root - -**Note**: PDF generation requires a Chromium-based browser to be installed. - -### 3. Serve Presentation Interactively - -Run from the repository root: - -```bash -make presentation-serve -``` - -This command: -- Checks if Marp CLI is installed (installs it automatically if needed) -- Starts a local web server with live reload -- Opens your browser to view the presentation -- Automatically refreshes when you edit `PRESENTATION.md` - -**Server**: Usually runs at `http://localhost:8080` - -**Stop server**: Press `Ctrl+C` in the terminal - -## Creating Your Presentation - -### Editing PRESENTATION.md - -The source file for your presentation is located at the repository root: `/PRESENTATION.md` - -To edit it: - -```bash -# Open in your favorite editor -vim PRESENTATION.md -# or -code PRESENTATION.md -# or -nano PRESENTATION.md -``` - -### Marp Markdown Syntax - -Marp extends standard Markdown with special directives for presentations. - -#### Basic Structure - -```markdown ---- -marp: true -theme: default -paginate: true ---- - - -# My First Slide - -Content goes here - ---- - -## Second Slide - -- Bullet point 1 -- Bullet point 2 - ---- - -## Third Slide - -More content -``` - -#### Key Directives - -- `---` β€” Creates a new slide -- `` β€” Centres content on the slide -- Front matter (between `---` at the start) β€” Configures presentation settings - -#### Styling - -The current presentation uses custom CSS in the front matter: - -```yaml -style: | - section { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - } - h1 { - color: #2FA4A9; - } -``` - -You can modify these styles to match your branding. - -## Common Workflows - -### Quick Preview While Editing - -For the best editing experience: - -1. Open two terminals -2. In terminal 1: `make presentation-serve` -3. In terminal 2: Edit `PRESENTATION.md` -4. Changes appear instantly in your browser - -### Generate Final Deliverables - -Before presenting or sharing: - -```bash -# Generate both HTML and PDF -make presentation -make presentation-pdf -``` - -This creates: -- `presentation.html` β€” For web viewing -- `presentation.pdf` β€” For offline viewing or printing - -### Updating the Presentation - -1. Edit `PRESENTATION.md` with your changes -2. Regenerate outputs: - ```bash - make presentation - make presentation-pdf - ``` -3. Test in a browser to ensure everything looks correct - -## Troubleshooting - -### Marp CLI Not Found - -**Problem**: `marp: command not found` - -**Solution**: The Makefile should install it automatically, but if it doesn't: -```bash -npm install -g @marp-team/marp-cli -``` - -### npm Not Found - -**Problem**: `npm: command not found` - -**Solution**: Install Node.js from [https://nodejs.org/](https://nodejs.org/) - -### PDF Generation Fails - -**Problem**: `Error: Failed to launch browser` - -**Solution**: Install Google Chrome or Chromium: -- **Ubuntu/Debian**: `sudo apt-get install chromium-browser` -- **macOS**: `brew install chromium` -- **Windows**: Download from [https://www.google.com/chrome/](https://www.google.com/chrome/) - -### Permission Errors During npm Install - -**Problem**: `EACCES: permission denied` when installing Marp - -**Solution**: Either: -- Use `sudo` (not recommended): `sudo npm install -g @marp-team/marp-cli` -- Configure npm to use a local directory (recommended): - ```bash - mkdir ~/.npm-global - npm config set prefix '~/.npm-global' - echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc - source ~/.bashrc - npm install -g @marp-team/marp-cli - ``` - -### Styles Not Applying - -**Problem**: Custom styles in front matter don't appear - -**Solution**: -- Ensure `marp: true` is set in the front matter -- Check that your CSS syntax is valid -- Try clearing browser cache - -## Advanced Usage - -### Custom Themes - -Create a custom Marp theme: - -1. Create a CSS file with your theme (e.g., `custom-theme.css`) -2. Reference it in the front matter of `PRESENTATION.md`: - ```yaml - --- - marp: true - theme: custom-theme - --- - ``` -3. Modify the Makefile targets to include your theme directory: - ```makefile - presentation: ## generate presentation slides with custom theme - @marp PRESENTATION.md --theme-set custom-theme.css -o presentation.html - ``` - -### Exporting to PowerPoint - -While Marp doesn't directly export to PowerPoint, you can: -1. Generate PDF: `make presentation-pdf` -2. Use a PDF-to-PPTX converter online or with Adobe Acrobat - -### Multiple Presentations - -To create additional presentations: -1. Create a new Markdown file (e.g., `WORKSHOP.md`) -2. Add new targets to `presentation/presentation.mk` following the existing pattern: - ```makefile - workshop: ## generate workshop slides from WORKSHOP.md using Marp - @printf "${BLUE}[INFO] Checking for Marp CLI...${RESET}\n" - @if ! command -v marp >/dev/null 2>&1; then \ - if command -v npm >/dev/null 2>&1; then \ - printf "${YELLOW}[WARN] Marp CLI not found. Installing with npm...${RESET}\n"; \ - npm install -g @marp-team/marp-cli || { \ - printf "${RED}[ERROR] Failed to install Marp CLI.${RESET}\n"; \ - exit 1; \ - }; \ - else \ - printf "${RED}[ERROR] npm not found.${RESET}\n"; \ - exit 1; \ - fi; \ - fi - @printf "${BLUE}[INFO] Generating HTML workshop slides...${RESET}\n" - @marp WORKSHOP.md -o workshop.html - @printf "${GREEN}[SUCCESS] Workshop slides generated: workshop.html${RESET}\n" - ``` -3. Run: `make workshop` - -## Learn More - -- **Marp Documentation**: [https://marpit.marp.app/](https://marpit.marp.app/) -- **Marp CLI Documentation**: [https://github.com/marp-team/marp-cli](https://github.com/marp-team/marp-cli) -- **Marpit Markdown**: [https://marpit.marp.app/markdown](https://marpit.marp.app/markdown) -- **Theme Customisation**: [https://marpit.marp.app/theme-css](https://marpit.marp.app/theme-css) - -## Integration with Rhiza - -This presentation system is part of the Rhiza template collection. When you integrate Rhiza into your project, you automatically get: - -- βœ… The Makefile targets for presentation generation -- βœ… A sample `PRESENTATION.md` file -- βœ… Automatic Marp CLI installation -- βœ… GitHub Actions integration (optional) - -The presentation targets are included in the main Makefile through: -```makefile --include presentation/presentation.mk -``` - -## Contributing - -If you improve the presentation system: -1. Update `presentation.mk` for new features -2. Update this README with documentation -3. Update `PRESENTATION.md` with examples -4. Test all three commands: `presentation`, `presentation-pdf`, `presentation-serve` - -## License - -This presentation system is part of Rhiza and is licensed under the MIT License. diff --git a/pytest.ini b/pytest.ini index a39a4c5..d82d333 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +testpaths = tests # Enable live logs on console log_cli = true # Show DEBUG+ messages @@ -7,3 +8,7 @@ log_cli_format = %(asctime)s %(levelname)s %(name)s: %(message)s log_cli_date_format = %H:%M:%S # Show extra summary info for skipped/failed tests addopts = -ra +# Register custom markers +markers = + stress: marks tests as stress tests (deselect with '-m "not stress"') + property: marks tests as property-based tests diff --git a/ruff.toml b/ruff.toml index f99a993..e5b3910 100644 --- a/ruff.toml +++ b/ruff.toml @@ -69,10 +69,10 @@ extend-select = [ "D107", # pydocstyle - Require docstrings for __init__ "B", # flake8-bugbear - Find likely bugs and design problems "C4", # flake8-comprehensions - Better list/set/dict comprehensions - #"SIM", # flake8-simplify - Simplify code + "SIM", # flake8-simplify - Simplify code "PT", # flake8-pytest-style - Check pytest best practices "RUF", # Ruff-specific rules - #"S", # flake8-bandit - Find security issues + "S", # flake8-bandit - Find security issues #"ERA", # eradicate - Find commented out code #"T10", # flake8-debugger - Check for debugger imports and calls "TRY", # flake8-try-except-raise - Try/except/raise checks @@ -101,25 +101,30 @@ line-ending = "auto" # File-specific rule exceptions [lint.per-file-ignores] -"tests/**/*.py" = [ +# Test files - allow assert statements and subprocess calls for testing +"**/tests/**/*.py" = [ "S101", # Allow assert statements in tests "S603", # Allow subprocess calls without shell=False check + "S607", # Allow starting processes with partial paths in tests "PLW1510", # Allow subprocess without explicit check parameter - "ERA001", # Allow commented out code - "PLR2004", # Allow magic values - "RUF002", # Allow ambiguous unicode - "RUF012", # Allow mutable class attributes ] -# this will also work book/marimo or test/resources/marimo ... +"tests/**/*.py" = [ + "ERA001", # Allow commented out code in project tests + "PLR2004", # Allow magic values in project tests + "RUF002", # Allow ambiguous unicode in project tests + "RUF012", # Allow mutable class attributes in project tests +] +# Marimo notebooks - allow flexible coding patterns for interactive exploration "**/marimo/**/*.py" = [ - "N803", # Allow non-lowercase variable names - "S101", # Allow assert statements - "PLC0415", # Allow imports not at top-level - "B018", # Allow useless expressions - "RUF001", # Allow ambiguous unicode - "RUF002", # Allow ambiguous unicode + "N803", # Allow non-lowercase variable names in notebooks + "S101", # Allow assert statements in notebooks + "PLC0415", # Allow imports not at top-level in notebooks + "B018", # Allow useless expressions in notebooks + "RUF001", # Allow ambiguous unicode in notebooks + "RUF002", # Allow ambiguous unicode in notebooks ] +# Internal utility scripts - specific exceptions for internal tooling ".rhiza/utils/*.py" = [ - "PLW2901", # Allow loop variable overwriting - "TRY003", # Allow long exception messages + "PLW2901", # Allow loop variable overwriting in utility scripts + "TRY003", # Allow long exception messages in utility scripts ] diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py new file mode 100644 index 0000000..a3c07de --- /dev/null +++ b/tests/benchmarks/conftest.py @@ -0,0 +1,5 @@ +"""Pytest configuration for benchmark tests. + +This file can be used to add custom fixtures or configuration +for your benchmark tests. +""" diff --git a/tests/benchmarks/test_benchmarks.py b/tests/benchmarks/test_benchmarks.py new file mode 100644 index 0000000..e7a6f0f --- /dev/null +++ b/tests/benchmarks/test_benchmarks.py @@ -0,0 +1,59 @@ +"""Example benchmark tests. + +This file contains simple example benchmark tests that demonstrate +how to use pytest-benchmark. These are placeholder tests that you +should replace with your own meaningful benchmarks. + +Uses pytest-benchmark to measure and compare execution times. +""" + +from __future__ import annotations + + +class TestExampleBenchmarks: + """Example benchmark tests demonstrating basic usage.""" + + def test_string_concatenation(self, benchmark): + """Example: Benchmark string concatenation.""" + + def concatenate_strings(): + result = "" + for i in range(100): + result += str(i) + return result + + result = benchmark(concatenate_strings) + assert len(result) > 0 + + def test_list_comprehension(self, benchmark): + """Example: Benchmark list comprehension.""" + + def create_list(): + return [i * 2 for i in range(1000)] + + result = benchmark(create_list) + assert len(result) == 1000 + + def test_dictionary_operations(self, benchmark): + """Example: Benchmark dictionary operations.""" + + def dictionary_ops(): + data = {} + for i in range(100): + data[f"key_{i}"] = i * 2 + return sum(data.values()) + + result = benchmark(dictionary_ops) + assert result > 0 + + def test_simple_computation(self, benchmark): + """Example: Benchmark simple computation.""" + + def compute_sum(): + total = 0 + for i in range(1000): + total += i + return total + + result = benchmark(compute_sum) + assert result == sum(range(1000)) diff --git a/tests/property/test_makefile_properties.py b/tests/property/test_makefile_properties.py new file mode 100644 index 0000000..8d32a3c --- /dev/null +++ b/tests/property/test_makefile_properties.py @@ -0,0 +1,26 @@ +"""Property-based tests using Hypothesis. + +This module currently exercises generic Python behavior (for example, list sorting) +rather than any project Makefile targets or operations. + +Uses Hypothesis to generate test cases that verify behavior across a wide range of inputs. +""" + +from __future__ import annotations + +import itertools +from collections import Counter + +import pytest +from hypothesis import given +from hypothesis import strategies as st + + +@pytest.mark.property +@given(st.lists(st.integers() | st.floats(allow_nan=False, allow_infinity=False))) +def test_sort_correctness_using_properties(lst): + """Verify that sorted() correctly orders lists and preserves all elements.""" + result = sorted(lst) + # Use Counter to ensure multiplicities (duplicates) are preserved + assert Counter(lst) == Counter(result) + assert all(a <= b for a, b in itertools.pairwise(result)) diff --git a/tests/test_rhiza/README.md b/tests/test_rhiza/README.md deleted file mode 100644 index 33942d8..0000000 --- a/tests/test_rhiza/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Rhiza Test Suite - -This directory contains the core test suite that flows down via SYNC action from the [jebel-quant/rhiza](https://github.com/jebel-quant/rhiza) repository. - -## Purpose - -These tests validate the foundational infrastructure and workflows that are shared across all Rhiza-synchronized projects: - -- **Git-based workflows**: Version bumping, releasing, and tagging -- **Project structure**: Ensuring required files and directories exist -- **Build automation**: Makefile targets and commands -- **Documentation**: README code examples and docstring validation -- **Synchronization**: Template file exclusion and sync script behavior -- **Development tools**: Mock fixtures for testing in isolation - -## Test Organization - -- `conftest.py` - Pytest fixtures including the `git_repo` fixture for sandboxed testing -- `test_bump_script.py` - Tests for version bumping workflow -- `test_docstrings.py` - Doctest validation across all modules -- `test_git_repo_fixture.py` - Validation of the mock git repository fixture -- `test_makefile.py` - Makefile target validation using dry-runs -- `test_marimushka_script.py` - Testing the marimushka Makefile target (exports notebooks to static HTML) -- `test_readme.py` - README code example execution and validation -- `test_release_script.py` - Release and tagging workflow tests -- `test_structure.py` - Project structure and file existence checks -- `test_updatereadme_script` - Testing our abilities to embed the output of make directly in markdown files. - -## Exclusion from Sync - -While it is **technically possible** to exclude these tests from synchronization by adding them to the `exclude` section of your `template.yml` file, this is **not recommended**. - -These tests ensure that the shared infrastructure components work correctly in your project. Excluding them means: - -- ❌ No validation of version bumping and release workflows -- ❌ No automated checks for project structure requirements -- ❌ Missing critical integration tests for synced scripts -- ❌ Potential breakage when shared components are updated - -## When to Exclude - -You should only consider excluding specific tests if: - -1. Your project has fundamentally different workflow requirements -2. You've replaced the synced scripts with custom implementations -3. You have equivalent or better test coverage for the same functionality - -If you must exclude tests, do so selectively rather than excluding the entire `test_rhiza/` directory. - -## Running the Tests - -```bash -# Run all Rhiza tests -make test - -# Run specific test files -pytest tests/test_rhiza/test_bump_script.py -v - -# Run tests with detailed output -pytest tests/test_rhiza/ -vv -``` - -## Customization - -If you need to customize or extend these tests for your project-specific needs, consider: - -1. Creating additional test files in `tests/` (outside `test_rhiza/`) -2. Adding project-specific fixtures to a separate `conftest.py` -3. Keeping the synced tests intact for baseline validation - -This approach maintains the safety net of standardized tests while accommodating your unique requirements. diff --git a/tests/test_rhiza/benchmarks/.gitignore b/tests/test_rhiza/benchmarks/.gitignore deleted file mode 100644 index 8111dfb..0000000 --- a/tests/test_rhiza/benchmarks/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -benchmarks.json -benchmarks.svg -benchmarks.html diff --git a/tests/test_rhiza/benchmarks/README.md b/tests/test_rhiza/benchmarks/README.md deleted file mode 100644 index 17cb3dd..0000000 --- a/tests/test_rhiza/benchmarks/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Benchmarks - -This folder contains benchmark analysis scripts for the project. -It does **not** contain the benchmark tests themselves, -which are expected to be located in `tests/benchmarks/`. - -## Files - -- `analyze_benchmarks.py` – Script to analyze benchmark results and generate reports. -- `README.md` – This file. - -## Running Benchmarks - -Benchmarks are executed via the Makefile or `pytest`: - -```bash -# Using Makefile target -make benchmark - -# Or manually via uv -uv run pytest tests/benchmarks/ \ - --benchmark-only \ - --benchmark-histogram=tests/test_rhiza/benchmarks/benchmarks \ - --benchmark-json=tests/test_rhiza/benchmarks/benchmarks.json - -# Analyze results -uv run tests/test_rhiza/benchmarks/analyze_benchmarks.py -``` -## Output - -* benchmarks.json – JSON file containing benchmark results. -* Histogram plots – Generated in the folder specified by --benchmark-histogram (by default tests/test_rhiza/benchmarks/benchmarks). - -## Notes - -* Ensure pytest-benchmark (v5.2.3) and pygal (v3.1.0) are installed. -* The Makefile target handles this automatically. -* analyze_benchmarks.py reads the JSON output and generates human-readable summaries and plots. - -## Example benchmark tests - -```python -import time - -def something(duration=0.001): - """ - Function that needs some serious benchmarking. - """ - time.sleep(duration) - # You may return anything you want, like the result of a computation - return 123 - -def test_my_stuff(benchmark): - # benchmark something - result = benchmark(something) - - # Extra code, to verify that the run completed correctly. - # Sometimes you may want to check the result, fast functions - # are no good if they return incorrect results :-) - assert result == 123 -``` - -Please note the usage of the `@pytest.mark.benchmark` fixture -which becomes available after installing pytest-benchmark. - -See https://pytest-benchmark.readthedocs.io/en/stable/ for more details. - - - diff --git a/tests/test_rhiza/benchmarks/analyze_benchmarks.py b/tests/test_rhiza/benchmarks/analyze_benchmarks.py deleted file mode 100644 index 0edf649..0000000 --- a/tests/test_rhiza/benchmarks/analyze_benchmarks.py +++ /dev/null @@ -1,85 +0,0 @@ -# /// script -# dependencies = [ -# "pandas", -# "plotly", -# ] -# /// - -"""Analyze pytest-benchmark results and visualize them. - -This script reads a local ``benchmarks.json`` file produced by pytest-benchmark, -prints a reduced table with benchmark name, mean milliseconds, and operations -per second, and renders an interactive Plotly bar chart of mean runtimes. -""" - -# Python script: read JSON, create reduced table, and Plotly chart -import json -import logging -import sys -from pathlib import Path - -import pandas as pd -import plotly.express as px - -# check if the file exists at all -if not Path(__file__).parent.joinpath("benchmarks.json").exists(): - logging.warning("benchmarks.json not found; skipping analysis and exiting successfully.") - sys.exit(0) - -# Load pytest-benchmark JSON -with open(Path(__file__).parent / "benchmarks.json") as f: - # Do not continue if JSON is invalid (e.g. empty file) - try: - data = json.load(f) - except json.JSONDecodeError: - logging.warning("benchmarks.json is invalid or empty; skipping analysis and exiting successfully.") - sys.exit(0) - -# Validate structure: require a 'benchmarks' list -if not isinstance(data, dict) or "benchmarks" not in data or not isinstance(data["benchmarks"], list): - logging.warning("benchmarks.json missing valid 'benchmarks' list; skipping analysis and exiting successfully.") - sys.exit(0) - -# Extract relevant info: Benchmark name, Mean (ms), OPS -benchmarks = [] -for bench in data["benchmarks"]: - mean_s = bench["stats"]["mean"] - benchmarks.append( - { - "Benchmark": bench["name"], - "Mean_ms": mean_s * 1000, # convert seconds β†’ milliseconds - "OPS": 1 / mean_s, - } - ) - -# Create DataFrame and sort fastest β†’ slowest -df = pd.DataFrame(benchmarks) -df = df.sort_values("Mean_ms") - -# 3️⃣ Display reduced table -print(df[["Benchmark", "Mean_ms", "OPS"]].to_string(index=False, float_format="%.3f")) - -# 4️⃣ Create interactive Plotly bar chart -fig = px.bar( - df, - x="Benchmark", - y="Mean_ms", - color="Mean_ms", - color_continuous_scale="Viridis_r", - title="Benchmark Mean Runtime (ms) per Test", - text="Mean_ms", -) - -fig.update_traces(texttemplate="%{text:.2f} ms", textposition="outside") -fig.update_layout( - xaxis_tickangle=-45, - yaxis_title="Mean Runtime (ms)", - coloraxis_colorbar={"title": "ms"}, - height=600, - margin={"t": 100, "b": 200}, -) - -fig.show() - -# plotly fig to html -fig.write_html(Path(__file__).parent / "benchmarks.html") diff --git a/tests/test_rhiza/test_book.py b/tests/test_rhiza/test_book.py deleted file mode 100644 index c9d560a..0000000 --- a/tests/test_rhiza/test_book.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for book-related Makefile targets and their resilience.""" - -import shutil -import subprocess - -import pytest - -MAKE = shutil.which("make") or "/usr/bin/make" - - -def test_no_book_folder(git_repo): - """Test that make targets fail gracefully when book folder is missing.""" - shutil.rmtree(git_repo / "book") - assert not (git_repo / "book").exists() - - for target in ["book", "docs", "marimushka"]: - # test resilience - result = subprocess.run([MAKE, target], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode != 0 - assert "no rule to make target" in result.stderr.lower() - - -def test_book_folder_but_no_mk(git_repo): - """Test behavior when book folder exists but book.mk is missing.""" - # ensure book folder exists but has no Makefile - shutil.rmtree(git_repo / "book") - # create an empty book folder. Make treats an existing directory as an β€œup-to-date” target. - (git_repo / "book").mkdir() - - # assert the book folder exists - assert (git_repo / "book").exists() - # assert the book.mk file does not exist - assert not (git_repo / "book" / "book.mk").exists() - # assert the git_repo / "book" folder is empty - assert not list((git_repo / "book").iterdir()) - - # test resilience - result = subprocess.run([MAKE, "book"], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode == 0 - assert "nothing to be done" in result.stdout.lower() - - for target in ["docs", "marimushka"]: - # test resilience - result = subprocess.run([MAKE, target], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode != 0 - assert "no rule to make target" in result.stderr.lower() - - -def test_book_folder(git_repo): - """Test that book.mk defines the expected phony targets.""" - # if file book/book.mk exists, make should run successfully - if not (git_repo / "book" / "book.mk").exists(): - pytest.skip("book.mk not found, skipping test") - - makefile = git_repo / "book" / "book.mk" - content = makefile.read_text() - - # get the list of phony targets from the Makefile - phony_targets = [line.strip() for line in content.splitlines() if line.startswith(".PHONY:")] - targets = set(phony_targets[0].split(":")[1].strip().split()) - assert {"book", "docs", "marimushka"} == targets, ( - f"Expected phony targets to include book, docs, and marimushka, got {targets}" - ) diff --git a/tests/test_rhiza/test_check_workflow_names.py b/tests/test_rhiza/test_check_workflow_names.py deleted file mode 100644 index 9651172..0000000 --- a/tests/test_rhiza/test_check_workflow_names.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Unit tests for .rhiza/scripts/check_workflow_names.py. - -Tests the workflow name prefix checker used in pre-commit hooks. -""" - -import sys -from pathlib import Path - -# Add .rhiza/scripts to path so we can import check_workflow_names -sys.path.insert(0, str(Path(__file__).parent.parent.parent / ".rhiza" / "scripts")) - -from check_workflow_names import check_file - - -class TestCheckFile: - """Tests for check_file function.""" - - def test_correct_prefix_returns_true(self, tmp_path): - """File with correct (RHIZA) prefix returns True.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text('name: "(RHIZA) My Workflow"\non: push\n') - - assert check_file(str(workflow)) is True - - def test_missing_prefix_updates_file(self, tmp_path): - """File without (RHIZA) prefix is updated and returns False.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text("name: My Workflow\non: push\n") - - result = check_file(str(workflow)) - - assert result is False - content = workflow.read_text() - assert "(RHIZA) My Workflow" in content - - def test_missing_name_field_returns_false(self, tmp_path, capsys): - """File without name field returns False with error message.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n") - - result = check_file(str(workflow)) - - assert result is False - captured = capsys.readouterr() - assert "missing 'name' field" in captured.out - - def test_invalid_yaml_returns_false(self, tmp_path, capsys): - """Invalid YAML returns False with error message.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text("name: test\n invalid: yaml: syntax:\n") - - result = check_file(str(workflow)) - - assert result is False - captured = capsys.readouterr() - assert "Error parsing YAML" in captured.out - - def test_empty_file_returns_true(self, tmp_path): - """Empty YAML file returns True (nothing to check).""" - workflow = tmp_path / "workflow.yml" - workflow.write_text("") - - assert check_file(str(workflow)) is True - - def test_preserves_other_content(self, tmp_path): - """Updating name prefix preserves other file content.""" - original = """name: CI Pipeline -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 -""" - workflow = tmp_path / "workflow.yml" - workflow.write_text(original) - - check_file(str(workflow)) - - content = workflow.read_text() - # Check name was updated - assert "(RHIZA) CI Pipeline" in content - # Check other content preserved - assert "branches: [main]" in content - assert "runs-on: ubuntu-latest" in content - assert "actions/checkout@v4" in content - - def test_quoted_name_with_prefix(self, tmp_path): - """File with quoted name containing prefix returns True.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text('name: "(RHIZA) Test"\non: push\n') - - assert check_file(str(workflow)) is True - - def test_unquoted_name_with_prefix(self, tmp_path): - """File with unquoted name containing prefix returns True.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text("name: (RHIZA) Test\non: push\n") - - assert check_file(str(workflow)) is True - - def test_name_with_special_characters(self, tmp_path): - """Name with special characters is handled correctly.""" - workflow = tmp_path / "workflow.yml" - workflow.write_text("name: Build & Deploy\non: push\n") - - check_file(str(workflow)) - - content = workflow.read_text() - assert "(RHIZA) Build & Deploy" in content diff --git a/tests/test_rhiza/test_makefile_api.py b/tests/test_rhiza/test_makefile_api.py deleted file mode 100644 index b329c70..0000000 --- a/tests/test_rhiza/test_makefile_api.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Tests for the new Makefile API structure (Wrapper + Makefile.rhiza).""" - -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -GIT = shutil.which("git") or "/usr/bin/git" -MAKE = shutil.which("make") or "/usr/bin/make" - -# Files required for the API test environment -REQUIRED_FILES = [ - "Makefile", - "pyproject.toml", - "README.md", # is needed to do uv sync, etc. -] - -# Folders to copy recursively -REQUIRED_FOLDERS = [ - ".rhiza", -] - -OPTIONAL_FOLDERS = [ - "tests", # for tests/tests.mk - "docker", # for docker/docker.mk, if referenced - "book", - "presentation", -] - - -@pytest.fixture -def setup_api_env(logger, root, tmp_path: Path): - """Set up the Makefile API test environment in a temp folder.""" - logger.debug("Setting up Makefile API test env in: %s", tmp_path) - - # Copy files - for filename in REQUIRED_FILES: - src = root / filename - if src.exists(): - shutil.copy(src, tmp_path / filename) - else: - pytest.fail(f"Required file {filename} not found in root") - - # Copy required directories - for folder in REQUIRED_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - else: - pytest.fail(f"Required folder {folder} not found in root") - - # Copy optional directories - for folder in OPTIONAL_FOLDERS: - src = root / folder - if src.exists(): - dest = tmp_path / folder - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - - # Create .rhiza/make.d and ensure no local.mk exists initially - (tmp_path / ".rhiza" / "make.d").mkdir(parents=True, exist_ok=True) - if (tmp_path / "local.mk").exists(): - (tmp_path / "local.mk").unlink() - - # Initialize git repo for rhiza tools (required for sync/validate) - subprocess.run([GIT, "init"], cwd=tmp_path, check=True, capture_output=True) - # Configure git user for commits if needed (some rhiza checks might need commits) - subprocess.run([GIT, "config", "user.email", "you@example.com"], cwd=tmp_path, check=True, capture_output=True) - subprocess.run([GIT, "config", "user.name", "Rhiza Test"], cwd=tmp_path, check=True, capture_output=True) - # Add origin remote to simulate being in the rhiza repo (triggers the skip logic in rhiza.mk) - subprocess.run( - [GIT, "remote", "add", "origin", "https://github.com/jebel-quant/rhiza.git"], - cwd=tmp_path, - check=True, - capture_output=True, - ) - - # Move to tmp dir - old_cwd = Path.cwd() - os.chdir(tmp_path) - try: - yield tmp_path - finally: - os.chdir(old_cwd) - - -def run_make(args: list[str] | None = None, dry_run: bool = True) -> subprocess.CompletedProcess: - """Run make in the current directory.""" - cmd = [MAKE] - if dry_run: - cmd.append("-n") - if args: - cmd.extend(args) - - # We use -s (silent) to minimize noise, but sometimes we want to see output - if dry_run: - # For dry-run, we often want to see the commands - pass - else: - cmd[:1] = [MAKE, "-s"] - - return subprocess.run(cmd, capture_output=True, text=True) - - -def test_api_delegation(setup_api_env): - """Test that 'make help' works and delegates to .rhiza/rhiza.mk.""" - result = run_make(["help"], dry_run=False) - assert result.returncode == 0 - # "Rhiza Workflows" is a section in .rhiza/rhiza.mk - assert "Rhiza Workflows" in result.stdout - - # "docker-build" is a target in Makefile.rhiza (docker/docker.mk) - # Only assert if docker folder exists in setup_api_env (it is optional) - if (setup_api_env / "docker").exists(): - assert "docker-build" in result.stdout - - -def test_minimal_setup_works(setup_api_env): - """Test that make works even if optional folders (tests, docker, etc.) are missing.""" - # Remove optional folders - for folder in OPTIONAL_FOLDERS: - p = setup_api_env / folder - if p.exists(): - shutil.rmtree(p) - - # Also remove files that might be copied if they were in the root? - # Just mainly folders. - - # Run make help - result = run_make(["help"], dry_run=False) - assert result.returncode == 0 - - # Check that core rhiza targets exist - assert "Rhiza Workflows" in result.stdout - assert "sync" in result.stdout - - # Check that optional targets do NOT exist - assert "docker-build" not in result.stdout - # "test" target (from tests/) should likely not be there OR be there but fail? - # Make check: Makefile.rhiza usually has `test:` delegating. - # If the include tests/tests.mk failed (silently), then `test` target might not be defined - # unless it's defined in Makefile.rhiza directly. - # In earlier steps I saw Makefile.rhiza includes tests/tests.mk. - # If tests.mk is gone, the target `test` (if defined ONLY in tests.mk) will be gone. - # If it is defined in Makefile.rhiza to check for file existence, it might be there. - # But usually splitting means the file owns the target. - - -def test_extension_mechanism(setup_api_env): - """Test that .rhiza/make.d/*.mk files are included.""" - ext_file = setup_api_env / ".rhiza" / "make.d" / "50-custom.mk" - ext_file.write_text(""" -.PHONY: custom-target -custom-target: - @echo "Running custom target" -""") - - # Verify the target is listed in help (if we were parsing help, but running it is better) - # Note: make -n might not show @echo commands if they are silent, - # but here we just want to see if make accepts the target. - - result = run_make(["custom-target"], dry_run=False) - assert result.returncode == 0 - assert "Running custom target" in result.stdout - - -def test_local_override(setup_api_env): - """Test that local.mk is included and can match targets.""" - local_file = setup_api_env / "local.mk" - local_file.write_text(""" -.PHONY: local-target -local-target: - @echo "Running local target" -""") - - result = run_make(["local-target"], dry_run=False) - assert result.returncode == 0 - assert "Running local target" in result.stdout - - -def test_local_override_pre_hook(setup_api_env): - """Test using local.mk to override a pre-hook.""" - local_file = setup_api_env / "local.mk" - # We override pre-sync to print a marker (using double-colon to match rhiza.mk) - local_file.write_text(""" -pre-sync:: - @echo "[[LOCAL_PRE_SYNC]]" -""") - - # Run sync in dry-run. - # Note: Makefile.rhiza defines pre-sync as empty rule (or with @:). - # Make warns if we redefine a target unless it's a double-colon rule or we are careful. - # But usually the last one loaded wins or they merge if double-colon. - # The current definition in Makefile.rhiza is `pre-sync: ; @echo ...` or similar. - # Wait, I defined it as `pre-sync: ; @:` (single colon). - # So redefining it in local.mk (which is included AFTER) might trigger a warning but should work. - - result = run_make(["sync"], dry_run=False) - # We might expect a warning about overriding commands for target `pre-sync` - # checking stdout/stderr for the marker - - assert "[[LOCAL_PRE_SYNC]]" in result.stdout - - -def test_hooks_flow(setup_api_env): - """Verify that sync runs pre-sync, the sync logic, and post-sync.""" - # We can't easily see execution order in dry run if commands are hidden. - # Let's inspect the output of make -n sync - - result = run_make(["sync"], dry_run=True) - assert result.returncode == 0 - - # The output should contain the command sequences. - # Since pre-sync is currently empty (@:) it might not show up in -n output unless we override it. - - -def test_hook_execution_order(setup_api_env): - """Define hooks and verify execution order.""" - # Create an extension that defines visible hooks (using double-colon) - (setup_api_env / ".rhiza" / "make.d" / "hooks.mk").write_text(""" -pre-sync:: - @echo "STARTING_SYNC" - -post-sync:: - @echo "FINISHED_SYNC" -""") - - result = run_make(["sync"], dry_run=False) - assert result.returncode == 0 - output = result.stdout - - # Check that markers are present - assert "STARTING_SYNC" in output - assert "FINISHED_SYNC" in output - - # Check order: STARTING_SYNC comes before FINISHED_SYNC - start_index = output.find("STARTING_SYNC") - finish_index = output.find("FINISHED_SYNC") - assert start_index < finish_index - - -def test_override_core_target(setup_api_env): - """Verify that a repo extension can override a core target (with warning).""" - # Override 'fmt' which is defined in Makefile.rhiza - (setup_api_env / ".rhiza" / "make.d" / "override.mk").write_text(""" -fmt: - @echo "CUSTOM_FMT" -""") - - result = run_make(["fmt"], dry_run=False) - assert result.returncode == 0 - # It should run the custom one because .rhiza/make.d is included later - assert "CUSTOM_FMT" in result.stdout - # It should NOT run the original one (which runs pre-commit) - # The original one has "@${UV_BIN} run pre-commit..." - # We can check that the output doesn't look like pre-commit output or just check presence of CUSTOM_FMT - - # We expect a warning on stderr about overriding - assert "warning: overriding" in result.stderr.lower() - assert "fmt" in result.stderr.lower() diff --git a/tests/test_rhiza/test_makefile_gh.py b/tests/test_rhiza/test_makefile_gh.py deleted file mode 100644 index 76bbac5..0000000 --- a/tests/test_rhiza/test_makefile_gh.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for the GitHub Makefile targets using safe dry-runs. - -These tests validate that the .github/github.mk targets are correctly exposed -and emit the expected commands without actually executing them. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -MAKE = shutil.which("make") or "/usr/bin/make" - -# We need to copy these files to the temp dir for the tests to work -REQUIRED_FILES = [ - ".github/github.mk", -] - - -@pytest.fixture(autouse=True) -def setup_gh_makefile(logger, root, tmp_path: Path): - """Copy the Makefile and GitHub Makefile into a temp directory.""" - logger.debug("Setting up temporary GitHub Makefile test dir: %s", tmp_path) - - # Copy the main Makefile - if (root / "Makefile").exists(): - shutil.copy(root / "Makefile", tmp_path / "Makefile") - - # Copy core Rhiza Makefiles - if (root / ".rhiza" / "rhiza.mk").exists(): - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / "rhiza.mk", tmp_path / ".rhiza" / "rhiza.mk") - - if (root / ".rhiza" / ".env").exists(): - (tmp_path / ".rhiza").mkdir(exist_ok=True) - shutil.copy(root / ".rhiza" / ".env", tmp_path / ".rhiza" / ".env") - - # Copy required split Makefiles - for rel_path in REQUIRED_FILES: - source_path = root / rel_path - if source_path.exists(): - dest_path = tmp_path / rel_path - dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(source_path, dest_path) - logger.debug("Copied %s to %s", source_path, dest_path) - else: - pytest.skip(f"Required file {rel_path} not found") - - # Move into tmp directory - old_cwd = Path.cwd() - os.chdir(tmp_path) - try: - yield - finally: - os.chdir(old_cwd) - - -def run_make( - logger, args: list[str] | None = None, check: bool = True, dry_run: bool = True -) -> subprocess.CompletedProcess: - """Run `make` with optional arguments.""" - cmd = [MAKE] - if args: - cmd.extend(args) - flags = "-sn" if dry_run else "-s" - cmd.insert(1, flags) - - logger.info("Running command: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True) - - if check and result.returncode != 0: - msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" - raise AssertionError(msg) - return result - - -def test_gh_targets_exist(logger): - """Verify that GitHub targets are listed in help.""" - result = run_make(logger, ["help"], dry_run=False) - output = result.stdout - - expected_targets = ["gh-install", "view-prs", "view-issues", "failed-workflows", "whoami"] - - for target in expected_targets: - assert target in output, f"Target {target} not found in help output" - - -def test_gh_install_dry_run(logger): - """Verify gh-install target dry-run.""" - result = run_make(logger, ["gh-install"]) - # In dry-run, we expect to see the shell commands that would be executed. - # Since the recipe uses @if, make -n might verify the syntax or show the command if not silenced. - # However, with -s (silent), make -n might not show much for @ commands unless they are echoed. - # But we mainly want to ensure it runs without error. - assert result.returncode == 0 - - -def test_view_prs_dry_run(logger): - """Verify view-prs target dry-run.""" - result = run_make(logger, ["view-prs"]) - assert result.returncode == 0 - - -def test_view_issues_dry_run(logger): - """Verify view-issues target dry-run.""" - result = run_make(logger, ["view-issues"]) - assert result.returncode == 0 - - -def test_failed_workflows_dry_run(logger): - """Verify failed-workflows target dry-run.""" - result = run_make(logger, ["failed-workflows"]) - assert result.returncode == 0 - - -def test_whoami_dry_run(logger): - """Verify whoami target dry-run.""" - result = run_make(logger, ["whoami"]) - assert result.returncode == 0 diff --git a/tests/test_rhiza/test_release_script.py b/tests/test_rhiza/test_release_script.py deleted file mode 100644 index 090add2..0000000 --- a/tests/test_rhiza/test_release_script.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Tests for the release.sh script using a sandboxed git environment. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -The script exposes the `release` command (creates and pushes tags). -Tests call the script from a temporary clone and use a small mock `uv` -to avoid external dependencies. -""" - -import shutil -import subprocess - -# Get absolute paths for executables to avoid S607 warnings from CodeFactor/Bandit -SHELL = shutil.which("sh") or "/bin/sh" -GIT = shutil.which("git") or "/usr/bin/git" - - -def test_release_creates_tag(git_repo): - """Release creates a tag.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Run release - # 1. Prompts to create tag -> y - # 2. Prompts to push tag -> y - result = subprocess.run([SHELL, str(script)], cwd=git_repo, input="y\ny\n", capture_output=True, text=True) - assert result.returncode == 0 - assert "Tag 'v0.1.0' created locally" in result.stdout - - # Verify the tag exists - verify_result = subprocess.run( - [GIT, "tag", "-l", "v0.1.0"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert "v0.1.0" in verify_result.stdout - - -def test_release_fails_if_local_tag_exists(git_repo): - """If the target tag already exists locally, release should warn and abort if user says no.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Create a local tag that matches current version - subprocess.run([GIT, "tag", "v0.1.0"], cwd=git_repo, check=True) - - # Input 'n' to abort - result = subprocess.run([SHELL, str(script)], cwd=git_repo, input="n\n", capture_output=True, text=True) - - assert result.returncode == 0 - assert "Tag 'v0.1.0' already exists locally" in result.stdout - assert "Aborted by user" in result.stdout - - -def test_release_fails_if_remote_tag_exists(git_repo): - """Release fails if tag exists on remote.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Create tag locally and push to remote - subprocess.run([GIT, "tag", "v0.1.0"], cwd=git_repo, check=True) - subprocess.run([GIT, "push", "origin", "v0.1.0"], cwd=git_repo, check=True) - - result = subprocess.run([SHELL, str(script)], cwd=git_repo, input="y\n", capture_output=True, text=True) - - assert result.returncode == 1 - assert "already exists on remote" in result.stdout - - -def test_release_uncommitted_changes_failure(git_repo): - """Release fails if there are uncommitted changes (even pyproject.toml).""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Modify pyproject.toml (which is allowed in bump but NOT in release) - with open(git_repo / "pyproject.toml", "a") as f: - f.write("\n# comment") - - result = subprocess.run([SHELL, str(script)], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode == 1 - assert "You have uncommitted changes" in result.stdout - - -def test_release_pushes_if_ahead_of_remote(git_repo): - """Release prompts to push if local branch is ahead of remote.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Create a commit locally that isn't on remote - tracked_file = git_repo / "file.txt" - tracked_file.touch() - subprocess.run([GIT, "add", "file.txt"], cwd=git_repo, check=True) - subprocess.run([GIT, "commit", "-m", "Local commit"], cwd=git_repo, check=True) - - # Run release - # 1. Prompts to push -> y - # 2. Prompts to create tag -> y - # 3. Prompts to push tag -> y - result = subprocess.run([SHELL, str(script)], cwd=git_repo, input="y\ny\ny\n", capture_output=True, text=True) - - assert result.returncode == 0 - assert "Your branch is ahead" in result.stdout - assert "Unpushed commits:" in result.stdout - assert "Local commit" in result.stdout - assert "Push changes to remote before releasing?" in result.stdout - - -def test_release_fails_if_behind_remote(git_repo): - """Release fails if local branch is behind remote.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Create a commit on remote that isn't local - # We need to clone another repo to push to remote - other_clone = git_repo.parent / "other_clone" - subprocess.run([GIT, "clone", str(git_repo.parent / "remote.git"), str(other_clone)], check=True) - - # Configure git user for other_clone (needed in CI) - subprocess.run([GIT, "config", "user.email", "test@example.com"], cwd=other_clone, check=True) - subprocess.run([GIT, "config", "user.name", "Test User"], cwd=other_clone, check=True) - - # Commit and push from other clone - with open(other_clone / "other.txt", "w") as f: - f.write("content") - subprocess.run([GIT, "add", "other.txt"], cwd=other_clone, check=True) - subprocess.run([GIT, "commit", "-m", "Remote commit"], cwd=other_clone, check=True) - subprocess.run([GIT, "push"], cwd=other_clone, check=True) - - # Run release (it will fetch and see it's behind) - result = subprocess.run([SHELL, str(script)], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode == 1 - assert "Your branch is behind" in result.stdout - - -def test_dry_run_flag_recognized(git_repo): - """Test that --dry-run flag is recognized and script executes.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Run with --dry-run flag - result = subprocess.run([SHELL, str(script), "--dry-run"], cwd=git_repo, capture_output=True, text=True) - - # Should exit successfully - assert result.returncode == 0 - # Should show dry-run messages - assert "[DRY-RUN]" in result.stdout - - -def test_dry_run_no_git_operations(git_repo): - """Test that no actual git operations are performed in dry-run mode.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Get initial git state - tags_before = subprocess.run( - [GIT, "tag", "-l"], - cwd=git_repo, - capture_output=True, - text=True, - ).stdout - - # Run with --dry-run - result = subprocess.run([SHELL, str(script), "--dry-run"], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode == 0 - - # Verify no tags were created - tags_after = subprocess.run( - [GIT, "tag", "-l"], - cwd=git_repo, - capture_output=True, - text=True, - ).stdout - assert tags_before == tags_after - - # Verify tag doesn't exist using consistent pattern with other tests - tag_check = subprocess.run( - [GIT, "tag", "-l", "v0.1.0"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert "v0.1.0" not in tag_check.stdout - - -def test_dry_run_shows_appropriate_messages(git_repo): - """Test that appropriate DRY-RUN messages are displayed.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - result = subprocess.run([SHELL, str(script), "--dry-run"], cwd=git_repo, capture_output=True, text=True) - - assert result.returncode == 0 - - # Check for key dry-run messages indicating simulation mode - assert "[DRY-RUN]" in result.stdout - assert "Would prompt" in result.stdout - assert "Would run: git tag" in result.stdout - assert "would be created locally" in result.stdout - assert "Would run: git push origin refs/tags/v0.1.0" in result.stdout - assert "would be pushed to remote" in result.stdout - assert "would trigger the release workflow" in result.stdout - - -def test_dry_run_exits_successfully_without_creating_tags(git_repo): - """Test that script exits successfully without creating or pushing tags in dry-run mode.""" - script = git_repo / ".rhiza" / "scripts" / "release.sh" - - # Run with --dry-run - result = subprocess.run([SHELL, str(script), "--dry-run"], cwd=git_repo, capture_output=True, text=True) - - # Should exit successfully - assert result.returncode == 0 - - # Verify no local tag was created - local_tag_check = subprocess.run( - [GIT, "tag", "-l", "v0.1.0"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert "v0.1.0" not in local_tag_check.stdout - - # Verify no remote tag was pushed - remote_tag_check = subprocess.run( - [GIT, "ls-remote", "--tags", "origin", "v0.1.0"], - cwd=git_repo, - capture_output=True, - text=True, - ) - assert "v0.1.0" not in remote_tag_check.stdout - - # Verify output indicates dry-run mode with specific indicators - assert "[DRY-RUN]" in result.stdout - assert "Would run:" in result.stdout diff --git a/tests/test_rhiza/test_structure.py b/tests/test_rhiza/test_structure.py deleted file mode 100644 index f48ce66..0000000 --- a/tests/test_rhiza/test_structure.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for the root pytest fixture that yields the repository root Path. - -This file and its associated tests flow down via a SYNC action from the jebel-quant/rhiza repository -(https://github.com/jebel-quant/rhiza). - -This module ensures the fixture resolves to the true project root and that -expected files/directories exist, enabling other tests to locate resources -reliably. -""" - -import warnings -from pathlib import Path - - -class TestRootFixture: - """Tests for the root fixture that provides repository root path.""" - - def test_root_returns_pathlib_path(self, root): - """Root fixture should return a pathlib.Path object.""" - assert isinstance(root, Path) - - def test_root_is_absolute_path(self, root): - """Root fixture should return an absolute path.""" - assert root.is_absolute() - - def test_root_resolves_correctly_from_nested_location(self, root): - """Root should correctly resolve to repository root from tests/test_config_templates/.""" - conftest_path = root / "tests" / "test_rhiza" / "conftest.py" - assert conftest_path.exists() - - def test_root_contains_expected_directories(self, root): - """Root should contain all expected project directories.""" - expected_dirs = [".rhiza", "src", "tests", "book"] - for dirname in expected_dirs: - if not (root / dirname).exists(): - warnings.warn(f"Expected directory {dirname} not found", stacklevel=2) - - def test_root_contains_expected_files(self, root): - """Root should contain all expected configuration files.""" - expected_files = [ - "pyproject.toml", - "README.md", - "Makefile", - "ruff.toml", - ".gitignore", - ".editorconfig", - ] - for filename in expected_files: - if not (root / filename).exists(): - warnings.warn(f"Expected file {filename} not found", stacklevel=2) - - def test_root_can_locate_github_scripts(self, root): - """Root should allow locating GitHub scripts.""" - scripts_dir = root / ".rhiza" / "scripts" - if not scripts_dir.exists(): - warnings.warn("GitHub scripts directory not found", stacklevel=2) - elif not (scripts_dir / "release.sh").exists(): - warnings.warn("Expected script release.sh not found", stacklevel=2) diff --git a/tests/test_rhiza/test_version_matrix.py b/tests/test_rhiza/test_version_matrix.py deleted file mode 100644 index 6c7bbdc..0000000 --- a/tests/test_rhiza/test_version_matrix.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Tests for version_matrix.py utility. - -Tests cover version parsing, specifier validation, and edge cases -for malformed inputs. -""" - -import sys -from pathlib import Path - -import pytest - -# Add the utils directory to the path for imports -sys.path.insert(0, str(Path(__file__).parent.parent.parent / ".rhiza" / "utils")) - -from version_matrix import ( - CANDIDATES, - PyProjectError, - RhizaError, - VersionSpecifierError, - parse_version, - satisfies, - supported_versions, -) - - -class TestExceptionHierarchy: - """Test custom exception class hierarchy.""" - - def test_version_specifier_error_inherits_from_rhiza_error(self): - """VersionSpecifierError should inherit from RhizaError.""" - assert issubclass(VersionSpecifierError, RhizaError) - - def test_pyproject_error_inherits_from_rhiza_error(self): - """PyProjectError should inherit from RhizaError.""" - assert issubclass(PyProjectError, RhizaError) - - def test_rhiza_error_inherits_from_exception(self): - """RhizaError should inherit from Exception.""" - assert issubclass(RhizaError, Exception) - - def test_can_catch_all_rhiza_errors(self): - """All custom exceptions should be catchable as RhizaError.""" - with pytest.raises(RhizaError): - raise VersionSpecifierError("test") - - with pytest.raises(RhizaError): - raise PyProjectError("test") - - -class TestParseVersion: - """Tests for parse_version function.""" - - def test_simple_version(self): - """Parse simple version strings.""" - assert parse_version("3.11") == (3, 11) - assert parse_version("3.12") == (3, 12) - assert parse_version("3.14") == (3, 14) - - def test_three_part_version(self): - """Parse three-part version strings.""" - assert parse_version("3.11.0") == (3, 11, 0) - assert parse_version("3.12.5") == (3, 12, 5) - - def test_version_with_rc_suffix(self): - """Parse version with release candidate suffix.""" - assert parse_version("3.11.0rc1") == (3, 11, 0) - assert parse_version("3.14.0a1") == (3, 14, 0) - assert parse_version("3.13.0b2") == (3, 13, 0) - - def test_single_component_version(self): - """Parse single component version.""" - assert parse_version("3") == (3,) - - def test_many_component_version(self): - """Parse version with many components.""" - assert parse_version("1.2.3.4.5") == (1, 2, 3, 4, 5) - - def test_malformed_version_no_numeric_prefix(self): - """Raise VersionSpecifierError for non-numeric component.""" - with pytest.raises(VersionSpecifierError) as exc_info: - parse_version("abc.11") - assert "abc" in str(exc_info.value) - assert "expected a numeric prefix" in str(exc_info.value) - - def test_malformed_version_empty_component(self): - """Raise VersionSpecifierError for empty component.""" - with pytest.raises(VersionSpecifierError) as exc_info: - parse_version("3..11") - assert "expected a numeric prefix" in str(exc_info.value) - - def test_malformed_version_letter_only(self): - """Raise VersionSpecifierError for letter-only version.""" - with pytest.raises(VersionSpecifierError) as exc_info: - parse_version("x.y.z") - assert "Invalid version component" in str(exc_info.value) - - def test_empty_string(self): - """Raise VersionSpecifierError for empty string.""" - with pytest.raises(VersionSpecifierError): - parse_version("") - - def test_whitespace_version(self): - """Raise VersionSpecifierError for whitespace-only version.""" - with pytest.raises(VersionSpecifierError): - parse_version(" ") - - -class TestSatisfies: - """Tests for satisfies function.""" - - def test_greater_than_or_equal(self): - """Test >= operator.""" - assert satisfies("3.11", ">=3.11") is True - assert satisfies("3.12", ">=3.11") is True - assert satisfies("3.10", ">=3.11") is False - - def test_less_than_or_equal(self): - """Test <= operator.""" - assert satisfies("3.11", "<=3.11") is True - assert satisfies("3.10", "<=3.11") is True - assert satisfies("3.12", "<=3.11") is False - - def test_greater_than(self): - """Test > operator.""" - assert satisfies("3.12", ">3.11") is True - assert satisfies("3.11", ">3.11") is False - assert satisfies("3.10", ">3.11") is False - - def test_less_than(self): - """Test < operator.""" - assert satisfies("3.10", "<3.11") is True - assert satisfies("3.11", "<3.11") is False - assert satisfies("3.12", "<3.11") is False - - def test_equal(self): - """Test == operator.""" - assert satisfies("3.11", "==3.11") is True - assert satisfies("3.12", "==3.11") is False - - def test_not_equal(self): - """Test != operator.""" - assert satisfies("3.12", "!=3.11") is True - assert satisfies("3.11", "!=3.11") is False - - def test_implicit_equality(self): - """Test version without operator implies equality.""" - assert satisfies("3.11", "3.11") is True - assert satisfies("3.12", "3.11") is False - - def test_compound_specifier(self): - """Test comma-separated specifiers.""" - assert satisfies("3.11", ">=3.11,<3.14") is True - assert satisfies("3.12", ">=3.11,<3.14") is True - assert satisfies("3.14", ">=3.11,<3.14") is False - assert satisfies("3.10", ">=3.11,<3.14") is False - - def test_specifier_with_whitespace(self): - """Test specifiers with whitespace.""" - assert satisfies("3.11", ">= 3.11") is True - assert satisfies("3.11", ">=3.11, <3.14") is True - - def test_invalid_specifier_format(self): - """Raise VersionSpecifierError for invalid specifier.""" - with pytest.raises(VersionSpecifierError) as exc_info: - satisfies("3.11", "~=3.11") - assert "Invalid specifier" in str(exc_info.value) - assert "~=3.11" in str(exc_info.value) - - def test_invalid_specifier_garbage(self): - """Raise VersionSpecifierError for garbage input.""" - with pytest.raises(VersionSpecifierError) as exc_info: - satisfies("3.11", "foobar") - assert "Invalid specifier" in str(exc_info.value) - - def test_invalid_specifier_operator_only(self): - """Raise VersionSpecifierError for operator without version.""" - with pytest.raises(VersionSpecifierError) as exc_info: - satisfies("3.11", ">=") - assert "Invalid specifier" in str(exc_info.value) - - -class TestSupportedVersions: - """Tests for supported_versions function.""" - - def test_returns_list_of_versions(self): - """supported_versions returns a list of version strings.""" - versions = supported_versions() - assert isinstance(versions, list) - assert all(isinstance(v, str) for v in versions) - - def test_versions_are_subset_of_candidates(self): - """Returned versions should be from the CANDIDATES list.""" - versions = supported_versions() - assert all(v in CANDIDATES for v in versions) - - def test_missing_requires_python(self, tmp_path, monkeypatch): - """Raise PyProjectError when requires-python is missing.""" - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "test"\n') - - # Patch PYPROJECT to point to our temp file - monkeypatch.setattr("version_matrix.PYPROJECT", pyproject) - - with pytest.raises(PyProjectError) as exc_info: - supported_versions() - assert "missing 'project.requires-python'" in str(exc_info.value) - - def test_no_matching_versions(self, tmp_path, monkeypatch): - """Raise PyProjectError when no candidates match specifier.""" - pyproject = tmp_path / "pyproject.toml" - # Require Python 2.7 which no candidate satisfies - pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=2.7,<3.0"\n') - - monkeypatch.setattr("version_matrix.PYPROJECT", pyproject) - - with pytest.raises(PyProjectError) as exc_info: - supported_versions() - assert "no supported Python versions match" in str(exc_info.value) - # Error message should include evaluated candidates - assert "Evaluated candidates" in str(exc_info.value) - assert "3.11" in str(exc_info.value) - - def test_filters_versions_correctly(self, tmp_path, monkeypatch): - """Correctly filter versions based on specifier.""" - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "test"\nrequires-python = ">=3.12"\n') - - monkeypatch.setattr("version_matrix.PYPROJECT", pyproject) - - versions = supported_versions() - assert "3.11" not in versions - assert "3.12" in versions - assert "3.13" in versions - assert "3.14" in versions - - -class TestEdgeCases: - """Additional edge case tests.""" - - def test_version_comparison_tuple_length_mismatch(self): - """Version tuples of different lengths use Python tuple comparison. - - Note: Python tuple comparison treats (3, 11) < (3, 11, 0) because - the shorter tuple is exhausted first. This is intentional behavior - of the simple implementation. - """ - # (3, 11) < (3, 11, 0) in Python tuple comparison - assert satisfies("3.11", ">=3.11.0") is False - # (3, 11, 0) >= (3, 11) is True - assert satisfies("3.11.0", ">=3.11") is True - - def test_leading_zeros_in_version(self): - """Leading zeros are stripped when parsing.""" - assert parse_version("03.011") == (3, 11) - - def test_very_large_version_numbers(self): - """Handle large version numbers.""" - assert parse_version("999.999.999") == (999, 999, 999) - assert satisfies("999.999", ">=3.11") is True - - def test_specifier_with_multiple_commas(self): - """Handle multiple constraints.""" - assert satisfies("3.12", ">=3.11,<3.14,!=3.13") is True - assert satisfies("3.13", ">=3.11,<3.14,!=3.13") is False