Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/pytest-and-coverage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: CI - Pytest & Coverage

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

# read repo and publish checks
permissions:
contents: read
checks: write

strategy:
matrix:
python-version: ["3.10", "3.11"]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: |
pyproject.toml
setup.cfg
requirements*.txt

- name: Install project with dev dependencies
run: |
python -m pip install --upgrade pip
pip install .[dev]

- name: Run tests with coverage + JUnit report
run: |
pytest \
--junitxml=pytest-results.xml \
--cov-report=xml \
--cov-report=html

# Upload artifacts only for Python 3.11 to avoid duplicates
- name: Upload coverage reports (only on 3.11)
if: matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: |
coverage.xml
htmlcov/

- name: Upload pytest results (only on 3.11)
if: matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
with:
name: pytest-results
path: pytest-results.xml

- name: Publish test results (only on 3.11)
if: matrix.python-version == '3.11' && github.event_name == 'pull_request'
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: pytest-results.xml
pull_request_build: commit
comment_mode: off
check_run: true
job_summary: true

install-test:
needs: test
runs-on: ubuntu-latest

strategy:
matrix:
python-version: ["3.10", "3.11"]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Build wheel & sdist
run: |
python -m pip install --upgrade pip build
python -m build
ls -l dist

- name: Install from wheel
run: |
python -m venv venv
source venv/bin/activate
pip install dist/*.whl

- name: Run CLI smoke tests
run: |
source venv/bin/activate
prich --help
prich init -g
prich init

publish-badges:
needs: [test, install-test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'

# for gh-pages publishing
permissions:
contents: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: coverage-reports
path: .

- name: Download test results
uses: actions/download-artifact@v4
with:
name: pytest-results
path: .

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: |
pyproject.toml
setup.cfg
requirements*.txt

# Badge generation from test/coverage results
- name: Install badge tools
run: |
python -m pip install --upgrade pip
pip install coverage-badge anybadge junitparser

- name: Generate coverage badge
run: coverage-badge -o coverage.svg -f

- name: Generate tests badge
run: |
python - <<'EOF'
from junitparser import JUnitXml
import anybadge

xml = JUnitXml.fromfile("pytest-results.xml")
failures = xml.failures + xml.errors
tests = xml.tests

if failures == 0:
anybadge.Badge("tests", f"{tests} passed", default_color="green").write_badge("tests.svg", overwrite=True)
else:
anybadge.Badge("tests", f"{failures} failed", default_color="red").write_badge("tests.svg", overwrite=True)
EOF

- name: Publish badges to gh-pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
publish_dir: .
destination_dir: badges
keep_files: true
commit_message: "Update badges [skip ci]"
enable_jekyll: false
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
██║ ██║ ██║██║╚██████╗██║ ██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝
```
![Tests](https://oleks-dev.github.io/prich/badges/tests.svg)
![Coverage](https://oleks-dev.github.io/prich/badges/coverage.svg)

**prich** is a lightweight CLI tool for creating, managing, executing, and sharing reusable LLM prompt pipelines for *any* use case-development, data analysis, content generation, and more. With Jinja2 templating, flexible scripting (in any language), and shareable template packages, **prich** shines for teams collaborating on standardized LLM workflows. Share templates via files, git, or cloud storage, and streamline tasks like code review, git diff analysis, or CSV data insights.

Expand Down Expand Up @@ -36,12 +38,14 @@
> **Supported LLMs**: Ollama API, OpenAI API, MLX LM, STDIN (different cli tools like q chat, mlx_lm.generate, etc.)

## Quick Start
> prich requires **python 3.10+**

1. Install `prich` tool `pipx install git+https://github.com/oleks-dev/prich` (see `Installation`)
2. Initialize config (use global for the start): `prich init --global`
3. Create simple example template (`prich create <template_id> --global`): `prich create my-template -g`
4. Run template (`prich run <template_id>`): `prich run my-template`
> Note: By default prich will set up and use echo provider which just outputs the rendered template
> To use it with LLM see `Configure .prich/config.yaml` and follow it to add your LLM provider
> To use it with LLM see `Configure .prich/config.yaml` and follow it to add your LLM provider

Optionally you can also run for the start:
* Run template with help flag (`prich run <template_id> --help`): `prich run my-template --help`
Expand Down
3 changes: 2 additions & 1 deletion docs/how-to/install.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Install & Update
### **Install prich**

> Note: prich requires **python 3.10+**

Until prich is published on PyPI, you can install it directly from GitHub.

**Recommended: Use `pipx`**
Expand Down
5 changes: 2 additions & 3 deletions prich/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,12 @@ def run_template(template_id, **kwargs):
idx = 0
for validate in step.validate_:
idx += 1
validated = True
if isinstance(step, (PythonStep, CommandStep)) and (validate.match_exit_code is not None or validate.not_match_exit_code is not None):
validated = validate_step_exit_code(validate, step_return_exit_code, variables)
if validated:
validated = validate_step_output(validate, step_output, variables)
elif validate.match_exit_code is not None or validate.not_match_exit_code is not None:
raise click.ClickException("Step validation using 'match_exitcode' and/or 'not_match_exitcode' supported only in 'python' and 'command' step types.")
else:
if validated:
validated = validate_step_output(validate, step_output, variables)
if not validated:
action = validate.on_fail
Expand Down
5 changes: 2 additions & 3 deletions prich/core/template_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Dict

import click

from zoneinfo import ZoneInfo
from prich.models.template import LLMStep
from prich.models.config import ConfigModel
from prich.core.state import _jinja_env
Expand Down Expand Up @@ -62,7 +62,6 @@ def include_file_with_line_numbers(filename):

def render_template_text(template_text: str, variables: dict, jinja_env_name: str = "default"):
import datetime
import os
import getpass
import platform

Expand All @@ -71,7 +70,7 @@ def render_template_text(template_text: str, variables: dict, jinja_env_name: st

builtin = {
"now": datetime.datetime.now(),
"now_utc": datetime.datetime.now(datetime.UTC),
"now_utc": datetime.datetime.now(ZoneInfo("UTC")),
"today": datetime.datetime.today().date(),
"home": get_home_dir(),
"cwd": get_cwd_dir(),
Expand Down
4 changes: 2 additions & 2 deletions prich/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ def is_only_final_output() -> bool:
def is_piped() -> bool:
""" Check if prich executed with a piped command (should work only when not executed from pytest) """
# TODO: revisit, we need to allow executions from templates for example
# return not console.is_terminal and not os.getenv("PYTEST_CURRENT_TEST")
return False
return not console.is_terminal and not os.getenv("PYTEST_CURRENT_TEST")
# return False

def console_print(message: str = "", end: str = "\n", markup = None, flush: bool = None):
""" Print to console wrapper """
Expand Down
29 changes: 13 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ version = "0.1.0"
description = "A CLI tool for creating and executing reusable LLM prompts with preprocessing and team sharing"
readme = "README.md"
authors = [{name = "Oleksandr Mikriukov", email = "oleks@oleksm.dev"}]
license = {text = "MIT"}
requires-python = ">=3.8"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = [
"click>=8.2.1",
"jinja2>=3.1.6",
"pyyaml>=6.0.2",
"rich>=13.7.0",
"pydantic>=2.11.5",
"requests>=2.32.3",
"python_dotenv>=1.1.1",
"click>=8.1,<9.0",
"jinja2>=3.1,<4.0",
"pyyaml>=6.0,<7.0",
"rich>=13.7,<14.0",
"pydantic>=2.11,<3.0",
"requests>=2.32,<3.0",
"python-dotenv>=1.1,<2.0",
]
keywords = ["llm", "prompt-engineering", "pipeline", "cli", "team-collaboration"]
classifiers = [
Expand All @@ -26,9 +26,9 @@ classifiers = [
"Operating System :: OS Independent",
]
[project.optional-dependencies]
openai = ["openai>=1.0.0"]
mlx = ["mlx_lm>=0.24.1"]
dev = ["pytest", "coverage", "pytest-cov", "twine", "build", "faker"]
openai = ["openai>=1.0.0,<2.0.0"]
mlx = ["mlx_lm>=0.24.1,<1.0.0"]
dev = ["openai", "mlx_lm", "pytest", "coverage", "pytest-cov", "twine", "build", "faker"]

[project.urls]
Homepage = "https://github.com/oleks-dev/prich"
Expand All @@ -38,9 +38,6 @@ Documentation = "https://github.com/oleks-dev/prich#readme"
[project.scripts]
prich = "prich.cli.main:cli"

[project.entry-points."console_scripts"]
prich = "prich.cli.main:cli"

[tool.setuptools]
include-package-data = false

Expand All @@ -58,5 +55,5 @@ show_missing = true
skip_covered = true

[tool.pytest.ini_options]
#addopts = "-ra --cov=prich --cov-report=term-missing"
addopts = "-ra --cov=prich --cov-report=term-missing"
testpaths = ["tests"]
36 changes: 7 additions & 29 deletions tests/fixtures/paths.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import os
import shutil
from dataclasses import dataclass
import pytest
from pathlib import Path

from yaml import SafeLoader

from prich.models.config import ConfigModel

from prich.models.file_scope import FileScope
from prich.core.state import _loaded_templates, _loaded_config, _loaded_config_paths
from tests.fixtures.config import CONFIG_YAML

import pytest

from tests.utils.paths import MainFolder, PrichFolder

@pytest.fixture
def mock_paths(tmp_path, monkeypatch):
Expand All @@ -22,41 +17,21 @@ def mock_paths(tmp_path, monkeypatch):
_loaded_config_paths = []
config = ConfigModel(**yaml.load(CONFIG_YAML, SafeLoader))
home_dir = tmp_path / "home"
print(f"Setup home: {home_dir}")
cwd_dir = tmp_path / "local"
print(f"Setup cwd: {cwd_dir}")
global_prich_dir = home_dir / ".prich"
local_prich_dir = cwd_dir / ".prich"
global_prich_templates_dir = global_prich_dir / "templates"
local_prich_templates_dir = local_prich_dir / "templates"
home_dir.mkdir(exist_ok=True)
cwd_dir.mkdir(exist_ok=True)
global_prich_dir.mkdir(exist_ok=True)
local_prich_dir.mkdir(exist_ok=True)
global_prich_templates_dir.mkdir(exist_ok=True)
local_prich_templates_dir.mkdir(exist_ok=True)

monkeypatch.setattr(Path, "home", lambda: home_dir)
monkeypatch.setattr(Path, "cwd", lambda: cwd_dir)
os.environ['HOME'] = str(home_dir)
print(f"Setup env home: {os.environ['HOME']}")
os.environ['PWD'] = str(cwd_dir)
print(f"Setup env pwd: {os.environ['PWD']}")
config.save(FileScope.GLOBAL)
config.save(FileScope.LOCAL)

@dataclass
class PrichFolder:
global_dir: Path
local_dir: Path
global_templates: Path
local_templates: Path

@dataclass
class MainFolder:
home_dir: Path
cwd_dir: Path
prich: PrichFolder

yield MainFolder(
home_dir=home_dir,
cwd_dir=cwd_dir,
Expand All @@ -67,5 +42,8 @@ class MainFolder:
local_templates=local_prich_templates_dir, #cwd_dir / ".prich" / "templates"
)
)
if str(tmp_path).startswith("/private/var/folders/"):
if "/pytest-" in str(tmp_path):
shutil.rmtree(tmp_path)
else:
raise RuntimeError(f"Failed to check folder before removing! {tmp_path}")

Loading