diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index affc833710..7c163578ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,6 +52,12 @@ repos: # Check and update the uv lockfile - id: uv-lock +- repo: https://github.com/kynan/nbstripout + rev: 0.8.1 + hooks: + - id: nbstripout + files: \.ipynb$ + - repo: local hooks: diff --git a/docs/source/howto/interact.rst b/docs/source/howto/interact.rst index ab6c8c6d85..7177a06e60 100644 --- a/docs/source/howto/interact.rst +++ b/docs/source/howto/interact.rst @@ -163,6 +163,43 @@ It is also possible to run ``verdi`` commands inside the notebook, for example: %verdi status +Running AiiDA engine processes in notebooks +------------------------------------------- + +AiiDA supports running engine processes (such as calculation functions and work chains) directly in Jupyter notebooks. +When :meth:`~aiida.manage.configuration.load_profile` is called inside a Jupyter notebook, AiiDA automatically sets up the necessary infrastructure to allow synchronous process execution within the notebook's event loop. + +.. important:: + + ``load_profile()`` must be called in a **separate cell** before any AiiDA engine processes can be executed. + The setup takes effect from the **next cell** after loading the profile. + +For example, load the profile in the first cell: + +.. code-block:: ipython + + In [1]: from aiida import load_profile + ...: load_profile() + +Then, in a subsequent cell, you can run engine processes as usual: + +.. code-block:: ipython + + In [2]: from aiida.engine import calcfunction + ...: from aiida import orm + ...: + ...: @calcfunction + ...: def add(x, y): + ...: return orm.Int(x.value + y.value) + ...: + ...: result = add(orm.Int(3), orm.Int(4)) + ...: print(result) + +.. warning:: + + Attempting to run engine processes in the **same cell** where ``load_profile()`` is called will raise an error. + Always ensure the profile is loaded in a separate cell. + .. _how-to:interact-restapi: diff --git a/docs/source/tutorials/basic.md b/docs/source/tutorials/basic.md index e490fa13f7..8c14114cb5 100644 --- a/docs/source/tutorials/basic.md +++ b/docs/source/tutorials/basic.md @@ -34,6 +34,10 @@ If you are working on your own machine, note that the tutorial assumes that you If this is not the case, consult the {ref}`getting started page`. ::: +:::{important} +If you are running this tutorial in a Jupyter notebook, make sure to call `load_profile()` in a **separate cell** before running any AiiDA engine processes (e.g. calculation functions or work chains). +::: + :::{tip} This tutorial can be downloaded and run as a Jupyter Notebook: {nb-download}`basic.ipynb` {octicon}`download` ::: diff --git a/environment.yml b/environment.yml index 62aa69f83f..11ab953543 100644 --- a/environment.yml +++ b/environment.yml @@ -12,15 +12,15 @@ dependencies: - circus~=0.19.0 - click-spinner~=0.1.8 - click<8.3,>=8.1.0 -- disk-objectstore~=1.4.0 +- disk-objectstore~=1.5.0 - docstring_parser - get-annotations~=0.1 - python-graphviz~=0.19 -- plumpy~=0.25.0 +- plumpy~=0.26.0 - ipython>=7.6 - jedi<0.19 - jinja2~=3.0 -- kiwipy[rmq]~=0.8.4 +- kiwipy[rmq]~=0.9.0 - importlib-metadata~=6.0 - numpy<3,>=1.21 - paramiko~=3.0 diff --git a/pyproject.toml b/pyproject.toml index 496737aa11..3b9b312d19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,15 +35,15 @@ dependencies = [ 'circus~=0.19.0', 'click-spinner~=0.1.8', 'click>=8.1.0,<8.3', - 'disk-objectstore~=1.4.0', + 'disk-objectstore~=1.5.0', 'docstring-parser', 'get-annotations~=0.1;python_version<"3.10"', 'graphviz~=0.19', - 'plumpy~=0.25.0', + 'plumpy~=0.26.0', 'ipython>=7.6', 'jedi<0.19', 'jinja2~=3.0', - 'kiwipy[rmq]~=0.8.4', + 'kiwipy[rmq]~=0.9.0', 'importlib-metadata~=6.0', 'numpy>=1.21,<3', 'paramiko~=3.0', @@ -274,6 +274,9 @@ ssh_kerberos = [ tests = [ 'aiida-core[atomic_tools,rest]', 'aiida-export-migration-tests==0.9.0', + 'ipykernel~=6.9', + 'nbclient~=0.10', + 'nbformat~=5.10', 'pg8000~=1.13', 'pgtest~=1.3,>=1.3.1', 'pytest~=7.0', @@ -413,7 +416,6 @@ filterwarnings = [ 'ignore:The `Code` class is deprecated.*:aiida.common.warnings.AiidaDeprecationWarning', # https://github.com/aiidateam/plumpy/issues/283 'ignore:There is no current event loop:DeprecationWarning:plumpy', - 'ignore:There is no current event loop:DeprecationWarning:nest_asyncio', # spglib deprecation 'ignore:dict interface is deprecated:DeprecationWarning', # https://github.com/aiidateam/archive-path/issues/21 diff --git a/src/aiida/engine/processes/functions.py b/src/aiida/engine/processes/functions.py index 08441cddcc..10840968eb 100644 --- a/src/aiida/engine/processes/functions.py +++ b/src/aiida/engine/processes/functions.py @@ -601,7 +601,9 @@ async def run(self) -> 'ExitCode' | None: # The remaining inputs have to be keyword arguments. kwargs.update(**inputs) - result = self._func(*args, **kwargs) + from plumpy import run_with_portal + + result = await run_with_portal(self._func, *args, **kwargs) if result is None or isinstance(result, ExitCode): # type: ignore[redundant-expr] return result # type: ignore[unreachable] diff --git a/src/aiida/engine/processes/futures.py b/src/aiida/engine/processes/futures.py index 096c11b277..09a9428aa1 100644 --- a/src/aiida/engine/processes/futures.py +++ b/src/aiida/engine/processes/futures.py @@ -12,6 +12,7 @@ from typing import Optional, Union import kiwipy +from plumpy import get_or_create_event_loop from aiida.orm import Node, load_node @@ -43,7 +44,7 @@ def __init__( from .process import ProcessState # create future in specified event loop - loop = loop if loop is not None else asyncio.get_event_loop() + loop = loop if loop is not None else get_or_create_event_loop() super().__init__(loop=loop) assert not (poll_interval is None and communicator is None), 'Must poll or have a communicator to use' diff --git a/src/aiida/engine/processes/process.py b/src/aiida/engine/processes/process.py index b16bd95826..c83e748f33 100644 --- a/src/aiida/engine/processes/process.py +++ b/src/aiida/engine/processes/process.py @@ -40,6 +40,7 @@ import plumpy.persistence import plumpy.processes from kiwipy.communications import UnroutableError +from plumpy import run_until_complete from plumpy.process_states import Finished, ProcessState from plumpy.processes import ConnectionClosed # type: ignore[attr-defined] from plumpy.processes import Process as PlumpyProcess @@ -361,7 +362,7 @@ def kill(self, msg_text: str | None = None, force_kill: bool = False) -> Union[b coro = self._launch_task(task_kill_job, self.node, self.runner.transport) self._cancelling_scheduler_job = asyncio.create_task(coro) try: - self.loop.run_until_complete(self._cancelling_scheduler_job) + run_until_complete(self.loop, self._cancelling_scheduler_job) except Exception as exc: self.node.logger.error(f'While cancelling the scheduler job an error was raised: {exc}') return False diff --git a/src/aiida/engine/processes/workchains/workchain.py b/src/aiida/engine/processes/workchains/workchain.py index 4b847722a0..bb8ac8e94b 100644 --- a/src/aiida/engine/processes/workchains/workchain.py +++ b/src/aiida/engine/processes/workchains/workchain.py @@ -15,6 +15,7 @@ import logging import typing as t +from plumpy import run_with_portal from plumpy.persistence import auto_persist from plumpy.process_states import Continue, Wait from plumpy.processes import ProcessStateMachineMeta @@ -299,7 +300,7 @@ def _update_process_status(self) -> None: @Protect.final async def run(self) -> t.Any: self._stepper = self.spec().get_outline().create_stepper(self) # type: ignore[arg-type] - return self._do_step() + return await run_with_portal(self._do_step) def _do_step(self) -> t.Any: """Execute the next step in the outline and return the result. diff --git a/src/aiida/engine/runners.py b/src/aiida/engine/runners.py index b19821b2e7..36d35b1139 100644 --- a/src/aiida/engine/runners.py +++ b/src/aiida/engine/runners.py @@ -19,8 +19,9 @@ from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Type, Union import kiwipy +from plumpy import run_until_complete from plumpy.communications import wrap_communicator -from plumpy.events import reset_event_loop_policy, set_event_loop_policy +from plumpy.events import get_or_create_event_loop from plumpy.persistence import Persister from plumpy.process_comms import RemoteProcessThreadController @@ -81,8 +82,7 @@ def __init__( broker_submit and persister is None ), 'Must supply a persister if you want to submit using communicator' - set_event_loop_policy() - self._loop = loop if loop is not None else asyncio.get_event_loop() + self._loop = loop if loop else get_or_create_event_loop() self._poll_interval = poll_interval self._broker_submit = broker_submit self._transport = transports.TransportQueue(self._loop) @@ -156,8 +156,9 @@ def stop(self) -> None: def run_until_complete(self, future: asyncio.Future) -> Any: """Run the loop until the future has finished and return the result.""" + with utils.loop_scope(self._loop): - return self._loop.run_until_complete(future) + return run_until_complete(self._loop, future) def close(self) -> None: """Close the runner by stopping the loop.""" @@ -165,7 +166,6 @@ def close(self) -> None: self.stop() if not self._loop.is_running(): self._loop.close() - reset_event_loop_policy() self._closed = True def instantiate_process(self, process: TYPE_RUN_PROCESS, **inputs): diff --git a/src/aiida/engine/transports.py b/src/aiida/engine/transports.py index e71b4ab102..2c19b2489b 100644 --- a/src/aiida/engine/transports.py +++ b/src/aiida/engine/transports.py @@ -15,6 +15,8 @@ import traceback from typing import TYPE_CHECKING, AsyncIterator, Awaitable, Dict, Hashable, Optional +from plumpy import get_or_create_event_loop + from aiida.orm import AuthInfo if TYPE_CHECKING: @@ -44,8 +46,8 @@ class TransportQueue: """ def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None): - """:param loop: An asyncio event, will use `asyncio.get_event_loop()` if not supplied""" - self._loop = loop if loop is not None else asyncio.get_event_loop() + """:param loop: An asyncio event, will use `get_or_create_event_loop()` if not supplied""" + self._loop = loop if loop else get_or_create_event_loop() self._transport_requests: Dict[Hashable, TransportRequest] = {} @property @@ -67,6 +69,15 @@ async def transport_task(transport_queue, authinfo): :param authinfo: The authinfo to be used to get transport :return: A future that can be yielded to give the transport """ + + from plumpy import ensure_portal + + # NOTE: We need to ensure the portal here only because + # our scheduler has only a sync interface and _get_jobs_from_scheduler is using that + # if we ever provide a fully async scheduler interface then we can remove this here + # An issue is opened to reference this https://github.com/aiidateam/aiida-core/issues/7222 + await ensure_portal() + open_callback_handle = None transport_request = self._transport_requests.get(authinfo.pk, None) diff --git a/src/aiida/engine/utils.py b/src/aiida/engine/utils.py index 5fc4335aa4..b8d7cb312e 100644 --- a/src/aiida/engine/utils.py +++ b/src/aiida/engine/utils.py @@ -17,6 +17,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator, List, Optional, Tuple, Type, Union +from plumpy import get_or_create_event_loop + if TYPE_CHECKING: from aiida.orm import ProcessNode @@ -125,10 +127,10 @@ def interruptable_task( """Turn the given coroutine into an interruptable task by turning it into an InterruptableFuture and returning it. :param coro: the coroutine that should be made interruptable with object of InterutableFuture as last paramenter - :param loop: the event loop in which to run the coroutine, by default uses asyncio.get_event_loop() + :param loop: the event loop in which to run the coroutine, by default uses get_or_create_event_loop() :return: an InterruptableFuture """ - loop = loop or asyncio.get_event_loop() + loop = loop or get_or_create_event_loop() future = InterruptableFuture() async def execute_coroutine(): @@ -252,7 +254,7 @@ def loop_scope(loop) -> Iterator[None]: :param loop: The event loop to make current for the duration of the scope """ - current = asyncio.get_event_loop() + current = get_or_create_event_loop() try: asyncio.set_event_loop(loop) diff --git a/src/aiida/manage/manager.py b/src/aiida/manage/manager.py index 26655a51f6..97a18b656e 100644 --- a/src/aiida/manage/manager.py +++ b/src/aiida/manage/manager.py @@ -145,9 +145,58 @@ def load_profile(self, profile: Union[None, str, 'Profile'] = None, allow_switch # Check whether a development version is being run. Note that needs to be called after ``configure_logging`` # because this function relies on the logging being properly configured for the warning to show. self.check_version() + self._setup_event_loop_in_ipython() return self._profile + def _setup_event_loop_in_ipython(self) -> None: + """Monkey-patch ``IPythonKernel.do_execute`` to ensure a portal. + + When running inside an environment with an already-running event loop + (e.g. a Jupyter notebook kernel), this patches the kernel's + ``do_execute`` to open a portal before executing each cell. + The portal is opended on whichever asyncio task ipykernel uses for that cell. + + The patch takes effect from the **next cell**. + ``load_profile()`` must therefore be called in a separate cell before + synchronous process execution. + + This is a no-op if no event loop is running (scripts, CLI, daemon). + """ + import asyncio + + try: + asyncio.get_running_loop() + except RuntimeError: + return # No running loop — not in Jupyter, nothing to do + + self._patch_kernel_do_execute() + + def _patch_kernel_do_execute(self) -> None: + """Patch ``IPythonKernel.do_execute`` to ensure a portal before each cell.""" + try: + from ipykernel.ipkernel import IPythonKernel + + if getattr(IPythonKernel, '_aiida_portal_patched', False): + return # Already patched + + from plumpy import ensure_portal + + _orig_do_execute = IPythonKernel.do_execute + + async def _patched_do_execute(self, code, silent, *args, **kwargs): + await ensure_portal() + return await _orig_do_execute(self, code, silent, *args, **kwargs) + + IPythonKernel.do_execute = _patched_do_execute # type: ignore[method-assign] + IPythonKernel._aiida_portal_patched = True # type: ignore[attr-defined] + self.logger.debug( + 'Patched IPythonKernel.do_execute for portal. ' + 'This should occur in a Jupyter kernel, and only once per kernel session.' + ) + except Exception: + self.logger.debug('Could not patch IPythonKernel for portal.', exc_info=True) + def reset_profile(self) -> None: """Close and reset any associated resources for the current profile.""" self.reset_broker() diff --git a/src/aiida/manage/tests/pytest_fixtures.py b/src/aiida/manage/tests/pytest_fixtures.py index 8af0fe0350..465da49a21 100644 --- a/src/aiida/manage/tests/pytest_fixtures.py +++ b/src/aiida/manage/tests/pytest_fixtures.py @@ -409,7 +409,7 @@ def clear_database_before_test_class(aiida_profile): @pytest.fixture(scope='function') def temporary_event_loop(): """Create a temporary loop for independent test case""" - current = asyncio.get_event_loop() + current = plumpy.get_or_create_event_loop() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: diff --git a/src/aiida/repository/backend/disk_object_store.py b/src/aiida/repository/backend/disk_object_store.py index 9f9eaafc37..fb4ab2adf5 100644 --- a/src/aiida/repository/backend/disk_object_store.py +++ b/src/aiida/repository/backend/disk_object_store.py @@ -114,7 +114,7 @@ def open(self, key: str) -> t.Iterator[t.BinaryIO]: yield t.cast(t.BinaryIO, handle) def iter_object_streams(self, keys: t.Iterable[str]) -> t.Iterator[t.Tuple[str, t.BinaryIO]]: - with self._container.get_objects_stream_and_meta(keys) as triplets: # type: ignore[arg-type] + with self._container.get_objects_stream_and_meta(keys) as triplets: for key, stream, _ in triplets: assert stream is not None yield key, stream # type: ignore[misc] @@ -199,7 +199,7 @@ def maintain( if not dry_run: with get_progress_reporter()(total=1) as progress: callback = create_callback(progress) - container.pack_all_loose(compress=compress_mode, callback=callback) # type: ignore[arg-type] + container.pack_all_loose(compress=compress_mode, callback=callback) if do_repack: files_numb = container.count_objects().packed @@ -208,7 +208,7 @@ def maintain( if not dry_run: with get_progress_reporter()(total=1) as progress: callback = create_callback(progress) - container.repack(callback=callback) # type: ignore[arg-type] + container.repack(callback=callback) if clean_storage: logger.report(f'Cleaning the repository database (with `vacuum={do_vacuum}`) ...') diff --git a/src/aiida/transports/transport.py b/src/aiida/transports/transport.py index e60034bf33..5f02c59c8d 100644 --- a/src/aiida/transports/transport.py +++ b/src/aiida/transports/transport.py @@ -1883,12 +1883,13 @@ class AsyncTransport(Transport): """ def run_command_blocking(self, func, *args, **kwargs): - """The event loop must be the one of manager.""" + """Run an async transport method synchronously.""" + from plumpy import run_until_complete from aiida.manage import get_manager - loop = get_manager().get_runner() - return loop.run_until_complete(func(*args, **kwargs)) + loop = get_manager().get_runner().loop + return run_until_complete(loop, func(*args, **kwargs)) def open(self): return self.run_command_blocking(self.open_async) diff --git a/tests/conftest.py b/tests/conftest.py index b93feaf99f..5ef9f0cd8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,12 @@ class TestDbBackend(Enum): PSQL = 'psql' +@pytest.fixture(autouse=True) +def _reset_runner(request): + yield + get_manager().reset_runner() + + def pytest_collection_modifyitems(items, config): """Automatically generate markers for certain tests. diff --git a/tests/engine/test_manager.py b/tests/engine/test_manager.py index c4ffdab225..cd538de78a 100644 --- a/tests/engine/test_manager.py +++ b/tests/engine/test_manager.py @@ -12,6 +12,7 @@ import time import pytest +from plumpy import get_or_create_event_loop from aiida.engine.processes.calcjobs.manager import JobManager, JobsList from aiida.engine.transports import TransportQueue @@ -24,7 +25,7 @@ class TestJobManager: @pytest.fixture(autouse=True) def init_profile(self, aiida_localhost): """Initialize the profile.""" - self.loop = asyncio.get_event_loop() + self.loop = get_or_create_event_loop() self.transport_queue = TransportQueue(self.loop) self.user = User.collection.get_default() self.computer = aiida_localhost @@ -54,7 +55,7 @@ class TestJobsList: @pytest.fixture(autouse=True) def init_profile(self, aiida_localhost): """Initialize the profile.""" - self.loop = asyncio.get_event_loop() + self.loop = get_or_create_event_loop() self.transport_queue = TransportQueue(self.loop) self.user = User.collection.get_default() self.computer = aiida_localhost diff --git a/tests/engine/test_utils.py b/tests/engine/test_utils.py index fe18020445..151e0b76db 100644 --- a/tests/engine/test_utils.py +++ b/tests/engine/test_utils.py @@ -12,6 +12,7 @@ import contextlib import pytest +from plumpy import get_or_create_event_loop from aiida import orm from aiida.engine import calcfunction, workfunction @@ -43,7 +44,7 @@ def test_exp_backoff_success(): """Test that exponential backoff will successfully catch exceptions as long as max_attempts is not exceeded.""" global ITERATION # noqa: PLW0603 ITERATION = 0 - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() async def coro(): """A function that will raise RuntimeError as long as ITERATION is smaller than MAX_ITERATIONS.""" @@ -59,7 +60,7 @@ def test_exp_backoff_max_attempts_exceeded(self): """Test that exponential backoff will finally raise if max_attempts is exceeded""" global ITERATION # noqa: PLW0603 ITERATION = 0 - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() def coro(): """A function that will raise RuntimeError as long as ITERATION is smaller than MAX_ITERATIONS.""" @@ -103,7 +104,7 @@ class TestInterruptable: def test_normal_future(self): """Test interrupt future not being interrupted""" - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() interruptable = InterruptableFuture() fut = asyncio.Future() @@ -117,7 +118,7 @@ async def task(): def test_interrupt(self): """Test interrupt future being interrupted""" - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() interruptable = InterruptableFuture() loop.call_soon(interruptable.interrupt, RuntimeError('STOP')) @@ -132,7 +133,7 @@ def test_interrupt(self): def test_inside_interrupted(self): """Test interrupt future being interrupted from inside of coroutine""" - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() interruptable = InterruptableFuture() fut = asyncio.Future() @@ -154,7 +155,7 @@ async def task(): def test_interruptable_future_set(self): """Test interrupt future being set before coroutine is done""" - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() interruptable = InterruptableFuture() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/notebook/__init__.py b/tests/integration/notebook/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/notebook/test_magic_cells.ipynb b/tests/integration/notebook/test_magic_cells.ipynb new file mode 100644 index 0000000000..0c693bb979 --- /dev/null +++ b/tests/integration/notebook/test_magic_cells.ipynb @@ -0,0 +1,120 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test 2: Magic cells after load_profile\n", + "\n", + "This tests that `%%bash`, `!echo`, `%who` etc. work after the greenback patch is installed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "echo \"Hello from bash!\"\n", + "echo \"This should work fine.\"\n", + "date" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aiida import load_profile\n", + "from aiida.storage.sqlite_temp import SqliteTempBackend\n", + "\n", + "profile = load_profile(\n", + " SqliteTempBackend.create_profile(\n", + " 'test-magic',\n", + " options={'warnings.development_version': False, 'runner.poll.interval': 1},\n", + " debug=False,\n", + " ),\n", + " allow_switch=True,\n", + ")\n", + "print('Profile loaded:', profile)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shell escape with !" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!echo \"Shell escape works!\"\n", + "!ls -la /tmp | head -3" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Line magic %who" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x = 42\n", + "y = 'hello'\n", + "%who" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Verify greenback portal is still active after all that" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import greenback\n", + "\n", + "print(f'Portal active: {greenback.has_portal()}')\n", + "assert greenback.has_portal(), 'Portal should still be active!'" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/integration/notebook/test_notebook_integration.py b/tests/integration/notebook/test_notebook_integration.py new file mode 100644 index 0000000000..f8a77e1b9e --- /dev/null +++ b/tests/integration/notebook/test_notebook_integration.py @@ -0,0 +1,62 @@ +"""Tests execution of Jupyter notebooks in a real kernel and verify outcomes.""" + +from pathlib import Path + +import nbformat +import pytest +from nbclient import NotebookClient +from nbclient.exceptions import CellExecutionError + +NOTEBOOK_DIR = Path(__file__).parent + + +def _execute_notebook(notebook_name, timeout=120): + """Execute a notebook and return the executed notebook object.""" + path = NOTEBOOK_DIR / notebook_name + nb = nbformat.read(path, as_version=4) + client = NotebookClient(nb, timeout=timeout, kernel_name='python3') + client.execute() + return nb + + +def _get_cell_outputs(cell, output_type='stream', name='stdout'): + """Extract text from cell outputs of a given type.""" + texts = [] + for output in cell.outputs: + if output.output_type == output_type and output.get('name') == name: + texts.append(output.text) + return ''.join(texts) + + +@pytest.mark.timeout(180) +def test_same_cell_fails_with_expected_error(): + """Test that load_profile + engine call in the same cell fails with a clear error.""" + path = NOTEBOOK_DIR / 'test_same_cell.ipynb' + nb = nbformat.read(path, as_version=4) + client = NotebookClient(nb, timeout=120, kernel_name='python3') + + with pytest.raises(CellExecutionError) as exc_info: + client.execute() + + code_cell = nb.cells[1] # Cell index 1, after the markdown cell + stdout = _get_cell_outputs(code_cell) + assert 'Profile loaded' in stdout + + error_message = str(exc_info.value) + # Note: this error message is from plumpy and is not ideal, + # but we have to ensure it contains the key information about the nature of the error and how to fix it. + assert 'RuntimeError' in error_message + assert 'event loop is running but no greenback portal' in error_message + assert 'If running in a Jupyter notebook, call load_profile() in a prior cell' in error_message + + +@pytest.mark.timeout(180) +def test_separate_cell_passes(): + """Test that load_profile in one cell and engine call in the next cell works.""" + _execute_notebook('test_separate_cell.ipynb') + + +@pytest.mark.timeout(180) +def test_magic_cells_passes(): + """Test that magic commands work correctly after greenback portal is installed.""" + _execute_notebook('test_magic_cells.ipynb') diff --git a/tests/integration/notebook/test_same_cell.ipynb b/tests/integration/notebook/test_same_cell.ipynb new file mode 100644 index 0000000000..d977e872d6 --- /dev/null +++ b/tests/integration/notebook/test_same_cell.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test 1: load_profile + multiply in separate cells\n", + "\n", + "Tests that `load_profile()` in the same cell while having an engine call in the same cells, raises a proper explanation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aiida import engine, load_profile, orm\n", + "from aiida.storage.sqlite_temp import SqliteTempBackend\n", + "\n", + "profile = load_profile(\n", + " SqliteTempBackend.create_profile(\n", + " 'test-same-cell',\n", + " options={'warnings.development_version': False, 'runner.poll.interval': 1},\n", + " debug=False,\n", + " ),\n", + " allow_switch=True,\n", + ")\n", + "print('Profile loaded')\n", + "\n", + "\n", + "@engine.calcfunction\n", + "def multiply(x, y):\n", + " return x * y\n", + "\n", + "\n", + "# This should fail because load_profile() was not called in a separate cell:\n", + "result = multiply(orm.Int(3), orm.Int(4))\n", + "print(f'3 * 4 = {result}')\n", + "assert result == 12, f'Expected 12, got {result}'\n", + "\n", + "# Verify it also works for subsequent calls\n", + "result2 = multiply(orm.Int(5), orm.Int(6))\n", + "print(f'5 * 6 = {result2}')\n", + "assert result2 == 30" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/integration/notebook/test_separate_cell.ipynb b/tests/integration/notebook/test_separate_cell.ipynb new file mode 100644 index 0000000000..055b46b04b --- /dev/null +++ b/tests/integration/notebook/test_separate_cell.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test 1: load_profile + multiply in separate cells\n", + "\n", + "Tests that `load_profile()` in one cell enables synchronous `multiply(x, y)` in subsequent cells.\n", + "Note: `load_profile()` must be in a **separate cell** — same-cell is not supported." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from aiida import engine, load_profile, orm\n", + "from aiida.storage.sqlite_temp import SqliteTempBackend\n", + "\n", + "profile = load_profile(\n", + " SqliteTempBackend.create_profile(\n", + " 'test-same-cell',\n", + " options={'warnings.development_version': False, 'runner.poll.interval': 1},\n", + " debug=False,\n", + " ),\n", + " allow_switch=True,\n", + ")\n", + "print('Profile loaded')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "@engine.calcfunction\n", + "def multiply(x, y):\n", + " return x * y\n", + "\n", + "\n", + "# This works because load_profile() was called in a separate cell:\n", + "result = multiply(orm.Int(3), orm.Int(4))\n", + "print(f'3 * 4 = {result}')\n", + "assert result == 12, f'Expected 12, got {result}'\n", + "\n", + "# Verify it also works for subsequent calls\n", + "result2 = multiply(orm.Int(5), orm.Int(6))\n", + "print(f'5 * 6 = {result2}')\n", + "assert result2 == 30" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/manage/test_manager.py b/tests/manage/test_manager.py index 3a8f4949cf..08b66cae2f 100644 --- a/tests/manage/test_manager.py +++ b/tests/manage/test_manager.py @@ -32,3 +32,15 @@ def test_disconnect(): assert node.is_finished_ok assert result == 2 manager.reset_profile() # This hangs before timing out + + +def test_kernel_patch_not_applied_outside_notebook(): + """Test that ``_setup_event_loop_in_ipython`` does not patch when no event loop is running.""" + from ipykernel.ipkernel import IPythonKernel + + from aiida.manage.manager import Manager + + manager = Manager() + manager._setup_event_loop_in_ipython() + + assert not getattr(IPythonKernel, '_aiida_portal_patched', False) diff --git a/tests/utils/memory.py b/tests/utils/memory.py index 1d688740ea..2d0adc151c 100644 --- a/tests/utils/memory.py +++ b/tests/utils/memory.py @@ -10,6 +10,7 @@ import asyncio +from plumpy import get_or_create_event_loop from pympler import muppy @@ -25,7 +26,7 @@ def get_instances(classes, delay=0.0): carry, although they may not actually be leaking memory. """ if delay > 0: - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() loop.run_until_complete(asyncio.sleep(delay)) all_objects = muppy.get_objects() # this also calls gc.collect() diff --git a/uv.lock b/uv.lock index ff82b1d777..223a2b0adb 100644 --- a/uv.lock +++ b/uv.lock @@ -107,9 +107,12 @@ pre-commit = [ { name = "flask" }, { name = "flask-cors" }, { name = "flask-restful" }, + { name = "ipykernel" }, { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "matplotlib", version = "3.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "mypy" }, + { name = "nbclient" }, + { name = "nbformat" }, { name = "packaging" }, { name = "pg8000" }, { name = "pgtest" }, @@ -163,8 +166,11 @@ tests = [ { name = "flask" }, { name = "flask-cors" }, { name = "flask-restful" }, + { name = "ipykernel" }, { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "matplotlib", version = "3.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nbclient" }, + { name = "nbformat" }, { name = "pg8000" }, { name = "pgtest" }, { name = "pycifrw" }, @@ -209,7 +215,7 @@ requires-dist = [ { name = "circus", specifier = "~=0.19.0" }, { name = "click", specifier = ">=8.1.0,<8.3" }, { name = "click-spinner", specifier = "~=0.1.8" }, - { name = "disk-objectstore", specifier = "~=1.4.0" }, + { name = "disk-objectstore", specifier = "~=1.5.0" }, { name = "docstring-parser" }, { name = "docutils", marker = "extra == 'tests'", specifier = "~=0.20" }, { name = "flask", marker = "extra == 'rest'", specifier = "~=2.3.3" }, @@ -219,15 +225,18 @@ requires-dist = [ { name = "graphviz", specifier = "~=0.19" }, { name = "gssapi", marker = "extra == 'ssh-kerberos'", specifier = "~=1.6" }, { name = "importlib-metadata", specifier = "~=6.0" }, + { name = "ipykernel", marker = "extra == 'tests'", specifier = "~=6.9" }, { name = "ipython", specifier = ">=7.6" }, { name = "jedi", specifier = "<0.19" }, { name = "jinja2", specifier = "~=3.0" }, { name = "jupyter", marker = "extra == 'notebook'", specifier = "~=1.0" }, { name = "jupyter-client", marker = "extra == 'notebook'", specifier = "~=8.0" }, - { name = "kiwipy", extras = ["rmq"], specifier = "~=0.8.4" }, + { name = "kiwipy", extras = ["rmq"], specifier = "~=0.9.0" }, { name = "matplotlib", marker = "extra == 'atomic-tools'", specifier = "~=3.3,>=3.3.4" }, { name = "mypy", marker = "extra == 'pre-commit'", specifier = "~=1.19.0" }, { name = "myst-nb", marker = "extra == 'docs'", specifier = "~=1.0.0" }, + { name = "nbclient", marker = "extra == 'tests'", specifier = "~=0.10" }, + { name = "nbformat", marker = "extra == 'tests'", specifier = "~=5.10" }, { name = "notebook", marker = "extra == 'notebook'", specifier = "~=6.1,>=6.1.5" }, { name = "numpy", specifier = ">=1.21,<3" }, { name = "packaging", marker = "extra == 'pre-commit'", specifier = "~=23.0" }, @@ -235,7 +244,7 @@ requires-dist = [ { name = "pg8000", marker = "extra == 'tests'", specifier = "~=1.13" }, { name = "pgsu", specifier = "~=0.3.0" }, { name = "pgtest", marker = "extra == 'tests'", specifier = "~=1.3,>=1.3.1" }, - { name = "plumpy", specifier = "~=0.25.0" }, + { name = "plumpy", specifier = "~=0.26.0" }, { name = "pre-commit", marker = "extra == 'pre-commit'", specifier = "~=3.5" }, { name = "psutil", specifier = "~=7.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.0.2,<4" }, @@ -302,15 +311,44 @@ sdist = { url = "https://files.pythonhosted.org/packages/ea/90/1c9c13f2fdfa96602 [[package]] name = "aio-pika" -version = "9.4.3" +version = "9.5.6" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] dependencies = [ - { name = "aiormq" }, - { name = "yarl" }, + { name = "aiormq", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "yarl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/52/fe35c898bce5cc8af839ba786b38f7db8932aac48a67ba8ca7de3b074e07/aio_pika-9.5.6.tar.gz", hash = "sha256:5013f429e1235e1ce8df054a821e0eea140ea9afc94a09725b96590ea2dad001", size = 47308, upload-time = "2025-08-05T14:18:35.949Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/fb/c1cfb7cb98ccd2abdc91e170e7ba0e1e3088b6a9d051e4f2899d3249a231/aio_pika-9.5.6-py3-none-any.whl", hash = "sha256:47b532419185cf1105ae18daa45a5052ff98064915c5e080b2433431fe808193", size = 54303, upload-time = "2025-08-05T14:18:34.62Z" }, +] + +[[package]] +name = "aio-pika" +version = "9.5.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", + "python_full_version == '3.10.*' and sys_platform == 'win32'", + "python_full_version == '3.10.*' and sys_platform != 'win32'", +] +dependencies = [ + { name = "aiormq", marker = "python_full_version >= '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "yarl", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/69/8649bdb97fa1521af3dafe23dbc5debadd4b01abb2850a4d193dae9b0451/aio_pika-9.4.3.tar.gz", hash = "sha256:fd2b1fce25f6ed5203ef1dd554dc03b90c9a46a64aaf758d032d78dc31e5295d", size = 47693, upload-time = "2024-08-13T06:49:09.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/73/8d1020683970de5532b3b01732d75c8bf922a6505fcdad1a9c7c6405242a/aio_pika-9.5.8.tar.gz", hash = "sha256:7c36874115f522bbe7486c46d8dd711a4dbedd67c4e8a8c47efe593d01862c62", size = 47408, upload-time = "2025-11-12T10:37:10.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/66/cad391d83b7266a667c85c826bb6c0d7f68519a0eed7634098c12fb39a4b/aio_pika-9.4.3-py3-none-any.whl", hash = "sha256:f1423d2d5a8b7315d144efe1773763bf687ac17aa1535385982687e9e5ed49bb", size = 53240, upload-time = "2024-08-13T06:49:07.276Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/513971861d845d28160ecb205ae2cfaf618b16918a9cd4e0b832b5360ce7/aio_pika-9.5.8-py3-none-any.whl", hash = "sha256:f4c6cb8a6c5176d00f39fd7431e9702e638449bc6e86d1769ad7548b2a506a8d", size = 54397, upload-time = "2025-11-12T10:37:08.374Z" }, ] [[package]] @@ -1867,16 +1905,16 @@ wheels = [ [[package]] name = "disk-objectstore" -version = "1.4.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/06/2f76aef59917d47e372158580db988bae1d09f73f5a22721cf88cb5b1dd1/disk_objectstore-1.4.0.tar.gz", hash = "sha256:4d9ea7617dd2de1c817255d7c8123f69e106f6daa0d4c0bdd09ab919ddbc49c8", size = 7390473, upload-time = "2025-10-06T08:35:53.392Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e1/6504cb67263efc95310652980aa7ac4ce28451117088bb9f6c440ff2efc0/disk_objectstore-1.5.0.tar.gz", hash = "sha256:1327843630dfec5956c5c92b14001e75aaf12f3014f98fd71e8ab896f1546e2e", size = 7438940, upload-time = "2026-02-26T13:15:26.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/95/fc379912f1c2b336de2af95331da764b6f09e3f8fabc6050cd7b71a6b895/disk_objectstore-1.4.0-py3-none-any.whl", hash = "sha256:e29d33e51a1196468062f30a79126b3d82ef9fcca6a505f7984cdfc8bed72f5e", size = 70636, upload-time = "2025-10-06T08:35:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/765f7ae779de4aabf30fcf362ffa3827d42e43a353f67a90477b9e84177e/disk_objectstore-1.5.0-py3-none-any.whl", hash = "sha256:55dbdaa99340cf127a61d38926ae801a728a7028cc4e487b59a873aa0af9c172", size = 71324, upload-time = "2026-02-26T13:15:25.512Z" }, ] [[package]] @@ -2123,6 +2161,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, ] +[[package]] +name = "greenback" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "outcome" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/3b70d0f03a1e0f48d4f2348de435fa282e5530ae60812fef672cabc40a28/greenback-1.3.0.tar.gz", hash = "sha256:d1441f542ec9c6efb32a9250dd954a5b1cc1eb789294c19b1eb747f49cab818c", size = 8070613, upload-time = "2025-12-23T01:49:33.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/41/a1b338d80775c47f79cd7310d57ad4b98730f0656b15464a57dab821c5bb/greenback-1.3.0-py3-none-any.whl", hash = "sha256:b0a333a35b40f422981ebdeefc7e0a00568f2ac634604d0108cc8c30da9b6252", size = 29079, upload-time = "2025-12-23T01:49:31.81Z" }, +] + [[package]] name = "greenlet" version = "3.2.4" @@ -2354,62 +2406,27 @@ wheels = [ name = "ipykernel" version = "6.31.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] dependencies = [ - { name = "appnope", marker = "python_full_version < '3.10' and sys_platform == 'darwin'" }, - { name = "comm", marker = "python_full_version < '3.10'" }, - { name = "debugpy", marker = "python_full_version < '3.10'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, { name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jupyter-client", marker = "python_full_version < '3.10'" }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.10'" }, - { name = "nest-asyncio", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "psutil", marker = "python_full_version < '3.10'" }, - { name = "pyzmq", marker = "python_full_version < '3.10'" }, - { name = "tornado", marker = "python_full_version < '3.10'" }, - { name = "traitlets", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" }, -] - -[[package]] -name = "ipykernel" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform != 'win32'", - "python_full_version == '3.10.*' and sys_platform == 'win32'", - "python_full_version == '3.10.*' and sys_platform != 'win32'", -] -dependencies = [ - { name = "appnope", marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, - { name = "comm", marker = "python_full_version >= '3.10'" }, - { name = "debugpy", marker = "python_full_version >= '3.10'" }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "ipython", version = "9.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jupyter-client", marker = "python_full_version >= '3.10'" }, + { name = "jupyter-client" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.10'" }, - { name = "nest-asyncio", marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "psutil", marker = "python_full_version >= '3.10'" }, - { name = "pyzmq", marker = "python_full_version >= '3.10'" }, - { name = "tornado", marker = "python_full_version >= '3.10'" }, - { name = "traitlets", marker = "python_full_version >= '3.10'" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" }, ] [[package]] @@ -2665,8 +2682,7 @@ name = "jupyter" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel" }, { name = "ipywidgets" }, { name = "jupyter-console" }, { name = "jupyterlab" }, @@ -2721,8 +2737,7 @@ name = "jupyter-console" version = "6.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel" }, { name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "ipython", version = "9.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -2864,8 +2879,7 @@ dependencies = [ { name = "async-lru" }, { name = "httpx" }, { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel" }, { name = "jinja2" }, { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -2923,21 +2937,22 @@ wheels = [ [[package]] name = "kiwipy" -version = "0.8.5" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "pyyaml" }, { name = "shortuuid" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/c9/60f4597b2f7ce9f1ce9f202c1ddc70b857716597d828fc5baa123a2fa17e/kiwipy-0.8.5.tar.gz", hash = "sha256:23b746f60577120764d662673335cea40cf34083d15f1ee8ab4fa6155b50d60f", size = 41087, upload-time = "2024-12-02T08:19:59.85Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/d1/a56aea1ee27c9aa73c7c6c785f4eb0539799392566b758fc920397afea91/kiwipy-0.9.0.tar.gz", hash = "sha256:3dc5a2cbe4bf7127da2c8a6c20476ddad30849b32fa12b495c622059c633db4f", size = 242121, upload-time = "2025-10-21T05:58:10.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/50/2d180b54272d467a3e5eb4d7e64df80a8bb11d483e908404d71905a2801b/kiwipy-0.8.5-py3-none-any.whl", hash = "sha256:b6acf17ba69fdfc9ce246673efd35e1db06a27b2c624ba1735d2159f8e665a1b", size = 41820, upload-time = "2024-12-02T08:19:58.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/2ed83a3b88048db2504ddd67a419148e23a1cf2bc64ee0bbaacb46c80bbb/kiwipy-0.9.0-py3-none-any.whl", hash = "sha256:8d861310d64dc15de50667c1c7b7295f2dba50a5561284e8a889e3a6c2b197f9", size = 41865, upload-time = "2025-10-21T05:58:09.033Z" }, ] [package.optional-dependencies] rmq = [ - { name = "aio-pika" }, + { name = "aio-pika", version = "9.5.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "aio-pika", version = "9.5.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pamqp" }, { name = "pytray" }, ] @@ -3866,8 +3881,7 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, - { name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel" }, { name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "ipython", version = "9.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3946,8 +3960,7 @@ name = "nbclassic" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel" }, { name = "ipython-genutils" }, { name = "nest-asyncio" }, { name = "notebook-shim" }, @@ -4083,8 +4096,7 @@ version = "6.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argon2-cffi" }, - { name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel" }, { name = "ipython-genutils" }, { name = "jinja2" }, { name = "jupyter-client" }, @@ -4422,6 +4434,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/c7/13bed8834936ddb38a2f366aea9458ebb4fe80c459054e6a0cfbcae68e0d/orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6", size = 131383, upload-time = "2025-10-24T15:50:36.511Z" }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -4899,16 +4923,17 @@ wheels = [ [[package]] name = "plumpy" -version = "0.25.0" +version = "0.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "greenback" }, + { name = "greenlet" }, { name = "kiwipy", extra = ["rmq"] }, - { name = "nest-asyncio" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/b1/7c8141f04fb9060e9de7fd4fafed7ce429a16c1a903675a093d390f14b16/plumpy-0.25.0.tar.gz", hash = "sha256:5eccca0f11757db652b15bfb0bb95dc010a9a5fa000df5f9db51cf6a4d1e682f", size = 198187, upload-time = "2025-05-01T07:48:10.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/19/f3719840ec54880e6cf88f3a88c9ecf3aed0468d3e971339a354e95318f3/plumpy-0.26.0.tar.gz", hash = "sha256:5d0bb443c3983f43295be5306aec97d09e8005a27d1d645deacf08da84afb6e1", size = 230207, upload-time = "2026-02-24T14:38:08.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/9a/e939c60e6376bdcc84ed9ae92ed96312c13781044c08b1b96be036d4b11b/plumpy-0.25.0-py3-none-any.whl", hash = "sha256:f15e7b471185265e6bbd0cfd37b9cd4b5bf51a9019fdd74247d9cfe28c5da617", size = 75249, upload-time = "2025-05-01T07:48:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/1c/44/b6be39f1225b3a741c4e8a2b48039a8e95f514016eb0f5d092722a7c0949/plumpy-0.26.0-py3-none-any.whl", hash = "sha256:07d5558ead4bdc064c6092236e59f4328f70d24b52eb810e913c491841c6336f", size = 75911, upload-time = "2026-02-24T14:38:06.913Z" }, ] [[package]]