diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 7516597..0000000 --- a/.coveragerc +++ /dev/null @@ -1,17 +0,0 @@ -# Configuration for coverage.py - -[run] -branch = True -source = gnuplot_kernel -include = gnuplot_kernel/* -omit = - setup.py - gnuplot_kernel/__main__.py - -[report] -exclude_lines = - pragma: no cover - def __repr__ - if __name__ == .__main__.: - def register_ipython_magics - def load_ipython_extension diff --git a/.github/utils/_repo.py b/.github/utils/_repo.py new file mode 100644 index 0000000..2ef15da --- /dev/null +++ b/.github/utils/_repo.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python +from __future__ import annotations + +import os +import re +import shlex +from subprocess import PIPE, Popen +from typing import Literal, Sequence, TypeAlias + +ReleaseType: TypeAlias = Literal[ + "alpha", + "beta", + "candidate", + "development", + "stable", +] + +pre_release_lookup: dict[str, ReleaseType] = { + "a": "alpha", + "alpha": "alpha", + "b": "beta", + "beta": "beta", + "rc": "candidate", + "dev": "development", + ".dev": "development", +} + +# https://docs.github.com/en/actions/learn-github-actions/variables +# #default-environment-variables +GITHUB_VARS = [ + "GITHUB_REF_NAME", # main, dev, v0.1.0, v0.1.3a1 + "GITHUB_REF_TYPE", # "branch" or "tag" + "GITHUB_REPOSITORY", # has2k1/scikit-misc + "GITHUB_SERVER_URL", # https://github.com + "GITHUB_SHA", # commit shasum + "GITHUB_WORKSPACE", # /home/runner/work/scikit-misc/scikit-misc + "GITHUB_EVENT_NAME", # push, schedule, workflow_dispatch, ... +] + + +count = r"(?:[0-9]|[1-9][0-9]+)" +DESCRIBE = re.compile( + r"^v" + rf"(?P{count}\.{count}\.{count})" + rf"((?P
a|b|rc|alpha|beta|\.dev){count})?"
+    r"(-(?P\d+)-g(?P[a-z0-9]+))?"
+    r"(?P-dirty)?"
+    r"$"
+)
+
+# Define a stable release version to be valid according to PEP440
+# and is a semver
+STABLE_TAG = re.compile(r"^v" rf"{count}\.{count}\.{count}" r"$")
+
+# Prerelease version
+PRE_RELEASE_TAG = re.compile(
+    r"^v"
+    rf"{count}\.{count}\.{count}"
+    rf"((?P
a|b|rc|alpha|beta|\.dev){count})?"
+    r"$"
+)
+
+REF_NAME = os.environ.get("GITHUB_REF_NAME", "")
+REF_TYPE = os.environ.get("GITHUB_REF_TYPE", "")
+
+
+def run(cmd: str | Sequence[str]) -> str:
+    if isinstance(cmd, str) and os.name == "posix":
+        cmd = shlex.split(cmd)
+    with Popen(
+        cmd, stdin=PIPE, stderr=PIPE, stdout=PIPE, text=True, encoding="utf-8"
+    ) as p:
+        stdout, _ = p.communicate()
+    return stdout.strip()
+
+
+class Git:
+    @staticmethod
+    def checkout(committish):
+        """
+        Return True if inside a git repo
+        """
+        res = run(f"git checkout {committish}")
+        return res
+
+    @staticmethod
+    def commit_titles(n=1) -> list[str]:
+        """
+        Return a list n of commit titles
+        """
+        output = run(
+            f"git log --oneline --no-merges --pretty='format:%s' -{n}"
+        )
+        return output.split("\n")[:n]
+
+    @staticmethod
+    def commit_messages(n=1) -> list[str]:
+        """
+        Return a list n of commit messages
+        """
+        sep = "______ MESSAGE _____"
+        output = run(
+            f"git log --no-merges --pretty='format:%B{sep}' -{n}"
+        ).strip()
+        if output.endswith(sep):
+            output = output[: -len(sep)]
+        return output.split(sep)[:n]
+
+    @staticmethod
+    def commit_title() -> str:
+        """
+        Commit subject
+        """
+        return Git.commit_titles(1)[0]
+
+    @staticmethod
+    def commit_message() -> str:
+        """
+        Commit title
+        """
+        return Git.commit_messages(1)[0]
+
+    @staticmethod
+    def is_repo():
+        """
+        Return True if inside a git repo
+        """
+        res = run("git rev-parse --is-inside-work-tree")
+        return res == "return"
+
+    @staticmethod
+    def fetch_tags() -> str:
+        """
+        Fetch all tags
+        """
+        return run("git fetch --tags --force")
+
+    @staticmethod
+    def is_shallow() -> bool:
+        """
+        Return True if current repo is shallow
+        """
+        res = run("git rev-parse --is-shallow-repository")
+        return res == "true"
+
+    @staticmethod
+    def deepen(n: int = 1) -> str:
+        """
+        Fetch n commits beyond the shallow limit
+        """
+        return run(f"git fetch --deepen={n}")
+
+    @staticmethod
+    def describe() -> str:
+        """
+        Git describe to determine version
+        """
+        return run("git describe --dirty --tags --long --match '*[0-9]*'")
+
+    @staticmethod
+    def can_describe() -> bool:
+        """
+        Return True if repo can be "described" from a semver tag
+        """
+        return bool(DESCRIBE.match(Git.describe()))
+
+    @staticmethod
+    def get_tag_at_commit(committish: str) -> str:
+        """
+        Get tag of a given commit
+        """
+        return run(f"git describe --exact-match {committish}")
+
+    @staticmethod
+    def tag_message(tag: str) -> str:
+        """
+        Get the message of a tag
+        """
+        return run(f"git tag -l --format='%(subject)' {tag}")
+
+    @staticmethod
+    def is_annotated(tag: str) -> bool:
+        """
+        Return true if tag is annotated tag
+        """
+        # LHS prints to stderr and returns nothing when
+        # tag is an empty string
+        return run(f"git cat-file -t {tag}") == "tag"
+
+    @staticmethod
+    def shallow_checkout(branch: str, url: str, depth: int = 1) -> str:
+        """
+        Shallow clone upto n commits
+        """
+        _branch = f"--branch={branch}"
+        _depth = f"--depth={depth}"
+        return run(f"git clone {_depth} {_branch} {url} .")
+
+    @staticmethod
+    def is_stable_release():
+        """
+        Return True if event is a stable release
+        """
+        return REF_TYPE == "tag" and bool(STABLE_TAG.match(REF_NAME))
+
+    @staticmethod
+    def is_pre_release():
+        """
+        Return True if event is any kind of pre-release
+        """
+        return REF_TYPE == "tag" and bool(PRE_RELEASE_TAG.match(REF_NAME))
+
+    @staticmethod
+    def release_type() -> ReleaseType | None:
+        if Git.is_stable_release():
+            return "stable"
+        elif Git.is_pre_release():
+            match = PRE_RELEASE_TAG.match(REF_NAME)
+            assert match is not None
+            pre = match.group("pre")
+            return pre_release_lookup[pre]
+
+    @staticmethod
+    def branch():
+        """
+        Return event branch
+        """
+        return REF_NAME if REF_TYPE == "branch" else ""
diff --git a/.github/utils/please.py b/.github/utils/please.py
new file mode 100644
index 0000000..5ec3f5f
--- /dev/null
+++ b/.github/utils/please.py
@@ -0,0 +1,84 @@
+import os
+import sys
+from pathlib import Path
+from typing import Callable, TypeAlias
+
+from _repo import Git
+
+Ask: TypeAlias = Callable[[], bool | str]
+Do: TypeAlias = Callable[[], str]
+
+gh_output_file = os.environ.get("GITHUB_OUTPUT")
+
+
+def set_deploy_to():
+    """
+    Write where to deploy to deploy_on in the GITHUB_OUTPUT env
+    """
+    if not gh_output_file:
+        return
+
+    if Git.is_stable_release():
+        deploy_to = "website"
+    elif Git.is_pre_release():
+        deploy_to = "pre-website"
+    elif Git.branch() in {"main", "dev"}:
+        deploy_to = "gh-pages"
+    else:
+        deploy_to = ""
+
+    with Path(gh_output_file).open("a") as f:
+        print(f"deploy_to={deploy_to}", file=f)
+
+
+def set_publish_on():
+    """
+    Write index (pypi or testpypi) to publish_on in the GITHUB_OUTPUT env
+
+    i.e. Where to release
+    """
+    # Probably not on GHA
+    if not gh_output_file:
+        return
+
+    rtype = Git.release_type()
+
+    if rtype in {"stable", "alpha", "beta", "development"}:
+        publish_on = "pypi"
+    elif rtype == "candidate":
+        publish_on = "testpypi"
+    else:
+        publish_on = ""
+
+    with Path(gh_output_file).open("a") as f:
+        print(f"publish_on={publish_on}", file=f)
+
+
+def set_commit_title():
+    """
+    Write the commit title to commit_title in the GITHUB_OUTPUT env
+    """
+    if not gh_output_file:
+        return
+
+    with Path(gh_output_file).open("a") as f:
+        print(f"commit_title={Git.commit_title()}", file=f)
+
+
+def process_request(task_name: str) -> str | None:
+    if task_name in TASKS:
+        return TASKS[task_name]()
+
+
+TASKS: dict[str, Callable[[], str | None]] = {
+    "set_deploy_to": set_deploy_to,
+    "set_publish_on": set_publish_on,
+    "set_commit_title": set_commit_title,
+}
+
+if __name__ == "__main__":
+    if len(sys.argv) == 2:
+        arg = sys.argv[1]
+        output = process_request(arg)
+        if output:
+            print(output)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..14556e1
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,90 @@
+name: Release
+
+on:
+  push:
+    tags:
+      - 'v[0-9]*'
+
+jobs:
+  run-tests:
+    name: Run all tests
+    uses: ./.github/workflows/testing.yml
+    with:
+      skip_codecov: true
+
+  check-semver-tag:
+    name: Check if the tag is in semantic version format
+    needs: [run-tests]
+    runs-on: ubuntu-latest
+    outputs:
+      publish_on: ${{ steps.variables.outputs.publish_on }}
+
+    strategy:
+      matrix:
+        python-version: ["3.13"]
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Copy build utils
+        run: cp -r .github/utils ../utils
+
+      - name: Decide where to publish and create output variables
+        id: variables
+        run: uv run python ../utils/please.py set_publish_on
+
+      - name: See outputs
+        run: echo "publish_on="${{ steps.variables.outputs.publish_on }}
+
+  # Ref: https://github.com/pypa/gh-action-pypi-publish
+  publish:
+    name: Build and publish Python 🐍 distributions 📦 to TestPyPI or PyPI
+    needs: [check-semver-tag]
+    runs-on: ubuntu-latest
+
+    if: ${{ needs.check-semver-tag.outputs.publish_on != '' }}
+
+    environment:
+      name: release
+      url: https://github.com/has2k1/gnuplot_kernel
+
+    permissions:
+      id-token: write  # IMPORTANT: this permission is mandatory for trusted publishing
+
+    strategy:
+      matrix:
+        python-version: ["3.13"]
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install Packages
+        run: uv run uv pip install build
+
+      - name: Build a wheel and a source tarball
+        run: make dist
+
+      - name: Publish distribution 📦 to Test PyPI
+        if: ${{ needs.check-semver-tag.outputs.publish_on == 'testpypi' }}
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          repository-url: https://test.pypi.org/legacy/
+          skip-existing: true
+
+      - name: Publish distribution 📦 to PyPI
+        if: ${{ needs.check-semver-tag.outputs.publish_on == 'pypi' }}
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          skip-existing: true
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
new file mode 100644
index 0000000..50e4156
--- /dev/null
+++ b/.github/workflows/testing.yml
@@ -0,0 +1,121 @@
+name: build
+
+on:
+  push:
+    branches:
+      - '*'
+    tags-ignore:
+      - 'v[0-9]*'
+  pull_request:
+  workflow_call:
+
+jobs:
+  # Unittests
+  unittests:
+    runs-on: ubuntu-latest
+
+    # We want to run on external PRs, but not on our own internal PRs as they'll be run
+    # by the push to the branch.
+    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
+
+    strategy:
+      matrix:
+        include:
+          - python-version: "3.10"
+            resolution: "lowest-direct"
+          - python-version: 3.13
+            resolution: "highest"
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install Packages
+        run: |
+          sudo apt-get install gnuplot
+          # Install as an editable so that the coverage path
+          # is predicable
+          uv run uv pip install --resolution=${{ matrix.resolution }} -e ".[test]"
+
+      - name: Environment Information
+        run: |
+          gnuplot --version
+          uv pip list
+
+      - name: Run Tests
+        run: |
+          make test
+
+      # https://app.codecov.io/github/has2k1/gnuplot-kernel/settings
+      # https://github.com/has2k1/gnuplot-kernel/settings/secrets/actions
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v5
+        with:
+          fail_ci_if_error: true
+          name: "py${{ matrix.python-version }}"
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+  lint-and-format:
+    runs-on: ubuntu-latest
+
+    # We want to run on external PRs, but not on our own internal PRs as they'll be run
+    # by the push to the branch.
+    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
+
+    strategy:
+      matrix:
+        python-version: [3.13]
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install Packages
+        run: uv run uv pip install ruff
+
+      - name: Environment Information
+        run: uv pip list
+
+      - name: Check lint with Ruff
+        run: make lint
+
+      - name: Check format with Ruff
+        run: make format
+
+  typecheck:
+    runs-on: ubuntu-latest
+
+    # We want to run on external PRs, but not on our own internal PRs as they'll be run
+    # by the push to the branch.
+    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
+
+    strategy:
+      matrix:
+        python-version: [3.13]
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install Packages
+        run: uv run uv pip install ".[dev]"
+
+      - name: Environment Information
+        run: uv pip list
+
+      - name: Run Tests
+        run: make typecheck
diff --git a/.gitignore b/.gitignore
index 77f911b..0fd8f0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,7 +15,14 @@ pip-log.txt
 .tox
 htmlcov/
 .pytest_cache
