diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d0fb4c2..92bbd0d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,7 @@ jobs: - name: Create packages run: python -m build - name: Archive packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist @@ -36,7 +36,7 @@ jobs: id-token: write steps: - name: Retrieve packages - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Upload packages uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 767c2c4..22518c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,16 +11,16 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] include: - os: macos-latest - python-version: "3.8" + python-version: "3.9" - os: macos-latest - python-version: "3.12" + python-version: "3.13" - os: windows-latest - python-version: "3.8" + python-version: "3.9" - os: windows-latest - python-version: "3.12" + python-version: "3.13" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -32,7 +32,7 @@ jobs: cache: pip cache-dependency-path: pyproject.toml - name: Install dependencies - run: pip install .[test] + run: pip install -e .[test] - name: Test with pytest run: coverage run -m pytest -v - name: Generate coverage report diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3379c83..7827a73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ # * Run "pre-commit install". repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-toml - id: check-yaml @@ -16,18 +16,23 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.5 + rev: v0.8.4 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.14.0 hooks: - id: mypy - args: ["--explicit-package-bases"] additional_dependencies: - - asphalt - - py4j + - asphalt@git+https://github.com/asphalt-framework/asphalt - pytest + + - 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 diff --git a/.readthedocs.yml b/.readthedocs.yml index 026b967..ac1099a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.9" sphinx: configuration: docs/conf.py diff --git a/docs/api.rst b/docs/api.rst index cf4d164..279f54c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,7 +1,9 @@ API reference ============= +.. py:currentmodule:: asphalt.py4j + Component --------- -.. autoclass:: asphalt.py4j.component.Py4JComponent +.. autoclass:: Py4JComponent diff --git a/docs/conf.py b/docs/conf.py index 616bc8e..54d2559 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,6 +7,7 @@ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", + "sphinx_rtd_theme", ] templates_path = ["_templates"] @@ -24,6 +25,8 @@ exclude_patterns = ["_build"] pygments_style = "sphinx" +autodoc_default_options = {"members": True, "show-inheritance": True} +autodoc_inherit_docstrings = False highlight_language = "python3" todo_include_todos = False diff --git a/docs/configuration.rst b/docs/configuration.rst index f69cd41..634cce5 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -52,10 +52,8 @@ of the component: components: py4j: - py4j-remote: - type: py4j + py4j/remote: launch_jvm: false - resource_name: remote gateway: host: 10.0.0.1 diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 3b58a95..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Sphinx >= 1.5 -sphinx_rtd_theme -sphinx-autodoc-typehints >= 1.0.5 -sphinxcontrib-asyncio >= 0.2.0 diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 187e695..be788b7 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -3,6 +3,11 @@ Version history This library adheres to `Semantic Versioning 2.0 `_. +**UNRELEASED** + +- **BACKWARD INCOMPATIBLE** Bumped minimum Asphalt version to 5.0 +- Dropped support for Python 3.7 and 3.8 + **4.0.0** (2023-12-26) - **BACKWARD INCOMPATIBLE** Bumped minimum Asphalt version to 4.8 diff --git a/examples/simple.py b/examples/simple.py index 451c211..3c1bd18 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -3,19 +3,17 @@ standard output. """ +from anyio import to_thread +from asphalt.core import CLIApplicationComponent, get_resource_nowait, run_application from py4j.java_gateway import JavaGateway -from asphalt.core import CLIApplicationComponent, Context, run_application - class ApplicationComponent(CLIApplicationComponent): - async def start(self, ctx: Context) -> None: + def __init__(self) -> None: self.add_component("py4j") - await super().start(ctx) - async def run(self, ctx: Context) -> None: - javagw = ctx.require_resource(JavaGateway) - async with ctx.threadpool(): + async def run(self) -> None: + def read_file() -> None: f = javagw.jvm.java.io.File(__file__) buffer = javagw.new_array(javagw.jvm.char, f.length()) reader = javagw.jvm.java.io.FileReader(f) @@ -23,5 +21,8 @@ async def run(self, ctx: Context) -> None: reader.close() print(javagw.jvm.java.lang.String(buffer)) + javagw = get_resource_nowait(JavaGateway) + await to_thread.run_sync(read_file) + -run_application(ApplicationComponent()) +run_application(ApplicationComponent) diff --git a/pyproject.toml b/pyproject.toml index d9f79cb..7277c39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,15 +19,15 @@ classifiers = [ "Typing :: Typed", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "asphalt ~= 4.8", + "asphalt @ git+https://github.com/asphalt-framework/asphalt", "py4j >= 0.10.9" ] dynamic = ["version"] @@ -37,9 +37,9 @@ Homepage = "https://github.com/asphalt-framework/asphalt-py4j" [project.optional-dependencies] test = [ - "anyio >= 4.0", + "anyio[trio] >= 4.1", "coverage >= 7", - "pytest", + "pytest >= 7", ] doc = [ "Sphinx >= 7.0", @@ -48,29 +48,37 @@ doc = [ ] [project.entry-points."asphalt.components"] -py4j = "asphalt.py4j.component:Py4JComponent" +py4j = "asphalt.py4j:Py4JComponent" [tool.setuptools_scm] version_scheme = "post-release" local_scheme = "dirty-tag" -[tool.ruff] -select = [ +[tool.ruff.lint] +extend-select = [ "ASYNC", # flake8-async - "E", "F", "W", # default Flake8 "G", # flake8-logging-format "I", # isort "ISC", # flake8-implicit-str-concat "PGH", # pygrep-hooks - "RUF100", # unused noqa (yesqa) + "RUF", # Ruff-specific rules "UP", # pyupgrade + "W", # pycodestyle warnings ] +[tool.ruff.lint.isort] +known-first-party = ["asphalt.py4j"] +known-third-party = ["asphalt.core"] + +[tool.pytest.ini_options] +addopts = ["-rsfE", "--tb=short"] +testpaths = ["tests"] + [tool.mypy] -python_version = "3.8" +python_version = "3.9" strict = true -ignore_missing_imports = true -mypy_path = ["src", "tests"] +explicit_package_bases = true +mypy_path = ["src", "tests", "examples"] [tool.coverage.run] source = ["asphalt.py4j"] @@ -81,17 +89,14 @@ branch = true show_missing = true [tool.tox] -legacy_tox_ini = """ -[tox] -envlist = py38, py39, py310, py311, pypy3 +env_list = ["py39", "py310", "py311", "py312", "py313", "pypy3"] skip_missing_interpreters = true -minversion = 4.0 -[testenv] -extras = test -commands = python -m pytest {posargs} +[tool.tox.env_run_base] +commands = [["python", "-m", "pytest", { replace = "posargs", extend = true }]] +package = "editable" +extras = ["test"] -[testenv:docs] -extras = doc -commands = sphinx-build -W -n docs build/sphinx {posargs} -""" +[tool.tox.env.docs] +commands = [["sphinx-build", "-W", "-n", "docs", "build/sphinx", { replace = "posargs", extend = true }]] +extras = ["doc"] diff --git a/src/asphalt/py4j/__init__.py b/src/asphalt/py4j/__init__.py index e69de29..526ed02 100644 --- a/src/asphalt/py4j/__init__.py +++ b/src/asphalt/py4j/__init__.py @@ -0,0 +1,10 @@ +from typing import Any + +from ._component import Py4JComponent as Py4JComponent + +# Re-export imports, so they look like they live directly in this package +key: str +value: Any +for key, value in list(locals().items()): + if getattr(value, "__module__", "").startswith(f"{__name__}."): + value.__module__ = __name__ diff --git a/src/asphalt/py4j/component.py b/src/asphalt/py4j/_component.py similarity index 78% rename from src/asphalt/py4j/component.py rename to src/asphalt/py4j/_component.py index f0d8bea..ded7d4d 100644 --- a/src/asphalt/py4j/component.py +++ b/src/asphalt/py4j/_component.py @@ -3,11 +3,12 @@ import logging import os import re -from collections.abc import AsyncGenerator +from collections.abc import Iterable from importlib import import_module -from typing import Any, Iterable, cast +from typing import Any, cast + +from asphalt.core import Component, add_resource -from asphalt.core import Component, Context, context_teardown from py4j.java_gateway import ( CallbackServerParameters, GatewayParameters, @@ -15,7 +16,7 @@ launch_gateway, ) -logger = logging.getLogger(__name__) +logger = logging.getLogger("asphalt.py4j") package_re = re.compile(r"\{(.+?)\}") @@ -23,7 +24,6 @@ class Py4JComponent(Component): """ Creates a :class:`~py4j.java_gateway.JavaGateway` resource. - :param resource_name: name of the Java gateway resource to be published :param launch_jvm: ``True`` to spawn a Java Virtual Machine in a subprocess and connect to it, ``False`` to connect to an existing Py4J enabled JVM :param gateway: either a :class:`~py4j.java_gateway.GatewayParameters` object or @@ -37,14 +37,12 @@ class path def __init__( self, - resource_name: str = "default", launch_jvm: bool = True, gateway: GatewayParameters | dict[str, Any] | None = None, callback_server: CallbackServerParameters | dict[str, Any] | bool = False, javaopts: Iterable[str] = (), classpath: Iterable[str] = "", ): - self.resource_name = resource_name self.launch_jvm = launch_jvm classpath = ( classpath if isinstance(classpath, str) else os.pathsep.join(classpath) @@ -85,8 +83,7 @@ def __init__( else: self.callback_server_params = callback_server - @context_teardown - async def start(self, ctx: Context) -> AsyncGenerator[None, Exception | None]: + async def start(self) -> None: if self.launch_jvm: self.gateway_params.port = launch_gateway( classpath=self.classpath, javaopts=self.javaopts @@ -96,19 +93,7 @@ async def start(self, ctx: Context) -> AsyncGenerator[None, Exception | None]: gateway_parameters=self.gateway_params, callback_server_parameters=self.callback_server_params, ) - ctx.add_resource(gateway, self.resource_name) - logger.info( - "Configured Py4J gateway (%s; address=%s, port=%d)", - self.resource_name, - self.gateway_params.address, - self.gateway_params.port, + add_resource( + gateway, + teardown_callback=gateway.shutdown if self.launch_jvm else gateway.close, ) - - yield - - if self.launch_jvm: - gateway.shutdown() - else: - gateway.close() - - logger.info("Py4J gateway (%s) shut down", self.resource_name) diff --git a/src/asphalt/py4j/py.typed b/src/asphalt/py4j/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 5c53fe0..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - - -@pytest.fixture -def anyio_backend() -> str: - return "asyncio" diff --git a/tests/test_component.py b/tests/test_component.py index c8fbb40..5100eb5 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,44 +1,23 @@ from __future__ import annotations -import logging import os -from typing import Any +from typing import Any, ClassVar import pytest +from asphalt.core import Context, get_resource_nowait, start_component from py4j.java_gateway import CallbackServerParameters, GatewayParameters, JavaGateway -from pytest import LogCaptureFixture import asphalt.py4j -from asphalt.core.context import Context -from asphalt.py4j.component import Py4JComponent +from asphalt.py4j import Py4JComponent +pytestmark = pytest.mark.anyio -@pytest.mark.anyio -@pytest.mark.parametrize( - "kwargs, resource_name", - [ - pytest.param({}, "default", id="default"), - pytest.param({"resource_name": "alt"}, "alt", id="alternate"), - ], -) -async def test_default_gateway( - kwargs: dict[str, Any], resource_name: str, caplog: LogCaptureFixture -) -> None: + +async def test_default_gateway() -> None: """Test that the default gateway is started and is available on the context.""" - caplog.set_level(logging.INFO, logger="asphalt.py4j.component") - async with Context() as context: - await Py4JComponent(**kwargs).start(context) - context.require_resource(JavaGateway, resource_name) - - records = [ - record for record in caplog.records if record.name == "asphalt.py4j.component" - ] - records.sort(key=lambda r: r.message) - assert len(records) == 2 - assert records[0].message.startswith( - f"Configured Py4J gateway ({resource_name}; address=127.0.0.1, port=" - ) - assert records[1].message == f"Py4J gateway ({resource_name}) shut down" + async with Context(): + await start_component(Py4JComponent) + get_resource_nowait(JavaGateway) def test_bad_classpath_entry() -> None: @@ -89,7 +68,6 @@ def test_classpath_pkgname_substitution() -> None: assert component.classpath.endswith(os.path.join("asphalt", "py4j", "javadir", "*")) -@pytest.mark.anyio async def test_callback_server() -> None: """ Test that the gateway's callback server works when enabled in the configuration. @@ -100,11 +78,11 @@ def call(self) -> int: return 7 class Java: - implements = ["java.util.concurrent.Callable"] + implements: ClassVar[list[str]] = ["java.util.concurrent.Callable"] - async with Context() as context: - await Py4JComponent(callback_server=True).start(context) - gateway = context.require_resource(JavaGateway) + async with Context(): + await start_component(Py4JComponent, {"callback_server": True}) + gateway = get_resource_nowait(JavaGateway) executor = gateway.jvm.java.util.concurrent.Executors.newFixedThreadPool(1) try: future = executor.submit(NumberCallable()) @@ -113,7 +91,6 @@ class Java: executor.shutdown() -@pytest.mark.anyio async def test_gateway_close() -> None: """ Test that shutting down the context does not shut down the Java side gateway if @@ -121,11 +98,12 @@ async def test_gateway_close() -> None: """ gateway = JavaGateway.launch_gateway() - async with Context() as context: - await Py4JComponent( - gateway={"port": gateway.gateway_parameters.port}, launch_jvm=False - ).start(context) - gateway2 = context.require_resource(JavaGateway) + async with Context(): + await start_component( + Py4JComponent, + {"gateway": {"port": gateway.gateway_parameters.port}, "launch_jvm": False}, + ) + gateway2 = get_resource_nowait(JavaGateway) gateway2.jvm.java.lang.System.setProperty("TEST_VALUE", "abc") assert gateway.jvm.java.lang.System.getProperty("TEST_VALUE") == "abc"