From 84a3a0f3b719b91b3737e0aeaeebbd593fa731f6 Mon Sep 17 00:00:00 2001 From: James Prior Date: Sat, 17 Oct 2026 09:14:25 +0100 Subject: [PATCH 1/2] Test against 3.15 and remove `argparse.FileType` --- .github/workflows/lint.yaml | 8 +-- .github/workflows/tests-no-regex.yaml | 8 +-- .github/workflows/tests.yaml | 6 +- .github/workflows/typing.yaml | 8 +-- jsonpath/cli.py | 88 ++++++++++++++++----------- pyproject.toml | 3 +- tests/test_cli.py | 9 +-- 7 files changed, 72 insertions(+), 58 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 7c0b776..f8a1466 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -5,11 +5,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 + - uses: actions/checkout@v7 + - name: Set up Python 3.12 + uses: actions/setup-python@v7 with: - python-version: "3.10" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests-no-regex.yaml b/.github/workflows/tests-no-regex.yaml index 8969b45..f482cb0 100644 --- a/.github/workflows/tests-no-regex.yaml +++ b/.github/workflows/tests-no-regex.yaml @@ -5,13 +5,13 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 with: submodules: true - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 75b14ba..babe814 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] exclude: - os: macos-latest python-version: "3.10" @@ -20,11 +20,11 @@ jobs: - os: windows-latest python-version: "3.11" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 with: submodules: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/typing.yaml b/.github/workflows/typing.yaml index 8c59b95..057d955 100644 --- a/.github/workflows/typing.yaml +++ b/.github/workflows/typing.yaml @@ -5,11 +5,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - uses: actions/checkout@v7 + - name: Set up Python 3.12 + uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/jsonpath/cli.py b/jsonpath/cli.py index fd52358..21d3f7f 100644 --- a/jsonpath/cli.py +++ b/jsonpath/cli.py @@ -3,6 +3,8 @@ import argparse import json import sys +from contextlib import nullcontext +from typing import Literal import jsonpath from jsonpath.__about__ import __version__ @@ -27,16 +29,15 @@ def path_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 group.add_argument( "-r", - "--path-file", - type=argparse.FileType(mode="r"), + "--query-file", + default="-", help="Text file containing a JSONPath query.", ) parser.add_argument( "-f", "--file", - type=argparse.FileType(mode="rb"), - default=sys.stdin, + default="-", help=( "File to read the target JSON document from. " "Defaults to reading from the standard input stream." @@ -46,8 +47,7 @@ def path_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 parser.add_argument( "-o", "--output", - type=argparse.FileType(mode="w"), - default=sys.stdout, + default="-", help=( "File to write resulting objects to, as a JSON array. " "Defaults to the standard output stream." @@ -83,15 +83,14 @@ def pointer_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 group.add_argument( "-r", "--pointer-file", - type=argparse.FileType(mode="r"), + default="-", help="Text file containing an RFC 6901 formatted JSON Pointer string.", ) parser.add_argument( "-f", "--file", - type=argparse.FileType(mode="rb"), - default=sys.stdin, + default="-", help=( "File to read the target JSON document from. " "Defaults to reading from the standard input stream." @@ -101,8 +100,7 @@ def pointer_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 parser.add_argument( "-o", "--output", - type=argparse.FileType(mode="w"), - default=sys.stdout, + default="-", help=( "File to write the resulting object to, in JSON format. " "Defaults to the standard output stream." @@ -122,7 +120,7 @@ def patch_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 parser.add_argument( "patch", - type=argparse.FileType(mode="rb"), + default="-", metavar="PATCH", help="File containing an RFC 6902 formatted JSON Patch.", ) @@ -130,8 +128,7 @@ def patch_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 parser.add_argument( "-f", "--file", - type=argparse.FileType(mode="rb"), - default=sys.stdin, + default="-", help=( "File to read the target JSON document from. " "Defaults to reading from the standard input stream." @@ -141,8 +138,7 @@ def patch_sub_command(parser: argparse.ArgumentParser) -> None: # noqa: D103 parser.add_argument( "-o", "--output", - type=argparse.FileType(mode="w"), - default=sys.stdout, + default="-", help=( "File to write the resulting JSON document to. " "Defaults to the standard output stream." @@ -252,7 +248,8 @@ def handle_path_command(args: argparse.Namespace) -> None: # noqa: PLR0912 if args.query is not None: query = args.query else: - query = args.query_file.read().strip() + with open_input(args.query_file, "r") as fd: + query = fd.read().strip() try: path = jsonpath.JSONPathEnvironment( @@ -277,7 +274,8 @@ def handle_path_command(args: argparse.Namespace) -> None: # noqa: PLR0912 sys.exit(1) try: - matches = path.findall(args.file) + with open_input(args.file, "rb") as fd: + matches = path.findall(json.load(fd)) except json.JSONDecodeError as err: if args.debug: raise @@ -291,7 +289,9 @@ def handle_path_command(args: argparse.Namespace) -> None: # noqa: PLR0912 sys.exit(1) indent = INDENT if args.pretty else None - json.dump(matches, args.output, indent=indent) + + with open_output(args.output) as of: + json.dump(matches, of, indent=indent) def handle_pointer_command(args: argparse.Namespace) -> None: @@ -300,15 +300,17 @@ def handle_pointer_command(args: argparse.Namespace) -> None: if args.pointer is not None: pointer = args.pointer else: - pointer = args.pointer_file.read().strip() + with open_input(args.pointer_file, "r") as fd: + pointer = fd.read().strip() try: - match = jsonpath.pointer.resolve( - pointer, - args.file, - unicode_escape=not args.no_unicode_escape, - uri_decode=args.uri_decode, - ) + with open_input(args.file, "rb") as fd: + match = jsonpath.pointer.resolve( + pointer, + json.load(fd), + unicode_escape=not args.no_unicode_escape, + uri_decode=args.uri_decode, + ) except json.JSONDecodeError as err: if args.debug: raise @@ -321,13 +323,16 @@ def handle_pointer_command(args: argparse.Namespace) -> None: sys.exit(1) indent = INDENT if args.pretty else None - json.dump(match, args.output, indent=indent) + + with open_output(args.output) as of: + json.dump(match, of, indent=indent) def handle_patch_command(args: argparse.Namespace) -> None: """Handle the `patch` sub command.""" try: - patch = json.load(args.patch) + with open_input(args.patch, "rb") as fd: + patch = json.load(fd) except json.JSONDecodeError as err: if args.debug: raise @@ -341,12 +346,13 @@ def handle_patch_command(args: argparse.Namespace) -> None: sys.exit(1) try: - patched = jsonpath.patch.apply( - patch, - args.file, - unicode_escape=not args.no_unicode_escape, - uri_decode=args.uri_decode, - ) + with open_input(args.file, "rb") as fd: + patched = jsonpath.patch.apply( + patch, # type: ignore + json.load(fd), + unicode_escape=not args.no_unicode_escape, + uri_decode=args.uri_decode, + ) except json.JSONDecodeError as err: if args.debug: raise @@ -359,7 +365,19 @@ def handle_patch_command(args: argparse.Namespace) -> None: sys.exit(1) indent = INDENT if args.pretty else None - json.dump(patched, args.output, indent=indent) + + with open_output(args.output) as of: + json.dump(patched, of, indent=indent) + + +def open_input(filename: str, mode: Literal["r", "rb"]): + """A context manager for opening input files from command line arguments.""" + return nullcontext(sys.stdin) if filename == "-" else open(filename, mode) + + +def open_output(filename: str): + """A context manager for opening output files from command line arguments.""" + return nullcontext(sys.stdout) if filename == "-" else open(filename, "w") def main() -> None: diff --git a/pyproject.toml b/pyproject.toml index 02182e2..e7dca3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "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", ] @@ -77,7 +78,7 @@ lint = "ruff check ." typing = "mypy" [[tool.hatch.envs.test.matrix]] -python = ["38", "39", "310", "311", "312", "313", "314", "pypy38", "pypy39"] +python = ["38", "39", "310", "311", "312", "313", "314", "315", "pypy38", "pypy39"] [tool.hatch.envs.test.scripts] test = "pytest {args}" diff --git a/tests/test_cli.py b/tests/test_cli.py index c7de2ba..6b71215 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ import argparse import json import pathlib +from typing import Dict import pytest @@ -18,7 +19,7 @@ from jsonpath.exceptions import JSONPointerResolutionError from jsonpath.patch import JSONPatch -SAMPLE_DATA = { +SAMPLE_DATA: Dict[str, object] = { "categories": [ { "name": "footwear", @@ -286,7 +287,6 @@ def test_json_path( ) handle_path_command(args) - args.output.flush() with open(outfile, "r") as fd: assert len(json.load(fd)) == 4 # noqa: PLR2004 @@ -329,7 +329,6 @@ def test_json_path_strict( ) handle_path_command(args) - args.output.flush() with open(outfile, "r") as fd: rv = json.load(fd) @@ -400,7 +399,6 @@ def test_json_pointer( ) handle_pointer_command(args) - args.output.flush() with open(outfile, "r") as fd: assert json.load(fd) == "footwear" @@ -413,7 +411,6 @@ def test_json_pointer_empty_string( args = parser.parse_args(["pointer", "-p", "", "-f", sample_target, "-o", outfile]) handle_pointer_command(args) - args.output.flush() with open(outfile, "r") as fd: assert json.load(fd) == SAMPLE_DATA @@ -435,7 +432,6 @@ def test_read_pointer_from_file( ) handle_pointer_command(args) - args.output.flush() with open(outfile, "r") as fd: assert json.load(fd) == SAMPLE_DATA["price_cap"] @@ -589,7 +585,6 @@ def test_json_patch( ) handle_patch_command(args) - args.output.flush() with open(outfile, "r") as fd: patched = json.load(fd) From 7ef4f406c6fe1999c1bfabce58d776212d94782c Mon Sep 17 00:00:00 2001 From: James Prior Date: Sat, 17 Oct 2026 09:30:27 +0100 Subject: [PATCH 2/2] Add type annotations to context managers --- jsonpath/cli.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/jsonpath/cli.py b/jsonpath/cli.py index 21d3f7f..984543a 100644 --- a/jsonpath/cli.py +++ b/jsonpath/cli.py @@ -3,8 +3,14 @@ import argparse import json import sys +from contextlib import AbstractContextManager from contextlib import nullcontext +from typing import IO +from typing import Any +from typing import BinaryIO from typing import Literal +from typing import TextIO +from typing import overload import jsonpath from jsonpath.__about__ import __version__ @@ -370,12 +376,27 @@ def handle_patch_command(args: argparse.Namespace) -> None: json.dump(patched, of, indent=indent) -def open_input(filename: str, mode: Literal["r", "rb"]): +@overload +def open_input(filename: str, mode: Literal["r"]) -> AbstractContextManager[TextIO]: ... + + +@overload +def open_input( + filename: str, mode: Literal["rb"] +) -> AbstractContextManager[BinaryIO]: ... + + +def open_input( + filename: str, mode: Literal["r", "rb"] +) -> AbstractContextManager[IO[Any]]: """A context manager for opening input files from command line arguments.""" - return nullcontext(sys.stdin) if filename == "-" else open(filename, mode) + if filename == "-": + stream = sys.stdin if mode == "r" else sys.stdin.buffer + return nullcontext(stream) + return open(filename, mode) -def open_output(filename: str): +def open_output(filename: str) -> AbstractContextManager[TextIO]: """A context manager for opening output files from command line arguments.""" return nullcontext(sys.stdout) if filename == "-" else open(filename, "w")