From 26a326c78616fd297baded8bb057c6d77508f9d6 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 28 Nov 2022 12:16:33 +0300 Subject: [PATCH 01/26] Switch to pyproject.toml and setuptools_scm --- MANIFEST.in | 8 +----- Makefile | 9 ++++--- gnuplot_kernel/__init__.py | 9 +++++++ gnuplot_kernel/kernel.py | 5 +--- pyproject.toml | 19 ++++++++++++++ setup.cfg | 40 +++++++++++++++++++++++++++++- setup.py | 51 ++------------------------------------ 7 files changed, 77 insertions(+), 64 deletions(-) create mode 100644 pyproject.toml diff --git a/MANIFEST.in b/MANIFEST.in index e2d707f..0c73842 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.rst LICENSE diff --git a/Makefile b/Makefile index 1620d5d..90b0aaa 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ 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 @@ -36,7 +37,7 @@ lint: flake8 gnuplot_kernel test: clean-test - pytest --cov=gnuplot_kernel + pytest coverage: coverage report -m @@ -44,8 +45,7 @@ coverage: $(BROWSER) htmlcov/index.html dist: clean - python setup.py sdist - python setup.py bdist_wheel + python setup.py sdist bdist_wheel ls -l dist release: dist @@ -56,3 +56,6 @@ release-test: dist install: clean python setup.py install + +develop: clean-pyc + python setup.py develop diff --git a/gnuplot_kernel/__init__.py b/gnuplot_kernel/__init__.py index ae43d9c..279dfe6 100644 --- a/gnuplot_kernel/__init__.py +++ b/gnuplot_kernel/__init__.py @@ -3,6 +3,15 @@ __all__ = ['GnuplotKernel'] +from importlib.metadata import version, PackageNotFoundError + + +try: + __version__ = version('gnuplopt_kernel') +except PackageNotFoundError: + # package is not installed + pass + def load_ipython_extension(ipython): """ diff --git a/gnuplot_kernel/kernel.py b/gnuplot_kernel/kernel.py index 3a1f5d8..61b0279 100644 --- a/gnuplot_kernel/kernel.py +++ b/gnuplot_kernel/kernel.py @@ -7,13 +7,10 @@ from metakernel import MetaKernel, ProcessMetaKernel, pexpect, u from metakernel.process_metakernel import TextOutput +from . import __version__ 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..076f119 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +# Reference https://github.com/pydata/xarray/blob/main/pyproject.toml +[build-system] +requires = [ + "setuptools>=59", + "setuptools_scm[toml]>=6.4", + "wheel", +] +build-backend = "setuptools.build_meta" + +# pytest +[tool.pytest.ini_options] +testpaths = [ + "tests" +] +addopts = "--pyargs --cov=gnuplot_kernel --cov-report=xml --import-mode=importlib" + +[tool.setuptools_scm] +fallback_version = "999" +version_scheme = 'post-release' diff --git a/setup.cfg b/setup.cfg index c9d5653..37cc54d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,43 @@ +[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] -# 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 index cf032f9..b024da8 100644 --- a/setup.py +++ b/setup.py @@ -1,51 +1,4 @@ -import io -from setuptools import find_packages, setup +from setuptools import 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__ - ) +setup() From bc39c1cf1e1ae85557280022d7470be72acc5add Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 28 Nov 2022 12:26:01 +0300 Subject: [PATCH 02/26] Switch to github actions --- .github/workflows/testing.yml | 82 +++++++++++++++++++++++++++++++++++ .travis.yml | 34 --------------- gnuplot_kernel/__init__.py | 2 +- 3 files changed, 83 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/testing.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..541653e --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,82 @@ +name: build + +on: [push, pull_request] + +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: + python-version: ["3.8", "3.10"] + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Packages + shell: bash -l {0} + run: | + apt-get install gnuplot + pip install -e ".[test]" + pip install coveralls + + - name: Environment Information + shell: bash -l {0} + run: | + gnuplot --version + pip list + + - name: Run Tests + shell: bash -l {0} + run: | + coverage erase + make test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + fail_ci_if_error: true + name: "py${{ matrix.python-version }}" + + # Linting + lint: + 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.10"] + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Packages + shell: bash -l {0} + run: pip install flake8 + + - name: Environment Information + shell: bash -l {0} + run: pip list + + - name: Run Tests + shell: bash -l {0} + run: make lint 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/gnuplot_kernel/__init__.py b/gnuplot_kernel/__init__.py index 279dfe6..c4538d2 100644 --- a/gnuplot_kernel/__init__.py +++ b/gnuplot_kernel/__init__.py @@ -7,7 +7,7 @@ try: - __version__ = version('gnuplopt_kernel') + __version__ = version('gnuplot_kernel') except PackageNotFoundError: # package is not installed pass From 390057bfbae17442bbce3b25edf8391d687e2487 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 28 Nov 2022 13:42:22 +0300 Subject: [PATCH 03/26] Do not depend on __init__.py for the version Avoids circular import - __init__.py imports GnuplotKernel - GnuplotKernel imports __init__.py to get the package version --- .coveragerc | 17 ----------------- .github/workflows/testing.yml | 2 +- gnuplot_kernel/__init__.py | 10 +++++++--- gnuplot_kernel/kernel.py | 7 +++++-- gnuplot_kernel/utils.py | 17 +++++++++++++++++ pyproject.toml | 31 ++++++++++++++++++++++++++----- pytest.ini | 3 --- 7 files changed, 56 insertions(+), 31 deletions(-) delete mode 100644 .coveragerc create mode 100644 gnuplot_kernel/utils.py delete mode 100644 pytest.ini 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/workflows/testing.yml b/.github/workflows/testing.yml index 541653e..220232a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -27,7 +27,7 @@ jobs: - name: Install Packages shell: bash -l {0} run: | - apt-get install gnuplot + sudo apt-get install gnuplot pip install -e ".[test]" pip install coveralls diff --git a/gnuplot_kernel/__init__.py b/gnuplot_kernel/__init__.py index c4538d2..f956a00 100644 --- a/gnuplot_kernel/__init__.py +++ b/gnuplot_kernel/__init__.py @@ -1,13 +1,17 @@ +""" +Gnuplot Kernel Package +""" +from importlib.metadata import PackageNotFoundError + from .kernel import GnuplotKernel from .magics import register_ipython_magics +from .utils import get_version __all__ = ['GnuplotKernel'] -from importlib.metadata import version, PackageNotFoundError - try: - __version__ = version('gnuplot_kernel') + __version__ = get_version('gnuplot_kernel') except PackageNotFoundError: # package is not installed pass diff --git a/gnuplot_kernel/kernel.py b/gnuplot_kernel/kernel.py index 61b0279..39b2b90 100644 --- a/gnuplot_kernel/kernel.py +++ b/gnuplot_kernel/kernel.py @@ -7,9 +7,9 @@ from metakernel import MetaKernel, ProcessMetaKernel, pexpect, u from metakernel.process_metakernel import TextOutput -from . import __version__ from .replwrap import GnuplotREPLWrapper, PROMPT from .exceptions import GnuplotError +from .utils import get_version # name of the command i.e first token CMD_RE = re.compile(r'^\s*(\w+)\s?') @@ -78,8 +78,11 @@ def is_plot(stmt): class GnuplotKernel(ProcessMetaKernel): + """ + GnuplotKernel + """ implementation = 'Gnuplot Kernel' - implementation_version = __version__ + implementation_version = get_version('gnuplot_kernel') language = 'gnuplot' language_version = '5.0' banner = 'Gnuplot Kernel' diff --git a/gnuplot_kernel/utils.py b/gnuplot_kernel/utils.py new file mode 100644 index 0000000..0e7976e --- /dev/null +++ b/gnuplot_kernel/utils.py @@ -0,0 +1,17 @@ +""" +Useful functions +""" + +from importlib.metadata import version + + +def get_version(package): + """ + 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/pyproject.toml b/pyproject.toml index 076f119..ef67fc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,34 @@ requires = [ ] build-backend = "setuptools.build_meta" +[tool.setuptools_scm] +fallback_version = "999" +version_scheme = 'post-release' + # pytest [tool.pytest.ini_options] testpaths = [ - "tests" + "gnuplot_kernel/tests" ] -addopts = "--pyargs --cov=gnuplot_kernel --cov-report=xml --import-mode=importlib" +addopts = "--pyargs --cov --cov-report=xml --import-mode=importlib" -[tool.setuptools_scm] -fallback_version = "999" -version_scheme = 'post-release' +# Coverage.py +[tool.coverage.run] +branch = true +source = ["gnuplot_kernel"] +include = ["gnuplot_kernel/*"] +omit = [ + "setup.py", + "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 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 From d3cc6f4271209d386e71d01a97f947f838d6d777 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 28 Nov 2022 14:47:19 +0300 Subject: [PATCH 04/26] MAINT: Make regex a raw string --- gnuplot_kernel/kernel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gnuplot_kernel/kernel.py b/gnuplot_kernel/kernel.py index 39b2b90..f24cbd7 100644 --- a/gnuplot_kernel/kernel.py +++ b/gnuplot_kernel/kernel.py @@ -4,7 +4,7 @@ import uuid from IPython.display import Image, SVG -from metakernel import MetaKernel, ProcessMetaKernel, pexpect, u +from metakernel import MetaKernel, ProcessMetaKernel, pexpect from metakernel.process_metakernel import TextOutput from .replwrap import GnuplotREPLWrapper, PROMPT @@ -287,7 +287,7 @@ def makeWrapper(self): command = program d = dict(cmd_or_spawn=command, - prompt_regex=u('\w*> $'), + prompt_regex=r'\w*> $', prompt_change_cmd=None) wrapper = GnuplotREPLWrapper(**d) # No sleeping before sending commands to gnuplot From 9ba6c5ab91232143be1ea82b43baf5eb5d8b06bd Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 28 Nov 2022 17:08:55 +0300 Subject: [PATCH 05/26] Refactor: Use STMT class --- gnuplot_kernel/kernel.py | 102 +++++++----------------------------- gnuplot_kernel/replwrap.py | 53 +++++++++++++++---- gnuplot_kernel/statement.py | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 93 deletions(-) create mode 100644 gnuplot_kernel/statement.py diff --git a/gnuplot_kernel/kernel.py b/gnuplot_kernel/kernel.py index f24cbd7..d523b04 100644 --- a/gnuplot_kernel/kernel.py +++ b/gnuplot_kernel/kernel.py @@ -1,5 +1,4 @@ import sys -import re import os.path import uuid @@ -7,75 +6,11 @@ from metakernel import MetaKernel, ProcessMetaKernel, pexpect from metakernel.process_metakernel import TextOutput -from .replwrap import GnuplotREPLWrapper, PROMPT +from .statement import STMT from .exceptions import GnuplotError +from .replwrap import GnuplotREPLWrapper, PROMPT_RE, PROMPT_REMOVE_RE from .utils import get_version -# 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): """ @@ -184,7 +119,8 @@ def set_output_inline(lines): lines = [] sm = StateMachine() is_joined_stmt = False - for stmt in code.splitlines(): + for line in code.splitlines(): + stmt = STMT(line) sm.transition(stmt) add_inline_plot = ( sm.prev_cur in ( @@ -286,9 +222,11 @@ def makeWrapper(self): else: command = program - d = dict(cmd_or_spawn=command, - prompt_regex=r'\w*> $', - prompt_change_cmd=None) + d = dict( + cmd_or_spawn=command, + prompt_regex=PROMPT_RE, + prompt_change_cmd=None + ) wrapper = GnuplotREPLWrapper(**d) # No sleeping before sending commands to gnuplot wrapper.child.delaybeforesend = 0 @@ -309,7 +247,7 @@ def get_kernel_help_on(self, info, level=0, none_on_fail=False): else: return '' res = self.do_execute_direct('help %s' % obj) - text = res.output.strip().rstrip(PROMPT) + text = PROMPT_REMOVE_RE.sub('', res.output) self.bad_prompt_warning() return text @@ -372,34 +310,34 @@ def transition_from_plot(self, stmt): if self.current == 'output': self.current = 'none' elif self.current == 'plot': - if is_plot(stmt): + if stmt.is_plot(): self.current = 'plot' - elif is_set_output(stmt): + elif stmt.is_set_output(): self.current = 'output' else: self.current = 'none' def transition_from_none(self, stmt): - if is_plot(stmt): + if stmt.is_plot(): self.current = 'plot' - elif is_set_output(stmt): + elif stmt.is_set_output(): self.current = 'output' - elif is_set_multiplot(stmt): + elif stmt.is_set_multiplot(): self.current = 'multiplot' def transition_from_output(self, stmt): - if is_plot(stmt): + if stmt.is_plot(): self.current = 'plot' - elif is_set_multiplot(stmt): + elif stmt.is_set_multiplot(): self.current = 'output_multiplot' - elif is_unset_output(stmt): + elif stmt.is_unset_output(): self.current = 'none' def transition_from_multiplot(self, stmt): - if is_unset_multiplot(stmt): + if stmt.is_unset_multiplot(): self.current = 'none' def transition_from_output_multiplot(self, stmt): - if is_unset_multiplot(stmt): + if stmt.is_unset_multiplot(): self.previous = self.current self.current = 'output' diff --git a/gnuplot_kernel/replwrap.py b/gnuplot_kernel/replwrap.py index f0399a5..d4d2132 100644 --- a/gnuplot_kernel/replwrap.py +++ b/gnuplot_kernel/replwrap.py @@ -4,13 +4,46 @@ 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*$') + +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): @@ -18,8 +51,8 @@ class GnuplotREPLWrapper(REPLWrapper): prompt = '' _blocks = { 'data': { - 'start_re': re.compile(r'^\$\w+\s+<<\s*(?P\w+)$'), - 'end_re': re.compile(r'^(?P\w+)$') + 'start_re': START_DATABLOCK_RE, + 'end_re': END_DATABLOCK_RE } } _current_block = NO_BLOCK @@ -39,7 +72,7 @@ def is_error_output(self, text): """ Return True if text is recognised as error text """ - for pattern in ERROR_REs: + for pattern in ERROR_RE: if pattern.match(text): return True return False @@ -115,8 +148,7 @@ def _end_of_block(self, stmt, end_string): Terminal string for the current block. """ pattern_re = self._blocks[self._current_block]['end_re'] - m = pattern_re.match(stmt) - if m: + if m := pattern_re.match(stmt): if m.group('end') == end_string: return True return False @@ -142,8 +174,7 @@ def _start_of_block(self, stmt): block_type = NO_BLOCK 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') break @@ -215,7 +246,7 @@ def run_command(self, code, timeout=-1, stream_handler=None, # 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 diff --git a/gnuplot_kernel/statement.py b/gnuplot_kernel/statement.py new file mode 100644 index 0000000..f219b25 --- /dev/null +++ b/gnuplot_kernel/statement.py @@ -0,0 +1,97 @@ +""" +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)) From e7e52b341124de12edfd57d2df1420d26c63dcd2 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 28 Nov 2022 17:11:19 +0300 Subject: [PATCH 06/26] Ignore coverage.xml --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 77f911b..a148410 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ pip-log.txt .tox htmlcov/ .pytest_cache +coverage.xml # other .cache From 471165c01c417ff0feba24516528e29784b76ee0 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 10 Jan 2023 17:01:37 +0300 Subject: [PATCH 07/26] Make plotting in do_for loop possible closes #34 --- gnuplot_kernel/kernel.py | 77 +++++++++++++++++++---------- gnuplot_kernel/tests/test_kernel.py | 12 +++++ 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/gnuplot_kernel/kernel.py b/gnuplot_kernel/kernel.py index d523b04..80123ac 100644 --- a/gnuplot_kernel/kernel.py +++ b/gnuplot_kernel/kernel.py @@ -1,5 +1,6 @@ import sys -import os.path +from itertools import chain +from pathlib import Path import uuid from IPython.display import Image, SVG @@ -12,6 +13,10 @@ from .utils import get_version +IMG_COUNTER = '__gpk_img_index' +IMG_COUNTER_FMT = '%03d' + + class GnuplotKernel(ProcessMetaKernel): """ GnuplotKernel @@ -76,15 +81,13 @@ def _do_execute_direct(self, code): success = True try: - result = super(GnuplotKernel, - self).do_execute_direct(code, silent=True) + result = super().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) + super().do_execute_direct(self.reset_code, silent=True) if self.inline_plotting: if success: @@ -102,18 +105,21 @@ def add_inline_image_statements(self, code): This is what powers inline plotting """ - # Ensure that there are no stale images - self.delete_image_files() - + # "set output sprintf('foobar.%d.png', counter);" + # "counter=counter+1" def set_output_inline(lines): - filename = self.get_image_filename() - if filename: - lines.append("set output '{}'".format(filename)) + 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 every plot statement that is not - # inside a multiplot block + # - before every plot statement that is not in a + # multiplot block # - before every multiplot block lines = [] @@ -152,14 +158,25 @@ 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. - settings = self.plot_settings - filename = '/tmp/gnuplot-inline-{}.{}'.format( - uuid.uuid1(), - settings['format']) - filename = filename + fmt = self.plot_settings['format'] + filename = Path( + f'/tmp/gnuplot-inline-{uuid.uuid1()}' + f'.{IMG_COUNTER_FMT}' + f'.{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 @@ -171,9 +188,9 @@ def display_images(self): else: _Image = Image - for filename in self._image_files: + for filename in self.iter_image_files(): try: - size = os.path.getsize(filename) + size = filename.stat().st_size except FileNotFoundError: size = 0 @@ -187,7 +204,7 @@ def display_images(self): print(msg) continue - im = _Image(filename) + im = _Image(str(filename)) self.Display(im) def delete_image_files(self): @@ -196,9 +213,9 @@ def delete_image_files(self): """ # After display_images(), the real images are # no longer required. - for filename in self._image_files: + for filename in self.iter_image_files(): try: - os.remove(filename) + filename.unlink() except FileNotFoundError: pass @@ -237,7 +254,7 @@ def do_shutdown(self, restart): Exit the gnuplot process and any other underlying stuff """ self.wrapper.exit() - super(GnuplotKernel, self).do_shutdown(restart) + super().do_shutdown(restart) def get_kernel_help_on(self, info, level=0, none_on_fail=False): obj = info.get('help_obj', '') @@ -251,6 +268,13 @@ def get_kernel_help_on(self, info, level=0, none_on_fail=False): self.bad_prompt_warning() 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 @@ -271,14 +295,15 @@ def handle_plot_settings(self): 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 add inline commands - so that gnuplot can create inline images for the notebook + 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' diff --git a/gnuplot_kernel/tests/test_kernel.py b/gnuplot_kernel/tests/test_kernel.py index 1b8ec0e..5db28c4 100644 --- a/gnuplot_kernel/tests/test_kernel.py +++ b/gnuplot_kernel/tests/test_kernel.py @@ -273,6 +273,18 @@ def test_data_block(): 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(): From facb6f3205e7d0a334aafdd29eb985caa7c1209a Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 10 Jan 2023 19:11:14 +0300 Subject: [PATCH 08/26] Use pathlib instead of os.path --- gnuplot_kernel/tests/test_kernel.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/gnuplot_kernel/tests/test_kernel.py b/gnuplot_kernel/tests/test_kernel.py index 5db28c4..b0d5f7c 100644 --- a/gnuplot_kernel/tests/test_kernel.py +++ b/gnuplot_kernel/tests/test_kernel.py @@ -1,5 +1,5 @@ -import os import weakref +from pathlib import Path from metakernel.tests.utils import (get_kernel, get_log_text, clear_log_text) @@ -74,7 +74,7 @@ def test_file_plots(): plot sin(x) """ kernel.do_execute(code) - assert os.path.exists('sine.png') + assert Path('sine.png').exists() clear_log_text(kernel) # Multiple line statement @@ -84,7 +84,7 @@ def test_file_plots(): cos(x) """ kernel.do_execute(code) - assert os.path.exists('sine-cosine.png') + assert Path('sine-cosine.png').exists() # Multiple line statement code = """ @@ -94,8 +94,8 @@ def test_file_plots(): replot """ kernel.do_execute(code) - assert os.path.exists('tan.png') - assert os.path.exists('tan2.png') + assert Path('tan.png').exists() + assert Path('tan2.png').exists() remove_files('sine.png', 'sine-cosine.png') remove_files('tan.png', 'tan2.png') @@ -177,7 +177,7 @@ def test_multiplot(): unset output """ kernel.do_execute(code) - assert os.path.exists('multiplot-sin-cos.png') + assert Path('multiplot-sin-cos.png').exists() remove_files('multiplot-sin-cos.png') @@ -315,7 +315,7 @@ def test_cell_magic(): plot cos(x) """ kernel.do_execute(code) - assert os.path.exists('cosine.png') + assert Path('cosine.png').exists() clear_log_text(kernel) remove_files('cosine.png') @@ -330,13 +330,13 @@ def test_reset_cell_magic(): plot sin(x) + cos(x) """ kernel.call_magic(code) - assert not os.path.exists('sine+cosine.png') + assert not Path('sine+cosine.png').exists() code = """ unset key """ kernel.do_execute(code) - assert os.path.exists('sine+cosine.png') + assert Path('sine+cosine.png').exists() remove_files('sine+cosine.png') @@ -358,7 +358,7 @@ def test_reset_line_magic(): unset key """ kernel.do_execute(code) - assert not os.path.exists('sine+sine.png') + assert not Path('sine+sine.png').exists() # Bad inline backend # metakernel messes this exception!! @@ -378,8 +378,8 @@ def test_remove_files(): with open(filename, 'w'): pass - assert os.path.exists(filename) + assert Path(filename).exists() remove_files(filename) - assert not os.path.exists(filename) + assert not Path(filename).exists() From 9135123b1f0a3d374976521c6f35b3d5f7f53492 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 8 Sep 2025 18:36:50 -0400 Subject: [PATCH 09/26] Convert readme to markdown --- MANIFEST.in | 2 +- README.md | 40 +++++++++++++++++++++++++++++ README.rst | 72 ----------------------------------------------------- 3 files changed, 41 insertions(+), 73 deletions(-) create mode 100644 README.md delete mode 100644 README.rst 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/README.md b/README.md new file mode 100644 index 0000000..e25e9c7 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# 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/plotnine/workflows/build/badge.svg?branch=main)](https://github.com/has2k1/plotnine/actions?query=branch%3Amain+workflow%3A%22build%22) +[![Coverage](https://coveralls.io/repos/github/has2k1/gnuplot_kernel/badge.svg?branch=main)](https://coveralls.io/github/has2k1/gnuplot_kernel?branch=main) + +`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 release + +```console +$ 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 + +```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 From d08b8dac948934636347b4b66f21bd38d69d8531 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 11:01:50 -0400 Subject: [PATCH 10/26] Move to pyproject.toml --- .gitignore | 3 + pyproject.toml | 119 +++++++++++++++--- requirements.txt | 2 - requirements_dev.txt | 14 --- setup.cfg | 43 ------- setup.py | 4 - .../gnuplot_kernel}/__init__.py | 0 .../gnuplot_kernel}/__main__.py | 0 .../gnuplot_kernel}/exceptions.py | 0 .../gnuplot_kernel}/images/logo-32x32.png | Bin .../gnuplot_kernel}/images/logo-64x64.png | Bin .../gnuplot_kernel}/images/logo.gp | 0 .../gnuplot_kernel}/kernel.py | 0 .../gnuplot_kernel}/magics/__init__.py | 0 .../gnuplot_kernel}/magics/gnuplot_magic.py | 0 .../gnuplot_kernel}/magics/reset_magic.py | 0 .../gnuplot_kernel}/replwrap.py | 0 .../gnuplot_kernel}/statement.py | 0 .../gnuplot_kernel}/utils.py | 0 {gnuplot_kernel/tests => tests}/__init__.py | 0 {gnuplot_kernel/tests => tests}/conftest.py | 1 - .../tests => tests}/test_kernel.py | 0 22 files changed, 108 insertions(+), 78 deletions(-) delete mode 100644 requirements.txt delete mode 100644 requirements_dev.txt delete mode 100644 setup.cfg delete mode 100644 setup.py rename {gnuplot_kernel => src/gnuplot_kernel}/__init__.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/__main__.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/exceptions.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/images/logo-32x32.png (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/images/logo-64x64.png (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/images/logo.gp (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/kernel.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/magics/__init__.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/magics/gnuplot_magic.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/magics/reset_magic.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/replwrap.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/statement.py (100%) rename {gnuplot_kernel => src/gnuplot_kernel}/utils.py (100%) rename {gnuplot_kernel/tests => tests}/__init__.py (100%) rename {gnuplot_kernel/tests => tests}/conftest.py (99%) rename {gnuplot_kernel/tests => tests}/test_kernel.py (100%) diff --git a/.gitignore b/.gitignore index a148410..376c075 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ coverage.xml # other .cache examples/.ipynb_checkpoints + +# uv +uv.lock diff --git a/pyproject.toml b/pyproject.toml index ef67fc1..f1aacb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,44 @@ -# 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 = [ + "ruff", + "pytest-cov>=4.0.0", + "coveralls", + "matplotlib", +] + +[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 +47,85 @@ 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__", +] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 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 100% rename from gnuplot_kernel/__init__.py rename to src/gnuplot_kernel/__init__.py diff --git a/gnuplot_kernel/__main__.py b/src/gnuplot_kernel/__main__.py similarity index 100% rename from gnuplot_kernel/__main__.py rename to src/gnuplot_kernel/__main__.py diff --git a/gnuplot_kernel/exceptions.py b/src/gnuplot_kernel/exceptions.py similarity index 100% rename from gnuplot_kernel/exceptions.py rename to src/gnuplot_kernel/exceptions.py 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 100% rename from gnuplot_kernel/kernel.py rename to src/gnuplot_kernel/kernel.py diff --git a/gnuplot_kernel/magics/__init__.py b/src/gnuplot_kernel/magics/__init__.py similarity index 100% rename from gnuplot_kernel/magics/__init__.py rename to src/gnuplot_kernel/magics/__init__.py diff --git a/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py similarity index 100% rename from gnuplot_kernel/magics/gnuplot_magic.py rename to src/gnuplot_kernel/magics/gnuplot_magic.py diff --git a/gnuplot_kernel/magics/reset_magic.py b/src/gnuplot_kernel/magics/reset_magic.py similarity index 100% rename from gnuplot_kernel/magics/reset_magic.py rename to src/gnuplot_kernel/magics/reset_magic.py diff --git a/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py similarity index 100% rename from gnuplot_kernel/replwrap.py rename to src/gnuplot_kernel/replwrap.py diff --git a/gnuplot_kernel/statement.py b/src/gnuplot_kernel/statement.py similarity index 100% rename from gnuplot_kernel/statement.py rename to src/gnuplot_kernel/statement.py diff --git a/gnuplot_kernel/utils.py b/src/gnuplot_kernel/utils.py similarity index 100% rename from gnuplot_kernel/utils.py rename to src/gnuplot_kernel/utils.py diff --git a/gnuplot_kernel/tests/__init__.py b/tests/__init__.py similarity index 100% rename from gnuplot_kernel/tests/__init__.py rename to tests/__init__.py diff --git a/gnuplot_kernel/tests/conftest.py b/tests/conftest.py similarity index 99% rename from gnuplot_kernel/tests/conftest.py rename to tests/conftest.py index fd226a7..caebb40 100644 --- a/gnuplot_kernel/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ import os - def remove_files(*filenames): """ Remove the files created during the test diff --git a/gnuplot_kernel/tests/test_kernel.py b/tests/test_kernel.py similarity index 100% rename from gnuplot_kernel/tests/test_kernel.py rename to tests/test_kernel.py From f01374e3b8f37c9e9133fca575376625950ce506 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 11:03:15 -0400 Subject: [PATCH 11/26] Ignored unnamed notebook files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 376c075..0fd8f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,8 @@ coverage.xml .cache examples/.ipynb_checkpoints +# Catch all unnamed notebook files +**/Untitled*.ipynb + # uv uv.lock From 819abeabc95236c5e901b216381a4465cac225d0 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 11:09:40 -0400 Subject: [PATCH 12/26] Use uv in Makefile --- Makefile | 70 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 90b0aaa..1cb7677 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,54 @@ 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 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 ".[extra]" -develop: clean-pyc - python setup.py develop +develop: clean-cache + $(PIP) install -e ".[dev]" From 8478cbc0a5124f4562f69481e4377620517fff01 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 11:12:10 -0400 Subject: [PATCH 13/26] LINT: Apply safe fixes --- examples/gnuplot-magic.ipynb | 2 +- src/gnuplot_kernel/__init__.py | 4 +- src/gnuplot_kernel/__main__.py | 3 +- src/gnuplot_kernel/kernel.py | 149 ++++++++++----------- src/gnuplot_kernel/magics/__init__.py | 2 +- src/gnuplot_kernel/magics/gnuplot_magic.py | 35 +++-- src/gnuplot_kernel/magics/reset_magic.py | 2 +- src/gnuplot_kernel/replwrap.py | 67 +++++---- src/gnuplot_kernel/statement.py | 64 ++++----- tests/conftest.py | 1 + tests/test_kernel.py | 100 +++++++------- 11 files changed, 213 insertions(+), 216 deletions(-) diff --git a/examples/gnuplot-magic.ipynb b/examples/gnuplot-magic.ipynb index 4a876a0..a93bf9b 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", diff --git a/src/gnuplot_kernel/__init__.py b/src/gnuplot_kernel/__init__.py index f956a00..f5b21c6 100644 --- a/src/gnuplot_kernel/__init__.py +++ b/src/gnuplot_kernel/__init__.py @@ -7,11 +7,11 @@ from .magics import register_ipython_magics from .utils import get_version -__all__ = ['GnuplotKernel'] +__all__ = ["GnuplotKernel"] try: - __version__ = get_version('gnuplot_kernel') + __version__ = get_version("gnuplot_kernel") except PackageNotFoundError: # package is not installed pass diff --git a/src/gnuplot_kernel/__main__.py b/src/gnuplot_kernel/__main__.py index cf569a0..4b17c0f 100644 --- a/src/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/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py index 80123ac..d92c267 100644 --- a/src/gnuplot_kernel/kernel.py +++ b/src/gnuplot_kernel/kernel.py @@ -1,49 +1,48 @@ import sys +import uuid from itertools import chain from pathlib import Path -import uuid -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" + 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, + "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 = [] _error = False @@ -52,7 +51,7 @@ def bad_prompt_warning(self): """ Print warning if the prompt is not 'gnuplot>' """ - if not self.wrapper.prompt.startswith('gnuplot>'): + if not self.wrapper.prompt.startswith("gnuplot>"): msg = ("Warning: The prompt is currently set " "to '{}'".format(self.wrapper.prompt)) print(msg) @@ -130,9 +129,9 @@ def set_output_inline(lines): sm.transition(stmt) add_inline_plot = ( sm.prev_cur in ( - ('none', 'plot'), - ('none', 'multiplot'), - ('plot', 'plot') + ("none", "plot"), + ("none", "multiplot"), + ("plot", "plot") ) and not is_joined_stmt ) @@ -140,12 +139,12 @@ def set_output_inline(lines): 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 +157,11 @@ 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()}" + f".{IMG_COUNTER_FMT}" + f".{fmt}" ) self._image_files.append(filename) return filename @@ -172,7 +171,7 @@ def iter_image_files(self): Iterate over the image files """ it = chain(*[ - sorted(f.parent.glob(f.name.replace(IMG_COUNTER_FMT, '*'))) + sorted(f.parent.glob(f.name.replace(IMG_COUNTER_FMT, "*"))) for f in self._image_files ]) return it @@ -183,7 +182,7 @@ def display_images(self): """ settings = self.plot_settings if self.inline_plotting: - if settings['format'] == 'svg': + if settings["format"] == "svg": _Image = SVG else: _Image = Image @@ -225,17 +224,17 @@ 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 @@ -257,14 +256,14 @@ 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) + return "" + res = self.do_execute_direct("help %s" % obj) + text = PROMPT_REMOVE_RE.sub("", res.output) self.bad_prompt_warning() return text @@ -272,7 +271,7 @@ 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 +282,17 @@ 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' + 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 ("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 +304,9 @@ 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,7 +323,7 @@ def current(self, value): def transition(self, stmt): lookup = { - s: getattr(self, f'transition_from_{s}') + s: getattr(self, f"transition_from_{s}") for s in self.states } _transition = lookup[self.current] @@ -332,37 +331,37 @@ def transition(self, stmt): 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/src/gnuplot_kernel/magics/__init__.py b/src/gnuplot_kernel/magics/__init__.py index fe11134..486cb06 100644 --- a/src/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/src/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py index 7cf6af3..1351d36 100644 --- a/src/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,22 @@ 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': + 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 + 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): @@ -111,8 +110,8 @@ def register_ipython_magics(): kernel.makeSubkernel(ip.parent) # 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): @@ -131,13 +130,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/src/gnuplot_kernel/magics/reset_magic.py b/src/gnuplot_kernel/magics/reset_magic.py index dfb2935..daba7ff 100644 --- a/src/gnuplot_kernel/magics/reset_magic.py +++ b/src/gnuplot_kernel/magics/reset_magic.py @@ -10,7 +10,7 @@ def line_reset(self, *line): Example: %reset """ - self.kernel.reset_code = '' + self.kernel.reset_code = "" def cell_reset(self, line): """ diff --git a/src/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py index d4d2132..eeba8ab 100644 --- a/src/gnuplot_kernel/replwrap.py +++ b/src/gnuplot_kernel/replwrap.py @@ -1,32 +1,31 @@ import re -import textwrap import signal +import textwrap 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*' + r"\w*>\s*" ) # Data block e.g. @@ -38,21 +37,21 @@ # 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 @@ -66,7 +65,7 @@ def exit(self): except GnuplotError: return self.child.kill(signal.SIGKILL) - self.sendline('exit') + self.sendline("exit") def is_error_output(self, text): """ @@ -83,16 +82,16 @@ def validate_input(self, code): Raises GnuplotError if it cannot deal with it. """ - if code.endswith('\\'): + 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): """ @@ -147,9 +146,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'] + pattern_re = self._blocks[self._current_block]["end_re"] if m := pattern_re.match(stmt): - if m.group('end') == end_string: + if m.group("end") == end_string: return True return False @@ -172,11 +171,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 +189,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,7 +210,7 @@ def _splitlines(self, code): lines.append(stmt) if self._current_block: - msg = 'Error: {} block not terminated correctly.'.format( + msg = "Error: {} block not terminated correctly.".format( self._current_block) self._current_block = NO_BLOCK raise GnuplotError(msg) @@ -237,21 +236,21 @@ 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 = self.child.before.replace(CRLF, "\n") self.prompt = self.child.after if self.is_error_output(retval): - msg = '{}\n{}'.format( + 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/src/gnuplot_kernel/statement.py b/src/gnuplot_kernel/statement.py index f219b25..67befec 100644 --- a/src/gnuplot_kernel/statement.py +++ b/src/gnuplot_kernel/statement.py @@ -5,59 +5,59 @@ # 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/tests/conftest.py b/tests/conftest.py index caebb40..fd226a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import os + def remove_files(*filenames): """ Remove the files created during the test diff --git a/tests/test_kernel.py b/tests/test_kernel.py index b0d5f7c..99437da 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -1,8 +1,8 @@ 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 @@ -50,10 +50,10 @@ 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,12 +61,12 @@ 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 = """ @@ -74,7 +74,7 @@ def test_file_plots(): plot sin(x) """ kernel.do_execute(code) - assert Path('sine.png').exists() + assert Path("sine.png").exists() clear_log_text(kernel) # Multiple line statement @@ -84,7 +84,7 @@ def test_file_plots(): cos(x) """ kernel.do_execute(code) - assert Path('sine-cosine.png').exists() + assert Path("sine-cosine.png").exists() # Multiple line statement code = """ @@ -94,16 +94,16 @@ def test_file_plots(): replot """ kernel.do_execute(code) - assert Path('tan.png').exists() - assert Path('tan2.png').exists() + assert Path("tan.png").exists() + assert Path("tan2.png").exists() - remove_files('sine.png', 'sine-cosine.png') - remove_files('tan.png', 'tan2.png') + remove_files("sine.png", "sine-cosine.png") + remove_files("tan.png", "tan2.png") 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 +111,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 +121,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 +149,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,7 +164,7 @@ 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 = """ @@ -177,8 +177,8 @@ def test_multiplot(): unset output """ kernel.do_execute(code) - assert Path('multiplot-sin-cos.png').exists() - remove_files('multiplot-sin-cos.png') + assert Path("multiplot-sin-cos.png").exists() + remove_files("multiplot-sin-cos.png") def test_help(): @@ -188,17 +188,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 +206,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 +248,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 +264,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,7 +282,7 @@ 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 # @@ -297,28 +297,28 @@ 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') + 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() + assert Path("cosine.png").exists() clear_log_text(kernel) - remove_files('cosine.png') + remove_files("cosine.png") def test_reset_cell_magic(): @@ -330,15 +330,15 @@ def test_reset_cell_magic(): plot sin(x) + cos(x) """ kernel.call_magic(code) - assert not Path('sine+cosine.png').exists() + assert not Path("sine+cosine.png").exists() code = """ unset key """ kernel.do_execute(code) - assert Path('sine+cosine.png').exists() + assert Path("sine+cosine.png").exists() - remove_files('sine+cosine.png') + remove_files("sine+cosine.png") def test_reset_line_magic(): @@ -353,12 +353,12 @@ 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.png").exists() # Bad inline backend # metakernel messes this exception!! @@ -372,10 +372,10 @@ def test_remove_files(): This test create a file. Next test tests that it is deleted """ - filename = 'antigravit.txt' + filename = "antigravit.txt" # Create file # make sure it exis - with open(filename, 'w'): + with open(filename, "w"): pass assert Path(filename).exists() From 1bc1b6b3eca892a584a53459212500d5a7ce3666 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 13:29:19 -0400 Subject: [PATCH 14/26] LINT: Resolve manually --- pyproject.toml | 1 + src/gnuplot_kernel/__init__.py | 6 +- src/gnuplot_kernel/kernel.py | 10 +- src/gnuplot_kernel/magics/gnuplot_magic.py | 9 +- src/gnuplot_kernel/replwrap.py | 12 +- tests/conftest.py | 26 +++- tests/test_kernel.py | 143 +++++++++------------ 7 files changed, 94 insertions(+), 113 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f1aacb2..be62892 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ unfixable = [] # Exclude a variety of commonly ignored directories. exclude = [ "**/__pycache__", + "**/*.ipynb", ] # Allow unused variables when underscore-prefixed. diff --git a/src/gnuplot_kernel/__init__.py b/src/gnuplot_kernel/__init__.py index f5b21c6..9e87d16 100644 --- a/src/gnuplot_kernel/__init__.py +++ b/src/gnuplot_kernel/__init__.py @@ -1,6 +1,7 @@ """ Gnuplot Kernel Package """ +from contextlib import suppress from importlib.metadata import PackageNotFoundError from .kernel import GnuplotKernel @@ -10,11 +11,8 @@ __all__ = ["GnuplotKernel"] -try: +with suppress(PackageNotFoundError): __version__ = get_version("gnuplot_kernel") -except PackageNotFoundError: - # package is not installed - pass def load_ipython_extension(ipython): diff --git a/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py index d92c267..5ba7149 100644 --- a/src/gnuplot_kernel/kernel.py +++ b/src/gnuplot_kernel/kernel.py @@ -1,3 +1,4 @@ +import contextlib import sys import uuid from itertools import chain @@ -182,10 +183,7 @@ 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 for filename in self.iter_image_files(): try: @@ -213,10 +211,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 = [] diff --git a/src/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py index 1351d36..d00e381 100644 --- a/src/gnuplot_kernel/magics/gnuplot_magic.py +++ b/src/gnuplot_kernel/magics/gnuplot_magic.py @@ -50,11 +50,10 @@ def line_gnuplot(self, *args): "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) + 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 diff --git a/src/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py index eeba8ab..c122594 100644 --- a/src/gnuplot_kernel/replwrap.py +++ b/src/gnuplot_kernel/replwrap.py @@ -71,10 +71,7 @@ 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): """ @@ -146,10 +143,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): diff --git a/tests/conftest.py b/tests/conftest.py index fd226a7..b62389c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,24 @@ import os +from contextlib import contextmanager +from pathlib import Path +os.environ["JUPYTER_PLATFORM_DIRS"] = "1" -def remove_files(*filenames): + +@contextmanager +def ensure_deleted(*paths: str): """ - Remove the files created during the test + Ensures the given file paths are deleted when the block exits + + Parameters + ---------- + *paths : pathlib.Path + One or more file paths """ - for filename in filenames: - try: - os.remove(filename) - except FileNotFoundError: - pass + 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/tests/test_kernel.py b/tests/test_kernel.py index 99437da..b7831c6 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -6,7 +6,7 @@ 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 @@ -69,36 +66,37 @@ def test_file_plots(): 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(): @@ -167,18 +165,18 @@ def test_multiplot(): 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(): @@ -310,35 +308,35 @@ def test_cell_magic(): # 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() + + 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) - remove_files("cosine.png") 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(): @@ -358,28 +356,9 @@ def test_reset_line_magic(): 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() From 62eac4417afcec90a9fd3d816566a2820ec1ce65 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 13:30:21 -0400 Subject: [PATCH 15/26] FMT: Apply ruff formatting --- examples/gnuplot-magic.ipynb | 2 +- src/gnuplot_kernel/__init__.py | 1 + src/gnuplot_kernel/exceptions.py | 1 - src/gnuplot_kernel/kernel.py | 53 +++++++++++----------- src/gnuplot_kernel/magics/gnuplot_magic.py | 17 ++++--- src/gnuplot_kernel/magics/reset_magic.py | 1 - src/gnuplot_kernel/replwrap.py | 32 ++++++------- src/gnuplot_kernel/statement.py | 3 +- tests/conftest.py | 2 +- tests/test_kernel.py | 3 +- 10 files changed, 59 insertions(+), 56 deletions(-) diff --git a/examples/gnuplot-magic.ipynb b/examples/gnuplot-magic.ipynb index a93bf9b..e24bb1d 100644 --- a/examples/gnuplot-magic.ipynb +++ b/examples/gnuplot-magic.ipynb @@ -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/src/gnuplot_kernel/__init__.py b/src/gnuplot_kernel/__init__.py index 9e87d16..2912503 100644 --- a/src/gnuplot_kernel/__init__.py +++ b/src/gnuplot_kernel/__init__.py @@ -1,6 +1,7 @@ """ Gnuplot Kernel Package """ + from contextlib import suppress from importlib.metadata import PackageNotFoundError diff --git a/src/gnuplot_kernel/exceptions.py b/src/gnuplot_kernel/exceptions.py index 02bebbb..29dd510 100644 --- a/src/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/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py index 5ba7149..337b604 100644 --- a/src/gnuplot_kernel/kernel.py +++ b/src/gnuplot_kernel/kernel.py @@ -21,6 +21,7 @@ class GnuplotKernel(ProcessMetaKernel): """ GnuplotKernel """ + implementation = "Gnuplot Kernel" implementation_version = get_version("gnuplot_kernel") language = "gnuplot" @@ -34,9 +35,13 @@ class GnuplotKernel(ProcessMetaKernel): "help_links": MetaKernel.help_links, } kernel_json = { - "argv": [sys.executable, - "-m", "gnuplot_kernel", - "-f", "{connection_file}"], + "argv": [ + sys.executable, + "-m", + "gnuplot_kernel", + "-f", + "{connection_file}", + ], "display_name": "gnuplot", "language": "gnuplot", "name": "gnuplot", @@ -53,8 +58,9 @@ 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)) + msg = "Warning: The prompt is currently set to '{}'".format( + self.wrapper.prompt + ) print(msg) def do_execute_direct(self, code): @@ -105,6 +111,7 @@ def add_inline_image_statements(self, code): This is what powers inline plotting """ + # "set output sprintf('foobar.%d.png', counter);" # "counter=counter+1" def set_output_inline(lines): @@ -129,11 +136,8 @@ 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: @@ -160,9 +164,7 @@ def get_image_filename(self): # whodunnit. 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 +173,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): @@ -237,7 +241,7 @@ def makeWrapper(self): d = dict( 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 @@ -278,12 +282,9 @@ 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"]): + 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" @@ -300,6 +301,7 @@ 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" @@ -319,8 +321,7 @@ 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 diff --git a/src/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py index d00e381..8a4e874 100644 --- a/src/gnuplot_kernel/magics/gnuplot_magic.py +++ b/src/gnuplot_kernel/magics/gnuplot_magic.py @@ -44,15 +44,19 @@ 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"} + 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") + msg = ( + "For inline plots, the terminal must be " + "one of pngcairo, jpeg, svg or png" + ) raise ValueError(msg) self.kernel.plot_settings["backend"] = backend @@ -105,6 +109,7 @@ def register_ipython_magics(): # to some functionality. This connects it to the # main kernel. from IPython import get_ipython + ip = get_ipython() kernel.makeSubkernel(ip.parent) diff --git a/src/gnuplot_kernel/magics/reset_magic.py b/src/gnuplot_kernel/magics/reset_magic.py index daba7ff..c936f2d 100644 --- a/src/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 diff --git a/src/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py index c122594..b29c5a2 100644 --- a/src/gnuplot_kernel/replwrap.py +++ b/src/gnuplot_kernel/replwrap.py @@ -24,9 +24,7 @@ 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 @@ -49,10 +47,7 @@ class GnuplotREPLWrapper(REPLWrapper): # The prompt after the commands run 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 @@ -61,7 +56,7 @@ 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) @@ -80,8 +75,7 @@ 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.") + raise GnuplotError("Do not execute code that endswith backslash.") # Do not get stuck in the gnuplot process code = code.replace("\\\n", " ") @@ -94,7 +88,7 @@ def _force_prompt(self, timeout=30, n=4): """ Force prompt """ - quick_timeout = .05 + quick_timeout = 0.05 if timeout < quick_timeout: quick_timeout = timeout @@ -125,8 +119,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): @@ -207,14 +202,16 @@ def _splitlines(self, code): if self._current_block: msg = "Error: {} block not terminated correctly.".format( - self._current_block) + 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( + self, code, timeout=-1, stream_handler=None, stdin_handler=None + ): """ Run code @@ -235,8 +232,7 @@ def run_command(self, code, timeout=-1, stream_handler=None, retval = 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 diff --git a/src/gnuplot_kernel/statement.py b/src/gnuplot_kernel/statement.py index 67befec..26a98ab 100644 --- a/src/gnuplot_kernel/statement.py +++ b/src/gnuplot_kernel/statement.py @@ -1,13 +1,14 @@ """ 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"\w+" # The command r")" r"\s?" ) diff --git a/tests/conftest.py b/tests/conftest.py index b62389c..2dd7f4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ def ensure_deleted(*paths: str): """ Ensures the given file paths are deleted when the block exits - + Parameters ---------- *paths : pathlib.Path diff --git a/tests/test_kernel.py b/tests/test_kernel.py index b7831c6..2704d30 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -43,6 +43,7 @@ def teardown(): # Normal workflow tests # + def test_inline_magic(): kernel = get_kernel(GnuplotKernel) @@ -285,6 +286,7 @@ def test_do_for_loop(): # magics # + def test_cell_magic(): # To simulate '%load_ext gnuplot_kernel'; # create a main kernel, a gnuplot kernel and @@ -320,7 +322,6 @@ def test_cell_magic(): clear_log_text(kernel) - def test_reset_cell_magic(): kernel = get_kernel(GnuplotKernel) From 8d829df1a34d339f8683257fd7e78a6c24f67830 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 9 Sep 2025 14:05:57 -0400 Subject: [PATCH 16/26] CI: Release via github-actions --- .github/utils/_repo.py | 228 +++++++++++++++++++++++++++++++++ .github/utils/please.py | 84 ++++++++++++ .github/workflows/release.yml | 90 +++++++++++++ how-to-release.rst | 78 ----------- tools/release-checklist-tpl.md | 98 ++++++++++++++ tools/release-checklist.py | 159 +++++++++++++++++++++++ tools/term.py | 107 ++++++++++++++++ 7 files changed, 766 insertions(+), 78 deletions(-) create mode 100644 .github/utils/_repo.py create mode 100644 .github/utils/please.py create mode 100644 .github/workflows/release.yml delete mode 100644 how-to-release.rst create mode 100644 tools/release-checklist-tpl.md create mode 100644 tools/release-checklist.py create mode 100644 tools/term.py 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/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/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..78f9a02
--- /dev/null
+++ b/tools/release-checklist.py
@@ -0,0 +1,159 @@
+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/plotnine/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 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)  # type: ignore
+
+
+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  # type: ignore
+
+    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

