diff --git a/.github/workflows/pytest-and-coverage.yaml b/.github/workflows/pytest-and-coverage.yaml new file mode 100644 index 0000000..31a6ed1 --- /dev/null +++ b/.github/workflows/pytest-and-coverage.yaml @@ -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 diff --git a/README.md b/README.md index 70ab9af..a3662f6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 --global`): `prich create my-template -g` 4. Run template (`prich run `): `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 --help`): `prich run my-template --help` diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 2c95fea..e1cfaf9 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -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`** diff --git a/prich/core/engine.py b/prich/core/engine.py index 1856c4f..9f36ab5 100644 --- a/prich/core/engine.py +++ b/prich/core/engine.py @@ -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 diff --git a/prich/core/template_utils.py b/prich/core/template_utils.py index 06b8956..b6152c6 100644 --- a/prich/core/template_utils.py +++ b/prich/core/template_utils.py @@ -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 @@ -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 @@ -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(), diff --git a/prich/core/utils.py b/prich/core/utils.py index 820a21c..9280d2f 100644 --- a/prich/core/utils.py +++ b/prich/core/utils.py @@ -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 """ diff --git a/pyproject.toml b/pyproject.toml index e4c622a..995da80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ @@ -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" @@ -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 @@ -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"] diff --git a/tests/fixtures/paths.py b/tests/fixtures/paths.py index d1f8826..2a785d7 100644 --- a/tests/fixtures/paths.py +++ b/tests/fixtures/paths.py @@ -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): @@ -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, @@ -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}") + diff --git a/tests/test_dcg_n_main.py b/tests/test_dcg_n_main.py index 750a0ec..e65bb41 100644 --- a/tests/test_dcg_n_main.py +++ b/tests/test_dcg_n_main.py @@ -1,11 +1,25 @@ +from pathlib import Path + import pytest from click import Context, Command from prich.cli.dynamic_command_group import DynamicCommandGroup +from tests.fixtures.paths import mock_paths +from tests.fixtures.config import basic_config - -def test_dcg(): +def test_dcg(mock_paths, monkeypatch, basic_config): """Just to call the methods""" + global_dir = mock_paths.home_dir + local_dir = mock_paths.cwd_dir + + monkeypatch.setattr(Path, "home", lambda: global_dir) + monkeypatch.setattr(Path, "cwd", lambda: local_dir) + + local_config = basic_config.model_copy(deep=True) + global_config = basic_config.model_copy(deep=True) + local_config.save("local") + global_config.save("global") + cmd = Command(None) ctx = Context(cmd) dcg = DynamicCommandGroup(None) diff --git a/tests/test_init.py b/tests/test_init.py index c53da29..692a144 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -36,8 +36,10 @@ def test_init_cmd(mock_paths, monkeypatch, case): else: assert False, "Wrong init_folder param specified" - if str(prich_dir).startswith("/private/var/folders/") and prich_dir.exists(): + if "/pytest-" in str(prich_dir) and prich_dir.exists(): shutil.rmtree(prich_dir) + else: + raise RuntimeError(f"Failed to check folder before removing! {prich_dir}") runner = CliRunner() with runner.isolated_filesystem(temp_dir=mock_paths.home_dir): diff --git a/tests/test_run_template.py b/tests/test_run_template.py index 178ff1f..24f3ec0 100644 --- a/tests/test_run_template.py +++ b/tests/test_run_template.py @@ -293,11 +293,11 @@ CommandStep( name="Preprocess python", type="command", - call="ls", - args=["------ttgtgtg"], + call="python", + args=["notpresent"], validate=ValidateStepOutput( - not_match="test", - match_exit_code=1, + not_match="test12345", + match_exit_code=2, on_fail="error" ) ), @@ -315,10 +315,10 @@ CommandStep( name="Preprocess python", type="command", - call="ls", - args=["------ttgtgtg"], + call="python", + args=["notpresent"], validate=ValidateStepOutput( - not_match="test", + not_match="test12345", match_exit_code="{{test_error_code}}", on_fail="error" ) @@ -328,7 +328,7 @@ VariableDefinition( name="test_error_code", type="int", - default=1 + default=2 ) ], "folder": "." diff --git a/tests/test_template.py b/tests/test_template.py index 1bfba94..7fab3a9 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -61,7 +61,7 @@ }, { "args": ["template-local"], "expected_exit_code": 0, - "expected_exception_message": "Venv folder found. No dependencies to install. Done!", + "expected_exception_message": "Venv folder found.No dependencies to install.Done!", }, { "args": ["template-local", "--force"], "expected_exit_code": 0, @@ -78,7 +78,7 @@ }, { "args": ["template-global"], "expected_exit_code": 0, - "expected_exception_message": "Venv folder found. No dependencies to install. Done!", + "expected_exception_message": "Venv folder found.No dependencies to install.Done!", }, { "args": ["template-global", "--force"], "expected_exit_code": 0, @@ -91,11 +91,11 @@ "multiple": [ {"args": ["template-local"], "expected_exit_code": 0, - "expected_exception_message": "Installing shared venv... done! No dependencies to install. Done!", + "expected_exception_message": "Installing shared venv... done!No dependencies to install.Done!", }, {"args": ["template-local"], "expected_exit_code": 0, - "expected_exception_message": "Venv folder found. No dependencies to install. Done!", + "expected_exception_message": "Venv folder found.No dependencies to install.Done!", }, {"args": ["template-local", "--force"], "expected_exit_code": 1, @@ -108,11 +108,11 @@ "multiple": [ {"args": ["template-global"], "expected_exit_code": 0, - "expected_exception_message": "Installing shared venv... done! No dependencies to install. Done!", + "expected_exception_message": "Installing shared venv... done!No dependencies to install.Done!", }, {"args": ["template-global"], "expected_exit_code": 0, - "expected_exception_message": "Venv folder found. No dependencies to install. Done!", + "expected_exception_message": "Venv folder found.No dependencies to install.Done!", }, {"args": ["template-global", "--force"], "expected_exit_code": 1, @@ -166,7 +166,7 @@ def test_venv_install(mock_paths, template, case): for case_input in inputs: result = runner.invoke(venv_install, case_input.get("args")) if case_input.get("expected_exception_message") is not None: - assert case_input.get("expected_exception_message") in result.output.replace("\n", " "), f"Iteration {iteration_idx}" + assert case_input.get("expected_exception_message") in result.output.replace("\n", ""), f"Iteration {iteration_idx}" if case_input.get("expected_exit_code") is not None: assert result.exit_code == case_input.get("expected_exit_code"), f"Iteration {iteration_idx}" @@ -179,7 +179,7 @@ def test_venv_install(mock_paths, template, case): {"id": "show_template_id", "args": ["template-local"], "expected_exit_code": 0, - "expected_exception_message": "Template: id: template-local", + "expected_exception_message": "Template:id: template-local", }, {"id": "show_template_id_local_with_g", "args": ["template-local", "--global"], @@ -189,7 +189,7 @@ def test_venv_install(mock_paths, template, case): {"id": "show_template_id_global", "args": ["template-global"], "expected_exit_code": 0, - "expected_exception_message": "Template: id: template-global", + "expected_exception_message": "Template:id: template-global", }, ] @@ -214,7 +214,7 @@ def test_show_template(mock_paths, template, case): with runner.isolated_filesystem(): result = runner.invoke(show_template, case.get("args")) if case.get("expected_exception_message") is not None: - assert case.get("expected_exception_message") in result.output.replace("\n", " ") + assert case.get("expected_exception_message") in result.output.replace("\n", "") if case.get("expected_exit_code") is not None: assert result.exit_code == case.get("expected_exit_code") @@ -261,7 +261,7 @@ def test_create_template(mock_paths, case): result = runner.invoke(create_template, iteration.get("args")) if iteration.get("expected_exception_messages") is not None: for message in iteration.get("expected_exception_messages"): - assert message in result.output.replace("\n", " ") + assert message in result.output.replace("\n", "") if iteration.get("expected_exit_code") is not None: assert result.exit_code == iteration.get("expected_exit_code") diff --git a/tests/utils/paths.py b/tests/utils/paths.py new file mode 100644 index 0000000..264f5ee --- /dev/null +++ b/tests/utils/paths.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from pathlib import Path + + +@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 + + +def mock_paths_create_prich_global_folders(main_folder: MainFolder): + main_folder.prich.global_dir.mkdir(exist_ok=True) + main_folder.prich.global_templates.mkdir(exist_ok=True) + +def mock_paths_create_prich_local_folders(main_folder: MainFolder): + main_folder.prich.local_dir.mkdir(exist_ok=True) + main_folder.prich.local_templates.mkdir(exist_ok=True) + diff --git a/tests/utils/utils.py b/tests/utils/utils.py index a7ac59a..f28de6a 100644 --- a/tests/utils/utils.py +++ b/tests/utils/utils.py @@ -9,7 +9,7 @@ def capture_stdout(func, *args, **kwargs): sys.stdout = buffer try: - result = func(*args, **kwargs) # Call your method + result = func(*args, **kwargs) # Call method output = buffer.getvalue() finally: # Always restore stdout