From 01678a051b9fdb3a55492cb5aa460babbd98cf50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:00:04 +0000 Subject: [PATCH 01/12] Clean up broken, outdated and redundant bits across the project Library: - Fix the JsonPointer.path doctest, which assigned instead of asserting - Drop an unreachable assert in walk() (get_part already rejects non-indexable documents) and a redundant "$" in a fullmatch regex - Fix docstring typos CLI: - Parse the pointer once instead of re-parsing it for every file - Replace a dated comment with an explanation Packaging: - Add pyproject.toml declaring the setuptools build backend, so pip/build use an isolated, modern setuptools - Use an SPDX license expression (BSD-3-Clause) instead of the deprecated license classifier, silencing setuptools warnings - Read README.md as UTF-8 relative to setup.py; close the source file - Drop wheel/setuptools from requirements-dev.txt (unused by tests) - makefile: recommend "pip install ." instead of "setup.py install" Docs: - Update the CLI usage text to the current argparse help format - Fix the mod-jsonpointer label, grammar, and "method" vs "function" - Use current URLs for RFC 6901, PyPI and Read the Docs - Make conf.py's sys.path independent of the working directory and replace the texinfo placeholder description CI: - Pin the TestPyPI publish action to the same SHA as the PyPI one - Remove a redundant checkout ref and allow-prereleases for 3.13 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yaml | 5 +---- .readthedocs.yaml | 2 +- README.md | 13 +++++++------ bin/jsonpointer | 13 +++++++------ doc/commandline.rst | 6 +++--- doc/conf.py | 4 ++-- doc/index.rst | 4 ++-- doc/mod-jsonpointer.rst | 4 ++-- doc/tutorial.rst | 8 ++++---- jsonpointer.py | 13 ++++++------- makefile | 2 +- pyproject.toml | 3 +++ requirements-dev.txt | 2 -- setup.py | 10 ++++------ 15 files changed, 44 insertions(+), 47 deletions(-) create mode 100644 pyproject.toml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5493fbb..73c0d65 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -99,6 +99,6 @@ jobs: path: dist/ - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6fedd8f..9ebb9c0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,14 +16,11 @@ jobs: contents: read steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.13 - allow-prereleases: true + python-version: "3.13" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a11f336..bfe1aed 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -11,7 +11,7 @@ build: tools: python: "3.13" -# Build documentation in the docs/ directory with Sphinx +# Build documentation in the doc/ directory with Sphinx sphinx: configuration: doc/conf.py diff --git a/README.md b/README.md index 016ac01..601f508 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ python-json-pointer =================== -[![PyPI version](https://img.shields.io/pypi/v/jsonpointer.svg)](https://pypi.python.org/pypi/jsonpointer/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/jsonpointer.svg)](https://pypi.python.org/pypi/jsonpointer/) +[![PyPI version](https://img.shields.io/pypi/v/jsonpointer.svg)](https://pypi.org/project/jsonpointer/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/jsonpointer.svg)](https://pypi.org/project/jsonpointer/) [![Tests](https://github.com/stefankoegl/python-json-pointer/actions/workflows/test.yaml/badge.svg?branch=master)](https://github.com/stefankoegl/python-json-pointer/actions/workflows/test.yaml) [![Coverage Status](https://coveralls.io/repos/github/stefankoegl/python-json-pointer/badge.svg?branch=master)](https://coveralls.io/github/stefankoegl/python-json-pointer?branch=master) @@ -11,12 +11,13 @@ Resolve JSON Pointers in Python ------------------------------- Library to resolve JSON Pointers according to -[RFC 6901](http://tools.ietf.org/html/rfc6901) +[RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) + +See the documentation for examples. -See source code for examples * Website: https://github.com/stefankoegl/python-json-pointer * Repository: https://github.com/stefankoegl/python-json-pointer.git -* Documentation: https://python-json-pointer.readthedocs.org/ -* PyPI: https://pypi.python.org/pypi/jsonpointer +* Documentation: https://python-json-pointer.readthedocs.io/ +* PyPI: https://pypi.org/project/jsonpointer/ * GitHub Actions: https://github.com/stefankoegl/python-json-pointer/actions * Coveralls: https://coveralls.io/github/stefankoegl/python-json-pointer diff --git a/bin/jsonpointer b/bin/jsonpointer index d4c8cb7..a1954e5 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -44,7 +44,7 @@ def main(): def parse_pointer(args): - """ Return the pointer and the list of files to resolve it on + """ Return the parsed pointer and the list of files to resolve it on If the pointer is given via -f or -p, a POINTER positional argument is actually the first file. """ @@ -57,7 +57,8 @@ def parse_pointer(args): if args.pointer is not None: ptr = args.pointer else: - # 2026-10-11: Trim file line endings without changing member names. + # Strip only the trailing line ending; other whitespace may be + # part of a member name. try: ptr = args.pointer_file.read().rstrip("\r\n") except UnicodeDecodeError as e: @@ -75,11 +76,11 @@ def parse_pointer(args): # Catches e.g. `jsonpointer a.json b.json`, where the pointer was omitted # and the first file would otherwise be used as the pointer try: - jsonpointer.JsonPointer(ptr) + pointer = jsonpointer.JsonPointer(ptr) except jsonpointer.JsonPointerException as e: parser.error('invalid JSON pointer %r: %s' % (ptr, e)) - return ptr, files + return pointer, files def resolve_files(): @@ -89,7 +90,7 @@ def resolve_files(): or if --continue-on-error skipped a JSON or text decoding error. """ args = parser.parse_args() - ptr, files = parse_pointer(args) + pointer, files = parse_pointer(args) ok = True for name in files: @@ -112,7 +113,7 @@ def resolve_files(): f.close() try: - result = jsonpointer.resolve_pointer(doc, ptr) + result = pointer.resolve(doc) if isinstance(result, jsonpointer.EndOfList): raise jsonpointer.JsonPointerException( "'-' does not reference an existing array element") diff --git a/doc/commandline.rst b/doc/commandline.rst index cbb2a24..5c5da83 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -2,7 +2,7 @@ The ``jsonpointer`` commandline utility ======================================= The JSON pointer package also installs a ``jsonpointer`` commandline utility -that can be used to resolve a JSON pointers on JSON files. +that can be used to resolve a JSON pointer on JSON files. The program has the following usage :: @@ -19,9 +19,9 @@ The program has the following usage :: options: -h, --help show this help message and exit - -f POINTER_FILE, --pointer-file POINTER_FILE + -f, --pointer-file POINTER_FILE File containing a JSON pointer expression - -p POINTER, --pointer POINTER + -p, --pointer POINTER A JSON pointer expression --indent INDENT Indent output by n spaces --continue-on-error Continue with remaining files after JSON or text diff --git a/doc/conf.py b/doc/conf.py index f472f57..21c694e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -16,7 +16,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.join(os.path.abspath('.'), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) import jsonpointer @@ -231,7 +231,7 @@ # dir menu entry, description, category) texinfo_documents = [ ('index', 'python-json-pointer', 'python-json-pointer Documentation', - 'Stefan Kögl', 'python-json-pointer', 'One line description of project.', + 'Stefan Kögl', 'python-json-pointer', jsonpointer.__doc__.strip(), 'Miscellaneous'), ] diff --git a/doc/index.rst b/doc/index.rst index e1e0aae..9a87e25 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -7,7 +7,7 @@ python-json-pointer =================== *python-json-pointer* is a Python library for resolving JSON pointers (`RFC -6901 `_). Python 3.10+ +6901 `_). Python 3.10+ and PyPy are supported. **Contents** @@ -18,7 +18,7 @@ and PyPy are supported. tutorial mod-jsonpointer commandline - RFC 6901 + RFC 6901 Indices and tables diff --git a/doc/mod-jsonpointer.rst b/doc/mod-jsonpointer.rst index 7929d6d..ac48ec7 100644 --- a/doc/mod-jsonpointer.rst +++ b/doc/mod-jsonpointer.rst @@ -1,7 +1,7 @@ -.. mod-jsonpointer: +.. _mod-jsonpointer: The ``jsonpointer`` module -============================ +========================== .. automodule:: jsonpointer :members: diff --git a/doc/tutorial.rst b/doc/tutorial.rst index d1926e7..384f779 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -1,9 +1,9 @@ Tutorial ======== -Please refer to `RFC 6901 `_ for the exact -pointer syntax. ``jsonpointer`` has two interfaces. The ``resolve_pointer`` -method is basically a deep ``get``. +Please refer to `RFC 6901 `_ for the exact +pointer syntax. The ``resolve_pointer`` +function is basically a deep ``get``. .. code-block:: python @@ -29,7 +29,7 @@ method is basically a deep ``get``. True -The ``set_pointer`` method allows modifying a portion of an object using +The ``set_pointer`` function allows modifying a portion of an object using JSON pointer notation: .. code-block:: python diff --git a/jsonpointer.py b/jsonpointer.py index 3466488..f54f637 100644 --- a/jsonpointer.py +++ b/jsonpointer.py @@ -163,7 +163,7 @@ class JsonPointer: # Array indices must not contain: # leading zeros, signs, spaces, decimals, etc - _RE_ARRAY_INDEX = re.compile('0|[1-9][0-9]*$') + _RE_ARRAY_INDEX = re.compile('0|[1-9][0-9]*') _RE_INVALID_ESCAPE = re.compile('(~[^01]|~$)') def __init__(self, pointer): @@ -327,8 +327,6 @@ def walk(self, doc, part): part = JsonPointer.get_part(doc, part) - assert hasattr(doc, '__getitem__'), "invalid document type %s" % (type(doc),) - if isinstance(doc, Sequence): if part == '-': return EndOfList(doc) @@ -387,7 +385,7 @@ def __contains__(self, item): return self.contains(item) def join(self, suffix): - """ Returns a new JsonPointer with the given suffix append to this ptr """ + """ Returns a new JsonPointer with the given suffix appended to this ptr """ if isinstance(suffix, JsonPointer): suffix_parts = suffix.parts elif isinstance(suffix, str): @@ -406,7 +404,8 @@ def __truediv__(self, suffix): def path(self): """Returns the string representation of the pointer - >>> ptr = JsonPointer('/~0/0/~1').path == '/~0/0/~1' + >>> JsonPointer('/~0/0/~1').path == '/~0/0/~1' + True """ parts = [escape(part) for part in self.parts] return ''.join('/' + part for part in parts) @@ -414,7 +413,7 @@ def path(self): def __eq__(self, other): """Compares a pointer to another object - Pointers can be compared by comparing their strings (or splitted + Pointers can be compared by comparing their strings (or split strings), because no two different parts can point to the same structure in an object (eg no different number representations) """ @@ -435,7 +434,7 @@ def __repr__(self): @classmethod def from_parts(cls, parts): - """Constructs a JsonPointer from a list of (unescaped) paths + """Constructs a JsonPointer from a list of (unescaped) parts >>> JsonPointer.from_parts(['a', '~', '/', 0]).path == '/a/~0/~1/0' True diff --git a/makefile b/makefile index 3276190..29f45e0 100644 --- a/makefile +++ b/makefile @@ -6,7 +6,7 @@ help: @echo " - coverage: run tests with coverage" @echo @echo "To install jsonpointer, type" - @echo " python3 setup.py install" + @echo " pip install ." @echo test: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..232cef1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=77.0"] +build-backend = "setuptools.build_meta" diff --git a/requirements-dev.txt b/requirements-dev.txt index 239fcca..9c4e869 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,2 @@ -wheel -setuptools coverage flake8 diff --git a/setup.py b/setup.py index 785d397..a7673de 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -import io import os.path import re @@ -8,7 +7,8 @@ dirname = os.path.dirname(os.path.abspath(__file__)) filename = os.path.join(dirname, 'jsonpointer.py') -src = io.open(filename, encoding='utf-8').read() +with open(filename, encoding='utf-8') as f: + src = f.read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", src)) docstrings = re.findall('"""(.*)"""', src) @@ -21,20 +21,18 @@ AUTHOR_EMAIL = metadata['author'] VERSION = metadata['version'] WEBSITE = metadata['website'] -LICENSE = metadata['license'] DESCRIPTION = docstrings[0] # Extract name and e-mail ("Firstname Lastname ") AUTHOR, EMAIL = re.match(r'(.*) <(.*)>', AUTHOR_EMAIL).groups() -with open('README.md') as readme: +with open(os.path.join(dirname, 'README.md'), encoding='utf-8') as readme: long_description = readme.read() CLASSIFIERS = [ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', @@ -58,7 +56,7 @@ long_description_content_type="text/markdown", author=AUTHOR, author_email=EMAIL, - license=LICENSE, + license_expression='BSD-3-Clause', url=WEBSITE, py_modules=MODULES, # PEP 561 stub-only package, shipped in the same distribution From e18bde6ca778b580a95d1fd1a7b6a761d8990730 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:03:04 +0000 Subject: [PATCH 02/12] Use SPDX identifier for __license__ The package metadata already declares BSD-3-Clause; make the module attribute agree instead of the informal "Modified BSD License". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- jsonpointer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpointer.py b/jsonpointer.py index f54f637..b7b8ea5 100644 --- a/jsonpointer.py +++ b/jsonpointer.py @@ -34,7 +34,7 @@ __author__ = 'Stefan Kögl ' __version__ = '3.11' __website__ = 'https://github.com/stefankoegl/python-json-pointer' -__license__ = 'Modified BSD License' +__license__ = 'BSD-3-Clause' import copy import re From 7cab82918e041346e8cdc8b06162bdb5033fda19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:03:27 +0000 Subject: [PATCH 03/12] Deprecate pairwise() in favour of itertools.pairwise() pairwise() is not used by the library itself, and itertools.pairwise() is available on every supported Python version (3.10+). Keep it as a thin wrapper that emits a DeprecationWarning, and mark it @deprecated in the type stubs so type checkers flag callers too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- jsonpointer-stubs/__init__.pyi | 3 ++- jsonpointer.py | 22 +++++++++------------- tests.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/jsonpointer-stubs/__init__.pyi b/jsonpointer-stubs/__init__.pyi index a011a88..b483df3 100644 --- a/jsonpointer-stubs/__init__.pyi +++ b/jsonpointer-stubs/__init__.pyi @@ -2,7 +2,7 @@ from collections.abc import Iterable, Iterator from re import Pattern from typing import Any, Final, TypeVar -from typing_extensions import Self +from typing_extensions import Self, deprecated _T = TypeVar("_T") @@ -21,6 +21,7 @@ def set_pointer( create: bool = False, ) -> Any: ... def resolve_pointer(doc: Any, pointer: str, default: Any = ...) -> Any: ... +@deprecated("Use itertools.pairwise() instead") def pairwise(iterable: Iterable[_T]) -> Iterator[tuple[_T, _T]]: ... class JsonPointerException(Exception): ... diff --git a/jsonpointer.py b/jsonpointer.py index f54f637..18488f1 100644 --- a/jsonpointer.py +++ b/jsonpointer.py @@ -37,9 +37,11 @@ __license__ = 'Modified BSD License' import copy +import itertools import re +import warnings from collections.abc import Mapping, Sequence -from itertools import tee, chain +from itertools import chain _nothing = object() @@ -128,19 +130,13 @@ def pairwise(iterable): s -> (s0,s1), (s1,s2), (s2, s3), ... - >>> list(pairwise([])) - [] - - >>> list(pairwise([1])) - [] - - >>> list(pairwise([1, 2, 3, 4])) - [(1, 2), (2, 3), (3, 4)] + .. deprecated:: 3.12 + Use :func:`itertools.pairwise` instead. """ - a, b = tee(iterable) - for _ in b: - break - return zip(a, b) + warnings.warn('jsonpointer.pairwise() is deprecated, ' + 'use itertools.pairwise() instead', + DeprecationWarning, stacklevel=2) + return itertools.pairwise(iterable) class JsonPointerException(Exception): diff --git a/tests.py b/tests.py index c1542d7..89f18e0 100755 --- a/tests.py +++ b/tests.py @@ -307,6 +307,17 @@ def test_string_not_indexable(self): @unittest.skipUnless(hasattr(sys, 'get_int_max_str_digits'), 'Integer string conversion limits are not available') +class PairwiseTests(unittest.TestCase): + + def test_pairwise_is_deprecated(self): + for items, expected in (([], []), ([1], []), + ([1, 2, 3, 4], [(1, 2), (2, 3), (3, 4)])): + with self.subTest(items=items): + with self.assertWarns(DeprecationWarning): + result = jsonpointer.pairwise(items) + self.assertEqual(list(result), expected) + + class LargeIndexTests(unittest.TestCase): def setUp(self): From 4358bbb28d647c9846e185cd6d1ea46bc1798c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:03:40 +0000 Subject: [PATCH 04/12] Keep the original error when join() rejects a suffix join() turns any failure into JsonPointerException("Invalid suffix"). Raise it "from" the original exception so the underlying cause (e.g. a TypeError for a non-iterable suffix) shows up in tracebacks and is available as __cause__. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- jsonpointer.py | 4 ++-- tests.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/jsonpointer.py b/jsonpointer.py index f54f637..00b9ba3 100644 --- a/jsonpointer.py +++ b/jsonpointer.py @@ -394,8 +394,8 @@ def join(self, suffix): suffix_parts = suffix try: return self.__class__.from_parts(chain(self.parts, suffix_parts)) - except Exception: - raise JsonPointerException("Invalid suffix") + except Exception as e: + raise JsonPointerException("Invalid suffix") from e def __truediv__(self, suffix): return self.join(suffix) diff --git a/tests.py b/tests.py index c1542d7..49d8c4e 100755 --- a/tests.py +++ b/tests.py @@ -216,6 +216,11 @@ def test_join(self): self.assertRaises(JsonPointerException, self.ptr1.join, 0) + def test_join_chains_original_error(self): + with self.assertRaises(JsonPointerException) as caught: + self.ptr1.join(0) + self.assertIsInstance(caught.exception.__cause__, TypeError) + def test_join_magic(self): ptr12a = self.ptr1 / self.ptr2 self.assertEqual(ptr12a.path, "/a/b/c/a/b") From 4f85568a4ec4471f9bcc08d5cb71af806d9a7880 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:03:53 +0000 Subject: [PATCH 05/12] Pin Sphinx for reproducible documentation builds Read the Docs recommends pinning documentation dependencies so a new Sphinx release cannot silently change or break the build. Pin to the current release (9.2.0), which builds the docs without warnings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- doc/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/requirements.txt b/doc/requirements.txt index 6966869..7dc9833 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1 +1 @@ -sphinx +sphinx==9.2.0 From 9217f51bd664f8388db022fa7865a5bd0a8d9e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:04:16 +0000 Subject: [PATCH 06/12] Trim the Sphinx configuration to the settings in use doc/conf.py was the 2013 sphinx-quickstart template, mostly commented-out defaults. Keep only the settings that differ from Sphinx's defaults, and: - use the "classic" theme name instead of its legacy alias "default" (the generated HTML is identical apart from the alias stylesheet) - drop templates_path and html_static_path, which pointed at an empty or missing directory, along with doc/_static/.empty - define the author once and reuse it for the LaTeX, man and Texinfo outputs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- doc/_static/.empty | 0 doc/conf.py | 232 ++++----------------------------------------- 2 files changed, 17 insertions(+), 215 deletions(-) delete mode 100644 doc/_static/.empty diff --git a/doc/_static/.empty b/doc/_static/.empty deleted file mode 100644 index e69de29..0000000 diff --git a/doc/conf.py b/doc/conf.py index 21c694e..524b902 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,245 +1,47 @@ -# -# python-json-pointer documentation build configuration file, created by -# sphinx-quickstart on Sat Apr 13 16:52:59 2013. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# Sphinx configuration for the python-json-pointer documentation. +# See https://www.sphinx-doc.org/en/master/usage/configuration.html import os import sys -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# Document the jsonpointer module from this checkout sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) -import jsonpointer +import jsonpointer # noqa: E402 -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' +# -- Project information ------------------------------------------------------- -# General information about the project. project = 'python-json-pointer' +author = 'Stefan Kögl' copyright = jsonpointer.__author__ - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# - -# The short X.Y version. version = jsonpointer.__version__ -# The full version, including alpha/beta/rc tags. release = jsonpointer.__version__ -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# -- General configuration ----------------------------------------------------- -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. +extensions = ['sphinx.ext.autodoc'] exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - - # -- Options for HTML output --------------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'default' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. +html_theme = 'classic' htmlhelp_basename = 'python-json-pointerdoc' +# -- Options for other output formats ------------------------------------------ -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'python-json-pointer.tex', 'python-json-pointer Documentation', - 'Stefan Kögl', 'manual'), + ('index', 'python-json-pointer.tex', 'python-json-pointer Documentation', + author, 'manual'), ] -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'python-json-pointer', 'python-json-pointer Documentation', - ['Stefan Kögl'], 1) + ('index', 'python-json-pointer', 'python-json-pointer Documentation', + [author], 1), ] -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) texinfo_documents = [ - ('index', 'python-json-pointer', 'python-json-pointer Documentation', - 'Stefan Kögl', 'python-json-pointer', jsonpointer.__doc__.strip(), - 'Miscellaneous'), + ('index', 'python-json-pointer', 'python-json-pointer Documentation', + author, 'python-json-pointer', jsonpointer.__doc__.strip(), + 'Miscellaneous'), ] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' From 47ad3bc4526712817609621c7d60613ff2579507 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:04:35 +0000 Subject: [PATCH 07/12] Ship everything needed to test and build docs from the sdist The source distribution included the .rst sources and tests.py but not doc/conf.py, doc/Makefile or the requirements files, so the docs could not be built from it. Add those plus the makefile and .coveragerc, and drop the explicit README.md include, which setuptools adds automatically. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- MANIFEST.in | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 9384294..20127ef 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,11 @@ include AUTHORS include LICENSE.txt -include README.md +include makefile +include requirements-dev.txt +include .coveragerc include tests.py include doc/*.rst +include doc/conf.py +include doc/Makefile +include doc/requirements.txt recursive-include jsonpointer-stubs *.pyi From c4cded989269bb068a16391b5b217e6a91427a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:04:53 +0000 Subject: [PATCH 08/12] Update the tested Python versions - Test on Python 3.15 (released October 2026) and add its classifier; run the Windows and macOS jobs on 3.15 instead of 3.13 - Test the Python 3.16 pre-release as an allowed-to-fail job - Stop testing PyPy 3.10, which PyPy no longer releases; PyPy 3.11 stays Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- .github/workflows/test.yaml | 11 ++++++++--- setup.py | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9ebb9c0..504c2eb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -94,16 +94,21 @@ jobs: test: needs: [ lint ] runs-on: ${{ matrix.os }} + # Pre-release Pythons may fail for reasons unrelated to this package + continue-on-error: ${{ matrix.experimental || false }} strategy: fail-fast: false matrix: os: [ ubuntu-latest ] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.10", "pypy3.11" ] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "pypy3.11" ] include: - os: windows-latest - python-version: "3.13" + python-version: "3.15" - os: macos-latest - python-version: "3.13" + python-version: "3.15" + - os: ubuntu-latest + python-version: "3.16" + experimental: true steps: - uses: actions/checkout@v4 diff --git a/setup.py b/setup.py index a7673de..a80a407 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', From a6dff9e296bd6c1205c8df46a01d44ff59257a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:05:14 +0000 Subject: [PATCH 09/12] Update GitHub Actions to current major versions The v4/v5 releases of the first-party actions run on the deprecated Node 20 runtime. Update to the current majors, which run on Node 24: - actions/checkout v4 -> v7 - actions/setup-python v5 -> v7 - actions/upload-artifact v4 -> v7 - actions/download-artifact v4 -> v8 None of the inputs used here changed. Also move the pinned pypa/gh-action-pypi-publish commit from v1.14.2 to v1.14.3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- .github/workflows/publish.yml | 14 +++++++------- .github/workflows/test.yaml | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 73c0d65..4ab3532 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,10 +40,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" @@ -58,7 +58,7 @@ jobs: - name: Check distributions run: twine check --strict dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/ @@ -74,13 +74,13 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 publish-testpypi: name: Publish to TestPyPI @@ -93,12 +93,12 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9ebb9c0..2152519 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,10 +15,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -36,10 +36,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install build tools @@ -57,10 +57,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -78,10 +78,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -106,9 +106,9 @@ jobs: python-version: "3.13" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From ba249266dad94aed6d5611656f25ba1a4d70df63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:05:43 +0000 Subject: [PATCH 10/12] Exit with status 130 when the CLI is interrupted Ctrl-C made jsonpointer exit with 1, the same status as "pointer could not be resolved", so callers could not tell the two apart. Use 130 (128 + SIGINT), the conventional status for an interrupted command, and document it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- bin/jsonpointer | 3 ++- doc/commandline.rst | 4 ++-- tests.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index a1954e5..15b9f5b 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -37,7 +37,8 @@ def main(): try: ok = resolve_files() except KeyboardInterrupt: - sys.exit(1) + # Conventional status for termination by SIGINT (128 + 2) + sys.exit(130) if not ok: sys.exit(1) diff --git a/doc/commandline.rst b/doc/commandline.rst index 5c5da83..e170d02 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -35,8 +35,8 @@ JSON input files and pointer files are read as UTF-8. The exit status is 0 if the pointer could be resolved on all files, 1 if it could not be resolved on at least one file (the remaining files are still -processed) and 2 on usage errors, such as an invalid pointer or a missing -file. +processed), 2 on usage errors, such as an invalid pointer or a missing +file, and 130 if interrupted with Ctrl-C. By default, invalid JSON or a text decoding error stops processing. With ``--continue-on-error``, these errors are reported on standard error with diff --git a/tests.py b/tests.py index c1542d7..dcfd4c5 100755 --- a/tests.py +++ b/tests.py @@ -755,6 +755,17 @@ def test_keeps_stdin_open(self): self.assertTrue(cli['resolve_files']()) self.assertFalse(stdin.closed) + def test_keyboard_interrupt_exit_status(self): + cli = runpy.run_path(self.SCRIPT) + + def interrupted(): + raise KeyboardInterrupt + + cli['resolve_files'] = interrupted + with self.assertRaises(SystemExit) as caught: + cli['main']() + self.assertEqual(caught.exception.code, 130) + def test_utf8_json_file_without_utf8_mode(self): self._write('unicode.json', '{"value": "🚀"}') proc = self._run('/value', 'unicode.json', utf8=False) From 0c350271c7b07c2d8014e5b1beb4c26aa1633285 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:06:28 +0000 Subject: [PATCH 11/12] Fix the Ctrl-C exit status test runpy.run_path() returns a copy of the script's globals, so replacing resolve_files in it had no effect on main(). Raise KeyboardInterrupt from json.load() instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- tests.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests.py b/tests.py index dcfd4c5..6c12462 100755 --- a/tests.py +++ b/tests.py @@ -757,13 +757,11 @@ def test_keeps_stdin_open(self): def test_keyboard_interrupt_exit_status(self): cli = runpy.run_path(self.SCRIPT) - - def interrupted(): - raise KeyboardInterrupt - - cli['resolve_files'] = interrupted - with self.assertRaises(SystemExit) as caught: - cli['main']() + args = [self.SCRIPT, '/a', os.path.join(self.dir, 'a.json')] + with mock.patch.object(sys, 'argv', args), \ + mock.patch('json.load', side_effect=KeyboardInterrupt): + with self.assertRaises(SystemExit) as caught: + cli['main']() self.assertEqual(caught.exception.code, 130) def test_utf8_json_file_without_utf8_mode(self): From 725fd751aa4d150fe1e5b4b6ebb6311864b7c693 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 23:07:03 +0000 Subject: [PATCH 12/12] Stop using the deprecated argparse.FileType in the CLI argparse.FileType is deprecated since Python 3.14. Open the pointer file and the JSON inputs with a small helper instead, which keeps the existing behaviour: - "-" still means standard input, which is never closed - files are read as UTF-8 - a file that cannot be opened is a usage error (exit status 2) with the same "can't open" message The pointer file is now opened after argument parsing rather than during it, so the help output is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PewzpjhRBV84QNbKy9H564 --- bin/jsonpointer | 27 +++++++++++++++++++-------- tests.py | 6 ++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/bin/jsonpointer b/bin/jsonpointer index 15b9f5b..9e0efb2 100755 --- a/bin/jsonpointer +++ b/bin/jsonpointer @@ -13,7 +13,7 @@ parser = argparse.ArgumentParser( # Accept pointer as option, as file or as first positional argument ptr_group = parser.add_mutually_exclusive_group() -ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r', encoding='utf-8'), +ptr_group.add_argument('-f', '--pointer-file', type=str, help='File containing a JSON pointer expression') ptr_group.add_argument('-p', '--pointer', type=str, @@ -44,6 +44,19 @@ def main(): sys.exit(1) +def open_input(name): + """ Open a UTF-8 input file, or return stdin for "-" + + Exits with a usage error if the file cannot be opened. """ + if name == '-': + return sys.stdin + + try: + return open(name, encoding='utf-8') + except OSError as e: + parser.error("can't open '%s': %s" % (name, e)) + + def parse_pointer(args): """ Return the parsed pointer and the list of files to resolve it on @@ -60,13 +73,14 @@ def parse_pointer(args): else: # Strip only the trailing line ending; other whitespace may be # part of a member name. + pointer_file = open_input(args.pointer_file) try: - ptr = args.pointer_file.read().rstrip("\r\n") + ptr = pointer_file.read().rstrip("\r\n") except UnicodeDecodeError as e: parser.error('could not decode pointer file: %s' % e) finally: - if args.pointer_file is not sys.stdin: - args.pointer_file.close() + if pointer_file is not sys.stdin: + pointer_file.close() elif args.POINTER is not None: ptr = args.POINTER @@ -95,10 +109,7 @@ def resolve_files(): ok = True for name in files: - try: - f = argparse.FileType('r', encoding='utf-8')(name) - except argparse.ArgumentTypeError as e: - parser.error(str(e)) + f = open_input(name) try: doc = json.load(f) diff --git a/tests.py b/tests.py index 6c12462..412cd21 100755 --- a/tests.py +++ b/tests.py @@ -980,6 +980,12 @@ def test_continue_on_error_does_not_skip_missing_files(self): self.assertEqual(proc.stdout, self.A_OUT) self.assertIn('missing.json', proc.stderr) + def test_missing_pointer_file(self): + proc = self._run('-f', 'missing.txt', 'a.json') + self.assertEqual(proc.returncode, 2) + self.assertEqual(proc.stdout, '') + self.assertIn("can't open 'missing.txt'", proc.stderr) + def test_pointer_file_requires_value(self): proc = self._run('-f', '-p', '/a', 'a.json') self.assertEqual(proc.returncode, 2)