├── tests ├── __init__.py ├── requirements.txt ├── test_commands__cron.py ├── conftest.py ├── test_commands__list.py ├── test_commands__add.py ├── test_commands__run.py └── test_commands__remove.py ├── requirements.txt ├── runchain ├── __main__.py ├── __init__.py ├── exceptions.py ├── commands.py └── chain.py ├── docs ├── requirements.txt ├── environment.rst ├── changelog.rst ├── index.rst ├── Makefile ├── make.bat ├── contributing.rst ├── api.rst ├── quickstart.rst ├── conf.py ├── howto-backup.rst └── commands.rst ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── .github ├── workflows │ ├── pypi.yml │ └── ci.yml └── FUNDING.yml ├── samples └── backup-dup.sh ├── pyproject.toml ├── LICENSE ├── README.md └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | crondir 3 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | 3 | pytest 4 | pytest-cov 5 | time_machine 6 | -------------------------------------------------------------------------------- /runchain/__main__.py: -------------------------------------------------------------------------------- 1 | from .commands import invoke 2 | 3 | if __name__ == "__main__": 4 | invoke() 5 | -------------------------------------------------------------------------------- /runchain/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from runchain.chain import Chain, list_chains 4 | 5 | __version__ = "0.2.0" 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | 3 | # Core build requirements 4 | sphinx 5 | -e git+https://github.com/radiac/sphinx_radiac_theme.git#egg=sphinx_radiac_theme 6 | 7 | # Optional 8 | # sphinx-autobuild 9 | -------------------------------------------------------------------------------- /docs/environment.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Environment variables 3 | ===================== 4 | 5 | You can set the following environment variables: 6 | 7 | ``RUNCHAIN_PATH=``: 8 | Change the default runchain directory from ``~/.runchain/`` 9 | 10 | This can be set in your ``.bashrc``:: 11 | 12 | export RUNCHAIN_PATH=~/my_chains/ 13 | -------------------------------------------------------------------------------- /runchain/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from click import ClickException 4 | 5 | 6 | class RunchainException(ClickException): 7 | """Base exception for all runchain errors.""" 8 | 9 | pass 10 | 11 | 12 | class ChainError(RunchainException): 13 | """Exception raised for chain-related errors.""" 14 | 15 | pass 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 0.2.0 - 2025-09-05 6 | ------------------ 7 | 8 | Initial release 9 | 10 | Features: 11 | 12 | * Move to `~/.runchain/` 13 | * Rewrite as Python command with `build`, `add`, `remove` and `list` 14 | 15 | 16 | 0.1.0 - 2022-12-02 17 | ------------------ 18 | 19 | Initial bash script 20 | 21 | Features: 22 | 23 | * Run a chain of scripts from `~/scripts/` 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: .*migrations\/.* 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-merge-conflict 7 | - id: trailing-whitespace 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.9.9 10 | hooks: 11 | # Run the linter with import sorting 12 | - id: ruff 13 | args: ["check", "--select", "I", "--fix"] 14 | # Run the formatter 15 | - id: ruff-format 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | runchain 3 | ======== 4 | 5 | Manage and execute chains of scripts. 6 | 7 | Runchain allows you to group scripts into named chains, schedule them with cron, and 8 | run them in a predictable order. Perfect for backup scripts and maintenance tasks. 9 | 10 | - Group scripts into ordered chains 11 | - Schedule chains with crondir integration 12 | - Simple Python API and CLI interface 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: Contents: 17 | 18 | quickstart 19 | commands 20 | environment 21 | howto-backup 22 | api 23 | changelog 24 | contributing 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | name: Build and publish to PyPI 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.11 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install --upgrade setuptools build wheel 23 | - name: Build a binary wheel and a source tarball 24 | run: | 25 | python -m build 26 | - name: Publish to PyPI 27 | if: startsWith(github.ref, 'refs/tags') 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | -------------------------------------------------------------------------------- /samples/backup-dup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Back up ~/backup to B2 using duplicity 3 | set -e 4 | 5 | # Secrets 6 | B2_KEY_ID="your-key-id" 7 | B2_APP_KEY="your-application-key" 8 | B2_BUCKET="your-bucket-name" 9 | 10 | # Env 11 | LOCAL_ROOT="~/backup/" 12 | 13 | # Build bucket URL 14 | B2_URL="b2://$B2_KEY_ID:$B2_APP_KEY@$B2_BUCKET" 15 | 16 | # Clear out anything older than 90 days 17 | duplicity remove-older-than 90D -v9 --force $B2_URL 18 | 19 | # Perform the backup 20 | # 21 | # * Take a full backup of files every 30D, incremental in between 22 | # * Resolve synlinks to back up the files themselves 23 | # * To exclude files in ~/backup, add: --exclude '**/.git' 24 | # 25 | # For more options see https://duplicity.gitlab.io/stable/duplicity.1.html 26 | /usr/bin/duplicity \ 27 | --verbosity 1 \ 28 | --full-if-older-than 30D \ 29 | --copy-links \ 30 | --exclude-device-files \ 31 | $LOCAL_ROOT \ 32 | $B2_URL 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: radiac # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: py-${{ matrix.python }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | include: 14 | - python: "3.11" 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r tests/requirements.txt 26 | - name: Set Python path 27 | run: | 28 | echo "PYTHONPATH=." >> $GITHUB_ENV 29 | - name: Test 30 | run: | 31 | pytest 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v4 34 | with: 35 | name: ${{ matrix.python }} 36 | env: 37 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 38 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, preferably via pull request. Check the GitHub issues to see 6 | what needs work, or if you have an idea for a new feature it may be worth raising an 7 | issue to discuss it first. 8 | 9 | 10 | Installing 11 | ========== 12 | 13 | The easiest way to work on this is to fork the project on GitHub, then check it out and 14 | set it up:: 15 | 16 | git clone git@github.com:USERNAME/runchain.git 17 | cd runchain/ 18 | uv venv .venv 19 | source .venv/bin/activate 20 | uv pip install -r tests/requirements.txt 21 | pre-commit install 22 | 23 | (replacing ``USERNAME`` with your username). 24 | 25 | This will install the development dependencies too. 26 | 27 | 28 | Running locally 29 | =============== 30 | 31 | You can now run the local installation from your repository root with:: 32 | 33 | python -m runchain [] 34 | 35 | 36 | Testing 37 | ======= 38 | 39 | It's always easier to merge PRs when they come with tests. 40 | 41 | Run the tests with pytest from your repository root:: 42 | 43 | pytest 44 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | === 2 | API 3 | === 4 | 5 | You can use runchain from your Python code 6 | 7 | .. code-block:: python 8 | 9 | from runchain import Chain, list_chains 10 | 11 | # List all chains 12 | chains = list_chains() 13 | print("Available chains:", chains) 14 | 15 | # Work with a specific chain 16 | backup_chain = Chain("backup") 17 | 18 | if backup_chain.exists(): 19 | print("Scripts in backup chain:", backup_chain.list()) 20 | 21 | # Add a script file 22 | backup_chain.add_file("~/scripts/database.sh", "10") 23 | backup_chain.add_file("~/scripts/files.py", "20-backup-files") 24 | 25 | # Add a script from string content 26 | backup_chain.add_string( 27 | "#!/bin/bash", 28 | "echo 'Hello from string script'", 29 | target="30-hello" 30 | ) 31 | 32 | # Schedule the chain 33 | backup_chain.cron("0 2 * * *") 34 | 35 | # Run the chain 36 | success = backup_chain.run() 37 | if success: 38 | print("Backup completed successfully") 39 | 40 | # Remove a script 41 | backup_chain.remove("10-database.sh") 42 | 43 | # Remove entire chain 44 | backup_chain.destroy() 45 | 46 | 47 | API reference 48 | ============= 49 | 50 | .. autoclass:: runchain.chain.Chain 51 | :members: 52 | :show-inheritance: 53 | 54 | .. autofunction:: runchain.chain.list_chains 55 | -------------------------------------------------------------------------------- /tests/test_commands__cron.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from runchain.commands import cli 4 | 5 | 6 | def test_cron_success(mock_crondir, runner, home_dir, test_script): 7 | """ 8 | Test scheduling a chain with cron. 9 | """ 10 | # Add a script to create the chain 11 | runner.invoke(cli, ["add", "testchain", str(test_script), "10"]) 12 | 13 | result = runner.invoke(cli, ["cron", "testchain", "0 2 * * *"]) 14 | assert result.exit_code == 0 15 | assert "Scheduled chain 'testchain' with crondir" in result.output 16 | 17 | # Check crondir was called correctly 18 | mock_crondir.add_string.assert_called_once_with( 19 | "0 2 * * * runchain run testchain", 20 | snippet="runchain-testchain", 21 | force=True 22 | ) 23 | 24 | 25 | def test_cron_nonexistent_chain(runner, home_dir): 26 | """ 27 | Test scheduling a nonexistent chain. 28 | """ 29 | result = runner.invoke(cli, ["cron", "nonexistent", "0 2 * * *"]) 30 | assert result.exit_code != 0 31 | assert "does not exist" in result.output 32 | 33 | 34 | def test_cron_invalid_chain_name(runner, home_dir): 35 | """ 36 | Test scheduling with invalid chain name. 37 | """ 38 | result = runner.invoke(cli, ["cron", "invalid-name", "0 2 * * *"]) 39 | assert result.exit_code != 0 40 | assert "must contain only lowercase letters" in result.output -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "runchain" 7 | description = "A tool for running command chains" 8 | dynamic = ["version"] 9 | authors = [ 10 | { name="Richard Terry", email="code@radiac.net" }, 11 | ] 12 | license = "BSD-3-Clause" 13 | readme = "README.md" 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Programming Language :: Python :: 3", 17 | "Operating System :: OS Independent", 18 | ] 19 | requires-python = ">=3.9" 20 | dependencies = ["click", "crondir"] 21 | 22 | [project.scripts] 23 | runchain = "runchain.commands:invoke" 24 | 25 | [project.urls] 26 | Homepage = "https://radiac.net/projects/runchain/" 27 | Documentation = "https://github.com/radiac/runchain" 28 | Changelog = "https://runchain.readthedocs.io/en/latest/changelog.html" 29 | Repository = "https://github.com/radiac/runchain" 30 | Issues = "https://github.com/radiac/runchain/issues" 31 | 32 | [tool.setuptools] 33 | packages = ["runchain"] 34 | 35 | [tool.setuptools.dynamic] 36 | version = {attr = "runchain.__version__"} 37 | 38 | [tool.pytest.ini_options] 39 | addopts = "--cov=runchain --cov-report=term --cov-report=html" 40 | testpaths = [ 41 | "runchain", 42 | "tests", 43 | ] 44 | pythonpath = ["."] 45 | 46 | [tool.coverage.run] 47 | source = ["runchain"] 48 | 49 | [tool.ruff] 50 | line-length = 88 51 | lint.select = ["E", "F"] 52 | lint.ignore = [ 53 | "E501", # line length 54 | ] 55 | exclude = [ 56 | ".git", 57 | "dist", 58 | ] 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Richard Terry 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Quickstart 3 | ========== 4 | 5 | Installation 6 | ------------ 7 | 8 | Install using ``pip``:: 9 | 10 | pip install runchain 11 | 12 | or run directly using `uv `_ (recommended):: 13 | 14 | uvx runchain [] 15 | 16 | 17 | Basic usage 18 | ----------- 19 | 20 | Create a backup chain by adding scripts:: 21 | 22 | runchain add backup ~/scripts/database-backup.sh 10 23 | runchain add backup /usr/local/bin/file-backup.py 20-files 24 | runchain add backup ./30-cleanup.sh 25 | 26 | List your chains:: 27 | 28 | runchain list 29 | 30 | List scripts in a specific chain:: 31 | 32 | runchain list backup 33 | 34 | Run a chain manually:: 35 | 36 | runchain run backup 37 | 38 | Schedule a chain with crondir:: 39 | 40 | runchain cron backup "0 2 * * *" 41 | 42 | Remove a script from a chain:: 43 | 44 | runchain remove backup 20-files 45 | 46 | Remove an entire chain:: 47 | 48 | runchain remove backup 49 | 50 | 51 | Scripts and naming 52 | ------------------ 53 | 54 | Scripts in chains use init-style numbering (e.g., ``10-backup.sh``, ``20-cleanup.py``) 55 | and are executed in alphabetical order. You can: 56 | 57 | - specify the number to control the order: ``runchain add mychain script.sh 25`` → ``25-script.sh`` 58 | - add with a number and custom name: ``runchain add mychain script.sh 25-backup`` → ``25-backup`` 59 | - add scripts that already have numbers: ``runchain add mychain 30-deploy.sh`` 60 | 61 | Chain names must contain only lowercase letters (a-z). 62 | 63 | For full command line options, see :doc:`commands`. 64 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from click.testing import CliRunner 7 | 8 | from runchain.commands import cli 9 | 10 | 11 | @pytest.fixture 12 | def runner(): 13 | """ 14 | Click test runner. 15 | """ 16 | return CliRunner() 17 | 18 | 19 | @pytest.fixture 20 | def home_dir(tmp_path, monkeypatch): 21 | """ 22 | Set HOME to a temporary directory. 23 | """ 24 | monkeypatch.setenv("HOME", str(tmp_path)) 25 | return tmp_path 26 | 27 | 28 | @pytest.fixture 29 | def mock_subprocess(monkeypatch): 30 | """ 31 | Mock subprocess.run for testing script execution. 32 | """ 33 | mock = Mock() 34 | mock.return_value.returncode = 0 35 | monkeypatch.setattr("subprocess.run", mock) 36 | return mock 37 | 38 | 39 | @pytest.fixture 40 | def mock_crondir(monkeypatch): 41 | """ 42 | Mock crondir.Crondir for testing cron scheduling. 43 | """ 44 | mock_class = Mock() 45 | mock_instance = Mock() 46 | mock_class.return_value = mock_instance 47 | monkeypatch.setattr("runchain.chain.Crondir", mock_class) 48 | return mock_instance 49 | 50 | 51 | @pytest.fixture 52 | def test_script(tmp_path): 53 | """ 54 | Create a test script file. 55 | """ 56 | script_path = tmp_path / "test_script.sh" 57 | script_path.write_text("#!/bin/bash\necho 'Hello from test script'\n") 58 | return script_path 59 | 60 | 61 | @pytest.fixture 62 | def numbered_script(tmp_path): 63 | """ 64 | Create a test script with NN- prefix. 65 | """ 66 | script_path = tmp_path / "10-numbered.py" 67 | script_path.write_text("#!/usr/bin/env python3\nprint('Hello from numbered script')\n") 68 | return script_path 69 | 70 | 71 | @pytest.fixture 72 | def failing_script(tmp_path): 73 | """ 74 | Create a test script that fails. 75 | """ 76 | script_path = tmp_path / "fail_script.sh" 77 | script_path.write_text("#!/bin/bash\necho 'This will fail'\nexit 1\n") 78 | return script_path -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import re 10 | import sys 11 | from pathlib import Path 12 | 13 | # Add the project root to Python path for autodoc 14 | sys.path.insert(0, str(Path(__file__).parent.parent)) 15 | 16 | project = "runchain" 17 | copyright = "2025, Richard Terry" 18 | author = "Richard Terry" 19 | 20 | 21 | def find_version(*paths): 22 | path = Path(*paths) 23 | content = path.read_text() 24 | match = re.search(r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", content, re.M) 25 | if match: 26 | return match.group(1) 27 | raise RuntimeError("Unable to find version string.") 28 | 29 | 30 | release = find_version("..", project.replace("-", "_"), "__init__.py") 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 35 | 36 | extensions = [ 37 | "sphinx_radiac_theme", 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.autosummary", 40 | "sphinx.ext.napoleon", 41 | ] 42 | templates_path = ["_templates"] 43 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 48 | 49 | html_theme = "sphinx_radiac_theme" 50 | 51 | html_static_path = ["_static"] 52 | 53 | html_theme_options = { 54 | "logo_only": False, 55 | "display_version": True, 56 | # Toc options 57 | "collapse_navigation": True, 58 | "sticky_navigation": True, 59 | "navigation_depth": 4, 60 | "includehidden": True, 61 | "titles_only": False, 62 | # radiac.net theme 63 | "radiac_project_slug": project, 64 | "radiac_project_name": project, 65 | "radiac_subsite_links": [ 66 | # (f"https://radiac.net/projects/{project}/demo/", "Demo"), 67 | ], 68 | } 69 | -------------------------------------------------------------------------------- /docs/howto-backup.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | How to create a backup chain 3 | ============================ 4 | 5 | Let's create a simple backup chain. 6 | 7 | We're going to use `duplicity `_ to upload everything in 8 | ``~/backup/`` to `Backblaze B2 storage `_ - a 9 | cost-effective solution which will be encrypted at rest, with 90-day rolling history. 10 | 11 | 12 | Set up duplicity and B2 13 | ----------------------- 14 | 15 | Backblaze have a great `tutorial 16 | `_ 17 | for setting up a B2 bucket and installing duplicity. The short version:: 18 | 19 | sudo add-apt-repository ppa:duplicity-team/ppa 20 | sudo apt-get update 21 | sudo apt-get --only-upgrade install duplicity 22 | 23 | Now lets create our local backup directory:: 24 | 25 | mkdir -p ~/backup 26 | 27 | The duplicity commands to backup everything in ``~/backup/`` to B2 with a 90 day history 28 | will be:: 29 | 30 | B2_URL="b2://[keyID]:[application key]@[B2 bucket name]" 31 | duplicity remove-older-than 90D -v9 --force $B2_URL 32 | duplicity --full-if-older-than 30D --copy-links ~/backup/ $B2_URL 33 | 34 | You can download a fleshed out version of this from the runchain repo. Customise it then 35 | install it as ``90-dup.sh``, so it will run at the end of the backup chain:: 36 | 37 | wget https://raw.githubusercontent.com/radiac/runchain/refs/heads/main/samples/backup-dup.sh 38 | # Update the secrets in backup-dup.sh 39 | runchain add backup backup-dup.sh 90-dup.sh 40 | 41 | We can test the backup with:: 42 | 43 | runchain run backup 44 | 45 | Lastly, let's schedule the chain to run at 2am every day:: 46 | 47 | runchain cron backup "0 2 * * *" 48 | 49 | Now everything in your ``~/backup`` directory will be backed up to B2 at 2am. 50 | 51 | 52 | Back up a file or directory 53 | --------------------------- 54 | 55 | Because we have ``--copy-links``, to back up a file or a directory, you can just symlink 56 | it in:: 57 | 58 | ln -s ~/uploads ~/backup/uploads 59 | 60 | 61 | Back up something more complicated 62 | ---------------------------------- 63 | 64 | Let's see how we'd back up a PostgreSQL database running in a container. 65 | 66 | Lets create ``dump-postgres.sh``:: 67 | 68 | #!/bin/bash 69 | set -e 70 | docker exec postgres-container pg_dumpall > ~/backup/postgres_dump.sql 71 | 72 | Now just add it to the ``backup`` chain before 90-dup.sh:: 73 | 74 | runchain add backup dump-postgres.sh 50 75 | -------------------------------------------------------------------------------- /tests/test_commands__list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from runchain.commands import cli 4 | 5 | 6 | def test_list_no_chains(runner, home_dir): 7 | """ 8 | Test listing when no chains exist. 9 | """ 10 | result = runner.invoke(cli, ["list"]) 11 | assert result.exit_code == 0 12 | assert "No chains found." in result.output 13 | 14 | 15 | def test_list_all_chains(runner, home_dir): 16 | """ 17 | Test listing all chains. 18 | """ 19 | # Create some chain directories 20 | runchain_dir = home_dir / ".runchain" 21 | runchain_dir.mkdir() 22 | (runchain_dir / "backup").mkdir() 23 | (runchain_dir / "deploy").mkdir() 24 | (runchain_dir / "maintenance").mkdir() 25 | 26 | result = runner.invoke(cli, ["list"]) 27 | assert result.exit_code == 0 28 | assert "Chains found:" in result.output 29 | assert "backup" in result.output 30 | assert "deploy" in result.output 31 | assert "maintenance" in result.output 32 | 33 | 34 | def test_list_chain_scripts_empty(runner, home_dir): 35 | """ 36 | Test listing scripts in an empty chain. 37 | """ 38 | runchain_dir = home_dir / ".runchain" 39 | runchain_dir.mkdir() 40 | (runchain_dir / "testchain").mkdir() 41 | 42 | result = runner.invoke(cli, ["list", "testchain"]) 43 | assert result.exit_code == 0 44 | assert "No scripts found in chain 'testchain'." in result.output 45 | 46 | 47 | def test_list_chain_scripts_with_files(runner, home_dir): 48 | """ 49 | Test listing scripts in a chain with files. 50 | """ 51 | runchain_dir = home_dir / ".runchain" 52 | runchain_dir.mkdir() 53 | chain_dir = runchain_dir / "testchain" 54 | chain_dir.mkdir() 55 | 56 | # Create some test scripts 57 | script1 = chain_dir / "10-backup.sh" 58 | script1.write_text("#!/bin/bash\necho test") 59 | script1.chmod(0o755) 60 | 61 | script2 = chain_dir / "20-deploy.py" 62 | script2.write_text("#!/usr/bin/env python3\nprint('test')") 63 | # Don't make this one executable 64 | 65 | result = runner.invoke(cli, ["list", "testchain"]) 66 | assert result.exit_code == 0 67 | assert "Scripts in chain testchain:" in result.output 68 | assert "10-backup.sh (executable)" in result.output 69 | assert "20-deploy.py (not executable)" in result.output 70 | 71 | 72 | def test_list_invalid_chain_name(runner, home_dir): 73 | """ 74 | Test listing with invalid chain name. 75 | """ 76 | result = runner.invoke(cli, ["list", "invalid-name"]) 77 | assert result.exit_code != 0 78 | assert "must contain only lowercase letters" in result.output -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # runchain 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/runchain.svg)](https://pypi.org/project/runchain/) 4 | [![Documentation](https://readthedocs.org/projects/runchain/badge/?version=latest)](https://runchain.readthedocs.io/en/latest/) 5 | [![Tests](https://github.com/radiac/runchain/actions/workflows/ci.yml/badge.svg)](https://github.com/radiac/runchain/actions/workflows/ci.yml) 6 | [![Coverage](https://codecov.io/gh/radiac/runchain/branch/main/graph/badge.svg?token=BCNM45T6GI)](https://codecov.io/gh/radiac/runchain) 7 | 8 | Manage and execute chains of scripts. 9 | 10 | Runchain allows you to group scripts into named chains, schedule them with cron, and 11 | run them in a predictable order. Perfect for backup scripts and maintenance tasks. 12 | 13 | - Group scripts into ordered chains 14 | - Schedule chains with crondir integration 15 | - Simple Python API and CLI interface 16 | 17 | ## Usage 18 | 19 | Install runchain: 20 | 21 | ```bash 22 | pip install runchain 23 | ``` 24 | 25 | Create a backup chain by adding scripts: 26 | 27 | ```bash 28 | runchain add backup ~/scripts/database-backup.sh 10 29 | runchain add backup /usr/local/bin/file-backup.py 20-files 30 | runchain add backup ./30-cleanup.sh 31 | ``` 32 | 33 | List your chains: 34 | 35 | ```bash 36 | runchain list 37 | ``` 38 | 39 | List scripts in a specific chain: 40 | 41 | ```bash 42 | runchain list backup 43 | ``` 44 | 45 | Run a chain manually: 46 | 47 | ```bash 48 | runchain run backup 49 | ``` 50 | 51 | Schedule a chain with crondir: 52 | 53 | ```bash 54 | runchain cron backup "0 2 * * *" 55 | ``` 56 | 57 | Remove a script or entire chain: 58 | 59 | ```bash 60 | runchain remove backup 20-files 61 | runchain remove backup 62 | ``` 63 | 64 | Or you may find it easier to skip the install step and run it using [uv](https://docs.astral.sh/uv/): 65 | 66 | ```bash 67 | uvx runchain add backup ~/scripts/backup.sh 10 68 | uvx runchain cron backup "0 2 * * *" 69 | uvx runchain run backup 70 | ``` 71 | 72 | By default, runchain stores chains in `~/.runchain/`, with each chain in its own subdirectory. 73 | 74 | For full command line options, see [command documentation](https://runchain.readthedocs.io/en/latest/commands.html) 75 | 76 | ## Example 77 | 78 | For an example of setting up a backup chain, see 79 | [How to create a backup chain](https://runchain.readthedocs.io/en/latest/howto-backup.html) 80 | in the documentation. 81 | 82 | ## Python API 83 | 84 | ```python 85 | from runchain import Chain, list_chains 86 | 87 | # List all chains 88 | chains = list_chains() 89 | 90 | # Work with a specific chain 91 | backup = Chain("backup") 92 | backup.add("~/scripts/database.sh", "10") 93 | backup.cron("0 2 * * *") 94 | backup.run() 95 | ``` 96 | 97 | See the [API documentation](https://runchain.readthedocs.io/en/latest/api.html) for more details. 98 | -------------------------------------------------------------------------------- /tests/test_commands__add.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from runchain.commands import cli 6 | 7 | 8 | def test_add_with_number(runner, home_dir, test_script): 9 | """ 10 | Test adding a script with a number. 11 | """ 12 | result = runner.invoke(cli, ["add", "testchain", str(test_script), "10"]) 13 | assert result.exit_code == 0 14 | assert "Added 10-test_script.sh to chain 'testchain'" in result.output 15 | 16 | # Check file was created and is executable 17 | chain_dir = home_dir / ".runchain" / "testchain" 18 | target_file = chain_dir / "10-test_script.sh" 19 | assert target_file.exists() 20 | assert os.access(target_file, os.X_OK) 21 | 22 | 23 | def test_add_with_full_name(runner, home_dir, test_script): 24 | """ 25 | Test adding a script with full NN-name format. 26 | """ 27 | result = runner.invoke(cli, ["add", "testchain", str(test_script), "20-backup"]) 28 | assert result.exit_code == 0 29 | assert "Added 20-backup to chain 'testchain'" in result.output 30 | 31 | chain_dir = home_dir / ".runchain" / "testchain" 32 | target_file = chain_dir / "20-backup" 33 | assert target_file.exists() 34 | assert os.access(target_file, os.X_OK) 35 | 36 | 37 | def test_add_numbered_script_no_target(runner, home_dir, numbered_script): 38 | """ 39 | Test adding a script that already has NN- format without target. 40 | """ 41 | result = runner.invoke(cli, ["add", "testchain", str(numbered_script)]) 42 | assert result.exit_code == 0 43 | assert "Added 10-numbered.py to chain 'testchain'" in result.output 44 | 45 | 46 | def test_add_script_no_target_invalid(runner, home_dir, test_script): 47 | """ 48 | Test adding a script without NN- format and no target fails. 49 | """ 50 | result = runner.invoke(cli, ["add", "testchain", str(test_script)]) 51 | assert result.exit_code != 0 52 | assert "must start with NN- format when no target specified" in result.output 53 | 54 | 55 | def test_add_nonexistent_script(runner, home_dir): 56 | """ 57 | Test adding a script that doesn't exist. 58 | """ 59 | result = runner.invoke(cli, ["add", "testchain", "/nonexistent/script.sh", "10"]) 60 | assert result.exit_code != 0 61 | assert "does not exist" in result.output 62 | 63 | 64 | def test_add_invalid_target(runner, home_dir, test_script): 65 | """ 66 | Test adding with invalid target format. 67 | """ 68 | result = runner.invoke(cli, ["add", "testchain", str(test_script), "invalid"]) 69 | assert result.exit_code != 0 70 | assert "must be a number or start with NN- format" in result.output 71 | 72 | 73 | def test_add_creates_chain(runner, home_dir, test_script): 74 | """ 75 | Test that adding a script creates the chain directory. 76 | """ 77 | chain_dir = home_dir / ".runchain" / "newchain" 78 | assert not chain_dir.exists() 79 | 80 | result = runner.invoke(cli, ["add", "newchain", str(test_script), "10"]) 81 | assert result.exit_code == 0 82 | assert chain_dir.exists() 83 | 84 | 85 | def test_add_invalid_chain_name(runner, home_dir, test_script): 86 | """ 87 | Test adding with invalid chain name. 88 | """ 89 | result = runner.invoke(cli, ["add", "invalid-name", str(test_script), "10"]) 90 | assert result.exit_code != 0 91 | assert "must contain only lowercase letters" in result.output -------------------------------------------------------------------------------- /runchain/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | from runchain.chain import Chain, list_chains 6 | 7 | 8 | class ChainType(click.ParamType): 9 | """ 10 | Click parameter type for Chain objects. 11 | """ 12 | name = "chain" 13 | 14 | def __init__(self, required: bool = True): 15 | self.required = required 16 | 17 | def convert(self, value, param, ctx): 18 | if value is None: 19 | if self.required: 20 | self.fail("Chain name is required", param, ctx) 21 | return None 22 | return Chain(value) 23 | 24 | 25 | @click.group() 26 | @click.version_option() 27 | def cli() -> None: 28 | """ 29 | runchain: manage and run chains of scripts 30 | """ 31 | pass 32 | 33 | 34 | @cli.command() 35 | @click.argument("chain", type=ChainType(required=False), required=False) 36 | def list(chain: Chain | None) -> None: 37 | """ 38 | List all chains or scripts within a specific chain. 39 | """ 40 | if chain is None: 41 | chains = list_chains() 42 | if not chains: 43 | click.echo("No chains found.") 44 | return 45 | click.echo("Chains found:") 46 | for c in chains: 47 | click.echo(c) 48 | else: 49 | scripts = chain.list() 50 | if not scripts: 51 | click.echo(f"No scripts found in chain '{chain.name}'.") 52 | return 53 | click.echo(f"Scripts in chain {chain.name}:") 54 | for script in scripts: 55 | click.echo(script) 56 | 57 | 58 | @cli.command() 59 | @click.argument("chain", type=ChainType()) 60 | @click.argument("script") 61 | @click.argument("target", required=False) 62 | def add(chain: Chain, script: str, target: str | None) -> None: 63 | """ 64 | Add a script to a chain. 65 | """ 66 | filename = chain.add_file(script, target) 67 | click.echo(f"Added {filename} to chain '{chain.name}'") 68 | 69 | 70 | @cli.command() 71 | @click.argument("chain", type=ChainType()) 72 | @click.argument("script", required=False) 73 | @click.argument("target", required=False) 74 | @click.option("--force", is_flag=True, help="Skip confirmation prompts") 75 | def remove(chain: Chain, script: str | None, target: str | None, force: bool) -> None: 76 | """ 77 | Remove a script from a chain, or remove an entire chain. 78 | """ 79 | if script is None: 80 | # Remove entire chain 81 | if not force and chain.exists(): 82 | scripts = chain.list() 83 | if scripts and not click.confirm(f"Chain '{chain.name}' contains {len(scripts)} script(s). Remove anyway?"): 84 | return 85 | 86 | chain.destroy() 87 | click.echo(f"Removed chain '{chain.name}'") 88 | else: 89 | # Remove script from chain 90 | chain.remove(script, target) 91 | click.echo(f"Removed script from chain '{chain.name}'") 92 | 93 | 94 | @cli.command() 95 | @click.argument("chain", type=ChainType()) 96 | @click.argument("schedule") 97 | def cron(chain: Chain, schedule: str) -> None: 98 | """ 99 | Register a chain to run on a cron schedule using crondir. 100 | """ 101 | chain.cron(schedule) 102 | click.echo(f"Scheduled chain '{chain.name}' with crondir") 103 | 104 | 105 | @cli.command() 106 | @click.argument("chain", type=ChainType()) 107 | def run(chain: Chain) -> None: 108 | """ 109 | Execute all scripts in a chain in alphabetical order. 110 | """ 111 | success = chain.run() 112 | if not success: 113 | raise click.Abort() 114 | 115 | 116 | def invoke() -> None: 117 | """ 118 | Entry point for the CLI. 119 | """ 120 | cli() 121 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Commands 3 | ======== 4 | 5 | If using `uv `_, add ``uvx`` to the start of the following 6 | commands. 7 | 8 | The ``runchain`` command supports the following operations: 9 | 10 | 11 | ``runchain list`` 12 | ================= 13 | 14 | List all chains or scripts within a specific chain:: 15 | 16 | runchain list [] 17 | 18 | Without a chain argument, lists all available chains. 19 | With a chain argument, lists all scripts in that chain in alphabetical order. 20 | 21 | Examples 22 | -------- 23 | 24 | List all chains:: 25 | 26 | runchain list 27 | 28 | List scripts in the backup chain:: 29 | 30 | runchain list backup 31 | 32 | 33 | ``runchain add`` 34 | ================ 35 | 36 | Add a script to a chain:: 37 | 38 | runchain add