From c4e0101300db0975207aff112bcd9d9edd6a8a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 26 Dec 2023 12:01:38 +0200 Subject: [PATCH] Modernized the code base --- .github/workflows/publish.yml | 41 ++++++ .github/workflows/test.yml | 54 ++++++++ .gitignore | 6 +- .pre-commit-config.yaml | 33 +++++ .readthedocs.yml | 15 +++ .travis.yml | 36 ------ README.rst | 7 +- asphalt/py4j/component.py | 114 ---------------- docs/api.rst | 7 + docs/conf.py | 45 +++---- docs/configuration.rst | 83 ++++++------ docs/index.rst | 5 +- docs/modules/component.rst | 6 - docs/versionhistory.rst | 12 +- examples/simple.py | 22 ++-- pyproject.toml | 96 ++++++++++++++ setup.cfg | 19 --- setup.py | 51 -------- {asphalt => src/asphalt}/py4j/__init__.py | 0 src/asphalt/py4j/component.py | 115 ++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 6 + tests/test_component.py | 151 ++++++++++++---------- tox.ini | 21 --- 24 files changed, 551 insertions(+), 394 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .readthedocs.yml delete mode 100644 .travis.yml delete mode 100644 asphalt/py4j/component.py create mode 100644 docs/api.rst delete mode 100644 docs/modules/component.rst create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py rename {asphalt => src/asphalt}/py4j/__init__.py (100%) create mode 100644 src/asphalt/py4j/component.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py delete mode 100644 tox.ini diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..55e2bf5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +name: Publish packages to PyPI + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+.post[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +jobs: + build: + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.x + - name: Install dependencies + run: pip install build + - name: Create packages + run: python -m build + - name: Archive packages + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist + + publish: + needs: build + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Retrieve packages + uses: actions/download-artifact@v3 + - name: Upload packages + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0757d46 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,54 @@ +name: test suite + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.10"] + include: + - os: macos-latest + python-version: "3.8" + - os: macos-latest + python-version: "3.12" + - os: windows-latest + python-version: "3.8" + - os: windows-latest + python-version: "3.12" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: pip install .[test] + - name: Test with pytest + run: | + coverage run -m pytest + coverage xml + - name: Upload Coverage + uses: coverallsapp/github-action@v2 + with: + parallel: true + file: coverage.xml + + coveralls: + name: Finish Coveralls + needs: test + runs-on: ubuntu-latest + steps: + - name: Finished + uses: coverallsapp/github-action@v2 + with: + parallel-finished: true diff --git a/.gitignore b/.gitignore index 286b826..db8f7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,14 @@ .tox .coverage .cache +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ .eggs/ *.egg-info/ -*.pyc __pycache__/ docs/_build/ dist/ build/ +virtualenv/ +venv* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..82796fe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +# This is the configuration file for pre-commit (https://pre-commit.com/). +# To use: +# * Install pre-commit (https://pre-commit.com/#installation) +# * Copy this file as ".pre-commit-config.yaml" +# * Run "pre-commit install". +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + args: [ "--fix=lf" ] + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.9 + hooks: + - id: ruff + args: [--fix, --show-fixes] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + args: ["--explicit-package-bases"] + additional_dependencies: + - asphalt + - py4j + - pytest diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..026b967 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,15 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.8" + +sphinx: + configuration: docs/conf.py + +python: + install: + - method: pip + path: . + extra_requirements: [doc] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7859596..0000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -sudo: false - -language: python - -python: - - "3.5" - - "3.6" - -install: pip install tox-travis coveralls - -script: tox - -after_success: coveralls - -notifications: - webhooks: - urls: - - https://webhooks.gitter.im/e/aa5a289b7a0df8aefd68 - irc: - channels: - - "chat.freenode.net#asphalt" - on_success: change - on_failure: change - use_notice: true - skip_join: true - -deploy: - provider: pypi - user: agronholm - password: - secure: IgSfhS6d2kVsEDacq4GH470hbTwHRaQ+9V8o46+Mi+3pq6vwSH9U5jWqQ5u2n/wYAzEfYSS2KwxkLvh4f/lpu3hk9dOqG8b6eIcwzVe+eV9mmNETt+9JVjAV8VdEZPoWDvKoZS+N6ywfFLU4krxPZ4Te5nLjADFrAr3D66MQWqNAYByjZHJyemxPte5QHDo8CFtLtDZUNxyTHnAJCWSq2jASPhZ3maoyXMc+TOjS3xnp5UmbAD+TtwXfFucamK/cb6/jH8dW0UVV990oMnbF+ntjrRCPC5a54HFqiM5kQxKw1e1wgsmYcY8+gCg+sEff+D3CRFj7x+3TufgaATUCRhQ+tXjpwON2ZkkINg7CZc/i5nCQCp8oWVESgOewixoLdODL0rjZ9GzhqwYI0JV/MqSMScqo6vM/RFKQzYYG7PzZzMxXuq+UGEnVQYvwM7DR7K76etg+HCEuQHeYNHqSlg+Yn9axRg+piiXayR+knY5iOYB49ziiKXgqxnGv1juTEZrfH5Bl+QZM0OXjD7ev6vj2NpYuxPSf13aBBYWo2/yqbfu+hYbpttMA4Jmjk391F2UxQuLC9wCV8/FjIIXtBJG2TFjYYejRQ4o0FV1eJaDzJpPbSlxQeOLlE/FIHSmvcKKQBOmIK5/HUL34d5hyazuI3OGyo/FfAj++JwA24gA= - distributions: sdist bdist_wheel - on: - tags: true - python: "3.5" - repo: asphalt-framework/asphalt-py4j diff --git a/README.rst b/README.rst index 25c63c8..2a5a4d4 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,12 @@ -.. image:: https://travis-ci.org/asphalt-framework/asphalt-py4j.svg?branch=master - :target: https://travis-ci.org/asphalt-framework/asphalt-py4j +.. image:: https://github.com/asphalt-framework/asphalt-py4j/actions/workflows/test.yml/badge.svg + :target: https://github.com/asphalt-framework/asphalt-py4j/actions/workflows/test.yml :alt: Build Status .. image:: https://coveralls.io/repos/github/asphalt-framework/asphalt-py4j/badge.svg?branch=master :target: https://coveralls.io/github/asphalt-framework/asphalt-py4j?branch=master :alt: Code Coverage +.. image:: https://readthedocs.org/projects/asphalt-py4j/badge/?version=latest + :target: https://asphalt-py4j.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status This Asphalt framework component provides the ability to run Java code directly from Python. diff --git a/asphalt/py4j/component.py b/asphalt/py4j/component.py deleted file mode 100644 index 590dc97..0000000 --- a/asphalt/py4j/component.py +++ /dev/null @@ -1,114 +0,0 @@ -import logging -import os -import re -from importlib import import_module -from typing import Dict, Any, Union, Iterable - -from asphalt.core import Component, Context, merge_config, context_teardown -from async_generator import yield_ -from py4j.java_gateway import (JavaGateway, launch_gateway, GatewayParameters, - CallbackServerParameters) -from typeguard import check_argument_types - -logger = logging.getLogger(__name__) -package_re = re.compile(r'\{(.+?)\}') - - -class Py4JComponent(Component): - """ - Creates one or more :class:`~py4j.java_gateway.JavaGateway` resources. - - If ``gateways`` is given, a Java gateway resource will be published for each key in the - dictionary, using the key as the resource name. Any extra keyword arguments to the component - constructor will be used as defaults for omitted configuration values. The context attribute - will by default be the same as the resource name, unless explicitly set with the - ``context_attr`` option. - - If ``gateways`` is omitted, a single gateway resource (``default`` / ``ctx.java``) - is published using any extra keyword arguments passed to the component. - - :param gateways: a dictionary of resource name ⭢ :meth:`configure_gateway` arguments - :param default_gateway_args: default values for omitted :meth:`configure_gateway` arguments - """ - - def __init__(self, gateways: Dict[str, Dict[str, Any]] = None, **default_gateway_args): - assert check_argument_types() - if not gateways: - default_gateway_args.setdefault('context_attr', 'java') - gateways = {'default': default_gateway_args} - - self.gateways = [] - for resource_name, config in gateways.items(): - config = merge_config(default_gateway_args, config) - context_attr = config.pop('context_attr', resource_name) - gateway_settings = self.configure_gateway(**config) - self.gateways.append((resource_name, context_attr) + tuple(gateway_settings)) - - @classmethod - def configure_gateway( - cls, launch_jvm: bool = True, - gateway: Union[GatewayParameters, Dict[str, Any]] = None, - callback_server: Union[CallbackServerParameters, Dict[str, Any]] = False, - javaopts: Iterable[str] = (), classpath: Iterable[str] = ''): - """ - Configure a Py4J gateway. - - :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 a - dictionary of keyword arguments for it - :param callback_server: callback server parameters or a boolean indicating if a - callback server is wanted - :param javaopts: options passed to Java itself - :param classpath: path or iterable of paths to pass to the JVM launcher as the class path - - """ - assert check_argument_types() - classpath = classpath if isinstance(classpath, str) else os.pathsep.join(classpath) - javaopts = list(javaopts) - - # Substitute package names with their absolute directory paths - for match in package_re.finditer(classpath): - pkgname = match.group(1) - module = import_module(pkgname) - module_dir = os.path.dirname(module.__file__) - classpath = classpath.replace(match.group(0), module_dir) - - if gateway is None: - gateway = {} - if isinstance(gateway, dict): - gateway.setdefault('eager_load', True) - gateway.setdefault('auto_convert', True) - gateway = GatewayParameters(**gateway) - - if isinstance(callback_server, dict): - callback_server = CallbackServerParameters(**callback_server) - elif callback_server is True: - callback_server = CallbackServerParameters() - - return launch_jvm, gateway, callback_server, classpath, javaopts - - @context_teardown - async def start(self, ctx: Context): - gateways = [] - for (resource_name, context_attr, launch_jvm, gateway_params, callback_server_params, - classpath, javaopts) in self.gateways: - if launch_jvm: - gateway_params.port = launch_gateway(classpath=classpath, javaopts=javaopts) - - gateway = JavaGateway(gateway_parameters=gateway_params, - callback_server_parameters=callback_server_params) - gateways.append((resource_name, launch_jvm, gateway)) - ctx.add_resource(gateway, resource_name, context_attr) - logger.info('Configured Py4J gateway (%s / ctx.%s; address=%s, port=%d)', - resource_name, context_attr, gateway_params.address, gateway_params.port) - - await yield_() - - for resource_name, shutdown_jvm, gateway in gateways: - if shutdown_jvm: - gateway.shutdown() - else: - gateway.close() - - logger.info('Py4J gateway (%s) shut down', resource_name) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..cf4d164 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,7 @@ +API reference +============= + +Component +--------- + +.. autoclass:: asphalt.py4j.component.Py4JComponent diff --git a/docs/conf.py b/docs/conf.py index f422851..616bc8e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,36 +1,37 @@ #!/usr/bin/env python3 -import pkg_resources +import importlib.metadata +from packaging.version import parse extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx_autodoc_typehints', - 'sphinxcontrib.asyncio' + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx_autodoc_typehints", ] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' -project = 'asphalt-py4j' -author = 'Alex Grönholm' -copyright = '2015, ' + author +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" +project = "asphalt-py4j" +author = "Alex Grönholm" +copyright = "2015, " + author -v = pkg_resources.get_distribution(project).parsed_version +v = parse(importlib.metadata.version(project)) version = v.base_version release = v.public -language = None +language = "en" -exclude_patterns = ['_build'] -pygments_style = 'sphinx' -highlight_language = 'python3' +exclude_patterns = ["_build"] +pygments_style = "sphinx" +highlight_language = "python3" todo_include_todos = False -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] -htmlhelp_basename = project.replace('-', '') + 'doc' +html_theme = "sphinx_rtd_theme" +htmlhelp_basename = project.replace("-", "") + "doc" -intersphinx_mapping = {'python': ('http://docs.python.org/3/', None), - 'asphalt': ('http://asphalt.readthedocs.org/en/latest/', None), - 'py4j': ('https://www.py4j.org/', None)} +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "asphalt": ("https://asphalt.readthedocs.org/en/latest/", None), + "py4j": ("https://www.py4j.org/", None), +} diff --git a/docs/configuration.rst b/docs/configuration.rst index 8942513..f69cd41 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -8,84 +8,87 @@ There are two principal ways to use a Java Virtual Machine with Py4J: #. Launch a new JVM just for the application, in a subprocess #. Connect to an existing JVM -The first method is what most people will want. The Java Virtual Machine is started along with -the application and is shut down when the application is shut down. +The first method is what most people will want. The Java Virtual Machine is started +along with the application and is shut down when the application is shut down. -The second method is primarly useful in special scenarios like connecting to a Java EE container. -Shutting down the application has no effect in the Java side gateway then. +The second method is primarly useful in special scenarios like connecting to a Java EE +container. Shutting down the application has no effect in the Java side gateway then. The minimal configuration is as follows:: components: py4j: -This will publish a resource of type :class:`py4j.java_gateway.JavaGateway`, named ``default``. -It will appear in the context as the ``java`` attribute. +This will publish a resource of type :class:`py4j.java_gateway.JavaGateway`, named +``default``. Connecting to an existing JVM ----------------------------- -To connect to an existing Java Virtual Machine, specify the host address and port of the JVM that -has a GatewayServer_ running, you can use a configuration similar to this:: +To connect to an existing Java Virtual Machine, specify the host address and port of the +JVM that has a GatewayServer_ running, you can use a configuration similar to this:: components: py4j: launch_jvm: false - host: 10.0.0.1 - port: 25334 + gateway: + host: 10.0.0.1 + port: 25334 This configuration will connect to a JVM listening on ``10.0.0.1``, port 25334. -By default the JavaGateway connects to 127.0.0.1 port 25333, so you can leave out either value if -you want to use the default. +By default the JavaGateway connects to 127.0.0.1 port 25333, so you can leave out either +value if you want to use the default. .. _GatewayServer: https://www.py4j.org/_static/javadoc/index.html?py4j/GatewayServer.html Multiple gateways ----------------- -If you need to configure multiple gateways, you can do so by using the ``gateways`` configuration -option:: +If you need to configure multiple gateways, you will need to use multiple instances +of the component: + +.. code-block:: yaml components: py4j: - gateways: - default: - context_attr: java - remote: - launch_jvm: false - host: 10.0.0.1 - + py4j-remote: + type: py4j + launch_jvm: false + resource_name: remote + gateway: + host: 10.0.0.1 -This configures two :class:`py4j.gateway.JavaGateway` resources, named ``default`` and ``remote``. -Their corresponding context attributes are ``java`` and ``remote``. -If you omit the ``context_attr`` option for a gateway, its resource name will be used. +The above configuration creates two :class:`py4j.java_gateway.JavaGateway` resources: +``default`` and ``remote``. Adding jars to the class path ----------------------------- When you distribute your application, you often want to include some jar files with your -application. But when configuring the gateway to launch a new JVM, you need to include those jar -files on the class path. The problem is of course that you don't necessarily know the absolute -file system path to your jar files beforehand. The solution is to define a *package relative* class -path in your Py4J configuration. This feature is provided by the Py4J component and not the -upstream library itself. +application. But when configuring the gateway to launch a new JVM, you need to include +those jar files on the class path. The problem is of course that you don't necessarily +know the absolute file system path to your jar files beforehand. The solution is to +define a *package relative* class path in your Py4J configuration. This feature is +provided by the Py4J component and not the upstream library itself. -Suppose your project has a package named ``foo.bar.baz`` and a subdirectory named ``javalib``. -The relative path from your project root to this subdirectory would then be -``foo/bar/baz/javalib``. To properly express this in your class path configuration, you can do -this:: +Suppose your project has a package named ``foo.bar.baz`` and a subdirectory named +``javalib``. The relative path from your project root to this subdirectory would then be +``foo/bar/baz/javalib``. To properly express this in your class path configuration, you +can do this:: components: py4j: classpath: "{foo.bar.baz}/javalib/*" -This will add all the jars in the javalib subdirectory to the class path. The ``{foo.bar.baz}`` -part is substituted with the computed absolute path to the ``foo.bar.baz`` package directory. +This will add all the jars in the javalib subdirectory to the class path. The +``{foo.bar.baz}`` part is substituted with the computed absolute path to the +``foo.bar.baz`` package directory. .. note:: - Remember to enclose the path in quotes when specifying the class path in a YAML configuration - file. Otherwise the parser may mistake it for the beginning of a dictionary definition. + Remember to enclose the path in quotes when specifying the class path in a YAML + configuration file. Otherwise the parser may mistake it for the beginning of a + dictionary definition. .. code-block:: yaml @@ -95,6 +98,6 @@ part is substituted with the computed absolute path to the ``foo.bar.baz`` packa - "{foo.bar.baz}/javalib/*" - "{x.y}/jars/*" -This specifies a class path of multiple elements in an operating system independent manner using a -list. The final class path is computed by joining the elements using the operation system's path -separator character. +This specifies a class path of multiple elements in an operating system independent +manner using a list. The final class path is computed by joining the elements using the +operation system's path separator character. diff --git a/docs/index.rst b/docs/index.rst index 27b0d0c..7c385e0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,5 @@ .. include:: ../README.rst - :start-line: 7 + :start-line: 10 :end-before: Project links Table of contents @@ -10,6 +10,5 @@ Table of contents configuration usage + api versionhistory - -* :ref:`API reference ` diff --git a/docs/modules/component.rst b/docs/modules/component.rst deleted file mode 100644 index 5ddefeb..0000000 --- a/docs/modules/component.rst +++ /dev/null @@ -1,6 +0,0 @@ -:mod:`asphalt.py4j.component` -============================= - -.. automodule:: asphalt.py4j.component - :members: - :show-inheritance: diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index b078c6e..75bcf17 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -1,7 +1,17 @@ Version history =============== -This library adheres to `Semantic Versioning `_. +This library adheres to `Semantic Versioning 2.0 `_. + +**UNRELEASED** + +- **BACKWARD INCOMPATIBLE** Bumped minimum Asphalt version to 4.8 +- **BACKWARD INCOMPATIBLE** Refactored component to only provide a single Java gateway + (you will have to add two components to get two Java gateways) +- **BACKWARD INCOMPATIBLE** Dropped the context attribute (use dependency injection + instead) +- Dropped explicit run-time type checking +- Dropped support for Python 3.7 (and earlier) **3.0.1** (2017-06-04) diff --git a/examples/simple.py b/examples/simple.py index f4596bd..451c211 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,23 +1,27 @@ """ -A simple example that reads its own source code using Java classes and then prints it on standard -output. +A simple example that reads its own source code using Java classes and then prints it on +standard output. """ +from py4j.java_gateway import JavaGateway + from asphalt.core import CLIApplicationComponent, Context, run_application class ApplicationComponent(CLIApplicationComponent): - async def start(self, ctx: Context): - self.add_component('py4j') + async def start(self, ctx: Context) -> None: + self.add_component("py4j") await super().start(ctx) - async def run(self, ctx): + async def run(self, ctx: Context) -> None: + javagw = ctx.require_resource(JavaGateway) async with ctx.threadpool(): - f = ctx.java.jvm.java.io.File(__file__) - buffer = ctx.java.new_array(ctx.java.jvm.char, f.length()) - reader = ctx.java.jvm.java.io.FileReader(f) + f = javagw.jvm.java.io.File(__file__) + buffer = javagw.new_array(javagw.jvm.char, f.length()) + reader = javagw.jvm.java.io.FileReader(f) reader.read(buffer) reader.close() - print(ctx.java.jvm.java.lang.String(buffer)) + print(javagw.jvm.java.lang.String(buffer)) + run_application(ApplicationComponent()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d3c55d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,96 @@ +[build-system] +requires = [ + "setuptools >= 64", + "setuptools_scm >= 6.4" +] +build-backend = "setuptools.build_meta" + +[project] +name = "asphalt-py4j" +description = "Py4J integration component for the Asphalt framework" +readme = "README.rst" +authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] +license = {text = "Apache License 2.0"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Framework :: AsyncIO", + "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", +] +requires-python = ">=3.8" +dependencies = [ + "asphalt ~= 4.8", + "py4j >= 0.10.9" +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/asphalt-framework/asphalt-py4j" + +[project.optional-dependencies] +test = [ + "anyio >= 4.0", + "pytest", +] +doc = [ + "Sphinx >= 7.0", + "sphinx-rtd-theme >= 1.3.0", + "sphinx-autodoc-typehints >= 1.22", +] + +[project.entry-points."asphalt.components"] +py4j = "asphalt.py4j.component:Py4JComponent" + +[tool.setuptools_scm] +version_scheme = "post-release" +local_scheme = "dirty-tag" + +[tool.ruff] +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) + "UP", # pyupgrade +] + +[tool.mypy] +python_version = "3.8" +strict = true +ignore_missing_imports = true +mypy_path = ["src", "tests"] + +[tool.coverage.run] +source = ["asphalt.py4j"] +relative_files = true +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = py38, py39, py310, py311, pypy3 +skip_missing_interpreters = true +minversion = 4.0 + +[testenv] +extras = test +commands = python -m pytest {posargs} + +[testenv:docs] +extras = doc +commands = sphinx-build -W -n docs build/sphinx {posargs} +""" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 585d662..0000000 --- a/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[build_sphinx] -source-dir = docs -build-dir = docs/_build - -[tool:pytest] -addopts = -rsx --cov --tb=short -testpaths = tests - -[coverage:run] -source = asphalt.py4j -branch = 1 - -[coverage:report] -show_missing = true - -[flake8] -max-line-length = 99 -exclude = .tox,docs -ignore = E251 diff --git a/setup.py b/setup.py deleted file mode 100644 index 5384763..0000000 --- a/setup.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path - -from setuptools import setup - -setup( - name='asphalt-py4j', - use_scm_version={ - 'version_scheme': 'post-release', - 'local_scheme': 'dirty-tag' - }, - description='Py4J integration component for the Asphalt framework', - long_description=Path(__file__).with_name('README.rst').read_text('utf-8'), - author='Alex Grönholm', - author_email='alex.gronholm@nextday.fi', - url='https://github.com/asphalt-framework/asphalt-py4j', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6' - ], - license='Apache License 2.0', - zip_safe=False, - packages=[ - 'asphalt.py4j' - ], - setup_requires=[ - 'setuptools_scm >= 1.7.0' - ], - install_requires=[ - 'asphalt >= 3.0, < 5.0', - 'py4j >= 0.10.4', - 'typeguard ~= 2.0' - ], - extras_require={ - 'testing': [ - 'pytest', - 'pytest-cov', - 'pytest-catchlog', - 'pytest-asyncio >= 0.5.0' - ] - }, - entry_points={ - 'asphalt.components': [ - 'py4j = asphalt.py4j.component:Py4JComponent' - ] - } -) diff --git a/asphalt/py4j/__init__.py b/src/asphalt/py4j/__init__.py similarity index 100% rename from asphalt/py4j/__init__.py rename to src/asphalt/py4j/__init__.py diff --git a/src/asphalt/py4j/component.py b/src/asphalt/py4j/component.py new file mode 100644 index 0000000..985041f --- /dev/null +++ b/src/asphalt/py4j/component.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import logging +import os +import re +from collections.abc import AsyncGenerator +from importlib import import_module +from typing import Any, Iterable, cast + +from asphalt.core import Component, Context, context_teardown + +from py4j.java_gateway import ( + CallbackServerParameters, + GatewayParameters, + JavaGateway, + launch_gateway, +) + +logger = logging.getLogger(__name__) +package_re = re.compile(r"\{(.+?)\}") + + +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 + a dictionary of keyword arguments for it + :param callback_server: callback server parameters or a boolean indicating if a + callback server is wanted + :param javaopts: options passed to Java itself + :param classpath: path or iterable of paths to pass to the JVM launcher as the + 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) + ) + self.javaopts = list(javaopts) + + # Substitute package names with their absolute directory paths + self.classpath = ( + classpath if isinstance(classpath, str) else os.pathsep.join(classpath) + ) + for match in package_re.finditer(classpath): + pkgname = match.group(1) + module = import_module(pkgname) + try: + module_dir = os.path.dirname(cast(str, module.__file__)) + except AttributeError: + raise ValueError( + f"Cannot determine the file system path of package {pkgname}, as " + f"it has no __file__ attribute" + ) from None + + self.classpath = self.classpath.replace(match.group(0), module_dir) + + if isinstance(gateway, GatewayParameters): + self.gateway_params = gateway + else: + if gateway is None: + gateway = {} + + gateway.setdefault("eager_load", True) + gateway.setdefault("auto_convert", True) + self.gateway_params = GatewayParameters(**gateway) + + if isinstance(callback_server, dict): + self.callback_server_params = CallbackServerParameters(**callback_server) + elif callback_server is True: + self.callback_server_params = CallbackServerParameters() + else: + self.callback_server_params = callback_server + + @context_teardown + async def start(self, ctx: Context) -> AsyncGenerator[None, Exception | None]: + if self.launch_jvm: + self.gateway_params.port = launch_gateway( + classpath=self.classpath, javaopts=self.javaopts + ) + + gateway = JavaGateway( + 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, + ) + + yield + + if self.launch_jvm: + gateway.shutdown() + else: + gateway.close() + + logger.info("Py4J gateway (%s) shut down", self.resource_name) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5c53fe0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" diff --git a/tests/test_component.py b/tests/test_component.py index cfc5fee..59b0c61 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,73 +1,106 @@ +from __future__ import annotations + +import logging import os +from typing import Any +import asphalt.py4j import pytest -from py4j.java_gateway import JavaGateway, CallbackServerParameters, GatewayParameters - from asphalt.core.context import Context from asphalt.py4j.component import Py4JComponent - - -@pytest.mark.asyncio -async def test_default_gateway(caplog): +from py4j.java_gateway import CallbackServerParameters, GatewayParameters, JavaGateway +from pytest import LogCaptureFixture + + +@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: """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().start(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 = [ + 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("Configured Py4J gateway " - "(default / ctx.java; address=127.0.0.1, port=") - assert records[1].message == 'Py4J gateway (default) shut down' + 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" -def test_gateway_params(): - """Test that a GatewayParameters instance is used as is.""" - params = GatewayParameters() - launch_jvm, gw_params, *rest = Py4JComponent.configure_gateway(False, params) - assert gw_params is params +def test_bad_classpath_entry() -> None: + """Test that a built-in package in the classpath results in a proper ValueError.""" + with pytest.raises(ValueError): + Py4JComponent(classpath=["{builtins}"]) -@pytest.mark.parametrize('params', [ - CallbackServerParameters('1.2.3.4', 5678), - {'address': '1.2.3.4', 'port': 5678} -], ids=['object', 'dict']) -def test_callback_server_params(params): +def test_gateway_params() -> None: + """Test that a GatewayParameters instance is used as-is.""" + params = GatewayParameters() + component = Py4JComponent(gateway=params) + assert component.gateway_params is params + + +@pytest.mark.parametrize( + "params", + [ + pytest.param(CallbackServerParameters("1.2.3.4", 5678), id="object"), + pytest.param({"address": "1.2.3.4", "port": 5678}, id="dict"), + ], +) +def test_callback_server_params( + params: CallbackServerParameters | dict[str, Any], +) -> None: """ - Test that the callback server parameters can be given as both a CallbackServerParameters - instance or a dict. + Test that the callback server parameters can be given as both a + CallbackServerParameters instance or a dict. """ - params = Py4JComponent.configure_gateway(callback_server=params)[-3] - assert params.address == '1.2.3.4' - assert params.port == 5678 + component = Py4JComponent(callback_server=params) + assert component.callback_server_params.address == "1.2.3.4" + assert component.callback_server_params.port == 5678 -def test_classpath_pkgname_substitution(): +def test_classpath_pkgname_substitution() -> None: """ - Test that package names in the class path are substituted with the corresponding absolute - directory paths. + Test that package names in the class path are substituted with the corresponding + absolute directory paths. """ - import asphalt.py4j - classpath = Py4JComponent.configure_gateway(classpath='{asphalt.py4j}/javadir/*')[-2] - assert classpath == '%s/javadir/*' % os.path.dirname(asphalt.py4j.__file__) - assert classpath.endswith(os.path.join('asphalt', 'py4j', 'javadir', '*')) + component = Py4JComponent(classpath="{asphalt.py4j}/javadir/*") + assert component.classpath == f"{os.path.dirname(asphalt.py4j.__file__)}/javadir/*" + assert component.classpath.endswith(os.path.join("asphalt", "py4j", "javadir", "*")) -@pytest.mark.asyncio -async def test_callback_server(): - """Test that the gateway's callback server works when enabled in the configuration.""" +@pytest.mark.anyio +async def test_callback_server() -> None: + """ + Test that the gateway's callback server works when enabled in the configuration. + """ + class NumberCallable: - def call(self): + def call(self) -> int: return 7 class Java: - implements = ['java.util.concurrent.Callable'] + implements = ["java.util.concurrent.Callable"] async with Context() as context: await Py4JComponent(callback_server=True).start(context) - executor = context.java.jvm.java.util.concurrent.Executors.newFixedThreadPool(1) + gateway = context.require_resource(JavaGateway) + executor = gateway.jvm.java.util.concurrent.Executors.newFixedThreadPool(1) try: future = executor.submit(NumberCallable()) assert future.get() == 7 @@ -75,40 +108,20 @@ class Java: executor.shutdown() -@pytest.mark.asyncio -async def test_multiple_gateways(caplog): - """Test that a multiple gateway configuration works as intended.""" - async with Context() as context: - await Py4JComponent(gateways={ - 'java1': {}, - 'java2': {} - }).start(context) - assert isinstance(context.java1, JavaGateway) - assert isinstance(context.java2, JavaGateway) - - records = [record for record in caplog.records if record.name == 'asphalt.py4j.component'] - records.sort(key=lambda r: r.message) - assert len(records) == 4 - assert records[0].message.startswith("Configured Py4J gateway " - "(java1 / ctx.java1; address=127.0.0.1, port=") - assert records[1].message.startswith("Configured Py4J gateway " - "(java2 / ctx.java2; address=127.0.0.1, port=") - assert records[2].message == 'Py4J gateway (java1) shut down' - assert records[3].message == 'Py4J gateway (java2) shut down' - - -@pytest.mark.asyncio -async def test_gateway_close(): +@pytest.mark.anyio +async def test_gateway_close() -> None: """ - Test that shutting down the context does not shut down the Java side gateway if launch_jvm was - False. + Test that shutting down the context does not shut down the Java side gateway if + launch_jvm was False. """ gateway = JavaGateway.launch_gateway() async with Context() as context: - await Py4JComponent(gateway={'port': gateway.gateway_parameters.port}, - launch_jvm=False).start(context) - context.java.jvm.java.lang.System.setProperty('TEST_VALUE', 'abc') + await Py4JComponent( + gateway={"port": gateway.gateway_parameters.port}, launch_jvm=False + ).start(context) + gateway2 = context.require_resource(JavaGateway) + gateway2.jvm.java.lang.System.setProperty("TEST_VALUE", "abc") - assert gateway.jvm.java.lang.System.getProperty('TEST_VALUE') == 'abc' + assert gateway.jvm.java.lang.System.getProperty("TEST_VALUE") == "abc" gateway.shutdown() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index fa5a73b..0000000 --- a/tox.ini +++ /dev/null @@ -1,21 +0,0 @@ -[tox] -envlist = py35, py36, flake8 - -[travis] -python = - 3.5: py35, flake8, docs - 3.6: py36 - -[testenv] -extras = testing -commands = python -m pytest {posargs} - -[testenv:docs] -deps = -rdocs/requirements.txt -commands = python setup.py build_sphinx {posargs} -usedevelop = true - -[testenv:flake8] -deps = flake8 -commands = flake8 asphalt tests -skip_install = true