+coverage.xml
 
 # other
 .cache
 examples/.ipynb_checkpoints
+
+# Catch all unnamed notebook files
+**/Untitled*.ipynb
+
+# uv
+uv.lock
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index da608fc..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-os: linux
-dist: xenial
-language: python
-
-python:
-   - 3.6  # Minimum
-   - 3.8
-
-addons:
-  apt:
-    packages:
-      - gnuplot
-
-cache: pip
-
-notifications:
-  email: false
-
-before_install:
-   - gnuplot --version
-
-install:
-   - pip install ipykernel metakernel
-   - pip install --upgrade pytest>=3.0.6
-   - pip install pytest-cov
-   - pip install coveralls
-   - python setup.py install
-   - pip list
-
-script:
-   - make test
-
-after_success:
-   - coveralls --rcfile=.coveragerc
diff --git a/MANIFEST.in b/MANIFEST.in
index e2d707f..64ad321 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,7 +1 @@
-include *.rst
-include *.txt
-include LICENSE
-include Makefile
-include pytest.ini
-recursive-include examples *.ipynb
-recursive-include gnuplot_kernel *.gp
+include README.md LICENSE
diff --git a/Makefile b/Makefile
index 1620d5d..cb03adc 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,27 @@
 .PHONY: clean-pyc clean-build docs clean
 BROWSER := python -mwebbrowser
 
+# NOTE: Take care not to use tabs in any programming flow outside the
+# make target
+
+# Use uv (if it is installed) to run all python related commands,
+# and prefere the active environment over .venv in a parent folder
+ifeq ($(OS),Windows_NT)
+  HAS_UV := $(if $(shell where uv 2>NUL),true,false)
+else
+  HAS_UV := $(if $(shell command -v uv 2>/dev/null),true,false)
+endif
+
+ifeq ($(HAS_UV),true)
+  PYTHON ?= uv run --active python
+  PIP ?= uv pip
+  UVRUN ?= uv run --active
+else
+  PYTHON ?= python
+  PIP ?= pip
+  UVRUN ?=
+endif
+
 help:
 	@echo "clean - remove all build, test, coverage and Python artifacts"
 	@echo "clean-build - remove build artifacts"
@@ -12,47 +33,64 @@ help:
 	@echo "release - package and upload a release"
 	@echo "dist - package"
 	@echo "install - install the package to the active Python's site-packages"
+	@echo "develop - install the package in development mode"
 
 clean: clean-build clean-pyc clean-test
 
 clean-build:
 	rm -fr build/
 	rm -fr dist/
-	rm -fr .eggs/
 	find . -name '*.egg-info' -exec rm -fr {} +
-	find . -name '*.egg' -exec rm -f {} +
 
-clean-pyc:
-	find . -name '*.pyc' -exec rm -f {} +
-	find . -name '*.pyo' -exec rm -f {} +
-	find . -name '*~' -exec rm -f {} +
+clean-cache:
 	find . -name '__pycache__' -exec rm -fr {} +
 
 clean-test:
+	$(UVRUN) coverage erase
+	rm -f coverage.xml
 	rm -f .coverage
 	rm -fr htmlcov/
 
+format:
+	$(UVRUN) ruff format --check .
+
+format-fix:
+	$(UVRUN) ruff format .
+
 lint:
-	flake8 gnuplot_kernel
+	$(UVRUN) ruff check .
+
+lint-fix:
+	$(UVRUN) ruff check --fix .
+
+fix: format-fix lint-fix
+
+typecheck:
+	$(UVRUN) pyright
 
 test: clean-test
-	pytest --cov=gnuplot_kernel
+	$(UVRUN) pytest
 
 coverage:
-	coverage report -m
-	coverage html
+	$(UVRUN) coverage report -m
+	$(UVRUN) coverage html
 	$(BROWSER) htmlcov/index.html
 
-dist: clean
-	python setup.py sdist
-	python setup.py bdist_wheel
+dist: clean-build
+	$(PYTHON) -m build
 	ls -l dist
 
