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
index 220232a..50e4156 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -1,6 +1,13 @@
 name: build
 
-on: [push, pull_request]
+on:
+  push:
+    branches:
+      - '*'
+    tags-ignore:
+      - 'v[0-9]*'
+  pull_request:
+  workflow_call:
 
 jobs:
   # Unittests
@@ -13,44 +20,47 @@ jobs:
 
     strategy:
       matrix:
-        python-version: ["3.8", "3.10"]
+        include:
+          - python-version: "3.10"
+            resolution: "lowest-direct"
+          - python-version: 3.13
+            resolution: "highest"
 
     steps:
       - name: Checkout Code
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
-      - name: Setup Python
-        uses: actions/setup-python@v2
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
         with:
           python-version: ${{ matrix.python-version }}
 
       - name: Install Packages
-        shell: bash -l {0}
         run: |
           sudo apt-get install gnuplot
-          pip install -e ".[test]"
-          pip install coveralls
+          # Install as an editable so that the coverage path
+          # is predicable
+          uv run uv pip install --resolution=${{ matrix.resolution }} -e ".[test]"
 
       - name: Environment Information
-        shell: bash -l {0}
         run: |
           gnuplot --version
-          pip list
+          uv pip list
 
       - name: Run Tests
-        shell: bash -l {0}
         run: |
-          coverage erase
           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@v1
+        uses: codecov/codecov-action@v5
         with:
           fail_ci_if_error: true
           name: "py${{ matrix.python-version }}"
+          token: ${{ secrets.CODECOV_TOKEN }}
 
-  # Linting
-  lint:
+  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
@@ -59,24 +69,53 @@ jobs:
 
     strategy:
       matrix:
-        python-version: ["3.10"]
+        python-version: [3.13]
     steps:
       - name: Checkout Code
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
-      - name: Setup Python
-        uses: actions/setup-python@v2
+      - name: Install a specific version of uv
+        uses: astral-sh/setup-uv@v6
         with:
           python-version: ${{ matrix.python-version }}
 
       - name: Install Packages
-        shell: bash -l {0}
-        run: pip install flake8
+        run: uv run uv pip install ruff
 
       - name: Environment Information
-        shell: bash -l {0}
-        run: pip list
+        run: uv pip list
 
-      - name: Run Tests
-        shell: bash -l {0}
+      - 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 a148410..0fd8f0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,9 @@ coverage.xml
 # other
 .cache
 examples/.ipynb_checkpoints
