diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 7d10863..3f4dcf8 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -3,6 +3,7 @@ on: push: branches: ["main"] pull_request: + workflow_dispatch: schedule: - cron: "0 8 * * *" @@ -14,16 +15,16 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.7", "3.11"] + python-version: ["3.8", "3.12"] include: - os: windows-latest python-version: "3.9" - os: ubuntu-latest - python-version: "pypy-3.8" + python-version: "pypy-3.9" - os: ubuntu-latest python-version: "3.10" - os: macos-latest - python-version: "3.8" + python-version: "3.11" steps: - name: Checkout uses: actions/checkout@v3 @@ -38,10 +39,10 @@ jobs: steps: - uses: actions/checkout@v3 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - - name: Run Linters - run: | + - name: Run Linters + run: | hatch run typing:test - hatch run lint:style + hatch run lint:build pipx run 'validate-pyproject[all]' pyproject.toml check_release: @@ -66,3 +67,17 @@ jobs: - uses: actions/checkout@v3 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 + + check: # This job does nothing and is only used for the branch protection + if: always() + needs: + - check_links + - check_release + - test_lint + - build + runs-on: ubuntu-latest + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..04aea43 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,79 @@ +ci: + autoupdate_schedule: monthly + autoupdate_commit_msg: "chore: update pre-commit hooks" + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-case-conflict + - id: check-ast + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.27.0 + hooks: + - id: check-github-workflows + + - repo: https://github.com/executablebooks/mdformat + rev: 0.7.17 + hooks: + - id: mdformat + additional_dependencies: + [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v3.0.3" + hooks: + - id: prettier + types_or: [yaml, html, json] + + - repo: https://github.com/adamchainz/blacken-docs + rev: "1.16.0" + hooks: + - id: blacken-docs + additional_dependencies: [black==23.7.0] + + - repo: https://github.com/codespell-project/codespell + rev: "v2.2.6" + hooks: + - id: codespell + args: ["-L", "sur,nd"] + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: "v1.10.0" + hooks: + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.6.1" + hooks: + - id: mypy + files: comm + stages: [manual] + additional_dependencies: ["traitlets>=5.13"] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.3 + hooks: + - id: ruff + args: ["--fix", "--show-fixes"] + - id: ruff-format + + - repo: https://github.com/scientific-python/cookie + rev: "2023.10.27" + hooks: + - id: sp-repo-review + additional_dependencies: ["repo-review[cli]"] diff --git a/README.md b/README.md index 0ecf6df..f345f60 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ import comm class MyCustomComm(comm.base_comm.BaseComm): - def publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys): # TODO implement the logic for sending comm messages through the iopub channel pass diff --git a/comm/__init__.py b/comm/__init__.py index 341a42e..d85a8c8 100644 --- a/comm/__init__.py +++ b/comm/__init__.py @@ -7,8 +7,11 @@ the Jupyter kernel protocol. It also provides a base Comm implementation and a default CommManager for the IPython case. """ +from __future__ import annotations -from .base_comm import BaseComm, CommManager +from typing import Any + +from .base_comm import BaseComm, BuffersType, CommManager, MaybeDict __version__ = "0.1.4" __all__ = [ @@ -21,11 +24,18 @@ class DummyComm(BaseComm): - def publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys): + def publish_msg( + self, + msg_type: str, + data: MaybeDict = None, + metadata: MaybeDict = None, + buffers: BuffersType = None, + **keys: Any, + ) -> None: pass -def _create_comm(*args, **kwargs): +def _create_comm(*args: Any, **kwargs: Any) -> BaseComm: """Create a Comm. This method is intended to be replaced, so that it returns your Comm instance. @@ -33,7 +43,7 @@ def _create_comm(*args, **kwargs): return DummyComm(*args, **kwargs) -def _get_comm_manager(): +def _get_comm_manager() -> CommManager: """Get the current Comm manager, creates one if there is none. This method is intended to be replaced if needed (if you want to manage multiple CommManagers). diff --git a/comm/base_comm.py b/comm/base_comm.py index eeddfdc..058e557 100644 --- a/comm/base_comm.py +++ b/comm/base_comm.py @@ -3,16 +3,27 @@ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. +from __future__ import annotations import logging +import typing as t import uuid from traitlets.utils.importstring import import_item import comm +if t.TYPE_CHECKING: + from zmq.eventloop.zmqstream import ZMQStream + logger = logging.getLogger("Comm") +MessageType = t.Dict[str, t.Any] +MaybeDict = t.Optional[t.Dict[str, t.Any]] +BuffersType = t.Optional[t.List[bytes]] +CommCallback = t.Callable[[MessageType], None] +CommTargetCallback = t.Callable[["BaseComm", MessageType], None] + class BaseComm: """Class for communicating between a Frontend and a Kernel @@ -23,18 +34,18 @@ class BaseComm: def __init__( self, - target_name="comm", - data=None, - metadata=None, - buffers=None, - comm_id=None, - primary=True, - target_module=None, - topic=None, - _open_data=None, - _close_data=None, - **kwargs, - ): + target_name: str = "comm", + data: MaybeDict = None, + metadata: MaybeDict = None, + buffers: BuffersType = None, + comm_id: str | None = None, + primary: bool = True, + target_module: str | None = None, + topic: bytes | None = None, + _open_data: MaybeDict = None, + _close_data: MaybeDict = None, + **kwargs: t.Any, + ) -> None: super().__init__(**kwargs) self.comm_id = comm_id if comm_id else uuid.uuid4().hex @@ -46,8 +57,8 @@ def __init__( self._open_data = _open_data if _open_data else {} self._close_data = _close_data if _close_data else {} - self._msg_callback = None - self._close_callback = None + self._msg_callback: CommCallback | None = None + self._close_callback: CommCallback | None = None self._closed = True @@ -57,16 +68,25 @@ def __init__( else: self._closed = False - def publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys): + def publish_msg( + self, + msg_type: str, + data: MaybeDict = None, + metadata: MaybeDict = None, + buffers: BuffersType = None, + **keys: t.Any, + ) -> None: raise NotImplementedError("publish_msg Comm method is not implemented") - def __del__(self): + def __del__(self) -> None: """trigger close on gc""" self.close(deleting=True) # publishing messages - def open(self, data=None, metadata=None, buffers=None): # noqa + def open( # noqa: A003 + self, data: MaybeDict = None, metadata: MaybeDict = None, buffers: BuffersType = None + ) -> None: """Open the frontend-side version of this comm""" if data is None: @@ -90,7 +110,13 @@ def open(self, data=None, metadata=None, buffers=None): # noqa comm_manager.unregister_comm(self) raise - def close(self, data=None, metadata=None, buffers=None, deleting=False): + def close( + self, + data: MaybeDict = None, + metadata: MaybeDict = None, + buffers: BuffersType = None, + deleting: bool = False, + ) -> None: """Close the frontend-side version of this comm""" if self._closed: # only close once @@ -108,7 +134,9 @@ def close(self, data=None, metadata=None, buffers=None, deleting=False): # If deleting, the comm can't be registered comm.get_comm_manager().unregister_comm(self) - def send(self, data=None, metadata=None, buffers=None): + def send( + self, data: MaybeDict = None, metadata: MaybeDict = None, buffers: BuffersType = None + ) -> None: """Send a message to the frontend-side version of this comm""" self.publish_msg( "comm_msg", @@ -119,7 +147,7 @@ def send(self, data=None, metadata=None, buffers=None): # registering callbacks - def on_close(self, callback): + def on_close(self, callback: CommCallback | None) -> None: """Register a callback for comm_close Will be called with the `data` of the close message. @@ -128,7 +156,7 @@ def on_close(self, callback): """ self._close_callback = callback - def on_msg(self, callback): + def on_msg(self, callback: CommCallback | None) -> None: """Register a callback for comm_msg Will be called with the `data` of any comm_msg messages. @@ -139,17 +167,17 @@ def on_msg(self, callback): # handling of incoming messages - def handle_close(self, msg): + def handle_close(self, msg: MessageType) -> None: """Handle a comm_close message""" logger.debug("handle_close[%s](%s)", self.comm_id, msg) if self._close_callback: self._close_callback(msg) - def handle_msg(self, msg): + def handle_msg(self, msg: MessageType) -> None: """Handle a comm_msg message""" logger.debug("handle_msg[%s](%s)", self.comm_id, msg) if self._msg_callback: - from IPython import get_ipython # type:ignore + from IPython import get_ipython shell = get_ipython() if shell: @@ -164,11 +192,11 @@ class CommManager: # Public APIs - def __init__(self): - self.comms = {} - self.targets = {} + def __init__(self) -> None: + self.comms: dict[str, BaseComm] = {} + self.targets: dict[str, CommTargetCallback] = {} - def register_target(self, target_name, f): + def register_target(self, target_name: str, f: CommTargetCallback | str) -> None: """Register a callable f for a given target name f will be called with two arguments when a comm_open message is received with `target`: @@ -181,24 +209,24 @@ def register_target(self, target_name, f): if isinstance(f, str): f = import_item(f) - self.targets[target_name] = f + self.targets[target_name] = t.cast(CommTargetCallback, f) - def unregister_target(self, target_name, f): + def unregister_target(self, target_name: str, f: CommTargetCallback) -> CommTargetCallback: """Unregister a callable registered with register_target""" return self.targets.pop(target_name) - def register_comm(self, comm): + def register_comm(self, comm: BaseComm) -> str: """Register a new comm""" comm_id = comm.comm_id self.comms[comm_id] = comm return comm_id - def unregister_comm(self, comm): + def unregister_comm(self, comm: BaseComm) -> None: """Unregister a comm, and close its counterpart""" # unlike get_comm, this should raise a KeyError comm = self.comms.pop(comm.comm_id) - def get_comm(self, comm_id): + def get_comm(self, comm_id: str) -> BaseComm | None: """Get a comm with a particular id Returns the comm if found, otherwise None. @@ -213,10 +241,11 @@ def get_comm(self, comm_id): if logger.isEnabledFor(logging.DEBUG): # don't create the list of keys if debug messages aren't enabled logger.debug("Current comms: %s", list(self.comms.keys())) + return None # Message handlers - def comm_open(self, stream, ident, msg): + def comm_open(self, stream: ZMQStream, ident: str, msg: MessageType) -> None: """Handler for comm_open messages""" from comm import create_comm @@ -249,7 +278,7 @@ def comm_open(self, stream, ident, msg): exc_info=True, ) - def comm_msg(self, stream, ident, msg): + def comm_msg(self, stream: ZMQStream, ident: str, msg: MessageType) -> None: """Handler for comm_msg messages""" content = msg["content"] comm_id = content["comm_id"] @@ -262,7 +291,7 @@ def comm_msg(self, stream, ident, msg): except Exception: logger.error("Exception in comm_msg for %s", comm_id, exc_info=True) - def comm_close(self, stream, ident, msg): + def comm_close(self, stream: ZMQStream, ident: str, msg: MessageType) -> None: """Handler for comm_close messages""" content = msg["content"] comm_id = content["comm_id"] diff --git a/comm/py.typed b/comm/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 5dc076e..616870f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." readme = "README.md" license = { file="LICENSE" } -requires-python = ">=3.6" +requires-python = ">=3.8" authors = [ { name = "Jupyter contributors" }, ] @@ -22,11 +22,6 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", ] dependencies = [ "traitlets>=4", @@ -36,13 +31,6 @@ dependencies = [ test = [ "pytest", ] -lint = [ - "black>=22.6.0", - "mdformat>0.7", - "mdformat-gfm>=0.3.5", - "ruff>=0.0.156" -] -typing = ["mypy>=0.990"] [project.urls] Homepage = "https://github.com/ipython/comm" @@ -62,59 +50,46 @@ test = "python -m pytest -vv {args}" nowarn = "test -W default {args}" [tool.hatch.envs.typing] -features = ["typing", "test"] +dependencies = ["pre-commit"] +detached = true [tool.hatch.envs.typing.scripts] -test = "mypy --install-types --non-interactive {args:comm tests}" +test = "pre-commit run --all-files --hook-stage manual mypy" [tool.hatch.envs.lint] -features = ["lint"] +dependencies = ["pre-commit"] +detached = true [tool.hatch.envs.lint.scripts] -style = [ - "ruff {args:.}", - "black --check --diff {args:.}", - "mdformat --check {args:*.md}" -] -fmt = [ - "black {args:.}", - "ruff --fix {args:.}", - "mdformat {args:*.md}" -] +build = ["pre-commit run --all-files ruff"] [tool.pytest.ini_options] -addopts = "-raXs --durations 10 --color=yes" +minversion = "6.0" +testpaths = ["tests"] +xfail_strict = true +log_cli_level = "info" +addopts = [ + "-raXs", "--durations=10", "--color=yes", "--doctest-modules", + "--showlocals", "--strict-markers", "--strict-config" +] filterwarnings = ["error"] [tool.mypy] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_untyped_decorators = true -no_implicit_optional = true -no_implicit_reexport = true -pretty = true -show_error_context = true +files = "comm" +python_version = "3.8" +strict = true show_error_codes = true -strict_equality = true -strict_optional = true -warn_unused_configs = true -warn_redundant_casts = true -warn_return_any = true -warn_unused_ignores = true - -[tool.black] -line-length = 100 -skip-string-normalization = true -target-version = ["py37"] +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] +warn_unreachable = true [tool.ruff] -target-version = "py37" +target-version = "py38" line-length = 100 + +[tool.ruff.lint] select = [ - "A", "B", "C", "E", "F", "FBT", "I", "N", "Q", "RUF", "S", "T", + "A", "B", "C", "E", "F", "I", "N", "Q", "RUF", "S", "T", "UP", "W", "YTT", ] ignore = [ - # FBT002 Boolean default value in function definition - "FBT002", "FBT003", ] unfixable = [ # Don't touch print statements @@ -123,6 +98,9 @@ unfixable = [ "RUF100", ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] # S101 Use of `assert` detected "tests/*" = ["S101"] + +[tool.repo-review] +ignore = ["PY007", "PP308", "PY004", "GH102", "MY101", "RTD100"] diff --git a/tests/test_comm.py b/tests/test_comm.py index 15bdb99..51ba9ee 100644 --- a/tests/test_comm.py +++ b/tests/test_comm.py @@ -1,16 +1,25 @@ +from typing import Any + from comm.base_comm import BaseComm, CommManager class MyComm(BaseComm): - def publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys): + def publish_msg( + self, + msg_type: str, + data: Any = None, + metadata: Any = None, + buffers: Any = None, + **keys: Any, + ) -> None: pass -def test_comm_manager(): +def test_comm_manager() -> None: test = CommManager() assert test.targets == {} -def test_base_comm(): +def test_base_comm() -> None: test = MyComm() assert test.target_name == "comm"