-release: dist
-	twine upload dist/*
+release-major:
+	@$(PYTHON) ./tools/release-checklist.py major
 
-release-test: dist
-	twine upload -r pypitest dist/*
+release-minor:
+	@$(PYTHON) ./tools/release-checklist.py minor
+
+release-patch:
+	@$(PYTHON) ./tools/release-checklist.py patch
 
 install: clean
-	python setup.py install
+	$(PIP) install .
+
+develop: clean-cache
+	$(PIP) install -e ".[dev]"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..76b5a78
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# A Jupyter/IPython kernel for Gnuplot
+
+[![Release](https://img.shields.io/pypi/v/gnuplot_kernel.svg)](https://pypi.python.org/pypi/gnuplot_kernel)
+[![License](https://img.shields.io/pypi/l/gnuplot_kernel.svg)](https://pypi.python.org/pypi/gnuplot_kernel)
+[![Build Status](https://github.com/has2k1/gnuplot_kernel/actions/workflows/testing.yml/badge.svg)](https://github.com/has2k1/gnuplot_kernel/actions/workflows/testing.yml)
+[![Coverage](https://codecov.io/github/has2k1/gnuplot_kernel/branch/main/graph/badge.svg)](https://codecov.io/github/has2k1/gnuplot_kernel)
+
+`gnuplot_kernel` has been developed for use specifically with `Jupyter Notebook`.
+It can also be loaded as an `IPython` extension allowing for `gnuplot` code in the same `notebook`
+as `python` code.
+
+## Installation
+
+It is good practice to install `gnuplot_kernel` in a virtual environment.
+We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) or
+[python venv](https://docs.python.org/3/library/venv.html).
+
+### Option 1: Using `uv`
+
+```console
+$ uv venv
+```
+
+**Official release**
+
+```console
+$ uv pip install gnuplot_kernel
+$ uv run python -m gnuplot_kernel install --user
+```
+
+The last command installs a kernel spec file for the current python installation. This
+is the file that allows you to choose a jupyter kernel in a notebook.
+
+**Development version**
+
+
+```console
+$ uv pip install git+https://github.com/has2k1/gnuplot_kernel.git@master
+$ uv run python -m gnuplot_kernel install --user
+```
+
+### Option 2: Using `python venv`
+
+```console
+$ python3 -m venv .venv && source .venv/bin/activate
+```
+
+**Official release**
+
+```console
+$ pip install gnuplot_kernel
+$ python -m gnuplot_kernel install --user
+```
+
+**Development version**
+
+```console
+$ pip install git+https://github.com/has2k1/gnuplot_kernel.git@master
+$ python -m gnuplot_kernel install --user
+```
+
+## Requires
+
+- System installation of [Gnuplot](http://www.gnuplot.info/)
+
+## Documentation
+
+1. [Example Notebooks](https://github.com/has2k1/gnuplot_kernel/tree/main/examples) for `gnuplot_kernel`.
+2. [Metakernel magics](https://github.com/Calysto/metakernel/blob/master/metakernel/magics/README.md), these are available when using `gnuplot_kernel`.
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 9d90164..0000000
--- a/README.rst
+++ /dev/null
@@ -1,72 +0,0 @@
-####################################
-A Jupyter/IPython kernel for Gnuplot
-####################################
-
-=================    ===============
-Latest Release       |release|_
-License              |license|_
-Build Status         |buildstatus|_
-Coverage             |coverage|_
-=================    ===============
-
-.. image:: https://mybinder.org/badge_logo.svg
-  :target: https://mybinder.org/v2/gh/has2k1/gnuplot_kernel/master?filepath=examples
-
-`gnuplot_kernel` has been developed for use specifically with
-`Jupyter Notebook`. It can also be loaded as an `IPython`
-extension allowing for `gnuplot` code in the same `notebook`
-as `python` code.
-
-Installation
-============
-
-**Official version**
-
-.. code-block:: bash
-
-   pip install gnuplot_kernel
-   python -m gnuplot_kernel install --user
-
-The last command installs a kernel spec file for the current python installation. This
-is the file that allows you to choose a jupyter kernel in a notebook.
-
-**Development version**
-
-.. code-block:: bash
-
-   pip install git+https://github.com/has2k1/gnuplot_kernel.git@master
-   python -m gnuplot_kernel install --user
-
-
-Requires
-========
-
-- System installation of `Gnuplot`_
-- `Notebook`_ (IPython/Jupyter Notebook)
-- `Metakernel`_
-
-
-Documentation
-=============
-
-1. `Example Notebooks`_ for `gnuplot_kernel`.
-2. `Metakernel magics`_, these are available when using `gnuplot_kernel`.
-
-
-.. _`Notebook`: https://github.com/jupyter/notebook
-.. _`Gnuplot`: http://www.gnuplot.info/
-.. _`Example Notebooks`: https://github.com/has2k1/gnuplot_kernel/tree/master/examples
-.. _`Metakernel`: https://github.com/Calysto/metakernel
-.. _`Metakernel magics`: https://github.com/Calysto/metakernel/blob/master/metakernel/magics/README.md
-
-.. |release| image:: https://img.shields.io/pypi/v/gnuplot_kernel.svg
-.. _release: https://pypi.python.org/pypi/gnuplot_kernel
-
-.. |license| image:: https://img.shields.io/pypi/l/gnuplot_kernel.svg
-.. _license: https://pypi.python.org/pypi/gnuplot_kernel
-
-.. |buildstatus| image:: https://api.travis-ci.org/has2k1/gnuplot_kernel.svg?branch=master
-.. _buildstatus: https://travis-ci.org/has2k1/gnuplot_kernel
-
-.. |coverage| image:: https://coveralls.io/repos/github/has2k1/gnuplot_kernel/badge.svg?branch=master
-.. _coverage: https://coveralls.io/github/has2k1/gnuplot_kernel?branch=master
diff --git a/examples/gnuplot-magic.ipynb b/examples/gnuplot-magic.ipynb
index 4a876a0..e24bb1d 100644
--- a/examples/gnuplot-magic.ipynb
+++ b/examples/gnuplot-magic.ipynb
@@ -27,8 +27,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import numpy as np\n",
     "import matplotlib.pyplot as plt\n",
+    "import numpy as np\n",
     "\n",
     "# inline plots for matplotlib\n",
     "%matplotlib inline\n",
@@ -69,7 +69,7 @@
     "x = np.random.rand(N)\n",
     "y = np.random.rand(N)\n",
     "colors = np.random.rand(N)\n",
-    "area = np.pi * (15 * np.random.rand(N))**2  # 0 to 15 point radii\n",
+    "area = np.pi * (15 * np.random.rand(N)) ** 2  # 0 to 15 point radii\n",
     "\n",
     "plt.scatter(x, y, s=area, c=colors, alpha=0.5)\n",
     "plt.show()"
diff --git a/gnuplot_kernel/__init__.py b/gnuplot_kernel/__init__.py
deleted file mode 100644
index ae43d9c..0000000
--- a/gnuplot_kernel/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .kernel import GnuplotKernel
-from .magics import register_ipython_magics
-
-__all__ = ['GnuplotKernel']
-
-
-def load_ipython_extension(ipython):
-    """
-    Load the extension in IPython
-    """
-    register_ipython_magics()
diff --git a/gnuplot_kernel/kernel.py b/gnuplot_kernel/kernel.py
deleted file mode 100644
index 3a1f5d8..0000000
--- a/gnuplot_kernel/kernel.py
+++ /dev/null
@@ -1,405 +0,0 @@
-import sys
-import re
-import os.path
-import uuid
-
-from IPython.display import Image, SVG
-from metakernel import MetaKernel, ProcessMetaKernel, pexpect, u
-from metakernel.process_metakernel import TextOutput
-
-from .replwrap import GnuplotREPLWrapper, PROMPT
-from .exceptions import GnuplotError
-
-# This is the only place that the version is
-# specified
-__version__ = '0.4.1'
-
-# name of the command i.e first token
-CMD_RE = re.compile(r'^\s*(\w+)\s?')
-# "set multiplot" and abbreviated variants
-MULTI_RE = re.compile(r'\s*set\s+multip(?:l|lo|lot)?')
-# "unset multiplot" and abbreviated variants
-UNMULTI_RE = re.compile(r'\s*uns(?:e|et)?\s+multip(?:l|lo|lot)?')
-PLOT_CMDS = {
-    'plot', 'plo', 'pl', 'p',
-    'splot', 'splo', 'spl', 'sp',
-    'replot', 'replo', 'repl', 'rep',
-}
-# "set output" and abbreviated variants
-SET_OUTPUT_RE = re.compile(
-    r'\s*set\s+o(?:u|ut|utp|utpu|utput)?(?:\s+|$)'
-)
-
-# "unset output" and abbreviated variants
-UNSET_OUTPUT_RE = re.compile(
-    r'\s*uns(?:e|et)?\s+o(?:u|ut|utp|utpu|utput)?\s*'
-)
-
-
-# funtions to recognise gnuplot statements that determine
-# how we add temporary files for the images shown by jupyter
-def is_set_output(stmt):
-    """
-    Return True if stmt is a 'set output' statement
-    """
-    m = re.match(SET_OUTPUT_RE, stmt)
-    return True if m else False
-
-
-def is_unset_output(stmt):
-    """
-    Return True if stmt is an 'unset output' statement
-    """
-    m = re.match(UNSET_OUTPUT_RE, stmt)
-    return True if m else False
-
-
-def is_set_multiplot(stmt):
-    """
-    Return True if stmt is a plot statement
-    """
-    m = re.match(MULTI_RE, stmt)
-    return True if m else False
-
-
-def is_unset_multiplot(stmt):
-    """
-    Return True if stmt is a plot statement
-    """
-    m = re.match(UNMULTI_RE, stmt)
-    return True if m else False
-
-
-def is_plot(stmt):
-    """
-    Return True if stmt is a plot statement
-    """
-    m = re.match(CMD_RE, stmt)
-    if m:
-        return m.group(1) in PLOT_CMDS
-    return False
-
-
-class GnuplotKernel(ProcessMetaKernel):
-    implementation = 'Gnuplot Kernel'
-    implementation_version = __version__
-    language = 'gnuplot'
-    language_version = '5.0'
-    banner = 'Gnuplot Kernel'
-    language_info = {
-        'mimetype': 'text/x-gnuplot',
-        'name': 'gnuplot',
-        'file_extension': '.gp',
-        'codemirror_mode': 'Octave',
-        'help_links': MetaKernel.help_links,
-    }
-    kernel_json = {
-        'argv': [sys.executable,
-                 '-m', 'gnuplot_kernel',
-                 '-f', '{connection_file}'],
-        'display_name': 'gnuplot',
-        'language': 'gnuplot',
-        'name': 'gnuplot',
-    }
-
-    inline_plotting = True
-    reset_code = ''
-    _first = True
-    _image_files = []
-    _error = False
-
-    def bad_prompt_warning(self):
-        """
-        Print warning if the prompt is not 'gnuplot>'
-        """
-        if not self.wrapper.prompt.startswith('gnuplot>'):
-            msg = ("Warning: The prompt is currently set "
-                   "to '{}'".format(self.wrapper.prompt))
-            print(msg)
-
-    def do_execute_direct(self, code):
-        # We wrap the real function so that gnuplot_kernel can
-        # give a message when an exception occurs. Without
-        # this, an exception happens silently
-        try:
-            return self._do_execute_direct(code)
-        except Exception as err:
-            print(f"Error: {err}")
-            raise err
-
-    def _do_execute_direct(self, code):
-        """
-        Execute gnuplot code
-        """
-        if self._first:
-            self._first = False
-            self.handle_plot_settings()
-
-        if self.inline_plotting:
-            code = self.add_inline_image_statements(code)
-
-        success = True
-
-        try:
-            result = super(GnuplotKernel,
-                           self).do_execute_direct(code, silent=True)
-        except GnuplotError as e:
-            result = TextOutput(e.message)
-            success = False
-
-        if self.reset_code:
-            super(GnuplotKernel, self).do_execute_direct(
-                self.reset_code, silent=True)
-
-        if self.inline_plotting:
-            if success:
-                self.display_images()
-            self.delete_image_files()
-
-        self.bad_prompt_warning()
-
-        # No empty strings
-        return result if (result and result.output) else None
-
-    def add_inline_image_statements(self, code):
-        """
-        Add 'set output ...' before every plotting statement
-
-        This is what powers inline plotting
-        """
-        # Ensure that there are no stale images
-        self.delete_image_files()
-
-        def set_output_inline(lines):
-            filename = self.get_image_filename()
-            if filename:
-                lines.append("set output '{}'".format(filename))
-
-        # We automatically create an output file for the following
-        # cases if the user has not created one.
-        #    - before every every plot statement that is not
-        #      inside a multiplot block
-        #    - before every multiplot block
-
-        lines = []
-        sm = StateMachine()
-        is_joined_stmt = False
-        for stmt in code.splitlines():
-            sm.transition(stmt)
-            add_inline_plot = (
-                sm.prev_cur in (
-                    ('none', 'plot'),
-                    ('none', 'multiplot'),
-                    ('plot', 'plot')
-                )
-                and not is_joined_stmt
-            )
-            if add_inline_plot:
-                set_output_inline(lines)
-
-            lines.append(stmt)
-            is_joined_stmt = stmt.strip().endswith('\\')
-
-        # Make gnuplot flush the output
-        if not lines[-1].endswith('\\'):
-            lines.append('unset output')
-        code = '\n'.join(lines)
-        return code
-
-    def get_image_filename(self):
-        """
-        Create file to which gnuplot will write the plot
-
-        Returns the filename.
-        """
-        # we could use tempfile.NamedTemporaryFile but we do not
-        # want to create the file, gnuplot will create it.
-        # Later on when we check if the file exists we know
-        # whodunnit.
-        settings = self.plot_settings
-        filename = '/tmp/gnuplot-inline-{}.{}'.format(
-            uuid.uuid1(),
-            settings['format'])
-        filename = filename
-        self._image_files.append(filename)
-        return filename
-
-    def display_images(self):
-        """
-        Display images if gnuplot wrote to them
-        """
-        settings = self.plot_settings
-        if self.inline_plotting:
-            if settings['format'] == 'svg':
-                _Image = SVG
-            else:
-                _Image = Image
-
-        for filename in self._image_files:
-            try:
-                size = os.path.getsize(filename)
-            except FileNotFoundError:
-                size = 0
-
-            if not size:
-                msg = (
-                    "Failed to read and display image file from gnuplot."
-                    "Possibly:\n"
-                    "1. You have plotted to a non interactive terminal.\n"
-                    "2. You have an invalid expression."
-                )
-                print(msg)
-                continue
-
-            im = _Image(filename)
-            self.Display(im)
-
-    def delete_image_files(self):
-        """
-        Delete the image files
-        """
-        # After display_images(), the real images are
-        # no longer required.
-        for filename in self._image_files:
-            try:
-                os.remove(filename)
-            except FileNotFoundError:
-                pass
-
-        self._image_files = []
-
-    def makeWrapper(self):
-        """
-        Start gnuplot and return wrapper around the REPL
-        """
-        if pexpect.which('gnuplot'):
-            program = 'gnuplot'
-        elif pexpect.which('gnuplot.exe'):
-            program = 'gnuplot.exe'
-        else:
-            raise Exception("gnuplot not found.")
-
-        # We don't want help commands getting stuck,
-        # use a non interactive PAGER
-        if pexpect.which('env') and pexpect.which('cat'):
-            command = 'env PAGER=cat {}'.format(program)
-        else:
-            command = program
-
-        d = dict(cmd_or_spawn=command,
-                 prompt_regex=u('\w*> $'),
-                 prompt_change_cmd=None)
-        wrapper = GnuplotREPLWrapper(**d)
-        # No sleeping before sending commands to gnuplot
-        wrapper.child.delaybeforesend = 0
-        return wrapper
-
-    def do_shutdown(self, restart):
-        """
-        Exit the gnuplot process and any other underlying stuff
-        """
-        self.wrapper.exit()
-        super(GnuplotKernel, self).do_shutdown(restart)
-
-    def get_kernel_help_on(self, info, level=0, none_on_fail=False):
-        obj = info.get('help_obj', '')
-        if not obj or len(obj.split()) > 1:
-            if none_on_fail:
-                return None
-            else:
-                return ''
-        res = self.do_execute_direct('help %s' % obj)
-        text = res.output.strip().rstrip(PROMPT)
-        self.bad_prompt_warning()
-        return text
-
-    def handle_plot_settings(self):
-        """
-        Handle the current plot settings
-
-        This is used by the gnuplot line magic. The plot magic
-        is innadequate.
-        """
-        settings = self.plot_settings
-        if ('termspec' not in settings or
-                not settings['termspec']):
-            settings['termspec'] = ('pngcairo size 385, 256'
-                                    ' font "Arial,10"')
-        if ('format' not in settings or
-                not settings['format']):
-            settings['format'] = 'png'
-
-        self.inline_plotting = settings['backend'] == 'inline'
-
-        cmd = 'set terminal {}'.format(settings['termspec'])
-        self.do_execute_direct(cmd)
-
-
-class StateMachine:
-    """
-    Track context given gnuplot statements
-
-    This is used to help us tell when to add inline commands
-    so that gnuplot can create inline images for the notebook
-    """
-    states = ['none', 'plot', 'output', 'multiplot', 'output_multiplot']
-    previous = 'none'
-    _current = 'none'
-
-    @property
-    def prev_cur(self):
-        return (self.previous, self.current)
-
-    @property
-    def current(self):
-        return self._current
-
-    @current.setter
-    def current(self, value):
-        self.previous = self._current
-        self._current = value
-
-    def transition(self, stmt):
-        lookup = {
-            s: getattr(self, f'transition_from_{s}')
-            for s in self.states
-        }
-        _transition = lookup[self.current]
-        self.previous = self._current
-        return _transition(stmt)
-
-    def transition_from_plot(self, stmt):
-        if self.current == 'output':
-            self.current = 'none'
-        elif self.current == 'plot':
-            if is_plot(stmt):
-                self.current = 'plot'
-            elif is_set_output(stmt):
-                self.current = 'output'
-            else:
-                self.current = 'none'
-
-    def transition_from_none(self, stmt):
-        if is_plot(stmt):
-            self.current = 'plot'
-        elif is_set_output(stmt):
-            self.current = 'output'
-        elif is_set_multiplot(stmt):
-            self.current = 'multiplot'
-
-    def transition_from_output(self, stmt):
-        if is_plot(stmt):
-            self.current = 'plot'
-        elif is_set_multiplot(stmt):
-            self.current = 'output_multiplot'
-        elif is_unset_output(stmt):
-            self.current = 'none'
-
-    def transition_from_multiplot(self, stmt):
-        if is_unset_multiplot(stmt):
-            self.current = 'none'
-
-    def transition_from_output_multiplot(self, stmt):
-        if is_unset_multiplot(stmt):
-            self.previous = self.current
-            self.current = 'output'
diff --git a/gnuplot_kernel/tests/conftest.py b/gnuplot_kernel/tests/conftest.py
deleted file mode 100644
index fd226a7..0000000
--- a/gnuplot_kernel/tests/conftest.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import os
-
-
-def remove_files(*filenames):
-    """
-    Remove the files created during the test
-    """
-    for filename in filenames:
-        try:
-            os.remove(filename)
-        except FileNotFoundError:
-            pass
diff --git a/how-to-release.rst b/how-to-release.rst
deleted file mode 100644
index 6cc4799..0000000
--- a/how-to-release.rst
+++ /dev/null
@@ -1,78 +0,0 @@
-##############
-How to release
-##############
-
-Testing
-=======
-
-* `cd` to the root of project and run
-  ::
-
-    make test
-
-* Once all the tests pass move on
-
-
-Tagging
-=======
-
-* Check out the master branch, open `gnuplot_kernel/kernel.py`
-  increment the `__version__` string and make a commit.
-
-* Tag with the version number e.g
-  ::
-
-    git tag -a v0.1.0 -m 'Version 0.1.0'
-
-  Note the `v` before the version number.
-
-* Push tag upstream
-  ::
-
-    git push upstream v0.1.0
-
-
-Packaging
-=========
-
-* Make sure your `.pypirc` file is setup
-  `correctly `_.
-  ::
-
-    cat ~/.pypirc
-
-
-* Build distribution
-  ::
-
-    make dist
-
-* (optional) Upload to PyPi test repository
-  and then try install and test
-  ::
-
-     make release-test
-
-     mkvirtualenv test-gnuplot-kernel
-
-     pip install -r pypyitest gnuplot_kernel
-
-     cd cdsitepackages
-
-     cd gnuplot_kernel
-
-     nosetests
-
-     cd ..
-
-     deactivate
-
-     rmvirtualenv test-gnuplot-kernel
-
-
-* Upload to PyPi
-  ::
-
-    make release
-
-* Done.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d3bd177
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,179 @@
+[project]
+name = "gnuplot_kernel"
+description = "A gnuplot kernel for Jupyter"
+license = {file = "LICENSE"}
+authors = [
+  {name = "Hassan Kibirige", email = "has2k1@gmail.com"},
+]
+dynamic = ["version"]
+readme = "README.md"
+classifiers = [
+    "Framework :: IPython",
+    "Intended Audience :: End Users/Desktop",
+    "Intended Audience :: Science/Research",
+    "License :: OSI Approved :: BSD License",
+    "Programming Language :: Python :: 3",
+    "Topic :: Scientific/Engineering :: Visualization",
+    "Topic :: System :: Shells",
+]
+
+dependencies = [
+    "metakernel>=0.30.0",
+    "jupyter>=1.1.1",
+]
+
+requires-python = ">=3.10"
+
+[project.optional-dependencies]
+
+dev = [
+    "gnuplot_kernel[test]",
+    "ruff",
+    "matplotlib>=3.8.0",
+    "pyright>=1.1.405",
+]
+
+test = [
+    "pytest-cov>=4.0.0",
+]
+
+
+[project.urls]
+homepage = "https://github.com/has2k1/gnuplot_kernel"
+repository = "https://github.com/has2k1/gnuplot_kernel"
+ci = "https://github.com/has2k1/gnuplot_kernel/actions"
+
+########## Build System ##########
+[build-system]
+requires = [
+    "setuptools>=59",
+    "setuptools_scm[toml]>=6.4",
+    "wheel",
+]
+build-backend = "setuptools.build_meta"
+
+########## Tool - Setuptools ##########
+# https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
+
+[tool.setuptools_scm]
+fallback_version = "999"
+version_scheme = "post-release"
+
+
+########## Tool - Pytest ##########
+[tool.pytest.ini_options]
+testpaths = [
+    "tests"
+]
+addopts = "--pyargs --cov=src/gnuplot_kernel --cov-report=xml --import-mode=importlib"
+
+########## Tool - Coverage ##########
+# Coverage.py
+[tool.coverage.run]
+branch = true
+source = ["src"]
+include = [
+   "src/gnuplot_kernel/*"
+]
+omit = [
+    "src/gnuplot_kernel/__main__.py"
+]
+disable_warnings = ["include-ignored"]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    "def __repr__",
+    "if __name__ == .__main__.:",
+    "def register_ipython_magics",
+    "def load_ipython_extension"
+]
+precision = 1
+
+########## Tool - Ruff ##########
+[tool.ruff]
+line-length = 79
+
+[tool.ruff.lint]
+select = [
+   "E",
+   "F",
+   "I",
+   "TCH",
+   "Q",
+   "PIE",
+   "PTH",
+   "PD",
+   "PYI",
+   "RSE",
+   "SIM",
+   "B904",
+   "FLY",
+   "NPY",
+   "PERF102"
+]
+ignore = [
+    "E741",  # Ambiguous l
+    "E743",  # Ambiguous I
+    # .reset_index, .rename, .replace
+    # This will remain the correct choice until we enable copy-on-write
+    "PD002",
+    # Use specific rule codes when ignoring type issues and
+    # not # type: ignore
+    "PGH003"
+]
+
+# Allow autofix for all enabled rules (when `--fix`) is provided.
+fixable = ["ALL"]
+unfixable = []
+
+# Exclude a variety of commonly ignored directories.
+exclude = [
+    "**/__pycache__",
+    "**/*.ipynb",
+]
+
+# Allow unused variables when underscore-prefixed.
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+
+########## Tool - Pyright ##########
+[tool.pyright]
+# Paths of directories or files that should be included. If no paths
+# are specified, pyright defaults to the directory that contains the
+# config file. Paths may contain wildcard characters ** (a directory or
+# multiple levels of directories), * (a sequence of zero or more
+# characters), or ? (a single character). If no include paths are
+# specified, the root path for the workspace is assumed.
+include = [
+    "src/gnuplot_kernel/"
+]
+
+# Paths of directories or files whose diagnostic output (errors and
+# warnings) should be suppressed even if they are an included file or
+# within the transitive closure of an included file. Paths may contain
+# wildcard characters ** (a directory or multiple levels of
+# directories), * (a sequence of zero or more characters), or ? (a
+# single character).
+ignore = []
+
+# Set of identifiers that should be assumed to contain a constant
+# value wherever used within this program. For example, { "DEBUG": true
+# } indicates that pyright should assume that the identifier DEBUG will
+# always be equal to True. If this identifier is used within a
+# conditional expression (such as if not DEBUG:) pyright will use the
+# indicated value to determine whether the guarded block is reachable
+# or not. Member expressions that reference one of these constants
+# (e.g. my_module.DEBUG) are also supported.
+defineConstant = { DEBUG = true }
+
+# typeCheckingMode = "strict"
+useLibraryCodeForTypes = true
+reportUnnecessaryTypeIgnoreComment = true
+
+# Specifies a list of execution environments (see below). Execution
+# environments are searched from start to finish by comparing the path
+# of a source file with the root path specified in the execution
+# environment.
+executionEnvironments = []
+
+stubPath = ""
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index ab25f32..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,3 +0,0 @@
-[pytest]
-pyargs = gnuplot_kernel
-doctest_optionflags = ALLOW_UNICODE ALLOW_BYTES NORMALIZE_WHITESPACE
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index aa094d9..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-numpy
-matplotlib
diff --git a/requirements_dev.txt b/requirements_dev.txt
deleted file mode 100644
index 8c115f6..0000000
--- a/requirements_dev.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-# example notebooks
-matplotlib
-
-# Testing
-pytest-cov
-coveralls
-
-# Release
-wheel
-twine
-
-# Linting
-pycodestyle
-flake8
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index c9d5653..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-[bdist_wheel]
-
-[flake8]
-# Add E741,E743 to the defaults in 3.5.0
-ignore = E121,E123,E126,E226,E24,E704,W503,W504,E741,E743
diff --git a/setup.py b/setup.py
deleted file mode 100644
index cf032f9..0000000
--- a/setup.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import io
-from setuptools import find_packages, setup
-
-
-__author__ = 'Hassan Kibirige'
-__email__ = 'has2k1@gmail.com'
-__description__ = 'A gnuplot kernel for Jupyter'
-__license__ = 'BSD'
-__url__ = 'https://github.com/has2k1/gnuplot_kernel'
-__classifiers__ = [
-    'Framework :: IPython',
-    'Intended Audience :: End Users/Desktop',
-    'Intended Audience :: Science/Research',
-    'License :: OSI Approved :: BSD License',
-    'Programming Language :: Python :: 3',
-    'Topic :: Scientific/Engineering :: Visualization',
-    'Topic :: System :: Shells',
-]
-__install_requires__ = [
-    'metakernel >= 0.24.4',
-    'notebook >= 5.5.0'
-]
-__packages__ = find_packages(
-    include=['gnuplot_kernel', 'gnuplot_kernel.*']
-)
-__package_data__ = {'gnuplot_kernel': ['images/*.png']}
-
-with io.open('gnuplot_kernel/kernel.py', encoding='utf-8') as fid:
-    for line in fid:
-        if line.startswith('__version__'):
-            __version__ = line.strip().split()[-1][1:-1]
-            break
-
-with open('README.rst') as f:
-    readme = f.read()
-
-setup(name='gnuplot_kernel',
-      author=__author__,
-      maintainer=__author__,
-      maintainer_email=__email__,
-      version=__version__,
-      description=__description__,
-      long_description=readme,
-      license=__license__,
-      url=__url__,
-      python_requires='>=3.6',
-      install_requires=__install_requires__,
-      packages=__packages__,
-      package_data=__package_data__,
-      classifiers=__classifiers__
-      )
diff --git a/src/gnuplot_kernel/__init__.py b/src/gnuplot_kernel/__init__.py
new file mode 100644
index 0000000..2912503
--- /dev/null
+++ b/src/gnuplot_kernel/__init__.py
@@ -0,0 +1,23 @@
+"""
+Gnuplot Kernel Package
+"""
+
+from contextlib import suppress
+from importlib.metadata import PackageNotFoundError
+
+from .kernel import GnuplotKernel
+from .magics import register_ipython_magics
+from .utils import get_version
+
+__all__ = ["GnuplotKernel"]
+
+
+with suppress(PackageNotFoundError):
+    __version__ = get_version("gnuplot_kernel")
+
+
+def load_ipython_extension(ipython):
+    """
+    Load the extension in IPython
+    """
+    register_ipython_magics()
diff --git a/gnuplot_kernel/__main__.py b/src/gnuplot_kernel/__main__.py
similarity index 70%
rename from gnuplot_kernel/__main__.py
rename to src/gnuplot_kernel/__main__.py
index cf569a0..4b17c0f 100644
--- a/gnuplot_kernel/__main__.py
+++ b/src/gnuplot_kernel/__main__.py
@@ -1,5 +1,4 @@
 from .kernel import GnuplotKernel
 