From 00bd01b10be736798cc542b26ef62338a425eddd Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Tue, 9 Sep 2025 15:27:12 -0400
Subject: [PATCH 17/26] Add typechecking

---
 Makefile                                   |  3 ++
 pyproject.toml                             | 43 ++++++++++++++++++++++
 src/gnuplot_kernel/kernel.py               | 37 ++++++++++---------
 src/gnuplot_kernel/magics/gnuplot_magic.py | 10 ++---
 src/gnuplot_kernel/py.typed                |  0
 src/gnuplot_kernel/replwrap.py             | 20 ++++++----
 src/gnuplot_kernel/utils.py                |  2 +-
 7 files changed, 83 insertions(+), 32 deletions(-)
 create mode 100644 src/gnuplot_kernel/py.typed

diff --git a/Makefile b/Makefile
index 1cb7677..a6957df 100644
--- a/Makefile
+++ b/Makefile
@@ -65,6 +65,9 @@ lint-fix:
 
 fix: format-fix lint-fix
 
+typecheck:
+	$(UVRUN) pyright
+
 test: clean-test
 	$(UVRUN) pytest
 
diff --git a/pyproject.toml b/pyproject.toml
index be62892..2d53b8e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ dev = [
     "pytest-cov>=4.0.0",
     "coveralls",
     "matplotlib",
+    "pyright>=1.1.405",
 ]
 
 [project.urls]