+
+# Catch all unnamed notebook files
+**/Untitled*.ipynb
+
+# uv
+uv.lock
diff --git a/MANIFEST.in b/MANIFEST.in
index 0c73842..64ad321 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1 +1 @@
-include README.rst LICENSE
+include README.md LICENSE
diff --git a/Makefile b/Makefile
index 90b0aaa..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"
@@ -19,43 +40,57 @@ 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
+	$(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 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-minor:
+	@$(PYTHON) ./tools/release-checklist.py minor
 
-release-test: dist
-	twine upload -r pypitest dist/*
+release-patch:
+	@$(PYTHON) ./tools/release-checklist.py patch
 
 install: clean
-	python setup.py install
+	$(PIP) install .
 
-develop: clean-pyc
-	python setup.py develop
+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/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
index ef67fc1..d3bd177 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,4 +1,49 @@
-# Reference https://github.com/pydata/xarray/blob/main/pyproject.toml
+[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",
@@ -7,34 +52,128 @@ requires = [
 ]
 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'
+version_scheme = "post-release"
 
-# pytest
+
+########## Tool - Pytest ##########
 [tool.pytest.ini_options]
 testpaths = [
-    "gnuplot_kernel/tests"
+    "tests"
 ]
-addopts = "--pyargs --cov --cov-report=xml --import-mode=importlib"
+addopts = "--pyargs --cov=src/gnuplot_kernel --cov-report=xml --import-mode=importlib"
 
+########## Tool - Coverage ##########
 # Coverage.py
 [tool.coverage.run]
 branch = true
-source = ["gnuplot_kernel"]
-include = ["gnuplot_kernel/*"]
+source = ["src"]
+include = [
+   "src/gnuplot_kernel/*"
+]
 omit = [
-    "setup.py",
-    "gnuplot_kernel/__main__.py"
+    "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"
+    "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/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 37cc54d..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,43 +0,0 @@
-[metadata]
-name = gnuplot_kernel
-description = A gnuplot kernel for Jupyter
-url= https://github.com/has2k1/gnuplot_kernel
-license = BSD (3-clause)
-author = Hassan Kibirige
-author_email = has2k1@gmail.com
-long_description = file: README.rst
-long_description_content_type = text/x-rst
-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
-
-project_urls =
-    Source = https://github.com/has2k1/gnuplot_kernel
-    Bug Tracker = https://github.com/has2k1/gnuplot_kernel/issues
-    CI = https://github.com/has2k1/gnuplot_kernel/actions
-
-[options]
-packages = find:
-install_requires =
-    metakernel>=0.29.0
-    notebook>=6.5.0
-python_requires = >=3.8
-zip_safe = False
-
-[options.package_data]
-gnuplot.images = *.png
-
-[options.extras_require]
-test =
-    flake8
-    pytest-cov
-
-[bdist_wheel]
-
-[flake8]
-ignore = E121,E123,E126,E226,E24,E704,W503,W504,E741,E743
diff --git a/setup.py b/setup.py
deleted file mode 100644
index b024da8..0000000
--- a/setup.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from setuptools import setup
-
-
-setup()
diff --git a/gnuplot_kernel/__init__.py b/src/gnuplot_kernel/__init__.py
similarity index 67%
rename from gnuplot_kernel/__init__.py
rename to src/gnuplot_kernel/__init__.py
index f956a00..2912503 100644
--- a/gnuplot_kernel/__init__.py
+++ b/src/gnuplot_kernel/__init__.py
@@ -1,20 +1,19 @@
 """
 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']
+__all__ = ["GnuplotKernel"]
 
 
-try:
-    __version__ = get_version('gnuplot_kernel')
-except PackageNotFoundError:
-    # package is not installed
-    pass
+with suppress(PackageNotFoundError):
+    __version__ = get_version("gnuplot_kernel")
 
 
 def load_ipython_extension(ipython):
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/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py
similarity index 63%
rename from gnuplot_kernel/kernel.py
rename to src/gnuplot_kernel/kernel.py
index 80123ac..5701932 100644
--- a/gnuplot_kernel/kernel.py
+++ b/src/gnuplot_kernel/kernel.py
@@ -1,63 +1,77 @@
+from __future__ import annotations
+
+import contextlib
 import sys
+import uuid
 from itertools import chain
 from pathlib import Path
-import uuid
+from typing import cast
 
-from IPython.display import Image, SVG
+from IPython.display import SVG, Image
 from metakernel import MetaKernel, ProcessMetaKernel, pexpect
 from metakernel.process_metakernel import TextOutput
 
-from .statement import STMT
 from .exceptions import GnuplotError
-from .replwrap import GnuplotREPLWrapper, PROMPT_RE, PROMPT_REMOVE_RE
+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'
+IMG_COUNTER = "__gpk_img_index"
+IMG_COUNTER_FMT = "%03d"
 
 
 class GnuplotKernel(ProcessMetaKernel):
     """
     GnuplotKernel
     """
-    implementation = 'Gnuplot Kernel'
-    implementation_version = get_version('gnuplot_kernel')
-    language = 'gnuplot'
-    language_version = '5.0'
-    banner = 'Gnuplot Kernel'
+
+    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,
+        "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',
+        "argv": [
+            sys.executable,
+            "-m",
+            "gnuplot_kernel",
+            "-f",
+            "{connection_file}",
+        ],
+        "display_name": "gnuplot",
+        "language": "gnuplot",
+        "name": "gnuplot",
     }
 
     inline_plotting = True
-    reset_code = ''
+    reset_code = ""
     _first = True
-    _image_files = []
+    _image_files: list[Path] = []
     _error = False
 
-    def bad_prompt_warning(self):
+    wrapper: GnuplotREPLWrapper
+    _bad_prompts: set = set()
+
+    def check_prompt(self):
         """
-        Print warning if the prompt is not 'gnuplot>'
+        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.
         """
-        if not self.wrapper.prompt.startswith('gnuplot>'):
-            msg = ("Warning: The prompt is currently set "
-                   "to '{}'".format(self.wrapper.prompt))
-            print(msg)
+        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):
+    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
@@ -67,7 +81,7 @@ def do_execute_direct(self, code):
             print(f"Error: {err}")
             raise err
 
-    def _do_execute_direct(self, code):
+    def _do_execute_direct(self, code: str) -> TextOutput | None:
         """
         Execute gnuplot code
         """
@@ -94,17 +108,18 @@ def _do_execute_direct(self, code):
                 self.display_images()
             self.delete_image_files()
 
-        self.bad_prompt_warning()
+        self.check_prompt()
 
         # No empty strings
         return result if (result and result.output) else None
 
-    def add_inline_image_statements(self, code):
+    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):
@@ -129,23 +144,20 @@ def set_output_inline(lines):
             stmt = STMT(line)
             sm.transition(stmt)
             add_inline_plot = (
-                sm.prev_cur in (
-                    ('none', 'plot'),
-                    ('none', 'multiplot'),
-                    ('plot', '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('\\')
+            is_joined_stmt = stmt.strip().endswith("\\")
 
         # Make gnuplot flush the output
-        if not lines[-1].endswith('\\'):
-            lines.append('unset output')
-        code = '\n'.join(lines)
+        if not lines[-1].endswith("\\"):
+            lines.append("unset output")
+        code = "\n".join(lines)
         return code
 
     def get_image_filename(self):
@@ -158,11 +170,9 @@ def get_image_filename(self):
         # 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']
+        fmt = self.plot_settings["format"]
         filename = Path(
-            f'/tmp/gnuplot-inline-{uuid.uuid1()}'
-            f'.{IMG_COUNTER_FMT}'
-            f'.{fmt}'
+            f"/tmp/gnuplot-inline-{uuid.uuid1()}.{IMG_COUNTER_FMT}.{fmt}"
         )
         self._image_files.append(filename)
         return filename
@@ -171,10 +181,12 @@ 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
-        ])
+        it = chain(
+            *[
+                sorted(f.parent.glob(f.name.replace(IMG_COUNTER_FMT, "*")))
+                for f in self._image_files
+            ]
+        )
         return it
 
     def display_images(self):
@@ -183,10 +195,9 @@ def display_images(self):
         """
         settings = self.plot_settings
         if self.inline_plotting:
-            if settings['format'] == 'svg':
-                _Image = SVG
-            else:
-                _Image = Image
+            _Image = SVG if settings["format"] == "svg" else Image
+        else:
+            return
 
         for filename in self.iter_image_files():
             try:
@@ -214,10 +225,8 @@ def delete_image_files(self):
         # After display_images(), the real images are
         # no longer required.
         for filename in self.iter_image_files():
-            try:
+            with contextlib.suppress(FileNotFoundError):
                 filename.unlink()
-            except FileNotFoundError:
-                pass
 
         self._image_files = []
 
@@ -225,26 +234,25 @@ 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'
+        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)
+        if pexpect.which("env") and pexpect.which("cat"):
+            command = "env PAGER=cat {}".format(program)
         else:
             command = program
 
-        d = dict(
+        wrapper = GnuplotREPLWrapper(
             cmd_or_spawn=command,
             prompt_regex=PROMPT_RE,
-            prompt_change_cmd=None
+            prompt_change_cmd=None,
         )
-        wrapper = GnuplotREPLWrapper(**d)
         # No sleeping before sending commands to gnuplot
         wrapper.child.delaybeforesend = 0
         return wrapper
@@ -257,22 +265,19 @@ def do_shutdown(self, restart):
         super().do_shutdown(restart)
 
     def get_kernel_help_on(self, info, level=0, none_on_fail=False):
-        obj = info.get('help_obj', '')
+        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 = PROMPT_REMOVE_RE.sub('', res.output)
-        self.bad_prompt_warning()
+            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'
+        cmd = f"{IMG_COUNTER}=0"
         self.do_execute_direct(cmd)
 
     def handle_plot_settings(self):
@@ -283,17 +288,14 @@ def handle_plot_settings(self):
         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'
+        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'
+        self.inline_plotting = settings["backend"] == "inline"
 
-        cmd = 'set terminal {}'.format(settings['termspec'])
+        cmd = "set terminal {}".format(settings["termspec"])
         self.do_execute_direct(cmd)
         self.reset_image_counter()
 
@@ -305,9 +307,10 @@ class StateMachine:
     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'
+
+    states = ["none", "plot", "output", "multiplot", "output_multiplot"]
+    previous = "none"
+    _current = "none"
 
     @property
     def prev_cur(self):
@@ -324,45 +327,44 @@ def current(self, value):
 
     def transition(self, stmt):
         lookup = {
-            s: getattr(self, f'transition_from_{s}')
-            for s in self.states
+            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 self.current == "output":
+            self.current = "none"
+        elif self.current == "plot":
             if stmt.is_plot():
-                self.current = 'plot'
+                self.current = "plot"
             elif stmt.is_set_output():
-                self.current = 'output'
+                self.current = "output"
             else:
-                self.current = 'none'
+                self.current = "none"
 
     def transition_from_none(self, stmt):
         if stmt.is_plot():
-            self.current = 'plot'
+            self.current = "plot"
         elif stmt.is_set_output():
-            self.current = 'output'
+            self.current = "output"
         elif stmt.is_set_multiplot():
-            self.current = 'multiplot'
+            self.current = "multiplot"
 
     def transition_from_output(self, stmt):
         if stmt.is_plot():
-            self.current = 'plot'
+            self.current = "plot"
         elif stmt.is_set_multiplot():
-            self.current = 'output_multiplot'
+            self.current = "output_multiplot"
         elif stmt.is_unset_output():
-            self.current = 'none'
+            self.current = "none"
 
     def transition_from_multiplot(self, stmt):
         if stmt.is_unset_multiplot():
-            self.current = 'none'
+            self.current = "none"
 
     def transition_from_output_multiplot(self, stmt):
         if stmt.is_unset_multiplot():
             self.previous = self.current
-            self.current = 'output'
+            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 71%
rename from gnuplot_kernel/replwrap.py
rename to src/gnuplot_kernel/replwrap.py
index d4d2132..eba32de 100644
--- a/gnuplot_kernel/replwrap.py
+++ b/src/gnuplot_kernel/replwrap.py
@@ -1,33 +1,31 @@
 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'
-NO_BLOCK = ''
+CRLF = "\r\n"
+NO_BLOCK = ""
 
 ERROR_RE = [
     re.compile(
-        r'^\s*'
-        r'\^'  # Indicates error on above line
-        r'\s*'
-        r'\n'
+        r"^\s*"
+        r"\^"  # Indicates error on above line
+        r"\s*"
+        r"\n"
     )
 ]
 
 PROMPT_RE = re.compile(
     # most likely "gnuplot> "
-    r'\w*>\s*$'
+    r"\w*>\s*$"
 )
 
-PROMPT_REMOVE_RE = re.compile(
-    r'\w*>\s*'
-)
+PROMPT_REMOVE_RE = re.compile(r"\w*>\s*")
 
 # Data block e.g.
 # $DATA << EOD
@@ -38,22 +36,19 @@
 # EOD
 START_DATABLOCK_RE = re.compile(
     # $DATA << EOD
-    r'^\$\w+\s+<<\s*(?P\w+)$'
+    r"^\$\w+\s+<<\s*(?P\w+)$"
 )
 END_DATABLOCK_RE = re.compile(
     # EOD
-    r'^(?P\w+)$'
+    r"^(?P\w+)$"
 )
 
 
 class GnuplotREPLWrapper(REPLWrapper):
     # The prompt after the commands run
-    prompt = ''
+    prompt = ""
     _blocks = {
-        'data': {
-            'start_re': START_DATABLOCK_RE,
-            'end_re': END_DATABLOCK_RE
-        }
+        "data": {"start_re": START_DATABLOCK_RE, "end_re": END_DATABLOCK_RE}
     }
     _current_block = NO_BLOCK
 
@@ -62,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_RE:
-            if pattern.match(text):
-                return True
-        return False
+        return any(pattern.match(text) for pattern in ERROR_RE)
 
     def validate_input(self, code):
         """
@@ -83,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
@@ -119,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
 
@@ -129,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):
@@ -147,10 +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']
-        if m := pattern_re.match(stmt):
-            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):
@@ -172,11 +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():
-            if m := regexps['start_re'].match(stmt):
+            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
 
@@ -190,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:
@@ -211,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)
@@ -237,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 = PROMPT_REMOVE_RE.sub('', retval).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/gnuplot_kernel/statement.py b/src/gnuplot_kernel/statement.py
similarity index 70%
rename from gnuplot_kernel/statement.py
rename to src/gnuplot_kernel/statement.py
index f219b25..26a98ab 100644
--- a/gnuplot_kernel/statement.py
+++ b/src/gnuplot_kernel/statement.py
@@ -1,63 +1,64 @@
 """
 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?'
+    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?'
+    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'
+    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'
+    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+|$)'
+    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+|$)'
+    r"\s*"
+    r"(?:unset|unse|uns)"
+    r"\s+"
+    r"(?:output|outpu|outp|out|ou|o)"
+    r"(?:\s+|$)"
 )
 
 
diff --git a/gnuplot_kernel/utils.py b/src/gnuplot_kernel/utils.py
similarity index 90%
rename from gnuplot_kernel/utils.py
rename to src/gnuplot_kernel/utils.py
index 0e7976e..67ea9c3 100644
--- a/gnuplot_kernel/utils.py
+++ b/src/gnuplot_kernel/utils.py
@@ -5,7 +5,7 @@
 from importlib.metadata import version
 
 
-def get_version(package):
+def get_version(package: str) -> str:
     """
     Return the package version
 
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 b0d5f7c..2704d30 100644
--- a/gnuplot_kernel/tests/test_kernel.py
+++ b/tests/test_kernel.py
@@ -1,12 +1,12 @@
 import weakref
 from pathlib import Path
 
-from metakernel.tests.utils import (get_kernel, get_log_text,
-                                    clear_log_text)
+from metakernel.tests.utils import clear_log_text, get_kernel, get_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 Path('sine.png').exists()
+    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 Path('sine-cosine.png').exists()
+    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 Path('tan.png').exists()
-    assert Path('tan2.png').exists()
-
-    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 Path('multiplot-sin-cos.png').exists()
-    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,13 +263,13 @@ 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():
@@ -282,11 +281,12 @@ def test_do_for_loop():
     """
     kernel.do_execute(code)
     text = get_log_text(kernel)
-    assert text.count('Display Data') == 3
+    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
@@ -297,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 Path('cosine.png').exists()
-    clear_log_text(kernel)
+    kernel.call_magic("%gnuplot pngcairo size 560,420")
 
-    remove_files('cosine.png')
+    with ensure_deleted("cosine.png") as f1:
+        code = f"""%%gnuplot
+        set output '{f1}'
+        plot cos(x)
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
+
+    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 Path('sine+cosine.png').exists()
-
-    code = """
-    unset key
-    """
-    kernel.do_execute(code)
-    assert Path('sine+cosine.png').exists()
+    with ensure_deleted("sine+cosine.png") as f1:
+        code = f"""%%reset
+        set output '{f1}'
+        plot sin(x) + cos(x)
+        """
+        kernel.call_magic(code)
 
-    remove_files('sine+cosine.png')
+        code = """
+        unset key
+        """
+        kernel.do_execute(code)
+        assert f1.exists()
 
 
 def test_reset_line_magic():
@@ -353,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 Path('sine+sine.png').exists()
+    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 Path(filename).exists()
-
-    remove_files(filename)
-
-    assert not Path(filename).exists()
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