-
-if __name__ == '__main__':
+if __name__ == "__main__":
     GnuplotKernel.run_as_main()
diff --git a/gnuplot_kernel/exceptions.py b/src/gnuplot_kernel/exceptions.py
similarity index 99%
rename from gnuplot_kernel/exceptions.py
rename to src/gnuplot_kernel/exceptions.py
index 02bebbb..29dd510 100644
--- a/gnuplot_kernel/exceptions.py
+++ b/src/gnuplot_kernel/exceptions.py
@@ -1,5 +1,4 @@
 class GnuplotError(Exception):
-
     def __init__(self, message):
         self.args = (message,)
         self.message = message
diff --git a/gnuplot_kernel/images/logo-32x32.png b/src/gnuplot_kernel/images/logo-32x32.png
similarity index 100%
rename from gnuplot_kernel/images/logo-32x32.png
rename to src/gnuplot_kernel/images/logo-32x32.png
diff --git a/gnuplot_kernel/images/logo-64x64.png b/src/gnuplot_kernel/images/logo-64x64.png
similarity index 100%
rename from gnuplot_kernel/images/logo-64x64.png
rename to src/gnuplot_kernel/images/logo-64x64.png
diff --git a/gnuplot_kernel/images/logo.gp b/src/gnuplot_kernel/images/logo.gp
similarity index 100%
rename from gnuplot_kernel/images/logo.gp
rename to src/gnuplot_kernel/images/logo.gp
diff --git a/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py
new file mode 100644
index 0000000..5701932
--- /dev/null
+++ b/src/gnuplot_kernel/kernel.py
@@ -0,0 +1,370 @@
+from __future__ import annotations
+
+import contextlib
+import sys
+import uuid
+from itertools import chain
+from pathlib import Path
+from typing import cast
+
+from IPython.display import SVG, Image
+from metakernel import MetaKernel, ProcessMetaKernel, pexpect
+from metakernel.process_metakernel import TextOutput
+
+from .exceptions import GnuplotError
+from .replwrap import PROMPT_RE, PROMPT_REMOVE_RE, GnuplotREPLWrapper
+from .statement import STMT
+from .utils import get_version
+
+IMG_COUNTER = "__gpk_img_index"
+IMG_COUNTER_FMT = "%03d"
+
+
+class GnuplotKernel(ProcessMetaKernel):
+    """
+    GnuplotKernel
+    """
+
+    implementation = "Gnuplot Kernel"
+    implementation_version = get_version("gnuplot_kernel")
+    language = "gnuplot"
+    _banner = "Gnuplot Kernel"
+    language_version = "5.0"  # pyright: ignore[reportAssignmentType,reportIncompatibleMethodOverride]
+    language_info = {
+        "mimetype": "text/x-gnuplot",
+        "name": "gnuplot",
+        "file_extension": ".gp",
+        "codemirror_mode": "Octave",
+        "help_links": MetaKernel.help_links,
+    }
+    kernel_json = {
+        "argv": [
+            sys.executable,
+            "-m",
+            "gnuplot_kernel",
+            "-f",
+            "{connection_file}",
+        ],
+        "display_name": "gnuplot",
+        "language": "gnuplot",
+        "name": "gnuplot",
+    }
+
+    inline_plotting = True
+    reset_code = ""
+    _first = True
+    _image_files: list[Path] = []
+    _error = False
+
+    wrapper: GnuplotREPLWrapper
+    _bad_prompts: set = set()
+
+    def check_prompt(self):
+        """
+        Print warning if the prompt looks bad
+
+        A bad prompt is one that does not contain the string 'gnuplot>'.
+        The warning is printed once per bad prompt.
+        """
+        prompt = cast("str", self.wrapper.prompt)
+        if "gnuplot>" not in prompt and prompt not in self._bad_prompts:
+            print(f"Warning: The prompt is currently set to '{prompt}'")
+            self._bad_prompts.add(prompt)
+
+    def do_execute_direct(self, code, silent=False):
+        # We wrap the real function so that gnuplot_kernel can
+        # give a message when an exception occurs. Without
+        # this, an exception happens silently
+        try:
+            return self._do_execute_direct(code)
+        except Exception as err:
+            print(f"Error: {err}")
+            raise err
+
+    def _do_execute_direct(self, code: str) -> TextOutput | None:
+        """
+        Execute gnuplot code
+        """
+        if self._first:
+            self._first = False
+            self.handle_plot_settings()
+
+        if self.inline_plotting:
+            code = self.add_inline_image_statements(code)
+
+        success = True
+
+        try:
+            result = super().do_execute_direct(code, silent=True)
+        except GnuplotError as e:
+            result = TextOutput(e.message)
+            success = False
+
+        if self.reset_code:
+            super().do_execute_direct(self.reset_code, silent=True)
+
+        if self.inline_plotting:
+            if success:
+                self.display_images()
+            self.delete_image_files()
+
+        self.check_prompt()
+
+        # No empty strings
+        return result if (result and result.output) else None
+
+    def add_inline_image_statements(self, code: str) -> str:
+        """
+        Add 'set output ...' before every plotting statement
+
+        This is what powers inline plotting
+        """
+
+        # "set output sprintf('foobar.%d.png', counter);"
+        # "counter=counter+1"
+        def set_output_inline(lines):
+            tpl = self.get_image_filename()
+            if tpl:
+                cmd = (
+                    f"set output sprintf('{tpl}', {IMG_COUNTER});"
+                    f"{IMG_COUNTER}={IMG_COUNTER}+1"
+                )
+                lines.append(cmd)
+
+        # We automatically create an output file for the following
+        # cases if the user has not created one.
+        #    - before every plot statement that is not in a
+        #      multiplot block
+        #    - before every multiplot block
+
+        lines = []
+        sm = StateMachine()
+        is_joined_stmt = False
+        for line in code.splitlines():
+            stmt = STMT(line)
+            sm.transition(stmt)
+            add_inline_plot = (
+                sm.prev_cur
+                in (("none", "plot"), ("none", "multiplot"), ("plot", "plot"))
+                and not is_joined_stmt
+            )
+            if add_inline_plot:
+                set_output_inline(lines)
+
+            lines.append(stmt)
+            is_joined_stmt = stmt.strip().endswith("\\")
+
+        # Make gnuplot flush the output
+        if not lines[-1].endswith("\\"):
+            lines.append("unset output")
+        code = "\n".join(lines)
+        return code
+
+    def get_image_filename(self):
+        """
+        Create file to which gnuplot will write the plot
+
+        Returns the filename.
+        """
+        # we could use tempfile.NamedTemporaryFile but we do not
+        # want to create the file, gnuplot will create it.
+        # Later on when we check if the file exists we know
+        # whodunnit.
+        fmt = self.plot_settings["format"]
+        filename = Path(
+            f"/tmp/gnuplot-inline-{uuid.uuid1()}.{IMG_COUNTER_FMT}.{fmt}"
+        )
+        self._image_files.append(filename)
+        return filename
+
+    def iter_image_files(self):
+        """
+        Iterate over the image files
+        """
+        it = chain(
+            *[
+                sorted(f.parent.glob(f.name.replace(IMG_COUNTER_FMT, "*")))
+                for f in self._image_files
+            ]
+        )
+        return it
+
+    def display_images(self):
+        """
+        Display images if gnuplot wrote to them
+        """
+        settings = self.plot_settings
+        if self.inline_plotting:
+            _Image = SVG if settings["format"] == "svg" else Image
+        else:
+            return
+
+        for filename in self.iter_image_files():
+            try:
+                size = filename.stat().st_size
+            except FileNotFoundError:
+                size = 0
+
+            if not size:
+                msg = (
+                    "Failed to read and display image file from gnuplot."
+                    "Possibly:\n"
+                    "1. You have plotted to a non interactive terminal.\n"
+                    "2. You have an invalid expression."
+                )
+                print(msg)
+                continue
+
+            im = _Image(str(filename))
+            self.Display(im)
+
+    def delete_image_files(self):
+        """
+        Delete the image files
+        """
+        # After display_images(), the real images are
+        # no longer required.
+        for filename in self.iter_image_files():
+            with contextlib.suppress(FileNotFoundError):
+                filename.unlink()
+
+        self._image_files = []
+
+    def makeWrapper(self):
+        """
+        Start gnuplot and return wrapper around the REPL
+        """
+        if pexpect.which("gnuplot"):
+            program = "gnuplot"
+        elif pexpect.which("gnuplot.exe"):
+            program = "gnuplot.exe"
+        else:
+            raise Exception("gnuplot not found.")
+
+        # We don't want help commands getting stuck,
+        # use a non interactive PAGER
+        if pexpect.which("env") and pexpect.which("cat"):
+            command = "env PAGER=cat {}".format(program)
+        else:
+            command = program
+
+        wrapper = GnuplotREPLWrapper(
+            cmd_or_spawn=command,
+            prompt_regex=PROMPT_RE,
+            prompt_change_cmd=None,
+        )
+        # No sleeping before sending commands to gnuplot
+        wrapper.child.delaybeforesend = 0
+        return wrapper
+
+    def do_shutdown(self, restart):
+        """
+        Exit the gnuplot process and any other underlying stuff
+        """
+        self.wrapper.exit()
+        super().do_shutdown(restart)
+
+    def get_kernel_help_on(self, info, level=0, none_on_fail=False):
+        obj = info.get("help_obj", "")
+        if not obj or len(obj.split()) > 1:
+            return None if none_on_fail else ""
+        res = cast("TextOutput", self.do_execute_direct("help %s" % obj))
+        text = PROMPT_REMOVE_RE.sub("", res.output)
+        self.check_prompt()
+        return text
+
+    def reset_image_counter(self):
+        # Incremented after every plot image, and used in the
+        # plot image filename. Makes plotting in loops do_for
+        # loops work
+        cmd = f"{IMG_COUNTER}=0"
+        self.do_execute_direct(cmd)
+
+    def handle_plot_settings(self):
+        """
+        Handle the current plot settings
+
+        This is used by the gnuplot line magic. The plot magic
+        is innadequate.
+        """
+        settings = self.plot_settings
+        if "termspec" not in settings or not settings["termspec"]:
+            settings["termspec"] = 'pngcairo size 385, 256 font "Arial,10"'
+        if "format" not in settings or not settings["format"]:
+            settings["format"] = "png"
+
+        self.inline_plotting = settings["backend"] == "inline"
+
+        cmd = "set terminal {}".format(settings["termspec"])
+        self.do_execute_direct(cmd)
+        self.reset_image_counter()
+
+
+class StateMachine:
+    """
+    Track context given gnuplot statements
+
+    This is used to help us tell when to inject commands (i.e. set output)
+    that for inline plotting in the notebook.
+    """
+
+    states = ["none", "plot", "output", "multiplot", "output_multiplot"]
+    previous = "none"
+    _current = "none"
+
+    @property
+    def prev_cur(self):
+        return (self.previous, self.current)
+
+    @property
+    def current(self):
+        return self._current
+
+    @current.setter
+    def current(self, value):
+        self.previous = self._current
+        self._current = value
+
+    def transition(self, stmt):
+        lookup = {
+            s: getattr(self, f"transition_from_{s}") for s in self.states
+        }
+        _transition = lookup[self.current]
+        self.previous = self._current
+        return _transition(stmt)
+
+    def transition_from_plot(self, stmt):
+        if self.current == "output":
+            self.current = "none"
+        elif self.current == "plot":
+            if stmt.is_plot():
+                self.current = "plot"
+            elif stmt.is_set_output():
+                self.current = "output"
+            else:
+                self.current = "none"
+
+    def transition_from_none(self, stmt):
+        if stmt.is_plot():
+            self.current = "plot"
+        elif stmt.is_set_output():
+            self.current = "output"
+        elif stmt.is_set_multiplot():
+            self.current = "multiplot"
+
+    def transition_from_output(self, stmt):
+        if stmt.is_plot():
+            self.current = "plot"
+        elif stmt.is_set_multiplot():
+            self.current = "output_multiplot"
+        elif stmt.is_unset_output():
+            self.current = "none"
+
+    def transition_from_multiplot(self, stmt):
+        if stmt.is_unset_multiplot():
+            self.current = "none"
+
+    def transition_from_output_multiplot(self, stmt):
+        if stmt.is_unset_multiplot():
+            self.previous = self.current
+            self.current = "output"
diff --git a/gnuplot_kernel/magics/__init__.py b/src/gnuplot_kernel/magics/__init__.py
similarity index 55%
rename from gnuplot_kernel/magics/__init__.py
rename to src/gnuplot_kernel/magics/__init__.py
index fe11134..486cb06 100644
--- a/gnuplot_kernel/magics/__init__.py
+++ b/src/gnuplot_kernel/magics/__init__.py
@@ -1,3 +1,3 @@
 from .gnuplot_magic import GnuplotMagic, register_ipython_magics
 