@@ -130,3 +131,45 @@ exclude = [
 
 # 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/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py
index 337b604..86392ae 100644
--- a/src/gnuplot_kernel/kernel.py
+++ b/src/gnuplot_kernel/kernel.py
@@ -1,8 +1,11 @@
+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
@@ -25,8 +28,8 @@ class GnuplotKernel(ProcessMetaKernel):
     implementation = "Gnuplot Kernel"
     implementation_version = get_version("gnuplot_kernel")
     language = "gnuplot"
-    language_version = "5.0"
-    banner = "Gnuplot Kernel"
+    _banner = "Gnuplot Kernel"
+    language_version = "5.0"  # pyright: ignore[reportAssignmentType,reportIncompatibleMethodOverride]
     language_info = {
         "mimetype": "text/x-gnuplot",
         "name": "gnuplot",
@@ -50,20 +53,20 @@ class GnuplotKernel(ProcessMetaKernel):
     inline_plotting = True
     reset_code = ""
     _first = True
-    _image_files = []
+    _image_files: list[Path] = []
     _error = False
 
+    wrapper: GnuplotREPLWrapper
+
     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)
+        prompt = cast("str", self.wrapper.prompt).strip()
+        if not prompt.endswith("gnuplot>"):
+            print(f"Warning: The prompt is currently set to '{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
@@ -73,7 +76,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
         """
@@ -105,7 +108,7 @@ def _do_execute_direct(self, code):
         # 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
 
@@ -188,6 +191,8 @@ def display_images(self):
         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:
@@ -238,12 +243,11 @@ def makeWrapper(self):
         else:
             command = program
 
-        d = dict(
+        wrapper = GnuplotREPLWrapper(
             cmd_or_spawn=command,
             prompt_regex=PROMPT_RE,
             prompt_change_cmd=None,
         )
-        wrapper = GnuplotREPLWrapper(**d)
         # No sleeping before sending commands to gnuplot
         wrapper.child.delaybeforesend = 0
         return wrapper
@@ -258,11 +262,8 @@ def do_shutdown(self, 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)
+            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.bad_prompt_warning()
         return text
diff --git a/src/gnuplot_kernel/magics/gnuplot_magic.py b/src/gnuplot_kernel/magics/gnuplot_magic.py
index 8a4e874..b0bb4d2 100644
--- a/src/gnuplot_kernel/magics/gnuplot_magic.py
+++ b/src/gnuplot_kernel/magics/gnuplot_magic.py
@@ -108,23 +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
 
     @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()
 
diff --git a/src/gnuplot_kernel/py.typed b/src/gnuplot_kernel/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py
index b29c5a2..840ff52 100644
--- a/src/gnuplot_kernel/replwrap.py
+++ b/src/gnuplot_kernel/replwrap.py
@@ -1,6 +1,7 @@
 import re
 import signal
 import textwrap
+from typing import cast
 
 from metakernel import REPLWrapper
 from metakernel.pexpect import TIMEOUT
@@ -84,7 +85,7 @@ def validate_input(self, code):
     def send(self, cmd):
         self.child.send(cmd + "\r")
 
-    def _force_prompt(self, timeout=30, n=4):
+    def _force_prompt(self, timeout: float=30, n=4):
         """
         Force prompt
         """
@@ -109,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
 
@@ -209,8 +210,13 @@ def _splitlines(self, code):
 
         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
@@ -218,10 +224,10 @@ def run_command(
         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)
@@ -229,7 +235,7 @@ def run_command(
 
             # 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))
diff --git a/src/gnuplot_kernel/utils.py b/src/gnuplot_kernel/utils.py
index 0e7976e..67ea9c3 100644
--- a/src/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
 

From 4cf4e9328a0d608c89588eed6d69d710b703aa95 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Tue, 9 Sep 2025 15:35:52 -0400
Subject: [PATCH 18/26] CI: Revamp testing

---
 .github/workflows/testing.yml | 86 +++++++++++++++++++++++++----------
 1 file changed, 61 insertions(+), 25 deletions(-)

diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 220232a..53cf978 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,44 @@ 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 ".[dev]"
 
       - 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
 
       - 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 }}"
 
-  # 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 +66,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

From 037f4bb6ff4b070197826634a0ebbf6bf3e49e6f Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Tue, 9 Sep 2025 15:38:27 -0400
Subject: [PATCH 19/26] FMT: re-format

---
 src/gnuplot_kernel/kernel.py   | 2 +-
 src/gnuplot_kernel/replwrap.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py
index 86392ae..7dc5e7b 100644
--- a/src/gnuplot_kernel/kernel.py
+++ b/src/gnuplot_kernel/kernel.py
@@ -262,7 +262,7 @@ def do_shutdown(self, 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 "" 
+            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.bad_prompt_warning()
diff --git a/src/gnuplot_kernel/replwrap.py b/src/gnuplot_kernel/replwrap.py
index 840ff52..eba32de 100644
--- a/src/gnuplot_kernel/replwrap.py
+++ b/src/gnuplot_kernel/replwrap.py
@@ -85,7 +85,7 @@ def validate_input(self, code):
     def send(self, cmd):
         self.child.send(cmd + "\r")
 
-    def _force_prompt(self, timeout: float=30, n=4):
+    def _force_prompt(self, timeout: float = 30, n=4):
         """
         Force prompt
         """

From 8b1541b07ac2bb46c3a6ae70186f2ed39fe0fc14 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Tue, 9 Sep 2025 15:48:18 -0400
Subject: [PATCH 20/26] Add test group in the optional-dependencies

---
 .github/workflows/testing.yml |  2 +-
 pyproject.toml                | 11 ++++++++---
 2 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 53cf978..86a3071 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -40,7 +40,7 @@ jobs:
           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 ".[dev]"
+          uv run uv pip install --resolution=${{ matrix.resolution }} -e ".[test]"
 
       - name: Environment Information
         run: |
diff --git a/pyproject.toml b/pyproject.toml
index 2d53b8e..a692302 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,13 +27,18 @@ requires-python = ">=3.10"
 [project.optional-dependencies]
 
 dev = [
+    "gnuplot_kernel[test]",
     "ruff",
-    "pytest-cov>=4.0.0",
-    "coveralls",
-    "matplotlib",
+    "matplotlib>=3.8.0",
     "pyright>=1.1.405",
 ]
 
+test = [
+    "pytest-cov>=4.0.0",
+    "coveralls>=4.0.1",
+]
+
+
 [project.urls]
 homepage = "https://github.com/has2k1/gnuplot_kernel"
 repository = "https://github.com/has2k1/gnuplot_kernel"

From 194498b0c4f04da6554ff07a61e8cafe8ac54096 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Sat, 13 Sep 2025 10:40:55 -0400
Subject: [PATCH 21/26] CI: Add codecov token

---
 .github/workflows/testing.yml | 3 +++
 pyproject.toml                | 1 -
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 86a3071..50e4156 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -51,11 +51,14 @@ jobs:
         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
diff --git a/pyproject.toml b/pyproject.toml
index a692302..d3bd177 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,6 @@ dev = [
 
 test = [
     "pytest-cov>=4.0.0",
-    "coveralls>=4.0.1",
 ]
 
 

From e40209860ca8764080dbce3d710c8dbbe7e5ee94 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Mon, 15 Sep 2025 19:41:17 -0700
Subject: [PATCH 22/26] DOC: Install in virtual env

closes #36
---
 README.md | 39 ++++++++++++++++++++++++++++++++++-----
 1 file changed, 34 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index e25e9c7..875f589 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
 # 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/plotnine/workflows/build/badge.svg?branch=main)](https://github.com/has2k1/plotnine/actions?query=branch%3Amain+workflow%3A%22build%22)
@@ -12,18 +11,48 @@ as `python` code.
 
 ## Installation
 
-Official release
+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
-$ pip install gnuplot_kernel
-$ python -m gnuplot_kernel install --user
+$ 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
+**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

From 5c52682d2b3d476dd6a3d36470aae1bd3174edd9 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Mon, 15 Sep 2025 20:14:32 -0700
Subject: [PATCH 23/26] chores: Fix Makefile install command

---
 Makefile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index a6957df..cb03adc 100644
--- a/Makefile
+++ b/Makefile
@@ -90,7 +90,7 @@ release-patch:
 	@$(PYTHON) ./tools/release-checklist.py patch
 
 install: clean
-	$(PIP) install ".[extra]"
+	$(PIP) install .
 
 develop: clean-cache
 	$(PIP) install -e ".[dev]"

From f5b21bb6602b469b02954c1a508ed1976a620afe Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Mon, 15 Sep 2025 20:22:54 -0700
Subject: [PATCH 24/26] enh: Print a bad prompt once

---
 src/gnuplot_kernel/kernel.py | 17 +++++++++++------
 1 file changed, 11 insertions(+), 6 deletions(-)

diff --git a/src/gnuplot_kernel/kernel.py b/src/gnuplot_kernel/kernel.py
index 7dc5e7b..5701932 100644
--- a/src/gnuplot_kernel/kernel.py
+++ b/src/gnuplot_kernel/kernel.py
@@ -57,14 +57,19 @@ class GnuplotKernel(ProcessMetaKernel):
     _error = False
 
     wrapper: GnuplotREPLWrapper
+    _bad_prompts: set = set()
 
-    def bad_prompt_warning(self):
+    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.
         """
-        prompt = cast("str", self.wrapper.prompt).strip()
-        if not prompt.endswith("gnuplot>"):
+        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
@@ -103,7 +108,7 @@ def _do_execute_direct(self, code: str) -> TextOutput | None:
                 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
@@ -265,7 +270,7 @@ def get_kernel_help_on(self, info, level=0, none_on_fail=False):
             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.bad_prompt_warning()
+        self.check_prompt()
         return text
 
     def reset_image_counter(self):

From 56c4ef3277c90321d4fd29f3751693f4e58dfb38 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Mon, 15 Sep 2025 20:26:55 -0700
Subject: [PATCH 25/26] chore: fix copy paste slipups

---
 README.md                  |  2 +-
 tools/release-checklist.py | 10 ++++++----
 2 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 875f589..c5bbddc 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 [![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/plotnine/workflows/build/badge.svg?branch=main)](https://github.com/has2k1/plotnine/actions?query=branch%3Amain+workflow%3A%22build%22)
+[![Build Status](https://github.com/has2k1/gnuplot_kernel/workflows/build/badge.svg?branch=main)](https://github.com/has2k1/gnuplot_kernel/actions?query=branch%3Amain+workflow%3A%22build%22)
 [![Coverage](https://coveralls.io/repos/github/has2k1/gnuplot_kernel/badge.svg?branch=main)](https://coveralls.io/github/has2k1/gnuplot_kernel?branch=main)
 
 `gnuplot_kernel` has been developed for use specifically with `Jupyter Notebook`.
diff --git a/tools/release-checklist.py b/tools/release-checklist.py
index 78f9a02..ceee6fc 100644
--- a/tools/release-checklist.py
+++ b/tools/release-checklist.py
@@ -10,7 +10,7 @@
 
 TPL_FILENAME = "release-checklist-tpl.md"
 THIS_DIR = Path(__file__).parent
-NEW_ISSUE = "https://github.com/has2k1/plotnine/issues/new"
+NEW_ISSUE = "https://github.com/has2k1/gnuplot_kernel/issues/new"
 
 VersionPart: TypeAlias = Literal[
     "major",
@@ -53,7 +53,9 @@ def copy_to_clipboard(s: str):
     platform_cmds = {"Darwin": "pbcopy", "Linux": "xclip", "Windows": "clip"}
 
     try:
-        from pandas.io import clipboard
+        from pandas.io import (  # pyright: ignore[reportMissingImports]
+            clipboard,
+        )
     except ImportError:
         try:
             cmd = platform_cmds[plat]
@@ -62,7 +64,7 @@ def copy_to_clipboard(s: str):
             raise RuntimeError(msg) from err
         run(cmd, input=s)
     else:
-        clipboard.copy(s)  # type: ignore
+        clipboard.copy(s)
 
 
 def get_previous_version(s: Optional[str] = None) -> str:
@@ -139,7 +141,7 @@ def verbose(prev_version, next_version):
     """
     from textwrap import dedent
 
-    from term import T0 as T  # type: ignore
+    from term import T0 as T
 
     s = f"""
     Previous Version: {T(prev_version, "lightblue", effect="strikethrough")}

From 961d372b1b5b839080542562d5ad1733ecf70e97 Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Mon, 15 Sep 2025 20:41:05 -0700
Subject: [PATCH 26/26] chore: fix status badge links

---
 README.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index c5bbddc..76b5a78 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
 
 [![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/workflows/build/badge.svg?branch=main)](https://github.com/has2k1/gnuplot_kernel/actions?query=branch%3Amain+workflow%3A%22build%22)
-[![Coverage](https://coveralls.io/repos/github/has2k1/gnuplot_kernel/badge.svg?branch=main)](https://coveralls.io/github/has2k1/gnuplot_kernel?branch=main)
+[![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`