diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7cb17b..cb58c32 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,14 +28,14 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.8", "3.12", "3.13"] + python-version: ["3.12", "3.13", "3.14", "3.14t"] include: - os: windows-latest - python-version: "3.9" + python-version: "3.10" - os: ubuntu-latest python-version: "3.11" - os: ubuntu-latest - python-version: "pypy-3.9" + python-version: "pypy-3.10" - os: macos-latest python-version: "3.10" steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7b22a1a..c6aefa8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-case-conflict - id: check-ast @@ -21,11 +21,11 @@ repos: - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.33.0 + rev: 0.34.0 hooks: - id: check-github-workflows - - repo: https://github.com/executablebooks/mdformat + - repo: https://github.com/hukkin/mdformat rev: 0.7.22 hooks: - id: mdformat @@ -39,13 +39,13 @@ repos: types_or: [yaml, html, json] - repo: https://github.com/adamchainz/blacken-docs - rev: "1.19.1" + rev: "1.20.0" hooks: - id: blacken-docs additional_dependencies: [black==23.7.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.15.0" + rev: "v1.18.2" hooks: - id: mypy files: jupyter_core @@ -67,16 +67,16 @@ repos: - id: rst-inline-touching-normal - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.11 + rev: v0.14.0 hooks: - - id: ruff + - id: ruff-check types_or: [python, jupyter] args: ["--fix", "--show-fixes"] - id: ruff-format types_or: [python, jupyter] - repo: https://github.com/scientific-python/cookie - rev: "2025.05.02" + rev: "2025.10.01" hooks: - id: sp-repo-review additional_dependencies: ["repo-review[cli]"] diff --git a/jupyter_core/application.py b/jupyter_core/application.py index b9c0166..99c77f2 100644 --- a/jupyter_core/application.py +++ b/jupyter_core/application.py @@ -177,7 +177,7 @@ def migrate_config(self) -> None: f_marker.close() return # so we must have already migrated -> bail out - from .migrate import get_ipython_dir, migrate + from .migrate import get_ipython_dir, migrate # noqa: PLC0415 # No IPython dir, nothing to migrate if not Path(get_ipython_dir()).exists(): @@ -264,7 +264,7 @@ def initialize(self, argv: t.Any = None) -> None: def start(self) -> None: """Start the whole thing""" if self.subcommand: - os.execv(self.subcommand, [self.subcommand] + self.argv[1:]) # noqa: S606 + os.execv(self.subcommand, [self.subcommand, *self.argv[1:]]) # noqa: S606 raise NoStart() if self.subapp: diff --git a/jupyter_core/command.py b/jupyter_core/command.py index 9c9317d..b6a3876 100644 --- a/jupyter_core/command.py +++ b/jupyter_core/command.py @@ -43,7 +43,7 @@ def epilog(self, x: Any) -> None: def argcomplete(self) -> None: """Trigger auto-completion, if enabled""" try: - import argcomplete + import argcomplete # noqa: PLC0415 argcomplete.autocomplete(self) except ImportError: @@ -123,10 +123,10 @@ def _execvp(cmd: str, argv: list[str]) -> None: if cmd_path is None: msg = f"{cmd!r} not found" raise OSError(msg, errno.ENOENT) - p = Popen([cmd_path] + argv[1:]) # noqa: S603 + p = Popen([cmd_path, *argv[1:]]) # noqa: S603 # Don't raise KeyboardInterrupt in the parent process. # Set this after spawning, to avoid subprocess inheriting handler. - import signal + import signal # noqa: PLC0415 signal.signal(signal.SIGINT, signal.SIG_IGN) p.wait() @@ -203,7 +203,7 @@ def _evaluate_argcomplete(parser: JupyterParser) -> list[str]: try: # traitlets >= 5.8 provides some argcomplete support, # use helper methods to jump to argcomplete - from traitlets.config.argcomplete_config import ( + from traitlets.config.argcomplete_config import ( # noqa: PLC0415 get_argcomplete_cwords, increment_argcomplete_index, ) @@ -237,7 +237,7 @@ def main() -> None: # Avoids argparse gobbling up args passed to subcommand, such as `-h`. subcommand = argv[1] else: - args, opts = parser.parse_known_args() + args, _opts = parser.parse_known_args() subcommand = args.subcommand if args.version: print("Selected Jupyter core packages...") @@ -399,7 +399,7 @@ def main() -> None: sys.exit(str(e)) try: - _execvp(command, [command] + argv[2:]) + _execvp(command, [command, *argv[2:]]) except OSError as e: sys.exit(f"Error executing Jupyter command {subcommand!r}: {e}") diff --git a/jupyter_core/paths.py b/jupyter_core/paths.py index 34829f1..ba94a1e 100644 --- a/jupyter_core/paths.py +++ b/jupyter_core/paths.py @@ -15,9 +15,10 @@ import sys import tempfile import warnings +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterator, Literal, Optional, overload +from typing import Any, overload import platformdirs @@ -43,10 +44,10 @@ def envset(name: str, default: bool = False) -> bool: ... @overload -def envset(name: str, default: Literal[None]) -> Optional[bool]: ... +def envset(name: str, default: None) -> bool | None: ... -def envset(name: str, default: Optional[bool] = False) -> Optional[bool]: +def envset(name: str, default: bool | None = False) -> bool | None: """Return the boolean value of a given environment variable. An environment variable is considered set if it is assigned to a value @@ -182,12 +183,14 @@ def jupyter_data_dir() -> str: if sys.platform == "darwin": return str(Path(home, "Library", "Jupyter")) + # Bug in mypy which thinks it's unreachable: https://github.com/python/mypy/issues/10773 if sys.platform == "win32": appdata = os.environ.get("APPDATA", None) if appdata: return str(Path(appdata, "jupyter").resolve()) return pjoin(jupyter_config_dir(), "data") # Linux, non-OS X Unix, AIX, etc. + # Bug in mypy which thinks it's unreachable: https://github.com/python/mypy/issues/10773 xdg = env.get("XDG_DATA_HOME", None) if not xdg: xdg = pjoin(home, ".local", "share") @@ -303,7 +306,7 @@ def jupyter_path(*subdirs: str) -> list[str]: if site.ENABLE_USER_SITE: # Check if site.getuserbase() exists to be compatible with virtualenv, # which often does not have this method. - userbase: Optional[str] + userbase: str | None userbase = site.getuserbase() if hasattr(site, "getuserbase") else site.USER_BASE if userbase: @@ -400,7 +403,7 @@ def jupyter_config_path() -> list[str]: # Next is environment or user, depending on the JUPYTER_PREFER_ENV_PATH flag user = [jupyter_config_dir()] if site.ENABLE_USER_SITE: - userbase: Optional[str] + userbase: str | None # Check if site.getuserbase() exists to be compatible with virtualenv, # which often does not have this method. userbase = site.getuserbase() if hasattr(site, "getuserbase") else site.USER_BASE @@ -442,7 +445,7 @@ def exists(path: str) -> bool: return True -def is_file_hidden_win(abs_path: str, stat_res: Optional[Any] = None) -> bool: +def is_file_hidden_win(abs_path: str, stat_res: Any | None = None) -> bool: """Is a file hidden? This only checks the file itself; it should be called in combination with @@ -487,7 +490,7 @@ def is_file_hidden_win(abs_path: str, stat_res: Optional[Any] = None) -> bool: return False -def is_file_hidden_posix(abs_path: str, stat_res: Optional[Any] = None) -> bool: +def is_file_hidden_posix(abs_path: str, stat_res: Any | None = None) -> bool: """Is a file hidden? This only checks the file itself; it should be called in combination with @@ -602,12 +605,12 @@ def win32_restrict_file_to_user(fname: str) -> None: The path to the file to secure """ try: - import win32api + import win32api # noqa: PLC0415 except ImportError: return _win32_restrict_file_to_user_ctypes(fname) - import ntsecuritycon as con - import win32security + import ntsecuritycon as con # noqa: PLC0415 + import win32security # noqa: PLC0415 # everyone, _domain, _type = win32security.LookupAccountName("", "Everyone") admins = win32security.CreateWellKnownSid(win32security.WinBuiltinAdministratorsSid) @@ -646,8 +649,8 @@ def _win32_restrict_file_to_user_ctypes(fname: str) -> None: fname : unicode The path to the file to secure """ - import ctypes - from ctypes import wintypes + import ctypes # noqa: PLC0415 + from ctypes import wintypes # noqa: PLC0415 advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) # type:ignore[attr-defined] secur32 = ctypes.WinDLL("secur32", use_last_error=True) # type:ignore[attr-defined] diff --git a/jupyter_core/troubleshoot.py b/jupyter_core/troubleshoot.py index cd64c3d..db376da 100755 --- a/jupyter_core/troubleshoot.py +++ b/jupyter_core/troubleshoot.py @@ -10,10 +10,10 @@ import platform import subprocess import sys -from typing import Any, Optional, Union +from typing import Any, Union -def subs(cmd: Union[list[str], str]) -> Optional[str]: +def subs(cmd: Union[list[str], str]) -> str | None: """ get data from commands that we need to run outside of python """ diff --git a/jupyter_core/utils/__init__.py b/jupyter_core/utils/__init__.py index 665eac2..8dbe5f0 100644 --- a/jupyter_core/utils/__init__.py +++ b/jupyter_core/utils/__init__.py @@ -9,10 +9,11 @@ import sys import threading import warnings +from collections.abc import Awaitable, Callable from contextvars import ContextVar from pathlib import Path from types import FrameType -from typing import Any, Awaitable, Callable, TypeVar, cast +from typing import Any, TypeVar, cast def ensure_dir_exists(path: str | Path, mode: int = 0o777) -> None: @@ -176,7 +177,17 @@ def ensure_event_loop(prefer_selector_loop: bool = False) -> asyncio.AbstractEve loop = asyncio.get_running_loop() except RuntimeError: if sys.platform == "win32" and prefer_selector_loop: - loop = asyncio.WindowsSelectorEventLoopPolicy().new_event_loop() + if (3, 14) <= sys.version_info < (3, 15): + # ignore deprecation only for 3.14 and revisit later. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + DeprecationWarning, + message=".*WindowsSelectorEventLoopPolicy.*", + ) + loop = asyncio.WindowsSelectorEventLoopPolicy().new_event_loop() + else: + loop = asyncio.WindowsSelectorEventLoopPolicy().new_event_loop() else: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) diff --git a/pyproject.toml b/pyproject.toml index 2ba6fc0..4224847 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3" ] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ "platformdirs>=2.5", "traitlets>=5.3", @@ -97,13 +97,13 @@ dependencies = ["pre-commit"] detached = true [tool.hatch.envs.lint.scripts] build = [ - "pre-commit run --all-files ruff", + "pre-commit run --all-files ruff-check", "pre-commit run --all-files ruff-format" ] [tool.mypy] files = "jupyter_core" -python_version = "3.8" +python_version = "3.14" strict = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] warn_unreachable = true @@ -111,6 +111,11 @@ disallow_incomplete_defs = true disallow_untyped_defs = true warn_redundant_casts = true disallow_untyped_calls = true +# This is a workaround for a mypy bug which is incapable of treating sys.platform +# correctly https://github.com/python/mypy/issues/10773 +# With pre-commit running on user platform and linux on CI you can't get the +# types right, so we pin platform to linux +platform = "linux" [tool.pytest.ini_options] minversion = "7.0" diff --git a/tests/test_application.py b/tests/test_application.py index 5c6e2ba..2d074e9 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -3,6 +3,7 @@ import asyncio import os import shutil +import sys from tempfile import mkdtemp from unittest.mock import patch @@ -179,6 +180,7 @@ class AsyncTornadoApp(AsyncApp): _prefer_selector_loop = True +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") def test_async_tornado_app(): AsyncTornadoApp.launch_instance([]) app = AsyncApp.instance() diff --git a/tests/test_command.py b/tests/test_command.py index 30066ed..87945fb 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -65,7 +65,7 @@ def write_executable(path, source): if sys.platform == "win32": try: - import importlib.resources + import importlib.resources # noqa: PLC0415 if not hasattr(importlib.resources, "files"): raise ImportError @@ -152,7 +152,7 @@ def test_subcommand_not_found(): assert "Jupyter command `jupyter-nonexistant-subcommand` not found." in stderr -@patch.object(sys, "argv", [__file__] + sys.argv[1:]) +@patch.object(sys, "argv", [__file__, *sys.argv[1:]]) def test_subcommand_list(tmpdir): a = tmpdir.mkdir("a") for cmd in ("jupyter-foo-bar", "jupyter-xyz", "jupyter-babel-fish"): diff --git a/tests/test_migrate.py b/tests/test_migrate.py index 2beb2c8..0fe4d75 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -132,8 +132,9 @@ def notice_m_dir(src, dst): called["migrate_dir"] = True return migrate_dir(src, dst) - with patch.object(migrate_mod, "migrate_file", notice_m_file), patch.object( - migrate_mod, "migrate_dir", notice_m_dir + with ( + patch.object(migrate_mod, "migrate_file", notice_m_file), + patch.object(migrate_mod, "migrate_dir", notice_m_dir), ): assert migrate_one(src, dst) assert called == {"migrate_file": True} diff --git a/tests/test_paths.py b/tests/test_paths.py index 9984467..b83b477 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -301,7 +301,7 @@ def test_jupyter_path_user_site(): ] ) ) - for p, v in zip(path, values): + for p, v in zip(path, values, strict=False): assert p == v @@ -358,7 +358,7 @@ def test_jupyter_config_path(): ] ) ) - for p, v in zip(path, values): + for p, v in zip(path, values, strict=False): assert p == v @@ -383,7 +383,7 @@ def test_jupyter_config_path_prefer_env(): ] ) ) - for p, v in zip(path, values): + for p, v in zip(path, values, strict=False): assert p == v @@ -499,7 +499,7 @@ def test_is_hidden_win32_cpython(): reason="only run on windows/pypy < 7.3.6: https://foss.heptapod.net/pypy/pypy/-/issues/3469", ) def test_is_hidden_win32_pypy(): - import ctypes # noqa: F401 + import ctypes # noqa: F401, PLC0415 with tempfile.TemporaryDirectory() as root: subdir1 = os.path.join(root, "subdir")