-__all__ = ['GnuplotMagic', 'register_ipython_magics']
+__all__ = ["GnuplotMagic", "register_ipython_magics"]
diff --git a/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py
similarity index 73%
rename from gnuplot_kernel/magics/gnuplot_magic.py
rename to src/gnuplot_kernel/magics/gnuplot_magic.py
index 7cf6af3..b0bb4d2 100644
--- a/gnuplot_kernel/magics/gnuplot_magic.py
+++ b/src/gnuplot_kernel/magics/gnuplot_magic.py
@@ -1,5 +1,4 @@
-from IPython.core.magic import (register_line_magic,
-                                register_cell_magic)
+from IPython.core.magic import register_cell_magic, register_line_magic
 from metakernel import Magic
 
 
@@ -44,22 +43,25 @@ def line_gnuplot(self, *args):
 
         """
         backend, terminal, termspec = _parse_args(args)
-        terminal = terminal or 'pngcairo'
-        inline_terminals = {'pngcairo': 'png',
-                            'png': 'png',
-                            'jpeg': 'jpg',
-                            'svg': 'svg'}
-        format = inline_terminals.get(terminal, 'png')
-
-        if backend == 'inline':
-            if terminal not in inline_terminals:
-                msg = ("For inline plots, the terminal must be "
-                       "one of pngcairo, jpeg, svg or png")
-                raise ValueError(msg)
-
-        self.kernel.plot_settings['backend'] = backend
-        self.kernel.plot_settings['termspec'] = termspec
-        self.kernel.plot_settings['format'] = format
+        terminal = terminal or "pngcairo"
+        inline_terminals = {
+            "pngcairo": "png",
+            "png": "png",
+            "jpeg": "jpg",
+            "svg": "svg",
+        }
+        format = inline_terminals.get(terminal, "png")
+
+        if backend == "inline" and terminal not in inline_terminals:
+            msg = (
+                "For inline plots, the terminal must be "
+                "one of pngcairo, jpeg, svg or png"
+            )
+            raise ValueError(msg)
+
+        self.kernel.plot_settings["backend"] = backend
+        self.kernel.plot_settings["termspec"] = termspec
+        self.kernel.plot_settings["format"] = format
         self.kernel.handle_plot_settings()
 
     def cell_gnuplot(self):
@@ -106,22 +108,21 @@ def register_ipython_magics():
     # not the main kernel and it may not have access
     # to some functionality. This connects it to the
     # main kernel.
-    from IPython import get_ipython
+    from IPython.core.getipython import get_ipython
+
     ip = get_ipython()
-    kernel.makeSubkernel(ip.parent)
+    kernel.makeSubkernel(ip.parent)  # pyright: ignore[reportOptionalMemberAccess]
 
     # Make magics callable:
-    kernel.line_magics['gnuplot'] = magic
-    kernel.cell_magics['gnuplot'] = magic
+    kernel.line_magics["gnuplot"] = magic
+    kernel.cell_magics["gnuplot"] = magic
 
     @register_line_magic
-    def gnuplot(line):
+    def _(line):
         magic.line_gnuplot(line)
 
-    del gnuplot
-
     @register_cell_magic
-    def gnuplot(line, cell):
+    def _(line, cell):
         magic.code = cell
         magic.cell_gnuplot()
 
@@ -131,13 +132,13 @@ def _parse_args(args):
     Process the gnuplot line magic arguments
     """
     if len(args) > 1:
-        raise TypeError()
+        raise TypeError
 
     sargs = args[0].split()
     backend = sargs[0]
-    if backend == 'inline':
+    if backend == "inline":
         try:
-            termspec = ' '.join(sargs[1:])
+            termspec = " ".join(sargs[1:])
             terminal = sargs[1]
         except IndexError:
             termspec = None
diff --git a/gnuplot_kernel/magics/reset_magic.py b/src/gnuplot_kernel/magics/reset_magic.py
similarity index 94%
rename from gnuplot_kernel/magics/reset_magic.py
rename to src/gnuplot_kernel/magics/reset_magic.py
index dfb2935..c936f2d 100644
--- a/gnuplot_kernel/magics/reset_magic.py
+++ b/src/gnuplot_kernel/magics/reset_magic.py
@@ -2,7 +2,6 @@
 
 
 class ResetMagic(Magic):
-
     def line_reset(self, *line):
         """
         %reset - Clear any reset
@@ -10,7 +9,7 @@ def line_reset(self, *line):
         Example:
             %reset
         """
-        self.kernel.reset_code = ''
+        self.kernel.reset_code = ""
 
     def cell_reset(self, line):
         """
diff --git a/gnuplot_kernel/tests/__init__.py b/src/gnuplot_kernel/py.typed
similarity index 100%
rename from gnuplot_kernel/tests/__init__.py
rename to src/gnuplot_kernel/py.typed
diff --git a/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py
similarity index 68%
rename from gnuplot_kernel/replwrap.py
rename to src/gnuplot_kernel/replwrap.py
index f0399a5..eba32de 100644
--- a/gnuplot_kernel/replwrap.py
+++ b/src/gnuplot_kernel/replwrap.py
@@ -1,26 +1,54 @@
 import re
-import textwrap
 import signal
+import textwrap
+from typing import cast
 
 from metakernel import REPLWrapper
 from metakernel.pexpect import TIMEOUT
+
 from .exceptions import GnuplotError
 
-CRLF = '\r\n'
-ERROR_REs = [re.compile(r'^\s*\^\s*\n')]
-NO_BLOCK = ''
-PROMPT = 'gnuplot>'
-PROMPT_RE = re.compile(r'^\s*gnuplot>\s*$')
+CRLF = "\r\n"
+NO_BLOCK = ""
+
+ERROR_RE = [
+    re.compile(
+        r"^\s*"
+        r"\^"  # Indicates error on above line
+        r"\s*"
+        r"\n"
+    )
+]
+
+PROMPT_RE = re.compile(
+    # most likely "gnuplot> "
+    r"\w*>\s*$"
+)
+
+PROMPT_REMOVE_RE = re.compile(r"\w*>\s*")
+
+# Data block e.g.
+# $DATA << EOD
+# # x y
+# 1 1
+# 2 2
+# 3 3
+# EOD
+START_DATABLOCK_RE = re.compile(
+    # $DATA << EOD
+    r"^\$\w+\s+<<\s*(?P\w+)$"
+)
+END_DATABLOCK_RE = re.compile(
+    # EOD
+    r"^(?P\w+)$"
+)
 
 
 class GnuplotREPLWrapper(REPLWrapper):
     # The prompt after the commands run
-    prompt = ''
+    prompt = ""
     _blocks = {
-        'data': {
-            'start_re': re.compile(r'^\$\w+\s+<<\s*(?P\w+)$'),
-            'end_re': re.compile(r'^(?P\w+)$')
-        }
+        "data": {"start_re": START_DATABLOCK_RE, "end_re": END_DATABLOCK_RE}
     }
     _current_block = NO_BLOCK
 
@@ -29,20 +57,17 @@ def exit(self):
         Exit the gnuplot process
         """
         try:
-            self._force_prompt(timeout=.01)
+            self._force_prompt(timeout=0.01)
         except GnuplotError:
             return self.child.kill(signal.SIGKILL)
 
-        self.sendline('exit')
+        self.sendline("exit")
 
     def is_error_output(self, text):
         """
         Return True if text is recognised as error text
         """
-        for pattern in ERROR_REs:
-            if pattern.match(text):
-                return True
-        return False
+        return any(pattern.match(text) for pattern in ERROR_RE)
 
     def validate_input(self, code):
         """
@@ -50,22 +75,21 @@ def validate_input(self, code):
 
         Raises GnuplotError if it cannot deal with it.
         """
-        if code.endswith('\\'):
-            raise GnuplotError("Do not execute code that "
-                               "endswith backslash.")
+        if code.endswith("\\"):
+            raise GnuplotError("Do not execute code that endswith backslash.")
 
         # Do not get stuck in the gnuplot process
-        code = code.replace('\\\n', ' ')
+        code = code.replace("\\\n", " ")
         return code
 
     def send(self, cmd):
-        self.child.send(cmd + '\r')
+        self.child.send(cmd + "\r")
 
-    def _force_prompt(self, timeout=30, n=4):
+    def _force_prompt(self, timeout: float = 30, n=4):
         """
         Force prompt
         """
-        quick_timeout = .05
+        quick_timeout = 0.05
 
         if timeout < quick_timeout:
             quick_timeout = timeout
@@ -86,7 +110,7 @@ def patient_prompt():
 
         # Eagerly try to get a prompt quickly,
         # If that fails wait a while
-        for i in range(n):
+        for _ in range(n):
             if quick_prompt():
                 break
 
@@ -96,8 +120,9 @@ def patient_prompt():
         else:
             # Probably long computation going on
             if not patient_prompt():
-                msg = ("gnuplot prompt failed to return in "
-                       "in {} seconds").format(timeout)
+                msg = (
+                    "gnuplot prompt failed to return in in {} seconds"
+                ).format(timeout)
                 raise GnuplotError(msg)
 
     def _end_of_block(self, stmt, end_string):
@@ -114,11 +139,9 @@ def _end_of_block(self, stmt, end_string):
         end_string : str
             Terminal string for the current block.
         """
-        pattern_re = self._blocks[self._current_block]['end_re']
-        m = pattern_re.match(stmt)
-        if m:
-            if m.group('end') == end_string:
-                return True
+        pattern = self._blocks[self._current_block]["end_re"]
+        if m := pattern.match(stmt):
+            return m.group("end") == end_string
         return False
 
     def _start_of_block(self, stmt):
@@ -140,12 +163,11 @@ def _start_of_block(self, stmt):
         """
         # These are used to detect the end of the block
         block_type = NO_BLOCK
-        end_string = ''
+        end_string = ""
         for _type, regexps in self._blocks.items():
-            m = re.match(regexps['start_re'], stmt)
-            if m:
+            if m := regexps["start_re"].match(stmt):
                 block_type = _type
-                end_string = m.group('end')
+                end_string = m.group("end")
                 break
         return block_type, end_string
 
@@ -159,18 +181,18 @@ def _splitlines(self, code):
         # get a prompt.
         lines = []
         block_lines = []
-        end_string = ''
+        end_string = ""
         stmts = code.splitlines()
         for stmt in stmts:
             if self._current_block:
                 block_lines.append(stmt)
                 if self._end_of_block(stmt, end_string):
                     self._current_block = NO_BLOCK
-                    block_lines.append('')
-                    block = '\n'.join(block_lines)
+                    block_lines.append("")
+                    block = "\n".join(block_lines)
                     lines.append(block)
                     block_lines = []
-                    end_string = ''
+                    end_string = ""
             else:
                 block_name, end_string = self._start_of_block(stmt)
                 if block_name:
@@ -180,25 +202,32 @@ def _splitlines(self, code):
                     lines.append(stmt)
 
         if self._current_block:
-            msg = 'Error: {} block not terminated correctly.'.format(
-                self._current_block)
+            msg = "Error: {} block not terminated correctly.".format(
+                self._current_block
+            )
             self._current_block = NO_BLOCK
             raise GnuplotError(msg)
 
         return lines
 
-    def run_command(self, code, timeout=-1, stream_handler=None,
-                    stdin_handler=None):
+    def run_command(  # pyright: ignore[reportIncompatibleMethodOverride]
+        self,
+        command,
+        timeout=-1,
+        stream_handler=None,
+        line_handler=None,
+        stdin_handler=None,
+    ):
         """
         Run code
 
         This overrides the baseclass method to allow for
         input validation and error handling.
         """
-        code = self.validate_input(code)
+        command = self.validate_input(command)
 
         # Split up multiline commands and feed them in bit-by-bit
-        stmts = self._splitlines(code)
+        stmts = self._splitlines(command)
         output_lines = []
         for line in stmts:
             self.send(line)
@@ -206,21 +235,20 @@ def run_command(self, code, timeout=-1, stream_handler=None,
 
             # Removing any crlfs makes subsequent
             # processing cleaner
-            retval = self.child.before.replace(CRLF, '\n')
+            retval = cast("str", self.child.before).replace(CRLF, "\n")
             self.prompt = self.child.after
             if self.is_error_output(retval):
-                msg = '{}\n{}'.format(
-                    line, textwrap.dedent(retval))
+                msg = "{}\n{}".format(line, textwrap.dedent(retval))
                 raise GnuplotError(msg)
 
             # Sometimes block stmts like datablocks make the
             # the prompt leak into the return value
-            retval = retval.replace(PROMPT,  '').strip(' ')
+            retval = PROMPT_REMOVE_RE.sub("", retval).strip(" ")
 
             # Some gnuplot installations return the input statements
             # We do not count those as output
             if retval.strip() != line.strip():
                 output_lines.append(retval)
 
-        output = ''.join(output_lines)
+        output = "".join(output_lines)
         return output
diff --git a/src/gnuplot_kernel/statement.py b/src/gnuplot_kernel/statement.py
new file mode 100644
index 0000000..26a98ab
--- /dev/null
+++ b/src/gnuplot_kernel/statement.py
@@ -0,0 +1,98 @@
+"""
+Recognising gnuplot statements
+"""
+
+import re
+
+# name of the command i.e first token
+CMD_RE = re.compile(
+    r"^\s*"
+    r"(?P"
+    r"\w+"  # The command
+    r")"
+    r"\s?"
+)
+
+# plot statements
+PLOT_RE = re.compile(
+    r"^\s*"
+    r"(?P"
+    r"plot|plo|pl|p|"
+    r"splot|splo|spl|sp|"
+    r"replot|replo|repl|rep"
+    r")"
+    r"\s?"
+)
+
+# "set multiplot" and abbreviated variants
+SET_MULTIPLE_RE = re.compile(
+    r"\s*"
+    r"set"
+    r"\s+"
+    r"multip(?:lot|lo|l)?\b"
+    r"\b"
+)
+
+# "unset multiplot" and abbreviated variants
+UNSET_MULTIPLE_RE = re.compile(
+    r"\s*"
+    r"(?:unset|unse|uns)"
+    r"\s+"
+    r"multip(?:lot|lo|l)?\b"
+    r"\b"
+)
+
+
+# "set output" and abbreviated variants
+SET_OUTPUT_RE = re.compile(
+    r"\s*"
+    r"set"
+    r"\s+"
+    r"(?:output|outpu|outp|out|ou|o)"
+    r"(?:\s+|$)"
+)
+
+# "unset output" and abbreviated variants
+UNSET_OUTPUT_RE = re.compile(
+    r"\s*"
+    r"(?:unset|unse|uns)"
+    r"\s+"
+    r"(?:output|outpu|outp|out|ou|o)"
+    r"(?:\s+|$)"
+)
+
+
+class STMT(str):
+    """
+    A gnuplot statement
+    """
+
+    def is_set_output(self):
+        """
+        Return True if stmt is a 'set output' statement
+        """
+        return bool(SET_OUTPUT_RE.match(self))
+
+    def is_unset_output(self):
+        """
+        Return True if stmt is an 'unset output' statement
+        """
+        return bool(UNSET_OUTPUT_RE.match(self))
+
+    def is_set_multiplot(self):
+        """
+        Return True if stmt is a "set multiplot" statement
+        """
+        return bool(SET_MULTIPLE_RE.match(self))
+
+    def is_unset_multiplot(self):
+        """
+        Return True if stmt is a "unset multiplot" statement
+        """
+        return bool(UNSET_MULTIPLE_RE.match(self))
+
+    def is_plot(self):
+        """
+        Return True if stmt is a plot statement
+        """
+        return bool(PLOT_RE.match(self))
diff --git a/src/gnuplot_kernel/utils.py b/src/gnuplot_kernel/utils.py
new file mode 100644
index 0000000..67ea9c3
--- /dev/null
+++ b/src/gnuplot_kernel/utils.py
@@ -0,0 +1,17 @@
+"""
+Useful functions
+"""
+
+from importlib.metadata import version
+
+
+def get_version(package: str) -> str:
+    """
+    Return the package version
+
+    Raises PackageNotFoundError if package is not installed
+    """
+    # The goal of this function to avoid circular imports if the
+    # version is required in 2 or more spot before the package has
+    # been fully installed
+    return version(package)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..2dd7f4b
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,24 @@
+import os
+from contextlib import contextmanager
+from pathlib import Path
+
+os.environ["JUPYTER_PLATFORM_DIRS"] = "1"
+
+
+@contextmanager
+def ensure_deleted(*paths: str):
+    """
+    Ensures the given file paths are deleted when the block exits
+
+    Parameters
+    ----------
+    *paths : pathlib.Path
+        One or more file paths
+    """
+    paths = tuple(Path(path) for path in paths)
+
+    try:
+        yield paths if len(paths) > 1 else paths[0]
+    finally:
+        for path in paths:
+            Path(path).unlink()
diff --git a/gnuplot_kernel/tests/test_kernel.py b/tests/test_kernel.py
similarity index 61%
rename from gnuplot_kernel/tests/test_kernel.py
rename to tests/test_kernel.py
index 1b8ec0e..2704d30 100644
--- a/gnuplot_kernel/tests/test_kernel.py
+++ b/tests/test_kernel.py
@@ -1,12 +1,12 @@
-import os
 import weakref
+from pathlib import Path
+
+from metakernel.tests.utils import clear_log_text, get_kernel, get_log_text
 
-from metakernel.tests.utils import (get_kernel, get_log_text,
-                                    clear_log_text)
 from gnuplot_kernel import GnuplotKernel
 from gnuplot_kernel.magics import GnuplotMagic
 
-from .conftest import remove_files
+from .conftest import ensure_deleted
 
 # Note: Empty lines after indented triple quoted may
 # lead to empty statements which could obscure the
@@ -23,10 +23,7 @@ def get_kernel(klass=None):
     """
     Create & add to registry of live kernels
     """
-    if klass:
-        kernel = _get_kernel(klass)
-    else:
-        kernel = _get_kernel()
+    kernel = _get_kernel(klass) if klass else _get_kernel()
     KERNELS.add(kernel)
     return kernel
 
@@ -46,14 +43,15 @@ def teardown():
 
 # Normal workflow tests #
 
+
 def test_inline_magic():
     kernel = get_kernel(GnuplotKernel)
 
     # gnuplot line magic changes the plot settings
-    kernel.call_magic('%gnuplot pngcairo size 560, 420')
-    assert kernel.plot_settings['backend'] == 'pngcairo'
-    assert kernel.plot_settings['format'] == 'png'
-    assert kernel.plot_settings['termspec'] == 'pngcairo size 560, 420'
+    kernel.call_magic("%gnuplot pngcairo size 560, 420")
+    assert kernel.plot_settings["backend"] == "pngcairo"
+    assert kernel.plot_settings["format"] == "png"
+    assert kernel.plot_settings["termspec"] == "pngcairo size 560, 420"
 
 
 def test_print():
@@ -61,49 +59,50 @@ def test_print():
     code = "print cos(0)"
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert '1.0' in text
+    assert "1.0" in text
 
 
 def test_file_plots():
     kernel = get_kernel(GnuplotKernel)
-    kernel.call_magic('%gnuplot pngcairo size 560, 420')
+    kernel.call_magic("%gnuplot pngcairo size 560, 420")
 
     # With a non-inline terminal plot gets created
-    code = """
-    set output 'sine.png'
-    plot sin(x)
-    """
-    kernel.do_execute(code)
-    assert os.path.exists('sine.png')
+    with ensure_deleted("sine.png") as f1:
+        code = f"""
+        set output '{f1}'
+        plot sin(x)
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
+
     clear_log_text(kernel)
 
     # Multiple line statement
-    code = """
-    set output 'sine-cosine.png'
-    plot sin(x),\
-         cos(x)
-    """
-    kernel.do_execute(code)
-    assert os.path.exists('sine-cosine.png')
+    with ensure_deleted("sine-cosine.png") as f1:
+        code = f"""
+        set output '{f1}'
+        plot sin(x),\
+             cos(x)
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
 
     # Multiple line statement
-    code = """
-    set output 'tan.png'
-    plot tan(x)
-    set output 'tan2.png'
-    replot
-    """
-    kernel.do_execute(code)
-    assert os.path.exists('tan.png')
-    assert os.path.exists('tan2.png')
-
-    remove_files('sine.png', 'sine-cosine.png')
-    remove_files('tan.png', 'tan2.png')
+    with ensure_deleted("tan.png", "tan2.png") as (f1, f2):
+        code = f"""
+        set output '{f1}'
+        plot tan(x)
+        set output '{f2}'
+        replot
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
+        assert f2.exists()
 
 
 def test_inline_plots():
     kernel = get_kernel(GnuplotKernel)
-    kernel.call_magic('%gnuplot inline')
+    kernel.call_magic("%gnuplot inline")
 
     # inline plot creates data
     code = """
@@ -111,7 +110,7 @@ def test_inline_plots():
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert 'Display Data' in text
+    assert "Display Data" in text
     clear_log_text(kernel)
 
     # multiple plot statements data
@@ -121,17 +120,17 @@ def test_inline_plots():
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert text.count('Display Data') == 2
+    assert text.count("Display Data") == 2
     clear_log_text(kernel)
 
     # svg
-    kernel.call_magic('%gnuplot inline svg')
+    kernel.call_magic("%gnuplot inline svg")
     code = """
     plot tan(x)
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert 'Display Data' in text
+    assert "Display Data" in text
     clear_log_text(kernel)
 
 
@@ -149,7 +148,7 @@ def test_plot_abbreviations():
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert text.count('Display Data') == 4
+    assert text.count("Display Data") == 4
 
 
 def test_multiplot():
@@ -164,21 +163,21 @@ def test_multiplot():
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert text.count('Display Data') == 1
+    assert text.count("Display Data") == 1
 
     # With output
-    code = """
-    set terminal pncairo
-    set output 'multiplot-sin-cos.png'
-    set multiplot layout 2, 1
-    plot sin(x)
-    plot cos(x)
-    unset multiplot
-    unset output
-    """
-    kernel.do_execute(code)
-    assert os.path.exists('multiplot-sin-cos.png')
-    remove_files('multiplot-sin-cos.png')
+    with ensure_deleted("multiplot-sin-cos.png") as f1:
+        code = f"""
+        set terminal pncairo
+        set output '{f1}'
+        set multiplot layout 2, 1
+        plot sin(x)
+        plot cos(x)
+        unset multiplot
+        unset output
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
 
 
 def test_help():
@@ -188,17 +187,17 @@ def test_help():
     # stuck in pagers.
 
     # Fancy notebook help
-    code = 'terminal?'
+    code = "terminal?"
     kernel.do_execute(code)
     text = get_log_text(kernel).lower()
-    assert 'subtopic' in text
+    assert "subtopic" in text
     clear_log_text(kernel)
 
     # help by gnuplot statement
-    code = 'help print'
+    code = "help print"
     kernel.do_execute(code)
     text = get_log_text(kernel).lower()
-    assert 'syntax' in text
+    assert "syntax" in text
     clear_log_text(kernel)
 
 
@@ -206,30 +205,30 @@ def test_badinput():
     kernel = get_kernel(GnuplotKernel)
 
     # No code that endswith a backslash
-    code = 'plot sin(x),\\'
+    code = "plot sin(x),\\"
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert 'backslash' in text
+    assert "backslash" in text
 
 
 def test_gnuplot_error_message():
     kernel = get_kernel(GnuplotKernel)
 
     # The error messages gets to the kernel
-    code = 'plot [1,2][] sin(x)'
+    code = "plot [1,2][] sin(x)"
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert ' ^' in text
+    assert " ^" in text
 
 
 def test_bad_prompt():
     kernel = get_kernel(GnuplotKernel)
     # Anything other than 'gnuplot> '
     # is a bad prompt
-    code = 'set multiplot'
+    code = "set multiplot"
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert 'warning' in text.lower()
+    assert "warning" in text.lower()
 
 
 def test_data_block():
@@ -248,7 +247,7 @@ def test_data_block():
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert text.count('Display Data') == 1
+    assert text.count("Display Data") == 1
     clear_log_text(kernel)
 
     # Badly terminated data block
@@ -264,17 +263,30 @@ def test_data_block():
     """
     kernel.do_execute(bad_code)
     text = get_log_text(kernel)
-    assert 'Error' in text
+    assert "Error" in text
     clear_log_text(kernel)
 
     # Good code should work after the bad_code
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert text.count('Display Data') == 1
+    assert text.count("Display Data") == 1
+
+
+def test_do_for_loop():
+    kernel = get_kernel(GnuplotKernel)
+    code = """
+    do for [t=0:2] {
+      plot x**t t sprintf("x^%d",t)
+    }
+    """
+    kernel.do_execute(code)
+    text = get_log_text(kernel)
+    assert text.count("Display Data") == 3
 
 
 # magics #
 
+
 def test_cell_magic():
     # To simulate '%load_ext gnuplot_kernel';
     # create a main kernel, a gnuplot kernel and
@@ -285,48 +297,47 @@ def test_cell_magic():
     gkernel = GnuplotKernel()
     gmagic = GnuplotMagic(gkernel)
     gkernel.makeSubkernel(kernel)
-    kernel.line_magics['gnuplot'] = gmagic
-    kernel.cell_magics['gnuplot'] = gmagic
+    kernel.line_magics["gnuplot"] = gmagic
+    kernel.cell_magics["gnuplot"] = gmagic
 
     # inline output
     code = """%%gnuplot
     plot cos(x)
     """
     kernel.do_execute(code)
-    assert 'Display Data' in get_log_text(kernel)
+    assert "Display Data" in get_log_text(kernel)
     clear_log_text(kernel)
 
     # file output
-    kernel.call_magic('%gnuplot pngcairo size 560,420')
-    code = """%%gnuplot
-    set output 'cosine.png'
-    plot cos(x)
-    """
-    kernel.do_execute(code)
-    assert os.path.exists('cosine.png')
-    clear_log_text(kernel)
+    kernel.call_magic("%gnuplot pngcairo size 560,420")
+
+    with ensure_deleted("cosine.png") as f1:
+        code = f"""%%gnuplot
+        set output '{f1}'
+        plot cos(x)
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
 
-    remove_files('cosine.png')
+    clear_log_text(kernel)
 
 
 def test_reset_cell_magic():
     kernel = get_kernel(GnuplotKernel)
 
     # Use reset statements that have testable effect
-    code = """%%reset
-    set output 'sine+cosine.png'
-    plot sin(x) + cos(x)
-    """
-    kernel.call_magic(code)
-    assert not os.path.exists('sine+cosine.png')
+    with ensure_deleted("sine+cosine.png") as f1:
+        code = f"""%%reset
+        set output '{f1}'
+        plot sin(x) + cos(x)
+        """
+        kernel.call_magic(code)
 
-    code = """
-    unset key
-    """
-    kernel.do_execute(code)
-    assert os.path.exists('sine+cosine.png')
-
-    remove_files('sine+cosine.png')
+        code = """
+        unset key
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
 
 
 def test_reset_line_magic():
@@ -341,33 +352,14 @@ def test_reset_line_magic():
 
     # Remove the reset, execute some code and
     # make sure there are no effects
-    kernel.call_magic('%reset')
+    kernel.call_magic("%reset")
     code = """
     unset key
     """
     kernel.do_execute(code)
-    assert not os.path.exists('sine+sine.png')
+    assert not Path("sine+sine").exists()
 
     # Bad inline backend
     # metakernel messes this exception!!
     # with assert_raises(ValueError):
     #     kernel.call_magic('%gnuplot inline qt')
-
-
-# fixture tests #
-def test_remove_files():
-    """
-    This test create a file. Next test tests that it
-    is deleted
-    """
-    filename = 'antigravit.txt'
-    # Create file
-    # make sure it exis
-    with open(filename, 'w'):
-        pass
-
-    assert os.path.exists(filename)
-
-    remove_files(filename)
-
-    assert not os.path.exists(filename)
diff --git a/tools/release-checklist-tpl.md b/tools/release-checklist-tpl.md
new file mode 100644
index 0000000..90de4d6
--- /dev/null
+++ b/tools/release-checklist-tpl.md
@@ -0,0 +1,98 @@
+# Release Issue Checklist
+
+Copy the template below the line, substitute (`s//1.2.3/`) the correct
+version and create an [issue](https://github.com/has2k1/gnuplot_kernel/issues/new).
+
+The first line is the title of the issue
+
+------------------------------------------------------------------------------
+Release: gnuplot_kernel-
+
+- [ ] Upgrade key dependencies if necessary
+
+  - [ ] [metakernel](https://github.com/Calysto/metakernel)
+  - [ ] [jupyter](https://github.com/jupyter/jupyter)
+
+
+- [ ] Upgrade code quality checkers
+
+  - [ ] pre-commit
+
+    ```
+    pre-commit autoupdate
+    ```
+
+  - [ ] ruff
+
+    ```
+    pip install --upgrade ruff
+    ```
+
+  - [ ] pyright
+
+    ```sh
+    pip install --upgrade pyright
+    PYRIGHT_VERSION=$(pyright --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+    python -c "
+    import pathlib, re
+    f = pathlib.Path('pyproject.toml')
+    f.write_text(re.sub(r'pyright==[0-9]+\.[0-9]+\.[0-9]+', 'pyright==$PYRIGHT_VERSION', f.read_text()))
+    "
+    ```
+
+- [ ] Run tests and coverage locally
+
+  ```sh
+  git switch main
+  git pull origin/main
+  make typecheck
+  make test
+  make coverage
+  ```
+  - [ ] The tests pass
+  - [ ] The coverage is acceptable
+
+
+- [ ] Create a release branch
+
+  ```sh
+  git switch -c release-v
+  ```
+
+- [ ] Tag a pre-release version. These are automatically deployed on `testpypi`
+
+  ```sh
+  git tag -as vrc1 -m "Version rc1"  # e.g. a1, b1, rc1
+  git push -u origin release-v
+  ```
+  - [ ] GHA [release job](https://github.com/has2k1/gnuplot_kernel/actions/workflows/release.yml) passes
+  - [ ] gnuplot_kernel test release is on [TestPyPi](https://test.pypi.org/project/gnuplot_kernel/#history)
+
+- [ ] Update changelog
+
+  ```sh
+  nvim doc/changelog.qmd
+  git commit -am "Update changelog for release"
+  git push
+  ```
+  - [ ] Update / confirm the version to be released
+  - [ ] Add a release date
+  - [ ] The [GHA tests](https://github.com/has2k1/gnuplot_kernel/actions/workflows/testing.yml) pass
+
+- [ ] Tag final version and release
+
+  ```sh
+  git tag -as v -m "Version "
+  git push
+  ```
+
+  - [ ] The [GHA Release](https://github.com/has2k1/gnuplot_kernel/actions/workflows/release.yml) job passes
+  - [ ] [PyPi](https://pypi.org/project/gnuplot_kernel) shows the new release
+
+- [ ] Update `main` branch
+
+  ```sh
+  git switch main
+  git merge --ff-only release-v
+  git push
+  ```
diff --git a/tools/release-checklist.py b/tools/release-checklist.py
new file mode 100644
index 0000000..ceee6fc
--- /dev/null
+++ b/tools/release-checklist.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import os
+import re
+import shlex
+import sys
+from pathlib import Path
+from subprocess import PIPE, Popen
+from typing import Literal, Optional, Sequence, TypeAlias
+
+TPL_FILENAME = "release-checklist-tpl.md"
+THIS_DIR = Path(__file__).parent
+NEW_ISSUE = "https://github.com/has2k1/gnuplot_kernel/issues/new"
+
+VersionPart: TypeAlias = Literal[
+    "major",
+    "minor",
+    "patch",
+]
+
+count = r"(?:[0-9]|[1-9][0-9]+)"
+DESCRIBE_PATTERN = re.compile(
+    r"^v"
+    rf"(?P{count}\.{count}\.{count})"
+    rf"(?P
(a|b|rc){count})?"
+    r"(-(?P\d+)-g(?P[a-z0-9]+))?"
+    r"(?P-dirty)?"
+    r"$"
+)
+
+
+def run(cmd: str | Sequence[str], input: Optional[str] = None) -> str:
+    """
+    Run command
+    """
+    if isinstance(cmd, str) and os.name == "posix":
+        cmd = shlex.split(cmd)
+    with Popen(
+        cmd, stdin=PIPE, stderr=PIPE, stdout=PIPE, text=True, encoding="utf-8"
+    ) as p:
+        stdout, _ = p.communicate(input=input)
+    return stdout.strip()
+
+
+def copy_to_clipboard(s: str):
+    """
+    Copy s to clipboard
+    """
+    import platform
+
+    plat = platform.system()
+
+    platform_cmds = {"Darwin": "pbcopy", "Linux": "xclip", "Windows": "clip"}
+
+    try:
+        from pandas.io import (  # pyright: ignore[reportMissingImports]
+            clipboard,
+        )
+    except ImportError:
+        try:
+            cmd = platform_cmds[plat]
+        except KeyError as err:
+            msg = f"No clipboard for this system: {plat}"
+            raise RuntimeError(msg) from err
+        run(cmd, input=s)
+    else:
+        clipboard.copy(s)
+
+
+def get_previous_version(s: Optional[str] = None) -> str:
+    """
+    Get previous version
+
+    Either the 2nd commandline arg (v) or obtained from git describe
+    """
+    if s:
+        vtxt = s if s.startswith("v") else f"v{s}"
+    else:
+        cmd = "git describe --dirty --tags --long --match '*[0-9]*'"
+        vtxt = run(cmd)
+
+    m = DESCRIBE_PATTERN.match(vtxt)
+    if not m:
+        raise ValueError(f"Bad version: {vtxt}")
+
+    return m.group("version")
+
+
+def bump_version(version: str, part: VersionPart) -> str:
+    """
+    Bump version
+    """
+    parts = version.split(".")
+    i = ("major", "minor", "patch").index(part)
+    parts[i] = str(int(parts[i]) + 1)
+    # Zero-out the smaller parts
+    for j in range(i + 1, 3):
+        parts[j] = "0"
+    return ".".join(parts)
+
+
+def generate_checklist(version: str) -> str:
+    """
+    Generate checklist for releasing the given version
+    """
+    path = THIS_DIR / TPL_FILENAME
+    pattern = re.compile(
+        # The template is everything below the dashed line
+        r"\n-+\n(?P.+)",
+        flags=re.MULTILINE | re.DOTALL,
+    )
+    with Path(path).open("r") as f:
+        contents = f.read()
+
+    m = pattern.search(contents)
+    if not m:
+        raise ValueError(f"Cannot find the relevant content in '{path}'")
+
+    tpl = m.group("tpl")
+    return tpl.replace("", version)
+
+
+def process(part: VersionPart, prev: str | None):
+    """
+    Run the full process
+
+    1. Calculate the next version from the previous version
+    2. Add the next version to the checklist template
+    3. Copy the template to the system clipboard
+    """
+    prev_version = get_previous_version(prev)
+    next_version = bump_version(prev_version, part)
+    cl = generate_checklist(next_version)
+    copy_to_clipboard(cl)
+    verbose(prev_version, next_version)
+
+
+def verbose(prev_version, next_version):
+    """
+    Print version details
+    """
+    from textwrap import dedent
+
+    from term import T0 as T
+
+    s = f"""
+    Previous Version: {T(prev_version, "lightblue", effect="strikethrough")}
+        Next Version: {T(next_version, "lightblue", effect="bold")}
+
+    The release checklist has been copied to the clipboard. Use it to
+    open a new issue at: {T(NEW_ISSUE, "yellow")}\
+    """
+    print(dedent(s))
+
+
+if __name__ == "__main__":
+    if len(sys.argv) >= 2:
+        part = sys.argv[1]
+        prev = sys.argv[2] if len(sys.argv) >= 3 else None
+        assert part in ("major", "minor", "patch")
+        process(part, prev)
diff --git a/tools/term.py b/tools/term.py
new file mode 100644
index 0000000..d064aa4
--- /dev/null
+++ b/tools/term.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import sys
+from enum import Enum
+from typing import Optional
+
+RESET = "\033[0m"
+
+
+class Fg(Enum):
+    """
+    Foreground color codes
+    """
+
+    black = "\033[30m"
+    red = "\033[31m"
+    green = "\033[32m"
+    orange = "\033[33m"
+    blue = "\033[34m"
+    purple = "\033[35m"
+    cyan = "\033[36m"
+    lightgrey = "\033[37m"
+    darkgrey = "\033[90m"
+    lightred = "\033[91m"
+    lightgreen = "\033[92m"
+    yellow = "\033[93m"
+    lightblue = "\033[94m"
+    pink = "\033[95m"
+    lightcyan = "\033[96m"
+
+
+class Bg(Enum):
+    """
+    Background color codes
+    """
+
+    black = "\033[40m"
+    red = "\033[41m"
+    green = "\033[42m"
+    orange = "\033[43m"
+    blue = "\033[44m"
+    purple = "\033[45m"
+    cyan = "\033[46m"
+    lightgrey = "\033[47m"
+
+
+class Effect(Enum):
+    """
+    Text effect codes
+    """
+
+    bold = "\033[01m"
+    dim = "\033[02m"
+    underline = "\033[04m"
+    blink = "\033[05m"
+    reverse = "\033[07m"  # bg & fg are reversed
+    hide = "\033[08m"
+    strikethrough = "\033[09m"
+
+
+def T(
+    s: str,
+    fg: Optional[str] = None,
+    bg: Optional[str] = None,
+    effect: Optional[str] = None,
+) -> str:
+    """
+    Enclose text string with ANSI codes
+
+    e.g.
+        # Red text
+        T("sample", "red")
+
+        # Red on lightgrey background
+        T("sample", "red", "lightgrey")
+
+        # Red on lightgrey background and underlined
+        T("sample", "red", "lightgrey", "underlined")
+
+        # Red underlined text
+        T("sample", effect="underlined")
+
+        # Red & bold underlined text
+        T("sample", effect="bold, underlined")
+    """
+
+    def get(Ecls, prop_name) -> str:
+        return getattr(Ecls, prop_name).value if prop_name else ""
+
+    _fg = get(Fg, fg)
+    _bg = get(Bg, bg)
+    if effect:
+        _effect = "".join(get(Effect, e.strip()) for e in effect.split(","))
+    else:
+        _effect = ""
+
+    _reset = RESET if any((_fg, _bg, _effect)) else ""
+    return f"{_fg}{_bg}{_effect}{s}{_reset}"
+
+
+def T0(s: str, *args, **kwargs) -> str:
+    """
+    Enclose text string with ANSI codes if output is TTY
+    """
+    if sys.stdout.isatty():
+        return T(s, *args, **kwargs)
+    return s