From 29c54b59a2906a3315520786814234db63aee194 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Nov 2020 08:16:44 +0100 Subject: [PATCH 001/114] Dependencies: bump cryptography to 3.2 in `requirements` (#4520) Bumps `cryptography` from 2.8 to 3.2. Signed-off-by: dependabot[bot] Co-authored-by: Sebastiaan Huber --- requirements/requirements-py-3.6.txt | 2 +- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- requirements/requirements-py-3.9.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index f3fb98a8cc..4089d6d26c 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -22,7 +22,7 @@ click-config-file==0.6.0 click-spinner==0.1.8 configobj==5.0.6 coverage==4.5.4 -cryptography==2.8 +cryptography==3.2 cycler==0.10.0 dataclasses==0.7 decorator==4.4.2 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 2cb9e68289..4d527da138 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -22,7 +22,7 @@ click-config-file==0.6.0 click-spinner==0.1.8 configobj==5.0.6 coverage==4.5.4 -cryptography==2.8 +cryptography==3.2 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index b83043f2d6..886f9db80d 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -21,7 +21,7 @@ click-config-file==0.6.0 click-spinner==0.1.8 configobj==5.0.6 coverage==4.5.4 -cryptography==2.8 +cryptography==3.2 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 5404a60c2f..07e249f4f0 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -20,7 +20,7 @@ click-config-file==0.6.0 click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.2.1 +cryptography==3.2 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 From 49cd0e7562e9598e63b14538ea03c76ca823468e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 17 Nov 2020 08:15:23 +0100 Subject: [PATCH 002/114] CI: remove `run-on-comment` job in benchmark workflow (#4569) This job is failing due to this change: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/ It's not really used, so lets just remove it --- .github/workflows/benchmark.yml | 76 --------------------------------- 1 file changed, 76 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 450b33d1a8..5f33585d3a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -78,79 +78,3 @@ jobs: comment-on-alert: true fail-on-alert: false alert-comment-cc-users: '@chrisjsewell,@giovannipizzi' - - run-on-comment: - - if: ${{ github.event_name == 'pull_request' }} - - strategy: - matrix: - os: [ubuntu-18.04] - postgres: [12.3] - rabbitmq: [3.8.3] - backend: ['django'] - - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - services: - postgres: - image: "postgres:${{ matrix.postgres }}" - env: - POSTGRES_DB: test_${{ matrix.backend }} - POSTGRES_PASSWORD: '' - POSTGRES_HOST_AUTH_METHOD: trust - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - rabbitmq: - image: "rabbitmq:${{ matrix.rabbitmq }}" - ports: - - 5672:5672 - - steps: - # v2 was checking out the wrong commit! https://github.com/actions/checkout/issues/299 - - uses: actions/checkout@v1 - - - name: get commit message - run: echo ::set-env name=commitmsg::$(git log --format=%B -n 1 "${{ github.event.after }}") - - - if: contains( env.commitmsg , '[run bench]' ) - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.8 - - - if: contains( env.commitmsg , '[run bench]' ) - name: Install python dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/requirements-py-3.8.txt - pip install --no-deps -e . - reentry scan - pip freeze - - - if: contains( env.commitmsg , '[run bench]' ) - name: Run benchmarks - env: - AIIDA_TEST_BACKEND: ${{ matrix.backend }} - run: pytest --benchmark-only --benchmark-json benchmark.json - - - if: contains( env.commitmsg , '[run bench]' ) - name: Compare benchmark results - uses: aiidateam/github-action-benchmark@v3 - with: - output-file-path: benchmark.json - name: "pytest-benchmarks:${{ matrix.os }},${{ matrix.backend }}" - benchmark-data-dir-path: "dev/bench/${{ matrix.os }}/${{ matrix.backend }}" - metadata: "postgres:${{ matrix.postgres }}, rabbitmq:${{ matrix.rabbitmq }}" - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: false - # Show alert with commit comment on detecting possible performance regression - alert-threshold: '200%' - comment-always: true - fail-on-alert: true From 4c8f1b07b9050f85f7b2c2c90caa1df6e78c2225 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Tue, 17 Nov 2020 15:31:56 +0100 Subject: [PATCH 003/114] Docs: update citations with AiiDA workflows paper (#4568) Citation for the latest paper on the engine is added to the README and the documentation index page. The paper in `aiida/__init__.py` is also updated which was still referencing the original publication of 2016. --- README.md | 3 ++- aiida/__init__.py | 8 +++----- docs/source/index.rst | 8 ++++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f04634d30b..82def74c91 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ If you are experiencing problems with your AiiDA installation, please refer to t If you use AiiDA in your research, please consider citing the following publications: * **AiiDA >= 1.0**: S. P. Huber *et al.*, *AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and data provenance*, Scientific Data **7**, 300 (2020); DOI: [10.1038/s41597-020-00638-4](https://doi.org/10.1038/s41597-020-00638-4) - * **AiiDA < 1.0**: Giovanni Pizzi, Andrea Cepellotti, Riccardo Sabatini, Nicola Marzari,and Boris Kozinsky, *AiiDA: automated interactive infrastructure and database for computational science*, Comp. Mat. Sci **111**, 218-230 (2016); DOI: [10.1016/j.commatsci.2015.09.013](https://doi.org/10.1016/j.commatsci.2015.09.013) + * **AiiDA >= 1.0**: M. Uhrin *et al.*, *Workflows in AiiDA: Engineering a high-throughput, event-based engine for robust and modular computational workflows*, Computational Materials Science **187**, 110086 (2021); DOI: [10.1016/j.commatsci.2020.110086](https://doi.org/10.1016/j.commatsci.2020.110086) + * **AiiDA < 1.0**: Giovanni Pizzi, Andrea Cepellotti, Riccardo Sabatini, Nicola Marzari,and Boris Kozinsky, *AiiDA: automated interactive infrastructure and database for computational science*, Computational Materials Science **111**, 218-230 (2016); DOI: [10.1016/j.commatsci.2015.09.013](https://doi.org/10.1016/j.commatsci.2015.09.013) ## License diff --git a/aiida/__init__.py b/aiida/__init__.py index b3568d735a..ad7b487c53 100644 --- a/aiida/__init__.py +++ b/aiida/__init__.py @@ -34,12 +34,10 @@ __version__ = '1.5.0' __authors__ = 'The AiiDA team.' __paper__ = ( - 'G. Pizzi, A. Cepellotti, R. Sabatini, N. Marzari, and B. Kozinsky,' - '"AiiDA: automated interactive infrastructure and database for computational science", ' - 'Comp. Mat. Sci 111, 218-230 (2016); https://doi.org/10.1016/j.commatsci.2015.09.013 ' - '- http://www.aiida.net.' + 'S. P. Huber et al., "AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and ' + 'data provenance", Scientific Data 7, 300 (2020); https://doi.org/10.1038/s41597-020-00638-4' ) -__paper_short__ = 'G. Pizzi et al., Comp. Mat. Sci 111, 218 (2016).' +__paper_short__ = 'S. P. Huber et al., Scientific Data 7, 300 (2020).' def load_dbenv(profile=None): diff --git a/docs/source/index.rst b/docs/source/index.rst index bfc29c1d21..f58610009c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -110,11 +110,11 @@ How to cite If you use AiiDA for your research, please cite the following work: -.. highlights:: **AiiDA >= 1.0:** Sebastiaan. P. Huber, Spyros Zoupanos, Martin Uhrin, Leopold Talirz, Leonid Kahle, Rico Häuselmann, Dominik Gresch, Tiziano Müller, Aliaksandr V. Yakutovich, Casper W. Andersen, Francisco F. Ramirez, Carl S. Adorf, Fernando Gargiulo, Snehal Kumbhar, Elsa Passaro, Conrad Johnston, Andrius Merkys, Andrea Cepellotti, Nicolas Mounet, Nicola Marzari, Boris Kozinsky, Giovanni Pizzi, *AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and data provenance*, Scientific Data **7**, 300 (2020); DOI: [10.1038/s41597-020-00638-4](https://doi.org/10.1038/s41597-020-00638-4) +.. highlights:: **AiiDA >= 1.0:** Sebastiaan. P. Huber, Spyros Zoupanos, Martin Uhrin, Leopold Talirz, Leonid Kahle, Rico Häuselmann, Dominik Gresch, Tiziano Müller, Aliaksandr V. Yakutovich, Casper W. Andersen, Francisco F. Ramirez, Carl S. Adorf, Fernando Gargiulo, Snehal Kumbhar, Elsa Passaro, Conrad Johnston, Andrius Merkys, Andrea Cepellotti, Nicolas Mounet, Nicola Marzari, Boris Kozinsky, and Giovanni Pizzi, *AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and data provenance*, Scientific Data **7**, 300 (2020); DOI: [10.1038/s41597-020-00638-4](https://doi.org/10.1038/s41597-020-00638-4) -.. highlights:: **AiiDA < 1.0:** Giovanni Pizzi, Andrea Cepellotti, Riccardo Sabatini, Nicola Marzari, - and Boris Kozinsky, *AiiDA: automated interactive infrastructure and database - for computational science*, Comp. Mat. Sci 111, 218-230 (2016); DOI: [10.1016/j.commatsci.2015.09.013](https://doi.org/10.1016/j.commatsci.2015.09.013) +.. highlights:: **AiiDA >= 1.0:** Martin Uhrin, Sebastiaan. P. Huber, Jusong Yu, Nicola Marzari, and Giovanni Pizzi, *Workflows in AiiDA: Engineering a high-throughput, event-based engine for robust and modular computational workflows*, Computational Materials Science **187**, 110086 (2021); DOI: [10.1016/j.commatsci.2020.110086](https://doi.org/10.1016/j.commatsci.2020.110086) + +.. highlights:: **AiiDA < 1.0:** Giovanni Pizzi, Andrea Cepellotti, Riccardo Sabatini, Nicola Marzari, and Boris Kozinsky, *AiiDA: automated interactive infrastructure and database for computational science*, Computational Materials Science **111**, 218-230 (2016); DOI: [10.1016/j.commatsci.2015.09.013](https://doi.org/10.1016/j.commatsci.2015.09.013) **************** From f04dbf13ed824f6e5724666d5bc39f7c2bad9cf4 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 17 Nov 2020 22:52:25 +0100 Subject: [PATCH 004/114] Enforce verdi quicksetup --non-interactive (#4573) When in non-interactive mode, do not ask whether to use existing user/database --- aiida/manage/external/postgres.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aiida/manage/external/postgres.py b/aiida/manage/external/postgres.py index c4faff90c1..0a6ff8f937 100644 --- a/aiida/manage/external/postgres.py +++ b/aiida/manage/external/postgres.py @@ -116,6 +116,8 @@ def check_dbuser(self, dbuser): :param str dbuser: Name of the user to be created or reused. :returns: tuple (dbuser, created) """ + if not self.interactive: + return dbuser, not self.dbuser_exists(dbuser) create = True while create and self.dbuser_exists(dbuser): echo.echo_info(f'Database user "{dbuser}" already exists!') @@ -163,6 +165,8 @@ def check_db(self, dbname): :param str dbname: Name of the database to be created or reused. :returns: tuple (dbname, created) """ + if not self.interactive: + return dbname, not self.db_exists(dbname) create = True while create and self.db_exists(dbname): echo.echo_info(f'database {dbname} already exists!') From 1c48d7147584dc3bcb5b8ee9190802fd0b701fe6 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Nov 2020 09:59:17 +0100 Subject: [PATCH 005/114] `SinglefileData`: add support for `pathlib.Path` for `file` argument (#3614) --- aiida/orm/nodes/data/singlefile.py | 3 +- tests/orm/data/test_singlefile.py | 312 ++++++++++++++++------------- 2 files changed, 180 insertions(+), 135 deletions(-) diff --git a/aiida/orm/nodes/data/singlefile.py b/aiida/orm/nodes/data/singlefile.py index 17c03663ed..eecc0484d3 100644 --- a/aiida/orm/nodes/data/singlefile.py +++ b/aiida/orm/nodes/data/singlefile.py @@ -11,6 +11,7 @@ import inspect import os import warnings +import pathlib from aiida.common import exceptions from aiida.common.warnings import AiidaDeprecationWarning @@ -102,7 +103,7 @@ def set_file(self, file, filename=None): """ # pylint: disable=redefined-builtin - if isinstance(file, str): + if isinstance(file, (str, pathlib.Path)): is_filelike = False key = os.path.basename(file) diff --git a/tests/orm/data/test_singlefile.py b/tests/orm/data/test_singlefile.py index 0815749f22..d4cfac3edc 100644 --- a/tests/orm/data/test_singlefile.py +++ b/tests/orm/data/test_singlefile.py @@ -10,151 +10,195 @@ """Tests for the `SinglefileData` class.""" import os -import tempfile import io +import tempfile +import pathlib -from aiida.backends.testbase import AiidaTestCase -from aiida.orm import SinglefileData, load_node - - -class TestSinglefileData(AiidaTestCase): - """Tests for the `SinglefileData` class.""" - - def test_reload_singlefile_data(self): - """Test writing and reloading a `SinglefileData` instance.""" - content_original = 'some text ABCDE' - - with tempfile.NamedTemporaryFile(mode='w+') as handle: - filepath = handle.name - basename = os.path.basename(filepath) - handle.write(content_original) - handle.flush() - node = SinglefileData(file=filepath) - - uuid = node.uuid - - with node.open() as handle: - content_written = handle.read() - - self.assertEqual(node.list_object_names(), [basename]) - self.assertEqual(content_written, content_original) - - node.store() - - with node.open() as handle: - content_stored = handle.read() - - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [basename]) - - node_loaded = load_node(uuid) - self.assertTrue(isinstance(node_loaded, SinglefileData)) - - with node.open() as handle: - content_loaded = handle.read() - - self.assertEqual(content_loaded, content_original) - self.assertEqual(node_loaded.list_object_names(), [basename]) - - with node_loaded.open() as handle: - self.assertEqual(handle.read(), content_original) - - def test_construct_from_filelike(self): - """Test constructing an instance from filelike instead of filepath.""" - content_original = 'some testing text\nwith a newline' - - with tempfile.NamedTemporaryFile(mode='wb+') as handle: - basename = os.path.basename(handle.name) - handle.write(content_original.encode('utf-8')) - handle.flush() - handle.seek(0) - node = SinglefileData(file=handle) - - with node.open() as handle: - content_stored = handle.read() - - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [basename]) - - node.store() - - with node.open() as handle: - content_stored = handle.read() - - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [basename]) - - def test_construct_from_string(self): - """Test constructing an instance from a string.""" - content_original = 'some testing text\nwith a newline' - - with io.BytesIO(content_original.encode('utf-8')) as handle: - node = SinglefileData(file=handle) - - with node.open() as handle: - content_stored = handle.read() - - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [SinglefileData.DEFAULT_FILENAME]) - - node.store() - - with node.open() as handle: - content_stored = handle.read() - - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [SinglefileData.DEFAULT_FILENAME]) - - def test_construct_with_filename(self): - """Test constructing an instance, providing a filename.""" - content_original = 'some testing text\nwith a newline' - filename = 'myfile.txt' +import pytest - # test creating from string - with io.BytesIO(content_original.encode('utf-8')) as handle: - node = SinglefileData(file=handle, filename=filename) +from aiida.orm import SinglefileData, load_node - with node.open() as handle: - content_stored = handle.read() - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [filename]) +@pytest.fixture +def check_singlefile_content(): + """Fixture to check the content of a SinglefileData. - # test creating from file - with tempfile.NamedTemporaryFile(mode='wb+') as handle: - handle.write(content_original.encode('utf-8')) - handle.flush() - handle.seek(0) - node = SinglefileData(file=handle, filename=filename) + Checks the content of a SinglefileData node against the given + reference content and filename. + """ - with node.open() as handle: - content_stored = handle.read() + def inner(node, content_reference, filename, open_mode='r'): + with node.open(mode=open_mode) as handle: + assert handle.read() == content_reference - self.assertEqual(content_stored, content_original) - self.assertEqual(node.list_object_names(), [filename]) + assert node.list_object_names() == [filename] - def test_binary_file(self): - """Test that the constructor accepts binary files.""" - byte_array = [120, 3, 255, 0, 100] - content_binary = bytearray(byte_array) + return inner - with tempfile.NamedTemporaryFile(mode='wb+') as handle: - basename = os.path.basename(handle.name) - handle.write(bytearray(content_binary)) - handle.flush() - handle.seek(0) - node = SinglefileData(handle.name) - with node.open(mode='rb') as handle: - content_stored = handle.read() +@pytest.fixture +def check_singlefile_content_with_store(check_singlefile_content): # pylint: disable=redefined-outer-name + """Fixture to check the content of a SinglefileData before and after .store(). - self.assertEqual(content_stored, content_binary) - self.assertEqual(node.list_object_names(), [basename]) + Checks the content of a SinglefileData node against the given reference + content and filename twice, before and after calling .store(). + """ + def inner(node, content_reference, filename, open_mode='r'): + check_singlefile_content( + node=node, + content_reference=content_reference, + filename=filename, + open_mode=open_mode, + ) node.store() - - with node.open(mode='rb') as handle: - content_stored = handle.read() - - self.assertEqual(content_stored, content_binary) - self.assertEqual(node.list_object_names(), [basename]) + check_singlefile_content( + node=node, + content_reference=content_reference, + filename=filename, + open_mode=open_mode, + ) + + return inner + + +def test_reload_singlefile_data( + clear_database_before_test, # pylint: disable=unused-argument + check_singlefile_content_with_store, # pylint: disable=redefined-outer-name + check_singlefile_content # pylint: disable=redefined-outer-name +): + """Test writing and reloading a `SinglefileData` instance.""" + content_original = 'some text ABCDE' + + with tempfile.NamedTemporaryFile(mode='w+') as handle: + filepath = handle.name + basename = os.path.basename(filepath) + handle.write(content_original) + handle.flush() + node = SinglefileData(file=filepath) + + check_singlefile_content_with_store( + node=node, + content_reference=content_original, + filename=basename, + ) + + node_loaded = load_node(node.uuid) + assert isinstance(node_loaded, SinglefileData) + + check_singlefile_content( + node=node, + content_reference=content_original, + filename=basename, + ) + check_singlefile_content( + node=node_loaded, + content_reference=content_original, + filename=basename, + ) + + +def test_construct_from_filelike( + clear_database_before_test, # pylint: disable=unused-argument + check_singlefile_content_with_store # pylint: disable=redefined-outer-name +): + """Test constructing an instance from filelike instead of filepath.""" + content_original = 'some testing text\nwith a newline' + + with tempfile.NamedTemporaryFile(mode='wb+') as handle: + basename = os.path.basename(handle.name) + handle.write(content_original.encode('utf-8')) + handle.flush() + handle.seek(0) + node = SinglefileData(file=handle) + + check_singlefile_content_with_store( + node=node, + content_reference=content_original, + filename=basename, + ) + + +def test_construct_from_string( + clear_database_before_test, # pylint: disable=unused-argument + check_singlefile_content_with_store # pylint: disable=redefined-outer-name +): + """Test constructing an instance from a string.""" + content_original = 'some testing text\nwith a newline' + + with io.BytesIO(content_original.encode('utf-8')) as handle: + node = SinglefileData(file=handle) + + check_singlefile_content_with_store( + node=node, + content_reference=content_original, + filename=SinglefileData.DEFAULT_FILENAME, + ) + + +def test_construct_with_path( + clear_database_before_test, # pylint: disable=unused-argument + check_singlefile_content_with_store # pylint: disable=redefined-outer-name +): + """Test constructing an instance from a pathlib.Path.""" + content_original = 'please report to the ministry of silly walks' + + with tempfile.NamedTemporaryFile(mode='w+') as handle: + filepath = pathlib.Path(handle.name).resolve() + filename = filepath.name + handle.write(content_original) + handle.flush() + node = SinglefileData(file=filepath) + + check_singlefile_content_with_store( + node=node, + content_reference=content_original, + filename=filename, + ) + + +def test_construct_with_filename( + clear_database_before_test, # pylint: disable=unused-argument + check_singlefile_content # pylint: disable=redefined-outer-name +): + """Test constructing an instance, providing a filename.""" + content_original = 'some testing text\nwith a newline' + filename = 'myfile.txt' + + # test creating from string + with io.BytesIO(content_original.encode('utf-8')) as handle: + node = SinglefileData(file=handle, filename=filename) + + check_singlefile_content(node=node, content_reference=content_original, filename=filename) + + # test creating from file + with tempfile.NamedTemporaryFile(mode='wb+') as handle: + handle.write(content_original.encode('utf-8')) + handle.flush() + handle.seek(0) + node = SinglefileData(file=handle, filename=filename) + + check_singlefile_content(node=node, content_reference=content_original, filename=filename) + + +def test_binary_file( + clear_database_before_test, # pylint: disable=unused-argument + check_singlefile_content_with_store # pylint: disable=redefined-outer-name +): + """Test that the constructor accepts binary files.""" + byte_array = [120, 3, 255, 0, 100] + content_binary = bytearray(byte_array) + + with tempfile.NamedTemporaryFile(mode='wb+') as handle: + basename = os.path.basename(handle.name) + handle.write(bytearray(content_binary)) + handle.flush() + handle.seek(0) + node = SinglefileData(handle.name) + + check_singlefile_content_with_store( + node=node, + content_reference=content_binary, + filename=basename, + open_mode='rb', + ) From d0437c69c2b9e593cfdf211994809f0f44b6632c Mon Sep 17 00:00:00 2001 From: flavianojs Date: Wed, 18 Nov 2020 10:52:02 +0100 Subject: [PATCH 006/114] DOCS: Reverse daemon start and profile setup sections in intro. (#4574) The profile must be setup prior to starting the daemons to avoid an error. --- docs/source/intro/install_system.rst | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/source/intro/install_system.rst b/docs/source/intro/install_system.rst index 7d11b8e6d8..39550fd1eb 100644 --- a/docs/source/intro/install_system.rst +++ b/docs/source/intro/install_system.rst @@ -216,6 +216,23 @@ This is the *recommended* installation method to setup AiiDA on a personal lapto --- + **Setup profile** + + Next, set up an AiiDA configuration profile and related data storage, with the ``verdi quicksetup`` command. + + .. code-block:: console + + (aiida) $ verdi quicksetup + Info: enter "?" for help + Info: enter "!" to ignore the default and set no value + Profile name: me + Email Address (for sharing data): me@user.com + First name: my + Last name: name + Institution: where-i-work + + --- + **Start verdi daemons** Start the verdi daemon(s) that are used to run AiiDA workflows. @@ -234,23 +251,6 @@ This is the *recommended* installation method to setup AiiDA on a personal lapto --- - **Setup profile** - - Next, set up an AiiDA configuration profile and related data storage, with the ``verdi quicksetup`` command. - - .. code-block:: console - - (aiida) $ verdi quicksetup - Info: enter "?" for help - Info: enter "!" to ignore the default and set no value - Profile name: me - Email Address (for sharing data): me@user.com - First name: my - Last name: name - Institution: where-i-work - - --- - **Check setup** To check that everything is set up correctly, execute: From 20996d1801d2e44f46f780fa162b823448bffee8 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 18 Nov 2020 11:37:27 +0100 Subject: [PATCH 007/114] Fix `verdi --version` in editable mode (#4576) This commit fixes a bug, whereby click was using a version statically stored on install of the package. This meant changes to `__version__` were not dynamically reflected. --- aiida/cmdline/commands/cmd_verdi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aiida/cmdline/commands/cmd_verdi.py b/aiida/cmdline/commands/cmd_verdi.py index e9cebf5229..6a395b1185 100644 --- a/aiida/cmdline/commands/cmd_verdi.py +++ b/aiida/cmdline/commands/cmd_verdi.py @@ -12,6 +12,7 @@ import difflib import click +from aiida import __version__ from aiida.cmdline.params import options, types GIU = ( @@ -84,7 +85,9 @@ def get_command(self, ctx, cmd_name): @click.command(cls=MostSimilarCommandGroup, context_settings={'help_option_names': ['-h', '--help']}) @options.PROFILE(type=types.ProfileParamType(load_profile=True)) -@click.version_option(None, '-v', '--version', message='AiiDA version %(version)s') +# Note, __version__ should always be passed explicitly here, +# because click does not retrieve a dynamic version when installed in editable mode +@click.version_option(__version__, '-v', '--version', message='AiiDA version %(version)s') @click.pass_context def verdi(ctx, profile): """The command line interface of AiiDA.""" From d29fb3be78c9b7be2e495b287c6dca8960bfe83d Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 18 Nov 2020 12:30:38 +0100 Subject: [PATCH 008/114] Improve `verdi node delete` performance (#4575) The `verdi node delete` process fully loaded all ORM objects at multiple stages during the process, which is highly inefficient. This commit ensures the process now only loads the PKs when possible. As an example, the time to delete 100 "empty" nodes (no attributes/objects) is now reduced from ~32 seconds to ~5 seconds. --- .pre-commit-config.yaml | 2 + aiida/cmdline/commands/cmd_node.py | 16 +- aiida/manage/database/delete/nodes.py | 66 ++++--- aiida/tools/graph/graph_traversers.py | 169 ++++++++++-------- aiida/tools/importexport/dbexport/__init__.py | 2 +- mypy.ini | 1 - 6 files changed, 143 insertions(+), 113 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5353cc9842..78b1615e1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,6 +44,8 @@ repos: (?x)^( aiida/common/progress_reporter.py| aiida/engine/processes/calcjobs/calcjob.py| + aiida/manage/database/delete/nodes.py| + aiida/tools/graph/graph_traversers.py| aiida/tools/groups/paths.py| aiida/tools/importexport/archive/.*py| aiida/tools/importexport/dbexport/__init__.py| diff --git a/aiida/cmdline/commands/cmd_node.py b/aiida/cmdline/commands/cmd_node.py index 3f73c106ff..51ab802d37 100644 --- a/aiida/cmdline/commands/cmd_node.py +++ b/aiida/cmdline/commands/cmd_node.py @@ -296,19 +296,20 @@ def tree(nodes, depth): @verdi_node.command('delete') -@arguments.NODES('nodes', required=True) +@click.argument('identifier', nargs=-1, metavar='NODES') @options.VERBOSE() @options.DRY_RUN() @options.FORCE() @options.graph_traversal_rules(GraphTraversalRules.DELETE.value) @with_dbenv() -def node_delete(nodes, dry_run, verbose, force, **kwargs): +def node_delete(identifier, dry_run, verbose, force, **kwargs): """Delete nodes from the provenance graph. This will not only delete the nodes explicitly provided via the command line, but will also include the nodes necessary to keep a consistent graph, according to the rules outlined in the documentation. You can modify some of those rules using options of this command. """ + from aiida.orm.utils.loaders import NodeEntityLoader from aiida.manage.database.delete.nodes import delete_nodes verbosity = 1 @@ -317,9 +318,16 @@ def node_delete(nodes, dry_run, verbose, force, **kwargs): elif verbose: verbosity = 2 - node_pks_to_delete = [node.pk for node in nodes] + pks = [] - delete_nodes(node_pks_to_delete, dry_run=dry_run, verbosity=verbosity, force=force, **kwargs) + for obj in identifier: + # we only load the node if we need to convert from a uuid/label + if isinstance(obj, int): + pks.append(obj) + else: + pks.append(NodeEntityLoader.load_entity(obj).pk) + + delete_nodes(pks, dry_run=dry_run, verbosity=verbosity, force=force, **kwargs) @verdi_node.command('rehash') diff --git a/aiida/manage/database/delete/nodes.py b/aiida/manage/database/delete/nodes.py index 47860be84b..e9c89a6828 100644 --- a/aiida/manage/database/delete/nodes.py +++ b/aiida/manage/database/delete/nodes.py @@ -8,12 +8,15 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Function to delete nodes from the database.""" +from typing import Iterable import click from aiida.cmdline.utils import echo -def delete_nodes(pks, verbosity=0, dry_run=False, force=False, **kwargs): +def delete_nodes( + pks: Iterable[int], verbosity: int = 0, dry_run: bool = False, force: bool = False, **traversal_rules: bool +): """Delete nodes by a list of pks. This command will delete not only the specified nodes, but also the ones that are @@ -36,61 +39,56 @@ def delete_nodes(pks, verbosity=0, dry_run=False, force=False, **kwargs): inputs, and so on. :param pks: a list of the PKs of the nodes to delete - :param bool force: do not ask for confirmation to delete nodes. - :param int verbosity: 0 prints nothing, + :param force: do not ask for confirmation to delete nodes. + :param verbosity: 0 prints nothing, 1 prints just sums and total, 2 prints individual nodes. - :param kwargs: graph traversal rules. See :const:`aiida.common.links.GraphTraversalRules` what rule names + :param dry_run: + Just perform a dry run and do not delete anything. + Print statistics according to the verbosity level set. + :param force: Do not ask for confirmation to delete nodes + + :param traversal_rules: graph traversal rules. See :const:`aiida.common.links.GraphTraversalRules` what rule names are toggleable and what the defaults are. - :param bool dry_run: - Just perform a dry run and do not delete anything. Print statistics according - to the verbosity level set. - :param bool force: - Do not ask for confirmation to delete nodes. """ # pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-statements from aiida.backends.utils import delete_nodes_and_connections - from aiida.common import exceptions from aiida.orm import Node, QueryBuilder, load_node from aiida.tools.graph.graph_traversers import get_nodes_delete - starting_pks = [] - for pk in pks: - try: - load_node(pk) - except exceptions.NotExistent: - echo.echo_warning(f'warning: node with pk<{pk}> does not exist, skipping') - else: - starting_pks.append(pk) + def _missing_callback(_pks: Iterable[int]): + for _pk in _pks: + echo.echo_warning(f'warning: node with pk<{_pk}> does not exist, skipping') + + pks_set_to_delete = get_nodes_delete(pks, get_links=False, missing_callback=_missing_callback, + **traversal_rules)['nodes'] # An empty set might be problematic for the queries done below. - if not starting_pks: + if not pks_set_to_delete: if verbosity: echo.echo('Nothing to delete') return - pks_set_to_delete = get_nodes_delete(starting_pks, **kwargs)['nodes'] - if verbosity > 0: echo.echo( 'I {} delete {} node{}'.format( 'would' if dry_run else 'will', len(pks_set_to_delete), 's' if len(pks_set_to_delete) > 1 else '' ) ) - if verbosity > 1: - builder = QueryBuilder().append( - Node, filters={'id': { - 'in': pks_set_to_delete - }}, project=('uuid', 'id', 'node_type', 'label') - ) - echo.echo(f"The nodes I {'would' if dry_run else 'will'} delete:") - for uuid, pk, type_string, label in builder.iterall(): - try: - short_type_string = type_string.split('.')[-2] - except IndexError: - short_type_string = type_string - echo.echo(f' {uuid} {pk} {short_type_string} {label}') + if verbosity > 1: + builder = QueryBuilder().append( + Node, filters={'id': { + 'in': pks_set_to_delete + }}, project=('uuid', 'id', 'node_type', 'label') + ) + echo.echo(f"The nodes I {'would' if dry_run else 'will'} delete:") + for uuid, pk, type_string, label in builder.iterall(): + try: + short_type_string = type_string.split('.')[-2] + except IndexError: + short_type_string = type_string + echo.echo(f' {uuid} {pk} {short_type_string} {label}') if dry_run: if verbosity > 0: diff --git a/aiida/tools/graph/graph_traversers.py b/aiida/tools/graph/graph_traversers.py index cee4e9e52a..209f22bbc5 100644 --- a/aiida/tools/graph/graph_traversers.py +++ b/aiida/tools/graph/graph_traversers.py @@ -8,34 +8,60 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module for functions to traverse AiiDA graphs.""" +import sys +from typing import Any, Callable, cast, Dict, Iterable, List, Mapping, Optional, Set from numpy import inf -from aiida.common.links import GraphTraversalRules, LinkType - -def get_nodes_delete(starting_pks, get_links=False, **kwargs): +from aiida import orm +from aiida.common import exceptions +from aiida.common.links import GraphTraversalRules, LinkType +from aiida.orm.utils.links import LinkQuadruple +from aiida.tools.graph.age_entities import Basket +from aiida.tools.graph.age_rules import UpdateRule, RuleSequence, RuleSaveWalkers, RuleSetWalkers + +if sys.version_info >= (3, 8): + from typing import TypedDict + + class TraverseGraphOutput(TypedDict, total=False): + nodes: Set[int] + links: Optional[Set[LinkQuadruple]] + rules: Dict[str, bool] +else: + TraverseGraphOutput = Mapping[str, Any] + + +def get_nodes_delete( + starting_pks: Iterable[int], + get_links: bool = False, + missing_callback: Optional[Callable[[Iterable[int]], None]] = None, + **traversal_rules: bool +) -> TraverseGraphOutput: """ This function will return the set of all nodes that can be connected to a list of initial nodes through any sequence of specified authorized links and directions for deletion. - :type starting_pks: list or tuple or set :param starting_pks: Contains the (valid) pks of the starting nodes. - :param bool get_links: + :param get_links: Pass True to also return the links between all nodes (found + initial). - :param bool create_forward: will traverse CREATE links in the forward direction. - :param bool call_calc_forward: will traverse CALL_CALC links in the forward direction. - :param bool call_work_forward: will traverse CALL_WORK links in the forward direction. + :param missing_callback: A callback to handle missing starting_pks or if None raise NotExistent + For example to ignore them: ``missing_callback=lambda missing_pks: None`` + + :param traversal_rules: graph traversal rules. See :const:`aiida.common.links.GraphTraversalRules` what rule names + are toggleable and what the defaults are. + """ - traverse_links = validate_traversal_rules(GraphTraversalRules.DELETE, **kwargs) + traverse_links = validate_traversal_rules(GraphTraversalRules.DELETE, **traversal_rules) traverse_output = traverse_graph( starting_pks, get_links=get_links, links_forward=traverse_links['forward'], - links_backward=traverse_links['backward'] + links_backward=traverse_links['backward'], + missing_callback=missing_callback ) function_output = { @@ -44,30 +70,31 @@ def get_nodes_delete(starting_pks, get_links=False, **kwargs): 'rules': traverse_links['rules_applied'] } - return function_output + return cast(TraverseGraphOutput, function_output) -def get_nodes_export(starting_pks, get_links=False, **kwargs): +def get_nodes_export( + starting_pks: Iterable[int], get_links: bool = False, **traversal_rules: bool +) -> TraverseGraphOutput: """ This function will return the set of all nodes that can be connected to a list of initial nodes through any sequence of specified authorized links and directions for export. This will also return the links and the traversal rules parsed. - :type starting_pks: list or tuple or set :param starting_pks: Contains the (valid) pks of the starting nodes. - :param bool get_links: + :param get_links: Pass True to also return the links between all nodes (found + initial). - :param bool input_calc_forward: will traverse INPUT_CALC links in the forward direction. - :param bool create_backward: will traverse CREATE links in the backward direction. - :param bool return_backward: will traverse RETURN links in the backward direction. - :param bool input_work_forward: will traverse INPUT_WORK links in the forward direction. - :param bool call_calc_backward: will traverse CALL_CALC links in the backward direction. - :param bool call_work_backward: will traverse CALL_WORK links in the backward direction. + :param input_calc_forward: will traverse INPUT_CALC links in the forward direction. + :param create_backward: will traverse CREATE links in the backward direction. + :param return_backward: will traverse RETURN links in the backward direction. + :param input_work_forward: will traverse INPUT_WORK links in the forward direction. + :param call_calc_backward: will traverse CALL_CALC links in the backward direction. + :param call_work_backward: will traverse CALL_WORK links in the backward direction. """ - traverse_links = validate_traversal_rules(GraphTraversalRules.EXPORT, **kwargs) + traverse_links = validate_traversal_rules(GraphTraversalRules.EXPORT, **traversal_rules) traverse_output = traverse_graph( starting_pks, @@ -82,50 +109,49 @@ def get_nodes_export(starting_pks, get_links=False, **kwargs): 'rules': traverse_links['rules_applied'] } - return function_output + return cast(TraverseGraphOutput, function_output) -def validate_traversal_rules(ruleset=GraphTraversalRules.DEFAULT, **kwargs): +def validate_traversal_rules( + ruleset: GraphTraversalRules = GraphTraversalRules.DEFAULT, **traversal_rules: bool +) -> dict: """ Validates the keywords with a ruleset template and returns a parsed dictionary ready to be used. - :type ruleset: :py:class:`aiida.common.links.GraphTraversalRules` :param ruleset: Ruleset template used to validate the set of rules. - :param bool input_calc_forward: will traverse INPUT_CALC links in the forward direction. - :param bool input_calc_backward: will traverse INPUT_CALC links in the backward direction. - :param bool create_forward: will traverse CREATE links in the forward direction. - :param bool create_backward: will traverse CREATE links in the backward direction. - :param bool return_forward: will traverse RETURN links in the forward direction. - :param bool return_backward: will traverse RETURN links in the backward direction. - :param bool input_work_forward: will traverse INPUT_WORK links in the forward direction. - :param bool input_work_backward: will traverse INPUT_WORK links in the backward direction. - :param bool call_calc_forward: will traverse CALL_CALC links in the forward direction. - :param bool call_calc_backward: will traverse CALL_CALC links in the backward direction. - :param bool call_work_forward: will traverse CALL_WORK links in the forward direction. - :param bool call_work_backward: will traverse CALL_WORK links in the backward direction. + :param input_calc_forward: will traverse INPUT_CALC links in the forward direction. + :param input_calc_backward: will traverse INPUT_CALC links in the backward direction. + :param create_forward: will traverse CREATE links in the forward direction. + :param create_backward: will traverse CREATE links in the backward direction. + :param return_forward: will traverse RETURN links in the forward direction. + :param return_backward: will traverse RETURN links in the backward direction. + :param input_work_forward: will traverse INPUT_WORK links in the forward direction. + :param input_work_backward: will traverse INPUT_WORK links in the backward direction. + :param call_calc_forward: will traverse CALL_CALC links in the forward direction. + :param call_calc_backward: will traverse CALL_CALC links in the backward direction. + :param call_work_forward: will traverse CALL_WORK links in the forward direction. + :param call_work_backward: will traverse CALL_WORK links in the backward direction. """ - from aiida.common import exceptions - if not isinstance(ruleset, GraphTraversalRules): raise TypeError( f'ruleset input must be of type aiida.common.links.GraphTraversalRules\ninstead, it is: {type(ruleset)}' ) - rules_applied = {} - links_forward = [] - links_backward = [] + rules_applied: Dict[str, bool] = {} + links_forward: List[LinkType] = [] + links_backward: List[LinkType] = [] for name, rule in ruleset.value.items(): follow = rule.default - if name in kwargs: + if name in traversal_rules: if not rule.toggleable: raise ValueError(f'input rule {name} is not toggleable for ruleset {ruleset}') - follow = kwargs.pop(name) + follow = traversal_rules.pop(name) if not isinstance(follow, bool): raise ValueError(f'the value of rule {name} must be boolean, but it is: {follow}') @@ -141,8 +167,8 @@ def validate_traversal_rules(ruleset=GraphTraversalRules.DEFAULT, **kwargs): rules_applied[name] = follow - if kwargs: - error_message = f"unrecognized keywords: {', '.join(kwargs.keys())}" + if traversal_rules: + error_message = f"unrecognized keywords: {', '.join(traversal_rules.keys())}" raise exceptions.ValidationError(error_message) valid_output = { @@ -154,36 +180,33 @@ def validate_traversal_rules(ruleset=GraphTraversalRules.DEFAULT, **kwargs): return valid_output -def traverse_graph(starting_pks, max_iterations=None, get_links=False, links_forward=(), links_backward=()): +def traverse_graph( + starting_pks: Iterable[int], + max_iterations: Optional[int] = None, + get_links: bool = False, + links_forward: Iterable[LinkType] = (), + links_backward: Iterable[LinkType] = (), + missing_callback: Optional[Callable[[Iterable[int]], None]] = None +) -> TraverseGraphOutput: """ This function will return the set of all nodes that can be connected to a list of initial nodes through any sequence of specified links. Optionally, it may also return the links that connect these nodes. - :type starting_pks: list or tuple or set :param starting_pks: Contains the (valid) pks of the starting nodes. - :type max_iterations: int or None :param max_iterations: The number of iterations to apply the set of rules (a value of 'None' will iterate until no new nodes are added). - :param bool get_links: - Pass True to also return the links between all nodes (found + initial). + :param get_links: Pass True to also return the links between all nodes (found + initial). - :type links_forward: aiida.common.links.LinkType - :param links_forward: - List with all the links that should be traversed in the forward direction. + :param links_forward: List with all the links that should be traversed in the forward direction. + :param links_backward: List with all the links that should be traversed in the backward direction. - :type links_backward: aiida.common.links.LinkType - :param links_backward: - List with all the links that should be traversed in the backward direction. + :param missing_callback: A callback to handle missing starting_pks or if None raise NotExistent """ # pylint: disable=too-many-locals,too-many-statements,too-many-branches - from aiida import orm - from aiida.tools.graph.age_entities import Basket - from aiida.tools.graph.age_rules import UpdateRule, RuleSequence, RuleSaveWalkers, RuleSetWalkers - from aiida.common import exceptions if max_iterations is None: max_iterations = inf @@ -204,31 +227,31 @@ def traverse_graph(starting_pks, max_iterations=None, get_links=False, links_for linktype_list.append(linktype.value) filters_backwards = {'type': {'in': linktype_list}} - if not isinstance(starting_pks, (list, set, tuple)): - raise TypeError(f'starting_pks must be of type list, set or tuple\ninstead, it is {type(starting_pks)}') - - if not starting_pks: - if get_links: - output = {'nodes': set(), 'links': set()} - else: - output = {'nodes': set(), 'links': None} - return output + if not isinstance(starting_pks, Iterable): # pylint: disable=isinstance-second-argument-not-valid-type + raise TypeError(f'starting_pks must be an iterable\ninstead, it is {type(starting_pks)}') if any([not isinstance(pk, int) for pk in starting_pks]): raise TypeError(f'one of the starting_pks is not of type int:\n {starting_pks}') operational_set = set(starting_pks) + if not operational_set: + if get_links: + return {'nodes': set(), 'links': set()} + return {'nodes': set(), 'links': None} + query_nodes = orm.QueryBuilder() query_nodes.append(orm.Node, project=['id'], filters={'id': {'in': operational_set}}) existing_pks = set(query_nodes.all(flat=True)) missing_pks = operational_set.difference(existing_pks) - if missing_pks: + if missing_pks and missing_callback is None: raise exceptions.NotExistent( - f'The following pks are not in the database and must be pruned before this call: {missing_pks}' + f'The following pks are not in the database and must be pruned before this call: {missing_pks}' ) + elif missing_pks and missing_callback is not None: + missing_callback(missing_pks) rules = [] - basket = Basket(nodes=operational_set) + basket = Basket(nodes=existing_pks) # When max_iterations is finite, the order of traversal may affect the result # (its not the same to first go backwards and then forwards than vice-versa) @@ -269,4 +292,4 @@ def traverse_graph(starting_pks, max_iterations=None, get_links=False, links_for if get_links: output['links'] = results['nodes_nodes'].keyset - return output + return cast(TraverseGraphOutput, output) diff --git a/aiida/tools/importexport/dbexport/__init__.py b/aiida/tools/importexport/dbexport/__init__.py index b94f4a307d..6ceb6480a0 100644 --- a/aiida/tools/importexport/dbexport/__init__.py +++ b/aiida/tools/importexport/dbexport/__init__.py @@ -280,7 +280,7 @@ def export( _check_node_licenses(node_ids_to_be_exported, allowed_licenses, forbidden_licenses) # write the link data - if traverse_output['links']: + if traverse_output['links'] is not None: with get_progress_reporter()(total=len(traverse_output['links']), desc='Writing links') as progress: for link in traverse_output['links']: progress.update() diff --git a/mypy.ini b/mypy.ini index 22d349aacf..481b14c29f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,7 +1,6 @@ # Global options [mypy] -python_version = 3.6 check_untyped_defs = True scripts_are_modules = True From 17b77181d87abed9211fdaf1f432e00e276c1c11 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Thu, 19 Nov 2020 10:09:33 +0100 Subject: [PATCH 009/114] `CalcJob`: add the `additional_retrieve_list` metadata option (#4437) This new option allows one to specify additional files to be retrieved on a per-instance basis, in addition to the files that are already defined by the plugin to be retrieved. This was often implemented by plugin packages itself through a `settings` node that supported a key that would allow a user to specify these additional files. Since this is a common use case, we implement this functionality on `aiida-core` instead to guarantee a consistent interface across plugins. --- aiida/engine/processes/calcjobs/calcjob.py | 50 ++++++++---- tests/engine/test_calc_job.py | 93 +++++++++++++++++----- 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/aiida/engine/processes/calcjobs/calcjob.py b/aiida/engine/processes/calcjobs/calcjob.py index db4f8a60c3..b86ff8ee32 100644 --- a/aiida/engine/processes/calcjobs/calcjob.py +++ b/aiida/engine/processes/calcjobs/calcjob.py @@ -104,6 +104,20 @@ def validate_parser(parser_name, _): return f'invalid parser specified: {exception}' +def validate_additional_retrieve_list(additional_retrieve_list, _): + """Validate the additional retrieve list. + + :return: string with error message in case the input is invalid. + """ + import os + + if additional_retrieve_list is plumpy.UNSPECIFIED: + return + + if any(not isinstance(value, str) or os.path.isabs(value) for value in additional_retrieve_list): + return f'`additional_retrieve_list` should only contain relative filepaths but got: {additional_retrieve_list}' + + class CalcJob(Process): """Implementation of the CalcJob process.""" @@ -186,6 +200,9 @@ def define(cls, spec: CalcJobProcessSpec): 'script, just after the code execution',) spec.input('metadata.options.parser_name', valid_type=str, required=False, validator=validate_parser, help='Set a string for the output parser. Can be None if no output plugin is available or needed') + spec.input('metadata.options.additional_retrieve_list', required=False, + valid_type=(list, tuple), validator=validate_additional_retrieve_list, + help='List of relative file paths that should be retrieved in addition to what the plugin specifies.') spec.output('remote_folder', valid_type=orm.RemoteData, help='Input files necessary to run the process will be stored in this folder node.') @@ -462,16 +479,16 @@ def presubmit(self, folder): job_tmpl.sched_join_files = False # Set retrieve path, add also scheduler STDOUT and STDERR - retrieve_list = (calc_info.retrieve_list if calc_info.retrieve_list is not None else []) + retrieve_list = calc_info.retrieve_list or [] if (job_tmpl.sched_output_path is not None and job_tmpl.sched_output_path not in retrieve_list): retrieve_list.append(job_tmpl.sched_output_path) if not job_tmpl.sched_join_files: if (job_tmpl.sched_error_path is not None and job_tmpl.sched_error_path not in retrieve_list): retrieve_list.append(job_tmpl.sched_error_path) + retrieve_list.extend(self.node.get_option('additional_retrieve_list') or []) self.node.set_retrieve_list(retrieve_list) - retrieve_singlefile_list = (calc_info.retrieve_singlefile_list - if calc_info.retrieve_singlefile_list is not None else []) + retrieve_singlefile_list = calc_info.retrieve_singlefile_list or [] # a validation on the subclasses of retrieve_singlefile_list for _, subclassname, _ in retrieve_singlefile_list: file_sub_class = DataFactory(subclassname) @@ -483,8 +500,7 @@ def presubmit(self, folder): self.node.set_retrieve_singlefile_list(retrieve_singlefile_list) # Handle the retrieve_temporary_list - retrieve_temporary_list = (calc_info.retrieve_temporary_list - if calc_info.retrieve_temporary_list is not None else []) + retrieve_temporary_list = calc_info.retrieve_temporary_list or [] self.node.set_retrieve_temporary_list(retrieve_temporary_list) # the if is done so that if the method returns None, this is @@ -526,17 +542,13 @@ def presubmit(self, folder): raise PluginInternalError('Invalid codes_info, must be a list of CodeInfo objects') if code_info.code_uuid is None: - raise PluginInternalError('CalcInfo should have ' - 'the information of the code ' - 'to be launched') + raise PluginInternalError('CalcInfo should have the information of the code to be launched') this_code = load_node(code_info.code_uuid, sub_classes=(Code,)) this_withmpi = code_info.withmpi # to decide better how to set the default if this_withmpi is None: if len(calc_info.codes_info) > 1: - raise PluginInternalError('For more than one code, it is ' - 'necessary to set withmpi in ' - 'codes_info') + raise PluginInternalError('For more than one code, it is necessary to set withmpi in codes_info') else: this_withmpi = self.node.get_option('withmpi') @@ -558,8 +570,8 @@ def presubmit(self, folder): if len(codes) > 1: try: job_tmpl.codes_run_mode = calc_info.codes_run_mode - except KeyError: - raise PluginInternalError('Need to set the order of the code execution (parallel or serial?)') + except KeyError as exc: + raise PluginInternalError('Need to set the order of the code execution (parallel or serial?)') from exc else: job_tmpl.codes_run_mode = CodeRunMode.SERIAL ######################################################################## @@ -614,22 +626,26 @@ def presubmit(self, folder): try: validate_list_of_string_tuples(local_copy_list, tuple_length=3) except ValidationError as exc: - raise PluginInternalError(f'[presubmission of calc {this_pk}] local_copy_list format problem: {exc}') + raise PluginInternalError( + f'[presubmission of calc {this_pk}] local_copy_list format problem: {exc}' + ) from exc remote_copy_list = calc_info.remote_copy_list try: validate_list_of_string_tuples(remote_copy_list, tuple_length=3) except ValidationError as exc: - raise PluginInternalError(f'[presubmission of calc {this_pk}] remote_copy_list format problem: {exc}') + raise PluginInternalError( + f'[presubmission of calc {this_pk}] remote_copy_list format problem: {exc}' + ) from exc for (remote_computer_uuid, _, dest_rel_path) in remote_copy_list: try: Computer.objects.get(uuid=remote_computer_uuid) # pylint: disable=unused-variable - except exceptions.NotExistent: + except exceptions.NotExistent as exc: raise PluginInternalError('[presubmission of calc {}] ' 'The remote copy requires a computer with UUID={}' 'but no such computer was found in the ' - 'database'.format(this_pk, remote_computer_uuid)) + 'database'.format(this_pk, remote_computer_uuid)) from exc if os.path.isabs(dest_rel_path): raise PluginInternalError('[presubmission of calc {}] ' 'The destination path of the remote copy ' diff --git a/tests/engine/test_calc_job.py b/tests/engine/test_calc_job.py index 08429c9a56..3894998eb7 100644 --- a/tests/engine/test_calc_job.py +++ b/tests/engine/test_calc_job.py @@ -356,36 +356,45 @@ def test_parse_retrieved_folder(self): @pytest.fixture -def process(aiida_local_code_factory): +def generate_process(aiida_local_code_factory): """Instantiate a process with default inputs and return the `Process` instance.""" from aiida.engine.utils import instantiate_process from aiida.manage.manager import get_manager - inputs = { - 'code': aiida_local_code_factory('arithmetic.add', '/bin/bash'), - 'x': orm.Int(1), - 'y': orm.Int(2), - 'metadata': { - 'options': {} + def _generate_process(inputs=None): + + base_inputs = { + 'code': aiida_local_code_factory('arithmetic.add', '/bin/bash'), + 'x': orm.Int(1), + 'y': orm.Int(2), + 'metadata': { + 'options': {} + } } - } - manager = get_manager() - runner = manager.get_runner() + if inputs is not None: + base_inputs = {**base_inputs, **inputs} - process_class = CalculationFactory('arithmetic.add') - process = instantiate_process(runner, process_class, **inputs) - process.node.set_state(CalcJobState.PARSING) + manager = get_manager() + runner = manager.get_runner() - return process + process_class = CalculationFactory('arithmetic.add') + process = instantiate_process(runner, process_class, **base_inputs) + process.node.set_state(CalcJobState.PARSING) + + return process + + return _generate_process @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') -def test_parse_insufficient_data(process): +def test_parse_insufficient_data(generate_process): """Test the scheduler output parsing logic in `CalcJob.parse`. Here we check explicitly that the parsing does not except even if the required information is not available. """ + process = generate_process() + retrieved = orm.FolderData().store() retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) process.parse() @@ -409,12 +418,14 @@ def test_parse_insufficient_data(process): @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') -def test_parse_non_zero_retval(process): +def test_parse_non_zero_retval(generate_process): """Test the scheduler output parsing logic in `CalcJob.parse`. This is testing the case where the `detailed_job_info` is incomplete because the call failed. This is checked through the return value that is stored within the attribute dictionary. """ + process = generate_process() + retrieved = orm.FolderData().store() retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) @@ -426,11 +437,13 @@ def test_parse_non_zero_retval(process): @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') -def test_parse_not_implemented(process): +def test_parse_not_implemented(generate_process): """Test the scheduler output parsing logic in `CalcJob.parse`. Here we check explicitly that the parsing does not except even if the scheduler does not implement the method. """ + process = generate_process() + retrieved = orm.FolderData().store() retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) @@ -457,13 +470,15 @@ def test_parse_not_implemented(process): @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') -def test_parse_scheduler_excepted(process, monkeypatch): +def test_parse_scheduler_excepted(generate_process, monkeypatch): """Test the scheduler output parsing logic in `CalcJob.parse`. Here we check explicitly the case where the `Scheduler.parse_output` method excepts """ from aiida.schedulers.plugins.direct import DirectScheduler + process = generate_process() + retrieved = orm.FolderData().store() retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) @@ -558,3 +573,45 @@ def parse_retrieved_output(_, __): result = process.parse() assert isinstance(result, ExitCode) assert result.status == final + + +@pytest.mark.usefixtures('clear_database_before_test') +def test_additional_retrieve_list(generate_process, fixture_sandbox): + """Test the ``additional_retrieve_list`` option.""" + process = generate_process() + process.presubmit(fixture_sandbox) + retrieve_list = process.node.get_attribute('retrieve_list') + + # Keep reference of the base contents of the retrieve list. + base_retrieve_list = retrieve_list + + # Test that the code works if no explicit additional retrieve list is specified + assert len(retrieve_list) != 0 + assert isinstance(process.node.get_attribute('retrieve_list'), list) + + # Defining explicit additional retrieve list that is disjoint with the base retrieve list + additional_retrieve_list = ['file.txt', 'folder/file.txt'] + process = generate_process({'metadata': {'options': {'additional_retrieve_list': additional_retrieve_list}}}) + process.presubmit(fixture_sandbox) + retrieve_list = process.node.get_attribute('retrieve_list') + + # Check that the `retrieve_list` is a list and contains the union of the base and additional retrieve list + assert isinstance(process.node.get_attribute('retrieve_list'), list) + assert set(retrieve_list) == set(base_retrieve_list).union(set(additional_retrieve_list)) + + # Defining explicit additional retrieve list with elements that overlap with `base_retrieve_list + additional_retrieve_list = ['file.txt', 'folder/file.txt'] + base_retrieve_list + process = generate_process({'metadata': {'options': {'additional_retrieve_list': additional_retrieve_list}}}) + process.presubmit(fixture_sandbox) + retrieve_list = process.node.get_attribute('retrieve_list') + + # Check that the `retrieve_list` is a list and contains the union of the base and additional retrieve list + assert isinstance(process.node.get_attribute('retrieve_list'), list) + assert set(retrieve_list) == set(base_retrieve_list).union(set(additional_retrieve_list)) + + # Test the validator + with pytest.raises(ValueError, match=r'`additional_retrieve_list` should only contain relative filepaths.*'): + process = generate_process({'metadata': {'options': {'additional_retrieve_list': [None]}}}) + + with pytest.raises(ValueError, match=r'`additional_retrieve_list` should only contain relative filepaths.*'): + process = generate_process({'metadata': {'options': {'additional_retrieve_list': ['/abs/path']}}}) From a2d6c7673952ae48c0d5ae58aff2d2c808e3a982 Mon Sep 17 00:00:00 2001 From: Marnik Bercx Date: Sun, 22 Nov 2020 21:02:07 +0100 Subject: [PATCH 010/114] Add options for transport tasks (#4583) * Add options for transport tasks When encountering failures during the execution of transport tasks, a runner will wait for a time interval between transport task attempts. This time interval between attempts is increased using an exponential backoff mechanism, i.e. the time interval is equal to: (TRANSPORT_TASK_RETRY_INITIAL_INTERVAL) * 2 ** (N_ATTEMPT - 1) where N_ATTEMPT is the number of failed attempts. This mechanism is interrupted once the TRANSPORT_TASK_MAXIMUM_ATTEMPTS is reached. The initial interval and maximum attempts are currently fixed to 20 seconds and 5, respectively. This commit adds two configuration options that use these defaults, but allow the user to adjust them using `verdi config`. --- aiida/engine/processes/calcjobs/tasks.py | 25 ++++++++++++------------ aiida/engine/utils.py | 3 ++- aiida/manage/configuration/options.py | 16 +++++++++++++++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 0de3d8a8b7..494627e2b2 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -22,6 +22,7 @@ from aiida.engine.daemon import execmanager from aiida.engine.utils import exponential_backoff_retry, interruptable_task from aiida.schedulers.datastructures import JobState +from aiida.manage.configuration import get_config from ..process import ProcessState @@ -31,8 +32,8 @@ RETRIEVE_COMMAND = 'retrieve' KILL_COMMAND = 'kill' -TRANSPORT_TASK_RETRY_INITIAL_INTERVAL = 20 -TRANSPORT_TASK_MAXIMUM_ATTEMTPS = 5 +RETRY_INTERVAL_OPTION = 'transport.task_retry_initial_interval' +MAX_ATTEMPTS_OPTION = 'transport.task_maximum_attempts' logger = logging.getLogger(__name__) @@ -63,8 +64,8 @@ def task_upload_job(process, transport_queue, cancellable): logger.warning(f'CalcJob<{node.pk}> already marked as SUBMITTING, skipping task_update_job') raise Return - initial_interval = TRANSPORT_TASK_RETRY_INITIAL_INTERVAL - max_attempts = TRANSPORT_TASK_MAXIMUM_ATTEMTPS + initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) @@ -124,8 +125,8 @@ def task_submit_job(node, transport_queue, cancellable): logger.warning(f'CalcJob<{node.pk}> already marked as WITHSCHEDULER, skipping task_submit_job') raise Return(node.get_job_id()) - initial_interval = TRANSPORT_TASK_RETRY_INITIAL_INTERVAL - max_attempts = TRANSPORT_TASK_MAXIMUM_ATTEMTPS + initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) @@ -172,8 +173,8 @@ def task_update_job(node, job_manager, cancellable): logger.warning(f'CalcJob<{node.pk}> already marked as RETRIEVING, skipping task_update_job') raise Return(True) - initial_interval = TRANSPORT_TASK_RETRY_INITIAL_INTERVAL - max_attempts = TRANSPORT_TASK_MAXIMUM_ATTEMTPS + initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) job_id = node.get_job_id() @@ -233,8 +234,8 @@ def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, cancell logger.warning(f'CalcJob<{node.pk}> already marked as PARSING, skipping task_retrieve_job') raise Return - initial_interval = TRANSPORT_TASK_RETRY_INITIAL_INTERVAL - max_attempts = TRANSPORT_TASK_MAXIMUM_ATTEMTPS + initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) @@ -291,8 +292,8 @@ def task_kill_job(node, transport_queue, cancellable): :raises: Return if the tasks was successfully completed :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ - initial_interval = TRANSPORT_TASK_RETRY_INITIAL_INTERVAL - max_attempts = TRANSPORT_TASK_MAXIMUM_ATTEMTPS + initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) if node.get_state() in [CalcJobState.UPLOADING, CalcJobState.SUBMITTING]: logger.warning(f'CalcJob<{node.pk}> killed, it was in the {node.get_state()} state') diff --git a/aiida/engine/utils.py b/aiida/engine/utils.py index 8f96f703aa..4dac43df20 100644 --- a/aiida/engine/utils.py +++ b/aiida/engine/utils.py @@ -154,7 +154,8 @@ def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, logger This coroutine will loop ``max_attempts`` times, calling the ``fct`` function, breaking immediately when the call finished without raising an exception, at which point the returned result will be raised, wrapped in a ``tornado.gen.Result`` instance. If an exception is caught, the function will yield a ``tornado.gen.sleep`` with a - time interval equal to the ``initial_interval`` multiplied by ``2*N`` where ``N`` is the number of excepted calls. + time interval equal to the ``initial_interval`` multiplied by ``2 ** (N - 1)`` where ``N`` is the number of + excepted calls. :param fct: the function to call, which will be turned into a coroutine first if it is not already :param initial_interval: the time to wait after the first caught exception before calling the coroutine again diff --git a/aiida/manage/configuration/options.py b/aiida/manage/configuration/options.py index 176bb5c713..631f1b396a 100644 --- a/aiida/manage/configuration/options.py +++ b/aiida/manage/configuration/options.py @@ -185,6 +185,22 @@ 'description': 'Boolean whether to print AiiDA deprecation warnings', 'global_only': False, }, + 'transport.task_retry_initial_interval': { + 'key': 'task_retry_initial_interval', + 'valid_type': 'int', + 'valid_values': None, + 'default': 20, + 'description': 'Initial time interval for the exponential backoff mechanism.', + 'global_only': False, + }, + 'transport.task_maximum_attempts': { + 'key': 'task_maximum_attempts', + 'valid_type': 'int', + 'valid_values': None, + 'default': 5, + 'description': 'Maximum number of transport task attempts before a Process is Paused.', + 'global_only': False, + }, } From 36cb1335d7c43e0fcca12cd38e09e0fbffaf226f Mon Sep 17 00:00:00 2001 From: Marnik Bercx Date: Tue, 24 Nov 2020 19:48:04 +0100 Subject: [PATCH 011/114] Fix command for getting EBM config options (#4587) Currently the transport options for the EBM are obtained by using the get_config function, e.g.: `initial_interval = get_config_option(RETRY_INTERVAL_OPTION)` However, it seems that `get_config()` does not get you the current configuration (see #4586). Replacing `get_config().get_option()` with `get_config_option()` fixes this issue for the EBM options. --- aiida/engine/processes/calcjobs/tasks.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 494627e2b2..5d0e07e5a2 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -22,7 +22,7 @@ from aiida.engine.daemon import execmanager from aiida.engine.utils import exponential_backoff_retry, interruptable_task from aiida.schedulers.datastructures import JobState -from aiida.manage.configuration import get_config +from aiida.manage.configuration import get_config_option from ..process import ProcessState @@ -64,8 +64,8 @@ def task_upload_job(process, transport_queue, cancellable): logger.warning(f'CalcJob<{node.pk}> already marked as SUBMITTING, skipping task_update_job') raise Return - initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) - max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) + initial_interval = get_config_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) @@ -125,8 +125,8 @@ def task_submit_job(node, transport_queue, cancellable): logger.warning(f'CalcJob<{node.pk}> already marked as WITHSCHEDULER, skipping task_submit_job') raise Return(node.get_job_id()) - initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) - max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) + initial_interval = get_config_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) @@ -173,8 +173,8 @@ def task_update_job(node, job_manager, cancellable): logger.warning(f'CalcJob<{node.pk}> already marked as RETRIEVING, skipping task_update_job') raise Return(True) - initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) - max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) + initial_interval = get_config_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) job_id = node.get_job_id() @@ -234,8 +234,8 @@ def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, cancell logger.warning(f'CalcJob<{node.pk}> already marked as PARSING, skipping task_retrieve_job') raise Return - initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) - max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) + initial_interval = get_config_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) @@ -292,8 +292,8 @@ def task_kill_job(node, transport_queue, cancellable): :raises: Return if the tasks was successfully completed :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ - initial_interval = get_config().get_option(RETRY_INTERVAL_OPTION) - max_attempts = get_config().get_option(MAX_ATTEMPTS_OPTION) + initial_interval = get_config_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) if node.get_state() in [CalcJobState.UPLOADING, CalcJobState.SUBMITTING]: logger.warning(f'CalcJob<{node.pk}> killed, it was in the {node.get_state()} state') From 149762cd9e6dbb1d99f18046656db63ea378fe18 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 18 Nov 2020 19:32:00 +0100 Subject: [PATCH 012/114] CI: revert apt source list removal This work around was added some time ago because this source for the `apt` package manager was causing the install of system dependencies to fail. --- .github/workflows/ci-code.yml | 1 - .github/workflows/ci-style.yml | 1 - .github/workflows/test-install.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/workflows/ci-code.yml b/.github/workflows/ci-code.yml index 33d4bc7b02..735ec6d848 100644 --- a/.github/workflows/ci-code.yml +++ b/.github/workflows/ci-code.yml @@ -81,7 +81,6 @@ jobs: - name: Install system dependencies run: | - sudo rm -f /etc/apt/sources.list.d/dotnetdev.list /etc/apt/sources.list.d/microsoft-prod.list sudo apt update sudo apt install postgresql-10 graphviz diff --git a/.github/workflows/ci-style.yml b/.github/workflows/ci-style.yml index b8c2b75720..42175952a1 100644 --- a/.github/workflows/ci-style.yml +++ b/.github/workflows/ci-style.yml @@ -23,7 +23,6 @@ jobs: - name: Install system dependencies run: | - sudo rm -f /etc/apt/sources.list.d/dotnetdev.list /etc/apt/sources.list.d/microsoft-prod.list sudo apt update sudo apt install libkrb5-dev ruby ruby-dev diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index e8c0f6beb0..2f41082cd1 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -150,7 +150,6 @@ jobs: - name: Install system dependencies run: | - sudo rm -f /etc/apt/sources.list.d/dotnetdev.list /etc/apt/sources.list.d/microsoft-prod.list sudo apt update sudo apt install postgresql-10 graphviz From af12a62d3509aa6d3ade250aff2230add49f5523 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Mon, 16 Nov 2020 11:44:54 +0100 Subject: [PATCH 013/114] CI: Add workflow to run tests against various RabbitMQ versions The main test workflow runs against a single version of RabbitMQ but experience has shown that the code can break for different versions of the RabbitMQ server. Here we add a new CI workflow that runs various unit tests through pytest that simulate the typical interaction with the RabbitMQ server in normal AiiDA operation. The difference is that these are tested against the currently available versions of RabbitMQ. The current setup, still only tests part of the functionality that AiiDA uses, for example, the default credentials and virtual host are used. Connections over TLS are also not tested. These options would require the RabbitMQ service that is running in a docker container to be configured differently. It is not clear how these various options can be parametrized in concert with the actual unit tests. --- .github/workflows/rabbitmq.yml | 67 +++++++++++++++++++++++++++++++ tests/conftest.py | 7 ++++ tests/manage/external/test_rmq.py | 20 +++++++++ 3 files changed, 94 insertions(+) create mode 100644 .github/workflows/rabbitmq.yml diff --git a/.github/workflows/rabbitmq.yml b/.github/workflows/rabbitmq.yml new file mode 100644 index 0000000000..dbcc7320d9 --- /dev/null +++ b/.github/workflows/rabbitmq.yml @@ -0,0 +1,67 @@ +name: rabbitmq + +on: + push: + branches-ignore: [gh-pages] + pull_request: + branches-ignore: [gh-pages] + paths-ignore: ['docs/**'] + +jobs: + + tests: + + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + rabbitmq: [3.5, 3.6, 3.7, 3.8] + + services: + postgres: + image: postgres:10 + env: + POSTGRES_DB: test_django + POSTGRES_PASSWORD: '' + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + rabbitmq: + image: rabbitmq:${{ matrix.rabbitmq }} + ports: + - 5672:5672 + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install system dependencies + run: | + sudo apt update + sudo apt install postgresql-10 + + - name: Upgrade pip + run: | + pip install --upgrade pip + pip --version + + - name: Install aiida-core + run: | + pip install -r requirements/requirements-py-3.8.txt + pip install --no-deps -e . + reentry scan + pip freeze + + - name: Run tests + run: pytest -sv tests/manage/external/test_rmq.py diff --git a/tests/conftest.py b/tests/conftest.py index 2000f7500c..c0777ba956 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -227,6 +227,13 @@ def backend(): return get_manager().get_backend() +@pytest.fixture +def communicator(): + """Get the ``Communicator`` instance of the currently loaded profile to communicate with RabbitMQ.""" + from aiida.manage.manager import get_manager + return get_manager().get_communicator() + + @pytest.fixture def skip_if_not_django(backend): """Fixture that will skip any test that uses it when a profile is loaded with any other backend then Django.""" diff --git a/tests/manage/external/test_rmq.py b/tests/manage/external/test_rmq.py index 7733334fbd..5dac0105ee 100644 --- a/tests/manage/external/test_rmq.py +++ b/tests/manage/external/test_rmq.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the `aiida.manage.external.rmq` module.""" +from kiwipy.rmq import RmqThreadCommunicator import pytest from aiida.manage.external import rmq @@ -34,3 +35,22 @@ def test_get_rmq_url(args, kwargs, expected): else: with pytest.raises(expected): rmq.get_rmq_url(*args, **kwargs) + + +@pytest.mark.parametrize('url', ('amqp://guest:guest@127.0.0.1:5672',)) +def test_communicator(url): + """Test the instantiation of a ``kiwipy.rmq.RmqThreadCommunicator``. + + This class is used by all runners to communicate with the RabbitMQ server. + """ + RmqThreadCommunicator.connect(connection_params={'url': url}) + + +def test_add_rpc_subscriber(communicator): + """Test ``add_rpc_subscriber``.""" + communicator.add_rpc_subscriber(None) + + +def test_add_broadcast_subscriber(communicator): + """Test ``add_broadcast_subscriber``.""" + communicator.add_broadcast_subscriber(None) From 68f840da709d796a81b8458171f34b2c13fbb784 Mon Sep 17 00:00:00 2001 From: Jason Eu Date: Wed, 12 Aug 2020 15:33:15 +0800 Subject: [PATCH 014/114] Engine: replace `tornado` with `asyncio` The `plumpy` and `kiwipy` dependencies have already been migrated from using `tornado` to the Python built-in module `asyncio` in the versions `0.16.0` and `0.6.0`, respectively. This allows us to also rid AiiDA of the `tornado` dependency, which has been giving requirement clashes with other tools, specifically from the Jupyter and iPython world. The final limitation was the `circus` library that is used to daemonize the daemon workers, which as of `v0.17.1` also supports `tornado~=5`. A summary of the changes: * Replace `tornado.ioloop` with `asyncio` event loop. * Coroutines are marked with `async` instead of decorated with the `tornado.gen.coroutine` decorator. * Replace `yield` with `await` when calling a coroutine. * Replace `raise tornado.gen.Return` with `return` when returning from a coroutine. * Replace `add_callback` call on event loop with `call_soon` when scheduling a callback. * Replace `add_callback` call on event loop with `create_task` when scheduling `process.step_until_terminated()`. * Replace `run_sync` call on event loop with `run_until_complete`. * Replace `pika` uses with `aio-pika` which is now used by the `plumpy` and `kiwipy` libraries. * Replace `concurrent.Future` with `asyncio.Future`. * Replace `yield tornado.gen.sleep` with `await asyncio.sleep`. Additional changes: * Remove the `tornado` logger from the logging configuration. * Remove the `logging.tornado_loglevel` configuration option. * Turn the `TransportQueue.loop` attribute from method into property. * Call `Communicator.close()` instead of `Communicator.stop()` in the `Manager.close()` method. The `stop` method has been deprecated in `kiwipy==0.6.0`. --- .ci/test_daemon.py | 2 +- Dockerfile | 1 + aiida/backends/testbase.py | 12 +- aiida/common/log.py | 5 - aiida/engine/launch.py | 3 +- aiida/engine/processes/calcjobs/manager.py | 34 ++-- aiida/engine/processes/calcjobs/tasks.py | 125 ++++++-------- aiida/engine/processes/functions.py | 3 +- aiida/engine/processes/futures.py | 17 +- aiida/engine/processes/process.py | 5 +- .../engine/processes/workchains/workchain.py | 2 +- aiida/engine/runners.py | 20 +-- aiida/engine/transports.py | 22 +-- aiida/engine/utils.py | 88 +++++----- aiida/manage/configuration/options.py | 8 - aiida/manage/external/rmq.py | 12 +- aiida/manage/manager.py | 6 +- aiida/manage/tests/pytest_fixtures.py | 14 ++ docs/source/nitpick-exceptions | 5 +- environment.yml | 10 +- requirements/requirements-py-3.6.txt | 13 +- requirements/requirements-py-3.7.txt | 13 +- requirements/requirements-py-3.8.txt | 13 +- requirements/requirements-py-3.9.txt | 11 +- setup.json | 14 +- tests/cmdline/commands/test_data.py | 10 ++ tests/cmdline/commands/test_process.py | 12 +- tests/engine/test_futures.py | 12 +- tests/engine/test_manager.py | 9 +- tests/engine/test_rmq.py | 112 ++++++------ tests/engine/test_runners.py | 12 +- tests/engine/test_transport.py | 62 +++---- tests/engine/test_utils.py | 161 ++++++++++++++++-- tests/engine/test_work_chain.py | 61 +++---- .../importexport/orm/test_calculations.py | 2 + .../workflows/arithmetic/test_add_multiply.py | 2 +- 36 files changed, 501 insertions(+), 412 deletions(-) diff --git a/.ci/test_daemon.py b/.ci/test_daemon.py index 6dfd352341..17c3ca66ba 100644 --- a/.ci/test_daemon.py +++ b/.ci/test_daemon.py @@ -27,7 +27,7 @@ ) CODENAME_ADD = 'add@localhost' -CODENAME_DOUBLER = 'doubler' +CODENAME_DOUBLER = 'doubler@localhost' TIMEOUTSECS = 4 * 60 # 4 minutes NUMBER_CALCULATIONS = 15 # Number of calculations to submit NUMBER_WORKCHAINS = 8 # Number of workchains to submit diff --git a/Dockerfile b/Dockerfile index 849a1bd5ff..37fce8a720 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ ENV AIIDADB_BACKEND django # Copy and install AiiDA COPY . aiida-core RUN pip install ./aiida-core[atomic_tools] +RUN pip install --upgrade git+https://github.com/unkcpz/circus.git@fix/quit-wait # Configure aiida for the user COPY .docker/opt/configure-aiida.sh /opt/configure-aiida.sh diff --git a/aiida/backends/testbase.py b/aiida/backends/testbase.py index 68d8aa107c..055b759b94 100644 --- a/aiida/backends/testbase.py +++ b/aiida/backends/testbase.py @@ -11,8 +11,7 @@ import os import unittest import traceback - -from tornado import ioloop +import asyncio from aiida.common.exceptions import ConfigurationError, TestsNotAllowedError, InternalError from aiida.common.lang import classproperty @@ -84,17 +83,18 @@ def setUpClass(cls, *args, **kwargs): # pylint: disable=arguments-differ cls.insert_data() def setUp(self): - # Install a new IOLoop so that any messing up of the state of the loop is not propagated + # Install a new event loop so that any messing up of the state of the loop is not propagated # to subsequent tests. # This call should come before the backend instance setup call just in case it uses the loop - ioloop.IOLoop().make_current() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) def tearDown(self): # Clean up the loop we created in set up. # Call this after the instance tear down just in case it uses the loop reset_manager() - loop = ioloop.IOLoop.current() - if not loop._closing: # pylint: disable=protected-access,no-member + loop = asyncio.get_event_loop() + if not loop.is_closed(): loop.close() def reset_database(self): diff --git a/aiida/common/log.py b/aiida/common/log.py index 36310b36df..05679cc98e 100644 --- a/aiida/common/log.py +++ b/aiida/common/log.py @@ -83,11 +83,6 @@ def filter(self, record): 'level': lambda: get_config_option('logging.aiida_loglevel'), 'propagate': False, }, - 'tornado': { - 'handlers': ['console'], - 'level': lambda: get_config_option('logging.tornado_loglevel'), - 'propagate': False, - }, 'plumpy': { 'handlers': ['console'], 'level': lambda: get_config_option('logging.plumpy_loglevel'), diff --git a/aiida/engine/launch.py b/aiida/engine/launch.py index cea0378434..8cdcafbb9f 100644 --- a/aiida/engine/launch.py +++ b/aiida/engine/launch.py @@ -102,7 +102,6 @@ def submit(process, **inputs): raise InvalidOperation('Cannot use top-level `submit` from within another process, use `self.submit` instead') runner = manager.get_manager().get_runner() - controller = manager.get_manager().get_process_controller() process = instantiate_process(runner, process, **inputs) @@ -119,7 +118,7 @@ def submit(process, **inputs): process.close() # Do not wait for the future's result, because in the case of a single worker this would cock-block itself - controller.continue_process(process.pid, nowait=False, no_reply=True) + runner.controller.continue_process(process.pid, nowait=False, no_reply=True) return process.node diff --git a/aiida/engine/processes/calcjobs/manager.py b/aiida/engine/processes/calcjobs/manager.py index c6a2adfc96..4191f2494f 100644 --- a/aiida/engine/processes/calcjobs/manager.py +++ b/aiida/engine/processes/calcjobs/manager.py @@ -11,8 +11,7 @@ import contextlib import logging import time - -from tornado import concurrent, gen +import asyncio from aiida.common import lang @@ -50,7 +49,7 @@ def __init__(self, authinfo, transport_queue, last_updated=None): self._authinfo = authinfo self._transport_queue = transport_queue - self._loop = transport_queue.loop() + self._loop = transport_queue.loop self._logger = logging.getLogger(__name__) self._jobs_cache = {} @@ -83,8 +82,7 @@ def last_updated(self): """ return self._last_updated - @gen.coroutine - def _get_jobs_from_scheduler(self): + async def _get_jobs_from_scheduler(self): """Get the current jobs list from the scheduler. :return: a mapping of job ids to :py:class:`~aiida.schedulers.datastructures.JobInfo` instances @@ -92,7 +90,7 @@ def _get_jobs_from_scheduler(self): """ with self._transport_queue.request_transport(self._authinfo) as request: self.logger.info('waiting for transport') - transport = yield request + transport = await request scheduler = self._authinfo.computer.get_scheduler() scheduler.set_transport(transport) @@ -113,10 +111,9 @@ def _get_jobs_from_scheduler(self): for job_id, job_info in scheduler_response.items(): jobs_cache[job_id] = job_info - raise gen.Return(jobs_cache) + return jobs_cache - @gen.coroutine - def _update_job_info(self): + async def _update_job_info(self): """Update all of the job information objects. This will set the futures for all pending update requests where the corresponding job has a new status compared @@ -127,7 +124,7 @@ def _update_job_info(self): return # Update our cache of the job states - self._jobs_cache = yield self._get_jobs_from_scheduler() + self._jobs_cache = await self._get_jobs_from_scheduler() except Exception as exception: # Set the exception on all the update futures for future in self._job_update_requests.values(): @@ -158,7 +155,7 @@ def request_job_info_update(self, job_id): :return: future that will resolve to a `JobInfo` object when the job changes state """ # Get or create the future - request = self._job_update_requests.setdefault(job_id, concurrent.Future()) + request = self._job_update_requests.setdefault(job_id, asyncio.Future()) assert not request.done(), 'Expected pending job info future, found in done state.' try: @@ -173,19 +170,22 @@ def _ensure_updating(self): This will automatically stop if there are no outstanding requests. """ - @gen.coroutine - def updating(): + async def updating(): """Do the actual update, stop if not requests left.""" - yield self._update_job_info() + await self._update_job_info() # Any outstanding requests? if self._update_requests_outstanding(): - self._update_handle = self._loop.call_later(self._get_next_update_delay(), updating) + self._update_handle = self._loop.call_later( + self._get_next_update_delay(), asyncio.ensure_future, updating() + ) else: self._update_handle = None # Check if we're already updating if self._update_handle is None: - self._update_handle = self._loop.call_later(self._get_next_update_delay(), updating) + self._update_handle = self._loop.call_later( + self._get_next_update_delay(), asyncio.ensure_future, updating() + ) @staticmethod def _has_job_state_changed(old, new): @@ -274,7 +274,7 @@ def request_job_info_update(self, authinfo, job_id): This is a context manager so that if the user leaves the context the request is automatically cancelled. :return: A tuple containing the `JobInfo` object and detailed job info. Both can be None. - :rtype: :class:`tornado.concurrent.Future` + :rtype: :class:`asyncio.Future` """ with self.get_jobs_list(authinfo).request_job_info_update(job_id) as request: try: diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 5d0e07e5a2..3eb01e290b 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -12,8 +12,6 @@ import logging import tempfile -from tornado.gen import coroutine, Return - import plumpy from aiida.common.datastructures import CalcJobState @@ -35,15 +33,14 @@ RETRY_INTERVAL_OPTION = 'transport.task_retry_initial_interval' MAX_ATTEMPTS_OPTION = 'transport.task_maximum_attempts' -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # pylint: disable=invalid-name class PreSubmitException(Exception): """Raise in the `do_upload` coroutine when an exception is raised in `CalcJob.presubmit`.""" -@coroutine -def task_upload_job(process, transport_queue, cancellable): +async def task_upload_job(process, transport_queue, cancellable): """Transport task that will attempt to upload the files of a job calculation to the remote. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -55,24 +52,22 @@ def task_upload_job(process, transport_queue, cancellable): :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` - :raises: Return if the tasks was successfully completed :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ node = process.node if node.get_state() == CalcJobState.SUBMITTING: logger.warning(f'CalcJob<{node.pk}> already marked as SUBMITTING, skipping task_update_job') - raise Return + return initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) - @coroutine - def do_upload(): + async def do_upload(): with transport_queue.request_transport(authinfo) as request: - transport = yield cancellable.with_interrupt(request) + transport = await cancellable.with_interrupt(request) with SandboxFolder() as folder: # Any exception thrown in `presubmit` call is not transient so we circumvent the exponential backoff @@ -83,12 +78,12 @@ def do_upload(): else: execmanager.upload_calculation(node, transport, calc_info, folder) - raise Return + return try: logger.info(f'scheduled request to upload CalcJob<{node.pk}>') ignore_exceptions = (plumpy.CancelledError, PreSubmitException) - result = yield exponential_backoff_retry( + result = await exponential_backoff_retry( do_upload, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) except PreSubmitException: @@ -101,11 +96,10 @@ def do_upload(): else: logger.info(f'uploading CalcJob<{node.pk}> successful') node.set_state(CalcJobState.SUBMITTING) - raise Return(result) + return result -@coroutine -def task_submit_job(node, transport_queue, cancellable): +async def task_submit_job(node, transport_queue, cancellable): """Transport task that will attempt to submit a job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -117,28 +111,26 @@ def task_submit_job(node, transport_queue, cancellable): :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` - :raises: Return if the tasks was successfully completed :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ if node.get_state() == CalcJobState.WITHSCHEDULER: assert node.get_job_id() is not None, 'job is WITHSCHEDULER, however, it does not have a job id' logger.warning(f'CalcJob<{node.pk}> already marked as WITHSCHEDULER, skipping task_submit_job') - raise Return(node.get_job_id()) + return node.get_job_id() initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) - @coroutine - def do_submit(): + async def do_submit(): with transport_queue.request_transport(authinfo) as request: - transport = yield cancellable.with_interrupt(request) - raise Return(execmanager.submit_calculation(node, transport)) + transport = await cancellable.with_interrupt(request) + return execmanager.submit_calculation(node, transport) try: logger.info(f'scheduled request to submit CalcJob<{node.pk}>') - result = yield exponential_backoff_retry( + result = await exponential_backoff_retry( do_submit, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=plumpy.Interruption ) except plumpy.Interruption: @@ -149,11 +141,10 @@ def do_submit(): else: logger.info(f'submitting CalcJob<{node.pk}> successful') node.set_state(CalcJobState.WITHSCHEDULER) - raise Return(result) + return result -@coroutine -def task_update_job(node, job_manager, cancellable): +async def task_update_job(node, job_manager, cancellable): """Transport task that will attempt to update the scheduler status of the job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -167,11 +158,11 @@ def task_update_job(node, job_manager, cancellable): :type job_manager: :class:`aiida.engine.processes.calcjobs.manager.JobManager` :param cancellable: A cancel flag :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` - :raises: Return containing True if the tasks was successfully completed, False otherwise + :return: True if the tasks was successfully completed, False otherwise """ if node.get_state() == CalcJobState.RETRIEVING: logger.warning(f'CalcJob<{node.pk}> already marked as RETRIEVING, skipping task_update_job') - raise Return(True) + return True initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) @@ -179,11 +170,10 @@ def task_update_job(node, job_manager, cancellable): authinfo = node.computer.get_authinfo(node.user) job_id = node.get_job_id() - @coroutine - def do_update(): + async def do_update(): # Get the update request with job_manager.request_job_info_update(authinfo, job_id) as update_request: - job_info = yield cancellable.with_interrupt(update_request) + job_info = await cancellable.with_interrupt(update_request) if job_info is None: # If the job is computed or not found assume it's done @@ -194,11 +184,11 @@ def do_update(): node.set_scheduler_state(job_info.job_state) job_done = job_info.job_state == JobState.DONE - raise Return(job_done) + return job_done try: logger.info(f'scheduled request to update CalcJob<{node.pk}>') - job_done = yield exponential_backoff_retry( + job_done = await exponential_backoff_retry( do_update, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=plumpy.Interruption ) except plumpy.Interruption: @@ -211,11 +201,10 @@ def do_update(): if job_done: node.set_state(CalcJobState.RETRIEVING) - raise Return(job_done) + return job_done -@coroutine -def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, cancellable): +async def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, cancellable): """Transport task that will attempt to retrieve all files of a completed job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -227,22 +216,20 @@ def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, cancell :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` - :raises: Return if the tasks was successfully completed :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ if node.get_state() == CalcJobState.PARSING: logger.warning(f'CalcJob<{node.pk}> already marked as PARSING, skipping task_retrieve_job') - raise Return + return initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) authinfo = node.computer.get_authinfo(node.user) - @coroutine - def do_retrieve(): + async def do_retrieve(): with transport_queue.request_transport(authinfo) as request: - transport = yield cancellable.with_interrupt(request) + transport = await cancellable.with_interrupt(request) # Perform the job accounting and set it on the node if successful. If the scheduler does not implement this # still set the attribute but set it to `None`. This way we can distinguish calculation jobs for which the @@ -258,11 +245,11 @@ def do_retrieve(): else: node.set_detailed_job_info(detailed_job_info) - raise Return(execmanager.retrieve_calculation(node, transport, retrieved_temporary_folder)) + return execmanager.retrieve_calculation(node, transport, retrieved_temporary_folder) try: logger.info(f'scheduled request to retrieve CalcJob<{node.pk}>') - yield exponential_backoff_retry( + result = await exponential_backoff_retry( do_retrieve, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=plumpy.Interruption ) except plumpy.Interruption: @@ -273,11 +260,10 @@ def do_retrieve(): else: node.set_state(CalcJobState.PARSING) logger.info(f'retrieving CalcJob<{node.pk}> successful') - raise Return + return result -@coroutine -def task_kill_job(node, transport_queue, cancellable): +async def task_kill_job(node, transport_queue, cancellable): """Transport task that will attempt to kill a job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -289,7 +275,6 @@ def task_kill_job(node, transport_queue, cancellable): :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` - :raises: Return if the tasks was successfully completed :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ initial_interval = get_config_option(RETRY_INTERVAL_OPTION) @@ -297,19 +282,18 @@ def task_kill_job(node, transport_queue, cancellable): if node.get_state() in [CalcJobState.UPLOADING, CalcJobState.SUBMITTING]: logger.warning(f'CalcJob<{node.pk}> killed, it was in the {node.get_state()} state') - raise Return(True) + return True authinfo = node.computer.get_authinfo(node.user) - @coroutine - def do_kill(): + async def do_kill(): with transport_queue.request_transport(authinfo) as request: - transport = yield cancellable.with_interrupt(request) - raise Return(execmanager.kill_calculation(node, transport)) + transport = await cancellable.with_interrupt(request) + return execmanager.kill_calculation(node, transport) try: logger.info(f'scheduled request to kill CalcJob<{node.pk}>') - result = yield exponential_backoff_retry(do_kill, initial_interval, max_attempts, logger=node.logger) + result = await exponential_backoff_retry(do_kill, initial_interval, max_attempts, logger=node.logger) except plumpy.Interruption: raise except Exception: @@ -318,7 +302,7 @@ def do_kill(): else: logger.info(f'killing CalcJob<{node.pk}> successful') node.set_scheduler_state(JobState.DONE) - raise Return(result) + return result class Waiting(plumpy.Waiting): @@ -337,13 +321,13 @@ def load_instance_state(self, saved_state, load_context): self._task = None self._killing = None - @coroutine - def execute(self): + async def execute(self): # pylint: disable=invalid-overridden-method """Override the execute coroutine of the base `Waiting` state.""" # pylint: disable=too-many-branches node = self.process.node transport_queue = self.process.runner.transport command = self.data + result = self process_status = f'Waiting for transport task: {command}' @@ -351,13 +335,13 @@ def execute(self): if command == UPLOAD_COMMAND: node.set_process_status(process_status) - yield self._launch_task(task_upload_job, self.process, transport_queue) - raise Return(self.submit()) + await self._launch_task(task_upload_job, self.process, transport_queue) + result = self.submit() elif command == SUBMIT_COMMAND: node.set_process_status(process_status) - yield self._launch_task(task_submit_job, node, transport_queue) - raise Return(self.update()) + await self._launch_task(task_submit_job, node, transport_queue) + result = self.update() elif self.data == UPDATE_COMMAND: job_done = False @@ -367,16 +351,16 @@ def execute(self): scheduler_state_string = scheduler_state.name if scheduler_state else 'UNKNOWN' process_status = f'Monitoring scheduler: job state {scheduler_state_string}' node.set_process_status(process_status) - job_done = yield self._launch_task(task_update_job, node, self.process.runner.job_manager) + job_done = await self._launch_task(task_update_job, node, self.process.runner.job_manager) - raise Return(self.retrieve()) + result = self.retrieve() elif self.data == RETRIEVE_COMMAND: node.set_process_status(process_status) # Create a temporary folder that has to be deleted by JobProcess.retrieved after successful parsing temp_folder = tempfile.mkdtemp() - yield self._launch_task(task_retrieve_job, node, transport_queue, temp_folder) - raise Return(self.parse(temp_folder)) + await self._launch_task(task_retrieve_job, node, transport_queue, temp_folder) + result = self.parse(temp_folder) else: raise RuntimeError('Unknown waiting command') @@ -384,28 +368,27 @@ def execute(self): except TransportTaskException as exception: raise plumpy.PauseInterruption(f'Pausing after failed transport task: {exception}') except plumpy.KillInterruption: - yield self._launch_task(task_kill_job, node, transport_queue) + await self._launch_task(task_kill_job, node, transport_queue) self._killing.set_result(True) raise - except Return: - node.set_process_status(None) - raise except (plumpy.Interruption, plumpy.CancelledError): node.set_process_status(f'Transport task {command} was interrupted') raise + else: + node.set_process_status(None) + return result finally: # If we were trying to kill but we didn't deal with it, make sure it's set here if self._killing and not self._killing.done(): self._killing.set_result(False) - @coroutine - def _launch_task(self, coro, *args, **kwargs): + async def _launch_task(self, coro, *args, **kwargs): """Launch a coroutine as a task, making sure to make it interruptable.""" task_fn = functools.partial(coro, *args, **kwargs) try: self._task = interruptable_task(task_fn) - result = yield self._task - raise Return(result) + result = await self._task + return result finally: self._task = None diff --git a/aiida/engine/processes/functions.py b/aiida/engine/processes/functions.py index 1b57ea89aa..21156c8369 100644 --- a/aiida/engine/processes/functions.py +++ b/aiida/engine/processes/functions.py @@ -140,10 +140,9 @@ def run_get_node(*args, **kwargs): def kill_process(_num, _frame): """Send the kill signal to the process in the current scope.""" - from tornado import gen LOGGER.critical('runner received interrupt, killing process %s', process.pid) result = process.kill(msg='Process was killed because the runner received an interrupt') - raise gen.Return(result) + return result # Store the current handler on the signal such that it can be restored after process has terminated original_handler = signal.getsignal(kill_signal) diff --git a/aiida/engine/processes/futures.py b/aiida/engine/processes/futures.py index e98f25c64f..cf1d500cc8 100644 --- a/aiida/engine/processes/futures.py +++ b/aiida/engine/processes/futures.py @@ -9,15 +9,14 @@ ########################################################################### # pylint: disable=cyclic-import """Futures that can poll or receive broadcasted messages while waiting for a task to be completed.""" -import tornado.gen +import asyncio -import plumpy import kiwipy __all__ = ('ProcessFuture',) -class ProcessFuture(plumpy.Future): +class ProcessFuture(asyncio.Future): """Future that waits for a process to complete using both polling and listening for broadcast events if possible.""" _filtered = None @@ -36,7 +35,10 @@ def __init__(self, pk, loop=None, poll_interval=None, communicator=None): from aiida.orm import load_node from .process import ProcessState - super().__init__() + # create future in specified event loop + loop = loop if loop is not None else asyncio.get_event_loop() + super().__init__(loop=loop) + assert not (poll_interval is None and communicator is None), 'Must poll or have a communicator to use' node = load_node(pk=pk) @@ -56,7 +58,7 @@ def __init__(self, pk, loop=None, poll_interval=None, communicator=None): # Start polling if poll_interval is not None: - loop.add_callback(self._poll_process, node, poll_interval) + loop.create_task(self._poll_process(node, poll_interval)) def cleanup(self): """Clean up the future by removing broadcast subscribers from the communicator if it still exists.""" @@ -65,11 +67,10 @@ def cleanup(self): self._communicator = None self._broadcast_identifier = None - @tornado.gen.coroutine - def _poll_process(self, node, poll_interval): + async def _poll_process(self, node, poll_interval): """Poll whether the process node has reached a terminal state.""" while not self.done() and not node.is_terminated: - yield tornado.gen.sleep(poll_interval) + await asyncio.sleep(poll_interval) if not self.done(): self.set_result(node) diff --git a/aiida/engine/processes/process.py b/aiida/engine/processes/process.py index ae8cf25c7a..fbbdf57f4e 100644 --- a/aiida/engine/processes/process.py +++ b/aiida/engine/processes/process.py @@ -14,8 +14,7 @@ import uuid import traceback -from pika.exceptions import ConnectionClosed - +from aio_pika.exceptions import ConnectionClosed import plumpy from plumpy import ProcessState from kiwipy.communications import UnroutableError @@ -281,7 +280,7 @@ def kill(self, msg=None): if killing: # We are waiting for things to be killed, so return the 'gathered' future - result = plumpy.gather(killing) + result = plumpy.gather(*killing) return result diff --git a/aiida/engine/processes/workchains/workchain.py b/aiida/engine/processes/workchains/workchain.py index f0c1f96541..00f0f479f2 100644 --- a/aiida/engine/processes/workchains/workchain.py +++ b/aiida/engine/processes/workchains/workchain.py @@ -270,7 +270,7 @@ def action_awaitables(self): """ for awaitable in self._awaitables: if awaitable.target == AwaitableTarget.PROCESS: - callback = functools.partial(self._run_task, self.on_process_finished, awaitable) + callback = functools.partial(self.call_soon, self.on_process_finished, awaitable) self.runner.call_on_process_finish(awaitable.pk, callback) else: assert f"invalid awaitable target '{awaitable.target}'" diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index a0c43ed6d2..5b0d4798d7 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -15,10 +15,10 @@ import signal import threading import uuid +import asyncio import kiwipy import plumpy -import tornado.ioloop from aiida.common import exceptions from aiida.orm import load_node @@ -49,8 +49,7 @@ def __init__(self, poll_interval=0, loop=None, communicator=None, rmq_submit=Fal """Construct a new runner. :param poll_interval: interval in seconds between polling for status of active sub processes - :param loop: an event loop to use, if none is suppled a new one will be created - :type loop: :class:`tornado.ioloop.IOLoop` + :param loop: an asyncio event loop, if none is suppled a new one will be created :param communicator: the communicator to use :type communicator: :class:`kiwipy.Communicator` :param rmq_submit: if True, processes will be submitted to RabbitMQ, otherwise they will be scheduled here @@ -65,7 +64,7 @@ def __init__(self, poll_interval=0, loop=None, communicator=None, rmq_submit=Fal if loop is not None: self._loop = loop else: - self._loop = tornado.ioloop.IOLoop() + self._loop = asyncio.new_event_loop() self._do_close_loop = True self._poll_interval = poll_interval @@ -93,8 +92,7 @@ def loop(self): """ Get the event loop of this runner - :return: the event loop - :rtype: :class:`tornado.ioloop.IOLoop` + :return: the asyncio event loop """ return self._loop @@ -142,7 +140,7 @@ def is_closed(self): def start(self): """Start the internal event loop.""" - self._loop.start() + self._loop.run_forever() def stop(self): """Stop the internal event loop.""" @@ -151,7 +149,7 @@ def stop(self): def run_until_complete(self, future): """Run the loop until the future has finished and return the result.""" with utils.loop_scope(self._loop): - return self._loop.run_sync(lambda: future) + return self._loop.run_until_complete(future) def close(self): """Close the runner by stopping the loop.""" @@ -190,7 +188,7 @@ def submit(self, process, *args, **inputs): process.close() self.controller.continue_process(process.pid, nowait=False, no_reply=True) else: - self.loop.add_callback(process.step_until_terminated) + self.loop.create_task(process.step_until_terminated()) return process.node @@ -206,7 +204,7 @@ def schedule(self, process, *args, **inputs): assert not self._closed process = self.instantiate_process(process, *args, **inputs) - self.loop.add_callback(process.step_until_terminated) + self.loop.create_task(process.step_until_terminated()) return process.node def _run(self, process, *args, **inputs): @@ -329,6 +327,6 @@ def _poll_process(self, node, callback): if node.is_terminated: args = [node.__class__.__name__, node.pk] LOGGER.info('%s<%d> confirmed to be terminated by backup polling mechanism', *args) - self._loop.add_callback(callback) + self._loop.call_soon(callback) else: self._loop.call_later(self._poll_interval, self._poll_process, node, callback) diff --git a/aiida/engine/transports.py b/aiida/engine/transports.py index eb8cae8e0a..be028adb4f 100644 --- a/aiida/engine/transports.py +++ b/aiida/engine/transports.py @@ -12,7 +12,7 @@ import contextlib import logging import traceback -from tornado import concurrent, gen, ioloop +import asyncio _LOGGER = logging.getLogger(__name__) @@ -22,7 +22,7 @@ class TransportRequest: def __init__(self): super().__init__() - self.future = concurrent.Future() + self.future = asyncio.Future() self.count = 0 @@ -41,12 +41,12 @@ class TransportQueue: def __init__(self, loop=None): """ - :param loop: The event loop to use, will use `tornado.ioloop.IOLoop.current()` if not supplied - :type loop: :class:`tornado.ioloop.IOLoop` + :param loop: An asyncio event, will use `asyncio.get_event_loop()` if not supplied """ - self._loop = loop if loop is not None else ioloop.IOLoop.current() + self._loop = loop if loop is not None else asyncio.get_event_loop() self._transport_requests = {} + @property def loop(self): """ Get the loop being used by this transport queue """ return self._loop @@ -56,12 +56,11 @@ def request_transport(self, authinfo): """ Request a transport from an authinfo. Because the client is not allowed to request a transport immediately they will instead be given back a future - that can be yielded to get the transport:: + that can be awaited to get the transport:: - @tornado.gen.coroutine - def transport_task(transport_queue, authinfo): + async def transport_task(transport_queue, authinfo): with transport_queue.request_transport(authinfo) as request: - transport = yield request + transport = await request # Do some work with the transport :param authinfo: The authinfo to be used to get transport @@ -100,9 +99,6 @@ def do_open(): try: transport_request.count += 1 yield transport_request.future - except gen.Return: - # Have to have this special case so tornado returns are propagated up to the loop - raise except Exception: _LOGGER.error('Exception whilst using transport:\n%s', traceback.format_exc()) raise @@ -115,6 +111,6 @@ def do_open(): _LOGGER.debug('Transport request closing transport for %s', authinfo) transport_request.future.result().close() elif open_callback_handle is not None: - self._loop.remove_timeout(open_callback_handle) + open_callback_handle.cancel() self._transport_requests.pop(authinfo.id, None) diff --git a/aiida/engine/utils.py b/aiida/engine/utils.py index 4dac43df20..5130903966 100644 --- a/aiida/engine/utils.py +++ b/aiida/engine/utils.py @@ -12,9 +12,7 @@ import contextlib import logging - -import tornado.ioloop -from tornado import concurrent, gen +import asyncio __all__ = ('interruptable_task', 'InterruptableFuture', 'is_process_function') @@ -61,68 +59,69 @@ def instantiate_process(runner, process, *args, **inputs): return process -class InterruptableFuture(concurrent.Future): +class InterruptableFuture(asyncio.Future): """A future that can be interrupted by calling `interrupt`.""" def interrupt(self, reason): """This method should be called to interrupt the coroutine represented by this InterruptableFuture.""" self.set_exception(reason) - @gen.coroutine - def with_interrupt(self, yieldable): + async def with_interrupt(self, coro): """ - Yield a yieldable which will be interrupted if this future is interrupted :: + return result of a coroutine which will be interrupted if this future is interrupted :: - from tornado import ioloop, gen - loop = ioloop.IOLoop.current() + import asyncio + loop = asyncio.get_event_loop() interruptable = InterutableFuture() - loop.add_callback(interruptable.interrupt, RuntimeError("STOP")) - loop.run_sync(lambda: interruptable.with_interrupt(gen.sleep(2))) + loop.call_soon(interruptable.interrupt, RuntimeError("STOP")) + loop.run_until_complete(interruptable.with_interrupt(asyncio.sleep(2.))) >>> RuntimeError: STOP - :param yieldable: The yieldable - :return: The result of the yieldable + :param coro: The coroutine that can be interrupted + :return: The result of the coroutine """ - # Wait for one of the two to finish, if it's us that finishes we expect that it was - # because of an exception that will have been raised automatically - wait_iterator = gen.WaitIterator(yieldable, self) - result = yield wait_iterator.next() # pylint: disable=stop-iteration-return - if not wait_iterator.current_index == 0: - raise RuntimeError(f"This interruptible future had it's result set unexpectedly to {result}") + task = asyncio.ensure_future(coro) + wait_iter = asyncio.as_completed({self, task}) + result = await next(wait_iter) + if self.done(): + raise RuntimeError(f"This interruptible future had it's result set unexpectedly to '{result}'") - result = yield [yieldable, self][0] - raise gen.Return(result) + return result def interruptable_task(coro, loop=None): """ 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 - :param loop: the event loop in which to run the coroutine, by default uses tornado.ioloop.IOLoop.current() + :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() :return: an InterruptableFuture """ - loop = loop or tornado.ioloop.IOLoop.current() + loop = loop or asyncio.get_event_loop() future = InterruptableFuture() - @gen.coroutine - def execute_coroutine(): + async def execute_coroutine(): """Coroutine that wraps the original coroutine and sets it result on the future only if not already set.""" try: - result = yield coro(future) + result = await coro(future) except Exception as exception: # pylint: disable=broad-except if not future.done(): future.set_exception(exception) + else: + LOGGER.warning( + 'Interruptable future set to %s before its coro %s is done. %s', future.result(), coro.__name__, + str(exception) + ) else: # If the future has not been set elsewhere, i.e. by the interrupt call, by the time that the coroutine # is executed, set the future's result to the result of the coroutine if not future.done(): future.set_result(result) - loop.add_callback(execute_coroutine) + loop.create_task(execute_coroutine()) return future @@ -136,32 +135,29 @@ def ensure_coroutine(fct): :param fct: the function :returns: the coroutine """ - if tornado.gen.is_coroutine_function(fct): + if asyncio.iscoroutinefunction(fct): return fct - @tornado.gen.coroutine - def wrapper(*args, **kwargs): - raise tornado.gen.Return(fct(*args, **kwargs)) + async def wrapper(*args, **kwargs): + return fct(*args, **kwargs) return wrapper -@gen.coroutine -def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, logger=None, ignore_exceptions=None): +async def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, logger=None, ignore_exceptions=None): """ Coroutine to call a function, recalling it with an exponential backoff in the case of an exception This coroutine will loop ``max_attempts`` times, calling the ``fct`` function, breaking immediately when the call - finished without raising an exception, at which point the returned result will be raised, wrapped in a - ``tornado.gen.Result`` instance. If an exception is caught, the function will yield a ``tornado.gen.sleep`` with a - time interval equal to the ``initial_interval`` multiplied by ``2 ** (N - 1)`` where ``N`` is the number of - excepted calls. + finished without raising an exception, at which point the result will be returned. If an exception is caught, the + function will await a ``asyncio.sleep`` with a time interval equal to the ``initial_interval`` multiplied by + ``2 ** (N - 1)`` where ``N`` is the number of excepted calls. :param fct: the function to call, which will be turned into a coroutine first if it is not already :param initial_interval: the time to wait after the first caught exception before calling the coroutine again :param max_attempts: the maximum number of times to call the coroutine before re-raising the exception :param ignore_exceptions: list or tuple of exceptions to ignore, i.e. when caught do nothing and simply re-raise - :raises: ``tornado.gen.Result`` if the ``coro`` call completes within ``max_attempts`` retries without raising + :return: result if the ``coro`` call completes within ``max_attempts`` retries without raising """ if logger is None: logger = LOGGER @@ -172,7 +168,7 @@ def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, logger for iteration in range(max_attempts): try: - result = yield coro() + result = await coro() break # Finished successfully except Exception as exception: # pylint: disable=broad-except @@ -189,10 +185,10 @@ def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, logger raise else: logger.exception('iteration %d of %s excepted, retrying after %d seconds', count, coro_name, interval) - yield gen.sleep(interval) + await asyncio.sleep(interval) interval *= 2 - raise gen.Return(result) + return result def is_process_function(function): @@ -222,15 +218,15 @@ def loop_scope(loop): Make an event loop current for the scope of the context :param loop: The event loop to make current for the duration of the scope - :type loop: :class:`tornado.ioloop.IOLoop` + :type loop: asyncio event loop """ - current = tornado.ioloop.IOLoop.current() + current = asyncio.get_event_loop() try: - loop.make_current() + asyncio.set_event_loop(loop) yield finally: - current.make_current() + asyncio.set_event_loop(current) def set_process_state_change_timestamp(process): diff --git a/aiida/manage/configuration/options.py b/aiida/manage/configuration/options.py index 631f1b396a..0c29be1923 100644 --- a/aiida/manage/configuration/options.py +++ b/aiida/manage/configuration/options.py @@ -89,14 +89,6 @@ 'description': 'Minimum level to log to the DbLog table', 'global_only': False, }, - 'logging.tornado_loglevel': { - 'key': 'logging_tornado_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'WARNING', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `tornado` logger', - 'global_only': False, - }, 'logging.plumpy_loglevel': { 'key': 'logging_plumpy_log_level', 'valid_type': 'string', diff --git a/aiida/manage/external/rmq.py b/aiida/manage/external/rmq.py index 6b66c8d9c8..2f7bbc1df3 100644 --- a/aiida/manage/external/rmq.py +++ b/aiida/manage/external/rmq.py @@ -12,7 +12,6 @@ import collections import logging -from tornado import gen from kiwipy import communications, Future import plumpy @@ -152,8 +151,7 @@ def handle_continue_exception(node, exception, message): node.set_process_state(ProcessState.EXCEPTED) node.seal() - @gen.coroutine - def _continue(self, communicator, pid, nowait, tag=None): + async def _continue(self, communicator, pid, nowait, tag=None): """Continue the task. Note that the task may already have been completed, as indicated from the corresponding the node, in which @@ -180,7 +178,7 @@ def _continue(self, communicator, pid, nowait, tag=None): # we raise `Return` instead of `TaskRejected` because the latter would cause the task to be resent and start # to ping-pong between RabbitMQ and the daemon workers. LOGGER.exception('Cannot continue process<%d>', pid) - raise gen.Return(False) from exception + return False if node.is_terminated: @@ -195,10 +193,10 @@ def _continue(self, communicator, pid, nowait, tag=None): elif node.is_killed: future.set_exception(plumpy.KilledError()) - raise gen.Return(future.result()) + return future.result() try: - result = yield super()._continue(communicator, pid, nowait, tag) + result = await super()._continue(communicator, pid, nowait, tag) except ImportError as exception: message = 'the class of the process could not be imported.' self.handle_continue_exception(node, exception, message) @@ -215,4 +213,4 @@ def _continue(self, communicator, pid, nowait, tag=None): LOGGER.exception('failed to serialize the result for process<%d>', pid) raise - raise gen.Return(serialized) + return serialized diff --git a/aiida/manage/manager.py b/aiida/manage/manager.py index ed79afd026..5bd5d7f803 100644 --- a/aiida/manage/manager.py +++ b/aiida/manage/manager.py @@ -287,8 +287,8 @@ def create_daemon_runner(self, loop=None): This is used by workers when the daemon is running and in testing. - :param loop: the (optional) tornado event loop to use - :type loop: :class:`tornado.ioloop.IOLoop` + :param loop: the (optional) asyncio event loop to use + :type loop: the asyncio event loop :return: a runner configured to work in the daemon configuration :rtype: :class:`aiida.engine.runners.Runner` """ @@ -314,7 +314,7 @@ def create_daemon_runner(self, loop=None): def close(self): """Reset the global settings entirely and release any global objects.""" if self._communicator is not None: - self._communicator.stop() + self._communicator.close() if self._runner is not None: self._runner.stop() diff --git a/aiida/manage/tests/pytest_fixtures.py b/aiida/manage/tests/pytest_fixtures.py index 2512546bc4..191cbbc8f9 100644 --- a/aiida/manage/tests/pytest_fixtures.py +++ b/aiida/manage/tests/pytest_fixtures.py @@ -17,6 +17,7 @@ * aiida_local_code_factory """ +import asyncio import shutil import tempfile import pytest @@ -59,6 +60,19 @@ def clear_database_before_test(aiida_profile): yield +@pytest.fixture(scope='function') +def temporary_event_loop(): + """Create a temporary loop for independent test case""" + current = asyncio.get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield + finally: + loop.close() + asyncio.set_event_loop(current) + + @pytest.fixture(scope='function') def temp_dir(): """Get a temporary directory. diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index 19cfad02a8..5a8971e28f 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -60,10 +60,7 @@ py:class paramiko.proxy.ProxyCommand py:class plumpy.ports.PortNamespace py:class plumpy.utils.AttributesDict -py:class topika.Connection - -py:class tornado.ioloop.IOLoop -py:class tornado.concurrent.Future +py:class _asyncio.Future py:class tqdm.std.tqdm diff --git a/environment.yml b/environment.yml index 04411ca482..d128ef2212 100644 --- a/environment.yml +++ b/environment.yml @@ -9,7 +9,8 @@ dependencies: - aldjemy~=0.9.1 - alembic~=1.2 - archive-path~=0.2.1 -- circus~=0.16.1 +- aio-pika~=6.6 +- circus~=0.17.1 - click-completion~=0.5.1 - click-config-file~=0.6.0 - click-spinner~=0.1.8 @@ -20,11 +21,10 @@ dependencies: - python-graphviz~=0.13 - ipython~=7.0 - jinja2~=2.10 -- kiwipy[rmq]~=0.5.5 +- kiwipy[rmq]~=0.6.1 - numpy~=1.17 - paramiko~=2.7 -- pika~=1.1 -- plumpy~=0.15.1 +- plumpy~=0.16.1 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 @@ -36,8 +36,6 @@ dependencies: - sqlalchemy-utils~=0.34.2 - sqlalchemy>=1.3.10,~=1.3 - tabulate~=0.8.5 -- tornado<5.0 -- topika~=0.2.2 - tqdm~=4.45 - tzlocal~=2.0 - upf_to_json~=0.9.2 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 4089d6d26c..4abb31fa55 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -2,6 +2,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 alembic==1.4.1 +aio-pika~=6.6.1 aniso8601==8.0.0 appdirs==1.4.4 appnope==0.1.0 @@ -15,7 +16,7 @@ bleach==3.1.4 certifi==2019.11.28 cffi==1.14.0 chardet==3.0.4 -circus==0.16.1 +circus==0.17.1 Click==7.0 click-completion==0.5.2 click-config-file==0.6.0 @@ -55,7 +56,7 @@ jupyter==1.0.0 jupyter-client==6.0.0 jupyter-console==6.1.0 jupyter-core==4.6.3 -kiwipy==0.5.5 +kiwipy==0.6.1 kiwisolver==1.1.0 Mako==1.1.2 MarkupSafe==1.1.1 @@ -68,7 +69,7 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.4 networkx==2.4 -notebook==5.7.8 +notebook~=6.0.0 numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 @@ -83,9 +84,8 @@ pg8000==1.13.2 pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 -pika==1.1.0 pluggy==0.13.1 -plumpy==0.15.1 +plumpy==0.16.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 @@ -109,6 +109,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.8.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.3.4 +pytest-asyncio~=0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 @@ -154,8 +155,6 @@ tabulate==0.8.6 terminado==0.8.3 testpath==0.4.4 toml==0.10.1 -topika==0.2.2 -tornado==4.5.3 tqdm==4.45.0 traitlets==4.3.3 typed-ast==1.4.1 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 4d527da138..5171bdf17d 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -2,6 +2,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 alembic==1.4.1 +aio-pika~=6.6.1 aniso8601==8.0.0 appdirs==1.4.4 appnope==0.1.0 @@ -15,7 +16,7 @@ bleach==3.1.4 certifi==2019.11.28 cffi==1.14.0 chardet==3.0.4 -circus==0.16.1 +circus==0.17.1 Click==7.0 click-completion==0.5.2 click-config-file==0.6.0 @@ -54,7 +55,7 @@ jupyter==1.0.0 jupyter-client==6.0.0 jupyter-console==6.1.0 jupyter-core==4.6.3 -kiwipy==0.5.5 +kiwipy==0.6.1 kiwisolver==1.1.0 Mako==1.1.2 MarkupSafe==1.1.1 @@ -67,7 +68,7 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.4 networkx==2.4 -notebook==5.7.8 +notebook~=6.0.0 numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 @@ -82,9 +83,8 @@ pg8000==1.13.2 pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 -pika==1.1.0 pluggy==0.13.1 -plumpy==0.15.1 +plumpy==0.16.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 @@ -109,6 +109,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.8.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.3.4 +pytest-asyncio~=0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 @@ -154,8 +155,6 @@ tabulate==0.8.6 terminado==0.8.3 testpath==0.4.4 toml==0.10.1 -topika==0.2.2 -tornado==4.5.3 tqdm==4.45.0 traitlets==4.3.3 typed-ast==1.4.1 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 886f9db80d..9a46ac743d 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -1,6 +1,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 +aio-pika~=6.6.1 alembic==1.4.1 aniso8601==8.0.0 appnope==0.1.0 @@ -14,7 +15,7 @@ bleach==3.1.4 certifi==2019.11.28 cffi==1.14.0 chardet==3.0.4 -circus==0.16.1 +circus==0.17.1 Click==7.0 click-completion==0.5.2 click-config-file==0.6.0 @@ -51,7 +52,7 @@ jupyter==1.0.0 jupyter-client==6.0.0 jupyter-console==6.1.0 jupyter-core==4.6.3 -kiwipy==0.5.5 +kiwipy==0.6.1 kiwisolver==1.1.0 Mako==1.1.2 MarkupSafe==1.1.1 @@ -63,7 +64,7 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.4 networkx==2.4 -notebook==5.7.8 +notebook~=6.0.0 numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 @@ -77,9 +78,8 @@ pg8000==1.13.2 pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 -pika==1.1.0 pluggy==0.13.1 -plumpy==0.15.1 +plumpy==0.16.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 @@ -102,6 +102,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.8.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.3.4 +pytest-asyncio~=0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 @@ -147,8 +148,6 @@ tabulate==0.8.6 terminado==0.8.3 testpath==0.4.4 toml==0.10.1 -topika==0.2.2 -tornado==4.5.3 tqdm==4.45.0 traitlets==4.3.3 tzlocal==2.0.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 07e249f4f0..2f9dd6b226 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -1,6 +1,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 +aio-pika~=6.6.1 alembic==1.4.3 aniso8601==8.0.0 archive-path==0.2.1 @@ -13,7 +14,7 @@ bleach==3.2.1 certifi==2020.6.20 cffi==1.14.3 chardet==3.0.4 -circus==0.16.1 +circus==0.17.1 click==7.1.2 click-completion==0.5.2 click-config-file==0.6.0 @@ -50,7 +51,7 @@ jupyter==1.0.0 jupyter-client==6.1.7 jupyter-console==6.2.0 jupyter-core==4.6.3 -kiwipy==0.5.5 +kiwipy==0.6.1 kiwisolver==1.3.1 Mako==1.1.3 MarkupSafe==1.1.1 @@ -61,7 +62,7 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.8 networkx==2.5 -notebook==5.7.10 +notebook~=6.0.0 numpy==1.19.4 orderedmultidict==1.0.1 packaging==20.4 @@ -79,7 +80,7 @@ pika==1.1.0 Pillow==8.0.1 plotly==4.12.0 pluggy==0.13.1 -plumpy==0.15.1 +plumpy==0.16.1 prometheus-client==0.8.0 prompt-toolkit==3.0.8 psutil==5.7.3 @@ -144,8 +145,6 @@ tabulate==0.8.7 terminado==0.9.1 testpath==0.4.4 toml==0.10.2 -topika==0.2.2 -tornado==4.5.3 tqdm==4.51.0 traitlets==5.0.5 tzlocal==2.1 diff --git a/setup.json b/setup.json index 173bb42c01..8a6504baf4 100644 --- a/setup.json +++ b/setup.json @@ -24,7 +24,8 @@ "aldjemy~=0.9.1", "alembic~=1.2", "archive-path~=0.2.1", - "circus~=0.16.1", + "aio-pika~=6.6", + "circus~=0.17.1", "click-completion~=0.5.1", "click-config-file~=0.6.0", "click-spinner~=0.1.8", @@ -35,11 +36,10 @@ "graphviz~=0.13", "ipython~=7.0", "jinja2~=2.10", - "kiwipy[rmq]~=0.5.5", + "kiwipy[rmq]~=0.6.1", "numpy~=1.17", "paramiko~=2.7", - "pika~=1.1", - "plumpy~=0.15.1", + "plumpy~=0.16.1", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", @@ -51,8 +51,6 @@ "sqlalchemy-utils~=0.34.2", "sqlalchemy~=1.3,>=1.3.10", "tabulate~=0.8.5", - "tornado<5.0", - "topika~=0.2.2", "tqdm~=4.45", "tzlocal~=2.0", "upf_to_json~=0.9.2", @@ -91,8 +89,8 @@ "spglib~=1.14" ], "notebook": [ - "jupyter==1.0.0", - "notebook<6" + "jupyter~=1.0", + "notebook~=6.0" ], "pre-commit": [ "mypy==0.790", diff --git a/tests/cmdline/commands/test_data.py b/tests/cmdline/commands/test_data.py index c280f4bbc5..96b5828799 100644 --- a/tests/cmdline/commands/test_data.py +++ b/tests/cmdline/commands/test_data.py @@ -10,6 +10,7 @@ # pylint: disable=no-member, too-many-lines """Test data-related verdi commands.""" +import asyncio import io import os import shutil @@ -298,8 +299,17 @@ def connect_structure_bands(strct): # pylint: disable=unused-argument @classmethod def setUpClass(cls): # pylint: disable=arguments-differ super().setUpClass() + + # create a new event loop since the privious one is closed by other test case + cls.loop = asyncio.new_event_loop() + asyncio.set_event_loop(cls.loop) cls.ids = cls.create_structure_bands() + @classmethod + def tearDownClass(cls): # pylint: disable=arguments-differ + cls.loop.close() + super().tearDownClass() + def setUp(self): self.cli_runner = CliRunner() diff --git a/tests/cmdline/commands/test_process.py b/tests/cmdline/commands/test_process.py index af1ae5384b..bc41c83f0b 100644 --- a/tests/cmdline/commands/test_process.py +++ b/tests/cmdline/commands/test_process.py @@ -8,16 +8,15 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for `verdi process`.""" -import datetime import subprocess import sys import time +import asyncio from concurrent.futures import Future from click.testing import CliRunner -from tornado import gen -import kiwipy import plumpy +import kiwipy from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_process @@ -107,7 +106,7 @@ def test_pause_play_kill(self): # that we have the latest state of the node as it is in the database, we force refresh it by reloading it. calc = load_node(calc.pk) if calc.process_state != plumpy.ProcessState.WAITING: - self.runner.loop.run_sync(lambda: with_timeout(waiting_future)) + self.runner.loop.run_until_complete(asyncio.wait_for(waiting_future, timeout=5.0)) # Here we now that the process is with the daemon runner and in the waiting state so we can starting running # the `verdi process` commands that we want to test @@ -488,8 +487,3 @@ def test_multiple_processes(self): self.assertIn('No callers found', get_result_lines(result)[0]) self.assertIn(str(self.node_root.pk), get_result_lines(result)[1]) self.assertIn(str(self.node_root.pk), get_result_lines(result)[2]) - - -@gen.coroutine -def with_timeout(what, timeout=5.0): - raise gen.Return((yield gen.with_timeout(datetime.timedelta(seconds=timeout), what))) diff --git a/tests/engine/test_futures.py b/tests/engine/test_futures.py index 521693137d..b3e2babee7 100644 --- a/tests/engine/test_futures.py +++ b/tests/engine/test_futures.py @@ -8,9 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module to test process futures.""" -import datetime - -from tornado import gen +import asyncio from aiida.backends.testbase import AiidaTestCase from aiida.engine import processes, run @@ -21,7 +19,7 @@ class TestWf(AiidaTestCase): """Test process futures.""" - TIMEOUT = datetime.timedelta(seconds=5.0) + TIMEOUT = 5.0 # seconds def test_calculation_future_broadcasts(self): """Test calculation future broadcasts.""" @@ -31,11 +29,11 @@ def test_calculation_future_broadcasts(self): # No polling future = processes.futures.ProcessFuture( - pk=process.pid, poll_interval=None, communicator=manager.get_communicator() + pk=process.pid, loop=runner.loop, communicator=manager.get_communicator() ) run(process) - calc_node = runner.run_until_complete(gen.with_timeout(self.TIMEOUT, future)) + calc_node = runner.run_until_complete(asyncio.wait_for(future, self.TIMEOUT)) self.assertEqual(process.node.pk, calc_node.pk) @@ -49,6 +47,6 @@ def test_calculation_future_polling(self): future = processes.futures.ProcessFuture(pk=process.pid, loop=runner.loop, poll_interval=0) runner.run(process) - calc_node = runner.run_until_complete(gen.with_timeout(self.TIMEOUT, future)) + calc_node = runner.run_until_complete(asyncio.wait_for(future, self.TIMEOUT)) self.assertEqual(process.node.pk, calc_node.pk) diff --git a/tests/engine/test_manager.py b/tests/engine/test_manager.py index 4e2748e901..574f30713f 100644 --- a/tests/engine/test_manager.py +++ b/tests/engine/test_manager.py @@ -10,8 +10,7 @@ """Tests for the classes in `aiida.engine.processes.calcjobs.manager`.""" import time - -import tornado +import asyncio from aiida.orm import AuthInfo, User from aiida.backends.testbase import AiidaTestCase @@ -24,7 +23,7 @@ class TestJobManager(AiidaTestCase): def setUp(self): super().setUp() - self.loop = tornado.ioloop.IOLoop() + self.loop = asyncio.get_event_loop() self.transport_queue = TransportQueue(self.loop) self.user = User.objects.get_default() self.auth_info = AuthInfo(self.computer, self.user).store() @@ -45,7 +44,7 @@ def test_get_jobs_list(self): def test_request_job_info_update(self): """Test the `JobManager.request_job_info_update` method.""" with self.manager.request_job_info_update(self.auth_info, job_id=1) as request: - self.assertIsInstance(request, tornado.concurrent.Future) + self.assertIsInstance(request, asyncio.Future) class TestJobsList(AiidaTestCase): @@ -53,7 +52,7 @@ class TestJobsList(AiidaTestCase): def setUp(self): super().setUp() - self.loop = tornado.ioloop.IOLoop() + self.loop = asyncio.get_event_loop() self.transport_queue = TransportQueue(self.loop) self.user = User.objects.get_default() self.auth_info = AuthInfo(self.computer, self.user).store() diff --git a/tests/engine/test_rmq.py b/tests/engine/test_rmq.py index 670b971d2e..c552f22746 100644 --- a/tests/engine/test_rmq.py +++ b/tests/engine/test_rmq.py @@ -8,13 +8,12 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module to test RabbitMQ.""" -import datetime +import asyncio -from tornado import gen import plumpy from aiida.backends.testbase import AiidaTestCase -from aiida.engine import ProcessState, submit +from aiida.engine import ProcessState from aiida.manage.manager import get_manager from aiida.orm import Int @@ -29,141 +28,136 @@ class TestProcessControl(AiidaTestCase): def setUp(self): super().setUp() - # These two need to share a common event loop otherwise the first will never send - # the message while the daemon is running listening to intercept + # The coroutine defined in testcase should run in runner's loop + # and process need submit by runner.submit rather than `submit` import from + # aiida.engine, since the broad one will create its own loop manager = get_manager() self.runner = manager.get_runner() - self.daemon_runner = manager.create_daemon_runner(loop=self.runner.loop) - - def tearDown(self): - self.daemon_runner.close() - super().tearDown() def test_submit_simple(self): """"Launch the process.""" - @gen.coroutine - def do_submit(): - calc_node = submit(test_processes.DummyProcess) - yield self.wait_for_process(calc_node) + async def do_submit(): + calc_node = self.runner.submit(test_processes.DummyProcess) + await self.wait_for_process(calc_node) self.assertTrue(calc_node.is_finished_ok) self.assertEqual(calc_node.process_state.value, plumpy.ProcessState.FINISHED.value) - self.runner.loop.run_sync(do_submit) + self.runner.loop.run_until_complete(do_submit()) def test_launch_with_inputs(self): """Test launch with inputs.""" - @gen.coroutine - def do_launch(): + async def do_launch(): term_a = Int(5) term_b = Int(10) - calc_node = submit(test_processes.AddProcess, a=term_a, b=term_b) - yield self.wait_for_process(calc_node) + calc_node = self.runner.submit(test_processes.AddProcess, a=term_a, b=term_b) + await self.wait_for_process(calc_node) self.assertTrue(calc_node.is_finished_ok) self.assertEqual(calc_node.process_state.value, plumpy.ProcessState.FINISHED.value) - self.runner.loop.run_sync(do_launch) + self.runner.loop.run_until_complete(do_launch()) def test_submit_bad_input(self): with self.assertRaises(ValueError): - submit(test_processes.AddProcess, a=Int(5)) + self.runner.submit(test_processes.AddProcess, a=Int(5)) def test_exception_process(self): """Test process excpetion.""" - @gen.coroutine - def do_exception(): - calc_node = submit(test_processes.ExceptionProcess) - yield self.wait_for_process(calc_node) + async def do_exception(): + calc_node = self.runner.submit(test_processes.ExceptionProcess) + await self.wait_for_process(calc_node) self.assertFalse(calc_node.is_finished_ok) self.assertEqual(calc_node.process_state.value, plumpy.ProcessState.EXCEPTED.value) - self.runner.loop.run_sync(do_exception) + self.runner.loop.run_until_complete(do_exception()) def test_pause(self): """Testing sending a pause message to the process.""" controller = get_manager().get_process_controller() - @gen.coroutine - def do_pause(): - calc_node = submit(test_processes.WaitProcess) + async def do_pause(): + calc_node = self.runner.submit(test_processes.WaitProcess) while calc_node.process_state != ProcessState.WAITING: - yield + await asyncio.sleep(0.1) self.assertFalse(calc_node.paused) - future = yield with_timeout(controller.pause_process(calc_node.pk)) - result = yield self.wait_future(future) + pause_future = controller.pause_process(calc_node.pk) + future = await with_timeout(asyncio.wrap_future(pause_future)) + result = await self.wait_future(asyncio.wrap_future(future)) self.assertTrue(result) self.assertTrue(calc_node.paused) - self.runner.loop.run_sync(do_pause) + self.runner.loop.run_until_complete(do_pause()) def test_pause_play(self): """Test sending a pause and then a play message.""" controller = get_manager().get_process_controller() - @gen.coroutine - def do_pause_play(): - calc_node = submit(test_processes.WaitProcess) + async def do_pause_play(): + calc_node = self.runner.submit(test_processes.WaitProcess) self.assertFalse(calc_node.paused) while calc_node.process_state != ProcessState.WAITING: - yield + await asyncio.sleep(0.1) pause_message = 'Take a seat' - future = yield with_timeout(controller.pause_process(calc_node.pk, msg=pause_message)) - result = yield self.wait_future(future) + pause_future = controller.pause_process(calc_node.pk, msg=pause_message) + future = await with_timeout(asyncio.wrap_future(pause_future)) + result = await self.wait_future(asyncio.wrap_future(future)) self.assertTrue(calc_node.paused) self.assertEqual(calc_node.process_status, pause_message) - future = yield with_timeout(controller.play_process(calc_node.pk)) - result = yield self.wait_future(future) + play_future = controller.play_process(calc_node.pk) + future = await with_timeout(asyncio.wrap_future(play_future)) + result = await self.wait_future(asyncio.wrap_future(future)) + self.assertTrue(result) self.assertFalse(calc_node.paused) self.assertEqual(calc_node.process_status, None) - self.runner.loop.run_sync(do_pause_play) + self.runner.loop.run_until_complete(do_pause_play()) def test_kill(self): """Test sending a kill message.""" controller = get_manager().get_process_controller() - @gen.coroutine - def do_kill(): - calc_node = submit(test_processes.WaitProcess) + async def do_kill(): + calc_node = self.runner.submit(test_processes.WaitProcess) self.assertFalse(calc_node.is_killed) while calc_node.process_state != ProcessState.WAITING: - yield + await asyncio.sleep(0.1) kill_message = 'Sorry, you have to go mate' - future = yield with_timeout(controller.kill_process(calc_node.pk, msg=kill_message)) - result = yield self.wait_future(future) + kill_future = controller.kill_process(calc_node.pk, msg=kill_message) + future = await with_timeout(asyncio.wrap_future(kill_future)) + result = await self.wait_future(asyncio.wrap_future(future)) self.assertTrue(result) - self.wait_for_process(calc_node) + await self.wait_for_process(calc_node) self.assertTrue(calc_node.is_killed) self.assertEqual(calc_node.process_status, kill_message) - self.runner.loop.run_sync(do_kill) + self.runner.loop.run_until_complete(do_kill()) - @gen.coroutine - def wait_for_process(self, calc_node, timeout=2.): + async def wait_for_process(self, calc_node, timeout=2.): future = self.runner.get_process_future(calc_node.pk) - raise gen.Return((yield with_timeout(future, timeout))) + result = await with_timeout(future, timeout) + return result @staticmethod - @gen.coroutine - def wait_future(future, timeout=2.): - raise gen.Return((yield with_timeout(future, timeout))) + async def wait_future(future, timeout=2.): + result = await with_timeout(future, timeout) + return result -@gen.coroutine -def with_timeout(what, timeout=5.0): - raise gen.Return((yield gen.with_timeout(datetime.timedelta(seconds=timeout), what))) +async def with_timeout(what, timeout=5.0): + result = await asyncio.wait_for(what, timeout) + return result diff --git a/tests/engine/test_runners.py b/tests/engine/test_runners.py index 41b8887c6f..a7a5f407ab 100644 --- a/tests/engine/test_runners.py +++ b/tests/engine/test_runners.py @@ -10,6 +10,7 @@ # pylint: disable=redefined-outer-name """Module to test process runners.""" import threading +import asyncio import plumpy import pytest @@ -24,7 +25,8 @@ def create_runner(): """Construct and return a `Runner`.""" def _create_runner(poll_interval=0.5): - return get_manager().create_runner(poll_interval=poll_interval) + loop = asyncio.new_event_loop() + return get_manager().create_runner(poll_interval=poll_interval, loop=loop) return _create_runner @@ -53,7 +55,7 @@ def test_call_on_process_finish(create_runner): def calc_done(): if event.is_set(): - future.set_exc_info(AssertionError('the callback was called twice, which should never happen')) + future.set_exception(AssertionError('the callback was called twice, which should never happen')) future.set_result(True) event.set() @@ -62,9 +64,9 @@ def calc_done(): runner.call_on_process_finish(proc.node.pk, calc_done) # Run the calculation - runner.loop.add_callback(proc.step_until_terminated) + runner.loop.create_task(proc.step_until_terminated()) loop.call_later(5, the_hans_klok_comeback, runner.loop) - loop.start() + loop.run_forever() - assert not future.exc_info() + assert not future.exception() assert future.result() diff --git a/tests/engine/test_transport.py b/tests/engine/test_transport.py index 9974fc1d9f..cae5b4e895 100644 --- a/tests/engine/test_transport.py +++ b/tests/engine/test_transport.py @@ -8,7 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module to test transport.""" -from tornado.gen import coroutine, Return +import asyncio from aiida.backends.testbase import AiidaTestCase from aiida.engine.transports import TransportQueue @@ -30,70 +30,65 @@ def tearDown(self, *args, **kwargs): # pylint: disable=arguments-differ def test_simple_request(self): """ Test a simple transport request """ queue = TransportQueue() - loop = queue.loop() + loop = queue.loop - @coroutine - def test(): + async def test(): trans = None with queue.request_transport(self.authinfo) as request: - trans = yield request + trans = await request self.assertTrue(trans.is_open) self.assertFalse(trans.is_open) - loop.run_sync(lambda: test()) # pylint: disable=unnecessary-lambda + loop.run_until_complete(test()) def test_get_transport_nested(self): """Test nesting calls to get the same transport.""" transport_queue = TransportQueue() - loop = transport_queue.loop() + loop = transport_queue.loop - @coroutine - def nested(queue, authinfo): + async def nested(queue, authinfo): with queue.request_transport(authinfo) as request1: - trans1 = yield request1 + trans1 = await request1 self.assertTrue(trans1.is_open) with queue.request_transport(authinfo) as request2: - trans2 = yield request2 + trans2 = await request2 self.assertIs(trans1, trans2) self.assertTrue(trans2.is_open) - loop.run_sync(lambda: nested(transport_queue, self.authinfo)) + loop.run_until_complete(nested(transport_queue, self.authinfo)) def test_get_transport_interleaved(self): """Test interleaved calls to get the same transport.""" transport_queue = TransportQueue() - loop = transport_queue.loop() + loop = transport_queue.loop - @coroutine - def interleaved(authinfo): + async def interleaved(authinfo): with transport_queue.request_transport(authinfo) as trans_future: - yield trans_future + await trans_future - loop.run_sync(lambda: [interleaved(self.authinfo), interleaved(self.authinfo)]) + loop.run_until_complete(asyncio.gather(interleaved(self.authinfo), interleaved(self.authinfo))) def test_return_from_context(self): """Test raising a Return from coroutine context.""" queue = TransportQueue() - loop = queue.loop() + loop = queue.loop - @coroutine - def test(): + async def test(): with queue.request_transport(self.authinfo) as request: - trans = yield request - raise Return(trans.is_open) + trans = await request + return trans.is_open - retval = loop.run_sync(lambda: test()) # pylint: disable=unnecessary-lambda + retval = loop.run_until_complete(test()) self.assertTrue(retval) def test_open_fail(self): """Test that if opening fails.""" queue = TransportQueue() - loop = queue.loop() + loop = queue.loop - @coroutine - def test(): + async def test(): with queue.request_transport(self.authinfo) as request: - yield request + await request def broken_open(trans): raise RuntimeError('Could not open transport') @@ -104,7 +99,7 @@ def broken_open(trans): original = self.authinfo.get_transport().__class__.open self.authinfo.get_transport().__class__.open = broken_open with self.assertRaises(RuntimeError): - loop.run_sync(lambda: test()) # pylint: disable=unnecessary-lambda + loop.run_until_complete(test()) finally: self.authinfo.get_transport().__class__.open = original @@ -120,22 +115,21 @@ def test_safe_interval(self): import time queue = TransportQueue() - loop = queue.loop() + loop = queue.loop time_start = time.time() - @coroutine - def test(iteration): + async def test(iteration): trans = None with queue.request_transport(self.authinfo) as request: - trans = yield request + trans = await request time_current = time.time() time_elapsed = time_current - time_start time_minimum = trans.get_safe_open_interval() * (iteration + 1) self.assertTrue(time_elapsed > time_minimum, 'transport safe interval was violated') - for i in range(5): - loop.run_sync(lambda iteration=i: test(iteration)) + for iteration in range(5): + loop.run_until_complete(test(iteration)) finally: transport_class._DEFAULT_SAFE_OPEN_INTERVAL = original_interval # pylint: disable=protected-access diff --git a/tests/engine/test_utils.py b/tests/engine/test_utils.py index b0121c9325..d2b3ac724e 100644 --- a/tests/engine/test_utils.py +++ b/tests/engine/test_utils.py @@ -9,13 +9,15 @@ ########################################################################### # pylint: disable=global-statement """Test engine utilities such as the exponential backoff mechanism.""" -from tornado.ioloop import IOLoop -from tornado.gen import coroutine +import asyncio + +import pytest from aiida import orm from aiida.backends.testbase import AiidaTestCase from aiida.engine import calcfunction, workfunction -from aiida.engine.utils import exponential_backoff_retry, is_process_function +from aiida.engine.utils import exponential_backoff_retry, is_process_function, \ + InterruptableFuture, interruptable_task ITERATION = 0 MAX_ITERATIONS = 3 @@ -36,10 +38,9 @@ def test_exp_backoff_success(): """Test that exponential backoff will successfully catch exceptions as long as max_attempts is not exceeded.""" global ITERATION ITERATION = 0 - loop = IOLoop() + loop = asyncio.get_event_loop() - @coroutine - def coro(): + async def coro(): """A function that will raise RuntimeError as long as ITERATION is smaller than MAX_ITERATIONS.""" global ITERATION ITERATION += 1 @@ -47,15 +48,14 @@ def coro(): raise RuntimeError max_attempts = MAX_ITERATIONS + 1 - loop.run_sync(lambda: exponential_backoff_retry(coro, initial_interval=0.1, max_attempts=max_attempts)) + loop.run_until_complete(exponential_backoff_retry(coro, initial_interval=0.1, max_attempts=max_attempts)) def test_exp_backoff_max_attempts_exceeded(self): """Test that exponential backoff will finally raise if max_attempts is exceeded""" global ITERATION ITERATION = 0 - loop = IOLoop() + loop = asyncio.get_event_loop() - @coroutine def coro(): """A function that will raise RuntimeError as long as ITERATION is smaller than MAX_ITERATIONS.""" global ITERATION @@ -65,7 +65,11 @@ def coro(): max_attempts = MAX_ITERATIONS - 1 with self.assertRaises(RuntimeError): - loop.run_sync(lambda: exponential_backoff_retry(coro, initial_interval=0.1, max_attempts=max_attempts)) + loop.run_until_complete(exponential_backoff_retry(coro, initial_interval=0.1, max_attempts=max_attempts)) + + +class TestUtils(AiidaTestCase): + """ Tests for engine utils.""" def test_is_process_function(self): """Test the `is_process_function` utility.""" @@ -84,3 +88,140 @@ def work_function(): self.assertEqual(is_process_function(normal_function), False) self.assertEqual(is_process_function(calc_function), True) self.assertEqual(is_process_function(work_function), True) + + def test_is_process_scoped(self): + pass + + def test_loop_scope(self): + pass + + +class TestInterruptable(AiidaTestCase): + """ Tests for InterruptableFuture and interruptable_task.""" + + def test_normal_future(self): + """Test interrupt future not being interrupted""" + loop = asyncio.get_event_loop() + + interruptable = InterruptableFuture() + fut = asyncio.Future() + + async def task(): + fut.set_result('I am done') + + loop.run_until_complete(interruptable.with_interrupt(task())) + self.assertFalse(interruptable.done()) + self.assertEqual(fut.result(), 'I am done') + + def test_interrupt(self): + """Test interrupt future being interrupted""" + loop = asyncio.get_event_loop() + + interruptable = InterruptableFuture() + loop.call_soon(interruptable.interrupt, RuntimeError('STOP')) + try: + loop.run_until_complete(interruptable.with_interrupt(asyncio.sleep(10.))) + except RuntimeError as err: + self.assertEqual(str(err), 'STOP') + else: + self.fail('ExpectedException not raised') + + self.assertTrue(interruptable.done()) + + def test_inside_interrupted(self): + """Test interrupt future being interrupted from inside of coroutine""" + loop = asyncio.get_event_loop() + + interruptable = InterruptableFuture() + fut = asyncio.Future() + + async def task(): + await asyncio.sleep(1.) + interruptable.interrupt(RuntimeError('STOP')) + fut.set_result('I got set.') + + try: + loop.run_until_complete(interruptable.with_interrupt(task())) + except RuntimeError as err: + self.assertEqual(str(err), 'STOP') + else: + self.fail('ExpectedException not raised') + + self.assertTrue(interruptable.done()) + self.assertEqual(fut.result(), 'I got set.') + + def test_interruptable_future_set(self): + """Test interrupt future being set before coroutine is done""" + loop = asyncio.get_event_loop() + + interruptable = InterruptableFuture() + + async def task(): + interruptable.set_result('NOT ME!!!') + + loop.create_task(task()) + try: + loop.run_until_complete(interruptable.with_interrupt(asyncio.sleep(20.))) + except RuntimeError as err: + self.assertEqual(str(err), "This interruptible future had it's result set unexpectedly to 'NOT ME!!!'") + else: + self.fail('ExpectedException not raised') + + self.assertTrue(interruptable.done()) + + +class TestInterruptableTask(AiidaTestCase): + """ Tests for InterruptableFuture and interruptable_task.""" + + @pytest.mark.asyncio + async def test_task(self): + """Test coroutine run and succed""" + + async def task_fn(cancellable): + fut = asyncio.Future() + + async def coro(): + fut.set_result('I am done') + + await cancellable.with_interrupt(coro()) + return fut.result() + + task_fut = interruptable_task(task_fn) + result = await task_fut + self.assertTrue(isinstance(task_fut, InterruptableFuture)) + self.assertTrue(task_fut.done()) + self.assertEqual(result, 'I am done') + + @pytest.mark.asyncio + async def test_interrupted(self): + """Test interrupt future being interrupted""" + + async def task_fn(cancellable): + cancellable.interrupt(RuntimeError('STOP')) + + task_fut = interruptable_task(task_fn) + try: + await task_fut + except RuntimeError as err: + self.assertEqual(str(err), 'STOP') + else: + self.fail('ExpectedException not raised') + + @pytest.mark.asyncio + async def test_future_already_set(self): + """Test interrupt future being set before coroutine is done""" + + async def task_fn(cancellable): + fut = asyncio.Future() + + async def coro(): + fut.set_result('I am done') + + await cancellable.with_interrupt(coro()) + cancellable.set_result('NOT ME!!!') + return fut.result() + + task_fut = interruptable_task(task_fn) + + result = await task_fut + self.assertEqual(result, 'NOT ME!!!') diff --git a/tests/engine/test_work_chain.py b/tests/engine/test_work_chain.py index 040176894d..a96cc88789 100644 --- a/tests/engine/test_work_chain.py +++ b/tests/engine/test_work_chain.py @@ -13,7 +13,6 @@ import unittest import plumpy -from tornado import gen import pytest from aiida import orm @@ -28,7 +27,7 @@ def run_until_paused(proc): - """ Set up a future that will be resolved on entering the WAITING state """ + """ Set up a future that will be resolved when process is paused""" listener = plumpy.ProcessListener() paused = plumpy.Future() @@ -95,7 +94,7 @@ def define(cls, spec): spec.outputs.dynamic = True spec.outline( cls.step1, - if_(cls.is_a)(cls.step2).elif_(cls.is_b)(cls.step3).else_(cls.step4), + if_(cls.is_a)(cls.step2).elif_(cls.is_b)(cls.step3).else_(cls.step4), # pylint: disable=no-member cls.step5, while_(cls.larger_then_n)(cls.step6,), ) @@ -111,37 +110,37 @@ def on_create(self): } def step1(self): - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) def step2(self): - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) def step3(self): - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) def step4(self): - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) def step5(self): self.ctx.counter = 0 - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) def step6(self): self.ctx.counter = self.ctx.counter + 1 - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) def is_a(self): - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) return self.inputs.value.value == 'A' def is_b(self): - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) return self.inputs.value.value == 'B' def larger_then_n(self): keep_looping = self.ctx.counter < self.inputs.n.value if not keep_looping: - self._set_finished(inspect.stack()[0][3]) + self._set_finished(inspect.stack()[0].function) return keep_looping def _set_finished(self, function_name): @@ -252,8 +251,8 @@ def define(cls, spec): super().define(spec) spec.outline(if_(cls.condition)(cls.step1, cls.step2)) - def on_create(self, *args, **kwargs): - super().on_create(*args, **kwargs) + def on_create(self): + super().on_create() self.ctx.s1 = False self.ctx.s2 = False @@ -684,9 +683,8 @@ def test_if_block_persistence(self): wc = IfTest() runner.schedule(wc) - @gen.coroutine - def run_async(workchain): - yield run_until_paused(workchain) + async def run_async(workchain): + await run_until_paused(workchain) self.assertTrue(workchain.ctx.s1) self.assertFalse(workchain.ctx.s2) @@ -704,11 +702,11 @@ def run_async(workchain): self.assertDictEqual(bundle, bundle2) workchain.play() - yield workchain.future() + await workchain.future() self.assertTrue(workchain.ctx.s1) self.assertTrue(workchain.ctx.s2) - runner.loop.run_sync(lambda: run_async(wc)) # pylint: disable=unnecessary-lambda + runner.loop.run_until_complete(run_async(wc)) def test_report_dbloghandler(self): """ @@ -885,18 +883,17 @@ def test_simple_run(self): runner = get_manager().get_runner() process = TestWorkChainAbort.AbortableWorkChain() - @gen.coroutine - def run_async(): - yield run_until_paused(process) + async def run_async(): + await run_until_paused(process) process.play() with Capturing(): with self.assertRaises(RuntimeError): - yield process.future() + await process.future() runner.schedule(process) - runner.loop.run_sync(lambda: run_async()) # pylint: disable=unnecessary-lambda + runner.loop.run_until_complete(run_async()) self.assertEqual(process.node.is_finished_ok, False) self.assertEqual(process.node.is_excepted, True) @@ -911,9 +908,8 @@ def test_simple_kill_through_process(self): runner = get_manager().get_runner() process = TestWorkChainAbort.AbortableWorkChain() - @gen.coroutine - def run_async(): - yield run_until_paused(process) + async def run_async(): + await run_until_paused(process) self.assertTrue(process.paused) process.kill() @@ -922,7 +918,7 @@ def run_async(): launch.run(process) runner.schedule(process) - runner.loop.run_sync(lambda: run_async()) # pylint: disable=unnecessary-lambda + runner.loop.run_until_complete(run_async()) self.assertEqual(process.node.is_finished_ok, False) self.assertEqual(process.node.is_excepted, False) @@ -998,17 +994,16 @@ def test_simple_kill_through_process(self): runner = get_manager().get_runner() process = TestWorkChainAbortChildren.MainWorkChain(inputs={'kill': Bool(True)}) - @gen.coroutine - def run_async(): - yield run_until_waiting(process) + async def run_async(): + await run_until_waiting(process) process.kill() with self.assertRaises(plumpy.KilledError): - yield process.future() + await process.future() runner.schedule(process) - runner.loop.run_sync(lambda: run_async()) # pylint: disable=unnecessary-lambda + runner.loop.run_until_complete(run_async()) child = process.node.get_outgoing(link_type=LinkType.CALL_WORK).first().node self.assertEqual(child.is_finished_ok, False) diff --git a/tests/tools/importexport/orm/test_calculations.py b/tests/tools/importexport/orm/test_calculations.py index 74f1007bd4..55668fc2ad 100644 --- a/tests/tools/importexport/orm/test_calculations.py +++ b/tests/tools/importexport/orm/test_calculations.py @@ -23,10 +23,12 @@ class TestCalculations(AiidaTestCase): """Test ex-/import cases related to Calculations""" def setUp(self): + super().setUp() self.reset_database() def tearDown(self): self.reset_database() + super().tearDown() @with_temp_dir def test_calcfunction(self, temp_dir): diff --git a/tests/workflows/arithmetic/test_add_multiply.py b/tests/workflows/arithmetic/test_add_multiply.py index ddbee359b1..297c4440fd 100644 --- a/tests/workflows/arithmetic/test_add_multiply.py +++ b/tests/workflows/arithmetic/test_add_multiply.py @@ -21,7 +21,7 @@ def test_factory(): assert loaded.is_process_function -@pytest.mark.usefixtures('clear_database_before_test') +@pytest.mark.usefixtures('clear_database_before_test', 'temporary_event_loop') def test_run(): """Test running the work function.""" x = Int(1) From 8ea68f1aca6d02dbd43b4b784c558aaef4ae93b1 Mon Sep 17 00:00:00 2001 From: Jason Eu Date: Fri, 4 Sep 2020 11:38:29 +0200 Subject: [PATCH 015/114] `Process.kill`: properly resolve the killing futures The result returned by `ProcessController.kill_process` that is called in `Process.kill` for each of its children, if it has any, can itself be a future, since the killing cannot always be performed directly, but instead will be scheduled in the event loop. To resolve the future of the main process, it will have to wait for the futures of all its children to be resolved as well. Therefore an intermediate future needs to be added that will be done once all child futures are resolved. --- aiida/engine/processes/process.py | 21 ++++++++++++++------- aiida/manage/external/rmq.py | 2 +- tests/engine/test_rmq.py | 12 ++++++++++++ tests/engine/test_work_chain.py | 5 ++++- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/aiida/engine/processes/process.py b/aiida/engine/processes/process.py index fbbdf57f4e..4c2da21c8d 100644 --- a/aiida/engine/processes/process.py +++ b/aiida/engine/processes/process.py @@ -13,6 +13,8 @@ import inspect import uuid import traceback +import asyncio +from typing import Union from aio_pika.exceptions import ConnectionClosed import plumpy @@ -246,14 +248,11 @@ def load_instance_state(self, saved_state, load_context): self.node.logger.info(f'Loaded process<{self.node.pk}> from saved state') - def kill(self, msg=None): + def kill(self, msg: Union[str, None] = None) -> Union[bool, plumpy.Future]: """ Kill the process and all the children calculations it called :param msg: message - :type msg: str - - :rtype: bool """ self.node.logger.info(f'Request to kill Process<{self.node.pk}>') @@ -267,20 +266,28 @@ def kill(self, msg=None): for child in self.node.called: try: result = self.runner.controller.kill_process(child.pk, f'Killed by parent<{self.node.pk}>') - if isinstance(result, plumpy.Future): + result = asyncio.wrap_future(result) + if asyncio.isfuture(result): killing.append(result) except ConnectionClosed: self.logger.info('no connection available to kill child<%s>', child.pk) except UnroutableError: self.logger.info('kill signal was unable to reach child<%s>', child.pk) - if isinstance(result, plumpy.Future): + if asyncio.isfuture(result): # We ourselves are waiting to be killed so add it to the list killing.append(result) if killing: # We are waiting for things to be killed, so return the 'gathered' future - result = plumpy.gather(*killing) + kill_future = plumpy.gather(*killing) + result = self.loop.create_future() + + def done(done_future: plumpy.Future): + is_all_killed = all(done_future.result()) + result.set_result(is_all_killed) + + kill_future.add_done_callback(done) return result diff --git a/aiida/manage/external/rmq.py b/aiida/manage/external/rmq.py index 2f7bbc1df3..6dc353002c 100644 --- a/aiida/manage/external/rmq.py +++ b/aiida/manage/external/rmq.py @@ -145,7 +145,7 @@ def handle_continue_exception(node, exception, message): """ from aiida.engine import ProcessState - if not node.is_excepted: + if not node.is_excepted and not node.is_sealed: node.logger.exception(message) node.set_exception(str(exception)) node.set_process_state(ProcessState.EXCEPTED) diff --git a/tests/engine/test_rmq.py b/tests/engine/test_rmq.py index c552f22746..23074d983f 100644 --- a/tests/engine/test_rmq.py +++ b/tests/engine/test_rmq.py @@ -94,6 +94,12 @@ async def do_pause(): self.assertTrue(result) self.assertTrue(calc_node.paused) + kill_message = 'Sorry, you have to go mate' + kill_future = controller.kill_process(calc_node.pk, msg=kill_message) + future = await with_timeout(asyncio.wrap_future(kill_future)) + result = await self.wait_future(asyncio.wrap_future(future)) + self.assertTrue(result) + self.runner.loop.run_until_complete(do_pause()) def test_pause_play(self): @@ -122,6 +128,12 @@ async def do_pause_play(): self.assertFalse(calc_node.paused) self.assertEqual(calc_node.process_status, None) + kill_message = 'Sorry, you have to go mate' + kill_future = controller.kill_process(calc_node.pk, msg=kill_message) + future = await with_timeout(asyncio.wrap_future(kill_future)) + result = await self.wait_future(asyncio.wrap_future(future)) + self.assertTrue(result) + self.runner.loop.run_until_complete(do_pause_play()) def test_kill(self): diff --git a/tests/engine/test_work_chain.py b/tests/engine/test_work_chain.py index a96cc88789..141284726c 100644 --- a/tests/engine/test_work_chain.py +++ b/tests/engine/test_work_chain.py @@ -11,6 +11,7 @@ """Tests for the `WorkChain` class.""" import inspect import unittest +import asyncio import plumpy import pytest @@ -997,7 +998,9 @@ def test_simple_kill_through_process(self): async def run_async(): await run_until_waiting(process) - process.kill() + result = process.kill() + if asyncio.isfuture(result): + await result with self.assertRaises(plumpy.KilledError): await process.future() From cd0d15c79d48146be38104456313aa0a389411af Mon Sep 17 00:00:00 2001 From: Jason Eu Date: Fri, 4 Sep 2020 11:43:06 +0200 Subject: [PATCH 016/114] Unwrap the futures returned by `ProcessController` in `verdi process` The commands of `verdi process` that perform an RPC on a live process will do so through the `ProcessController`, which returns a future. Currently, the process controller uses the `LoopCommunicator` as its communicator which adds an additional layer of wrapping. Ideally, the return type of the communicator should not change depending on the specific implementation that is used, however, for now that is the case and so the future needs to be unwrapped explicitly one additional time. Once the `LoopCommunicator` is fixed to return the same future type as the base `Communicator` class, this workaround can and should be removed. --- aiida/cmdline/commands/cmd_process.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aiida/cmdline/commands/cmd_process.py b/aiida/cmdline/commands/cmd_process.py index 04513ad94a..ae18c2f553 100644 --- a/aiida/cmdline/commands/cmd_process.py +++ b/aiida/cmdline/commands/cmd_process.py @@ -336,6 +336,7 @@ def process_actions(futures_map, infinitive, present, past, wait=False, timeout= """ # pylint: disable=too-many-branches import kiwipy + from plumpy.futures import unwrap_kiwi_future from concurrent import futures from aiida.manage.external.rmq import CommunicationTimeout @@ -347,6 +348,8 @@ def process_actions(futures_map, infinitive, present, past, wait=False, timeout= process = futures_map[future] try: + # unwrap is need here since LoopCommunicator will also wrap a future + future = unwrap_kiwi_future(future) result = future.result() except CommunicationTimeout: echo.echo_error(f'call to {infinitive} Process<{process.pk}> timed out') From 20300d2449c9d0e8fe13c585f88f0112ef0bc69b Mon Sep 17 00:00:00 2001 From: Jason Eu Date: Wed, 12 Aug 2020 12:48:16 +0800 Subject: [PATCH 017/114] `Runner`: use global event loop and global runner for process functions With the migration to `asyncio`, there is now only a single event loop that is made reentrant through the `nest-asyncio` library, that monkey patches `asyncio`'s built-in mechanism to prevent this. This means that in the `Runner` constructor, we should simply get the global event loop instead of creating a new one, if no explicit loop is passed into the constructor. This also implies that the runner should never take charge in closing the loop, because it no longer owns the global loop. In addition, process functions now simply use the global runner instead of creating a new runner. This used to be necessary because running in the same runner, would mean running in the same loop and so the child process would block the parent. However, with the new design on `asyncio`, everything runs in a single reentrant loop and so child processes no longer need to spawn their own independent nested runner. --- aiida/engine/processes/functions.py | 7 +------ aiida/engine/runners.py | 11 +---------- aiida/manage/manager.py | 4 ++-- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/aiida/engine/processes/functions.py b/aiida/engine/processes/functions.py index 21156c8369..12238d4580 100644 --- a/aiida/engine/processes/functions.py +++ b/aiida/engine/processes/functions.py @@ -107,17 +107,13 @@ def run_get_node(*args, **kwargs): """ Run the FunctionProcess with the supplied inputs in a local runner. - The function will have to create a new runner for the FunctionProcess instead of using the global runner, - because otherwise if this process function were to call another one from within its scope, that would use - the same runner and it would be blocking the event loop from continuing. - :param args: input arguments to construct the FunctionProcess :param kwargs: input keyword arguments to construct the FunctionProcess :return: tuple of the outputs of the process and the process node pk :rtype: (dict, int) """ manager = get_manager() - runner = manager.create_runner(with_persistence=False) + runner = manager.get_runner(with_persistence=False) inputs = process_class.create_inputs(*args, **kwargs) # Remove all the known inputs from the kwargs @@ -154,7 +150,6 @@ def kill_process(_num, _frame): # If the `original_handler` is set, that means the `kill_process` was bound, which needs to be reset if original_handler: signal.signal(signal.SIGINT, original_handler) - runner.close() store_provenance = inputs.get('metadata', {}).get('store_provenance', True) if not store_provenance: diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index 5b0d4798d7..c4756c5ac8 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -59,14 +59,7 @@ def __init__(self, poll_interval=0, loop=None, communicator=None, rmq_submit=Fal assert not (rmq_submit and persister is None), \ 'Must supply a persister if you want to submit using communicator' - # Runner take responsibility to clear up loop only if the loop was created by Runner - self._do_close_loop = False - if loop is not None: - self._loop = loop - else: - self._loop = asyncio.new_event_loop() - self._do_close_loop = True - + self._loop = loop if loop is not None else asyncio.get_event_loop() self._poll_interval = poll_interval self._rmq_submit = rmq_submit self._transport = transports.TransportQueue(self._loop) @@ -155,8 +148,6 @@ def close(self): """Close the runner by stopping the loop.""" assert not self._closed self.stop() - if self._do_close_loop: - self._loop.close() self._closed = True def instantiate_process(self, process, *args, **inputs): diff --git a/aiida/manage/manager.py b/aiida/manage/manager.py index 5bd5d7f803..762952c196 100644 --- a/aiida/manage/manager.py +++ b/aiida/manage/manager.py @@ -234,14 +234,14 @@ def get_process_controller(self): return self._process_controller - def get_runner(self): + def get_runner(self, **kwargs): """Return a runner that is based on the current profile settings and can be used globally by the code. :return: the global runner :rtype: :class:`aiida.engine.runners.Runner` """ if self._runner is None: - self._runner = self.create_runner() + self._runner = self.create_runner(**kwargs) return self._runner From 281241c6cc86c1e62cabc41ede9cba862df22257 Mon Sep 17 00:00:00 2001 From: Jason Eu Date: Fri, 4 Sep 2020 11:36:18 +0200 Subject: [PATCH 018/114] Engine: cancel active tasks when a daemon runner is shutdown When a daemon runner is started, the `SIGINT` and `SIGTERM` signals are captured to shutdown the runner before exiting the interpreter. However, the async tasks associated with the interpreter should be properly canceled first. --- aiida/engine/daemon/runner.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/aiida/engine/daemon/runner.py b/aiida/engine/daemon/runner.py index c428dc9da0..227a79505f 100644 --- a/aiida/engine/daemon/runner.py +++ b/aiida/engine/daemon/runner.py @@ -10,6 +10,7 @@ """Function that starts a daemon runner.""" import logging import signal +import asyncio from aiida.common.log import configure_logging from aiida.engine.daemon.client import get_daemon_client @@ -31,16 +32,24 @@ def start_daemon(): LOGGER.exception('daemon runner failed to start') raise - def shutdown_daemon(_num, _frame): + async def shutdown(runner): + """Cleanup tasks tied to the service's shutdown.""" LOGGER.info('Received signal to shut down the daemon runner') - runner.close() - signal.signal(signal.SIGINT, shutdown_daemon) - signal.signal(signal.SIGTERM, shutdown_daemon) + tasks = [t for t in asyncio.Task.all_tasks() if t is not asyncio.Task.current_task()] + + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + runner.close() - LOGGER.info('Starting a daemon runner') + signals = (signal.SIGTERM, signal.SIGINT) + for s in signals: # pylint: disable=invalid-name + runner.loop.add_signal_handler(s, lambda s=s: asyncio.create_task(shutdown(runner))) try: + LOGGER.info('Starting a daemon runner') runner.start() except SystemError as exception: LOGGER.info('Received a SystemError: %s', exception) From 460b57148bf790ebb8fb68c49d275712e1ae0b8c Mon Sep 17 00:00:00 2001 From: Jason Eu Date: Tue, 20 Oct 2020 14:11:12 +0800 Subject: [PATCH 019/114] Engine: enable `plumpy`'s reentrant event loop policy The event loop implementation of `asyncio` does not allow to make the event loop to be reentrant, which essentially means that event loops cannot be nested. One event loop cannot be run within another event loop. However, this concept is crucial for `plumpy`'s design to work and was perfectly allowed by the previous event loop provider `tornado`. To work around this, `plumpy` uses the library `nest_asyncio` to patch the `asyncio` event loop and make it reentrant. The trick is that this should be applied at the correct time. Here we update the `Runner` to enable `plumpy`'s event loop policy, which will patch the default event loop policy. This location is chosen since any process in `aiida-core` *has* to be run by a `Runner` and only one runner instance will ever be created in a Python interpreter. When the runner shuts down, the event policy is reset to undo the patch. --- aiida/engine/runners.py | 3 +++ environment.yml | 2 +- requirements/requirements-py-3.6.txt | 2 +- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- requirements/requirements-py-3.9.txt | 2 +- setup.json | 2 +- 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index c4756c5ac8..be2a3d377b 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -19,6 +19,7 @@ import kiwipy import plumpy +from plumpy import set_event_loop_policy, reset_event_loop_policy from aiida.common import exceptions from aiida.orm import load_node @@ -59,6 +60,7 @@ def __init__(self, poll_interval=0, loop=None, communicator=None, rmq_submit=Fal assert not (rmq_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._poll_interval = poll_interval self._rmq_submit = rmq_submit @@ -148,6 +150,7 @@ def close(self): """Close the runner by stopping the loop.""" assert not self._closed self.stop() + reset_event_loop_policy() self._closed = True def instantiate_process(self, process, *args, **inputs): diff --git a/environment.yml b/environment.yml index d128ef2212..74bec6da9d 100644 --- a/environment.yml +++ b/environment.yml @@ -24,7 +24,7 @@ dependencies: - kiwipy[rmq]~=0.6.1 - numpy~=1.17 - paramiko~=2.7 -- plumpy~=0.16.1 +- plumpy~=0.17.1 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 4abb31fa55..003ed1cb43 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -85,7 +85,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.16.1 +plumpy==0.17.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 5171bdf17d..962c557218 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -84,7 +84,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.16.1 +plumpy==0.17.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 9a46ac743d..5d78a9e9ff 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -79,7 +79,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.16.1 +plumpy==0.17.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 2f9dd6b226..be0fe5d025 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -80,7 +80,7 @@ pika==1.1.0 Pillow==8.0.1 plotly==4.12.0 pluggy==0.13.1 -plumpy==0.16.1 +plumpy==0.17.1 prometheus-client==0.8.0 prompt-toolkit==3.0.8 psutil==5.7.3 diff --git a/setup.json b/setup.json index 8a6504baf4..4adeec32f4 100644 --- a/setup.json +++ b/setup.json @@ -39,7 +39,7 @@ "kiwipy[rmq]~=0.6.1", "numpy~=1.17", "paramiko~=2.7", - "plumpy~=0.16.1", + "plumpy~=0.17.1", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", From c75aeb4bbc61f09cda1b2615a3a1d069ebbb0a33 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Fri, 13 Nov 2020 22:25:46 +0100 Subject: [PATCH 020/114] Tests: do not create or destroy event loop in test setup/teardown --- aiida/backends/testbase.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/aiida/backends/testbase.py b/aiida/backends/testbase.py index 055b759b94..5d0f25a66b 100644 --- a/aiida/backends/testbase.py +++ b/aiida/backends/testbase.py @@ -11,7 +11,6 @@ import os import unittest import traceback -import asyncio from aiida.common.exceptions import ConfigurationError, TestsNotAllowedError, InternalError from aiida.common.lang import classproperty @@ -82,20 +81,8 @@ def setUpClass(cls, *args, **kwargs): # pylint: disable=arguments-differ cls.clean_db() cls.insert_data() - def setUp(self): - # Install a new event loop so that any messing up of the state of the loop is not propagated - # to subsequent tests. - # This call should come before the backend instance setup call just in case it uses the loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - def tearDown(self): - # Clean up the loop we created in set up. - # Call this after the instance tear down just in case it uses the loop reset_manager() - loop = asyncio.get_event_loop() - if not loop.is_closed(): - loop.close() def reset_database(self): """Reset the database to the default state deleting any content currently stored""" From 716a1d8f6801bd9ba72162a73497e1085a64bc52 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 18 Nov 2020 17:13:52 +0100 Subject: [PATCH 021/114] Engine: explicitly enable compatibility for RabbitMQ 3.5 RabbitMQ 3.6 changed the way integer values are interpreted for connection parameters. This would cause certain integer values that used to be perfectly acceptable, to all of suddent cause the declaration of resources, such as channels and queues, to fail. The library `pamqp`, that is used by `aiormq`, which in turn is used ultimately by `kiwipy` to communicate with the RabbitMQ server, adapted to these changes, but this would break code with RabbitMQ 3.5 that used to work just fine. For example, the message TTL when declaring a queue would now fail when `32767 < TTL < 655636` due to incorrect interpretation of the integer type. The library `pamqp` provides a way to enable compatibility with these older versions. One should merely call the method: pamqp.encode.support_deprecated_rabbitmq() This will enable the legacy integer conversion table and will restore functionality for RabbitMQ 3.5. --- aiida/manage/external/rmq.py | 7 +++++++ environment.yml | 1 + requirements/requirements-py-3.6.txt | 1 + requirements/requirements-py-3.7.txt | 1 + requirements/requirements-py-3.8.txt | 1 + requirements/requirements-py-3.9.txt | 1 + setup.json | 1 + 7 files changed, 13 insertions(+) diff --git a/aiida/manage/external/rmq.py b/aiida/manage/external/rmq.py index 6dc353002c..a62590b547 100644 --- a/aiida/manage/external/rmq.py +++ b/aiida/manage/external/rmq.py @@ -13,12 +13,19 @@ import logging from kiwipy import communications, Future +import pamqp.encode import plumpy from aiida.common.extendeddicts import AttributeDict __all__ = ('RemoteException', 'CommunicationTimeout', 'DeliveryFailed', 'ProcessLauncher', 'BROKER_DEFAULTS') +# The following statement enables support for RabbitMQ 3.5 because without it, connections established by `aiormq` will +# fail because the interpretation of the types of integers passed in connection parameters has changed after that +# version. Once RabbitMQ 3.5 is no longer supported (it has been EOL since October 2016) this can be removed. This +# should also allow to remove the direct dependency on `pamqp` entirely. +pamqp.encode.support_deprecated_rabbitmq() + LOGGER = logging.getLogger(__name__) RemoteException = plumpy.RemoteException diff --git a/environment.yml b/environment.yml index 74bec6da9d..b40e8edfad 100644 --- a/environment.yml +++ b/environment.yml @@ -23,6 +23,7 @@ dependencies: - jinja2~=2.10 - kiwipy[rmq]~=0.6.1 - numpy~=1.17 +- pamqp~=2.0 - paramiko~=2.7 - plumpy~=0.17.1 - pgsu~=0.1.0 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 003ed1cb43..3e56c17c5f 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -74,6 +74,7 @@ numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 palettable==3.3.0 +pamqp~=2.0 pandas==0.25.3 pandocfilters==1.4.2 paramiko==2.7.1 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 962c557218..f3e20bd067 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -73,6 +73,7 @@ numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 palettable==3.3.0 +pamqp~=2.0 pandas==0.25.3 pandocfilters==1.4.2 paramiko==2.7.1 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 5d78a9e9ff..92daa182d9 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -69,6 +69,7 @@ numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 palettable==3.3.0 +pamqp~=2.0 pandas==0.25.3 pandocfilters==1.4.2 paramiko==2.7.1 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index be0fe5d025..e7cee5e1a7 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -67,6 +67,7 @@ numpy==1.19.4 orderedmultidict==1.0.1 packaging==20.4 palettable==3.3.0 +pamqp~=2.0 pandas==1.1.4 pandocfilters==1.4.3 paramiko==2.7.2 diff --git a/setup.json b/setup.json index 4adeec32f4..5f31807491 100644 --- a/setup.json +++ b/setup.json @@ -38,6 +38,7 @@ "jinja2~=2.10", "kiwipy[rmq]~=0.6.1", "numpy~=1.17", + "pamqp~=2.0", "paramiko~=2.7", "plumpy~=0.17.1", "pgsu~=0.1.0", From e303cc450d3dd1e949de39785cbc141aa340ac70 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Tue, 1 Dec 2020 11:18:49 +0100 Subject: [PATCH 022/114] Dependencies: update minimum version for `notebook>=6.1.5` (#4593) Lower versions suffer from vulnerability `GHSA-c7vm-f5p4-8fqh`. Also update the requirement files to only use explicit pinned versions. The compatibility operator was erroneously used for the `aio-pika`, `pamqp` and `pytest-asyncio` dependencies. For `pamqp` the minimum required version is upped to `2.3` since that was the version that introduced the `support_deprecated_rabbitmq` function that is required from that library. --- environment.yml | 2 +- requirements/requirements-py-3.6.txt | 8 ++++---- requirements/requirements-py-3.7.txt | 8 ++++---- requirements/requirements-py-3.8.txt | 8 ++++---- requirements/requirements-py-3.9.txt | 6 +++--- setup.json | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/environment.yml b/environment.yml index b40e8edfad..e7dfea81c3 100644 --- a/environment.yml +++ b/environment.yml @@ -23,7 +23,7 @@ dependencies: - jinja2~=2.10 - kiwipy[rmq]~=0.6.1 - numpy~=1.17 -- pamqp~=2.0 +- pamqp~=2.3 - paramiko~=2.7 - plumpy~=0.17.1 - pgsu~=0.1.0 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 3e56c17c5f..1a0149240b 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -2,7 +2,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 alembic==1.4.1 -aio-pika~=6.6.1 +aio-pika==6.6.1 aniso8601==8.0.0 appdirs==1.4.4 appnope==0.1.0 @@ -69,12 +69,12 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.4 networkx==2.4 -notebook~=6.0.0 +notebook==6.1.5 numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 palettable==3.3.0 -pamqp~=2.0 +pamqp==2.3 pandas==0.25.3 pandocfilters==1.4.2 paramiko==2.7.1 @@ -110,7 +110,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.8.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.3.4 -pytest-asyncio~=0.12.0 +pytest-asyncio==0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index f3e20bd067..cecdb641b8 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -2,7 +2,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 alembic==1.4.1 -aio-pika~=6.6.1 +aio-pika==6.6.1 aniso8601==8.0.0 appdirs==1.4.4 appnope==0.1.0 @@ -68,12 +68,12 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.4 networkx==2.4 -notebook~=6.0.0 +notebook==6.1.5 numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 palettable==3.3.0 -pamqp~=2.0 +pamqp==2.3 pandas==0.25.3 pandocfilters==1.4.2 paramiko==2.7.1 @@ -110,7 +110,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.8.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.3.4 -pytest-asyncio~=0.12.0 +pytest-asyncio==0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 92daa182d9..2d7afdcf69 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -1,7 +1,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 -aio-pika~=6.6.1 +aio-pika==6.6.1 alembic==1.4.1 aniso8601==8.0.0 appnope==0.1.0 @@ -64,12 +64,12 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.4 networkx==2.4 -notebook~=6.0.0 +notebook==6.1.5 numpy==1.17.5 orderedmultidict==1.0.1 packaging==20.3 palettable==3.3.0 -pamqp~=2.0 +pamqp==2.3 pandas==0.25.3 pandocfilters==1.4.2 paramiko==2.7.1 @@ -103,7 +103,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.8.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.3.4 -pytest-asyncio~=0.12.0 +pytest-asyncio==0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index e7cee5e1a7..e733bcbbe0 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -1,7 +1,7 @@ aiida-export-migration-tests==0.9.0 alabaster==0.7.12 aldjemy==0.9.1 -aio-pika~=6.6.1 +aio-pika==6.6.1 alembic==1.4.3 aniso8601==8.0.0 archive-path==0.2.1 @@ -62,12 +62,12 @@ mpmath==1.1.0 nbconvert==5.6.1 nbformat==5.0.8 networkx==2.5 -notebook~=6.0.0 +notebook==6.1.5 numpy==1.19.4 orderedmultidict==1.0.1 packaging==20.4 palettable==3.3.0 -pamqp~=2.0 +pamqp==2.3 pandas==1.1.4 pandocfilters==1.4.3 paramiko==2.7.2 diff --git a/setup.json b/setup.json index 5f31807491..77daf205b3 100644 --- a/setup.json +++ b/setup.json @@ -38,7 +38,7 @@ "jinja2~=2.10", "kiwipy[rmq]~=0.6.1", "numpy~=1.17", - "pamqp~=2.0", + "pamqp~=2.3", "paramiko~=2.7", "plumpy~=0.17.1", "pgsu~=0.1.0", @@ -91,7 +91,7 @@ ], "notebook": [ "jupyter~=1.0", - "notebook~=6.0" + "notebook~=6.1,>=6.1.5" ], "pre-commit": [ "mypy==0.790", From e57d18df4cb264404770fbc4aa106fa608ccea67 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Thu, 3 Dec 2020 11:28:34 +0100 Subject: [PATCH 023/114] Daemon: replace deprecated classmethods of `asyncio.Task` in shutdown (#4608) The `shutdown` function, that was attached to the loop of the daemon runner in `aiida.engine.daemon.runner.start_daemon`, was calling the classmethods `current_task` and `all_tasks` of `asyncio.Task` which have been deprecated in Python 3.7 and are removed in Python 3.9. This would prevent the daemon runners from being shutdown in Python 3.9. The methods have been replaced with top level functions that can be imported directl from `asyncio`. This was not noticed in the tests because in the tests the daemon is stopped but it is not checked whether this happens successfully. Anyway, the error would only show up in the daemon log. To test the shutdown method, it has been made into a standalone coroutine and renamed to `shutdown_runner`. Since the `shutdown_runner` is a coroutine, the unit test that calls it also has to be one and therefore we need `pytest-asyncio` as a dependency. The `event_loop` fixture, that is provided by this library, is overrided such that it provides the event loop of the `Manager`, since in AiiDA only ever this single reentrant loop should be used. Note that the current CI tests run against Python 3.6 and Python 3.9 and so will still not catch this problem, however, the `test-install` workflow _does_ run against Python 3.9. I have opted not to change the continuous integrations to run against Python 3.9 instead of 3.8, since they take more than twice the time. Supposedly this is because certain dependencies have to be built and compiled from scratch when the testing environment is started. --- aiida/engine/daemon/runner.py | 36 ++++++++++++++++++---------- requirements/requirements-py-3.9.txt | 1 + setup.json | 1 + tests/conftest.py | 26 +++++++++++++++----- tests/engine/daemon/test_runner.py | 26 ++++++++++++++++++++ 5 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 tests/engine/daemon/test_runner.py diff --git a/aiida/engine/daemon/runner.py b/aiida/engine/daemon/runner.py index 227a79505f..7085c15167 100644 --- a/aiida/engine/daemon/runner.py +++ b/aiida/engine/daemon/runner.py @@ -19,6 +19,28 @@ LOGGER = logging.getLogger(__name__) +async def shutdown_runner(runner): + """Cleanup tasks tied to the service's shutdown.""" + LOGGER.info('Received signal to shut down the daemon runner') + + try: + from asyncio import all_tasks + from asyncio import current_task + except ImportError: + # Necessary for Python 3.6 as `asyncio.all_tasks` and `asyncio.current_task` were introduced in Python 3.7. The + # Standalone functions should be used as the classmethods are removed as of Python 3.9. + all_tasks = asyncio.Task.all_tasks + current_task = asyncio.Task.current_task + + tasks = [task for task in all_tasks() if task is not current_task()] + + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + runner.close() + + def start_daemon(): """Start a daemon runner for the currently configured profile.""" daemon_client = get_daemon_client() @@ -32,21 +54,9 @@ def start_daemon(): LOGGER.exception('daemon runner failed to start') raise - async def shutdown(runner): - """Cleanup tasks tied to the service's shutdown.""" - LOGGER.info('Received signal to shut down the daemon runner') - - tasks = [t for t in asyncio.Task.all_tasks() if t is not asyncio.Task.current_task()] - - for task in tasks: - task.cancel() - - await asyncio.gather(*tasks, return_exceptions=True) - runner.close() - signals = (signal.SIGTERM, signal.SIGINT) for s in signals: # pylint: disable=invalid-name - runner.loop.add_signal_handler(s, lambda s=s: asyncio.create_task(shutdown(runner))) + runner.loop.add_signal_handler(s, lambda s=s: asyncio.create_task(shutdown_runner(runner))) try: LOGGER.info('Starting a daemon runner') diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index e733bcbbe0..5ee76a284e 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -103,6 +103,7 @@ pytest-benchmark==3.2.3 pytest-cov==2.10.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.4.2 +pytest-asyncio==0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 diff --git a/setup.json b/setup.json index 77daf205b3..d582fb65d1 100644 --- a/setup.json +++ b/setup.json @@ -106,6 +106,7 @@ "pg8000~=1.13", "pgtest~=1.3,>=1.3.1", "pytest~=6.0", + "pytest-asyncio~=0.12", "pytest-timeout~=1.3", "pytest-cov~=2.7", "pytest-rerunfailures~=9.1,>=9.1.1", diff --git a/tests/conftest.py b/tests/conftest.py index c0777ba956..79790bc128 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -221,17 +221,31 @@ def _create_profile(name, **kwargs): @pytest.fixture -def backend(): - """Get the ``Backend`` instance of the currently loaded profile.""" +def manager(aiida_profile): # pylint: disable=unused-argument + """Get the ``Manager`` instance of the currently loaded profile.""" from aiida.manage.manager import get_manager - return get_manager().get_backend() + return get_manager() + + +@pytest.fixture +def event_loop(manager): + """Get the event loop instance of the currently loaded profile. + + This is automatically called as a fixture for any test marked with ``@pytest.mark.asyncio``. + """ + yield manager.get_runner().loop @pytest.fixture -def communicator(): +def backend(manager): + """Get the ``Backend`` instance of the currently loaded profile.""" + return manager.get_backend() + + +@pytest.fixture +def communicator(manager): """Get the ``Communicator`` instance of the currently loaded profile to communicate with RabbitMQ.""" - from aiida.manage.manager import get_manager - return get_manager().get_communicator() + return manager.get_communicator() @pytest.fixture diff --git a/tests/engine/daemon/test_runner.py b/tests/engine/daemon/test_runner.py new file mode 100644 index 0000000000..b4730ad7cf --- /dev/null +++ b/tests/engine/daemon/test_runner.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Unit tests for the :mod:`aiida.engine.daemon.runner` module.""" +import pytest + +from aiida.engine.daemon.runner import shutdown_runner + + +@pytest.mark.asyncio +async def test_shutdown_runner(manager): + """Test the ``shutdown_runner`` method.""" + runner = manager.get_runner() + await shutdown_runner(runner) + + try: + assert runner.is_closed() + finally: + # Reset the runner of the manager, because once closed it cannot be reused by other tests. + manager._runner = None # pylint: disable=protected-access From e073972ba6b0b83d7d7964750a762da26603fa74 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Mon, 7 Dec 2020 16:30:15 +0100 Subject: [PATCH 024/114] CLI: add the `verdi database version` command (#4613) This shows the schema generation and version of the database of the given profile, useful mostly for developers when debugging. In addition to the new command, the code in `aiida.manage.manager` had to be updated for the new functionality to work. The `get_backend_manager` was so far _not_ loading the backend, although that really doesn't make any sense. It is providing access to data from the database, but to do so the backend should be loaded, otherwise a connection isn't possible. This problem went unnoticed, because the `BackendManager` was so far only used in `aiida.engine.utils.set_process_state_change_timestamp`. By the time this gets used, the database backend will already have been loaded through another code path. For the change `verdi database version` command, however, the call to get the backend manager needed to make sure that the database backend itself was also loaded. It was not possible to have `get_backend_manager` simply call `_load_backend()` because this would lead to infinite recursion as `_load_backend()` also calls `get_backend_manager`. Therefore `_load_backend` is refactored to not call the former but rather to directly fetch it through `aiida.backends`. --- aiida/cmdline/commands/cmd_database.py | 16 ++++++++++++++++ aiida/manage/manager.py | 9 ++++++--- docs/source/reference/command_line.rst | 1 + tests/cmdline/commands/test_database.py | 10 ++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/aiida/cmdline/commands/cmd_database.py b/aiida/cmdline/commands/cmd_database.py index c486ea038f..6c0329221c 100644 --- a/aiida/cmdline/commands/cmd_database.py +++ b/aiida/cmdline/commands/cmd_database.py @@ -23,6 +23,22 @@ def verdi_database(): """Inspect and manage the database.""" +@verdi_database.command('version') +def database_version(): + """Show the version of the database. + + The database version is defined by the tuple of the schema generation and schema revision. + """ + from aiida.manage.manager import get_manager + + backend_manager = get_manager().get_backend_manager() + + echo.echo('Generation: ', bold=True, nl=False) + echo.echo(backend_manager.get_schema_generation_database()) + echo.echo('Revision: ', bold=True, nl=False) + echo.echo(backend_manager.get_schema_version_database()) + + @verdi_database.command('migrate') @options.FORCE() def database_migrate(force): diff --git a/aiida/manage/manager.py b/aiida/manage/manager.py index 762952c196..94251cb8c1 100644 --- a/aiida/manage/manager.py +++ b/aiida/manage/manager.py @@ -86,8 +86,9 @@ def _load_backend(self, schema_check=True): # Do NOT reload the backend environment if already loaded, simply reload the backend instance after if configuration.BACKEND_UUID is None: - manager = self.get_backend_manager() - manager.load_backend_environment(profile, validate_schema=schema_check) + from aiida.backends import get_backend_manager + backend_manager = get_backend_manager(self.get_profile().database_backend) + backend_manager.load_backend_environment(profile, validate_schema=schema_check) configuration.BACKEND_UUID = profile.uuid backend_type = profile.database_backend @@ -123,8 +124,10 @@ def get_backend_manager(self): :return: the database backend manager :rtype: :class:`aiida.backend.manager.BackendManager` """ + from aiida.backends import get_backend_manager + if self._backend_manager is None: - from aiida.backends import get_backend_manager + self._load_backend() self._backend_manager = get_backend_manager(self.get_profile().database_backend) return self._backend_manager diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 84f6989dad..79bdb09be3 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -199,6 +199,7 @@ Below is a list with all available subcommands. Commands: integrity Check the integrity of the database and fix potential issues. migrate Migrate the database to the latest schema version. + version Show the version of the database. .. _reference:command-line:verdi-devel: diff --git a/tests/cmdline/commands/test_database.py b/tests/cmdline/commands/test_database.py index 4269cf6c7e..90b7917f98 100644 --- a/tests/cmdline/commands/test_database.py +++ b/tests/cmdline/commands/test_database.py @@ -12,6 +12,7 @@ import enum from click.testing import CliRunner +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_database @@ -170,3 +171,12 @@ def test_detect_invalid_nodes_unknown_node_type(self): result = self.cli_runner.invoke(cmd_database.detect_invalid_nodes, []) self.assertNotEqual(result.exit_code, 0) self.assertIsNotNone(result.exception) + + +@pytest.mark.usefixtures('aiida_profile') +def tests_database_version(run_cli_command, manager): + """Test the ``verdi database version`` command.""" + backend_manager = manager.get_backend_manager() + result = run_cli_command(cmd_database.database_version) + assert result.output_lines[0].endswith(backend_manager.get_schema_generation_database()) + assert result.output_lines[1].endswith(backend_manager.get_schema_version_database()) From 8260b59e28fc5d0528732c590a556baa537ecba3 Mon Sep 17 00:00:00 2001 From: ramirezfranciscof Date: Wed, 9 Dec 2020 22:39:12 +0100 Subject: [PATCH 025/114] Add the `TransferCalcJob` plugin (#4194) This calcjob allows the user to copy files between a remote machine and the local machine running AiiDA. More specifically, it can do any of the following: * Take any number of files from any number of `RemoteData` folders in a remote machine and copy them in the local repository of a single newly created `FolderData` node. * Take any number of files from any number of `FolderData` nodes in the local machine and copy them in a single newly created `RemoteData` folder in a given remote machine. These are the main two use cases, but there are also other more complex combinations allowed by the current implementation. Co-authored-by: Sebastiaan Huber --- aiida/calculations/transfer.py | 253 +++++++++++++++++++++++ aiida/common/datastructures.py | 5 +- aiida/engine/processes/calcjobs/tasks.py | 16 +- docs/source/howto/data.rst | 73 +++++++ docs/source/nitpick-exceptions | 2 + setup.json | 1 + tests/calculations/test_transfer.py | 248 ++++++++++++++++++++++ 7 files changed, 591 insertions(+), 7 deletions(-) create mode 100644 aiida/calculations/transfer.py create mode 100644 tests/calculations/test_transfer.py diff --git a/aiida/calculations/transfer.py b/aiida/calculations/transfer.py new file mode 100644 index 0000000000..04811f6443 --- /dev/null +++ b/aiida/calculations/transfer.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Implementation of Transfer CalcJob.""" + +import os +from aiida import orm +from aiida.engine import CalcJob +from aiida.common.datastructures import CalcInfo + + +def validate_instructions(instructions, _): + """Check that the instructions dict contains the necessary keywords""" + + instructions_dict = instructions.get_dict() + retrieve_files = instructions_dict.get('retrieve_files', None) + + if retrieve_files is None: + errmsg = ( + '\n\n' + 'no indication of what to do in the instruction node:\n' + f' > {instructions.uuid}\n' + '(to store the files in the repository set retrieve_files=True,\n' + 'to copy them to the specified folder on the remote computer,\n' + 'set it to False)\n' + ) + return errmsg + + if not isinstance(retrieve_files, bool): + errmsg = ( + 'entry for retrieve files inside of instruction node:\n' + f' > {instructions.uuid}\n' + 'must be either True or False; instead, it is:\n' + f' > {retrieve_files}\n' + ) + return errmsg + + local_files = instructions_dict.get('local_files', None) + remote_files = instructions_dict.get('remote_files', None) + symlink_files = instructions_dict.get('symlink_files', None) + + if not any([local_files, remote_files, symlink_files]): + errmsg = ( + 'no indication of which files to copy were found in the instruction node:\n' + f' > {instructions.uuid}\n' + 'Please include at least one of `local_files`, `remote_files`, or `symlink_files`.\n' + 'These should be lists containing 3-tuples with the following format:\n' + ' (source_node_key, source_relpath, target_relpath)\n' + ) + return errmsg + + +def validate_transfer_inputs(inputs, _): + """Check that the instructions dict and the source nodes are consistent""" + + source_nodes = inputs['source_nodes'] + instructions = inputs['instructions'] + computer = inputs['metadata']['computer'] + + instructions_dict = instructions.get_dict() + local_files = instructions_dict.get('local_files', []) + remote_files = instructions_dict.get('remote_files', []) + symlink_files = instructions_dict.get('symlink_files', []) + + source_nodes_provided = set(source_nodes.keys()) + source_nodes_required = set() + error_message_list = [] + + for node_label, node_object in source_nodes.items(): + if isinstance(node_object, orm.RemoteData): + if computer.name != node_object.computer.name: + error_message = ( + f' > remote node `{node_label}` points to computer `{node_object.computer}`, ' + f'not the one being used (`{computer}`)' + ) + error_message_list.append(error_message) + + for source_label, _, _ in local_files: + source_nodes_required.add(source_label) + source_node = source_nodes.get(source_label, None) + error_message = check_node_type('local_files', source_label, source_node, orm.FolderData) + if error_message: + error_message_list.append(error_message) + + for source_label, _, _ in remote_files: + source_nodes_required.add(source_label) + source_node = source_nodes.get(source_label, None) + error_message = check_node_type('remote_files', source_label, source_node, orm.RemoteData) + if error_message: + error_message_list.append(error_message) + + for source_label, _, _ in symlink_files: + source_nodes_required.add(source_label) + source_node = source_nodes.get(source_label, None) + error_message = check_node_type('symlink_files', source_label, source_node, orm.RemoteData) + if error_message: + error_message_list.append(error_message) + + unrequired_nodes = source_nodes_provided.difference(source_nodes_required) + for node_label in unrequired_nodes: + error_message = f' > node `{node_label}` provided as inputs is not being used' + error_message_list.append(error_message) + + if len(error_message_list) > 0: + error_message = '\n\n' + for error_add in error_message_list: + error_message = error_message + error_add + '\n' + return error_message + + +def check_node_type(list_name, node_label, node_object, node_type): + """Common utility function to check the type of a node""" + + if node_object is None: + return f' > node `{node_label}` requested on list `{list_name}` not found among inputs' + + if not isinstance(node_object, node_type): + target_class = node_type.class_node_type + return f' > node `{node_label}`, requested on list `{list_name}` should be of type `{target_class}`' + + return None + + +class TransferCalculation(CalcJob): + """Utility to copy files from different FolderData and RemoteData nodes into a single place. + + The final destination for these files can be either the local repository (by creating a + new FolderData node to store them) or in the remote computer (by leaving the files in a + new remote folder saved in a RemoteData node). + + Only files from the local computer and from remote folders in the same external computer + can be moved at the same time with a single instance of this CalcJob. + + The user needs to provide three inputs: + + * ``instructions``: a dict node specifying which files to copy from which nodes. + * ``source_nodes``: a dict of nodes, each with a unique identifier label as its key. + * ``metadata.computer``: the computer that contains the remote files and will contain + the final RemoteData node. + + The ``instructions`` dict must have the ``retrieve_files`` flag. The CalcJob will create a + new folder in the remote machine (``RemoteData``) and put all the files there and will either: + + (1) leave them there (``retrieve_files = False``) or ... + (2) retrieve all the files and store them locally in a ``FolderData`` (``retrieve_files = True``) + + The `instructions` dict must also contain at least one list with specifications of which files + to copy and from where. All these lists take tuples of 3 that have the following format: + + .. code-block:: python + + ( source_node_key, path_to_file_in_source, path_to_file_in_target) + + where the ``source_node_key`` has to be the respective one used when providing the node in the + ``source_nodes`` input nodes dictionary. + + + The two main lists to include are ``local_files`` (for files to be taken from FolderData nodes) + and ``remote_files`` (for files to be taken from RemoteData nodes). Alternatively, files inside + of RemoteData nodes can instead be put in the ``symlink_files`` list: the only difference is that + files from the first list will be fully copied in the target RemoteData folder, whereas for the + files in second list only a symlink to the original file will be created there. This will only + affect the content of the final RemoteData target folder, but in both cases the full file will + be copied back in the local target FolderData (if ``retrieve_files = True``). + """ + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.input( + 'instructions', + valid_type=orm.Dict, + help='A dictionary containing the `retrieve_files` flag and at least one of the file lists:' + '`local_files`, `remote_files` and/or `symlink_files`.', + validator=validate_instructions, + ) + spec.input_namespace( + 'source_nodes', + valid_type=(orm.FolderData, orm.RemoteData), + dynamic=True, + help='All the nodes that contain files referenced in the instructions.', + ) + + # The transfer just needs a computer, the code are resources are set here + spec.inputs.pop('code', None) + spec.inputs['metadata']['computer'].required = True + spec.inputs['metadata']['options']['resources'].default = { + 'num_machines': 1, + 'num_mpiprocs_per_machine': 1, + } + + spec.inputs.validator = validate_transfer_inputs + + def prepare_for_submission(self, folder): + source_nodes = self.inputs.source_nodes + instructions = self.inputs.instructions.get_dict() + + local_files = instructions.get('local_files', []) + remote_files = instructions.get('remote_files', []) + symlink_files = instructions.get('symlink_files', []) + retrieve_files = instructions.get('retrieve_files') + + calc_info = CalcInfo() + calc_info.skip_submit = True + calc_info.codes_info = [] + calc_info.local_copy_list = [] + calc_info.remote_copy_list = [] + calc_info.remote_symlink_list = [] + retrieve_paths = [] + + for source_label, source_relpath, target_relpath in local_files: + + source_node = source_nodes[source_label] + retrieve_paths.append(target_relpath) + calc_info.local_copy_list.append(( + source_node.uuid, + source_relpath, + target_relpath, + )) + + for source_label, source_relpath, target_relpath in remote_files: + + source_node = source_nodes[source_label] + retrieve_paths.append(target_relpath) + calc_info.remote_copy_list.append(( + source_node.computer.uuid, + os.path.join(source_node.get_remote_path(), source_relpath), + target_relpath, + )) + + for source_label, source_relpath, target_relpath in symlink_files: + + source_node = source_nodes[source_label] + retrieve_paths.append(target_relpath) + calc_info.remote_symlink_list.append(( + source_node.computer.uuid, + os.path.join(source_node.get_remote_path(), source_relpath), + target_relpath, + )) + + if retrieve_files: + calc_info.retrieve_list = retrieve_paths + else: + calc_info.retrieve_list = [] + + return calc_info diff --git a/aiida/common/datastructures.py b/aiida/common/datastructures.py index e10a7cca22..7f8d0ca523 100644 --- a/aiida/common/datastructures.py +++ b/aiida/common/datastructures.py @@ -74,6 +74,8 @@ class CalcInfo(DefaultFieldsAttributeDict): already indirectly present in the repository through one of the data nodes passed as input to the calculation. * codes_info: a list of dictionaries used to pass the info of the execution of a code * codes_run_mode: a string used to specify the order in which multi codes can be executed + * skip_submit: a flag that, when set to True, orders the engine to skip the submit/update steps (so no code will + run, it will only upload the files and then retrieve/parse). """ _default_fields = ( @@ -98,7 +100,8 @@ class CalcInfo(DefaultFieldsAttributeDict): 'remote_symlink_list', 'provenance_exclude_list', 'codes_info', - 'codes_run_mode' + 'codes_run_mode', + 'skip_submit' ) diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 3eb01e290b..293065ff6c 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -77,13 +77,14 @@ async def do_upload(): raise PreSubmitException('exception occurred in presubmit call') from exception else: execmanager.upload_calculation(node, transport, calc_info, folder) + skip_submit = calc_info.skip_submit or False - return + return skip_submit try: logger.info(f'scheduled request to upload CalcJob<{node.pk}>') ignore_exceptions = (plumpy.CancelledError, PreSubmitException) - result = await exponential_backoff_retry( + skip_submit = await exponential_backoff_retry( do_upload, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) except PreSubmitException: @@ -96,7 +97,7 @@ async def do_upload(): else: logger.info(f'uploading CalcJob<{node.pk}> successful') node.set_state(CalcJobState.SUBMITTING) - return result + return skip_submit async def task_submit_job(node, transport_queue, cancellable): @@ -323,7 +324,7 @@ def load_instance_state(self, saved_state, load_context): async def execute(self): # pylint: disable=invalid-overridden-method """Override the execute coroutine of the base `Waiting` state.""" - # pylint: disable=too-many-branches + # pylint: disable=too-many-branches, too-many-statements node = self.process.node transport_queue = self.process.runner.transport command = self.data @@ -335,8 +336,11 @@ async def execute(self): # pylint: disable=invalid-overridden-method if command == UPLOAD_COMMAND: node.set_process_status(process_status) - await self._launch_task(task_upload_job, self.process, transport_queue) - result = self.submit() + skip_submit = await self._launch_task(task_upload_job, self.process, transport_queue) + if skip_submit: + result = self.retrieve() + else: + result = self.submit() elif command == SUBMIT_COMMAND: node.set_process_status(process_status) diff --git a/docs/source/howto/data.rst b/docs/source/howto/data.rst index 5f86102074..ddae0d6c75 100644 --- a/docs/source/howto/data.rst +++ b/docs/source/howto/data.rst @@ -805,3 +805,76 @@ This command will delete both the file repository and the database. .. danger:: It is not possible to restore a deleted profile unless it was previously backed up! + + +Transfering data +================ + +.. danger:: + + This feature is still in beta version and its API might change in the near future. + It is therefore not recommended that you rely on it for your public/production workflows. + + Moreover, feedback on its implementation is much appreciated. + +When a calculation job is launched, AiiDA will create a :py:class:`~aiida.orm.nodes.data.remote.RemoteData` node that is attached as an output node to the calculation node with the label ``remote_folder``. +The input files generated by the ``CalcJob`` plugin are copied to this remote folder and, since the job is executed there as well, the code will produce its output files in that same remote folder also. +Since the :py:class:`~aiida.orm.nodes.data.remote.RemoteData` node only explicitly stores the filepath on the remote computer, and not its actual contents, it functions more or less like a symbolic link. +That means that if the remote folder gets deleted, there will be no way to retrieve its contents. +The ``CalcJob`` plugin can for that reason specify some files that should be :ref:`retrieved` and stored locally in a :py:class:`~aiida.orm.nodes.data.folder.FolderData` node for safekeeing, which is attached to the calculation node as an output with the label ``retrieved_folder``. + +Although the :ref:`retrieve_list` allows to specify what output files are to be retrieved locally, this has to be done *before* the calculation is submitted. +In order to provide more flexibility in deciding what files of completed calculation jobs are to be stored locally, even after it has terminated, AiiDA ships with a the :py:class:`~aiida.calculations.transfer.TransferCalculation` plugin. +This calculation plugin enables to retrieve files from a remote machine and save them in a local :py:class:`~aiida.orm.nodes.data.folder.FolderData`. +The specifications of what to copy are provided through an input of type + +.. code-block:: ipython + + In [1]: instructions_cont = {} + ... instructions_cont['retrieve_files'] = True + ... instructions_cont['symlink_files'] = [ + ... ('node_keyname', 'source/path/filename', 'target/path/filename'), + ... ] + ... instructions_node = orm.Dict(dict=instructions_cont) + +The ``'source/path/filename'`` and ``'target/path/filename'`` are both relative paths (to their respective folders). +The ``node_keyname`` is a string that will be used when providing the source :py:class:`~aiida.orm.nodes.data.remote.RemoteData` node to the calculation. +You also need to provide the computer between which the transfer will occur: + +.. code-block:: ipython + + In [2]: transfer_builder = CalculationFactory('core.transfer').get_builder() + ... transfer_builder.instructions = instructions_node + ... transfer_builder.source_nodes = {'node_keyname': source_node} + ... transfer_builder.metadata.computer = source_node.computer + +The variable ``source_node`` here corresponds to the ``RemoteData`` node whose contents need to be retrieved. +Finally, you just run or submit the calculation as you would do with any other: + +.. code-block:: ipython + + In [2]: from aiida.engine import submit + ... submit(transfer_builder) + +You can also use this to copy local files into a new :py:class:`~aiida.orm.nodes.data.remote.RemoteData` folder. +For this you first have to adapt the instructions to set ``'retrieve_files'`` to ``False`` and use a ``'local_files'`` list instead of the ``'symlink_files'``: + +.. code-block:: ipython + + In [1]: instructions_cont = {} + ... instructions_cont['retrieve_files'] = False + ... instructions_cont['local_files'] = [ + ... ('node_keyname', 'source/path/filename', 'target/path/filename'), + ... ] + ... instructions_node = orm.Dict(dict=instructions_cont) + +It is also relevant to note that, in this case, the ``source_node`` will be of type :py:class:`~aiida.orm.nodes.data.folder.FolderData` so you will have to manually select the computer to where you want to copy the files. +You can do this by looking at your available computers running ``verdi computer list`` and using the label shown to load it with :py:func:`~aiida.orm.utils.load_computer`: + +.. code-block:: ipython + + In [2]: transfer_builder.metadata.computer = load_computer('some-computer-label') + +Both when uploading or retrieving, you can copy multiple files by appending them to the list of the ``local_files`` or ``symlink_files`` keys in the instructions input, respectively. +It is also possible to copy files from any number of nodes by providing several ``source_node`` s, each with a different ``'node_keyname'``. +The target node will always be one (so you can *"gather"* files in a single call, but not *"distribute"* them). diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index 5a8971e28f..9b290f994d 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -32,6 +32,8 @@ py:class aiida.tools.importexport.dbexport.ArchiveData py:class EntityType py:class aiida.tools.groups.paths.WalkNodeResult +py:class aiida.orm.utils.links.LinkQuadruple + ### python packages # Note: These exceptions are needed if # * the objects are referenced e.g. as param/return types types in method docstrings (without intersphinx mapping) diff --git a/setup.json b/setup.json index d9a4cc71c8..0ba6b2d58c 100644 --- a/setup.json +++ b/setup.json @@ -125,6 +125,7 @@ "runaiida=aiida.cmdline.commands.cmd_run:run" ], "aiida.calculations": [ + "core.transfer = aiida.calculations.transfer:TransferCalculation", "arithmetic.add = aiida.calculations.arithmetic.add:ArithmeticAddCalculation", "templatereplacer = aiida.calculations.templatereplacer:TemplatereplacerCalculation" ], diff --git a/tests/calculations/test_transfer.py b/tests/calculations/test_transfer.py new file mode 100644 index 0000000000..92ec0f7afc --- /dev/null +++ b/tests/calculations/test_transfer.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Tests for the `TransferCalculation` plugin.""" +import os +import pytest + +from aiida import orm +from aiida.common import datastructures + + +@pytest.mark.usefixtures('clear_database_before_test') +def test_get_transfer(fixture_sandbox, aiida_localhost, generate_calc_job, tmp_path): + """Test a default `TransferCalculation`.""" + + file1 = tmp_path / 'file1.txt' + file1.write_text('file 1 content') + folder = tmp_path / 'folder' + folder.mkdir() + file2 = folder / 'file2.txt' + file2.write_text('file 2 content') + data_source = orm.RemoteData(computer=aiida_localhost, remote_path=str(tmp_path)) + + entry_point_name = 'core.transfer' + list_of_files = [ + ('data_source', 'file1.txt', 'folder/file1.txt'), + ('data_source', 'folder/file2.txt', 'file2.txt'), + ] + list_of_nodes = {'data_source': data_source} + instructions = orm.Dict(dict={'retrieve_files': True, 'symlink_files': list_of_files}) + inputs = {'instructions': instructions, 'source_nodes': list_of_nodes, 'metadata': {'computer': aiida_localhost}} + + # Generate calc_info and verify basics + calc_info = generate_calc_job(fixture_sandbox, entry_point_name, inputs) + assert isinstance(calc_info, datastructures.CalcInfo) + assert isinstance(calc_info.codes_info, list) + assert len(calc_info.codes_info) == 0 + assert calc_info.skip_submit + + # Check that the lists were set correctly + copy_list = [ + (aiida_localhost.uuid, os.path.join(data_source.get_remote_path(), 'file1.txt'), 'folder/file1.txt'), + (aiida_localhost.uuid, os.path.join(data_source.get_remote_path(), 'folder/file2.txt'), 'file2.txt'), + ] + retrieve_list = [('folder/file1.txt'), ('file2.txt')] + assert sorted(calc_info.remote_symlink_list) == sorted(copy_list) + assert sorted(calc_info.remote_copy_list) == sorted(list()) + assert sorted(calc_info.local_copy_list) == sorted(list()) + assert sorted(calc_info.retrieve_list) == sorted(retrieve_list) + + # Now without symlinks + instructions = orm.Dict(dict={'retrieve_files': True, 'remote_files': list_of_files}) + inputs = {'instructions': instructions, 'source_nodes': list_of_nodes, 'metadata': {'computer': aiida_localhost}} + calc_info = generate_calc_job(fixture_sandbox, entry_point_name, inputs) + assert sorted(calc_info.remote_symlink_list) == sorted(list()) + assert sorted(calc_info.remote_copy_list) == sorted(copy_list) + assert sorted(calc_info.local_copy_list) == sorted(list()) + assert sorted(calc_info.retrieve_list) == sorted(retrieve_list) + + +@pytest.mark.usefixtures('clear_database_before_test') +def test_put_transfer(fixture_sandbox, aiida_localhost, generate_calc_job, tmp_path): + """Test a default `TransferCalculation`.""" + + file1 = tmp_path / 'file1.txt' + file1.write_text('file 1 content') + folder = tmp_path / 'folder' + folder.mkdir() + file2 = folder / 'file2.txt' + file2.write_text('file 2 content') + data_source = orm.FolderData(tree=str(tmp_path)) + + entry_point_name = 'core.transfer' + list_of_files = [ + ('data_source', 'file1.txt', 'folder/file1.txt'), + ('data_source', 'folder/file2.txt', 'file2.txt'), + ] + list_of_nodes = {'data_source': data_source} + instructions = orm.Dict(dict={'retrieve_files': False, 'local_files': list_of_files}) + inputs = {'instructions': instructions, 'source_nodes': list_of_nodes, 'metadata': {'computer': aiida_localhost}} + + # Generate calc_info and verify basics + calc_info = generate_calc_job(fixture_sandbox, entry_point_name, inputs) + assert isinstance(calc_info, datastructures.CalcInfo) + assert isinstance(calc_info.codes_info, list) + assert len(calc_info.codes_info) == 0 + assert calc_info.skip_submit + + # Check that the lists were set correctly + copy_list = [ + (data_source.uuid, 'file1.txt', 'folder/file1.txt'), + (data_source.uuid, 'folder/file2.txt', 'file2.txt'), + ] + assert sorted(calc_info.remote_symlink_list) == sorted(list()) + assert sorted(calc_info.remote_copy_list) == sorted(list()) + assert sorted(calc_info.local_copy_list) == sorted(copy_list) + assert sorted(calc_info.retrieve_list) == sorted(list()) + + +def test_validate_instructions(): + """Test the `TransferCalculation` validators.""" + from aiida.calculations.transfer import validate_instructions + + instructions = orm.Dict(dict={}).store() + result = validate_instructions(instructions, None) + expected = ( + '\n\nno indication of what to do in the instruction node:\n' + f' > {instructions.uuid}\n' + '(to store the files in the repository set retrieve_files=True,\n' + 'to copy them to the specified folder on the remote computer,\n' + 'set it to False)\n' + ) + assert result == expected + + instructions = orm.Dict(dict={'retrieve_files': 12}).store() + result = validate_instructions(instructions, None) + expected = ( + 'entry for retrieve files inside of instruction node:\n' + f' > {instructions.uuid}\n' + 'must be either True or False; instead, it is:\n > 12\n' + ) + assert result == expected + + instructions = orm.Dict(dict={'retrieve_files': True}).store() + result = validate_instructions(instructions, None) + expected = ( + 'no indication of which files to copy were found in the instruction node:\n' + f' > {instructions.uuid}\n' + 'Please include at least one of `local_files`, `remote_files`, or `symlink_files`.\n' + 'These should be lists containing 3-tuples with the following format:\n' + ' (source_node_key, source_relpath, target_relpath)\n' + ) + assert result == expected + + +def test_validate_transfer_inputs(aiida_localhost, tmp_path, temp_dir): + """Test the `TransferCalculation` validators.""" + from aiida.orm import Computer + from aiida.calculations.transfer import check_node_type, validate_transfer_inputs + + fake_localhost = Computer( + label='localhost-fake', + description='extra localhost computer set up by test', + hostname='localhost-fake', + workdir=temp_dir, + transport_type='local', + scheduler_type='direct' + ) + fake_localhost.store() + fake_localhost.set_minimum_job_poll_interval(0.) + fake_localhost.configure() + + inputs = { + 'source_nodes': { + 'unused_node': orm.RemoteData(computer=aiida_localhost, remote_path=str(tmp_path)), + }, + 'instructions': + orm.Dict( + dict={ + 'local_files': [('inexistent_node', None, None)], + 'remote_files': [('inexistent_node', None, None)], + 'symlink_files': [('inexistent_node', None, None)], + } + ), + 'metadata': { + 'computer': fake_localhost + }, + } + expected_list = [] + expected_list.append(( + f' > remote node `unused_node` points to computer `{aiida_localhost}`, ' + f'not the one being used (`{fake_localhost}`)' + )) + expected_list.append(check_node_type('local_files', 'inexistent_node', None, orm.FolderData)) + expected_list.append(check_node_type('remote_files', 'inexistent_node', None, orm.RemoteData)) + expected_list.append(check_node_type('symlink_files', 'inexistent_node', None, orm.RemoteData)) + expected_list.append(' > node `unused_node` provided as inputs is not being used') + + expected = '\n\n' + for addition in expected_list: + expected = expected + addition + '\n' + + result = validate_transfer_inputs(inputs, None) + assert result == expected + + result = check_node_type('list_name', 'node_label', None, orm.RemoteData) + expected = ' > node `node_label` requested on list `list_name` not found among inputs' + assert result == expected + + result = check_node_type('list_name', 'node_label', orm.FolderData(), orm.RemoteData) + expected_type = orm.RemoteData.class_node_type + expected = f' > node `node_label`, requested on list `list_name` should be of type `{expected_type}`' + assert result == expected + + +def test_integration_transfer(aiida_localhost, tmp_path): + """Test a default `TransferCalculation`.""" + from aiida.calculations.transfer import TransferCalculation + from aiida.engine import run + + content_local = 'Content of local file' + srcfile_local = tmp_path / 'file_local.txt' + srcfile_local.write_text(content_local) + srcnode_local = orm.FolderData(tree=str(tmp_path)) + + content_remote = 'Content of remote file' + srcfile_remote = tmp_path / 'file_remote.txt' + srcfile_remote.write_text(content_remote) + srcnode_remote = orm.RemoteData(computer=aiida_localhost, remote_path=str(tmp_path)) + + list_of_nodes = {} + list_of_nodes['source_local'] = srcnode_local + list_for_local = [('source_local', 'file_local.txt', 'file_local.txt')] + list_of_nodes['source_remote'] = srcnode_remote + list_for_remote = [('source_remote', 'file_remote.txt', 'file_remote.txt')] + + instructions = orm.Dict( + dict={ + 'retrieve_files': True, + 'local_files': list_for_local, + 'remote_files': list_for_remote, + } + ) + inputs = {'instructions': instructions, 'source_nodes': list_of_nodes, 'metadata': {'computer': aiida_localhost}} + + output_nodes = run(TransferCalculation, **inputs) + + output_remotedir = output_nodes['remote_folder'] + output_retrieved = output_nodes['retrieved'] + + # Check the retrieved folder + assert sorted(output_retrieved.list_object_names()) == sorted(['file_local.txt', 'file_remote.txt']) + assert output_retrieved.get_object_content('file_local.txt') == content_local + assert output_retrieved.get_object_content('file_remote.txt') == content_remote + + # Check the remote folder + assert 'file_local.txt' in output_remotedir.listdir() + assert 'file_remote.txt' in output_remotedir.listdir() + output_remotedir.getfile(relpath='file_local.txt', destpath=str(tmp_path / 'retrieved_local.txt')) + output_remotedir.getfile(relpath='file_remote.txt', destpath=str(tmp_path / 'retrieved_remote.txt')) + assert (tmp_path / 'retrieved_local.txt').read_text() == content_local + assert (tmp_path / 'retrieved_remote.txt').read_text() == content_remote From 7d0d5a90bd546549b1f87a74567a86db5ff905f1 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Thu, 10 Dec 2020 10:33:37 +0100 Subject: [PATCH 026/114] Dependencies: update requirement `kiwipy~=0.7.1` and `plumpy~=0.18.0` (#4629) A breaking change was released with `kiwipy==0.5.4` where the default value for the task message TTL was changed. This caused connections to existing RabbitMQ queues to fail. Since process task queues are permanent in AiiDA, this would break all existing installations. This problem was fixed by reverting the change which was released with `kiwipy==0.5.5`, however, this was a support patch at the time and the revert never made it into the main line, leaving all versions up from `v0.6.0` still affected. Since these versions of `kiwipy` were never required by a released version of `aiida-core`, but only the current `develop`, which will become `v1.6.0`, we can simply update the requirement to the latest patch `kiwipy==0.7.1` that addressed the problem. The dependency requirement for `plumpy` also had to be updated because the old pinned minor version was pinned to `kiwipy~=0.6.0` which is not compatible with our new requirements. --- environment.yml | 4 ++-- requirements/requirements-py-3.6.txt | 4 ++-- requirements/requirements-py-3.7.txt | 4 ++-- requirements/requirements-py-3.8.txt | 4 ++-- requirements/requirements-py-3.9.txt | 4 ++-- setup.json | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/environment.yml b/environment.yml index e7dfea81c3..1d1b7ea461 100644 --- a/environment.yml +++ b/environment.yml @@ -21,11 +21,11 @@ dependencies: - python-graphviz~=0.13 - ipython~=7.0 - jinja2~=2.10 -- kiwipy[rmq]~=0.6.1 +- kiwipy[rmq]~=0.7.1 - numpy~=1.17 - pamqp~=2.3 - paramiko~=2.7 -- plumpy~=0.17.1 +- plumpy~=0.18.0 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 1a0149240b..07af6d9a9c 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -56,7 +56,7 @@ jupyter==1.0.0 jupyter-client==6.0.0 jupyter-console==6.1.0 jupyter-core==4.6.3 -kiwipy==0.6.1 +kiwipy==0.7.1 kiwisolver==1.1.0 Mako==1.1.2 MarkupSafe==1.1.1 @@ -86,7 +86,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.17.1 +plumpy==0.18.0 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index cecdb641b8..25895f80e9 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -55,7 +55,7 @@ jupyter==1.0.0 jupyter-client==6.0.0 jupyter-console==6.1.0 jupyter-core==4.6.3 -kiwipy==0.6.1 +kiwipy==0.7.1 kiwisolver==1.1.0 Mako==1.1.2 MarkupSafe==1.1.1 @@ -85,7 +85,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.17.1 +plumpy==0.18.0 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 2d7afdcf69..6352745ff1 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -52,7 +52,7 @@ jupyter==1.0.0 jupyter-client==6.0.0 jupyter-console==6.1.0 jupyter-core==4.6.3 -kiwipy==0.6.1 +kiwipy==0.7.1 kiwisolver==1.1.0 Mako==1.1.2 MarkupSafe==1.1.1 @@ -80,7 +80,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.17.1 +plumpy==0.18.0 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 5ee76a284e..ff2a338596 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -51,7 +51,7 @@ jupyter==1.0.0 jupyter-client==6.1.7 jupyter-console==6.2.0 jupyter-core==4.6.3 -kiwipy==0.6.1 +kiwipy==0.7.1 kiwisolver==1.3.1 Mako==1.1.3 MarkupSafe==1.1.1 @@ -81,7 +81,7 @@ pika==1.1.0 Pillow==8.0.1 plotly==4.12.0 pluggy==0.13.1 -plumpy==0.17.1 +plumpy==0.18.0 prometheus-client==0.8.0 prompt-toolkit==3.0.8 psutil==5.7.3 diff --git a/setup.json b/setup.json index 0ba6b2d58c..2daf56e051 100644 --- a/setup.json +++ b/setup.json @@ -36,11 +36,11 @@ "graphviz~=0.13", "ipython~=7.0", "jinja2~=2.10", - "kiwipy[rmq]~=0.6.1", + "kiwipy[rmq]~=0.7.1", "numpy~=1.17", "pamqp~=2.3", "paramiko~=2.7", - "plumpy~=0.17.1", + "plumpy~=0.18.0", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", From 073639aeb6d844acece2fda70aeefdca9d9005fc Mon Sep 17 00:00:00 2001 From: ramirezfranciscof Date: Fri, 11 Dec 2020 13:10:02 +0100 Subject: [PATCH 027/114] Docs: add content from old documentation on caching/hashing (#4546) Move the content of "Controlling hashing" and "Design guidelines" inside of `developer_guide/core/caching.rst` to `topics/provenance/caching`. --- docs/source/conf.py | 1 - docs/source/developer_guide/core/caching.rst | 58 -------------------- docs/source/internals/engine.rst | 50 +++++++++++++++-- docs/source/internals/index.rst | 2 +- docs/source/topics/provenance/caching.rst | 50 +++++++++++++++-- 5 files changed, 91 insertions(+), 70 deletions(-) delete mode 100644 docs/source/developer_guide/core/caching.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 633078f48f..f99ab1eb34 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -97,7 +97,6 @@ 'get_started/**', 'howto/installation_more/index.rst', 'import_export/**', - 'internals/engine.rst', 'internals/global_design.rst', 'internals/orm.rst', 'scheduler/index.rst', diff --git a/docs/source/developer_guide/core/caching.rst b/docs/source/developer_guide/core/caching.rst deleted file mode 100644 index 3885222ae6..0000000000 --- a/docs/source/developer_guide/core/caching.rst +++ /dev/null @@ -1,58 +0,0 @@ -Caching: implementation details -+++++++++++++++++++++++++++++++ - -This section covers some details of the caching mechanism which are not discussed in the :ref:`user guide `. -If you are developing plugins and want to modify the caching behavior of your classes, we recommend you read :ref:`this section ` first. - -.. _devel_controlling_hashing: - -Controlling hashing -------------------- - -Below are some methods you can use to control how the hashes of calculation and data classes are computed: - -* To ignore specific attributes, a :py:class:`~aiida.orm.nodes.Node` subclass can have a ``_hash_ignored_attributes`` attribute. - This is a list of attribute names, which are ignored when creating the hash. -* For calculations, the ``_hash_ignored_inputs`` attribute lists inputs that should be ignored when creating the hash. -* To add things which should be considered in the hash, you can override the :meth:`~aiida.orm.nodes.Node._get_objects_to_hash` method. Note that doing so overrides the behavior described above, so you should make sure to use the ``super()`` method. -* Pass a keyword argument to :meth:`~aiida.orm.nodes.Node.get_hash`. - These are passed on to :meth:`~aiida.common.hashing.make_hash`. - -.. _devel_controlling_caching: - -Controlling caching -------------------- - -There are several methods you can use to disable caching for particular nodes: - -On the level of generic :class:`aiida.orm.nodes.Node`: - -* The :meth:`~aiida.orm.nodes.Node.is_valid_cache` property determines whether a particular node can be used as a cache. This is used for example to disable caching from failed calculations. -* Node classes have a ``_cachable`` attribute, which can be set to ``False`` to completely switch off caching for nodes of that class. This avoids performing queries for the hash altogether. - -On the level of :class:`aiida.engine.processes.process.Process` and :class:`aiida.orm.nodes.process.ProcessNode`: - -* The :meth:`ProcessNode.is_valid_cache ` calls :meth:`Process.is_valid_cache `, passing the node itself. This can be used in :class:`~aiida.engine.processes.process.Process` subclasses (e.g. in calculation plugins) to implement custom ways of invalidating the cache. -* The ``spec.exit_code`` has a keyword argument ``invalidates_cache``. If this is set to ``True``, returning that exit code means the process is no longer considered a valid cache. This is implemented in :meth:`Process.is_valid_cache `. - - -The ``WorkflowNode`` example -............................ - -As discussed in the :ref:`user guide `, nodes which can have ``RETURN`` links cannot be cached. -This is enforced on two levels: - -* The ``_cachable`` property is set to ``False`` in the :class:`~aiida.orm.nodes.Node`, and only re-enabled in :class:`~aiida.orm.nodes.process.calculation.calculation.CalculationNode` (which affects CalcJobs and calcfunctions). - This means that a :class:`~aiida.orm.nodes.process.workflow.workflow.WorkflowNode` will not be cached. -* The ``_store_from_cache`` method, which is used to "clone" an existing node, will raise an error if the existing node has any ``RETURN`` links. - This extra safe-guard prevents cases where a user might incorrectly override the ``_cachable`` property on a ``WorkflowNode`` subclass. - -Design guidelines ------------------ - -When modifying the hashing/caching behaviour of your classes, keep in mind that cache matches can go wrong in two ways: - -* False negatives, where two nodes *should* have the same hash but do not -* False positives, where two different nodes get the same hash by mistake - -False negatives are **highly preferrable** because they only increase the runtime of your calculations, while false positives can lead to wrong results. diff --git a/docs/source/internals/engine.rst b/docs/source/internals/engine.rst index 6a1c93c07e..0c4fd84f4b 100644 --- a/docs/source/internals/engine.rst +++ b/docs/source/internals/engine.rst @@ -1,11 +1,49 @@ -.. todo:: +.. _internal_architecture:engine: - .. _internal_architecture:engine: +****** +Engine +****** - ****** - Engine - ****** - `#4038`_ + +.. _internal_architecture:engine:caching: + +Controlling caching +------------------- + +.. important:: + + This section covers some details of the caching mechanism which are not discussed in the :ref:`topics section `. + If you are developing plugins and want to modify the caching behavior of your classes, we recommend you read that section first. + +There are several methods which the internal classes of AiiDA use to control the caching mechanism: + +On the level of the generic :class:`orm.Node ` class: + +* The :meth:`~aiida.orm.nodes.Node.is_valid_cache` property determines whether a particular node can be used as a cache. + This is used for example to disable caching from failed calculations. +* Node classes have a ``_cachable`` attribute, which can be set to ``False`` to completely switch off caching for nodes of that class. + This avoids performing queries for the hash altogether. + +On the level of the :class:`Process ` and :class:`orm.ProcessNode ` classes: + +* The :meth:`ProcessNode.is_valid_cache ` calls :meth:`Process.is_valid_cache `, passing the node itself. + This can be used in :class:`~aiida.engine.processes.process.Process` subclasses (e.g. in calculation plugins) to implement custom ways of invalidating the cache. +* The :meth:`ProcessNode._hash_ignored_inputs ` attribute lists the inputs that should be ignored when creating the hash. + This is checked by the :meth:`ProcessNode._get_objects_to_hash ` method. +* The :meth:`Process.is_valid_cache ` is where the :meth:`exit_codes ` that have been marked by ``invalidates_cache`` are checked. + + +The ``WorkflowNode`` example +............................ + +As discussed in the :ref:`topic section `, nodes which can have ``RETURN`` links cannot be cached. +This is enforced on two levels: + +* The ``_cachable`` property is set to ``False`` in the :class:`~aiida.orm.nodes.Node`, and only re-enabled in :class:`~aiida.orm.nodes.process.calculation.calculation.CalculationNode` (which affects CalcJobs and calcfunctions). + This means that a :class:`~aiida.orm.nodes.process.workflow.workflow.WorkflowNode` will not be cached. +* The ``_store_from_cache`` method, which is used to "clone" an existing node, will raise an error if the existing node has any ``RETURN`` links. + This extra safe-guard prevents cases where a user might incorrectly override the ``_cachable`` property on a ``WorkflowNode`` subclass. + .. _#4038: https://github.com/aiidateam/aiida-core/issues/4038 diff --git a/docs/source/internals/index.rst b/docs/source/internals/index.rst index 14f900f266..1bd33c1690 100644 --- a/docs/source/internals/index.rst +++ b/docs/source/internals/index.rst @@ -7,10 +7,10 @@ Internal architecture data_storage plugin_system + engine rest_api .. todo:: global_design orm - engine diff --git a/docs/source/topics/provenance/caching.rst b/docs/source/topics/provenance/caching.rst index 00d977eded..4c9867c381 100644 --- a/docs/source/topics/provenance/caching.rst +++ b/docs/source/topics/provenance/caching.rst @@ -4,6 +4,11 @@ Caching and hashing =================== +This section covers the more general considerations of the hashing/caching mechanism. +For a more practical guide on how to enable and disable this feature, please visit the corresponding :ref:`how-to section `. +If you want to know more about how the internal design of the mechanism is implemented, you can check the :ref:`internals section ` instead. + + .. _topics:provenance:caching:hashing: How are nodes hashed @@ -23,7 +28,7 @@ The hash of a :class:`~aiida.orm.ProcessNode` includes, on top of this, the hash Once a node is stored in the database, its hash is stored in the ``_aiida_hash`` extra, and this extra is used to find matching nodes. If a node of the same class with the same hash already exists in the database, this is considered a cache match. -Use the :meth:`~aiida.orm.nodes.Node.get_hash` method to check the hash of any node. +You can use the :meth:`~aiida.orm.nodes.Node.get_hash` method to check the hash of any node. In order to figure out why a calculation is *not* being reused, the :meth:`~aiida.orm.nodes.Node._get_objects_to_hash` method may be useful: .. code-block:: ipython @@ -53,10 +58,40 @@ In order to figure out why a calculation is *not* being reused, the :meth:`~aiid ] +.. _topics:provenance:caching:control: + +Controlling hashing +------------------- + +There are a couple of ways in which you can customized what properties are being considered when calculating the hash for a new instance of a data node. +Most of this are established at the time of creating the data plugin extension: + +* If you wish to ignore specific attributes, a :py:class:`~aiida.orm.nodes.Node` subclass can have a ``_hash_ignored_attributes`` attribute. + This is a list of attribute names, which are ignored when creating the hash. +* To add things which should be considered in the hash, you can override the :meth:`~aiida.orm.nodes.Node._get_objects_to_hash` method. + Note that doing so also overrides the behavior described above, so make sure to use the ``super()`` method in order to prevent this. +* You can pass a keyword argument to :meth:`~aiida.orm.nodes.Node.get_hash`. + These are, in turn, passed on to :meth:`~aiida.common.hashing.make_hash`. + +The process nodes have a fixed behavior that is internal to AiiDA and are not subclassable, so they can't be customized in a direct way. +To know more about the specifics of these internals you can visit the :ref:`corresponding section `. +The only way in which these can be influenced by plugin designers is indirectly via the hash criteria for the associated data types of their inputs. + +Controlling Caching +------------------- + +Although you can't directly controll the hashing mechanism of the process node when implementing a plugin, there are ways in which you can control its caching: + +* The :meth:` spec.exit_code ` has a keyword argument ``invalidates_cache``. + If this is set to ``True``, that means that a calculation with this exit code will not be used as a cache source for another one, even if their hashes match. +* The :class:`Process ` parent class from which calcjobs inherit has an :meth:`is_valid_cache ` method, which can be overriden in the plugin to implement custom ways of invalidating the cache. + When doing this, make sure to call :meth:`super().is_valid_cache(node)` and respect its output: if it is `False`, your implementation should also return `False`. + If you do not comply with this, the 'invalidates_cache' keyword on exit codes will not work. + .. _topics:provenance:caching:limitations: -Limitations ------------ +Limitations and Guidelines +-------------------------- #. Workflow nodes are not cached. In the current design this follows from the requirement that the provenance graph be independent of whether caching is enabled or not: @@ -72,4 +107,11 @@ Limitations While AiiDA's hashes include the version of the Python package containing the calculation/data classes, it cannot detect cases where the underlying Python code was changed without increasing the version number. Another scenario that can lead to an erroneous cache hit is if the parser and calculation are not implemented as part of the same Python package, because the calculation nodes store only the name, but not the version of the used parser. -#. Finally, while caching saves unnecessary computations, it does not save disk space: the output nodes of the cached calculation are full copies of the original outputs. +#. Note that while caching saves unnecessary computations, it does not save disk space: the output nodes of the cached calculation are full copies of the original outputs. + +#. Finally, When modifying the hashing/caching behaviour of your classes, keep in mind that cache matches can go wrong in two ways: + + * False negatives, where two nodes *should* have the same hash but do not + * False positives, where two different nodes get the same hash by mistake + + False negatives are **highly preferrable** because they only increase the runtime of your calculations, while false positives can lead to wrong results. From 6f11c8a5aae58b659cbe4ec1c522eb885631ca0e Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Tue, 15 Dec 2020 08:40:38 +0100 Subject: [PATCH 028/114] Engine: remote `with_persistence=False` from process function runner (#4633) In principle the runner for a process function does not need a persister since it runs in one go and does not have intermediate steps at which the progress needs to be persisted. However, since the process function implementation calls `Manager.get_runner`, if a runner has not yet been created in the interpreter, one will be created and set to be the global one. This is where the problem occurs because the process function specifies `with_persistence=False` for the runner. This will cause any subsequent process submissions to fail since the `submit` function will call `runner.persister.save_checkpoint` which will fail since the `persister` of the runner is `None`. --- aiida/engine/processes/functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiida/engine/processes/functions.py b/aiida/engine/processes/functions.py index 12238d4580..a08e0ef012 100644 --- a/aiida/engine/processes/functions.py +++ b/aiida/engine/processes/functions.py @@ -113,7 +113,7 @@ def run_get_node(*args, **kwargs): :rtype: (dict, int) """ manager = get_manager() - runner = manager.get_runner(with_persistence=False) + runner = manager.get_runner() inputs = process_class.create_inputs(*args, **kwargs) # Remove all the known inputs from the kwargs From e04e786756c73826384e4c9dfa4e89ed7b2b7e9e Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Tue, 15 Dec 2020 18:52:32 +0100 Subject: [PATCH 029/114] `CalcJob`: improve testing and documentation of `retrieve_list` (#4611) The documentation on the `retrieve_list` syntax and its functioning was incorrect. The inaccuracies are corrected and extensive examples are provided that give an example file hierarchy for the remote working directory and then for a variety of definitions of the `retrieve_list` the resulting file structure in the retrieved folder is depicted. --- aiida/common/datastructures.py | 35 +++-- docs/source/topics/calculations/usage.rst | 175 +++++++++++++++++++--- tests/engine/daemon/test_execmanager.py | 131 ++++++++++++---- 3 files changed, 277 insertions(+), 64 deletions(-) diff --git a/aiida/common/datastructures.py b/aiida/common/datastructures.py index 7f8d0ca523..166c7d88d9 100644 --- a/aiida/common/datastructures.py +++ b/aiida/common/datastructures.py @@ -32,25 +32,32 @@ class CalcInfo(DefaultFieldsAttributeDict): In the following descriptions all paths have to be considered relative - * retrieve_list: a list of strings or tuples that indicate files that are to be retrieved from the remote - after the calculation has finished and stored in the repository in a FolderData. - If the entry in the list is just a string, it is assumed to be the filepath on the remote and it will - be copied to '.' of the repository with name os.path.split(item)[1] - If the entry is a tuple it is expected to have the following format + * retrieve_list: a list of strings or tuples that indicate files that are to be retrieved from the remote after the + calculation has finished and stored in the ``retrieved_folder`` output node of type ``FolderData``. If the entry + in the list is just a string, it is assumed to be the filepath on the remote and it will be copied to the base + directory of the retrieved folder, where the name corresponds to the basename of the remote relative path. This + means that any remote folder hierarchy is ignored entirely. - ('remotepath', 'localpath', depth) + Remote folder hierarchy can be (partially) maintained by using a tuple instead, with the following format - If the 'remotepath' is a file or folder, it will be copied in the repository to 'localpath'. - However, if the 'remotepath' contains file patterns with wildcards, the 'localpath' should be set to '.' - and the depth parameter should be an integer that decides the localname. The 'remotepath' will be split on - file separators and the local filename will be determined by joining the N last elements, where N is - given by the depth variable. + (source, target, depth) - Example: ('some/remote/path/files/pattern*[0-9].xml', '.', 2) + The ``source`` and ``target`` elements are relative filepaths in the remote and retrieved folder. The contents + of ``source`` (whether it is a file or folder) are copied in its entirety to the ``target`` subdirectory in the + retrieved folder. If no subdirectory should be created, ``'.'`` should be specified for ``target``. - Will result in all files that match the pattern to be copied to the local repository with path + The ``source`` filepaths support glob patterns ``*`` in case the exact name of the files that are to be + retrieved are not know a priori. - 'files/pattern*[0-9].xml' + The ``depth`` element can be used to control what level of nesting of the source folder hierarchy should be + maintained. If ``depth`` equals ``0`` or ``1`` (they are equivalent), only the basename of the ``source`` + filepath is kept. For each additional level, another subdirectory of the remote hierarchy is kept. For example: + + ('path/sub/file.txt', '.', 2) + + will retrieve the ``file.txt`` and store it under the path: + + sub/file.txt * retrieve_temporary_list: a list of strings or tuples that indicate files that will be retrieved and stored temporarily in a FolderData, that will be available only during the parsing call. diff --git a/docs/source/topics/calculations/usage.rst b/docs/source/topics/calculations/usage.rst index 5226424c3d..2104f15f9a 100644 --- a/docs/source/topics/calculations/usage.rst +++ b/docs/source/topics/calculations/usage.rst @@ -312,38 +312,171 @@ Note that the source path can point to a directory, in which case its contents w Retrieve list ~~~~~~~~~~~~~ -The retrieve list supports various formats to define what files should be retrieved. -The simplest is retrieving a single file, whose filename you know before hand and you simply want to copy with the same name in the retrieved folder. -Imagine you want to retrieve the files ``output1.out`` and ``output_folder/output2.out`` you would simply add them as strings to the retrieve list: +The retrieve list is a list of instructions of what files and folders should be retrieved by the engine once a calculation job has terminated. +Each instruction should have one of two formats: -.. code:: python + * a string representing a relative filepath in the remote working directory + * a tuple of length three that allows to control the name of the retrieved file or folder in the retrieved folder - calc_info.retrieve_list = ['output1.out', 'output_folder/output2.out'] +The retrieve list can contain any number of instructions and can use both formats at the same time. +The first format is obviously the simplest, however, this requires one knows the exact name of the file or folder to be retrieved and in addition any subdirectories will be ignored when it is retrieved. +If the exact filename is not known and `glob patterns `_ should be used, or if the original folder structure should be (partially) kept, one should use the tuple format, which has the following format: -The retrieved files will be copied over keeping the exact names and hierarchy. -If you require more control over the hierarchy and nesting, you can use tuples of length three instead, with the following items: + * `source relative path`: the relative path, with respect to the working directory on the remote, of the file or directory to retrieve. + * `target relative path`: the relative path of the directory in the retrieved folder in to which the content of the source will be copied. The string ``'.'`` indicates the top level in the retrieved folder. + * `depth`: the number of levels of nesting in the source path to maintain when copying, starting from the deepest file. - * `source relative path`: the relative path, with respect to the working directory on the remote, of the file or directory to retrieve - * `target relative path`: the relative path where to copy the files locally in the retrieved folder. The string `'.'` indicates the top level in the retrieved folder. - * `depth`: the number of levels of nesting in the folder hierarchy to maintain when copying, starting from the deepest file +To illustrate the various possibilities, consider the following example file hierarchy in the remote working directory: -For example, imagine the calculation will have written a file in the remote working directory with the folder hierarchy ``some/remote/path/files/output.dat``. -If you want to copy the file, with the final resulting path ``path/files/output.dat``, you would specify: +.. code:: bash -.. code:: python + ├─ path + | ├── sub + │ │ ├─ file_c.txt + │ │ └─ file_d.txt + | └─ file_b.txt + └─ file_a.txt - calc_info.retrieve_list = [('some/remote/path/files/output.dat', '.', 2)] +Below, you will find examples for various use cases of files and folders to be retrieved. +Each example starts with the format of the ``retrieve_list``, followed by a schematic depiction of the final file hierarchy that would be created in the retrieved folder. -The depth of two, ensures that only two levels of nesting are copied. -If the output files have dynamic names that one cannot know beforehand, the ``'*'`` glob pattern can be used. -For example, if the code will generate a number of XML files in the folder ``relative/path/output`` with filenames that follow the pattern ``file_*[0-9].xml``, you can instruct to retrieve all of them as follows: +Explicit file or folder +....................... -.. code:: python +Retrieving a single toplevel file or folder (with all its contents) where the final folder structure is not important. + +.. code:: bash + + retrieve_list = ['file_a.txt'] + + └─ file_a.txt + +.. code:: bash + + retrieve_list = ['path'] + + ├── sub + │ ├─ file_c.txt + │ └─ file_d.txt + └─ file_b.txt + + +Explicit nested file or folder +.............................. + +Retrieving a single file or folder (with all its contents) that is located in a subdirectory in the remote working directory, where the final folder structure is not important. + +.. code:: bash + + retrieve_list = ['path/file_b.txt'] + + └─ file_b.txt + +.. code:: bash + + retrieve_list = ['path/sub'] + + ├─ file_c.txt + └─ file_d.txt + + +Explicit nested file or folder keeping (partial) hierarchy +.......................................................... + +The following examples show how the file hierarchy of the retrieved files can be controlled. +By changing the ``depth`` parameter of the tuple, one can control what part of the remote folder hierarchy is kept. +In the given example, the maximum depth of the remote folder hierarchy is ``3``. +The following example shows that by specifying ``3``, the exact folder structure is kept: + +.. code:: bash + + retrieve_list = [('path/sub/file_c.txt', '.', 3)] + + └─ path + └─ sub + └─ file_c.txt + +For ``depth=2``, only two levels of nesting are kept (including the file itself) and so the ``path`` folder is discarded. + +.. code:: bash + + retrieve_list = [('path/sub/file_c.txt', '.', 2)] + + └─ sub + └─ file_c.txt + +The same applies for directories. +By specifying a directory for the first element, all its contents will be retrieved. +With ``depth=1``, only the first level ``sub`` is kept of the folder hierarchy. + +.. code:: bash + + retrieve_list = [('path/sub', '.', 1)] + + └── sub + ├─ file_c.txt + └─ file_d.txt + + +Pattern matching +................ + +If the exact file or folder name is not known beforehand, glob patterns can be used. +In the following examples, all files that match ``*c.txt`` in the directory ``path/sub`` will be retrieved. +Since ``depth=0`` the files will be copied without the ``path/sub`` subdirectory. + +.. code:: bash + + retrieve_list = [('path/sub/*c.txt', '.', 0)] + + └─ file_c.txt + +To keep the subdirectory structure, one can set the depth parameter, just as in the previous examples. + +.. code:: bash + + retrieve_list = [('path/sub/*c.txt', '.', 2)] + + └── sub + └─ file_c.txt + + +Specific target directory +......................... + +The final folder hierarchy of the retrieved files in the retrieved folder is not only determined by the hierarchy of the remote working directory, but can also be controlled through the second and third elements of the instructions tuples. +The final ``depth`` element controls what level of hierarchy of the source is maintained, where the second element specifies the base path in the retrieved folder into which the remote files should be retrieved. +For example, to retrieve a nested file, maintaining the remote hierarchy and storing it locally in the ``target`` directory, one can do the following: + +.. code:: bash + + retrieve_list = [('path/sub/file_c.txt', 'target', 3)] + + └─ target + └─ path + └─ sub + └─ file_c.txt + +The same applies for folders that are to be retrieved: + +.. code:: bash + + retrieve_list = [('path/sub', 'target', 1)] + + └─ target + └── sub + ├─ file_c.txt + └─ file_d.txt + +Note that `target` here is not used to rename the retrieved file or folder, but indicates the path of the directory into which the source is copied. +The target relative path is also compatible with glob patterns in the source relative paths: + +.. code:: bash - calc_info.retrieve_list = [('relative/path/output/file_*[0-9].xml', '.', 1)] + retrieve_list = [('path/sub/*c.txt', 'target', 0)] -The second item when using globbing *has* to be ``'.'`` and the depth works just as before. -In this example, all files matching the globbing pattern will be copied in the directory ``output`` in the retrieved folder data node. + └─ target + └─ file_c.txt Retrieve temporary list diff --git a/tests/engine/daemon/test_execmanager.py b/tests/engine/daemon/test_execmanager.py index 62c496ba97..2cce4eebca 100644 --- a/tests/engine/daemon/test_execmanager.py +++ b/tests/engine/daemon/test_execmanager.py @@ -7,54 +7,127 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### +# pylint: disable=redefined-outer-name """Tests for the :mod:`aiida.engine.daemon.execmanager` module.""" import io import os +import pathlib +import typing + import pytest from aiida.engine.daemon import execmanager from aiida.transports.plugins.local import LocalTransport -@pytest.mark.usefixtures('clear_database_before_test') -def test_retrieve_files_from_list(tmp_path_factory, generate_calculation_node): - """Test the `retrieve_files_from_list` function.""" - node = generate_calculation_node() +def serialize_file_hierarchy(dirpath: pathlib.Path) -> typing.Dict: + """Serialize the file hierarchy at ``dirpath``. - retrieve_list = [ - 'file_a.txt', - ('sub/folder', 'sub/folder', 0), - ] + .. note:: empty directories are ignored. - source = tmp_path_factory.mktemp('source') - target = tmp_path_factory.mktemp('target') + :param dirpath: the base path. + :return: a mapping representing the file hierarchy, where keys are filenames. The leafs correspond to files and the + values are the text contents. + """ + serialized = {} - content_a = b'content_a' - content_b = b'content_b' + for root, _, files in os.walk(dirpath): + for filepath in files: - with open(str(source / 'file_a.txt'), 'wb') as handle: - handle.write(content_a) - handle.flush() + relpath = pathlib.Path(root).relative_to(dirpath) + subdir = serialized + if relpath.parts: + for part in relpath.parts: + subdir = subdir.setdefault(part, {}) + subdir[filepath] = (pathlib.Path(root) / filepath).read_text() - os.makedirs(str(source / 'sub' / 'folder')) + return serialized - with open(str(source / 'sub' / 'folder' / 'file_b.txt'), 'wb') as handle: - handle.write(content_b) - handle.flush() - with LocalTransport() as transport: - transport.chdir(str(source)) - execmanager.retrieve_files_from_list(node, transport, str(target), retrieve_list) +def create_file_hierarchy(hierarchy: typing.Dict, basepath: pathlib.Path) -> None: + """Create the file hierarchy represented by the hierarchy created by ``serialize_file_hierarchy``. + + .. note:: empty directories are ignored and are not created explicitly on disk. + + :param hierarchy: mapping with structure returned by ``serialize_file_hierarchy``. + :param basepath: the basepath where to write the hierarchy to disk. + """ + for filename, value in hierarchy.items(): + if isinstance(value, dict): + create_file_hierarchy(value, basepath / filename) + else: + basepath.mkdir(parents=True, exist_ok=True) + (basepath / filename).write_text(value) + + +@pytest.fixture +def file_hierarchy(): + """Return a sample nested file hierarchy.""" + return { + 'file_a.txt': 'file_a', + 'path': { + 'file_b.txt': 'file_b', + 'sub': { + 'file_c.txt': 'file_c', + 'file_d.txt': 'file_d' + } + } + } - assert sorted(os.listdir(str(target))) == sorted(['file_a.txt', 'sub']) - assert os.listdir(str(target / 'sub')) == ['folder'] - assert os.listdir(str(target / 'sub' / 'folder')) == ['file_b.txt'] - with open(str(target / 'sub' / 'folder' / 'file_b.txt'), 'rb') as handle: - assert handle.read() == content_b +def test_hierarchy_utility(file_hierarchy, tmp_path): + """Test that the ``create_file_hierarchy`` and ``serialize_file_hierarchy`` function as intended. + + This is tested by performing a round-trip. + """ + create_file_hierarchy(file_hierarchy, tmp_path) + assert serialize_file_hierarchy(tmp_path) == file_hierarchy + + +# yapf: disable +@pytest.mark.usefixtures('clear_database_before_test') +@pytest.mark.parametrize('retrieve_list, expected_hierarchy', ( + # Single file or folder, either toplevel or nested + (['file_a.txt'], {'file_a.txt': 'file_a'}), + (['path/sub/file_c.txt'], {'file_c.txt': 'file_c'}), + (['path'], {'path': {'file_b.txt': 'file_b', 'sub': {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}}}), + (['path/sub'], {'sub': {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}}), + # Single nested file that is retrieved keeping a varying level of depth of original hierarchy + ([('path/sub/file_c.txt', '.', 3)], {'path': {'sub': {'file_c.txt': 'file_c'}}}), + ([('path/sub/file_c.txt', '.', 2)], {'sub': {'file_c.txt': 'file_c'}}), + ([('path/sub/file_c.txt', '.', 1)], {'file_c.txt': 'file_c'}), + ([('path/sub/file_c.txt', '.', 0)], {'file_c.txt': 'file_c'}), + # Single nested folder that is retrieved keeping a varying level of depth of original hierarchy + ([('path/sub', '.', 2)], {'path': {'sub': {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}}}), + ([('path/sub', '.', 1)], {'sub': {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}}), + # Using globbing patterns + ([('path/*', '.', 0)], {'file_b.txt': 'file_b', 'sub': {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}}), + ([('path/sub/*', '.', 0)], {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}), # This is identical to ['path/sub'] + ([('path/sub/*c.txt', '.', 2)], {'sub': {'file_c.txt': 'file_c'}}), + ([('path/sub/*c.txt', '.', 0)], {'file_c.txt': 'file_c'}), + # Different target directory + ([('path/sub/file_c.txt', 'target', 3)], {'target': {'path': {'sub': {'file_c.txt': 'file_c'}}}}), + ([('path/sub', 'target', 1)], {'target': {'sub': {'file_c.txt': 'file_c', 'file_d.txt': 'file_d'}}}), + ([('path/sub/*c.txt', 'target', 2)], {'target': {'sub': {'file_c.txt': 'file_c'}}}), + # Missing files should be ignored and not cause the retrieval to except + (['file_a.txt', 'file_u.txt', 'path/file_u.txt', ('path/sub/file_u.txt', '.', 3)], {'file_a.txt': 'file_a'}), +)) +# yapf: enable +def test_retrieve_files_from_list( + tmp_path_factory, generate_calculation_node, file_hierarchy, retrieve_list, expected_hierarchy +): + """Test the `retrieve_files_from_list` function.""" + source = tmp_path_factory.mktemp('source') + target = tmp_path_factory.mktemp('target') + + create_file_hierarchy(file_hierarchy, source) + + with LocalTransport() as transport: + node = generate_calculation_node() + transport.chdir(source) + execmanager.retrieve_files_from_list(node, transport, target, retrieve_list) - with open(str(target / 'file_a.txt'), 'rb') as handle: - assert handle.read() == content_a + assert serialize_file_hierarchy(target) == expected_hierarchy @pytest.mark.usefixtures('clear_database_before_test') From acc870d7a218f9004da7843ac7068bd6975f25c6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 16 Dec 2020 23:17:29 +0100 Subject: [PATCH 030/114] CI: remote the `numpy` install workaround for `pymatgen` The problem occurred due to an outdated version of `setuptools` which would be invoked when `pymatgen` gets installed from a tarball, in which case the wheel has to be built. In this scenario, the build requirements get installed by `setuptools`, which at outdated versions did not respect the Python requirements of the dependencies which would cause incompatible version of `numpy` to be installed, calling the build to fail. By updating `setuptools` the workaround of manually installing a compatible `numpy` version beforehand is no longer necessary. --- .github/workflows/ci-code.yml | 12 +++--------- .github/workflows/test-install.yml | 11 ++++------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-code.yml b/.github/workflows/ci-code.yml index 216f1a9f6b..95f2c8676a 100644 --- a/.github/workflows/ci-code.yml +++ b/.github/workflows/ci-code.yml @@ -84,18 +84,12 @@ jobs: sudo apt update sudo apt install postgresql-10 graphviz - - name: Upgrade pip + - name: Upgrade pip and setuptools + # It is crucial to update `setuptools` or the installation of `pymatgen` can break run: | - pip install --upgrade pip + pip install --upgrade pip setuptools pip --version - # Work-around issue caused by pymatgen's setup process, which will install the latest - # numpy version (including release candidates) regardless of our actual specification - # By installing the version from the requirements file, we should get a compatible version - - name: Install numpy - run: | - pip install `grep 'numpy==' requirements/requirements-py-${{ matrix.python-version }}.txt` - - name: Install aiida-core run: | pip install --use-feature=2020-resolver -r requirements/requirements-py-${{ matrix.python-version }}.txt diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index c638cdeb3d..8c1c9d0393 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -153,14 +153,11 @@ jobs: sudo apt update sudo apt install postgresql-10 graphviz - - run: pip install --upgrade pip - - # Work-around issue caused by pymatgen's setup process, which will install the latest - # numpy version (including release candidates) regardless of our actual specification - # By installing the version from the requirements file, we should get a compatible version - - name: Install numpy + - name: Upgrade pip and setuptools + # It is crucial to update `setuptools` or the installation of `pymatgen` can break run: | - pip install `grep 'numpy==' requirements/requirements-py-${{ matrix.python-version }}.txt` + pip install --upgrade pip setuptools + pip --version - name: Install aiida-core run: | From 724fc6cfd12d70d80e350d3633b8fcad529a982f Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Thu, 17 Dec 2020 16:42:01 +0100 Subject: [PATCH 031/114] CI: skip `restapi.test_threaded_restapi:test_run_without_close_session` This test has been consistently failing on Python 3.8 and 3.9 despite the two reruns using flaky. For now we skip it entirely instead. --- tests/restapi/test_threaded_restapi.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/restapi/test_threaded_restapi.py b/tests/restapi/test_threaded_restapi.py index 02af3839ef..ff77342367 100644 --- a/tests/restapi/test_threaded_restapi.py +++ b/tests/restapi/test_threaded_restapi.py @@ -62,8 +62,7 @@ def test_run_threaded_server(restapi_server, server_url, aiida_localhost): pytest.fail('Thread did not close/join within 1 min after REST API server was called to shutdown') -# Tracked in issue #4281 -@pytest.mark.flaky(reruns=2) +@pytest.mark.skip('Is often failing on Python 3.8 and 3.9: see https://github.com/aiidateam/aiida-core/issues/4281') @pytest.mark.usefixtures('clear_database_before_test', 'restrict_sqlalchemy_queuepool') def test_run_without_close_session(restapi_server, server_url, aiida_localhost, capfd): """Run AiiDA REST API threaded in a separate thread and perform many sequential requests""" From 6c60afb1c81b88a4864573803860ef38b9da5e99 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Fri, 18 Dec 2020 15:34:13 +0100 Subject: [PATCH 032/114] Dependencies: update requirement `plumpy~=0.18.1` (#4642) This patch release of `plumpy` fixes a critical bug that makes the new `asyncio` based implementation of the engine compatible with Jupyter notebooks. --- environment.yml | 2 +- requirements/requirements-py-3.6.txt | 2 +- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- requirements/requirements-py-3.9.txt | 2 +- setup.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/environment.yml b/environment.yml index 1d1b7ea461..6701fa52ce 100644 --- a/environment.yml +++ b/environment.yml @@ -25,7 +25,7 @@ dependencies: - numpy~=1.17 - pamqp~=2.3 - paramiko~=2.7 -- plumpy~=0.18.0 +- plumpy~=0.18.1 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 07af6d9a9c..cb5064c3e2 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -86,7 +86,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.18.0 +plumpy==0.18.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 25895f80e9..68ca66e169 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -85,7 +85,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.18.0 +plumpy==0.18.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 6352745ff1..0bb77ebddc 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -80,7 +80,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.18.0 +plumpy==0.18.1 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index ff2a338596..6f3d65c319 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -81,7 +81,7 @@ pika==1.1.0 Pillow==8.0.1 plotly==4.12.0 pluggy==0.13.1 -plumpy==0.18.0 +plumpy==0.18.1 prometheus-client==0.8.0 prompt-toolkit==3.0.8 psutil==5.7.3 diff --git a/setup.json b/setup.json index 2daf56e051..6054209dde 100644 --- a/setup.json +++ b/setup.json @@ -40,7 +40,7 @@ "numpy~=1.17", "pamqp~=2.3", "paramiko~=2.7", - "plumpy~=0.18.0", + "plumpy~=0.18.1", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", From 32c3228b5965a4f903b230cbc51254715b86e905 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Sun, 20 Dec 2020 10:31:52 +0100 Subject: [PATCH 033/114] CLI: ensure `verdi database version` works even if schema outdated (#4641) The command was failing if the database schema was out of sync because the backend was loaded, through `get_manager`, with the default schema check on. Since the database does not actually have to be used, other than to retrieve the current schema version and generation, we can load the backend without the check. --- aiida/cmdline/commands/cmd_database.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aiida/cmdline/commands/cmd_database.py b/aiida/cmdline/commands/cmd_database.py index 6c0329221c..e3f1a63776 100644 --- a/aiida/cmdline/commands/cmd_database.py +++ b/aiida/cmdline/commands/cmd_database.py @@ -31,7 +31,9 @@ def database_version(): """ from aiida.manage.manager import get_manager - backend_manager = get_manager().get_backend_manager() + manager = get_manager() + manager._load_backend(schema_check=False) # pylint: disable=protected-access + backend_manager = manager.get_backend_manager() echo.echo('Generation: ', bold=True, nl=False) echo.echo(backend_manager.get_schema_generation_database()) From 7b03f04f8bdfa8ab89086ddaf98919ba9e3dafa0 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 7 Jan 2021 21:34:07 +0000 Subject: [PATCH 034/114] Add `verdi group delete --delete-nodes` (#4578) This commit makes a number of improvements to the deletion of nodes API/CLI: 1. Makes `delete_nodes` usable outside of `click`; adding a callback for the confirmation step, rather than calling `click.confirm` directly, and using logging instead of `click.echo` 2. Moves the function from `aiida/manage/database/delete/nodes.py` to `aiida/tools/graph/deletions.py`, leaving a deprecation warning at the old location. This is a more intuitive place since the function is directly build on the graph traversal functionality. 3. Exposes API functions *via* `from aiida.tools import delete_nodes` and adds their use to the documentation. 4. Adds `delete_group_nodes` mainly as a wrapper around `delete_nodes`; querying for all the node pks in the groups, then passing these to `delete_nodes` 5. Adds the ability to delete nodes to `verdi group delete --delete-nodes`, with the same flags and logic as `verdi node delete` 6. Fixes a bug in `verdi node delete`, introduced by #4575, if a node does not exist --- aiida/cmdline/commands/cmd_code.py | 24 ++- aiida/cmdline/commands/cmd_group.py | 54 +++++-- aiida/cmdline/commands/cmd_node.py | 31 ++-- aiida/manage/database/delete/nodes.py | 129 +++------------- aiida/tools/__init__.py | 5 +- aiida/tools/graph/__init__.py | 5 + aiida/tools/graph/deletions.py | 195 +++++++++++++++++++++++++ docs/source/howto/data.rst | 26 +++- docs/source/reference/command_line.rst | 4 +- pyproject.toml | 12 +- requirements/requirements-py-3.9.txt | 2 +- setup.json | 4 +- tests/cmdline/commands/test_group.py | 24 +++ tests/cmdline/commands/test_node.py | 5 + tests/test_nodes.py | 132 ++++++++++------- 15 files changed, 445 insertions(+), 207 deletions(-) create mode 100644 aiida/tools/graph/deletions.py diff --git a/aiida/cmdline/commands/cmd_code.py b/aiida/cmdline/commands/cmd_code.py index caf2f7e46c..b431271c70 100644 --- a/aiida/cmdline/commands/cmd_code.py +++ b/aiida/cmdline/commands/cmd_code.py @@ -9,6 +9,7 @@ ########################################################################### """`verdi code` command.""" from functools import partial +import logging import click import tabulate @@ -192,16 +193,25 @@ def delete(codes, verbose, dry_run, force): Note that codes are part of the data provenance, and deleting a code will delete all calculations using it. """ - from aiida.manage.database.delete.nodes import delete_nodes + from aiida.common.log import override_log_formatter_context + from aiida.tools import delete_nodes, DELETE_LOGGER - verbosity = 1 - if force: - verbosity = 0 - elif verbose: - verbosity = 2 + verbosity = logging.DEBUG if verbose else logging.INFO + DELETE_LOGGER.setLevel(verbosity) node_pks_to_delete = [code.pk for code in codes] - delete_nodes(node_pks_to_delete, dry_run=dry_run, verbosity=verbosity, force=force) + + def _dry_run_callback(pks): + if not pks or force: + return False + echo.echo_warning(f'YOU ARE ABOUT TO DELETE {len(pks)} NODES! THIS CANNOT BE UNDONE!') + return not click.confirm('Shall I continue?', abort=True) + + with override_log_formatter_context('%(message)s'): + _, was_deleted = delete_nodes(node_pks_to_delete, dry_run=dry_run or _dry_run_callback) + + if was_deleted: + echo.echo_success('Finished deletion.') @verdi_code.command() diff --git a/aiida/cmdline/commands/cmd_group.py b/aiida/cmdline/commands/cmd_group.py index ee773b7daf..f623fe74c0 100644 --- a/aiida/cmdline/commands/cmd_group.py +++ b/aiida/cmdline/commands/cmd_group.py @@ -9,6 +9,7 @@ ########################################################################### """`verdi group` commands""" import warnings +import logging import click from aiida.common.exceptions import UniquenessError @@ -17,6 +18,7 @@ from aiida.cmdline.params import options, arguments from aiida.cmdline.utils import echo from aiida.cmdline.utils.decorators import with_dbenv +from aiida.common.links import GraphTraversalRules @verdi.group('group') @@ -30,7 +32,7 @@ def verdi_group(): @arguments.NODES() @with_dbenv() def group_add_nodes(group, force, nodes): - """Add nodes to the a group.""" + """Add nodes to a group.""" if not force: click.confirm(f'Do you really want to add {len(nodes)} nodes to Group<{group.label}>?', abort=True) @@ -61,29 +63,55 @@ def group_remove_nodes(group, nodes, clear, force): @verdi_group.command('delete') @arguments.GROUP() +@options.FORCE() +@click.option( + '--delete-nodes', is_flag=True, default=False, help='Delete all nodes in the group along with the group itself.' +) +@options.graph_traversal_rules(GraphTraversalRules.DELETE.value) +@options.DRY_RUN() +@options.VERBOSE() @options.GROUP_CLEAR( help='Remove all nodes before deleting the group itself.' + ' [deprecated: No longer has any effect. Will be removed in 2.0.0]' ) -@options.FORCE() @with_dbenv() -def group_delete(group, clear, force): - """Delete a group. - - Note that this command only deletes groups - nodes contained in the group will remain untouched. - """ +def group_delete(group, clear, delete_nodes, dry_run, force, verbose, **traversal_rules): + """Delete a group and (optionally) the nodes it contains.""" + from aiida.common.log import override_log_formatter_context + from aiida.tools import delete_group_nodes, DELETE_LOGGER from aiida import orm - label = group.label - if clear: warnings.warn('`--clear` is deprecated and no longer has any effect.', AiidaDeprecationWarning) # pylint: disable=no-member - if not force: - click.confirm(f'Are you sure to delete Group<{label}>?', abort=True) + label = group.label + klass = group.__class__.__name__ + + verbosity = logging.DEBUG if verbose else logging.INFO + DELETE_LOGGER.setLevel(verbosity) + + if not (force or dry_run): + click.confirm(f'Are you sure to delete {klass}<{label}>?', abort=True) + elif dry_run: + echo.echo_info(f'Would have deleted {klass}<{label}>.') + + if delete_nodes: + + def _dry_run_callback(pks): + if not pks or force: + return False + echo.echo_warning(f'YOU ARE ABOUT TO DELETE {len(pks)} NODES! THIS CANNOT BE UNDONE!') + return not click.confirm('Shall I continue?', abort=True) + + with override_log_formatter_context('%(message)s'): + _, nodes_deleted = delete_group_nodes([group.pk], dry_run=dry_run or _dry_run_callback, **traversal_rules) + if not nodes_deleted: + # don't delete the group if the nodes were not deleted + return - orm.Group.objects.delete(group.pk) - echo.echo_success(f'Group<{label}> deleted.') + if not dry_run: + orm.Group.objects.delete(group.pk) + echo.echo_success(f'{klass}<{label}> deleted.') @verdi_group.command('relabel') diff --git a/aiida/cmdline/commands/cmd_node.py b/aiida/cmdline/commands/cmd_node.py index 51ab802d37..1be70e7ee4 100644 --- a/aiida/cmdline/commands/cmd_node.py +++ b/aiida/cmdline/commands/cmd_node.py @@ -9,6 +9,7 @@ ########################################################################### """`verdi node` command.""" +import logging import shutil import pathlib @@ -302,32 +303,40 @@ def tree(nodes, depth): @options.FORCE() @options.graph_traversal_rules(GraphTraversalRules.DELETE.value) @with_dbenv() -def node_delete(identifier, dry_run, verbose, force, **kwargs): +def node_delete(identifier, dry_run, verbose, force, **traversal_rules): """Delete nodes from the provenance graph. This will not only delete the nodes explicitly provided via the command line, but will also include the nodes necessary to keep a consistent graph, according to the rules outlined in the documentation. You can modify some of those rules using options of this command. """ + from aiida.common.log import override_log_formatter_context from aiida.orm.utils.loaders import NodeEntityLoader - from aiida.manage.database.delete.nodes import delete_nodes + from aiida.tools import delete_nodes, DELETE_LOGGER - verbosity = 1 - if force: - verbosity = 0 - elif verbose: - verbosity = 2 + verbosity = logging.DEBUG if verbose else logging.INFO + DELETE_LOGGER.setLevel(verbosity) pks = [] for obj in identifier: # we only load the node if we need to convert from a uuid/label - if isinstance(obj, int): - pks.append(obj) - else: + try: + pks.append(int(obj)) + except ValueError: pks.append(NodeEntityLoader.load_entity(obj).pk) - delete_nodes(pks, dry_run=dry_run, verbosity=verbosity, force=force, **kwargs) + def _dry_run_callback(pks): + if not pks or force: + return False + echo.echo_warning(f'YOU ARE ABOUT TO DELETE {len(pks)} NODES! THIS CANNOT BE UNDONE!') + return not click.confirm('Shall I continue?', abort=True) + + with override_log_formatter_context('%(message)s'): + _, was_deleted = delete_nodes(pks, dry_run=dry_run or _dry_run_callback, **traversal_rules) + + if was_deleted: + echo.echo_success('Finished deletion.') @verdi_node.command('rehash') diff --git a/aiida/manage/database/delete/nodes.py b/aiida/manage/database/delete/nodes.py index e9c89a6828..03a7edc47f 100644 --- a/aiida/manage/database/delete/nodes.py +++ b/aiida/manage/database/delete/nodes.py @@ -7,118 +7,31 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### -"""Function to delete nodes from the database.""" -from typing import Iterable - -import click -from aiida.cmdline.utils import echo +"""Functions to delete nodes from the database, preserving provenance integrity.""" +from typing import Callable, Iterable, Optional, Set, Tuple, Union +import warnings def delete_nodes( - pks: Iterable[int], verbosity: int = 0, dry_run: bool = False, force: bool = False, **traversal_rules: bool -): - """Delete nodes by a list of pks. - - This command will delete not only the specified nodes, but also the ones that are - linked to these and should be also deleted in order to keep a consistent provenance - according to the rules explained in the concepts section of the documentation. - In summary: - - 1. If a DATA node is deleted, any process nodes linked to it will also be deleted. - - 2. If a CALC node is deleted, any incoming WORK node (callers) will be deleted as - well whereas any incoming DATA node (inputs) will be kept. Outgoing DATA nodes - (outputs) will be deleted by default but this can be disabled. - - 3. If a WORK node is deleted, any incoming WORK node (callers) will be deleted as - well, but all DATA nodes will be kept. Outgoing WORK or CALC nodes will be kept by - default, but deletion of either of both kind of connected nodes can be enabled. + pks: Iterable[int], + verbosity: Optional[int] = None, + dry_run: Union[bool, Callable[[Set[int]], bool]] = True, + force: Optional[bool] = None, + **traversal_rules: bool +) -> Tuple[Set[int], bool]: + """Delete nodes given a list of "starting" PKs. + + .. deprecated:: 1.6.0 + This function has been moved and will be removed in `v2.0.0`. + It should now be imported using `from aiida.tools import delete_nodes` - These rules are 'recursive', so if a CALC node is deleted, then its output DATA - nodes will be deleted as well, and then any CALC node that may have those as - inputs, and so on. - - :param pks: a list of the PKs of the nodes to delete - :param force: do not ask for confirmation to delete nodes. - :param verbosity: 0 prints nothing, - 1 prints just sums and total, - 2 prints individual nodes. - - :param dry_run: - Just perform a dry run and do not delete anything. - Print statistics according to the verbosity level set. - :param force: Do not ask for confirmation to delete nodes - - :param traversal_rules: graph traversal rules. See :const:`aiida.common.links.GraphTraversalRules` what rule names - are toggleable and what the defaults are. """ - # pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-statements - from aiida.backends.utils import delete_nodes_and_connections - from aiida.orm import Node, QueryBuilder, load_node - from aiida.tools.graph.graph_traversers import get_nodes_delete - - def _missing_callback(_pks: Iterable[int]): - for _pk in _pks: - echo.echo_warning(f'warning: node with pk<{_pk}> does not exist, skipping') - - pks_set_to_delete = get_nodes_delete(pks, get_links=False, missing_callback=_missing_callback, - **traversal_rules)['nodes'] - - # An empty set might be problematic for the queries done below. - if not pks_set_to_delete: - if verbosity: - echo.echo('Nothing to delete') - return - - if verbosity > 0: - echo.echo( - 'I {} delete {} node{}'.format( - 'would' if dry_run else 'will', len(pks_set_to_delete), 's' if len(pks_set_to_delete) > 1 else '' - ) - ) - if verbosity > 1: - builder = QueryBuilder().append( - Node, filters={'id': { - 'in': pks_set_to_delete - }}, project=('uuid', 'id', 'node_type', 'label') - ) - echo.echo(f"The nodes I {'would' if dry_run else 'will'} delete:") - for uuid, pk, type_string, label in builder.iterall(): - try: - short_type_string = type_string.split('.')[-2] - except IndexError: - short_type_string = type_string - echo.echo(f' {uuid} {pk} {short_type_string} {label}') - - if dry_run: - if verbosity > 0: - echo.echo('\nThis was a dry run, exiting without deleting anything') - return - - # Asking for user confirmation here - if force: - pass - else: - echo.echo_warning(f'YOU ARE ABOUT TO DELETE {len(pks_set_to_delete)} NODES! THIS CANNOT BE UNDONE!') - if not click.confirm('Shall I continue?'): - echo.echo('Exiting without deleting') - return - - # Recover the list of folders to delete before actually deleting the nodes. I will delete the folders only later, - # so that if there is a problem during the deletion of the nodes in the DB, I don't delete the folders - repositories = [load_node(pk)._repository for pk in pks_set_to_delete] # pylint: disable=protected-access - - if verbosity > 0: - echo.echo('Starting node deletion...') - delete_nodes_and_connections(pks_set_to_delete) - - if verbosity > 0: - echo.echo('Nodes deleted from database, deleting files from the repository now...') + from aiida.common.warnings import AiidaDeprecationWarning + from aiida.tools import delete_nodes as _delete - # If we are here, we managed to delete the entries from the DB. - # I can now delete the folders - for repository in repositories: - repository.erase(force=True) + warnings.warn( + 'This function has been moved and will be removed in `v2.0.0`.' + 'It should now be imported using `from aiida.tools import delete_nodes`', AiidaDeprecationWarning + ) # pylint: disable=no-member - if verbosity > 0: - echo.echo('Deletion completed.') + return _delete(pks, verbosity, dry_run, force, **traversal_rules) diff --git a/aiida/tools/__init__.py b/aiida/tools/__init__.py index fe4146ea57..ffdf77d6e5 100644 --- a/aiida/tools/__init__.py +++ b/aiida/tools/__init__.py @@ -25,5 +25,8 @@ from .data.array.kpoints import * from .data.structure import * from .dbimporters import * +from .graph import * -__all__ = (calculations.__all__ + data.array.kpoints.__all__ + data.structure.__all__ + dbimporters.__all__) +__all__ = ( + calculations.__all__ + data.array.kpoints.__all__ + data.structure.__all__ + dbimporters.__all__ + graph.__all__ +) diff --git a/aiida/tools/graph/__init__.py b/aiida/tools/graph/__init__.py index 2776a55f97..c095d1619a 100644 --- a/aiida/tools/graph/__init__.py +++ b/aiida/tools/graph/__init__.py @@ -7,3 +7,8 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### +# pylint: disable=wildcard-import,undefined-variable +"""Provides tools for traversing the provenance graph.""" +from .deletions import * + +__all__ = deletions.__all__ diff --git a/aiida/tools/graph/deletions.py b/aiida/tools/graph/deletions.py new file mode 100644 index 0000000000..b151f7d3c8 --- /dev/null +++ b/aiida/tools/graph/deletions.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Functions to delete entities from the database, preserving provenance integrity.""" +import logging +from typing import Callable, Iterable, Optional, Set, Tuple, Union +import warnings + +from aiida.backends.utils import delete_nodes_and_connections +from aiida.common.log import AIIDA_LOGGER +from aiida.common.warnings import AiidaDeprecationWarning +from aiida.orm import Group, Node, QueryBuilder, load_node +from aiida.tools.graph.graph_traversers import get_nodes_delete + +__all__ = ('DELETE_LOGGER', 'delete_nodes', 'delete_group_nodes') + +DELETE_LOGGER = AIIDA_LOGGER.getChild('delete') + + +def delete_nodes( + pks: Iterable[int], + verbosity: Optional[int] = None, + dry_run: Union[bool, Callable[[Set[int]], bool]] = True, + force: Optional[bool] = None, + **traversal_rules: bool +) -> Tuple[Set[int], bool]: + """Delete nodes given a list of "starting" PKs. + + This command will delete not only the specified nodes, but also the ones that are + linked to these and should be also deleted in order to keep a consistent provenance + according to the rules explained in the Topics - Provenance section of the documentation. + In summary: + + 1. If a DATA node is deleted, any process nodes linked to it will also be deleted. + + 2. If a CALC node is deleted, any incoming WORK node (callers) will be deleted as + well whereas any incoming DATA node (inputs) will be kept. Outgoing DATA nodes + (outputs) will be deleted by default but this can be disabled. + + 3. If a WORK node is deleted, any incoming WORK node (callers) will be deleted as + well, but all DATA nodes will be kept. Outgoing WORK or CALC nodes will be kept by + default, but deletion of either of both kind of connected nodes can be enabled. + + These rules are 'recursive', so if a CALC node is deleted, then its output DATA + nodes will be deleted as well, and then any CALC node that may have those as + inputs, and so on. + + .. deprecated:: 1.6.0 + The `verbosity` keyword will be removed in `v2.0.0`, set the level of `DELETE_LOGGER` instead. + + .. deprecated:: 1.6.0 + The `force` keyword will be removed in `v2.0.0`, use the `dry_run` option instead. + + :param pks: a list of starting PKs of the nodes to delete + (the full set will be based on the traversal rules) + + :param dry_run: + If True, return the pks to delete without deleting anything. + If False, delete the pks without confirmation + If callable, a function that return True/False, based on the pks, e.g. ``dry_run=lambda pks: True`` + + :param traversal_rules: graph traversal rules. + See :const:`aiida.common.links.GraphTraversalRules` for what rule names + are toggleable and what the defaults are. + + :returns: (pks to delete, whether they were deleted) + + """ + # pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-statements + + if verbosity is not None: + warnings.warn( + 'The verbosity option is deprecated and will be removed in `aiida-core==2.0.0`. ' + 'Set the level of DELETE_LOGGER instead', AiidaDeprecationWarning + ) # pylint: disable=no-member + + if force is not None: + warnings.warn( + 'The force option is deprecated and will be removed in `aiida-core==2.0.0`. ' + 'Use dry_run instead', AiidaDeprecationWarning + ) # pylint: disable=no-member + if force is True: + dry_run = False + + def _missing_callback(_pks: Iterable[int]): + for _pk in _pks: + DELETE_LOGGER.warning(f'warning: node with pk<{_pk}> does not exist, skipping') + + pks_set_to_delete = get_nodes_delete(pks, get_links=False, missing_callback=_missing_callback, + **traversal_rules)['nodes'] + + DELETE_LOGGER.info('%s Node(s) marked for deletion', len(pks_set_to_delete)) + + if pks_set_to_delete and DELETE_LOGGER.level == logging.DEBUG: + builder = QueryBuilder().append( + Node, filters={'id': { + 'in': pks_set_to_delete + }}, project=('uuid', 'id', 'node_type', 'label') + ) + DELETE_LOGGER.debug('Node(s) to delete:') + for uuid, pk, type_string, label in builder.iterall(): + try: + short_type_string = type_string.split('.')[-2] + except IndexError: + short_type_string = type_string + DELETE_LOGGER.debug(f' {uuid} {pk} {short_type_string} {label}') + + if dry_run is True: + DELETE_LOGGER.info('This was a dry run, exiting without deleting anything') + return (pks_set_to_delete, False) + + # confirm deletion + if callable(dry_run) and dry_run(pks_set_to_delete): + DELETE_LOGGER.info('This was a dry run, exiting without deleting anything') + return (pks_set_to_delete, False) + + if not pks_set_to_delete: + return (pks_set_to_delete, True) + + # Recover the list of folders to delete before actually deleting the nodes. I will delete the folders only later, + # so that if there is a problem during the deletion of the nodes in the DB, I don't delete the folders + repositories = [load_node(pk)._repository for pk in pks_set_to_delete] # pylint: disable=protected-access + + DELETE_LOGGER.info('Starting node deletion...') + delete_nodes_and_connections(pks_set_to_delete) + + DELETE_LOGGER.info('Nodes deleted from database, deleting files from the repository now...') + + # If we are here, we managed to delete the entries from the DB. + # I can now delete the folders + for repository in repositories: + repository.erase(force=True) + + DELETE_LOGGER.info('Deletion of nodes completed.') + + return (pks_set_to_delete, True) + + +def delete_group_nodes( + pks: Iterable[int], + dry_run: Union[bool, Callable[[Set[int]], bool]] = True, + **traversal_rules: bool +) -> Tuple[Set[int], bool]: + """Delete nodes contained in a list of groups (not the groups themselves!). + + This command will delete not only the nodes, but also the ones that are + linked to these and should be also deleted in order to keep a consistent provenance + according to the rules explained in the concepts section of the documentation. + In summary: + + 1. If a DATA node is deleted, any process nodes linked to it will also be deleted. + + 2. If a CALC node is deleted, any incoming WORK node (callers) will be deleted as + well whereas any incoming DATA node (inputs) will be kept. Outgoing DATA nodes + (outputs) will be deleted by default but this can be disabled. + + 3. If a WORK node is deleted, any incoming WORK node (callers) will be deleted as + well, but all DATA nodes will be kept. Outgoing WORK or CALC nodes will be kept by + default, but deletion of either of both kind of connected nodes can be enabled. + + These rules are 'recursive', so if a CALC node is deleted, then its output DATA + nodes will be deleted as well, and then any CALC node that may have those as + inputs, and so on. + + :param pks: a list of the groups + + :param dry_run: + If True, return the pks to delete without deleting anything. + If False, delete the pks without confirmation + If callable, a function that return True/False, based on the pks, e.g. ``dry_run=lambda pks: True`` + + :param traversal_rules: graph traversal rules. See :const:`aiida.common.links.GraphTraversalRules` what rule names + are toggleable and what the defaults are. + + :returns: (node pks to delete, whether they were deleted) + + """ + group_node_query = QueryBuilder().append( + Group, + filters={ + 'id': { + 'in': list(pks) + } + }, + tag='groups', + ).append(Node, project='id', with_group='groups') + group_node_query.distinct() + node_pks = group_node_query.all(flat=True) + return delete_nodes(node_pks, dry_run=dry_run, **traversal_rules) diff --git a/docs/source/howto/data.rst b/docs/source/howto/data.rst index ddae0d6c75..98eea01c89 100644 --- a/docs/source/howto/data.rst +++ b/docs/source/howto/data.rst @@ -507,10 +507,15 @@ From the command line interface: Are you sure to delete Group? [y/N]: y Success: Group deleted. -.. important:: - Any deletion operation related to groups won't affect the nodes themselves. - For example if you delete a group, the nodes that belonged to the group will remain in the database. - The same happens if you remove nodes from the group -- they will remain in the database but won't belong to the group anymore. +Any deletion operation related to groups, by default, will not affect the nodes themselves. +For example if you delete a group, the nodes that belonged to the group will remain in the database. +The same happens if you remove nodes from the group -- they will remain in the database but won't belong to the group anymore. + +If you also wish to delete the nodes, when deleting the group, use the ``--delete-nodes`` option: + +.. code-block:: console + + $ verdi group delete another_group --delete-nodes Copy one group into another ^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -764,7 +769,7 @@ Deleting data By default, every time you run or submit a new calculation, AiiDA will create for you new nodes in the database, and will never replace or delete data. There are cases, however, when it might be useful to delete nodes that are not useful anymore, for instance test runs or incorrect/wrong data and calculations. -For this case, AiiDA provides the ``verdi node delete`` command to remove the nodes from the provenance graph. +For this case, AiiDA provides the ``verdi node delete`` command and the :py:func:`~aiida.tools.graph.deletions.delete_nodes` function, to remove the nodes from the provenance graph. .. caution:: Once the data is deleted, there is no way to recover it (unless you made a backup). @@ -780,6 +785,13 @@ In addition, there are a number of additional rules that are not mandatory to en For instance, you can set ``--create-forward`` if, when deleting a calculation, you want to delete also the data it produced (using instead ``--no-create-forward`` will delete the calculation only, keeping the output data: note that this effectively strips out the provenance information of the output data). The full list of these flags is available from the help command ``verdi node delete -h``. +.. code-block:: python + + from aiida.tools import delete_nodes + pks_to_be_deleted = delete_nodes( + [1, 2, 3], dry_run=True, create_forward=True, call_calc_forward=True, call_work_forward=True + ) + Deleting computers ------------------ To delete a computer, you can use ``verdi computer delete``. @@ -807,8 +819,8 @@ This command will delete both the file repository and the database. It is not possible to restore a deleted profile unless it was previously backed up! -Transfering data -================ +Transferring data +================= .. danger:: diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 79bdb09be3..3832c50941 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -278,10 +278,10 @@ Below is a list with all available subcommands. --help Show this message and exit. Commands: - add-nodes Add nodes to the a group. + add-nodes Add nodes to a group. copy Duplicate a group. create Create an empty group with a given name. - delete Delete a group. + delete Delete a group and (optionally) the nodes it contains. description Change the description of a group. list Show a list of existing groups. path Inspect groups of nodes, with delimited label paths. diff --git a/pyproject.toml b/pyproject.toml index 939a30965f..a2a957cce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,16 @@ setenv = sqla: AIIDA_TEST_BACKEND = sqlalchemy commands = pytest {posargs} +[testenv:py{36,37,38,39}-verdi] +deps = + py36: -rrequirements/requirements-py-3.6.txt + py37: -rrequirements/requirements-py-3.7.txt + py38: -rrequirements/requirements-py-3.8.txt + py39: -rrequirements/requirements-py-3.9.txt +setenv = + AIIDA_TEST_BACKEND = django +commands = verdi {posargs} + [testenv:py{36,37,38,39}-docs-{clean,update}] description = clean: Build the documentation (remove any existing build) @@ -95,7 +105,7 @@ deps = py36: -rrequirements/requirements-py-3.6.txt py37: -rrequirements/requirements-py-3.7.txt py38: -rrequirements/requirements-py-3.8.txt - py38: -rrequirements/requirements-py-3.9.txt + py39: -rrequirements/requirements-py-3.9.txt passenv = RUN_APIDOC setenv = update: RUN_APIDOC = False diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 6f3d65c319..32564e4c39 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -126,7 +126,7 @@ simplejson==3.17.2 six==1.15.0 snowballstemmer==2.0.0 spglib==1.16.0 -Sphinx==3.3.0 +Sphinx==3.2.1 sphinx-copybutton==0.3.1 sphinx-notfound-page==0.5 sphinx-panels==0.5.2 diff --git a/setup.json b/setup.json index 6054209dde..ff7303dc57 100644 --- a/setup.json +++ b/setup.json @@ -74,7 +74,7 @@ "docutils==0.15.2", "pygments~=2.5", "pydata-sphinx-theme~=0.4.0", - "sphinx~=3.2", + "sphinx~=3.2.1", "sphinxcontrib-details-directive~=0.1.0", "sphinx-panels~=0.5.0", "sphinx-copybutton~=0.3.0", @@ -98,7 +98,7 @@ "packaging==20.3", "pre-commit~=2.2", "pylint~=2.5.0", - "pylint-django~=2.0", + "pylint-django>=2.0,<2.4.0", "tomlkit~=0.7.0" ], "tests": [ diff --git a/tests/cmdline/commands/test_group.py b/tests/cmdline/commands/test_group.py index a8b53370a5..0a4f3c0933 100644 --- a/tests/cmdline/commands/test_group.py +++ b/tests/cmdline/commands/test_group.py @@ -131,6 +131,12 @@ def test_delete(self): """Test `verdi group delete` command.""" orm.Group(label='group_test_delete_01').store() orm.Group(label='group_test_delete_02').store() + orm.Group(label='group_test_delete_03').store() + + # dry run + result = self.cli_runner.invoke(cmd_group.group_delete, ['--dry-run', 'group_test_delete_01']) + self.assertClickResultNoException(result) + orm.load_group(label='group_test_delete_01') result = self.cli_runner.invoke(cmd_group.group_delete, ['--force', 'group_test_delete_01']) self.assertClickResultNoException(result) @@ -142,6 +148,7 @@ def test_delete(self): node_01 = orm.CalculationNode().store() node_02 = orm.CalculationNode().store() + node_pks = {node_01.pk, node_02.pk} # Add some nodes and then use `verdi group delete` to delete a group that contains nodes group = orm.load_group(label='group_test_delete_02') @@ -154,6 +161,23 @@ def test_delete(self): with self.assertRaises(exceptions.NotExistent): orm.load_group(label='group_test_delete_02') + # check nodes still exist + for pk in node_pks: + orm.load_node(pk) + + # delete the group and the nodes it contains + group = orm.load_group(label='group_test_delete_03') + group.add_nodes([node_01, node_02]) + result = self.cli_runner.invoke(cmd_group.group_delete, ['--force', '--delete-nodes', 'group_test_delete_03']) + self.assertClickResultNoException(result) + + # check group and nodes no longer exist + with self.assertRaises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_03') + for pk in node_pks: + with self.assertRaises(exceptions.NotExistent): + orm.load_node(pk) + def test_show(self): """Test `verdi group show` command.""" result = self.cli_runner.invoke(cmd_group.group_show, ['dummygroup1']) diff --git a/tests/cmdline/commands/test_node.py b/tests/cmdline/commands/test_node.py index a99de1b532..619241e9b0 100644 --- a/tests/cmdline/commands/test_node.py +++ b/tests/cmdline/commands/test_node.py @@ -651,3 +651,8 @@ def test_basics(self): with self.assertRaises(NotExistent): orm.load_node(newnodepk) + + def test_missing_pk(self): + """Check that no exception is raised when a non-existent pk is given (just warns).""" + result = self.cli_runner.invoke(cmd_node.node_delete, ['999']) + self.assertClickResultNoException(result) diff --git a/tests/test_nodes.py b/tests/test_nodes.py index f032336afb..530ecad83b 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -18,8 +18,7 @@ from aiida.backends.testbase import AiidaTestCase from aiida.common.exceptions import InvalidOperation, ModificationNotAllowed, StoringNotAllowed, ValidationError from aiida.common.links import LinkType -from aiida.common.utils import Capturing -from aiida.manage.database.delete.nodes import delete_nodes +from aiida.tools import delete_nodes, delete_group_nodes class TestNodeIsStorable(AiidaTestCase): @@ -1544,8 +1543,34 @@ def _check_existence(self, uuids_check_existence, uuids_check_deleted): def test_deletion_non_existing_pk(): """Verify that passing a non-existing pk should not raise.""" non_existing_pk = -1 - with Capturing(): - delete_nodes([non_existing_pk], force=True) + delete_nodes([non_existing_pk], dry_run=False) + + def test_deletion_dry_run_true(self): + """Verify that a dry run should not delete the node.""" + node = orm.Data().store() + node_pk = node.pk + deleted_pks, was_deleted = delete_nodes([node_pk], dry_run=True) + self.assertTrue(not was_deleted) + self.assertSetEqual(deleted_pks, {node_pk}) + orm.load_node(node_pk) + + def test_deletion_dry_run_callback(self): + """Verify that a dry_run callback works.""" + from aiida.common.exceptions import NotExistent + node = orm.Data().store() + node_pk = node.pk + callback_pks = [] + + def _callback(pks): + callback_pks.extend(pks) + return False + + deleted_pks, was_deleted = delete_nodes([node_pk], dry_run=_callback) + self.assertTrue(was_deleted) + self.assertSetEqual(deleted_pks, {node_pk}) + with self.assertRaises(NotExistent): + orm.load_node(node_pk) + self.assertListEqual(callback_pks, [node_pk]) # TEST BASIC CASES @@ -1642,15 +1667,13 @@ def test_delete_cases(self): di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di]] uuids_check_deleted = [n.uuid for n in [dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([w1.pk], force=True) + delete_nodes([w1.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di]] uuids_check_deleted = [n.uuid for n in [dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([w2.pk], force=True) + delete_nodes([w2.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # By default, targetting a calculation will have the same effect because @@ -1659,15 +1682,13 @@ def test_delete_cases(self): di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di]] uuids_check_deleted = [n.uuid for n in [dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([c1.pk], force=True) + delete_nodes([c1.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di]] uuids_check_deleted = [n.uuid for n in [dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([c2.pk], force=True) + delete_nodes([c2.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # By default, targetting a data node will also have the same effect because @@ -1676,22 +1697,19 @@ def test_delete_cases(self): di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in []] uuids_check_deleted = [n.uuid for n in [di, dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([di.pk], force=True) + delete_nodes([di.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di]] uuids_check_deleted = [n.uuid for n in [dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([dm.pk], force=True) + delete_nodes([dm.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di]] uuids_check_deleted = [n.uuid for n in [dm, do, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([do.pk], force=True) + delete_nodes([do.pk], dry_run=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # Data deletion within the highest level workflow can be prevented by @@ -1700,15 +1718,13 @@ def test_delete_cases(self): di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di, dm, do]] uuids_check_deleted = [n.uuid for n in [c1, c2, w1, w2]] - with Capturing(): - delete_nodes([w2.pk], force=True, create_forward=False) + delete_nodes([w2.pk], dry_run=False, create_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [dm, do]] uuids_check_deleted = [n.uuid for n in [di, c1, c2, w1, w2]] - with Capturing(): - delete_nodes([di.pk], force=True, create_forward=False) + delete_nodes([di.pk], dry_run=False, create_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # On the other hand, the whole data provenance can be protected by @@ -1719,15 +1735,13 @@ def test_delete_cases(self): di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di, dm, do, c1, c2]] uuids_check_deleted = [n.uuid for n in [w1, w2]] - with Capturing(): - delete_nodes([w2.pk], force=True, call_calc_forward=False) + delete_nodes([w2.pk], dry_run=False, call_calc_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di, dm, c1]] uuids_check_deleted = [n.uuid for n in [do, c2, w1, w2]] - with Capturing(): - delete_nodes([c2.pk], force=True, call_calc_forward=False) + delete_nodes([c2.pk], dry_run=False, call_calc_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # Another posibility which also exists, though may have more limited @@ -1739,22 +1753,19 @@ def test_delete_cases(self): di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di, dm, c1, w1]] uuids_check_deleted = [n.uuid for n in [do, c2, w2]] - with Capturing(): - delete_nodes([w2.pk], force=True, call_work_forward=False) + delete_nodes([w2.pk], dry_run=False, call_work_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di, dm, do, c1, w1]] uuids_check_deleted = [n.uuid for n in [c2, w2]] - with Capturing(): - delete_nodes([w2.pk], force=True, call_work_forward=False, create_forward=False) + delete_nodes([w2.pk], dry_run=False, call_work_forward=False, create_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) di, dm, do, c1, c2, w1, w2 = self._create_simple_graph() uuids_check_existence = [n.uuid for n in [di, dm, do, c1, c2, w1]] uuids_check_deleted = [n.uuid for n in [w2]] - with Capturing(): - delete_nodes([w2.pk], force=True, call_work_forward=False, call_calc_forward=False) + delete_nodes([w2.pk], dry_run=False, call_work_forward=False, call_calc_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) @staticmethod @@ -1850,15 +1861,13 @@ def test_indep2w(self): dia, doa, pca, pwa, dib, dob, pcb, pwb, pw0 = self._create_indep2w_graph() uuids_check_existence = [n.uuid for n in [dia, dib]] uuids_check_deleted = [n.uuid for n in [doa, pca, pwa, dob, pcb, pwb, pw0]] - with Capturing(): - delete_nodes((pca.pk,), force=True, create_forward=True, call_calc_forward=True, call_work_forward=True) + delete_nodes((pca.pk,), dry_run=False, create_forward=True, call_calc_forward=True, call_work_forward=True) self._check_existence(uuids_check_existence, uuids_check_deleted) dia, doa, pca, pwa, dib, dob, pcb, pwb, pw0 = self._create_indep2w_graph() uuids_check_existence = [n.uuid for n in [dia, dib]] uuids_check_deleted = [n.uuid for n in [doa, pca, pwa, dob, pcb, pwb, pw0]] - with Capturing(): - delete_nodes((pwa.pk,), force=True, create_forward=True, call_calc_forward=True, call_work_forward=True) + delete_nodes((pwa.pk,), dry_run=False, create_forward=True, call_calc_forward=True, call_work_forward=True) self._check_existence(uuids_check_existence, uuids_check_deleted) # In this particular case where the workflow (pwa) only calls a single @@ -1872,15 +1881,13 @@ def test_indep2w(self): dia, doa, pca, pwa, dib, dob, pcb, pwb, pw0 = self._create_indep2w_graph() uuids_check_existence = [n.uuid for n in [dia, dib, dob, pcb, pwb]] uuids_check_deleted = [n.uuid for n in [doa, pca, pwa, pw0]] - with Capturing(): - delete_nodes((pca.pk,), force=True, create_forward=True, call_calc_forward=True, call_work_forward=False) + delete_nodes((pca.pk,), dry_run=False, create_forward=True, call_calc_forward=True, call_work_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) dia, doa, pca, pwa, dib, dob, pcb, pwb, pw0 = self._create_indep2w_graph() uuids_check_existence = [n.uuid for n in [dia, dib, dob, pcb, pwb]] uuids_check_deleted = [n.uuid for n in [doa, pca, pwa, pw0]] - with Capturing(): - delete_nodes((pwa.pk,), force=True, create_forward=True, call_calc_forward=True, call_work_forward=False) + delete_nodes((pwa.pk,), dry_run=False, create_forward=True, call_calc_forward=True, call_work_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # The best and most controlled way to deal with this situation would be @@ -1897,12 +1904,10 @@ def test_indep2w(self): uuids_check_existence2 = [n.uuid for n in [dia, dib, dob, pcb, pwb]] uuids_check_deleted2 = [n.uuid for n in [doa, pca, pwa, pw0]] - with Capturing(): - delete_nodes((pw0.pk,), force=True, create_forward=False, call_calc_forward=False, call_work_forward=False) + delete_nodes((pw0.pk,), dry_run=False, create_forward=False, call_calc_forward=False, call_work_forward=False) self._check_existence(uuids_check_existence1, uuids_check_deleted1) - with Capturing(): - delete_nodes((pwa.pk,), force=True, create_forward=True, call_calc_forward=True, call_work_forward=True) + delete_nodes((pwa.pk,), dry_run=False, create_forward=True, call_calc_forward=True, call_work_forward=True) self._check_existence(uuids_check_existence2, uuids_check_deleted2) @staticmethod @@ -1972,8 +1977,7 @@ def test_loop_cases(self): di1, di2, di3, do1, pws, pcs, pwm = self._create_looped_graph() uuids_check_existence = [n.uuid for n in [di1, di2]] uuids_check_deleted = [n.uuid for n in [di3, do1, pcs, pws, pwm]] - with Capturing(): - delete_nodes([di3.pk], force=True, create_forward=True, call_calc_forward=True, call_work_forward=True) + delete_nodes([di3.pk], dry_run=False, create_forward=True, call_calc_forward=True, call_work_forward=True) self._check_existence(uuids_check_existence, uuids_check_deleted) # When disabling the call_calc and call_work forward rules, deleting @@ -1982,8 +1986,7 @@ def test_loop_cases(self): di1, di2, di3, do1, pws, pcs, pwm = self._create_looped_graph() uuids_check_existence = [n.uuid for n in [di1, di2, do1, pcs]] uuids_check_deleted = [n.uuid for n in [di3, pws, pwm]] - with Capturing(): - delete_nodes([di3.pk], force=True, create_forward=True, call_calc_forward=False, call_work_forward=False) + delete_nodes([di3.pk], dry_run=False, create_forward=True, call_calc_forward=False, call_work_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # Of course, deleting the selected input will cause all the procedure to @@ -1991,8 +1994,7 @@ def test_loop_cases(self): di1, di2, di3, do1, pws, pcs, pwm = self._create_looped_graph() uuids_check_existence = [n.uuid for n in [di2, di3]] uuids_check_deleted = [n.uuid for n in [di1, do1, pws, pcs, pwm]] - with Capturing(): - delete_nodes([di1.pk], force=True, create_forward=True, call_calc_forward=False, call_work_forward=False) + delete_nodes([di1.pk], dry_run=False, create_forward=True, call_calc_forward=False, call_work_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) # Deleting with these settings the workflow that chooses inputs should @@ -2000,8 +2002,7 @@ def test_loop_cases(self): di1, di2, di3, do1, pws, pcs, pwm = self._create_looped_graph() uuids_check_existence = [n.uuid for n in [di1, di2, di3, do1, pcs]] uuids_check_deleted = [n.uuid for n in [pws, pwm]] - with Capturing(): - delete_nodes([pws.pk], force=True, create_forward=True, call_calc_forward=False, call_work_forward=False) + delete_nodes([pws.pk], dry_run=False, create_forward=True, call_calc_forward=False, call_work_forward=False) self._check_existence(uuids_check_existence, uuids_check_deleted) @staticmethod @@ -2042,6 +2043,29 @@ def test_long_case(self): node_list = self._create_long_graph(10) uuids_check_existence = [n.uuid for n in node_list[:3]] uuids_check_deleted = [n.uuid for n in node_list[3:]] - with Capturing(): - delete_nodes((node_list[3].pk,), force=True, create_forward=True) + delete_nodes((node_list[3].pk,), dry_run=False, create_forward=True) self._check_existence(uuids_check_existence, uuids_check_deleted) + + def test_delete_group_nodes(self): + """Test deleting all nodes in a group.""" + group = orm.Group(label='agroup').store() + nodes = [orm.Data().store() for _ in range(2)] + node_pks = {node.pk for node in nodes} + node_uuids = {node.uuid for node in nodes} + group.add_nodes(nodes) + deleted_pks, was_deleted = delete_group_nodes([group.pk], dry_run=False) + self.assertTrue(was_deleted) + self.assertSetEqual(deleted_pks, node_pks) + self._check_existence([], node_uuids) + + def test_delete_group_nodes_dry_run_true(self): + """Verify that a dry run should not delete the node.""" + group = orm.Group(label='agroup2').store() + nodes = [orm.Data().store() for _ in range(2)] + node_pks = {node.pk for node in nodes} + node_uuids = {node.uuid for node in nodes} + group.add_nodes(nodes) + deleted_pks, was_deleted = delete_group_nodes([group.pk], dry_run=True) + self.assertTrue(not was_deleted) + self.assertSetEqual(deleted_pks, node_pks) + self._check_existence(node_uuids, []) From 9419068ffa8717c2470fe8774155d7006080540d Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 10 Jan 2021 17:31:00 +0000 Subject: [PATCH 035/114] =?UTF-8?q?=F0=9F=A7=AA=20FIX:=20engine=20benchmar?= =?UTF-8?q?k=20tests=20(#4652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `test_workchain_daemon` test group required updating to using asyncio (rather than tornado) --- tests/benchmark/test_engine.py | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/tests/benchmark/test_engine.py b/tests/benchmark/test_engine.py index 77009e5c96..ef9b996e19 100644 --- a/tests/benchmark/test_engine.py +++ b/tests/benchmark/test_engine.py @@ -13,12 +13,11 @@ The purpose of these tests is to benchmark and compare processes, which are executed *via* both a local runner and the daemon. """ -import datetime +import asyncio -from tornado import gen import pytest -from aiida.engine import run_get_node, submit, ToContext, while_, WorkChain +from aiida.engine import run_get_node, submit, while_, WorkChain from aiida.manage.manager import get_manager from aiida.orm import Code, Int from aiida.plugins.factories import CalculationFactory @@ -55,7 +54,7 @@ class WorkchainLoopWcSerial(WorkchainLoop): def run_task(self): future = self.submit(WorkchainLoop, iterations=Int(1)) - return ToContext(**{f'wkchain{str(self.ctx.counter)}': future}) + return self.to_context(**{f'wkchain{str(self.ctx.counter)}': future}) class WorkchainLoopWcThreaded(WorkchainLoop): @@ -71,7 +70,7 @@ def run_task(self): f'wkchain{str(i)}': self.submit(WorkchainLoop, iterations=Int(1)) for i in range(self.inputs.iterations.value) } - return ToContext(**context) + return self.to_context(**context) class WorkchainLoopCalcSerial(WorkchainLoop): @@ -84,7 +83,7 @@ def run_task(self): 'code': self.inputs.code, } future = self.submit(ArithmeticAddCalculation, **inputs) - return ToContext(addition=future) + return self.to_context(addition=future) class WorkchainLoopCalcThreaded(WorkchainLoop): @@ -103,7 +102,7 @@ def run_task(self): 'code': self.inputs.code, } futures[f'addition{str(i)}'] = self.submit(ArithmeticAddCalculation, **inputs) - return ToContext(**futures) + return self.to_context(**futures) WORKCHAINS = { @@ -131,17 +130,15 @@ def _run(): assert len(result.node.get_outgoing().all()) == outgoing -@gen.coroutine -def with_timeout(what, timeout=60): - """Coroutine return with timeout.""" - raise gen.Return((yield gen.with_timeout(datetime.timedelta(seconds=timeout), what))) +async def with_timeout(what, timeout=60): + result = await asyncio.wait_for(what, timeout) + return result -@gen.coroutine -def wait_for_process(runner, calc_node, timeout=60): - """Coroutine block with timeout.""" +async def wait_for_process(runner, calc_node, timeout=60): future = runner.get_process_future(calc_node.pk) - raise gen.Return((yield with_timeout(future, timeout))) + result = await with_timeout(future, timeout) + return result @pytest.fixture() @@ -159,13 +156,12 @@ def submit_get_node(): def _submit(_process, timeout=60, **kwargs): - @gen.coroutine - def _do_submit(): + async def _do_submit(): node = submit(_process, **kwargs) - yield wait_for_process(runner, node) + await wait_for_process(runner, node) return node - result = runner.loop.run_sync(_do_submit, timeout=timeout) + result = runner.loop.run_until_complete(_do_submit()) return result From dd6e7a6c59e50de3c5be379c0f1ad9ce90cd0d16 Mon Sep 17 00:00:00 2001 From: Pranjal Mishra <39010495+pranjalmish1@users.noreply.github.com> Date: Mon, 11 Jan 2021 20:05:26 +0530 Subject: [PATCH 036/114] Docs: Minor documentation fixes (#4643) Small changes and fixes in the documentation. --- docs/source/howto/data.rst | 8 ++++---- docs/source/howto/exploring.rst | 6 +++--- docs/source/howto/faq.rst | 6 +++--- docs/source/howto/installation.rst | 2 +- docs/source/howto/run_codes.rst | 8 ++++---- docs/source/howto/workflows.rst | 4 ++-- docs/source/intro/installation.rst | 2 +- docs/source/intro/troubleshooting.rst | 4 ++-- docs/source/intro/tutorial.rst | 8 ++++---- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/source/howto/data.rst b/docs/source/howto/data.rst index 98eea01c89..1431733700 100644 --- a/docs/source/howto/data.rst +++ b/docs/source/howto/data.rst @@ -10,7 +10,7 @@ How to work with data Importing data ============== -AiiDA allows users to export data from their database into an export archive file, which can be imported in any other AiiDA database. +AiiDA allows users to export data from their database into an export archive file, which can be imported into any other AiiDA database. If you have an AiiDA export archive that you would like to import, you can use the ``verdi import`` command (see :ref:`the reference section` for details). .. note:: For information on exporting and importing data via AiiDA archives, see :ref:`"How to share data"`. @@ -71,7 +71,7 @@ Then we just construct an instance of that class, passing the file of interest a Note that after construction, you will get an *unstored* node. This means that at this point your data is not yet stored in the database and you can first inspect it and optionally modify it. If you are happy with the results, you can store the new data permanently by calling the :py:meth:`~aiida.orm.nodes.node.Node.store` method. -Every node is assigned a Universal Unique Identifer (UUID) upon creation and once stored it is also assigned a primary key (PK), which can be retrieved through the ``node.uuid`` and ``node.pk`` properties, respectively. +Every node is assigned a Universal Unique Identifier (UUID) upon creation and once stored it is also assigned a primary key (PK), which can be retrieved through the ``node.uuid`` and ``node.pk`` properties, respectively. You can use these identifiers to reference and or retrieve a node. Ways to find and retrieve data that have previously been imported are described in section :ref:`"How to find data"`. @@ -129,7 +129,7 @@ However, they have to be of the same ORM-type (e.g. all have to be subclasses of .. code-block:: python qb = QueryBuilder() # Instantiating instance. One instance -> one query - qb.append([CalcJobNode, WorkChainNode]) # Setting first vertice of path, either WorkChainNode or Job. + qb.append([CalcJobNode, WorkChainNode]) # Setting first vertices of path, either WorkChainNode or Job. .. note:: @@ -148,7 +148,7 @@ There are several ways to obtain data from a query: .. code-block:: python qb = QueryBuilder() # Instantiating instance - qb.append(CalcJobNode) # Setting first vertice of path + qb.append(CalcJobNode) # Setting first vertices of path first_row = qb.first() # Returns a list (!) of the results of the first row diff --git a/docs/source/howto/exploring.rst b/docs/source/howto/exploring.rst index 13a6bba8c3..5eff89a5c1 100644 --- a/docs/source/howto/exploring.rst +++ b/docs/source/howto/exploring.rst @@ -10,11 +10,11 @@ Incoming and outgoing links =========================== The provenance graph in AiiDA is a :ref:`directed graph `. -The vertices of the graph are the *nodes* and the edges that connect them are called *links*. +The vertices of the graph are the *nodes*, and the edges that connect them are called *links*. Since the graph is directed, any node can have *incoming* and *outgoing* links that connect it to neighboring nodes. To discover the neighbors of a given node, you can use the methods :meth:`~aiida.orm.nodes.node.Node.get_incoming` and :meth:`~aiida.orm.nodes.node.Node.get_outgoing`. -They have the exact same interface but will return the neighbors connected to the current node with link coming into it, or with links going out of it, respectively. +They have the exact same interface but will return the neighbors connected to the current node with a link coming into it or with links going out of it, respectively. For example, for a given ``node``, to inspect all the neighboring nodes from which a link is incoming to the ``node``: .. code-block:: python @@ -22,7 +22,7 @@ For example, for a given ``node``, to inspect all the neighboring nodes from whi node.get_incoming() This will return an instance of the :class:`~aiida.orm.utils.links.LinkManager`. -From that manager you can request the results in a specific format. +From that manager, you can request the results in a specific format. If you are only interested in the neighboring nodes themselves, you can call the :class:`~aiida.orm.utils.links.LinkManager.all_nodes` method: .. code-block:: python diff --git a/docs/source/howto/faq.rst b/docs/source/howto/faq.rst index 4c6ae14ad8..92337b5131 100644 --- a/docs/source/howto/faq.rst +++ b/docs/source/howto/faq.rst @@ -39,11 +39,11 @@ Simply reloading your shell will solve the problem. Why are calculation jobs taking very long to run on remote machines even though the actual computation time should be fast? =========================================================================================================================== -First make sure that the calculation is not actually waiting in the queue of the scheduler, but it is actually running or has already completed. +First, make sure that the calculation is not actually waiting in the queue of the scheduler, but it is actually running or has already completed. If it then still takes seemingly a lot of time for AiiDA to update your calculations, there are a couple of explanations. First, if you are running many processes, your daemon workers may simply be busy managing other calculations and workflows. -If that is not the case, you may be witnessing the effects of the built in throttling mechanisms of AiiDA's engine. -To ensure that the AiiDA daemon does not overload remote computers or their schedulers, there are built in limits to how often the daemon workers are allowed to open an SSH connection, or poll the scheduler. +If that is not the case, you may be witnessing the effects of the built-in throttling mechanisms of AiiDA's engine. +To ensure that the AiiDA daemon does not overload remote computers or their schedulers, there are built-in limits to how often the daemon workers are allowed to open an SSH connection, or poll the scheduler. To determine the minimum transport and job polling interval, use ``verdi computer configure show `` and ``computer.get_minimum_job_poll_interval()``, respectively. You can lower these values using: diff --git a/docs/source/howto/installation.rst b/docs/source/howto/installation.rst index f741ff6f16..e00e297dc6 100644 --- a/docs/source/howto/installation.rst +++ b/docs/source/howto/installation.rst @@ -166,7 +166,7 @@ By default, each AiiDA instance (each installation) will store associated profil A best practice is to always separate the profiles together with the code to which they belong. The typical approach is to place the configuration folder in the virtual environment itself and have it automatically selected whenever the environment is activated. -The location of the AiiDA configuration folder, can be controlled with the ``AIIDA_PATH`` environment variable. +The location of the AiiDA configuration folder can be controlled with the ``AIIDA_PATH`` environment variable. This allows us to change the configuration folder automatically, by adding the following lines to the activation script of a virtual environment. For example, if the path of your virtual environment is ``/home/user/.virtualenvs/aiida``, add the following line: diff --git a/docs/source/howto/run_codes.rst b/docs/source/howto/run_codes.rst index 7610543fc1..8468fe62f8 100644 --- a/docs/source/howto/run_codes.rst +++ b/docs/source/howto/run_codes.rst @@ -4,14 +4,14 @@ How to run external codes ************************* -This how-to walks you through the steps of setting up a (possibly remote) compute resource, setting up a code on that computer and submitting a calculation through AiiDA (similar to the :ref:`introductory tutorial `, but in more detail). +This how-to walks you through the steps of setting up a (possibly remote) compute resource, setting up a code on that computer, and submitting a calculation through AiiDA (similar to the :ref:`introductory tutorial `, but in more detail). To run an external code with AiiDA, you need an appropriate :ref:`calculation plugin `. In the following, we assume that a plugin for your code is already available from the `aiida plugin registry `_ and installed on your machine. Refer to the :ref:`how-to:plugins-install` section for details on how to install an existing plugin. If a plugin for your code is not yet available, see :ref:`how-to:plugin-codes`. -Throughout the process you will be prompted for information on the computer and code. +Throughout the process, you will be prompted for information on the computer and code. In these prompts: * Type ``?`` followed by ```` to get help on what is being asked at any prompt. @@ -150,7 +150,7 @@ This command will perform various tests to make sure that AiiDA can connect to t Mitigating connection overloads ---------------------------------- -Some compute resources, particularly large supercomputing centres, may not tolerate submitting too many jobs at once, executing scheduler commands too frequently or opening too many SSH connections. +Some compute resources, particularly large supercomputing centers, may not tolerate submitting too many jobs at once, executing scheduler commands too frequently, or opening too many SSH connections. * Limit the number of jobs in the queue. @@ -257,7 +257,7 @@ At the end of these steps, you will be prompted to edit a script, where you can * *before* running the submission script (after the 'Pre execution script' lines), and * *after* running the submission script (after the 'Post execution script' separator). -Use this for instance to load modules or set variables that are needed by the code, such as: +Use this, for instance, to load modules or set variables that are needed by the code, such as: .. code-block:: bash diff --git a/docs/source/howto/workflows.rst b/docs/source/howto/workflows.rst index c9ccb0bd17..3c5f992c75 100644 --- a/docs/source/howto/workflows.rst +++ b/docs/source/howto/workflows.rst @@ -242,9 +242,9 @@ So, it is advisable to *submit* more complex or longer work chains to the daemon workchain_node = submit(MultiplyAddWorkChain, **inputs) -Note that when using ``submit`` the work chain is not run in the local interpreter but is sent off to the daemon and you get back control instantly. +Note that when using ``submit`` the work chain is not run in the local interpreter but is sent off to the daemon, and you get back control instantly. This allows you to submit multiple work chains at the same time and the daemon will start working on them in parallel. -Once the ``submit`` call returns, you will not get the result as with ``run``, but you will get the **node** that represents the work chain. +Once the ``submit`` call returns, you will not get the result as with ``run``, but you will get the **node** representing the work chain. Submitting a work chain instead of directly running it not only makes it easier to execute multiple work chains in parallel, but also ensures that the progress of a workchain is not lost when you restart your computer. .. important:: diff --git a/docs/source/intro/installation.rst b/docs/source/intro/installation.rst index 04e7c4c4f2..0f3e891d08 100644 --- a/docs/source/intro/installation.rst +++ b/docs/source/intro/installation.rst @@ -6,7 +6,7 @@ Advanced configuration ********************** This chapter covers topics that go beyond the :ref:`standard setup of AiiDA `. -If you are new to AiiDA, we recommed you first go through the :ref:`Basic Tutorial `, +If you are new to AiiDA, we recommend you first go through the :ref:`Basic Tutorial `, or see our :ref:`Next steps guide `. .. _intro:install:database: diff --git a/docs/source/intro/troubleshooting.rst b/docs/source/intro/troubleshooting.rst index c6a23d5960..f01519e161 100644 --- a/docs/source/intro/troubleshooting.rst +++ b/docs/source/intro/troubleshooting.rst @@ -326,9 +326,9 @@ Use in ipython/jupyter ---------------------- In order to use the AiiDA objects and functions in Jupyter, this latter has to be instructed to use the iPython kernel installed in the AiiDA virtual environment. -This happens by default if you install AiiDA with ``pip`` including the ``notebook`` option and run Jupyter from the AiiDA virtual environment. +This happens by default if you install AiiDA with ``pip`` including the ``notebook`` option, and run Jupyter from the AiiDA virtual environment. -If, for any reason, you do not want to install Jupyter in the virtual environment, you might consider to install it out of the virtual environment, if not already done: +If for any reason, you do not want to install Jupyter in the virtual environment, you might consider to install it out of the virtual environment, if not already done: .. code-block:: console diff --git a/docs/source/intro/tutorial.rst b/docs/source/intro/tutorial.rst index d1b7a3fd66..8fffa94945 100644 --- a/docs/source/intro/tutorial.rst +++ b/docs/source/intro/tutorial.rst @@ -13,7 +13,7 @@ Basic tutorial Welcome to the AiiDA tutorial! The goal of this tutorial is to give you a basic idea of how AiiDA helps you in executing data-driven workflows. -At the end of this tutorial you will know how to: +At the end of this tutorial, you will know how to: * Store data in the database and subsequently retrieve it. * Decorate a Python function such that its inputs and outputs are automatically tracked. @@ -22,7 +22,7 @@ At the end of this tutorial you will know how to: .. important:: - If you are working on your own machine, note that the tutorial assumes that you have a working AiiDA installation, and have set up your AiiDA profile in the current Python environment. + If you are working on your own machine, note that the tutorial assumes that you have a working AiiDA installation and have set up your AiiDA profile in the current Python environment. If this is not the case, consult the :ref:`getting started page`. Provenance @@ -44,7 +44,7 @@ In the provenance graph, you can see different types of *nodes* represented by d The green ellipses are ``Data`` nodes, the blue ellipse is a ``Code`` node, and the rectangles represent *processes*, i.e. the calculations performed in your *workflow*. The provenance graph allows us to not only see what data we have, but also how it was produced. -During this tutorial we will be using AiiDA to generate the provenance graph in :numref:`fig_intro_workchain_graph` step by step. +During this tutorial, we will be using AiiDA to generate the provenance graph in :numref:`fig_intro_workchain_graph` step by step. Data nodes ========== @@ -94,7 +94,7 @@ Use the PK only if you are working within a single database, i.e. in an interact The PK numbers shown throughout this tutorial assume that you start from a completely empty database. It is possible that the nodes' PKs will be different for your database! - The UUIDs are generated randomly and are therefore **guaranteed** to be different. + The UUIDs are generated randomly and are, therefore, **guaranteed** to be different. Next, let's leave the IPython shell by typing ``exit()`` and then enter. Back in the terminal, use the ``verdi`` command line interface (CLI) to check the data node we have just created: From 8e80ac14d9e5372393528d9ba4107a7656d44923 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Wed, 13 Jan 2021 15:48:24 +0100 Subject: [PATCH 037/114] Docs: clarify docstrings of `get_last_job_info` and `get_detailed_job_info` (#4657) `CalcJobNode`s contain two differente job infos, the `detailed_job_info` and the `last_job_info`. The distinction between the two was not obvious, and not documented. The docstrings are improved to clarify the difference. --- aiida/orm/nodes/process/calculation/calcjob.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/aiida/orm/nodes/process/calculation/calcjob.py b/aiida/orm/nodes/process/calculation/calcjob.py index 311ccf8a6b..3e5776d4d2 100644 --- a/aiida/orm/nodes/process/calculation/calcjob.py +++ b/aiida/orm/nodes/process/calculation/calcjob.py @@ -427,6 +427,8 @@ def set_detailed_job_info(self, detailed_job_info): def get_detailed_job_info(self): """Return the detailed job info dictionary. + The scheduler is polled for the detailed job info after the job is completed and ready to be retrieved. + :return: the dictionary with detailed job info if defined or None """ return self.get_attribute(self.SCHEDULER_DETAILED_JOB_INFO_KEY, None) @@ -441,6 +443,12 @@ def set_last_job_info(self, last_job_info): def get_last_job_info(self): """Return the last information asked to the scheduler about the status of the job. + The last job info is updated on every poll of the scheduler, except for the final poll when the job drops from + the scheduler's job queue. + For completed jobs, the last job info therefore contains the "second-to-last" job info that still shows the job + as running. Please use :meth:`~aiida.orm.nodes.process.calculation.calcjob.CalcJobNode.get_detailed_job_info` + instead. + :return: a `JobInfo` object (that closely resembles a dictionary) or None. """ from aiida.schedulers.datastructures import JobInfo From 61e48d7a3dac0bc9956f8ac34b8e3cc19db1fc3e Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Tue, 19 Jan 2021 21:12:23 +0100 Subject: [PATCH 038/114] docs: simplify proxycommand (#4662) The 'netcat mode' `-W` was added in OpenSSH 5.4, released March 2010. Given that this simplifies the setup and and delegates handling of netcat to ssh, this is what we should recommend. For example, MacOS ships with OpenSSH 5.6 since MacOS 10.7, released October 2010. --- docs/source/howto/ssh.rst | 38 ++------------------------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/docs/source/howto/ssh.rst b/docs/source/howto/ssh.rst index ad590e53c1..94e3b2e87c 100644 --- a/docs/source/howto/ssh.rst +++ b/docs/source/howto/ssh.rst @@ -191,26 +191,6 @@ This section explains how to use the ``proxy_command`` feature of ``ssh`` in ord This method can also be used to automatically tunnel into virtual private networks, if you have an account on a proxy/jumphost server with access to the network. -Requirements -^^^^^^^^^^^^ - -The ``netcat`` tool needs to be present on the *PROXY* server (executable may be named ``netcat`` or ``nc``). -``netcat`` simply takes the standard input and redirects it to a given TCP port. - -.. dropdown:: Installing netcat - - If neither ``netcat`` or ``nc`` are available, you will need to install it on your own. - You can download a `netcat distribution `_, unzip the downloaded package, ``cd`` into the folder and execute something like: - - .. code-block:: console - - $ ./configure --prefix=. - $ make - $ make install - - This usually creates a subfolder ``bin``, containing the ``netcat`` and ``nc`` executables. - Write down the full path to ``nc`` which we will need later. - SSH configuration @@ -222,14 +202,9 @@ Edit the ``~/.ssh/config`` file on the computer on which you installed AiiDA (or Hostname FULLHOSTNAME_TARGET User USER_TARGET IdentityFile ~/.ssh/aiida - ProxyCommand ssh USER_PROXY@FULLHOSTNAME_PROXY ABSPATH_NETCAT %h %p - -replacing the ``..._TARGET`` and ``..._PROXY`` variables with the host/user names of the respective servers, and replacing ``ABSPATH_NETCAT`` with the result of ``which netcat`` (or ``which nc``). - -.. note:: - - If desired/necessary for your netcat implementation, hide warnings and errors that may occur during the proxying/tunneling by redirecting stdout and stderr, e.g. by appending ``2> /dev/null`` to the ``ProxyCommand``. + ProxyCommand ssh -W %h:%p USER_PROXY@FULLHOSTNAME_PROXY +replacing the ``..._TARGET`` and ``..._PROXY`` variables with the host/user names of the respective servers. This should allow you to directly connect to the *TARGET* server using @@ -240,15 +215,6 @@ This should allow you to directly connect to the *TARGET* server using For a *passwordless* connection, you need to follow the instructions :ref:`how-to:ssh:passwordless` *twice*: once for the connection from your computer to the *PROXY* server, and once for the connection from the *PROXY* server to the *TARGET* server. -.. warning:: - - There are occasionally ``netcat`` implementations, which keep running after you close your SSH connection, resulting in a growing number of open SSH connections between the *PROXY* server and the *TARGET* server. - If you suspect an issue, it may be worth connecting to the *PROXY* server and checking how many ``netcat`` processes are running, e.g. via: - - .. code-block:: console - - $ ps -aux | grep netcat - AiiDA configuration ^^^^^^^^^^^^^^^^^^^ From 0e1f39f6e8d39e56d1f31ffe7bb6f5b2cf232926 Mon Sep 17 00:00:00 2001 From: Marnik Bercx Date: Mon, 25 Jan 2021 15:17:50 +0100 Subject: [PATCH 039/114] Docs: Add redirect for database backup page (#4675) --- docs/source/redirects.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/redirects.txt b/docs/source/redirects.txt index 1068be6541..94f833917f 100644 --- a/docs/source/redirects.txt +++ b/docs/source/redirects.txt @@ -21,6 +21,7 @@ datatypes/index.rst topics/data_types.rst datatypes/functionality.rst topics/data_types.rst datatypes/kpoints.rst topics/data_types.rst datatypes/bands.rst topics/data_types.rst +backup/index.rst howto/installation.rst # fix https://www.materialscloud.org/dmp apidoc/aiida.orm.rst reference/apidoc/aiida.orm.rst From 2ae0f420e7466b68c61bf73f5b5e7008de07968e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 25 Jan 2021 21:32:29 +0000 Subject: [PATCH 040/114] Type checking: `aiida/engine` (+bug fixes) (#4669) Added type checking for the modules * `aiida.engine` * `aiida.manage.manager` Move `aiida.orm` imports to top of file in `aiida.engine` module. This should be fine as `aiida.orm` should not import anything from `aiida.engine` and this way we don't need import guards specifically for type checking. --- .pre-commit-config.yaml | 3 +- aiida/engine/__init__.py | 2 +- aiida/engine/daemon/client.py | 76 ++-- aiida/engine/daemon/execmanager.py | 81 ++-- aiida/engine/daemon/runner.py | 9 +- aiida/engine/launch.py | 63 ++- aiida/engine/persistence.py | 50 ++- aiida/engine/processes/__init__.py | 4 +- aiida/engine/processes/builder.py | 26 +- aiida/engine/processes/calcjobs/__init__.py | 2 +- aiida/engine/processes/calcjobs/calcjob.py | 146 ++++--- aiida/engine/processes/calcjobs/manager.py | 66 +-- aiida/engine/processes/calcjobs/tasks.py | 134 ++++-- aiida/engine/processes/exit_code.py | 8 +- aiida/engine/processes/functions.py | 114 +++-- aiida/engine/processes/futures.py | 20 +- aiida/engine/processes/ports.py | 70 ++-- aiida/engine/processes/process.py | 394 ++++++++++-------- aiida/engine/processes/process_spec.py | 46 +- aiida/engine/processes/workchains/__init__.py | 2 +- .../engine/processes/workchains/awaitable.py | 3 +- aiida/engine/processes/workchains/context.py | 13 +- aiida/engine/processes/workchains/restart.py | 87 ++-- aiida/engine/processes/workchains/utils.py | 23 +- .../engine/processes/workchains/workchain.py | 79 ++-- aiida/engine/runners.py | 160 +++---- aiida/engine/transports.py | 16 +- aiida/engine/utils.py | 47 ++- aiida/manage/manager.py | 171 +++++--- aiida/sphinxext/process.py | 9 +- docs/source/nitpick-exceptions | 35 +- environment.yml | 2 +- mypy.ini | 12 +- pyproject.toml | 16 +- requirements/requirements-py-3.6.txt | 2 +- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- requirements/requirements-py-3.9.txt | 2 +- setup.json | 2 +- .../sphinxext/reference_results/workchain.xml | 22 +- 40 files changed, 1166 insertions(+), 855 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78b1615e1c..2e8baeb6d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,8 @@ repos: files: >- (?x)^( aiida/common/progress_reporter.py| - aiida/engine/processes/calcjobs/calcjob.py| + aiida/manage/manager.py| + aiida/engine/.*py| aiida/manage/database/delete/nodes.py| aiida/tools/graph/graph_traversers.py| aiida/tools/groups/paths.py| diff --git a/aiida/engine/__init__.py b/aiida/engine/__init__.py index 41e147e19e..984ff61866 100644 --- a/aiida/engine/__init__.py +++ b/aiida/engine/__init__.py @@ -14,4 +14,4 @@ from .processes import * from .utils import * -__all__ = (launch.__all__ + processes.__all__ + utils.__all__) +__all__ = (launch.__all__ + processes.__all__ + utils.__all__) # type: ignore[name-defined] diff --git a/aiida/engine/daemon/client.py b/aiida/engine/daemon/client.py index 32d96466bf..1215bb5056 100644 --- a/aiida/engine/daemon/client.py +++ b/aiida/engine/daemon/client.py @@ -16,13 +16,21 @@ import shutil import socket import tempfile +from typing import Any, Dict, Optional, TYPE_CHECKING from aiida.manage.configuration import get_config, get_config_option +from aiida.manage.configuration.profile import Profile + +if TYPE_CHECKING: + from circus.client import CircusClient VERDI_BIN = shutil.which('verdi') # Recent versions of virtualenv create the environment variable VIRTUAL_ENV VIRTUALENV = os.environ.get('VIRTUAL_ENV', None) +# see https://github.com/python/typing/issues/182 +JsonDictType = Dict[str, Any] + class ControllerProtocol(enum.Enum): """ @@ -33,13 +41,13 @@ class ControllerProtocol(enum.Enum): TCP = 1 -def get_daemon_client(profile_name=None): +def get_daemon_client(profile_name: Optional[str] = None) -> 'DaemonClient': """ Return the daemon client for the given profile or the current profile if not specified. :param profile_name: the profile name, will use the current profile if None :return: the daemon client - :rtype: :class:`aiida.engine.daemon.client.DaemonClient` + :raises aiida.common.MissingConfigurationError: if the configuration file cannot be found :raises aiida.common.ProfileConfigurationError: if the given profile does not exist """ @@ -65,7 +73,7 @@ class DaemonClient: # pylint: disable=too-many-public-methods _DAEMON_NAME = 'aiida-{name}' _ENDPOINT_PROTOCOL = ControllerProtocol.IPC - def __init__(self, profile): + def __init__(self, profile: Profile): """ Construct a DaemonClient instance for a given profile @@ -73,22 +81,22 @@ def __init__(self, profile): """ config = get_config() self._profile = profile - self._SOCKET_DIRECTORY = None # pylint: disable=invalid-name - self._DAEMON_TIMEOUT = config.get_option('daemon.timeout') # pylint: disable=invalid-name + self._SOCKET_DIRECTORY: Optional[str] = None # pylint: disable=invalid-name + self._DAEMON_TIMEOUT: int = config.get_option('daemon.timeout') # pylint: disable=invalid-name @property - def profile(self): + def profile(self) -> Profile: return self._profile @property - def daemon_name(self): + def daemon_name(self) -> str: """ Get the daemon name which is tied to the profile name """ return self._DAEMON_NAME.format(name=self.profile.name) @property - def cmd_string(self): + def cmd_string(self) -> str: """ Return the command string to start the AiiDA daemon """ @@ -101,42 +109,42 @@ def cmd_string(self): return f'{VERDI_BIN} -p {self.profile.name} devel run_daemon' @property - def loglevel(self): + def loglevel(self) -> str: return get_config_option('logging.circus_loglevel') @property - def virtualenv(self): + def virtualenv(self) -> Optional[str]: return VIRTUALENV @property - def circus_log_file(self): + def circus_log_file(self) -> str: return self.profile.filepaths['circus']['log'] @property - def circus_pid_file(self): + def circus_pid_file(self) -> str: return self.profile.filepaths['circus']['pid'] @property - def circus_port_file(self): + def circus_port_file(self) -> str: return self.profile.filepaths['circus']['port'] @property - def circus_socket_file(self): + def circus_socket_file(self) -> str: return self.profile.filepaths['circus']['socket']['file'] @property - def circus_socket_endpoints(self): + def circus_socket_endpoints(self) -> Dict[str, str]: return self.profile.filepaths['circus']['socket'] @property - def daemon_log_file(self): + def daemon_log_file(self) -> str: return self.profile.filepaths['daemon']['log'] @property - def daemon_pid_file(self): + def daemon_pid_file(self) -> str: return self.profile.filepaths['daemon']['pid'] - def get_circus_port(self): + def get_circus_port(self) -> int: """ Retrieve the port for the circus controller, which should be written to the circus port file. If the daemon is running, the port file should exist and contain the port to which the controller is connected. @@ -158,7 +166,7 @@ def get_circus_port(self): return port - def get_circus_socket_directory(self): + def get_circus_socket_directory(self) -> str: """ Retrieve the absolute path of the directory where the circus sockets are stored if the IPC protocol is used and the daemon is running. If the daemon is running, the sockets file should exist and contain the @@ -192,7 +200,7 @@ def get_circus_socket_directory(self): self._SOCKET_DIRECTORY = socket_dir_path return socket_dir_path - def get_daemon_pid(self): + def get_daemon_pid(self) -> Optional[int]: """ Get the daemon pid which should be written in the daemon pid file specific to the profile @@ -207,7 +215,7 @@ def get_daemon_pid(self): return None @property - def is_daemon_running(self): + def is_daemon_running(self) -> bool: """ Return whether the daemon is running, which is determined by seeing if the daemon pid file is present @@ -215,7 +223,7 @@ def is_daemon_running(self): """ return self.get_daemon_pid() is not None - def delete_circus_socket_directory(self): + def delete_circus_socket_directory(self) -> None: """ Attempt to delete the directory used to store the circus endpoint sockets. Will not raise if the directory does not exist @@ -321,7 +329,7 @@ def get_tcp_endpoint(self, port=None): return endpoint @property - def client(self): + def client(self) -> 'CircusClient': """ Return an instance of the CircusClient with the endpoint defined by the controller endpoint, which used the port that was written to the port file upon starting of the daemon @@ -334,7 +342,7 @@ def client(self): from circus.client import CircusClient return CircusClient(endpoint=self.get_controller_endpoint(), timeout=self._DAEMON_TIMEOUT) - def call_client(self, command): + def call_client(self, command: JsonDictType) -> JsonDictType: """ Call the client with a specific command. Will check whether the daemon is running first by checking for the pid file. When the pid is found yet the call still fails with a @@ -358,47 +366,51 @@ def call_client(self, command): return result - def get_status(self): + def get_status(self) -> JsonDictType: """ Get the daemon running status :return: the client call response + If successful, will will contain 'status' key """ command = {'command': 'status', 'properties': {'name': self.daemon_name}} return self.call_client(command) - def get_numprocesses(self): + def get_numprocesses(self) -> JsonDictType: """ Get the number of running daemon processes :return: the client call response + If successful, will contain 'numprocesses' key """ command = {'command': 'numprocesses', 'properties': {'name': self.daemon_name}} return self.call_client(command) - def get_worker_info(self): + def get_worker_info(self) -> JsonDictType: """ Get workers statistics for this daemon :return: the client call response + If successful, will contain 'info' key """ command = {'command': 'stats', 'properties': {'name': self.daemon_name}} return self.call_client(command) - def get_daemon_info(self): + def get_daemon_info(self) -> JsonDictType: """ Get statistics about this daemon itself :return: the client call response + If successful, will contain 'info' key """ command = {'command': 'dstats', 'properties': {}} return self.call_client(command) - def increase_workers(self, number): + def increase_workers(self, number: int) -> JsonDictType: """ Increase the number of workers @@ -409,7 +421,7 @@ def increase_workers(self, number): return self.call_client(command) - def decrease_workers(self, number): + def decrease_workers(self, number: int) -> JsonDictType: """ Decrease the number of workers @@ -420,7 +432,7 @@ def decrease_workers(self, number): return self.call_client(command) - def stop_daemon(self, wait): + def stop_daemon(self, wait: bool) -> JsonDictType: """ Stop the daemon @@ -436,7 +448,7 @@ def stop_daemon(self, wait): return result - def restart_daemon(self, wait): + def restart_daemon(self, wait: bool) -> JsonDictType: """ Restart the daemon diff --git a/aiida/engine/daemon/execmanager.py b/aiida/engine/daemon/execmanager.py index 5f8a136589..6d1eecf5ca 100644 --- a/aiida/engine/daemon/execmanager.py +++ b/aiida/engine/daemon/execmanager.py @@ -13,23 +13,56 @@ the routines make reference to the suitable plugins for all plugin-specific operations. """ +from collections.abc import Mapping +from logging import LoggerAdapter import os import shutil +from tempfile import NamedTemporaryFile +from typing import Any, List, Optional, Mapping as MappingType, Tuple, Union from aiida.common import AIIDA_LOGGER, exceptions +from aiida.common.datastructures import CalcInfo from aiida.common.folders import SandboxFolder from aiida.common.links import LinkType -from aiida.orm import FolderData, Node +from aiida.orm import load_node, CalcJobNode, Code, FolderData, Node, RemoteData from aiida.orm.utils.log import get_dblogger_extra from aiida.plugins import DataFactory from aiida.schedulers.datastructures import JobState +from aiida.transports import Transport REMOTE_WORK_DIRECTORY_LOST_FOUND = 'lost+found' execlogger = AIIDA_LOGGER.getChild('execmanager') -def upload_calculation(node, transport, calc_info, folder, inputs=None, dry_run=False): +def _find_data_node(inputs: MappingType[str, Any], uuid: str) -> Optional[Node]: + """Find and return the node with the given UUID from a nested mapping of input nodes. + + :param inputs: (nested) mapping of nodes + :param uuid: UUID of the node to find + :return: instance of `Node` or `None` if not found + """ + data_node = None + + for input_node in inputs.values(): + if isinstance(input_node, Mapping): + data_node = _find_data_node(input_node, uuid) + elif isinstance(input_node, Node) and input_node.uuid == uuid: + data_node = input_node + if data_node is not None: + break + + return data_node + + +def upload_calculation( + node: CalcJobNode, + transport: Transport, + calc_info: CalcInfo, + folder: SandboxFolder, + inputs: Optional[MappingType[str, Any]] = None, + dry_run: bool = False +) -> None: """Upload a `CalcJob` instance :param node: the `CalcJobNode`. @@ -38,9 +71,6 @@ def upload_calculation(node, transport, calc_info, folder, inputs=None, dry_run= :param folder: temporary local file system folder containing the inputs written by `CalcJob.prepare_for_submission` """ # pylint: disable=too-many-locals,too-many-branches,too-many-statements - from logging import LoggerAdapter - from tempfile import NamedTemporaryFile - from aiida.orm import load_node, Code, RemoteData # If the calculation already has a `remote_folder`, simply return. The upload was apparently already completed # before, which can happen if the daemon is restarted and it shuts down after uploading but before getting the @@ -162,30 +192,10 @@ def upload_calculation(node, transport, calc_info, folder, inputs=None, dry_run= for uuid, filename, target in local_copy_list: logger.debug(f'[submission of calculation {node.uuid}] copying local file/folder to {target}') - def find_data_node(inputs, uuid): - """Find and return the node with the given UUID from a nested mapping of input nodes. - - :param inputs: (nested) mapping of nodes - :param uuid: UUID of the node to find - :return: instance of `Node` or `None` if not found - """ - from collections.abc import Mapping - data_node = None - - for input_node in inputs.values(): - if isinstance(input_node, Mapping): - data_node = find_data_node(input_node, uuid) - elif isinstance(input_node, Node) and input_node.uuid == uuid: - data_node = input_node - if data_node is not None: - break - - return data_node - try: data_node = load_node(uuid=uuid) except exceptions.NotExistent: - data_node = find_data_node(inputs, uuid) + data_node = _find_data_node(inputs, uuid) if inputs else None if data_node is None: logger.warning(f'failed to load Node<{uuid}> specified in the `local_copy_list`') @@ -294,7 +304,7 @@ def find_data_node(inputs, uuid): remotedata.store() -def submit_calculation(calculation, transport): +def submit_calculation(calculation: CalcJobNode, transport: Transport) -> str: """Submit a previously uploaded `CalcJob` to the scheduler. :param calculation: the instance of CalcJobNode to submit. @@ -322,7 +332,7 @@ def submit_calculation(calculation, transport): return job_id -def retrieve_calculation(calculation, transport, retrieved_temporary_folder): +def retrieve_calculation(calculation: CalcJobNode, transport: Transport, retrieved_temporary_folder: str) -> None: """Retrieve all the files of a completed job calculation using the given transport. If the job defined anything in the `retrieve_temporary_list`, those entries will be stored in the @@ -394,7 +404,7 @@ def retrieve_calculation(calculation, transport, retrieved_temporary_folder): retrieved_files.add_incoming(calculation, link_type=LinkType.CREATE, link_label=calculation.link_label_retrieved) -def kill_calculation(calculation, transport): +def kill_calculation(calculation: CalcJobNode, transport: Transport) -> bool: """ Kill the calculation through the scheduler @@ -425,7 +435,13 @@ def kill_calculation(calculation, transport): return True -def _retrieve_singlefiles(job, transport, folder, retrieve_file_list, logger_extra=None): +def _retrieve_singlefiles( + job: CalcJobNode, + transport: Transport, + folder: SandboxFolder, + retrieve_file_list: List[Tuple[str, str, str]], + logger_extra: Optional[dict] = None +): """Retrieve files specified through the singlefile list mechanism.""" singlefile_list = [] for (linkname, subclassname, filename) in retrieve_file_list: @@ -454,7 +470,10 @@ def _retrieve_singlefiles(job, transport, folder, retrieve_file_list, logger_ext fil.store() -def retrieve_files_from_list(calculation, transport, folder, retrieve_list): +def retrieve_files_from_list( + calculation: CalcJobNode, transport: Transport, folder: str, retrieve_list: List[Union[str, Tuple[str, str, int], + list]] +) -> None: """ Retrieve all the files in the retrieve_list from the remote into the local folder instance through the transport. The entries in the retrieve_list diff --git a/aiida/engine/daemon/runner.py b/aiida/engine/daemon/runner.py index 7085c15167..1826da38c2 100644 --- a/aiida/engine/daemon/runner.py +++ b/aiida/engine/daemon/runner.py @@ -14,12 +14,13 @@ from aiida.common.log import configure_logging from aiida.engine.daemon.client import get_daemon_client +from aiida.engine.runners import Runner from aiida.manage.manager import get_manager LOGGER = logging.getLogger(__name__) -async def shutdown_runner(runner): +async def shutdown_runner(runner: Runner) -> None: """Cleanup tasks tied to the service's shutdown.""" LOGGER.info('Received signal to shut down the daemon runner') @@ -40,8 +41,10 @@ async def shutdown_runner(runner): await asyncio.gather(*tasks, return_exceptions=True) runner.close() + LOGGER.info('Daemon runner stopped') + -def start_daemon(): +def start_daemon() -> None: """Start a daemon runner for the currently configured profile.""" daemon_client = get_daemon_client() configure_logging(daemon=True, daemon_log_file=daemon_client.daemon_log_file) @@ -65,4 +68,4 @@ def start_daemon(): LOGGER.info('Received a SystemError: %s', exception) runner.close() - LOGGER.info('Daemon runner stopped') + LOGGER.info('Daemon runner started') diff --git a/aiida/engine/launch.py b/aiida/engine/launch.py index 8cdcafbb9f..6026ac4731 100644 --- a/aiida/engine/launch.py +++ b/aiida/engine/launch.py @@ -8,27 +8,30 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Top level functions that can be used to launch a Process.""" +from typing import Any, Dict, Tuple, Type, Union from aiida.common import InvalidOperation from aiida.manage import manager +from aiida.orm import ProcessNode from .processes.functions import FunctionProcess -from .processes.process import Process +from .processes.process import Process, ProcessBuilder from .utils import is_process_scoped, instantiate_process __all__ = ('run', 'run_get_pk', 'run_get_node', 'submit') +TYPE_RUN_PROCESS = Union[Process, Type[Process], ProcessBuilder] # pylint: disable=invalid-name +# run can also be process function, but it is not clear what type this should be +TYPE_SUBMIT_PROCESS = Union[Process, Type[Process], ProcessBuilder] # pylint: disable=invalid-name -def run(process, *args, **inputs): + +def run(process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Dict[str, Any]: """Run the process with the supplied inputs in a local runner that will block until the process is completed. :param process: the process class or process function to run - :type process: :class:`aiida.engine.Process` - :param inputs: the inputs to be passed to the process - :type inputs: dict :return: the outputs of the process - :rtype: dict + """ if isinstance(process, Process): runner = process.runner @@ -38,17 +41,13 @@ def run(process, *args, **inputs): return runner.run(process, *args, **inputs) -def run_get_node(process, *args, **inputs): +def run_get_node(process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Tuple[Dict[str, Any], ProcessNode]: """Run the process with the supplied inputs in a local runner that will block until the process is completed. - :param process: the process class or process function to run - :type process: :class:`aiida.engine.Process` - + :param process: the process class, instance, builder or function to run :param inputs: the inputs to be passed to the process - :type inputs: dict :return: tuple of the outputs of the process and the process node - :rtype: (dict, :class:`aiida.orm.ProcessNode`) """ if isinstance(process, Process): @@ -59,17 +58,14 @@ def run_get_node(process, *args, **inputs): return runner.run_get_node(process, *args, **inputs) -def run_get_pk(process, *args, **inputs): +def run_get_pk(process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Tuple[Dict[str, Any], int]: """Run the process with the supplied inputs in a local runner that will block until the process is completed. - :param process: the process class or process function to run - :type process: :class:`aiida.engine.Process` - + :param process: the process class, instance, builder or function to run :param inputs: the inputs to be passed to the process - :type inputs: dict :return: tuple of the outputs of the process and process node pk - :rtype: (dict, int) + """ if isinstance(process, Process): runner = process.runner @@ -79,7 +75,7 @@ def run_get_pk(process, *args, **inputs): return runner.run_get_pk(process, *args, **inputs) -def submit(process, **inputs): +def submit(process: TYPE_SUBMIT_PROCESS, **inputs: Any) -> ProcessNode: """Submit the process with the supplied inputs to the daemon immediately returning control to the interpreter. .. warning: this should not be used within another process. Instead, there one should use the `submit` method of @@ -87,14 +83,11 @@ def submit(process, **inputs): .. warning: submission of processes requires `store_provenance=True` - :param process: the process class to submit - :type process: :class:`aiida.engine.Process` - + :param process: the process class, instance or builder to submit :param inputs: the inputs to be passed to the process - :type inputs: dict :return: the calculation node of the process - :rtype: :class:`aiida.orm.ProcessNode` + """ # Submitting from within another process requires `self.submit` unless it is a work function, in which case the # current process in the scope should be an instance of `FunctionProcess` @@ -102,27 +95,29 @@ def submit(process, **inputs): raise InvalidOperation('Cannot use top-level `submit` from within another process, use `self.submit` instead') runner = manager.get_manager().get_runner() + assert runner.persister is not None, 'runner does not have a persister' + assert runner.controller is not None, 'runner does not have a persister' - process = instantiate_process(runner, process, **inputs) + process_inited = instantiate_process(runner, process, **inputs) # If a dry run is requested, simply forward to `run`, because it is not compatible with `submit`. We choose for this # instead of raising, because in this way the user does not have to change the launcher when testing. - if process.metadata.get('dry_run', False): - _, node = run_get_node(process) + if process_inited.metadata.get('dry_run', False): + _, node = run_get_node(process_inited) return node - if not process.metadata.store_provenance: + if not process_inited.metadata.store_provenance: raise InvalidOperation('cannot submit a process with `store_provenance=False`') - runner.persister.save_checkpoint(process) - process.close() + runner.persister.save_checkpoint(process_inited) + process_inited.close() # Do not wait for the future's result, because in the case of a single worker this would cock-block itself - runner.controller.continue_process(process.pid, nowait=False, no_reply=True) + runner.controller.continue_process(process_inited.pid, nowait=False, no_reply=True) - return process.node + return process_inited.node # Allow one to also use run.get_node and run.get_pk as a shortcut, without having to import the functions themselves -run.get_node = run_get_node -run.get_pk = run_get_pk +run.get_node = run_get_node # type: ignore[attr-defined] +run.get_pk = run_get_pk # type: ignore[attr-defined] diff --git a/aiida/engine/persistence.py b/aiida/engine/persistence.py index 5aedd9d386..2ccdac03c1 100644 --- a/aiida/engine/persistence.py +++ b/aiida/engine/persistence.py @@ -13,21 +13,27 @@ import importlib import logging import traceback +from typing import Any, Hashable, Optional, TYPE_CHECKING -import plumpy +import plumpy.persistence +import plumpy.loaders +from plumpy.exceptions import PersistenceError from aiida.orm.utils import serialize +if TYPE_CHECKING: + from aiida.engine.processes.process import Process + __all__ = ('AiiDAPersister', 'ObjectLoader', 'get_object_loader') LOGGER = logging.getLogger(__name__) OBJECT_LOADER = None -class ObjectLoader(plumpy.DefaultObjectLoader): +class ObjectLoader(plumpy.loaders.DefaultObjectLoader): """Custom object loader for `aiida-core`.""" - def load_object(self, identifier): + def load_object(self, identifier: str) -> Any: # pylint: disable=no-self-use """Attempt to load the object identified by the given `identifier`. .. note:: We override the `plumpy.DefaultObjectLoader` to be able to throw an `ImportError` instead of a @@ -37,11 +43,11 @@ def load_object(self, identifier): :return: loaded object :raises ImportError: if the object cannot be loaded """ - module, name = identifier.split(':') + module_name, name = identifier.split(':') try: - module = importlib.import_module(module) + module = importlib.import_module(module_name) except ImportError: - raise ImportError(f"module '{module}' from identifier '{identifier}' could not be loaded") + raise ImportError(f"module '{module_name}' from identifier '{identifier}' could not be loaded") try: return getattr(module, name) @@ -49,11 +55,11 @@ def load_object(self, identifier): raise ImportError(f"object '{name}' from identifier '{identifier}' could not be loaded") -def get_object_loader(): +def get_object_loader() -> ObjectLoader: """Return the global AiiDA object loader. :return: The global object loader - :rtype: :class:`plumpy.ObjectLoader` + """ global OBJECT_LOADER if OBJECT_LOADER is None: @@ -61,15 +67,15 @@ def get_object_loader(): return OBJECT_LOADER -class AiiDAPersister(plumpy.Persister): +class AiiDAPersister(plumpy.persistence.Persister): """Persister to take saved process instance states and persisting them to the database.""" - def save_checkpoint(self, process, tag=None): + def save_checkpoint(self, process: 'Process', tag: Optional[str] = None): # type: ignore[override] # pylint: disable=no-self-use """Persist a Process instance. :param process: :class:`aiida.engine.Process` :param tag: optional checkpoint identifier to allow distinguishing multiple checkpoints for the same process - :raises: :class:`plumpy.PersistenceError` Raised if there was a problem saving the checkpoint + :raises: :class:`PersistenceError` Raised if there was a problem saving the checkpoint """ LOGGER.debug('Persisting process<%d>', process.pid) @@ -77,26 +83,26 @@ def save_checkpoint(self, process, tag=None): raise NotImplementedError('Checkpoint tags not supported yet') try: - bundle = plumpy.Bundle(process, plumpy.LoadSaveContext(loader=get_object_loader())) + bundle = plumpy.persistence.Bundle(process, plumpy.persistence.LoadSaveContext(loader=get_object_loader())) except ImportError: # Couldn't create the bundle - raise plumpy.PersistenceError(f"Failed to create a bundle for '{process}': {traceback.format_exc()}") + raise PersistenceError(f"Failed to create a bundle for '{process}': {traceback.format_exc()}") try: process.node.set_checkpoint(serialize.serialize(bundle)) except Exception: - raise plumpy.PersistenceError(f"Failed to store a checkpoint for '{process}': {traceback.format_exc()}") + raise PersistenceError(f"Failed to store a checkpoint for '{process}': {traceback.format_exc()}") return bundle - def load_checkpoint(self, pid, tag=None): + def load_checkpoint(self, pid: Hashable, tag: Optional[str] = None) -> plumpy.persistence.Bundle: # pylint: disable=no-self-use """Load a process from a persisted checkpoint by its process id. :param pid: the process id of the :class:`plumpy.Process` :param tag: optional checkpoint identifier to allow retrieving a specific sub checkpoint :return: a bundle with the process state :rtype: :class:`plumpy.Bundle` - :raises: :class:`plumpy.PersistenceError` Raised if there was a problem loading the checkpoint + :raises: :class:`PersistenceError` Raised if there was a problem loading the checkpoint """ from aiida.common.exceptions import MultipleObjectsError, NotExistent from aiida.orm import load_node @@ -107,17 +113,17 @@ def load_checkpoint(self, pid, tag=None): try: calculation = load_node(pid) except (MultipleObjectsError, NotExistent): - raise plumpy.PersistenceError(f'Failed to load the node for process<{pid}>: {traceback.format_exc()}') + raise PersistenceError(f'Failed to load the node for process<{pid}>: {traceback.format_exc()}') checkpoint = calculation.checkpoint if checkpoint is None: - raise plumpy.PersistenceError(f'Calculation<{calculation.pk}> does not have a saved checkpoint') + raise PersistenceError(f'Calculation<{calculation.pk}> does not have a saved checkpoint') try: bundle = serialize.deserialize(checkpoint) except Exception: - raise plumpy.PersistenceError(f'Failed to load the checkpoint for process<{pid}>: {traceback.format_exc()}') + raise PersistenceError(f'Failed to load the checkpoint for process<{pid}>: {traceback.format_exc()}') return bundle @@ -127,14 +133,14 @@ def get_checkpoints(self): :return: list of PersistedCheckpoint tuples with element containing the process id and optional checkpoint tag. """ - def get_process_checkpoints(self, pid): + def get_process_checkpoints(self, pid: Hashable): """Return a list of all the current persisted process checkpoints for the specified process. :param pid: the process pid :return: list of PersistedCheckpoint tuples with element containing the process id and optional checkpoint tag. """ - def delete_checkpoint(self, pid, tag=None): + def delete_checkpoint(self, pid: Hashable, tag: Optional[str] = None) -> None: # pylint: disable=no-self-use,unused-argument """Delete a persisted process checkpoint, where no error will be raised if the checkpoint does not exist. :param pid: the process id of the :class:`plumpy.Process` @@ -145,7 +151,7 @@ def delete_checkpoint(self, pid, tag=None): calc = load_node(pid) calc.delete_checkpoint() - def delete_process_checkpoints(self, pid): + def delete_process_checkpoints(self, pid: Hashable): """Delete all persisted checkpoints related to the given process id. :param pid: the process id of the :class:`aiida.engine.processes.process.Process` diff --git a/aiida/engine/processes/__init__.py b/aiida/engine/processes/__init__.py index de5a86cf18..b3045dcfd4 100644 --- a/aiida/engine/processes/__init__.py +++ b/aiida/engine/processes/__init__.py @@ -19,6 +19,6 @@ from .workchains import * __all__ = ( - builder.__all__ + calcjobs.__all__ + exit_code.__all__ + functions.__all__ + ports.__all__ + process.__all__ + - process_spec.__all__ + workchains.__all__ + builder.__all__ + calcjobs.__all__ + exit_code.__all__ + functions.__all__ + # type: ignore[name-defined] + ports.__all__ + process.__all__ + process_spec.__all__ + workchains.__all__ # type: ignore[name-defined] ) diff --git a/aiida/engine/processes/builder.py b/aiida/engine/processes/builder.py index 9a620244b4..c7f6939918 100644 --- a/aiida/engine/processes/builder.py +++ b/aiida/engine/processes/builder.py @@ -9,10 +9,14 @@ ########################################################################### """Convenience classes to help building the input dictionaries for Processes.""" import collections +from typing import Any, Type, TYPE_CHECKING from aiida.orm import Node from aiida.engine.processes.ports import PortNamespace +if TYPE_CHECKING: + from aiida.engine.processes.process import Process + __all__ = ('ProcessBuilder', 'ProcessBuilderNamespace') @@ -22,7 +26,7 @@ class ProcessBuilderNamespace(collections.abc.MutableMapping): Dynamically generates the getters and setters for the input ports of a given PortNamespace """ - def __init__(self, port_namespace): + def __init__(self, port_namespace: PortNamespace) -> None: """Dynamically construct the get and set properties for the ports of the given port namespace. For each port in the given port namespace a get and set property will be constructed dynamically @@ -30,7 +34,7 @@ def __init__(self, port_namespace): by calling str() on the Port, which should return the description of the Port. :param port_namespace: the inputs PortNamespace for which to construct the builder - :type port_namespace: str + """ # pylint: disable=super-init-not-called self._port_namespace = port_namespace @@ -52,7 +56,7 @@ def fgetter(self, name=name): return self._data.get(name) elif port.has_default(): - def fgetter(self, name=name, default=port.default): # pylint: disable=cell-var-from-loop + def fgetter(self, name=name, default=port.default): # type: ignore # pylint: disable=cell-var-from-loop return self._data.get(name, default) else: @@ -67,16 +71,12 @@ def fsetter(self, value, name=name): getter.setter(fsetter) # pylint: disable=too-many-function-args setattr(self.__class__, name, getter) - def __setattr__(self, attr, value): + def __setattr__(self, attr: str, value: Any) -> None: """Assign the given value to the port with key `attr`. .. note:: Any attributes without a leading underscore being set correspond to inputs and should hence be validated with respect to the corresponding input port from the process spec - :param attr: attribute - :type attr: str - - :param value: value """ if attr.startswith('_'): object.__setattr__(self, attr, value) @@ -87,7 +87,7 @@ def __setattr__(self, attr, value): if not self._port_namespace.dynamic: raise AttributeError(f'Unknown builder parameter: {attr}') else: - value = port.serialize(value) + value = port.serialize(value) # type: ignore[union-attr] validation_error = port.validate(value) if validation_error: raise ValueError(f'invalid attribute value {validation_error.message}') @@ -126,10 +126,8 @@ def _update(self, *args, **kwds): principle the method functions just as `collections.abc.MutableMapping.update`. :param args: a single mapping that should be mapped on the namespace - :type args: list :param kwds: keyword value pairs that should be mapped onto the ports - :type kwds: dict """ if len(args) > 1: raise TypeError(f'update expected at most 1 arguments, got {int(len(args))}') @@ -147,7 +145,7 @@ def _update(self, *args, **kwds): else: self.__setattr__(key, value) - def _inputs(self, prune=False): + def _inputs(self, prune: bool = False) -> dict: """Return the entire mapping of inputs specified for this builder. :param prune: boolean, when True, will prune nested namespaces that contain no actual values whatsoever @@ -182,7 +180,7 @@ def _prune(self, value): class ProcessBuilder(ProcessBuilderNamespace): # pylint: disable=too-many-ancestors """A process builder that helps setting up the inputs for creating a new process.""" - def __init__(self, process_class): + def __init__(self, process_class: Type['Process']): """Construct a `ProcessBuilder` instance for the given `Process` class. :param process_class: the `Process` subclass @@ -192,6 +190,6 @@ def __init__(self, process_class): super().__init__(self._process_spec.inputs) @property - def process_class(self): + def process_class(self) -> Type['Process']: """Return the process class for which this builder is constructed.""" return self._process_class diff --git a/aiida/engine/processes/calcjobs/__init__.py b/aiida/engine/processes/calcjobs/__init__.py index dc7c275880..57d4777ae7 100644 --- a/aiida/engine/processes/calcjobs/__init__.py +++ b/aiida/engine/processes/calcjobs/__init__.py @@ -12,4 +12,4 @@ from .calcjob import * -__all__ = (calcjob.__all__) +__all__ = (calcjob.__all__) # type: ignore[name-defined] diff --git a/aiida/engine/processes/calcjobs/calcjob.py b/aiida/engine/processes/calcjobs/calcjob.py index b86ff8ee32..7fafe77a7c 100644 --- a/aiida/engine/processes/calcjobs/calcjob.py +++ b/aiida/engine/processes/calcjobs/calcjob.py @@ -9,8 +9,12 @@ ########################################################################### """Implementation of the CalcJob process.""" import io +import os +import shutil +from typing import Any, Dict, Hashable, Optional, Type, Union -import plumpy +import plumpy.ports +import plumpy.process_states from aiida import orm from aiida.common import exceptions, AttributeDict @@ -20,6 +24,7 @@ from aiida.common.links import LinkType from ..exit_code import ExitCode +from ..ports import PortNamespace from ..process import Process, ProcessState from ..process_spec import CalcJobProcessSpec from .tasks import Waiting, UPLOAD_COMMAND @@ -27,7 +32,7 @@ __all__ = ('CalcJob',) -def validate_calc_job(inputs, ctx): # pylint: disable=too-many-return-statements +def validate_calc_job(inputs: Any, ctx: PortNamespace) -> Optional[str]: # pylint: disable=too-many-return-statements """Validate the entire set of inputs passed to the `CalcJob` constructor. Reasons that will cause this validation to raise an `InputValidationError`: @@ -43,7 +48,7 @@ def validate_calc_job(inputs, ctx): # pylint: disable=too-many-return-statement ctx.get_port('metadata.computer') except ValueError: # If the namespace no longer contains the `code` or `metadata.computer` ports we skip validation - return + return None code = inputs.get('code', None) computer_from_code = code.computer @@ -69,11 +74,11 @@ def validate_calc_job(inputs, ctx): # pylint: disable=too-many-return-statement try: resources_port = ctx.get_port('metadata.options.resources') except ValueError: - return + return None # If the resources port exists but is not required, we don't need to validate it against the computer's scheduler if not resources_port.required: - return + return None computer = computer_from_code or computer_from_metadata scheduler = computer.get_scheduler() @@ -89,43 +94,47 @@ def validate_calc_job(inputs, ctx): # pylint: disable=too-many-return-statement except ValueError as exception: return f'input `metadata.options.resources` is not valid for the `{scheduler}` scheduler: {exception}' + return None -def validate_parser(parser_name, _): + +def validate_parser(parser_name: Any, _: Any) -> Optional[str]: """Validate the parser. :return: string with error message in case the inputs are invalid """ from aiida.plugins import ParserFactory - if parser_name is not plumpy.UNSPECIFIED: + if parser_name is not plumpy.ports.UNSPECIFIED: try: ParserFactory(parser_name) except exceptions.EntryPointError as exception: return f'invalid parser specified: {exception}' + return None + -def validate_additional_retrieve_list(additional_retrieve_list, _): +def validate_additional_retrieve_list(additional_retrieve_list: Any, _: Any) -> Optional[str]: """Validate the additional retrieve list. :return: string with error message in case the input is invalid. """ - import os - - if additional_retrieve_list is plumpy.UNSPECIFIED: - return + if additional_retrieve_list is plumpy.ports.UNSPECIFIED: + return None if any(not isinstance(value, str) or os.path.isabs(value) for value in additional_retrieve_list): return f'`additional_retrieve_list` should only contain relative filepaths but got: {additional_retrieve_list}' + return None + class CalcJob(Process): """Implementation of the CalcJob process.""" _node_class = orm.CalcJobNode _spec_class = CalcJobProcessSpec - link_label_retrieved = 'retrieved' + link_label_retrieved: str = 'retrieved' - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Construct a CalcJob instance. Construct the instance only if it is a sub class of `CalcJob`, otherwise raise `InvalidOperation`. @@ -138,14 +147,18 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @classmethod - def define(cls, spec: CalcJobProcessSpec): - # yapf: disable + def define(cls, spec: CalcJobProcessSpec) -> None: # type: ignore[override] """Define the process specification, including its inputs, outputs and known exit codes. + Ports are added to the `metadata` input namespace (inherited from the base Process), + and a `code` input Port, a `remote_folder` output Port and retrieved folder output Port + are added. + :param spec: the calculation job process spec to define. """ + # yapf: disable super().define(spec) - spec.inputs.validator = validate_calc_job + spec.inputs.validator = validate_calc_job # type: ignore[assignment] # takes only PortNamespace not Port spec.input('code', valid_type=orm.Code, help='The `Code` to use for this job.') spec.input('metadata.dry_run', valid_type=bool, default=False, help='When set to `True` will prepare the calculation job for submission but not actually launch it.') @@ -217,6 +230,7 @@ def define(cls, spec: CalcJobProcessSpec): message='The job ran out of memory.') spec.exit_code(120, 'ERROR_SCHEDULER_OUT_OF_WALLTIME', message='The job ran out of walltime.') + # yapf: enable @classproperty def spec_options(cls): # pylint: disable=no-self-argument @@ -228,11 +242,11 @@ def spec_options(cls): # pylint: disable=no-self-argument return cls.spec_metadata['options'] # pylint: disable=unsubscriptable-object @property - def options(self): + def options(self) -> AttributeDict: """Return the options of the metadata that were specified when this process instance was launched. :return: options dictionary - :rtype: dict + """ try: return self.metadata.options @@ -240,14 +254,18 @@ def options(self): return AttributeDict() @classmethod - def get_state_classes(cls): + def get_state_classes(cls) -> Dict[Hashable, Type[plumpy.process_states.State]]: + """A mapping of the State constants to the corresponding state class. + + Overrides the waiting state with the Calcjob specific version. + """ # Overwrite the waiting state states_map = super().get_state_classes() states_map[ProcessState.WAITING] = Waiting return states_map @override - def on_terminated(self): + def on_terminated(self) -> None: """Cleanup the node by deleting the calulation job state. .. note:: This has to be done before calling the super because that will seal the node after we cannot change it @@ -256,13 +274,17 @@ def on_terminated(self): super().on_terminated() @override - def run(self): + def run(self) -> Union[plumpy.process_states.Stop, int, plumpy.process_states.Wait]: """Run the calculation job. This means invoking the `presubmit` and storing the temporary folder in the node's repository. Then we move the process in the `Wait` state, waiting for the `UPLOAD` transport task to be started. + + :returns: the `Stop` command if a dry run, int if the process has an exit status, + `Wait` command if the calcjob is to be uploaded + """ - if self.inputs.metadata.dry_run: + if self.inputs.metadata.dry_run: # type: ignore[union-attr] from aiida.common.folders import SubmitTestFolder from aiida.engine.daemon.execmanager import upload_calculation from aiida.transports.plugins.local import LocalTransport @@ -276,7 +298,7 @@ def run(self): 'folder': folder.abspath, 'script_filename': self.node.get_option('submit_script_filename') } - return plumpy.Stop(None, True) + return plumpy.process_states.Stop(None, True) # The following conditional is required for the caching to properly work. Even if the source node has a process # state of `Finished` the cached process will still enter the running state. The process state will have then @@ -286,7 +308,7 @@ def run(self): return self.node.exit_status # Launch the upload operation - return plumpy.Wait(msg='Waiting to upload', data=UPLOAD_COMMAND) + return plumpy.process_states.Wait(msg='Waiting to upload', data=UPLOAD_COMMAND) def prepare_for_submission(self, folder: Folder) -> CalcInfo: """Prepare the calculation for submission. @@ -301,13 +323,14 @@ def prepare_for_submission(self, folder: Folder) -> CalcInfo: """ raise NotImplementedError - def parse(self, retrieved_temporary_folder=None): + def parse(self, retrieved_temporary_folder: Optional[str] = None) -> ExitCode: """Parse a retrieved job calculation. This is called once it's finished waiting for the calculation to be finished and the data has been retrieved. - """ - import shutil + :param retrieved_temporary_folder: The path to the temporary folder + + """ try: retrieved = self.node.outputs.retrieved except exceptions.NotExistent: @@ -337,6 +360,7 @@ def parse(self, retrieved_temporary_folder=None): self.logger.warning(msg) # The final exit code is that of the scheduler, unless the output parser returned one + exit_code: Optional[ExitCode] if exit_code_retrieved is not None: exit_code = exit_code_retrieved else: @@ -346,9 +370,9 @@ def parse(self, retrieved_temporary_folder=None): for entry in self.node.get_outgoing(): self.out(entry.link_label, entry.node) - return exit_code or ExitCode(0) + return exit_code or ExitCode(0) # type: ignore[call-arg] - def parse_scheduler_output(self, retrieved): + def parse_scheduler_output(self, retrieved: orm.Node) -> Optional[ExitCode]: """Parse the output of the scheduler if that functionality has been implemented for the plugin.""" scheduler = self.node.computer.get_scheduler() filename_stderr = self.node.get_option('scheduler_stderr') @@ -376,16 +400,16 @@ def parse_scheduler_output(self, retrieved): # Only attempt to call the scheduler parser if all three resources of information are available if any(entry is None for entry in [detailed_job_info, scheduler_stderr, scheduler_stdout]): - return + return None try: exit_code = scheduler.parse_output(detailed_job_info, scheduler_stdout, scheduler_stderr) except exceptions.FeatureNotAvailable: self.logger.info(f'`{scheduler.__class__.__name__}` does not implement scheduler output parsing') - return + return None except Exception as exception: # pylint: disable=broad-except self.logger.error(f'the `parse_output` method of the scheduler excepted: {exception}') - return + return None if exit_code is not None and not isinstance(exit_code, ExitCode): args = (scheduler.__class__.__name__, type(exit_code)) @@ -393,12 +417,12 @@ def parse_scheduler_output(self, retrieved): return exit_code - def parse_retrieved_output(self, retrieved_temporary_folder=None): + def parse_retrieved_output(self, retrieved_temporary_folder: Optional[str] = None) -> Optional[ExitCode]: """Parse the retrieved data by calling the parser plugin if it was defined in the inputs.""" parser_class = self.node.get_parser_class() if parser_class is None: - return + return None parser = parser_class(self.node) parse_kwargs = parser.get_outputs_for_parsing() @@ -422,18 +446,15 @@ def parse_retrieved_output(self, retrieved_temporary_folder=None): return exit_code - def presubmit(self, folder): + def presubmit(self, folder: Folder) -> CalcInfo: """Prepares the calculation folder with all inputs, ready to be copied to the cluster. :param folder: a SandboxFolder that can be used to write calculation input files and the scheduling script. - :type folder: :class:`aiida.common.folders.Folder` :return calcinfo: the CalcInfo object containing the information needed by the daemon to handle operations. - :rtype calcinfo: :class:`aiida.common.CalcInfo` + """ # pylint: disable=too-many-locals,too-many-statements,too-many-branches - import os - from aiida.common.exceptions import PluginInternalError, ValidationError, InvalidOperation, InputValidationError from aiida.common import json from aiida.common.utils import validate_list_of_string_tuples @@ -445,19 +466,23 @@ def presubmit(self, folder): computer = self.node.computer inputs = self.node.get_incoming(link_type=LinkType.INPUT_CALC) - if not self.inputs.metadata.dry_run and self.node.has_cached_links(): + if not self.inputs.metadata.dry_run and self.node.has_cached_links(): # type: ignore[union-attr] raise InvalidOperation('calculation node has unstored links in cache') codes = [_ for _ in inputs.all_nodes() if isinstance(_, Code)] for code in codes: if not code.can_run_on(computer): - raise InputValidationError('The selected code {} for calculation {} cannot run on computer {}'.format( - code.pk, self.node.pk, computer.label)) + raise InputValidationError( + 'The selected code {} for calculation {} cannot run on computer {}'.format( + code.pk, self.node.pk, computer.label + ) + ) if code.is_local() and code.get_local_executable() in folder.get_content_list(): - raise PluginInternalError('The plugin created a file {} that is also the executable name!'.format( - code.get_local_executable())) + raise PluginInternalError( + f'The plugin created a file {code.get_local_executable()} that is also the executable name!' + ) calc_info = self.prepare_for_submission(folder) calc_info.uuid = str(self.node.uuid) @@ -495,7 +520,8 @@ def presubmit(self, folder): if not issubclass(file_sub_class, orm.SinglefileData): raise PluginInternalError( '[presubmission of calc {}] retrieve_singlefile_list subclass problem: {} is ' - 'not subclass of SinglefileData'.format(self.node.pk, file_sub_class.__name__)) + 'not subclass of SinglefileData'.format(self.node.pk, file_sub_class.__name__) + ) if retrieve_singlefile_list: self.node.set_retrieve_singlefile_list(retrieve_singlefile_list) @@ -553,11 +579,13 @@ def presubmit(self, folder): this_withmpi = self.node.get_option('withmpi') if this_withmpi: - this_argv = (mpi_args + extra_mpirun_params + [this_code.get_execname()] + - (code_info.cmdline_params if code_info.cmdline_params is not None else [])) + this_argv = ( + mpi_args + extra_mpirun_params + [this_code.get_execname()] + + (code_info.cmdline_params if code_info.cmdline_params is not None else []) + ) else: - this_argv = [this_code.get_execname()] + (code_info.cmdline_params - if code_info.cmdline_params is not None else []) + this_argv = [this_code.get_execname() + ] + (code_info.cmdline_params if code_info.cmdline_params is not None else []) # overwrite the old cmdline_params and add codename and mpirun stuff code_info.cmdline_params = this_argv @@ -642,13 +670,17 @@ def presubmit(self, folder): try: Computer.objects.get(uuid=remote_computer_uuid) # pylint: disable=unused-variable except exceptions.NotExistent as exc: - raise PluginInternalError('[presubmission of calc {}] ' - 'The remote copy requires a computer with UUID={}' - 'but no such computer was found in the ' - 'database'.format(this_pk, remote_computer_uuid)) from exc + raise PluginInternalError( + '[presubmission of calc {}] ' + 'The remote copy requires a computer with UUID={}' + 'but no such computer was found in the ' + 'database'.format(this_pk, remote_computer_uuid) + ) from exc if os.path.isabs(dest_rel_path): - raise PluginInternalError('[presubmission of calc {}] ' - 'The destination path of the remote copy ' - 'is absolute! ({})'.format(this_pk, dest_rel_path)) + raise PluginInternalError( + '[presubmission of calc {}] ' + 'The destination path of the remote copy ' + 'is absolute! ({})'.format(this_pk, dest_rel_path) + ) return calc_info diff --git a/aiida/engine/processes/calcjobs/manager.py b/aiida/engine/processes/calcjobs/manager.py index 4191f2494f..46d3c057e6 100644 --- a/aiida/engine/processes/calcjobs/manager.py +++ b/aiida/engine/processes/calcjobs/manager.py @@ -8,12 +8,18 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module containing utilities and classes relating to job calculations running on systems that require transport.""" +import asyncio import contextlib import logging import time -import asyncio +from typing import Any, Dict, Hashable, Iterator, List, Optional, TYPE_CHECKING from aiida.common import lang +from aiida.orm import AuthInfo + +if TYPE_CHECKING: + from aiida.engine.transports import TransportQueue + from aiida.schedulers.datastructures import JobInfo __all__ = ('JobsList', 'JobManager') @@ -35,15 +41,13 @@ class JobsList: See the :py:class:`~aiida.engine.processes.calcjobs.manager.JobManager` for example usage. """ - def __init__(self, authinfo, transport_queue, last_updated=None): + def __init__(self, authinfo: AuthInfo, transport_queue: 'TransportQueue', last_updated: Optional[float] = None): """Construct an instance for the given authinfo and transport queue. :param authinfo: The authinfo used to check the jobs list - :type authinfo: :class:`aiida.orm.AuthInfo` :param transport_queue: A transport queue - :type: :class:`aiida.engine.transports.TransportQueue` :param last_updated: initialize the last updated timestamp - :type: float + """ lang.type_check(last_updated, float, allow_none=True) @@ -52,41 +56,41 @@ def __init__(self, authinfo, transport_queue, last_updated=None): self._loop = transport_queue.loop self._logger = logging.getLogger(__name__) - self._jobs_cache = {} - self._job_update_requests = {} # Mapping: {job_id: Future} + self._jobs_cache: Dict[Hashable, 'JobInfo'] = {} + self._job_update_requests: Dict[Hashable, asyncio.Future] = {} # Mapping: {job_id: Future} self._last_updated = last_updated - self._update_handle = None + self._update_handle: Optional[asyncio.TimerHandle] = None @property - def logger(self): + def logger(self) -> logging.Logger: """Return the logger configured for this instance. :return: the logger """ return self._logger - def get_minimum_update_interval(self): + def get_minimum_update_interval(self) -> float: """Get the minimum interval that should be respected between updates of the list. :return: the minimum interval - :rtype: float + """ return self._authinfo.computer.get_minimum_job_poll_interval() @property - def last_updated(self): + def last_updated(self) -> Optional[float]: """Get the timestamp of when the list was last updated as produced by `time.time()` :return: The last update point - :rtype: float + """ return self._last_updated - async def _get_jobs_from_scheduler(self): + async def _get_jobs_from_scheduler(self) -> Dict[Hashable, 'JobInfo']: """Get the current jobs list from the scheduler. :return: a mapping of job ids to :py:class:`~aiida.schedulers.datastructures.JobInfo` instances - :rtype: dict + """ with self._transport_queue.request_transport(self._authinfo) as request: self.logger.info('waiting for transport') @@ -95,7 +99,7 @@ async def _get_jobs_from_scheduler(self): scheduler = self._authinfo.computer.get_scheduler() scheduler.set_transport(transport) - kwargs = {'as_dict': True} + kwargs: Dict[str, Any] = {'as_dict': True} if scheduler.get_feature('can_query_by_user'): kwargs['user'] = '$USER' else: @@ -113,7 +117,7 @@ async def _get_jobs_from_scheduler(self): return jobs_cache - async def _update_job_info(self): + async def _update_job_info(self) -> None: """Update all of the job information objects. This will set the futures for all pending update requests where the corresponding job has a new status compared @@ -146,7 +150,7 @@ async def _update_job_info(self): self._job_update_requests = {} @contextlib.contextmanager - def request_job_info_update(self, job_id): + def request_job_info_update(self, job_id: Hashable) -> Iterator['asyncio.Future[JobInfo]']: """Request job info about a job when the job next changes state. If the job is not found in the jobs list at the update, the future will resolve to `None`. @@ -164,7 +168,7 @@ def request_job_info_update(self, job_id): finally: pass - def _ensure_updating(self): + def _ensure_updating(self) -> None: """Ensure that we are updating the job list from the remote resource. This will automatically stop if there are no outstanding requests. @@ -188,12 +192,10 @@ async def updating(): ) @staticmethod - def _has_job_state_changed(old, new): + def _has_job_state_changed(old: Optional['JobInfo'], new: Optional['JobInfo']) -> bool: """Return whether the states `old` and `new` are different. - :type old: :class:`aiida.schedulers.JobInfo` or `None` - :type new: :class:`aiida.schedulers.JobInfo` or `None` - :rtype: bool + """ if old is None and new is None: return False @@ -204,14 +206,14 @@ def _has_job_state_changed(old, new): return old.job_state != new.job_state or old.job_substate != new.job_substate - def _get_next_update_delay(self): + def _get_next_update_delay(self) -> float: """Calculate when we are next allowed to poll the scheduler. This delay is calculated as the minimum polling interval defined by the authentication info for this instance, minus time elapsed since the last update. :return: delay (in seconds) after which the scheduler may be polled again - :rtype: float + """ if self.last_updated is None: # Never updated, so do it straight away @@ -225,10 +227,10 @@ def _get_next_update_delay(self): return delay - def _update_requests_outstanding(self): + def _update_requests_outstanding(self) -> bool: return any(not request.done() for request in self._job_update_requests.values()) - def _get_jobs_with_scheduler(self): + def _get_jobs_with_scheduler(self) -> List[str]: """Get all the jobs that are currently with scheduler. :return: the list of jobs with the scheduler @@ -252,11 +254,11 @@ class JobManager: only hold per runner. """ - def __init__(self, transport_queue): + def __init__(self, transport_queue: 'TransportQueue') -> None: self._transport_queue = transport_queue - self._job_lists = {} + self._job_lists: Dict[Hashable, 'JobInfo'] = {} - def get_jobs_list(self, authinfo): + def get_jobs_list(self, authinfo: AuthInfo) -> JobsList: """Get or create a new `JobLists` instance for the given authinfo. :param authinfo: the `AuthInfo` @@ -268,13 +270,11 @@ def get_jobs_list(self, authinfo): return self._job_lists[authinfo.id] @contextlib.contextmanager - def request_job_info_update(self, authinfo, job_id): + def request_job_info_update(self, authinfo: AuthInfo, job_id: Hashable) -> Iterator['asyncio.Future[JobInfo]']: """Get a future that will resolve to information about a given job. This is a context manager so that if the user leaves the context the request is automatically cancelled. - :return: A tuple containing the `JobInfo` object and detailed job info. Both can be None. - :rtype: :class:`asyncio.Future` """ with self.get_jobs_list(authinfo).request_job_info_update(job_id) as request: try: diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 293065ff6c..8a837a189c 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -11,19 +11,27 @@ import functools import logging import tempfile +from typing import Any, Callable, Optional, TYPE_CHECKING import plumpy +import plumpy.process_states +import plumpy.futures from aiida.common.datastructures import CalcJobState from aiida.common.exceptions import FeatureNotAvailable, TransportTaskException from aiida.common.folders import SandboxFolder from aiida.engine.daemon import execmanager -from aiida.engine.utils import exponential_backoff_retry, interruptable_task +from aiida.engine.transports import TransportQueue +from aiida.engine.utils import exponential_backoff_retry, interruptable_task, InterruptableFuture +from aiida.orm.nodes.process.calculation.calcjob import CalcJobNode from aiida.schedulers.datastructures import JobState from aiida.manage.configuration import get_config_option from ..process import ProcessState +if TYPE_CHECKING: + from .calcjob import CalcJob + UPLOAD_COMMAND = 'upload' SUBMIT_COMMAND = 'submit' UPDATE_COMMAND = 'update' @@ -40,7 +48,7 @@ class PreSubmitException(Exception): """Raise in the `do_upload` coroutine when an exception is raised in `CalcJob.presubmit`.""" -async def task_upload_job(process, transport_queue, cancellable): +async def task_upload_job(process: 'CalcJob', transport_queue: TransportQueue, cancellable: InterruptableFuture): """Transport task that will attempt to upload the files of a job calculation to the remote. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -48,10 +56,10 @@ async def task_upload_job(process, transport_queue, cancellable): retry after an interval that increases exponentially with the number of retries, for a maximum number of retries. If all retries fail, the task will raise a TransportTaskException - :param node: the node that represents the job calculation + :param process: the job calculation :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled - :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` + :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ node = process.node @@ -83,13 +91,13 @@ async def do_upload(): try: logger.info(f'scheduled request to upload CalcJob<{node.pk}>') - ignore_exceptions = (plumpy.CancelledError, PreSubmitException) + ignore_exceptions = (plumpy.futures.CancelledError, PreSubmitException) skip_submit = await exponential_backoff_retry( do_upload, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) except PreSubmitException: raise - except plumpy.CancelledError: + except plumpy.futures.CancelledError: pass except Exception: logger.warning(f'uploading CalcJob<{node.pk}> failed') @@ -100,7 +108,7 @@ async def do_upload(): return skip_submit -async def task_submit_job(node, transport_queue, cancellable): +async def task_submit_job(node: CalcJobNode, transport_queue: TransportQueue, cancellable: InterruptableFuture): """Transport task that will attempt to submit a job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -111,7 +119,7 @@ async def task_submit_job(node, transport_queue, cancellable): :param node: the node that represents the job calculation :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled - :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` + :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ if node.get_state() == CalcJobState.WITHSCHEDULER: @@ -132,9 +140,13 @@ async def do_submit(): try: logger.info(f'scheduled request to submit CalcJob<{node.pk}>') result = await exponential_backoff_retry( - do_submit, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=plumpy.Interruption + do_submit, + initial_interval, + max_attempts, + logger=node.logger, + ignore_exceptions=plumpy.process_states.Interruption ) - except plumpy.Interruption: + except plumpy.process_states.Interruption: pass except Exception: logger.warning(f'submitting CalcJob<{node.pk}> failed') @@ -145,7 +157,7 @@ async def do_submit(): return result -async def task_update_job(node, job_manager, cancellable): +async def task_update_job(node: CalcJobNode, job_manager, cancellable: InterruptableFuture): """Transport task that will attempt to update the scheduler status of the job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -190,9 +202,13 @@ async def do_update(): try: logger.info(f'scheduled request to update CalcJob<{node.pk}>') job_done = await exponential_backoff_retry( - do_update, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=plumpy.Interruption + do_update, + initial_interval, + max_attempts, + logger=node.logger, + ignore_exceptions=plumpy.process_states.Interruption ) - except plumpy.Interruption: + except plumpy.process_states.Interruption: raise except Exception: logger.warning(f'updating CalcJob<{node.pk}> failed') @@ -205,7 +221,10 @@ async def do_update(): return job_done -async def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, cancellable): +async def task_retrieve_job( + node: CalcJobNode, transport_queue: TransportQueue, retrieved_temporary_folder: str, + cancellable: InterruptableFuture +): """Transport task that will attempt to retrieve all files of a completed job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -215,8 +234,9 @@ async def task_retrieve_job(node, transport_queue, retrieved_temporary_folder, c :param node: the node that represents the job calculation :param transport_queue: the TransportQueue from which to request a Transport + :param retrieved_temporary_folder: the absolute path to a directory to store files :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled - :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` + :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ if node.get_state() == CalcJobState.PARSING: @@ -251,9 +271,13 @@ async def do_retrieve(): try: logger.info(f'scheduled request to retrieve CalcJob<{node.pk}>') result = await exponential_backoff_retry( - do_retrieve, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=plumpy.Interruption + do_retrieve, + initial_interval, + max_attempts, + logger=node.logger, + ignore_exceptions=plumpy.process_states.Interruption ) - except plumpy.Interruption: + except plumpy.process_states.Interruption: raise except Exception: logger.warning(f'retrieving CalcJob<{node.pk}> failed') @@ -264,7 +288,7 @@ async def do_retrieve(): return result -async def task_kill_job(node, transport_queue, cancellable): +async def task_kill_job(node: CalcJobNode, transport_queue: TransportQueue, cancellable: InterruptableFuture): """Transport task that will attempt to kill a job calculation. The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager @@ -275,7 +299,7 @@ async def task_kill_job(node, transport_queue, cancellable): :param node: the node that represents the job calculation :param transport_queue: the TransportQueue from which to request a Transport :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled - :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` + :raises: TransportTaskException if after the maximum number of retries the transport task still excepted """ initial_interval = get_config_option(RETRY_INTERVAL_OPTION) @@ -295,7 +319,7 @@ async def do_kill(): try: logger.info(f'scheduled request to kill CalcJob<{node.pk}>') result = await exponential_backoff_retry(do_kill, initial_interval, max_attempts, logger=node.logger) - except plumpy.Interruption: + except plumpy.process_states.Interruption: raise except Exception: logger.warning(f'killing CalcJob<{node.pk}> failed') @@ -306,29 +330,42 @@ async def do_kill(): return result -class Waiting(plumpy.Waiting): +class Waiting(plumpy.process_states.Waiting): """The waiting state for the `CalcJob` process.""" - def __init__(self, process, done_callback, msg=None, data=None): + def __init__( + self, + process: 'CalcJob', + done_callback: Optional[Callable[..., Any]], + msg: Optional[str] = None, + data: Optional[Any] = None + ): """ - :param :class:`~plumpy.base.state_machine.StateMachine` process: The process this state belongs to + :param process: The process this state belongs to """ super().__init__(process, done_callback, msg, data) - self._task = None - self._killing = None + self._task: Optional[InterruptableFuture] = None + self._killing: Optional[plumpy.futures.Future] = None + + @property + def process(self) -> 'CalcJob': + """ + :return: The process + """ + return self.state_machine # type: ignore[return-value] def load_instance_state(self, saved_state, load_context): super().load_instance_state(saved_state, load_context) self._task = None self._killing = None - async def execute(self): # pylint: disable=invalid-overridden-method + async def execute(self) -> plumpy.process_states.State: # type: ignore[override] # pylint: disable=invalid-overridden-method """Override the execute coroutine of the base `Waiting` state.""" # pylint: disable=too-many-branches, too-many-statements node = self.process.node transport_queue = self.process.runner.transport command = self.data - result = self + result: plumpy.process_states.State = self process_status = f'Waiting for transport task: {command}' @@ -370,12 +407,15 @@ async def execute(self): # pylint: disable=invalid-overridden-method raise RuntimeError('Unknown waiting command') except TransportTaskException as exception: - raise plumpy.PauseInterruption(f'Pausing after failed transport task: {exception}') - except plumpy.KillInterruption: + raise plumpy.process_states.PauseInterruption(f'Pausing after failed transport task: {exception}') + except plumpy.process_states.KillInterruption: await self._launch_task(task_kill_job, node, transport_queue) - self._killing.set_result(True) + if self._killing is not None: + self._killing.set_result(True) + else: + logger.warning(f'killed CalcJob<{node.pk}> but async future was None') raise - except (plumpy.Interruption, plumpy.CancelledError): + except (plumpy.process_states.Interruption, plumpy.futures.CancelledError): node.set_process_status(f'Transport task {command} was interrupted') raise else: @@ -396,39 +436,45 @@ async def _launch_task(self, coro, *args, **kwargs): finally: self._task = None - def upload(self): + def upload(self) -> 'Waiting': """Return the `Waiting` state that will `upload` the `CalcJob`.""" msg = 'Waiting for calculation folder upload' - return self.create_state(ProcessState.WAITING, None, msg=msg, data=UPLOAD_COMMAND) + return self.create_state(ProcessState.WAITING, None, msg=msg, data=UPLOAD_COMMAND) # type: ignore[return-value] - def submit(self): + def submit(self) -> 'Waiting': """Return the `Waiting` state that will `submit` the `CalcJob`.""" msg = 'Waiting for scheduler submission' - return self.create_state(ProcessState.WAITING, None, msg=msg, data=SUBMIT_COMMAND) + return self.create_state(ProcessState.WAITING, None, msg=msg, data=SUBMIT_COMMAND) # type: ignore[return-value] - def update(self): + def update(self) -> 'Waiting': """Return the `Waiting` state that will `update` the `CalcJob`.""" msg = 'Waiting for scheduler update' - return self.create_state(ProcessState.WAITING, None, msg=msg, data=UPDATE_COMMAND) + return self.create_state(ProcessState.WAITING, None, msg=msg, data=UPDATE_COMMAND) # type: ignore[return-value] - def retrieve(self): + def retrieve(self) -> 'Waiting': """Return the `Waiting` state that will `retrieve` the `CalcJob`.""" msg = 'Waiting to retrieve' - return self.create_state(ProcessState.WAITING, None, msg=msg, data=RETRIEVE_COMMAND) + return self.create_state( + ProcessState.WAITING, None, msg=msg, data=RETRIEVE_COMMAND + ) # type: ignore[return-value] - def parse(self, retrieved_temporary_folder): + def parse(self, retrieved_temporary_folder: str) -> plumpy.process_states.Running: """Return the `Running` state that will parse the `CalcJob`. :param retrieved_temporary_folder: temporary folder used in retrieving that can be used during parsing. """ - return self.create_state(ProcessState.RUNNING, self.process.parse, retrieved_temporary_folder) + return self.create_state( + ProcessState.RUNNING, self.process.parse, retrieved_temporary_folder + ) # type: ignore[return-value] - def interrupt(self, reason): + def interrupt(self, reason: Any) -> Optional[plumpy.futures.Future]: # type: ignore[override] """Interrupt the `Waiting` state by calling interrupt on the transport task `InterruptableFuture`.""" if self._task is not None: self._task.interrupt(reason) - if isinstance(reason, plumpy.KillInterruption): + if isinstance(reason, plumpy.process_states.KillInterruption): if self._killing is None: - self._killing = plumpy.Future() + self._killing = plumpy.futures.Future() return self._killing + + return None diff --git a/aiida/engine/processes/exit_code.py b/aiida/engine/processes/exit_code.py index 0c54a5be72..cb13b0a765 100644 --- a/aiida/engine/processes/exit_code.py +++ b/aiida/engine/processes/exit_code.py @@ -34,7 +34,7 @@ class ExitCode(namedtuple('ExitCode', ['status', 'message', 'invalidates_cache'] :type invalidates_cache: bool """ - def format(self, **kwargs): + def format(self, **kwargs: str) -> 'ExitCode': """Create a clone of this exit code where the template message is replaced by the keyword arguments. :param kwargs: replacement parameters for the template message @@ -50,7 +50,7 @@ def format(self, **kwargs): # Set the defaults for the `ExitCode` attributes -ExitCode.__new__.__defaults__ = (0, None, False) +ExitCode.__new__.__defaults__ = (0, None, False) # type: ignore[attr-defined] class ExitCodesNamespace(AttributeDict): @@ -60,15 +60,13 @@ class ExitCodesNamespace(AttributeDict): `ExitCode` that needs to be retrieved or the key in the collection. """ - def __call__(self, identifier): + def __call__(self, identifier: str) -> ExitCode: """Return a specific exit code identified by either its exit status or label. :param identifier: the identifier of the exit code. If the type is integer, it will be interpreted as the exit code status, otherwise it be interpreted as the exit code label - :type identifier: str :returns: an `ExitCode` instance - :rtype: :class:`aiida.engine.ExitCode` :raises ValueError: if no exit code with the given label is defined for this process """ diff --git a/aiida/engine/processes/functions.py b/aiida/engine/processes/functions.py index a08e0ef012..0dd6ef4759 100644 --- a/aiida/engine/processes/functions.py +++ b/aiida/engine/processes/functions.py @@ -13,18 +13,24 @@ import inspect import logging import signal +from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Type, TYPE_CHECKING from aiida.common.lang import override from aiida.manage.manager import get_manager +from aiida.orm import CalcFunctionNode, Data, ProcessNode, WorkFunctionNode +from aiida.orm.utils.mixins import FunctionCalculationMixin from .process import Process +if TYPE_CHECKING: + from .exit_code import ExitCode + __all__ = ('calcfunction', 'workfunction', 'FunctionProcess') LOGGER = logging.getLogger(__name__) -def calcfunction(function): +def calcfunction(function: Callable[..., Any]) -> Callable[..., Any]: """ A decorator to turn a standard python function into a calcfunction. Example usage: @@ -51,11 +57,10 @@ def calcfunction(function): :return: The decorated function. :rtype: callable """ - from aiida.orm import CalcFunctionNode return process_function(node_class=CalcFunctionNode)(function) -def workfunction(function): +def workfunction(function: Callable[..., Any]) -> Callable[..., Any]: """ A decorator to turn a standard python function into a workfunction. Example usage: @@ -80,13 +85,12 @@ def workfunction(function): :type function: callable :return: The decorated function. - :rtype: callable - """ - from aiida.orm import WorkFunctionNode + + """ return process_function(node_class=WorkFunctionNode)(function) -def process_function(node_class): +def process_function(node_class: Type['ProcessNode']) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ The base function decorator to create a FunctionProcess out of a normal python function. @@ -94,7 +98,7 @@ def process_function(node_class): :type node_class: :class:`aiida.orm.ProcessNode` """ - def decorator(function): + def decorator(function: Callable[..., Any]) -> Callable[..., Any]: """ Turn the decorated function into a FunctionProcess. @@ -103,14 +107,14 @@ def decorator(function): """ process_class = FunctionProcess.build(function, node_class=node_class) - def run_get_node(*args, **kwargs): + def run_get_node(*args, **kwargs) -> Tuple[Optional[Dict[str, Any]], 'ProcessNode']: """ Run the FunctionProcess with the supplied inputs in a local runner. :param args: input arguments to construct the FunctionProcess :param kwargs: input keyword arguments to construct the FunctionProcess - :return: tuple of the outputs of the process and the process node pk - :rtype: (dict, int) + :return: tuple of the outputs of the process and the process node + """ manager = get_manager() runner = manager.get_runner() @@ -158,13 +162,13 @@ def kill_process(_num, _frame): return result, process.node - def run_get_pk(*args, **kwargs): + def run_get_pk(*args, **kwargs) -> Tuple[Optional[Dict[str, Any]], int]: """Recreate the `run_get_pk` utility launcher. :param args: input arguments to construct the FunctionProcess :param kwargs: input keyword arguments to construct the FunctionProcess :return: tuple of the outputs of the process and the process node pk - :rtype: (dict, int) + """ result, node = run_get_node(*args, **kwargs) return result, node.pk @@ -175,14 +179,14 @@ def decorated_function(*args, **kwargs): result, _ = run_get_node(*args, **kwargs) return result - decorated_function.run = decorated_function - decorated_function.run_get_pk = run_get_pk - decorated_function.run_get_node = run_get_node - decorated_function.is_process_function = True - decorated_function.node_class = node_class - decorated_function.process_class = process_class - decorated_function.recreate_from = process_class.recreate_from - decorated_function.spec = process_class.spec + decorated_function.run = decorated_function # type: ignore[attr-defined] + decorated_function.run_get_pk = run_get_pk # type: ignore[attr-defined] + decorated_function.run_get_node = run_get_node # type: ignore[attr-defined] + decorated_function.is_process_function = True # type: ignore[attr-defined] + decorated_function.node_class = node_class # type: ignore[attr-defined] + decorated_function.process_class = process_class # type: ignore[attr-defined] + decorated_function.recreate_from = process_class.recreate_from # type: ignore[attr-defined] + decorated_function.spec = process_class.spec # type: ignore[attr-defined] return decorated_function @@ -192,10 +196,10 @@ def decorated_function(*args, **kwargs): class FunctionProcess(Process): """Function process class used for turning functions into a Process""" - _func_args = None + _func_args: Sequence[str] = () @staticmethod - def _func(*_args, **_kwargs): + def _func(*_args, **_kwargs) -> dict: """ This is used internally to store the actual function that is being wrapped and will be replaced by the build method. @@ -203,7 +207,7 @@ def _func(*_args, **_kwargs): return {} @staticmethod - def build(func, node_class): + def build(func: Callable[..., Any], node_class: Type['ProcessNode']) -> Type['FunctionProcess']: """ Build a Process from the given function. @@ -211,19 +215,13 @@ def build(func, node_class): these will also become inputs. :param func: The function to build a process from - :type func: callable - :param node_class: Provide a custom node class to be used, has to be constructable with no arguments. It has to be a sub class of `ProcessNode` and the mixin :class:`~aiida.orm.utils.mixins.FunctionCalculationMixin`. - :type node_class: :class:`aiida.orm.nodes.process.process.ProcessNode` :return: A Process class that represents the function - :rtype: :class:`FunctionProcess` - """ - from aiida import orm - from aiida.orm.utils.mixins import FunctionCalculationMixin - if not issubclass(node_class, orm.ProcessNode) or not issubclass(node_class, FunctionCalculationMixin): + """ + if not issubclass(node_class, ProcessNode) or not issubclass(node_class, FunctionCalculationMixin): raise TypeError('the node_class should be a sub class of `ProcessNode` and `FunctionCalculationMixin`') args, varargs, keywords, defaults, _, _, _ = inspect.getfullargspec(func) @@ -240,7 +238,7 @@ def _define(cls, spec): # pylint: disable=unused-argument for i, arg in enumerate(args): default = () - if i >= first_default_pos: + if defaults and i >= first_default_pos: default = defaults[i - first_default_pos] # If the keyword was already specified, simply override the default @@ -251,9 +249,9 @@ def _define(cls, spec): # pylint: disable=unused-argument # Note that we cannot use `None` because the validation will call `isinstance` which does not work # when passing `None`, but it does work with `NoneType` which is returned by calling `type(None)` if default is None: - valid_type = (orm.Data, type(None)) + valid_type = (Data, type(None)) else: - valid_type = (orm.Data,) + valid_type = (Data,) spec.input(arg, valid_type=valid_type, default=default) @@ -269,7 +267,7 @@ def _define(cls, spec): # pylint: disable=unused-argument # Function processes must have a dynamic output namespace since we do not know beforehand what outputs # will be returned and the valid types for the value should be `Data` nodes as well as a dictionary because # the output namespace can be nested. - spec.outputs.valid_type = (orm.Data, dict) + spec.outputs.valid_type = (Data, dict) return type( func.__name__, (FunctionProcess,), { @@ -283,7 +281,7 @@ def _define(cls, spec): # pylint: disable=unused-argument ) @classmethod - def validate_inputs(cls, *args, **kwargs): # pylint: disable=unused-argument + def validate_inputs(cls, *args: Any, **kwargs: Any) -> None: # pylint: disable=unused-argument """ Validate the positional and keyword arguments passed in the function call. @@ -302,11 +300,8 @@ def validate_inputs(cls, *args, **kwargs): # pylint: disable=unused-argument raise TypeError(f'{name}() takes {nparameters} positional arguments but {nargs} were given') @classmethod - def create_inputs(cls, *args, **kwargs): - """Create the input args for the FunctionProcess. - - :rtype: dict - """ + def create_inputs(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]: + """Create the input args for the FunctionProcess.""" cls.validate_inputs(*args, **kwargs) ins = {} @@ -317,29 +312,28 @@ def create_inputs(cls, *args, **kwargs): return ins @classmethod - def args_to_dict(cls, *args): + def args_to_dict(cls, *args: Any) -> Dict[str, Any]: """ Create an input dictionary (of form label -> value) from supplied args. :param args: The values to use for the dictionary - :type args: list :return: A label -> value dictionary - :rtype: dict + """ return dict(list(zip(cls._func_args, args))) @classmethod - def get_or_create_db_record(cls): + def get_or_create_db_record(cls) -> 'ProcessNode': return cls._node_class() - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: if kwargs.get('enable_persistence', False): raise RuntimeError('Cannot persist a function process') - super().__init__(enable_persistence=False, *args, **kwargs) + super().__init__(enable_persistence=False, *args, **kwargs) # type: ignore @property - def process_class(self): + def process_class(self) -> Callable[..., Any]: """ Return the class that represents this Process, for the FunctionProcess this is the function itself. @@ -348,33 +342,29 @@ def process_class(self): class that really represents what was being executed. :return: A Process class that represents the function - :rtype: :class:`FunctionProcess` + """ return self._func - def execute(self): + def execute(self) -> Optional[Dict[str, Any]]: """Execute the process.""" result = super().execute() # FunctionProcesses can return a single value as output, and not a dictionary, so we should also return that - if len(result) == 1 and self.SINGLE_OUTPUT_LINKNAME in result: + if result and len(result) == 1 and self.SINGLE_OUTPUT_LINKNAME in result: return result[self.SINGLE_OUTPUT_LINKNAME] return result @override - def _setup_db_record(self): + def _setup_db_record(self) -> None: """Set up the database record for the process.""" super()._setup_db_record() self.node.store_source_info(self._func) @override - def run(self): - """Run the process. - - :rtype: :class:`aiida.engine.ExitCode` - """ - from aiida.orm import Data + def run(self) -> Optional['ExitCode']: + """Run the process.""" from .exit_code import ExitCode # The following conditional is required for the caching to properly work. Even if the source node has a process @@ -388,9 +378,9 @@ def run(self): args = [None] * len(self._func_args) kwargs = {} - for name, value in self.inputs.items(): + for name, value in (self.inputs or {}).items(): try: - if self.spec().inputs[name].non_db: + if self.spec().inputs[name].non_db: # type: ignore[union-attr] # Don't consider non-database inputs continue except KeyError: @@ -418,4 +408,4 @@ def run(self): 'Must be a Data type or a mapping of {{string: Data}}'.format(result.__class__) ) - return ExitCode() + return ExitCode() # type: ignore[call-arg] diff --git a/aiida/engine/processes/futures.py b/aiida/engine/processes/futures.py index cf1d500cc8..1c3d06b67b 100644 --- a/aiida/engine/processes/futures.py +++ b/aiida/engine/processes/futures.py @@ -10,9 +10,12 @@ # pylint: disable=cyclic-import """Futures that can poll or receive broadcasted messages while waiting for a task to be completed.""" import asyncio +from typing import Optional, Union import kiwipy +from aiida.orm import Node, load_node + __all__ = ('ProcessFuture',) @@ -21,18 +24,23 @@ class ProcessFuture(asyncio.Future): _filtered = None - def __init__(self, pk, loop=None, poll_interval=None, communicator=None): + def __init__( + self, + pk: int, + loop: Optional[asyncio.AbstractEventLoop] = None, + poll_interval: Union[None, int, float] = None, + communicator: Optional[kiwipy.Communicator] = None + ): """Construct a future for a process node being finished. - If a None poll_interval is supplied polling will not be used. If a communicator is supplied it will be used - to listen for broadcast messages. + If a None poll_interval is supplied polling will not be used. + If a communicator is supplied it will be used to listen for broadcast messages. :param pk: process pk :param loop: An event loop :param poll_interval: optional polling interval, if None, polling is not activated. :param communicator: optional communicator, if None, will not subscribe to broadcasts. """ - from aiida.orm import load_node from .process import ProcessState # create future in specified event loop @@ -60,14 +68,14 @@ def __init__(self, pk, loop=None, poll_interval=None, communicator=None): if poll_interval is not None: loop.create_task(self._poll_process(node, poll_interval)) - def cleanup(self): + def cleanup(self) -> None: """Clean up the future by removing broadcast subscribers from the communicator if it still exists.""" if self._communicator is not None: self._communicator.remove_broadcast_subscriber(self._broadcast_identifier) self._communicator = None self._broadcast_identifier = None - async def _poll_process(self, node, poll_interval): + async def _poll_process(self, node: Node, poll_interval: Union[int, float]) -> None: """Poll whether the process node has reached a terminal state.""" while not self.done() and not node.is_terminated: await asyncio.sleep(poll_interval) diff --git a/aiida/engine/processes/ports.py b/aiida/engine/processes/ports.py index 1613d2169d..b288747138 100644 --- a/aiida/engine/processes/ports.py +++ b/aiida/engine/processes/ports.py @@ -10,9 +10,14 @@ """AiiDA specific implementation of plumpy Ports and PortNamespaces for the ProcessSpec.""" import collections import re +from typing import Any, Callable, Dict, Optional, Sequence import warnings from plumpy import ports +from plumpy.ports import breadcrumbs_to_port + +from aiida.common.links import validate_link_label +from aiida.orm import Data, Node __all__ = ( 'PortNamespace', 'InputPort', 'OutputPort', 'CalcJobOutputPort', 'WithNonDb', 'WithSerialize', @@ -26,21 +31,21 @@ class WithNonDb: """ - A mixin that adds support to a port to flag a that should not be stored + A mixin that adds support to a port to flag that it should not be stored in the database using the non_db=True flag. The mixins have to go before the main port class in the superclass order to make sure the mixin has the chance to strip out the non_db keyword. """ - def __init__(self, *args, **kwargs): - self._non_db_explicitly_set = bool('non_db' in kwargs) + def __init__(self, *args, **kwargs) -> None: + self._non_db_explicitly_set: bool = bool('non_db' in kwargs) non_db = kwargs.pop('non_db', False) - super().__init__(*args, **kwargs) - self._non_db = non_db + super().__init__(*args, **kwargs) # type: ignore[call-arg] + self._non_db: bool = non_db @property - def non_db_explicitly_set(self): + def non_db_explicitly_set(self) -> bool: """Return whether the a value for `non_db` was explicitly passed in the construction of the `Port`. :return: boolean, True if `non_db` was explicitly defined during construction, False otherwise @@ -48,7 +53,7 @@ def non_db_explicitly_set(self): return self._non_db_explicitly_set @property - def non_db(self): + def non_db(self) -> bool: """Return whether the value of this `Port` should be stored as a `Node` in the database. :return: boolean, True if it should be storable as a `Node`, False otherwise @@ -56,10 +61,8 @@ def non_db(self): return self._non_db @non_db.setter - def non_db(self, non_db): + def non_db(self, non_db: bool) -> None: """Set whether the value of this `Port` should be stored as a `Node` in the database. - - :param non_db: boolean """ self._non_db_explicitly_set = True self._non_db = non_db @@ -71,19 +74,17 @@ class WithSerialize: that are not AiiDA data types. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: serializer = kwargs.pop('serializer', None) - super().__init__(*args, **kwargs) - self._serializer = serializer + super().__init__(*args, **kwargs) # type: ignore[call-arg] + self._serializer: Callable[[Any], 'Data'] = serializer - def serialize(self, value): + def serialize(self, value: Any) -> 'Data': """Serialize the given value if it is not already a Data type and a serializer function is defined :param value: the value to be serialized :returns: a serialized version of the value or the unchanged value """ - from aiida.orm import Data - if self._serializer is None or isinstance(value, Data): return value @@ -96,11 +97,9 @@ class InputPort(WithSerialize, WithNonDb, ports.InputPort): value serialization to database storable types and support non database storable input types as well. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Override the constructor to check the type of the default if set and warn if not immutable.""" # pylint: disable=redefined-builtin,too-many-arguments - from aiida.orm import Node - if 'default' in kwargs: default = kwargs['default'] # If the default is specified and it is a node instance, raise a warning. This is to try and prevent that @@ -112,7 +111,7 @@ def __init__(self, *args, **kwargs): super(InputPort, self).__init__(*args, **kwargs) - def get_description(self): + def get_description(self) -> Dict[str, str]: """ Return a description of the InputPort, which will be a dictionary of its attributes @@ -127,13 +126,13 @@ def get_description(self): class CalcJobOutputPort(ports.OutputPort): """Sub class of plumpy.OutputPort which adds the `_pass_to_parser` attribute.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: pass_to_parser = kwargs.pop('pass_to_parser', False) super().__init__(*args, **kwargs) - self._pass_to_parser = pass_to_parser + self._pass_to_parser: bool = pass_to_parser @property - def pass_to_parser(self): + def pass_to_parser(self) -> bool: return self._pass_to_parser @@ -143,7 +142,7 @@ class PortNamespace(WithNonDb, ports.PortNamespace): serialization of a given mapping onto the ports of the PortNamespace. """ - def __setitem__(self, key, port): + def __setitem__(self, key: str, port: ports.Port) -> None: """Ensure that a `Port` being added inherits the `non_db` attribute if not explicitly defined at construction. The reasoning is that if a `PortNamespace` has `non_db=True`, which is different from the default value, very @@ -157,13 +156,13 @@ def __setitem__(self, key, port): self.validate_port_name(key) - if hasattr(port, 'non_db_explicitly_set') and not port.non_db_explicitly_set: - port.non_db = self.non_db + if hasattr(port, 'non_db_explicitly_set') and not port.non_db_explicitly_set: # type: ignore[attr-defined] + port.non_db = self.non_db # type: ignore[attr-defined] super().__setitem__(key, port) @staticmethod - def validate_port_name(port_name): + def validate_port_name(port_name: str) -> None: """Validate the given port name. Valid port names adhere to the following restrictions: @@ -181,8 +180,6 @@ def validate_port_name(port_name): :raise TypeError: if the port name is not a string type :raise ValueError: if the port name is invalid """ - from aiida.common.links import validate_link_label - try: validate_link_label(port_name) except ValueError as exception: @@ -195,7 +192,7 @@ def validate_port_name(port_name): if any([len(entry) > PORT_NAME_MAX_CONSECUTIVE_UNDERSCORES for entry in consecutive_underscores]): raise ValueError(f'invalid port name `{port_name}`: more than two consecutive underscores') - def serialize(self, mapping, breadcrumbs=()): + def serialize(self, mapping: Optional[Dict[str, Any]], breadcrumbs: Sequence[str] = ()) -> Optional[Dict[str, Any]]: """Serialize the given mapping onto this `Portnamespace`. It will recursively call this function on any nested `PortNamespace` or the serialize function on any `Ports`. @@ -204,26 +201,27 @@ def serialize(self, mapping, breadcrumbs=()): :param breadcrumbs: a tuple with the namespaces of parent namespaces :returns: the serialized mapping """ - from plumpy.ports import breadcrumbs_to_port - if mapping is None: return None - breadcrumbs += (self.name,) + breadcrumbs = (*breadcrumbs, self.name) if not isinstance(mapping, collections.Mapping): - port = breadcrumbs_to_port(breadcrumbs) - raise TypeError(f'port namespace `{port}` received `{type(mapping)}` instead of a dictionary') + port_name = breadcrumbs_to_port(breadcrumbs) + raise TypeError(f'port namespace `{port_name}` received `{type(mapping)}` instead of a dictionary') result = {} for name, value in mapping.items(): if name in self: + port = self[name] if isinstance(port, PortNamespace): result[name] = port.serialize(value, breadcrumbs) - else: + elif isinstance(port, InputPort): result[name] = port.serialize(value) + else: + raise AssertionError(f'port does not have a serialize method: {port}') else: result[name] = value diff --git a/aiida/engine/processes/process.py b/aiida/engine/processes/process.py index 4c2da21c8d..01cb51da30 100644 --- a/aiida/engine/processes/process.py +++ b/aiida/engine/processes/process.py @@ -8,36 +8,47 @@ # For further information please visit http://www.aiida.net # ########################################################################### """The AiiDA process class""" +import asyncio import collections import enum import inspect -import uuid +import logging +from uuid import UUID import traceback -import asyncio -from typing import Union +from types import TracebackType +from typing import ( + Any, cast, Dict, Iterable, Iterator, List, MutableMapping, Optional, Type, Tuple, Union, TYPE_CHECKING +) from aio_pika.exceptions import ConnectionClosed -import plumpy -from plumpy import ProcessState +import plumpy.exceptions +import plumpy.futures +import plumpy.processes +import plumpy.persistence +from plumpy.process_states import ProcessState, Finished from kiwipy.communications import UnroutableError from aiida import orm +from aiida.orm.utils import serialize from aiida.common import exceptions from aiida.common.extendeddicts import AttributeDict from aiida.common.lang import classproperty, override from aiida.common.links import LinkType from aiida.common.log import LOG_LEVEL_REPORT -from .exit_code import ExitCode +from .exit_code import ExitCode, ExitCodesNamespace from .builder import ProcessBuilder from .ports import InputPort, OutputPort, PortNamespace, PORT_NAMESPACE_SEPARATOR from .process_spec import ProcessSpec +if TYPE_CHECKING: + from aiida.engine.runners import Runner + __all__ = ('Process', 'ProcessState') -@plumpy.auto_persist('_parent_pid', '_enable_persistence') -class Process(plumpy.Process): +@plumpy.persistence.auto_persist('_parent_pid', '_enable_persistence') +class Process(plumpy.processes.Process): """ This class represents an AiiDA process which can be executed and will have full provenance saved in the database. @@ -47,88 +58,109 @@ class Process(plumpy.Process): _node_class = orm.ProcessNode _spec_class = ProcessSpec - SINGLE_OUTPUT_LINKNAME = 'result' + SINGLE_OUTPUT_LINKNAME: str = 'result' class SaveKeys(enum.Enum): """ Keys used to identify things in the saved instance state bundle. """ - CALC_ID = 'calc_id' + CALC_ID: str = 'calc_id' @classmethod - def define(cls, spec): - # yapf: disable + def spec(cls) -> ProcessSpec: + return super().spec() # type: ignore[return-value] + + @classmethod + def define(cls, spec: ProcessSpec) -> None: # type: ignore[override] + """Define the specification of the process, including its inputs, outputs and known exit codes. + + A `metadata` input namespace is defined, with optional ports that are not stored in the database. + + """ super().define(spec) spec.input_namespace(spec.metadata_key, required=False, non_db=True) - spec.input(f'{spec.metadata_key}.store_provenance', valid_type=bool, default=True, - help='If set to `False` provenance will not be stored in the database.') - spec.input(f'{spec.metadata_key}.description', valid_type=str, required=False, - help='Description to set on the process node.') - spec.input(f'{spec.metadata_key}.label', valid_type=str, required=False, - help='Label to set on the process node.') - spec.input(f'{spec.metadata_key}.call_link_label', valid_type=str, default='CALL', - help='The label to use for the `CALL` link if the process is called by another process.') + spec.input( + f'{spec.metadata_key}.store_provenance', + valid_type=bool, + default=True, + help='If set to `False` provenance will not be stored in the database.' + ) + spec.input( + f'{spec.metadata_key}.description', + valid_type=str, + required=False, + help='Description to set on the process node.' + ) + spec.input( + f'{spec.metadata_key}.label', valid_type=str, required=False, help='Label to set on the process node.' + ) + spec.input( + f'{spec.metadata_key}.call_link_label', + valid_type=str, + default='CALL', + help='The label to use for the `CALL` link if the process is called by another process.' + ) spec.exit_code(1, 'ERROR_UNSPECIFIED', message='The process has failed with an unspecified error.') spec.exit_code(2, 'ERROR_LEGACY_FAILURE', message='The process failed with legacy failure mode.') spec.exit_code(10, 'ERROR_INVALID_OUTPUT', message='The process returned an invalid output.') spec.exit_code(11, 'ERROR_MISSING_OUTPUT', message='The process did not register a required output.') @classmethod - def get_builder(cls): + def get_builder(cls) -> ProcessBuilder: return ProcessBuilder(cls) @classmethod - def get_or_create_db_record(cls): + def get_or_create_db_record(cls) -> orm.ProcessNode: """ Create a process node that represents what happened in this process. :return: A process node - :rtype: :class:`aiida.orm.ProcessNode` """ return cls._node_class() - def __init__(self, inputs=None, logger=None, runner=None, parent_pid=None, enable_persistence=True): + def __init__( + self, + inputs: Optional[Dict[str, Any]] = None, + logger: Optional[logging.Logger] = None, + runner: Optional['Runner'] = None, + parent_pid: Optional[int] = None, + enable_persistence: bool = True + ) -> None: """ Process constructor. :param inputs: process inputs - :type inputs: dict - :param logger: aiida logger - :type logger: :class:`logging.Logger` - :param runner: process runner - :type: :class:`aiida.engine.runners.Runner` - :param parent_pid: id of parent process - :type parent_pid: int - :param enable_persistence: whether to persist this process - :type enable_persistence: bool + """ from aiida.manage import manager self._runner = runner if runner is not None else manager.get_manager().get_runner() + assert self._runner.communicator is not None, 'communicator not set for runner' super().__init__( inputs=self.spec().inputs.serialize(inputs), logger=logger, loop=self._runner.loop, - communicator=self.runner.communicator) + communicator=self._runner.communicator + ) - self._node = None + self._node: Optional[orm.ProcessNode] = None self._parent_pid = parent_pid self._enable_persistence = enable_persistence if self._enable_persistence and self.runner.persister is None: self.logger.warning('Disabling persistence, runner does not have a persister') self._enable_persistence = False - def init(self): + def init(self) -> None: super().init() if self._logger is None: self.set_logger(self.node.logger) @classmethod - def get_exit_statuses(cls, exit_code_labels): + def get_exit_statuses(cls, exit_code_labels: Iterable[str]) -> List[int]: """Return the exit status (integers) for the given exit code labels. :param exit_code_labels: a list of strings that reference exit code labels of this process class @@ -139,37 +171,34 @@ def get_exit_statuses(cls, exit_code_labels): return [getattr(exit_codes, label).status for label in exit_code_labels] @classproperty - def exit_codes(cls): # pylint: disable=no-self-argument + def exit_codes(cls) -> ExitCodesNamespace: # pylint: disable=no-self-argument """Return the namespace of exit codes defined for this WorkChain through its ProcessSpec. The namespace supports getitem and getattr operations with an ExitCode label to retrieve a specific code. Additionally, the namespace can also be called with either the exit code integer status to retrieve it. :returns: ExitCodesNamespace of ExitCode named tuples - :rtype: :class:`aiida.engine.ExitCodesNamespace` + """ return cls.spec().exit_codes @classproperty - def spec_metadata(cls): # pylint: disable=no-self-argument - """Return the metadata port namespace of the process specification of this process. - - :return: metadata dictionary - :rtype: dict - """ - return cls.spec().inputs['metadata'] + def spec_metadata(cls) -> PortNamespace: # pylint: disable=no-self-argument + """Return the metadata port namespace of the process specification of this process.""" + return cls.spec().inputs['metadata'] # type: ignore[return-value] @property - def node(self): + def node(self) -> orm.ProcessNode: """Return the ProcessNode used by this process to represent itself in the database. :return: instance of sub class of ProcessNode - :rtype: :class:`aiida.orm.ProcessNode` + """ + assert self._node is not None return self._node @property - def uuid(self): + def uuid(self) -> str: # type: ignore[override] """Return the UUID of the process which corresponds to the UUID of its associated `ProcessNode`. :return: the UUID associated to this process instance @@ -177,32 +206,43 @@ def uuid(self): return self.node.uuid @property - def metadata(self): + def metadata(self) -> AttributeDict: """Return the metadata that were specified when this process instance was launched. :return: metadata dictionary - :rtype: dict + """ try: + assert self.inputs is not None return self.inputs.metadata - except AttributeError: + except (AssertionError, AttributeError): return AttributeDict() - def _save_checkpoint(self): + def _save_checkpoint(self) -> None: """ Save the current state in a chechpoint if persistence is enabled and the process state is not terminal If the persistence call excepts with a PersistenceError, it will be caught and a warning will be logged. """ if self._enable_persistence and not self._state.is_terminal(): + if self.runner.persister is None: + self.logger.exception( + 'No persister set to save checkpoint, this means you will ' + 'not be able to restart in case of a crash until the next successful checkpoint.' + ) + return None try: self.runner.persister.save_checkpoint(self) - except plumpy.PersistenceError: - self.logger.exception('Exception trying to save checkpoint, this means you will ' - 'not be able to restart in case of a crash until the next successful checkpoint.') + except plumpy.exceptions.PersistenceError: + self.logger.exception( + 'Exception trying to save checkpoint, this means you will ' + 'not be able to restart in case of a crash until the next successful checkpoint.' + ) @override - def save_instance_state(self, out_state, save_context): + def save_instance_state( + self, out_state: MutableMapping[str, Any], save_context: Optional[plumpy.persistence.LoadSaveContext] + ) -> None: """Save instance state. See documentation of :meth:`!plumpy.processes.Process.save_instance_state`. @@ -214,21 +254,23 @@ def save_instance_state(self, out_state, save_context): out_state[self.SaveKeys.CALC_ID.value] = self.pid - def get_provenance_inputs_iterator(self): + def get_provenance_inputs_iterator(self) -> Iterator[Tuple[str, Union[InputPort, PortNamespace]]]: """Get provenance input iterator. :rtype: filter """ + assert self.inputs is not None return filter(lambda kv: not kv[0].startswith('_'), self.inputs.items()) @override - def load_instance_state(self, saved_state, load_context): + def load_instance_state( + self, saved_state: MutableMapping[str, Any], load_context: plumpy.persistence.LoadSaveContext + ) -> None: """Load instance state. :param saved_state: saved instance state - :param load_context: - :type load_context: :class:`!plumpy.persistence.LoadSaveContext` + """ from aiida.manage import manager @@ -242,13 +284,13 @@ def load_instance_state(self, saved_state, load_context): if self.SaveKeys.CALC_ID.value in saved_state: self._node = orm.load_node(saved_state[self.SaveKeys.CALC_ID.value]) - self._pid = self.node.pk + self._pid = self.node.pk # pylint: disable=attribute-defined-outside-init else: - self._pid = self._create_and_setup_db_record() + self._pid = self._create_and_setup_db_record() # pylint: disable=attribute-defined-outside-init self.node.logger.info(f'Loaded process<{self.node.pk}> from saved state') - def kill(self, msg: Union[str, None] = None) -> Union[bool, plumpy.Future]: + def kill(self, msg: Union[str, None] = None) -> Union[bool, plumpy.futures.Future]: """ Kill the process and all the children calculations it called @@ -264,9 +306,12 @@ def kill(self, msg: Union[str, None] = None) -> Union[bool, plumpy.Future]: if result is not False and not had_been_terminated: killing = [] for child in self.node.called: + if self.runner.controller is None: + self.logger.info('no controller available to kill child<%s>', child.pk) + continue try: result = self.runner.controller.kill_process(child.pk, f'Killed by parent<{self.node.pk}>') - result = asyncio.wrap_future(result) + result = asyncio.wrap_future(result) # type: ignore[arg-type] if asyncio.isfuture(result): killing.append(result) except ConnectionClosed: @@ -276,31 +321,30 @@ def kill(self, msg: Union[str, None] = None) -> Union[bool, plumpy.Future]: if asyncio.isfuture(result): # We ourselves are waiting to be killed so add it to the list - killing.append(result) + killing.append(result) # type: ignore[arg-type] if killing: # We are waiting for things to be killed, so return the 'gathered' future - kill_future = plumpy.gather(*killing) + kill_future = plumpy.futures.gather(*killing) result = self.loop.create_future() - def done(done_future: plumpy.Future): + def done(done_future: plumpy.futures.Future): is_all_killed = all(done_future.result()) - result.set_result(is_all_killed) + result.set_result(is_all_killed) # type: ignore[union-attr] kill_future.add_done_callback(done) return result @override - def out(self, output_port, value=None): + def out(self, output_port: str, value: Any = None) -> None: """Attach output to output port. The name of the port will be used as the link label. :param output_port: name of output port - :type output_port: str - :param value: value to put inside output port + """ if value is None: # In this case assume that output_port is the actual value and there is just one return value @@ -309,7 +353,7 @@ def out(self, output_port, value=None): return super().out(output_port, value) - def out_many(self, out_dict): + def out_many(self, out_dict: Dict[str, Any]) -> None: """Attach outputs to multiple output ports. Keys of the dictionary will be used as output port names, values as outputs. @@ -320,39 +364,40 @@ def out_many(self, out_dict): for key, value in out_dict.items(): self.out(key, value) - def on_create(self): + def on_create(self) -> None: """Called when a Process is created.""" super().on_create() # If parent PID hasn't been supplied try to get it from the stack if self._parent_pid is None and Process.current(): current = Process.current() if isinstance(current, Process): - self._parent_pid = current.pid - self._pid = self._create_and_setup_db_record() + self._parent_pid = current.pid # type: ignore[assignment] + self._pid = self._create_and_setup_db_record() # pylint: disable=attribute-defined-outside-init @override - def on_entering(self, state): + def on_entering(self, state: plumpy.process_states.State) -> None: super().on_entering(state) # Update the node attributes every time we enter a new state - def on_entered(self, from_state): + def on_entered(self, from_state: Optional[plumpy.process_states.State]) -> None: + """After entering a new state, save a checkpoint and update the latest process state change timestamp.""" # pylint: disable=cyclic-import from aiida.engine.utils import set_process_state_change_timestamp self.update_node_state(self._state) self._save_checkpoint() - # Update the latest process state change timestamp set_process_state_change_timestamp(self) super().on_entered(from_state) @override - def on_terminated(self): + def on_terminated(self) -> None: """Called when a Process enters a terminal state.""" super().on_terminated() if self._enable_persistence: try: + assert self.runner.persister is not None self.runner.persister.delete_checkpoint(self.pid) - except Exception: # pylint: disable=broad-except - self.logger.exception('Failed to delete checkpoint') + except Exception as error: # pylint: disable=broad-except + self.logger.exception('Failed to delete checkpoint: %s', error) try: self.node.seal() @@ -360,7 +405,7 @@ def on_terminated(self): pass @override - def on_except(self, exc_info): + def on_except(self, exc_info: Tuple[Any, Exception, TracebackType]) -> None: """ Log the exception by calling the report method with formatted stack trace from exception info object and store the exception string as a node attribute @@ -372,14 +417,12 @@ def on_except(self, exc_info): self.report(''.join(traceback.format_exception(*exc_info))) @override - def on_finish(self, result, successful): + def on_finish(self, result: Union[int, ExitCode], successful: bool) -> None: """ Set the finish status on the process node. :param result: result of the process - :type result: int or :class:`aiida.engine.ExitCode` - :param successful: whether execution was successful - :type successful: bool + """ super().on_finish(result, successful) @@ -395,23 +438,24 @@ def on_finish(self, result, successful): self.node.set_exit_status(result.status) self.node.set_exit_message(result.message) else: - raise ValueError('the result should be an integer, ExitCode or None, got {} {} {}'.format( - type(result), result, self.pid)) + raise ValueError( + f'the result should be an integer, ExitCode or None, got {type(result)} {result} {self.pid}' + ) @override - def on_paused(self, msg=None): + def on_paused(self, msg: Optional[str] = None) -> None: """ The Process was paused so set the paused attribute on the process node :param msg: message - :type msg: str + """ super().on_paused(msg) self._save_checkpoint() self.node.pause() @override - def on_playing(self): + def on_playing(self) -> None: """ The Process was unpaused so remove the paused attribute on the process node """ @@ -419,14 +463,13 @@ def on_playing(self): self.node.unpause() @override - def on_output_emitting(self, output_port, value): + def on_output_emitting(self, output_port: str, value: Any) -> None: """ The process has emitted a value on the given output port. :param output_port: The output port name the value was emitted on - :type output_port: str - :param value: The value emitted + """ super().on_output_emitting(output_port, value) @@ -434,39 +477,36 @@ def on_output_emitting(self, output_port, value): if isinstance(output_port, OutputPort) and not isinstance(value, orm.Data): raise TypeError(f'Processes can only return `orm.Data` instances as output, got {value.__class__}') - def set_status(self, status): + def set_status(self, status: Optional[str]) -> None: """ The status of the Process is about to be changed, so we reflect this is in node's attribute proxy. :param status: the status message - :type status: str + """ super().set_status(status) self.node.set_process_status(status) - def submit(self, process, *args, **kwargs): + def submit(self, process: Type['Process'], *args, **kwargs) -> orm.ProcessNode: """Submit process for execution. :param process: process - :type process: :class:`aiida.engine.Process` + :return: the calculation node of the process """ return self.runner.submit(process, *args, **kwargs) @property - def runner(self): - """Get process runner. - - :rtype: :class:`aiida.engine.runners.Runner` - """ + def runner(self) -> 'Runner': + """Get process runner.""" return self._runner - def get_parent_calc(self): + def get_parent_calc(self) -> Optional[orm.ProcessNode]: """ Get the parent process node :return: the parent process node if there is one - :rtype: :class:`aiida.orm.ProcessNode` + """ # Can't get it if we don't know our parent if self._parent_pid is None: @@ -475,12 +515,11 @@ def get_parent_calc(self): return orm.load_node(pk=self._parent_pid) @classmethod - def build_process_type(cls): + def build_process_type(cls) -> str: """ The process type. :return: string of the process type - :rtype: str Note: This could be made into a property 'process_type' but in order to have it be a property of the class it would need to be defined in the metaclass, see https://bugs.python.org/issue20659 @@ -499,29 +538,25 @@ def build_process_type(cls): return process_type - def report(self, msg, *args, **kwargs): + def report(self, msg: str, *args, **kwargs) -> None: """Log a message to the logger, which should get saved to the database through the attached DbLogHandler. The pk, class name and function name of the caller are prepended to the given message :param msg: message to log - :type msg: str - :param args: args to pass to the log call - :type args: list - :param kwargs: kwargs to pass to the log call - :type kwargs: dict + """ message = f'[{self.node.pk}|{self.__class__.__name__}|{inspect.stack()[1][3]}]: {msg}' self.logger.log(LOG_LEVEL_REPORT, message, *args, **kwargs) - def _create_and_setup_db_record(self): + def _create_and_setup_db_record(self) -> Union[int, UUID]: """ Create and setup the database record for this process - :return: the uuid of the process - :rtype: :class:`!uuid.UUID` + :return: the uuid or pk of the process + """ self._node = self.get_or_create_db_record() self._setup_db_record() @@ -529,7 +564,7 @@ def _create_and_setup_db_record(self): try: self.node.store_all() if self.node.is_finished_ok: - self._state = ProcessState.FINISHED + self._state = Finished(self, None, True) # pylint: disable=attribute-defined-outside-init for entry in self.node.get_outgoing(link_type=LinkType.RETURN): if entry.link_label.endswith(f'_{entry.node.pk}'): continue @@ -548,35 +583,33 @@ def _create_and_setup_db_record(self): if self.node.pk is not None: return self.node.pk - return uuid.UUID(self.node.uuid) + return UUID(self.node.uuid) @override - def encode_input_args(self, inputs): + def encode_input_args(self, inputs: Dict[str, Any]) -> str: # pylint: disable=no-self-use """ Encode input arguments such that they may be saved in a Bundle :param inputs: A mapping of the inputs as passed to the process :return: The encoded (serialized) inputs """ - from aiida.orm.utils import serialize return serialize.serialize(inputs) @override - def decode_input_args(self, encoded): + def decode_input_args(self, encoded: str) -> Dict[str, Any]: # pylint: disable=no-self-use """ Decode saved input arguments as they came from the saved instance state Bundle :param encoded: encoded (serialized) inputs :return: The decoded input args """ - from aiida.orm.utils import serialize return serialize.deserialize(encoded) - def update_node_state(self, state): + def update_node_state(self, state: plumpy.process_states.State) -> None: self.update_outputs() self.node.set_process_state(state.LABEL) - def update_outputs(self): + def update_outputs(self) -> None: """Attach new outputs to the node since the last call. Does nothing, if self.metadata.store_provenance is False. @@ -600,7 +633,7 @@ def update_outputs(self): output.store() - def _setup_db_record(self): + def _setup_db_record(self) -> None: """ Create the database record for this process and the links with respect to its inputs @@ -637,7 +670,7 @@ def _setup_db_record(self): self._setup_metadata() self._setup_inputs() - def _setup_metadata(self): + def _setup_metadata(self) -> None: """Store the metadata on the ProcessNode.""" version_info = self.runner.plugin_version_provider.get_version_info(self) self.node.set_attribute_many(version_info) @@ -658,7 +691,7 @@ def _setup_metadata(self): else: raise RuntimeError(f'unsupported metadata key: {name}') - def _setup_inputs(self): + def _setup_inputs(self) -> None: """Create the links between the input nodes and the ProcessNode that represents this process.""" for name, node in self._flat_inputs().items(): @@ -677,7 +710,7 @@ def _setup_inputs(self): elif isinstance(self.node, orm.WorkflowNode): self.node.add_incoming(node, LinkType.INPUT_WORK, name) - def _flat_inputs(self): + def _flat_inputs(self) -> Dict[str, Any]: """ Return a flattened version of the parsed inputs dictionary. @@ -685,12 +718,13 @@ def _flat_inputs(self): is not passed, as those are dealt with separately in `_setup_metadata`. :return: flat dictionary of parsed inputs - :rtype: dict + """ + assert self.inputs is not None inputs = {key: value for key, value in self.inputs.items() if key != self.spec().metadata_key} return dict(self._flatten_inputs(self.spec().inputs, inputs)) - def _flat_outputs(self): + def _flat_outputs(self) -> Dict[str, Any]: """ Return a flattened version of the registered outputs dictionary. @@ -700,24 +734,23 @@ def _flat_outputs(self): """ return dict(self._flatten_outputs(self.spec().outputs, self.outputs)) - def _flatten_inputs(self, port, port_value, parent_name='', separator=PORT_NAMESPACE_SEPARATOR): + def _flatten_inputs( + self, + port: Union[None, InputPort, PortNamespace], + port_value: Any, + parent_name: str = '', + separator: str = PORT_NAMESPACE_SEPARATOR + ) -> List[Tuple[str, Any]]: """ Function that will recursively flatten the inputs dictionary, omitting inputs for ports that are marked as being non database storable :param port: port against which to map the port value, can be InputPort or PortNamespace - :type port: :class:`plumpy.ports.Port` - :param port_value: value for the current port, can be a Mapping - :param parent_name: the parent key with which to prefix the keys - :type parent_name: str - :param separator: character to use for the concatenation of keys - :type separator: str - :return: flat list of inputs - :rtype: list + """ if (port is None and isinstance(port_value, orm.Node)) or (isinstance(port, InputPort) and not port.non_db): return [(parent_name, port_value)] @@ -729,36 +762,36 @@ def _flatten_inputs(self, port, port_value, parent_name='', separator=PORT_NAMES prefixed_key = parent_name + separator + name if parent_name else name try: - nested_port = port[name] + nested_port = cast(Union[InputPort, PortNamespace], port[name]) if port else None except (KeyError, TypeError): nested_port = None sub_items = self._flatten_inputs( - port=nested_port, port_value=value, parent_name=prefixed_key, separator=separator) + port=nested_port, port_value=value, parent_name=prefixed_key, separator=separator + ) items.extend(sub_items) return items assert (port is None) or (isinstance(port, InputPort) and port.non_db) return [] - def _flatten_outputs(self, port, port_value, parent_name='', separator=PORT_NAMESPACE_SEPARATOR): + def _flatten_outputs( + self, + port: Union[None, OutputPort, PortNamespace], + port_value: Any, + parent_name: str = '', + separator: str = PORT_NAMESPACE_SEPARATOR + ) -> List[Tuple[str, Any]]: """ Function that will recursively flatten the outputs dictionary. :param port: port against which to map the port value, can be OutputPort or PortNamespace - :type port: :class:`plumpy.ports.Port` - :param port_value: value for the current port, can be a Mapping - :type parent_name: str - :param parent_name: the parent key with which to prefix the keys - :type parent_name: str - :param separator: character to use for the concatenation of keys - :type separator: str :return: flat list of outputs - :rtype: list + """ if port is None and isinstance(port_value, orm.Node) or isinstance(port, OutputPort): return [(parent_name, port_value)] @@ -770,34 +803,34 @@ def _flatten_outputs(self, port, port_value, parent_name='', separator=PORT_NAME prefixed_key = parent_name + separator + name if parent_name else name try: - nested_port = port[name] + nested_port = cast(Union[OutputPort, PortNamespace], port[name]) if port else None except (KeyError, TypeError): nested_port = None sub_items = self._flatten_outputs( - port=nested_port, port_value=value, parent_name=prefixed_key, separator=separator) + port=nested_port, port_value=value, parent_name=prefixed_key, separator=separator + ) items.extend(sub_items) return items assert port is None, port return [] - def exposed_inputs(self, process_class, namespace=None, agglomerate=True): - """ - Gather a dictionary of the inputs that were exposed for a given Process class under an optional namespace. + def exposed_inputs( + self, + process_class: Type['Process'], + namespace: Optional[str] = None, + agglomerate: bool = True + ) -> AttributeDict: + """Gather a dictionary of the inputs that were exposed for a given Process class under an optional namespace. :param process_class: Process class whose inputs to try and retrieve - :type process_class: :class:`aiida.engine.Process` - :param namespace: PortNamespace in which to look for the inputs - :type namespace: str - :param agglomerate: If set to true, all parent namespaces of the given ``namespace`` will also be searched for inputs. Inputs in lower-lying namespaces take precedence. - :type agglomerate: bool :returns: exposed inputs - :rtype: dict + """ exposed_inputs = {} @@ -811,9 +844,9 @@ def exposed_inputs(self, process_class, namespace=None, agglomerate=True): else: inputs = self.inputs for part in sub_namespace.split('.'): - inputs = inputs[part] + inputs = inputs[part] # type: ignore[index] try: - port_namespace = self.spec().inputs.get_port(sub_namespace) + port_namespace = self.spec().inputs.get_port(sub_namespace) # type: ignore[assignment] except KeyError: raise ValueError(f'this process does not contain the "{sub_namespace}" input namespace') @@ -821,26 +854,26 @@ def exposed_inputs(self, process_class, namespace=None, agglomerate=True): exposed_inputs_list = self.spec()._exposed_inputs[sub_namespace][process_class] # pylint: disable=protected-access for name in port_namespace.ports.keys(): - if name in inputs and name in exposed_inputs_list: + if inputs and name in inputs and name in exposed_inputs_list: exposed_inputs[name] = inputs[name] return AttributeDict(exposed_inputs) - def exposed_outputs(self, node, process_class, namespace=None, agglomerate=True): + def exposed_outputs( + self, + node: orm.ProcessNode, + process_class: Type['Process'], + namespace: Optional[str] = None, + agglomerate: bool = True + ) -> AttributeDict: """Return the outputs which were exposed from the ``process_class`` and emitted by the specific ``node`` :param node: process node whose outputs to try and retrieve - :type node: :class:`aiida.orm.nodes.process.ProcessNode` - :param namespace: Namespace in which to search for exposed outputs. - :type namespace: str - :param agglomerate: If set to true, all parent namespaces of the given ``namespace`` will also be searched for outputs. Outputs in lower-lying namespaces take precedence. - :type agglomerate: bool :returns: exposed outputs - :rtype: dict """ namespace_separator = self.spec().namespace_separator @@ -849,9 +882,7 @@ def exposed_outputs(self, node, process_class, namespace=None, agglomerate=True) # maps the exposed name to all outputs that belong to it top_namespace_map = collections.defaultdict(list) link_types = (LinkType.CREATE, LinkType.RETURN) - process_outputs_dict = { - entry.link_label: entry.node for entry in node.get_outgoing(link_type=link_types) - } + process_outputs_dict = {entry.link_label: entry.node for entry in node.get_outgoing(link_type=link_types)} for port_name in process_outputs_dict: top_namespace = port_name.split(namespace_separator)[0] @@ -876,30 +907,27 @@ def exposed_outputs(self, node, process_class, namespace=None, agglomerate=True) return AttributeDict(result) @staticmethod - def _get_namespace_list(namespace=None, agglomerate=True): + def _get_namespace_list(namespace: Optional[str] = None, agglomerate: bool = True) -> List[Optional[str]]: """Get the list of namespaces in a given namespace. :param namespace: name space - :type namespace: str - :param agglomerate: If set to true, all parent namespaces of the given ``namespace`` will also be searched. - :type agglomerate: bool :returns: namespace list - :rtype: list + """ if not agglomerate: return [namespace] - namespace_list = [None] + namespace_list: List[Optional[str]] = [None] if namespace is not None: split_ns = namespace.split('.') namespace_list.extend(['.'.join(split_ns[:i]) for i in range(1, len(split_ns) + 1)]) return namespace_list @classmethod - def is_valid_cache(cls, node): + def is_valid_cache(cls, node: orm.ProcessNode) -> bool: """Check if the given node can be cached from. .. warning :: When overriding this method, make sure to call @@ -915,7 +943,7 @@ def is_valid_cache(cls, node): return True -def get_query_string_from_process_type_string(process_type_string): # pylint: disable=invalid-name +def get_query_string_from_process_type_string(process_type_string: str) -> str: # pylint: disable=invalid-name """ Take the process type string of a Node and create the queryable type string. diff --git a/aiida/engine/processes/process_spec.py b/aiida/engine/processes/process_spec.py index 334a8e0794..4e73005f2a 100644 --- a/aiida/engine/processes/process_spec.py +++ b/aiida/engine/processes/process_spec.py @@ -8,7 +8,11 @@ # For further information please visit http://www.aiida.net # ########################################################################### """AiiDA specific implementation of plumpy's ProcessSpec.""" -import plumpy +from typing import Optional + +import plumpy.process_spec + +from aiida.orm import Dict from .exit_code import ExitCode, ExitCodesNamespace from .ports import InputPort, PortNamespace, CalcJobOutputPort @@ -16,32 +20,32 @@ __all__ = ('ProcessSpec', 'CalcJobProcessSpec') -class ProcessSpec(plumpy.ProcessSpec): +class ProcessSpec(plumpy.process_spec.ProcessSpec): """Default process spec for process classes defined in `aiida-core`. This sub class defines custom classes for input ports and port namespaces. It also adds support for the definition of exit codes and retrieving them subsequently. """ - METADATA_KEY = 'metadata' - METADATA_OPTIONS_KEY = 'options' + METADATA_KEY: str = 'metadata' + METADATA_OPTIONS_KEY: str = 'options' INPUT_PORT_TYPE = InputPort PORT_NAMESPACE_TYPE = PortNamespace - def __init__(self): + def __init__(self) -> None: super().__init__() self._exit_codes = ExitCodesNamespace() @property - def metadata_key(self): + def metadata_key(self) -> str: return self.METADATA_KEY @property - def options_key(self): + def options_key(self) -> str: return self.METADATA_OPTIONS_KEY @property - def exit_codes(self): + def exit_codes(self) -> ExitCodesNamespace: """ Return the namespace of exit codes defined for this ProcessSpec @@ -49,7 +53,7 @@ def exit_codes(self): """ return self._exit_codes - def exit_code(self, status, label, message, invalidates_cache=False): + def exit_code(self, status: int, label: str, message: str, invalidates_cache: bool = False) -> None: """ Add an exit code to the ProcessSpec @@ -76,24 +80,36 @@ def exit_code(self, status, label, message, invalidates_cache=False): self._exit_codes[label] = ExitCode(status, message, invalidates_cache=invalidates_cache) + # override return type to aiida's PortNamespace subclass + + @property + def ports(self) -> PortNamespace: + return super().ports # type: ignore[return-value] + + @property + def inputs(self) -> PortNamespace: + return super().inputs # type: ignore[return-value] + + @property + def outputs(self) -> PortNamespace: + return super().outputs # type: ignore[return-value] + class CalcJobProcessSpec(ProcessSpec): """Process spec intended for the `CalcJob` process class.""" OUTPUT_PORT_TYPE = CalcJobOutputPort - def __init__(self): + def __init__(self) -> None: super().__init__() - self._default_output_node = None + self._default_output_node: Optional[str] = None @property - def default_output_node(self): + def default_output_node(self) -> Optional[str]: return self._default_output_node @default_output_node.setter - def default_output_node(self, port_name): - from aiida.orm import Dict - + def default_output_node(self, port_name: str) -> None: if port_name not in self.outputs: raise ValueError(f'{port_name} is not a registered output port') diff --git a/aiida/engine/processes/workchains/__init__.py b/aiida/engine/processes/workchains/__init__.py index bea66d0a5b..9b0cf508c9 100644 --- a/aiida/engine/processes/workchains/__init__.py +++ b/aiida/engine/processes/workchains/__init__.py @@ -14,4 +14,4 @@ from .utils import * from .workchain import * -__all__ = (context.__all__ + restart.__all__ + utils.__all__ + workchain.__all__) +__all__ = (context.__all__ + restart.__all__ + utils.__all__ + workchain.__all__) # type: ignore[name-defined] diff --git a/aiida/engine/processes/workchains/awaitable.py b/aiida/engine/processes/workchains/awaitable.py index fee97be995..ea8954ae92 100644 --- a/aiida/engine/processes/workchains/awaitable.py +++ b/aiida/engine/processes/workchains/awaitable.py @@ -9,6 +9,7 @@ ########################################################################### """Enums and function for the awaitables of Processes.""" from enum import Enum +from typing import Union from plumpy.utils import AttributesDict from aiida.orm import ProcessNode @@ -31,7 +32,7 @@ class AwaitableAction(Enum): APPEND = 'append' -def construct_awaitable(target): +def construct_awaitable(target: Union[Awaitable, ProcessNode]) -> Awaitable: """ Construct an instance of the Awaitable class that will contain the information related to the action to be taken with respect to the context once the awaitable diff --git a/aiida/engine/processes/workchains/context.py b/aiida/engine/processes/workchains/context.py index c0c9f31bb4..a22bc0cc02 100644 --- a/aiida/engine/processes/workchains/context.py +++ b/aiida/engine/processes/workchains/context.py @@ -8,14 +8,17 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Convenience functions to add awaitables to the Context of a WorkChain.""" -from .awaitable import construct_awaitable, AwaitableAction +from typing import Union + +from aiida.orm import ProcessNode +from .awaitable import construct_awaitable, Awaitable, AwaitableAction __all__ = ('ToContext', 'assign_', 'append_') ToContext = dict -def assign_(target): +def assign_(target: Union[Awaitable, ProcessNode]) -> Awaitable: """ Convenience function that will construct an Awaitable for a given class instance with the context action set to ASSIGN. When the awaitable target is completed @@ -24,14 +27,14 @@ def assign_(target): :param target: an instance of a Process or Awaitable :returns: the awaitable - :rtype: Awaitable + """ awaitable = construct_awaitable(target) awaitable.action = AwaitableAction.ASSIGN return awaitable -def append_(target): +def append_(target: Union[Awaitable, ProcessNode]) -> Awaitable: """ Convenience function that will construct an Awaitable for a given class instance with the context action set to APPEND. When the awaitable target is completed @@ -40,7 +43,7 @@ def append_(target): :param target: an instance of a Process or Awaitable :returns: the awaitable - :rtype: Awaitable + """ awaitable = construct_awaitable(target) awaitable.action = AwaitableAction.APPEND diff --git a/aiida/engine/processes/workchains/restart.py b/aiida/engine/processes/workchains/restart.py index 7bf5d368bd..5719e1496f 100644 --- a/aiida/engine/processes/workchains/restart.py +++ b/aiida/engine/processes/workchains/restart.py @@ -9,6 +9,9 @@ ########################################################################### """Base implementation of `WorkChain` class that implements a simple automated restart mechanism for sub processes.""" import functools +from inspect import getmembers +from types import FunctionType +from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING from aiida import orm from aiida.common import AttributeDict @@ -17,10 +20,17 @@ from .workchain import WorkChain from .utils import ProcessHandlerReport, process_handler +if TYPE_CHECKING: + from aiida.engine.processes import ExitCode, PortNamespace, Process, ProcessSpec + __all__ = ('BaseRestartWorkChain',) -def validate_handler_overrides(process_class, handler_overrides, ctx): # pylint: disable=unused-argument +def validate_handler_overrides( + process_class: 'BaseRestartWorkChain', + handler_overrides: Optional[orm.Dict], + ctx: 'PortNamespace' # pylint: disable=unused-argument +) -> Optional[str]: """Validator for the `handler_overrides` input port of the `BaseRestartWorkChain. The `handler_overrides` should be a dictionary where keys are strings that are the name of a process handler, i.e. a @@ -36,7 +46,7 @@ def validate_handler_overrides(process_class, handler_overrides, ctx): # pylint :param ctx: the `PortNamespace` in which the port is embedded """ if not handler_overrides: - return + return None for handler, override in handler_overrides.get_dict().items(): if not isinstance(handler, str): @@ -48,6 +58,8 @@ def validate_handler_overrides(process_class, handler_overrides, ctx): # pylint if not isinstance(override, bool): return f'The value of key `{handler}` is not a boolean.' + return None + class BaseRestartWorkChain(WorkChain): """Base restart work chain. @@ -101,11 +113,19 @@ def handle_problem(self, node): `inspect_process`. Refer to their respective documentation for details. """ - _process_class = None + _process_class: Optional[Type['Process']] = None _considered_handlers_extra = 'considered_handlers' + @property + def process_class(self) -> Type['Process']: + """Return the process class to run in the loop.""" + from ..process import Process # pylint: disable=cyclic-import + if self._process_class is None or not issubclass(self._process_class, Process): + raise ValueError('no valid Process class defined for `_process_class` attribute') + return self._process_class + @classmethod - def define(cls, spec): + def define(cls, spec: 'ProcessSpec') -> None: # type: ignore[override] """Define the process specification.""" # yapf: disable super().define(spec) @@ -126,25 +146,28 @@ def define(cls, spec): message='The maximum number of iterations was exceeded.') spec.exit_code(402, 'ERROR_SECOND_CONSECUTIVE_UNHANDLED_FAILURE', message='The process failed for an unknown reason, twice in a row.') + # yapf: enable - def setup(self): + def setup(self) -> None: """Initialize context variables that are used during the logical flow of the `BaseRestartWorkChain`.""" - overrides = self.inputs.handler_overrides.get_dict() if 'handler_overrides' in self.inputs else {} + overrides = self.inputs.handler_overrides.get_dict() if (self.inputs and + 'handler_overrides' in self.inputs) else {} self.ctx.handler_overrides = overrides - self.ctx.process_name = self._process_class.__name__ + self.ctx.process_name = self.process_class.__name__ self.ctx.unhandled_failure = False self.ctx.is_finished = False self.ctx.iteration = 0 - def should_run_process(self): + def should_run_process(self) -> bool: """Return whether a new process should be run. This is the case as long as the last process has not finished successfully and the maximum number of restarts has not yet been exceeded. """ - return not self.ctx.is_finished and self.ctx.iteration < self.inputs.max_iterations.value + max_iterations = self.inputs.max_iterations.value # type: ignore[union-attr] + return not self.ctx.is_finished and self.ctx.iteration < max_iterations - def run_process(self): + def run_process(self) -> ToContext: """Run the next process, taking the input dictionary from the context at `self.ctx.inputs`.""" self.ctx.iteration += 1 @@ -156,8 +179,8 @@ def run_process(self): # Set the `CALL` link label unwrapped_inputs.setdefault('metadata', {})['call_link_label'] = f'iteration_{self.ctx.iteration:02d}' - inputs = self._wrap_bare_dict_inputs(self._process_class.spec().inputs, unwrapped_inputs) - node = self.submit(self._process_class, **inputs) + inputs = self._wrap_bare_dict_inputs(self.process_class.spec().inputs, unwrapped_inputs) + node = self.submit(self.process_class, **inputs) # Add a new empty list to the `BaseRestartWorkChain._considered_handlers_extra` extra. This will contain the # name and return value of all class methods, decorated with `process_handler`, that are called during @@ -170,7 +193,7 @@ def run_process(self): return ToContext(children=append_(node)) - def inspect_process(self): # pylint: disable=too-many-branches + def inspect_process(self) -> Optional['ExitCode']: # pylint: disable=too-many-branches """Analyse the results of the previous process and call the handlers when necessary. If the process is excepted or killed, the work chain will abort. Otherwise any attached handlers will be called @@ -202,10 +225,11 @@ def inspect_process(self): # pylint: disable=too-many-branches last_report = None # Sort the handlers with a priority defined, based on their priority in reverse order - for handler in sorted(self.get_process_handlers(), key=lambda handler: handler.priority, reverse=True): + get_priority = lambda handler: handler.priority + for handler in sorted(self.get_process_handlers(), key=get_priority, reverse=True): # Skip if the handler is enabled, either explicitly through `handler_overrides` or by default - if not self.ctx.handler_overrides.get(handler.__name__, handler.enabled): + if not self.ctx.handler_overrides.get(handler.__name__, handler.enabled): # type: ignore[attr-defined] continue # Even though the `handler` is an instance method, the `get_process_handlers` method returns unbound methods @@ -236,7 +260,7 @@ def inspect_process(self): # pylint: disable=too-many-branches self.ctx.unhandled_failure = True self.report('{}<{}> failed and error was not handled, restarting once more'.format(*report_args)) - return + return None # Here either the process finished successful or at least one handler returned a report so it can no longer be # considered to be an unhandled failed process and therefore we reset the flag @@ -260,16 +284,21 @@ def inspect_process(self): # pylint: disable=too-many-branches # Otherwise the process was successful and no handler returned anything so we consider the work done self.ctx.is_finished = True - def results(self): + return None + + def results(self) -> Optional['ExitCode']: """Attach the outputs specified in the output specification from the last completed process.""" node = self.ctx.children[self.ctx.iteration - 1] # We check the `is_finished` attribute of the work chain and not the successfulness of the last process # because the error handlers in the last iteration can have qualified a "failed" process as satisfactory # for the outcome of the work chain and so have marked it as `is_finished=True`. - if not self.ctx.is_finished and self.ctx.iteration >= self.inputs.max_iterations.value: - self.report('reached the maximum number of iterations {}: last ran {}<{}>'.format( - self.inputs.max_iterations.value, self.ctx.process_name, node.pk)) + max_iterations = self.inputs.max_iterations.value # type: ignore[union-attr] + if not self.ctx.is_finished and self.ctx.iteration >= max_iterations: + self.report( + f'reached the maximum number of iterations {max_iterations}: ' + f'last ran {self.ctx.process_name}<{node.pk}>' + ) return self.exit_codes.ERROR_MAXIMUM_ITERATIONS_EXCEEDED # pylint: disable=no-member self.report(f'work chain completed after {self.ctx.iteration} iterations') @@ -284,16 +313,17 @@ def results(self): else: self.out(name, output) - def __init__(self, *args, **kwargs): + return None + + def __init__(self, *args, **kwargs) -> None: """Construct the instance.""" - from ..process import Process # pylint: disable=cyclic-import super().__init__(*args, **kwargs) - if self._process_class is None or not issubclass(self._process_class, Process): - raise ValueError('no valid Process class defined for `_process_class` attribute') + # try retrieving process class + self.process_class # pylint: disable=pointless-statement @classmethod - def is_process_handler(cls, process_handler_name): + def is_process_handler(cls, process_handler_name: Union[str, FunctionType]) -> bool: """Return whether the given method name corresponds to a process handler of this class. :param process_handler_name: string name of the instance method @@ -308,15 +338,14 @@ def is_process_handler(cls, process_handler_name): return getattr(handler, 'decorator', None) == process_handler @classmethod - def get_process_handlers(cls): - from inspect import getmembers + def get_process_handlers(cls) -> List[FunctionType]: return [method[1] for method in getmembers(cls) if cls.is_process_handler(method[1])] def on_terminated(self): """Clean the working directories of all child calculation jobs if `clean_workdir=True` in the inputs.""" super().on_terminated() - if self.inputs.clean_workdir.value is False: + if self.inputs.clean_workdir.value is False: # type: ignore[union-attr] self.report('remote folders will not be cleaned') return @@ -333,7 +362,7 @@ def on_terminated(self): if cleaned_calcs: self.report(f"cleaned remote folders of calculations: {' '.join(cleaned_calcs)}") - def _wrap_bare_dict_inputs(self, port_namespace, inputs): + def _wrap_bare_dict_inputs(self, port_namespace: 'PortNamespace', inputs: Dict[str, Any]) -> AttributeDict: """Wrap bare dictionaries in `inputs` in a `Dict` node if dictated by the corresponding inputs portnamespace. :param port_namespace: a `PortNamespace` diff --git a/aiida/engine/processes/workchains/utils.py b/aiida/engine/processes/workchains/utils.py index 53dceb3a60..b25f15de20 100644 --- a/aiida/engine/processes/workchains/utils.py +++ b/aiida/engine/processes/workchains/utils.py @@ -12,6 +12,7 @@ from functools import partial from inspect import getfullargspec from types import FunctionType # pylint: disable=no-name-in-module +from typing import List, Optional, Union from wrapt import decorator from ..exit_code import ExitCode @@ -19,7 +20,7 @@ __all__ = ('ProcessHandlerReport', 'process_handler') ProcessHandlerReport = namedtuple('ProcessHandlerReport', 'do_break exit_code') -ProcessHandlerReport.__new__.__defaults__ = (False, ExitCode()) +ProcessHandlerReport.__new__.__defaults__ = (False, ExitCode()) # type: ignore[attr-defined,call-arg] """A namedtuple to define a process handler report for a :class:`aiida.engine.BaseRestartWorkChain`. This namedtuple should be returned by a process handler of a work chain instance if the condition of the handler was @@ -36,7 +37,13 @@ """ -def process_handler(wrapped=None, *, priority=0, exit_codes=None, enabled=True): +def process_handler( + wrapped: Optional[FunctionType] = None, + *, + priority: int = 0, + exit_codes: Union[None, ExitCode, List[ExitCode]] = None, + enabled: bool = True +) -> FunctionType: """Decorator to register a :class:`~aiida.engine.BaseRestartWorkChain` instance method as a process handler. The decorator will validate the `priority` and `exit_codes` optional keyword arguments and then add itself as an @@ -55,7 +62,7 @@ def process_handler(wrapped=None, *, priority=0, exit_codes=None, enabled=True): `do_break` attribute should be set to `True`. If the work chain is to be aborted entirely, the `exit_code` of the report can be set to an `ExitCode` instance with a non-zero status. - :param cls: the work chain class to register the process handler with + :param wrapped: the work chain method to register the process handler with :param priority: optional integer that defines the order in which registered handlers will be called during the handling of a finished process. Higher priorities will be handled first. Default value is `0`. Multiple handlers with the same priority is allowed, but the order of those is not well defined. @@ -67,7 +74,9 @@ def process_handler(wrapped=None, *, priority=0, exit_codes=None, enabled=True): basis through the input `handler_overrides`. """ if wrapped is None: - return partial(process_handler, priority=priority, exit_codes=exit_codes, enabled=enabled) + return partial( + process_handler, priority=priority, exit_codes=exit_codes, enabled=enabled + ) # type: ignore[return-value] if not isinstance(wrapped, FunctionType): raise TypeError('first argument can only be an instance method, use keywords for decorator arguments.') @@ -89,9 +98,9 @@ def process_handler(wrapped=None, *, priority=0, exit_codes=None, enabled=True): if len(handler_args) != 2: raise TypeError(f'process handler `{wrapped.__name__}` has invalid signature: should be (self, node)') - wrapped.decorator = process_handler - wrapped.priority = priority - wrapped.enabled = enabled + wrapped.decorator = process_handler # type: ignore[attr-defined] + wrapped.priority = priority # type: ignore[attr-defined] + wrapped.enabled = enabled # type: ignore[attr-defined] @decorator def wrapper(wrapped, instance, args, kwargs): diff --git a/aiida/engine/processes/workchains/workchain.py b/aiida/engine/processes/workchains/workchain.py index 00f0f479f2..698ad9de44 100644 --- a/aiida/engine/processes/workchains/workchain.py +++ b/aiida/engine/processes/workchains/workchain.py @@ -10,15 +10,17 @@ """Components for the WorkChain concept of the workflow engine.""" import collections import functools +import logging +from typing import Any, List, Optional, Sequence, Union, TYPE_CHECKING -import plumpy -from plumpy import auto_persist, Wait, Continue -from plumpy.workchains import if_, while_, return_, _PropagateReturn +from plumpy.persistence import auto_persist +from plumpy.process_states import Wait, Continue +from plumpy.workchains import if_, while_, return_, _PropagateReturn, Stepper, WorkChainSpec as PlumpyWorkChainSpec from aiida.common import exceptions from aiida.common.extendeddicts import AttributeDict from aiida.common.lang import override -from aiida.orm import Node, WorkChainNode +from aiida.orm import Node, ProcessNode, WorkChainNode from aiida.orm.utils import load_node from ..exit_code import ExitCode @@ -26,10 +28,13 @@ from ..process import Process, ProcessState from .awaitable import Awaitable, AwaitableTarget, AwaitableAction, construct_awaitable +if TYPE_CHECKING: + from aiida.engine.runners import Runner + __all__ = ('WorkChain', 'if_', 'while_', 'return_') -class WorkChainSpec(ProcessSpec, plumpy.WorkChainSpec): +class WorkChainSpec(ProcessSpec, PlumpyWorkChainSpec): pass @@ -42,22 +47,21 @@ class WorkChain(Process): _STEPPER_STATE = 'stepper_state' _CONTEXT = 'CONTEXT' - def __init__(self, inputs=None, logger=None, runner=None, enable_persistence=True): + def __init__( + self, + inputs: Optional[dict] = None, + logger: Optional[logging.Logger] = None, + runner: Optional['Runner'] = None, + enable_persistence: bool = True + ) -> None: """Construct a WorkChain instance. Construct the instance only if it is a sub class of `WorkChain`, otherwise raise `InvalidOperation`. :param inputs: work chain inputs - :type inputs: dict - :param logger: aiida logger - :type logger: :class:`logging.Logger` - :param runner: work chain runner - :type: :class:`aiida.engine.runners.Runner` - :param enable_persistence: whether to persist this work chain - :type enable_persistence: bool """ if self.__class__ == WorkChain: @@ -65,21 +69,22 @@ def __init__(self, inputs=None, logger=None, runner=None, enable_persistence=Tru super().__init__(inputs, logger, runner, enable_persistence=enable_persistence) - self._stepper = None - self._awaitables = [] + self._stepper: Optional[Stepper] = None + self._awaitables: List[Awaitable] = [] self._context = AttributeDict() - @property - def ctx(self): - """Get context. + @classmethod + def spec(cls) -> WorkChainSpec: + return super().spec() # type: ignore[return-value] - :rtype: :class:`aiida.common.extendeddicts.AttributeDict` - """ + @property + def ctx(self) -> AttributeDict: + """Get the context.""" return self._context @override def save_instance_state(self, out_state, save_context): - """Save instance stace. + """Save instance state. :param out_state: state to save in @@ -105,7 +110,7 @@ def load_instance_state(self, saved_state, load_context): self._stepper = None stepper_state = saved_state.get(self._STEPPER_STATE, None) if stepper_state is not None: - self._stepper = self.spec().get_outline().recreate_stepper(stepper_state, self) + self._stepper = self.spec().get_outline().recreate_stepper(stepper_state, self) # type: ignore[arg-type] self.set_logger(self.node.logger) @@ -116,7 +121,7 @@ def on_run(self): super().on_run() self.node.set_stepper_state_info(str(self._stepper)) - def insert_awaitable(self, awaitable): + def insert_awaitable(self, awaitable: Awaitable) -> None: """Insert an awaitable that should be terminated before before continuing to the next step. :param awaitable: the thing to await @@ -137,7 +142,7 @@ def insert_awaitable(self, awaitable): self._update_process_status() - def resolve_awaitable(self, awaitable, value): + def resolve_awaitable(self, awaitable: Awaitable, value: Any) -> None: """Resolve an awaitable. Precondition: must be an awaitable that was previously inserted. @@ -164,7 +169,7 @@ def resolve_awaitable(self, awaitable, value): self._update_process_status() - def to_context(self, **kwargs): + def to_context(self, **kwargs: Union[Awaitable, ProcessNode]) -> None: """Add a dictionary of awaitables to the context. This is a convenience method that provides syntactic sugar, for a user to add multiple intersteps that will @@ -175,7 +180,7 @@ def to_context(self, **kwargs): awaitable.key = key self.insert_awaitable(awaitable) - def _update_process_status(self): + def _update_process_status(self) -> None: """Set the process status with a message accounting the current sub processes that we are waiting for.""" if self._awaitables: status = f"Waiting for child processes: {', '.join([str(_.pk) for _ in self._awaitables])}" @@ -184,11 +189,11 @@ def _update_process_status(self): self.node.set_process_status(None) @override - def run(self): - self._stepper = self.spec().get_outline().create_stepper(self) + def run(self) -> Any: + self._stepper = self.spec().get_outline().create_stepper(self) # type: ignore[arg-type] return self._do_step() - def _do_step(self): + def _do_step(self) -> Any: """Execute the next step in the outline and return the result. If the stepper returns a non-finished status and the return value is of type ToContext, the contents of the @@ -199,16 +204,17 @@ def _do_step(self): from .context import ToContext self._awaitables = [] - result = None + result: Any = None try: + assert self._stepper is not None finished, stepper_result = self._stepper.step() except _PropagateReturn as exception: finished, result = True, exception.exit_code else: # Set result to None unless stepper_result was non-zero positive integer or ExitCode with similar status if isinstance(stepper_result, int) and stepper_result > 0: - result = ExitCode(stepper_result) + result = ExitCode(stepper_result) # type: ignore[call-arg] elif isinstance(stepper_result, ExitCode) and stepper_result.status > 0: result = stepper_result else: @@ -226,7 +232,7 @@ def _do_step(self): return Continue(self._do_step) - def _store_nodes(self, data): + def _store_nodes(self, data: Any) -> None: """Recurse through a data structure and store any unstored nodes that are found along the way :param data: a data structure potentially containing unstored nodes @@ -241,7 +247,7 @@ def _store_nodes(self, data): self._store_nodes(value) @override - def on_exiting(self): + def on_exiting(self) -> None: """Ensure that any unstored nodes in the context are stored, before the state is exited After the state is exited the next state will be entered and if persistence is enabled, a checkpoint will @@ -254,14 +260,15 @@ def on_exiting(self): # An uncaught exception here will have bizarre and disastrous consequences self.logger.exception('exception in _store_nodes called in on_exiting') - def on_wait(self, awaitables): + def on_wait(self, awaitables: Sequence[Awaitable]): + """Entering the WAITING state.""" super().on_wait(awaitables) if self._awaitables: self.action_awaitables() else: self.call_soon(self.resume) - def action_awaitables(self): + def action_awaitables(self) -> None: """Handle the awaitables that are currently registered with the work chain. Depending on the class type of the awaitable's target a different callback @@ -275,7 +282,7 @@ def action_awaitables(self): else: assert f"invalid awaitable target '{awaitable.target}'" - def on_process_finished(self, awaitable): + def on_process_finished(self, awaitable: Awaitable) -> None: """Callback function called by the runner when the process instance identified by pk is completed. The awaitable will be effectuated on the context of the work chain and removed from the internal list. If all diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index be2a3d377b..7708c851b5 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -9,23 +9,25 @@ ########################################################################### # pylint: disable=global-statement """Runners that can run and submit processes.""" -import collections +import asyncio import functools import logging import signal import threading +from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Type, Union import uuid -import asyncio import kiwipy -import plumpy -from plumpy import set_event_loop_policy, reset_event_loop_policy +from plumpy.persistence import Persister +from plumpy.process_comms import RemoteProcessThreadController +from plumpy.events import set_event_loop_policy, reset_event_loop_policy +from plumpy.communications import wrap_communicator from aiida.common import exceptions -from aiida.orm import load_node +from aiida.orm import load_node, ProcessNode from aiida.plugins.utils import PluginVersionProvider -from .processes import futures, ProcessState +from .processes import futures, Process, ProcessBuilder, ProcessState from .processes.calcjobs import manager from . import transports from . import utils @@ -34,28 +36,46 @@ LOGGER = logging.getLogger(__name__) -ResultAndNode = collections.namedtuple('ResultAndNode', ['result', 'node']) -ResultAndPk = collections.namedtuple('ResultAndPk', ['result', 'pk']) + +class ResultAndNode(NamedTuple): + node: ProcessNode + result: Dict[str, Any] + + +class ResultAndPk(NamedTuple): + node: ProcessNode + pk: int + + +TYPE_RUN_PROCESS = Union[Process, Type[Process], ProcessBuilder] # pylint: disable=invalid-name +# run can also be process function, but it is not clear what type this should be +TYPE_SUBMIT_PROCESS = Union[Process, Type[Process], ProcessBuilder] # pylint: disable=invalid-name class Runner: # pylint: disable=too-many-public-methods """Class that can launch processes by running in the current interpreter or by submitting them to the daemon.""" - _persister = None - _communicator = None - _controller = None - _closed = False - - def __init__(self, poll_interval=0, loop=None, communicator=None, rmq_submit=False, persister=None): + _persister: Optional[Persister] = None + _communicator: Optional[kiwipy.Communicator] = None + _controller: Optional[RemoteProcessThreadController] = None + _closed: bool = False + + def __init__( + self, + poll_interval: Union[int, float] = 0, + loop: Optional[asyncio.AbstractEventLoop] = None, + communicator: Optional[kiwipy.Communicator] = None, + rmq_submit: bool = False, + persister: Optional[Persister] = None + ): """Construct a new runner. :param poll_interval: interval in seconds between polling for status of active sub processes :param loop: an asyncio event loop, if none is suppled a new one will be created :param communicator: the communicator to use - :type communicator: :class:`kiwipy.Communicator` :param rmq_submit: if True, processes will be submitted to RabbitMQ, otherwise they will be scheduled here :param persister: the persister to use to persist processes - :type persister: :class:`plumpy.Persister` + """ assert not (rmq_submit and persister is None), \ 'Must supply a persister if you want to submit using communicator' @@ -70,94 +90,86 @@ def __init__(self, poll_interval=0, loop=None, communicator=None, rmq_submit=Fal self._plugin_version_provider = PluginVersionProvider() if communicator is not None: - self._communicator = plumpy.wrap_communicator(communicator, self._loop) - self._controller = plumpy.RemoteProcessThreadController(communicator) + self._communicator = wrap_communicator(communicator, self._loop) + self._controller = RemoteProcessThreadController(communicator) elif self._rmq_submit: LOGGER.warning('Disabling RabbitMQ submission, no communicator provided') self._rmq_submit = False - def __enter__(self): + def __enter__(self) -> 'Runner': return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() @property - def loop(self): - """ - Get the event loop of this runner - - :return: the asyncio event loop - """ + def loop(self) -> asyncio.AbstractEventLoop: + """Get the event loop of this runner.""" return self._loop @property - def transport(self): + def transport(self) -> transports.TransportQueue: return self._transport @property - def persister(self): + def persister(self) -> Optional[Persister]: + """Get the persister used by this runner.""" return self._persister @property - def communicator(self): - """ - Get the communicator used by this runner - - :return: the communicator - :rtype: :class:`kiwipy.Communicator` - """ + def communicator(self) -> Optional[kiwipy.Communicator]: + """Get the communicator used by this runner.""" return self._communicator @property - def plugin_version_provider(self): + def plugin_version_provider(self) -> PluginVersionProvider: return self._plugin_version_provider @property - def job_manager(self): + def job_manager(self) -> manager.JobManager: return self._job_manager @property - def controller(self): + def controller(self) -> Optional[RemoteProcessThreadController]: + """Get the controller used by this runner.""" return self._controller @property - def is_daemon_runner(self): + def is_daemon_runner(self) -> bool: """Return whether the runner is a daemon runner, which means it submits processes over RabbitMQ. :return: True if the runner is a daemon runner - :rtype: bool """ return self._rmq_submit - def is_closed(self): + def is_closed(self) -> bool: return self._closed - def start(self): + def start(self) -> None: """Start the internal event loop.""" self._loop.run_forever() - def stop(self): + def stop(self) -> None: """Stop the internal event loop.""" self._loop.stop() - def run_until_complete(self, future): + 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) - def close(self): + def close(self) -> None: """Close the runner by stopping the loop.""" assert not self._closed self.stop() reset_event_loop_policy() self._closed = True - def instantiate_process(self, process, *args, **inputs): + def instantiate_process(self, process: TYPE_RUN_PROCESS, *args, **inputs): from .utils import instantiate_process return instantiate_process(self, process, *args, **inputs) - def submit(self, process, *args, **inputs): + def submit(self, process: TYPE_SUBMIT_PROCESS, *args: Any, **inputs: Any): """ Submit the process with the supplied inputs to this runner immediately returning control to the interpreter. The return value will be the calculation node of the submitted process @@ -169,24 +181,26 @@ def submit(self, process, *args, **inputs): assert not utils.is_process_function(process), 'Cannot submit a process function' assert not self._closed - process = self.instantiate_process(process, *args, **inputs) + process_inited = self.instantiate_process(process, *args, **inputs) - if not process.metadata.store_provenance: + if not process_inited.metadata.store_provenance: raise exceptions.InvalidOperation('cannot submit a process with `store_provenance=False`') - if process.metadata.get('dry_run', False): + if process_inited.metadata.get('dry_run', False): raise exceptions.InvalidOperation('cannot submit a process from within another with `dry_run=True`') if self._rmq_submit: - self.persister.save_checkpoint(process) - process.close() - self.controller.continue_process(process.pid, nowait=False, no_reply=True) + assert self.persister is not None, 'runner does not have a persister' + assert self.controller is not None, 'runner does not have a controller' + self.persister.save_checkpoint(process_inited) + process_inited.close() + self.controller.continue_process(process_inited.pid, nowait=False, no_reply=True) else: - self.loop.create_task(process.step_until_terminated()) + self.loop.create_task(process_inited.step_until_terminated()) - return process.node + return process_inited.node - def schedule(self, process, *args, **inputs): + def schedule(self, process: TYPE_SUBMIT_PROCESS, *args: Any, **inputs: Any) -> ProcessNode: """ Schedule a process to be executed by this runner @@ -197,11 +211,11 @@ def schedule(self, process, *args, **inputs): assert not utils.is_process_function(process), 'Cannot submit a process function' assert not self._closed - process = self.instantiate_process(process, *args, **inputs) - self.loop.create_task(process.step_until_terminated()) - return process.node + process_inited = self.instantiate_process(process, *args, **inputs) + self.loop.create_task(process_inited.step_until_terminated()) + return process_inited.node - def _run(self, process, *args, **inputs): + def _run(self, process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Tuple[Dict[str, Any], ProcessNode]: """ Run the process with the supplied inputs in this runner that will block until the process is completed. The return value will be the results of the completed process @@ -213,24 +227,24 @@ def _run(self, process, *args, **inputs): assert not self._closed if utils.is_process_function(process): - result, node = process.run_get_node(*args, **inputs) + result, node = process.run_get_node(*args, **inputs) # type: ignore[union-attr] return result, node with utils.loop_scope(self.loop): - process = self.instantiate_process(process, *args, **inputs) + process_inited = self.instantiate_process(process, *args, **inputs) def kill_process(_num, _frame): """Send the kill signal to the process in the current scope.""" - LOGGER.critical('runner received interrupt, killing process %s', process.pid) - process.kill(msg='Process was killed because the runner received an interrupt') + LOGGER.critical('runner received interrupt, killing process %s', process_inited.pid) + process_inited.kill(msg='Process was killed because the runner received an interrupt') signal.signal(signal.SIGINT, kill_process) signal.signal(signal.SIGTERM, kill_process) - process.execute() - return process.outputs, process.node + process_inited.execute() + return process_inited.outputs, process_inited.node - def run(self, process, *args, **inputs): + def run(self, process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Dict[str, Any]: """ Run the process with the supplied inputs in this runner that will block until the process is completed. The return value will be the results of the completed process @@ -242,7 +256,7 @@ def run(self, process, *args, **inputs): result, _ = self._run(process, *args, **inputs) return result - def run_get_node(self, process, *args, **inputs): + def run_get_node(self, process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> ResultAndNode: """ Run the process with the supplied inputs in this runner that will block until the process is completed. The return value will be the results of the completed process @@ -254,7 +268,7 @@ def run_get_node(self, process, *args, **inputs): result, node = self._run(process, *args, **inputs) return ResultAndNode(result, node) - def run_get_pk(self, process, *args, **inputs): + def run_get_pk(self, process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> ResultAndPk: """ Run the process with the supplied inputs in this runner that will block until the process is completed. The return value will be the results of the completed process @@ -266,7 +280,7 @@ def run_get_pk(self, process, *args, **inputs): result, node = self._run(process, *args, **inputs) return ResultAndPk(result, node.pk) - def call_on_process_finish(self, pk, callback): + def call_on_process_finish(self, pk: int, callback: Callable[[], Any]) -> None: """Schedule a callback when the process of the given pk is terminated. This method will add a broadcast subscriber that will listen for state changes of the target process to be @@ -276,6 +290,8 @@ def call_on_process_finish(self, pk, callback): :param pk: pk of the process :param callback: function to be called upon process termination """ + assert self.communicator is not None, 'communicator not set for runner' + node = load_node(pk=pk) subscriber_identifier = str(uuid.uuid4()) event = threading.Event() @@ -293,17 +309,17 @@ def inline_callback(event, *args, **kwargs): # pylint: disable=unused-argument callback() finally: event.set() - self._communicator.remove_broadcast_subscriber(subscriber_identifier) + self.communicator.remove_broadcast_subscriber(subscriber_identifier) # type: ignore[union-attr] broadcast_filter = kiwipy.BroadcastFilter(functools.partial(inline_callback, event), sender=pk) for state in [ProcessState.FINISHED, ProcessState.KILLED, ProcessState.EXCEPTED]: broadcast_filter.add_subject_filter(f'state_changed.*.{state.value}') LOGGER.info('adding subscriber for broadcasts of %d', pk) - self._communicator.add_broadcast_subscriber(broadcast_filter, subscriber_identifier) + self.communicator.add_broadcast_subscriber(broadcast_filter, subscriber_identifier) self._poll_process(node, functools.partial(inline_callback, event)) - def get_process_future(self, pk): + def get_process_future(self, pk: int) -> futures.ProcessFuture: """Return a future for a process. The future will have the process node as the result when finished. diff --git a/aiida/engine/transports.py b/aiida/engine/transports.py index be028adb4f..8cd0204d40 100644 --- a/aiida/engine/transports.py +++ b/aiida/engine/transports.py @@ -12,8 +12,12 @@ import contextlib import logging import traceback +from typing import Awaitable, Dict, Hashable, Iterator, Optional import asyncio +from aiida.orm import AuthInfo +from aiida.transports import Transport + _LOGGER = logging.getLogger(__name__) @@ -22,7 +26,7 @@ class TransportRequest: def __init__(self): super().__init__() - self.future = asyncio.Future() + self.future: asyncio.Future = asyncio.Future() self.count = 0 @@ -39,20 +43,20 @@ class TransportQueue: """ AuthInfoEntry = namedtuple('AuthInfoEntry', ['authinfo', 'transport', 'callbacks', 'callback_handle']) - def __init__(self, loop=None): + 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() - self._transport_requests = {} + self._transport_requests: Dict[Hashable, TransportRequest] = {} @property - def loop(self): + def loop(self) -> asyncio.AbstractEventLoop: """ Get the loop being used by this transport queue """ return self._loop @contextlib.contextmanager - def request_transport(self, authinfo): + def request_transport(self, authinfo: AuthInfo) -> Iterator[Awaitable[Transport]]: """ Request a transport from an authinfo. Because the client is not allowed to request a transport immediately they will instead be given back a future @@ -79,7 +83,7 @@ async def transport_task(transport_queue, authinfo): def do_open(): """ Actually open the transport """ - if transport_request.count > 0: + if transport_request and transport_request.count > 0: # The user still wants the transport so open it _LOGGER.debug('Transport request opening transport for %s', authinfo) try: diff --git a/aiida/engine/utils.py b/aiida/engine/utils.py index 5130903966..d76d55443e 100644 --- a/aiida/engine/utils.py +++ b/aiida/engine/utils.py @@ -10,9 +10,15 @@ # pylint: disable=invalid-name """Utilities for the workflow engine.""" +import asyncio import contextlib +from datetime import datetime import logging -import asyncio +from typing import Any, Awaitable, Callable, Iterator, List, Optional, Type, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from .processes import Process, ProcessBuilder + from .runners import Runner __all__ = ('interruptable_task', 'InterruptableFuture', 'is_process_function') @@ -21,7 +27,9 @@ PROCESS_STATE_CHANGE_DESCRIPTION = 'The last time a process of type {}, changed state' -def instantiate_process(runner, process, *args, **inputs): +def instantiate_process( + runner: 'Runner', process: Union['Process', Type['Process'], 'ProcessBuilder'], *args, **inputs +) -> 'Process': """ Return an instance of the process with the given inputs. The function can deal with various types of the `process`: @@ -48,7 +56,7 @@ def instantiate_process(runner, process, *args, **inputs): process_class = builder.process_class inputs.update(**builder._inputs(prune=True)) # pylint: disable=protected-access elif is_process_function(process): - process_class = process.process_class + process_class = process.process_class # type: ignore[attr-defined] elif issubclass(process, Process): process_class = process else: @@ -62,11 +70,11 @@ def instantiate_process(runner, process, *args, **inputs): class InterruptableFuture(asyncio.Future): """A future that can be interrupted by calling `interrupt`.""" - def interrupt(self, reason): + def interrupt(self, reason: Exception) -> None: """This method should be called to interrupt the coroutine represented by this InterruptableFuture.""" self.set_exception(reason) - async def with_interrupt(self, coro): + async def with_interrupt(self, coro: Awaitable[Any]) -> Any: """ return result of a coroutine which will be interrupted if this future is interrupted :: @@ -91,7 +99,10 @@ async def with_interrupt(self, coro): return result -def interruptable_task(coro, loop=None): +def interruptable_task( + coro: Callable[[InterruptableFuture], Awaitable[Any]], + loop: Optional[asyncio.AbstractEventLoop] = None +) -> InterruptableFuture: """ Turn the given coroutine into an interruptable task by turning it into an InterruptableFuture and returning it. @@ -126,7 +137,7 @@ async def execute_coroutine(): return future -def ensure_coroutine(fct): +def ensure_coroutine(fct: Callable[..., Any]) -> Callable[..., Awaitable[Any]]: """ Ensure that the given function ``fct`` is a coroutine @@ -144,7 +155,13 @@ async def wrapper(*args, **kwargs): return wrapper -async def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, logger=None, ignore_exceptions=None): +async def exponential_backoff_retry( + fct: Callable[..., Any], + initial_interval: Union[int, float] = 10.0, + max_attempts: int = 5, + logger: Optional[logging.Logger] = None, + ignore_exceptions=None +) -> Any: """ Coroutine to call a function, recalling it with an exponential backoff in the case of an exception @@ -162,7 +179,7 @@ async def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, if logger is None: logger = LOGGER - result = None + result: Any = None coro = ensure_coroutine(fct) interval = initial_interval @@ -191,7 +208,7 @@ async def exponential_backoff_retry(fct, initial_interval=10.0, max_attempts=5, return result -def is_process_function(function): +def is_process_function(function: Any) -> bool: """Return whether the given function is a process function :param function: a function @@ -203,7 +220,7 @@ def is_process_function(function): return False -def is_process_scoped(): +def is_process_scoped() -> bool: """Return whether the current scope is within a process. :returns: True if the current scope is within a nested process, False otherwise @@ -213,7 +230,7 @@ def is_process_scoped(): @contextlib.contextmanager -def loop_scope(loop): +def loop_scope(loop) -> Iterator[None]: """ Make an event loop current for the scope of the context @@ -229,7 +246,7 @@ def loop_scope(loop): asyncio.set_event_loop(current) -def set_process_state_change_timestamp(process): +def set_process_state_change_timestamp(process: 'Process') -> None: """ Set the global setting that reflects the last time a process changed state, for the process type of the given process, to the current timestamp. The process type will be determined based on @@ -263,7 +280,7 @@ def set_process_state_change_timestamp(process): process.logger.debug(f'could not update the {key} setting because of a UniquenessError: {exception}') -def get_process_state_change_timestamp(process_type=None): +def get_process_state_change_timestamp(process_type: Optional[str] = None) -> Optional[datetime]: """ Get the global setting that reflects the last time a process of the given process type changed its state. The returned value will be the corresponding timestamp or None if the setting does not exist. @@ -288,7 +305,7 @@ def get_process_state_change_timestamp(process_type=None): else: process_types = [process_type] - timestamps = [] + timestamps: List[datetime] = [] for process_type_key in process_types: key = PROCESS_STATE_CHANGE_KEY.format(process_type_key) diff --git a/aiida/manage/manager.py b/aiida/manage/manager.py index 94251cb8c1..1c88029764 100644 --- a/aiida/manage/manager.py +++ b/aiida/manage/manager.py @@ -9,11 +9,23 @@ ########################################################################### # pylint: disable=cyclic-import """AiiDA manager for global settings""" +import asyncio import functools +from typing import Any, Optional, TYPE_CHECKING -__all__ = ('get_manager', 'reset_manager') +if TYPE_CHECKING: + from kiwipy.rmq import RmqThreadCommunicator + from plumpy.process_comms import RemoteProcessThreadController + + from aiida.backends.manager import BackendManager + from aiida.engine.daemon.client import DaemonClient + from aiida.engine.runners import Runner + from aiida.manage.configuration.config import Config + from aiida.manage.configuration.profile import Profile + from aiida.orm.implementation import Backend + from aiida.engine.persistence import AiiDAPersister -MANAGER = None +__all__ = ('get_manager', 'reset_manager') class Manager: @@ -32,34 +44,62 @@ class Manager: * reset manager cache when loading a new profile """ + def __init__(self) -> None: + self._backend: Optional['Backend'] = None + self._backend_manager: Optional['BackendManager'] = None + self._config: Optional['Config'] = None + self._daemon_client: Optional['DaemonClient'] = None + self._profile: Optional['Profile'] = None + self._communicator: Optional['RmqThreadCommunicator'] = None + self._process_controller: Optional['RemoteProcessThreadController'] = None + self._persister: Optional['AiiDAPersister'] = None + self._runner: Optional['Runner'] = None + + def close(self) -> None: + """Reset the global settings entirely and release any global objects.""" + if self._communicator is not None: + self._communicator.close() + if self._runner is not None: + self._runner.stop() + + self._backend = None + self._backend_manager = None + self._config = None + self._profile = None + self._communicator = None + self._daemon_client = None + self._process_controller = None + self._persister = None + self._runner = None + @staticmethod - def get_config(): + def get_config() -> 'Config': """Return the current config. :return: current loaded config instance - :rtype: :class:`~aiida.manage.configuration.config.Config` :raises aiida.common.ConfigurationError: if the configuration file could not be found, read or deserialized + """ from .configuration import get_config return get_config() @staticmethod - def get_profile(): + def get_profile() -> Optional['Profile']: """Return the current loaded profile, if any :return: current loaded profile instance - :rtype: :class:`~aiida.manage.configuration.profile.Profile` or None + """ from .configuration import get_profile return get_profile() - def unload_backend(self): + def unload_backend(self) -> None: """Unload the current backend and its corresponding database environment.""" manager = self.get_backend_manager() manager.reset_backend_environment() self._backend = None - def _load_backend(self, schema_check=True): + def _load_backend(self, schema_check: bool = True) -> 'Backend': """Load the backend for the currently configured profile and return it. .. note:: this will reconstruct the `Backend` instance in `self._backend` so the preferred method to load the @@ -67,7 +107,7 @@ def _load_backend(self, schema_check=True): :param schema_check: force a database schema check if the database environment has not yet been loaded :return: the database backend - :rtype: :class:`aiida.orm.implementation.Backend` + """ from aiida.backends import BACKEND_DJANGO, BACKEND_SQLA from aiida.common import ConfigurationError, InvalidOperation @@ -87,7 +127,7 @@ def _load_backend(self, schema_check=True): # Do NOT reload the backend environment if already loaded, simply reload the backend instance after if configuration.BACKEND_UUID is None: from aiida.backends import get_backend_manager - backend_manager = get_backend_manager(self.get_profile().database_backend) + backend_manager = get_backend_manager(profile.database_backend) backend_manager.load_backend_environment(profile, validate_schema=schema_check) configuration.BACKEND_UUID = profile.uuid @@ -108,46 +148,52 @@ def _load_backend(self, schema_check=True): return self._backend @property - def backend_loaded(self): + def backend_loaded(self) -> bool: """Return whether a database backend has been loaded. :return: boolean, True if database backend is currently loaded, False otherwise """ return self._backend is not None - def get_backend_manager(self): + def get_backend_manager(self) -> 'BackendManager': """Return the database backend manager. .. note:: this is not the actual backend, but a manager class that is necessary for database operations that go around the actual ORM. For example when the schema version has not yet been validated. :return: the database backend manager - :rtype: :class:`aiida.backend.manager.BackendManager` + """ from aiida.backends import get_backend_manager + from aiida.common import ConfigurationError if self._backend_manager is None: self._load_backend() - self._backend_manager = get_backend_manager(self.get_profile().database_backend) + profile = self.get_profile() + if profile is None: + raise ConfigurationError( + 'Could not determine the current profile. Consider loading a profile using `aiida.load_profile()`.' + ) + self._backend_manager = get_backend_manager(profile.database_backend) return self._backend_manager - def get_backend(self): + def get_backend(self) -> 'Backend': """Return the database backend :return: the database backend - :rtype: :class:`aiida.orm.implementation.Backend` + """ if self._backend is None: self._load_backend() return self._backend - def get_persister(self): + def get_persister(self) -> 'AiiDAPersister': """Return the persister :return: the current persister instance - :rtype: :class:`plumpy.Persister` + """ from aiida.engine import persistence @@ -156,18 +202,20 @@ def get_persister(self): return self._persister - def get_communicator(self): + def get_communicator(self) -> 'RmqThreadCommunicator': """Return the communicator :return: a global communicator instance - :rtype: :class:`kiwipy.Communicator` + """ if self._communicator is None: self._communicator = self.create_communicator() return self._communicator - def create_communicator(self, task_prefetch_count=None, with_orm=True): + def create_communicator( + self, task_prefetch_count: Optional[int] = None, with_orm: bool = True + ) -> 'RmqThreadCommunicator': """Create a Communicator. :param task_prefetch_count: optional specify how many tasks this communicator take simultaneously @@ -175,12 +223,17 @@ def create_communicator(self, task_prefetch_count=None, with_orm=True): This is used by verdi status to get a communicator without needing to load the dbenv. :return: the communicator instance - :rtype: :class:`~kiwipy.rmq.communicator.RmqThreadCommunicator` + """ + from aiida.common import ConfigurationError from aiida.manage.external import rmq import kiwipy.rmq profile = self.get_profile() + if profile is None: + raise ConfigurationError( + 'Could not determine the current profile. Consider loading a profile using `aiida.load_profile()`.' + ) if task_prefetch_count is None: task_prefetch_count = self.get_config().get_option('daemon.worker_process_slots', profile.name) @@ -210,11 +263,11 @@ def create_communicator(self, task_prefetch_count=None, with_orm=True): testing_mode=profile.is_test_profile, ) - def get_daemon_client(self): + def get_daemon_client(self) -> 'DaemonClient': """Return the daemon client for the current profile. :return: the daemon client - :rtype: :class:`aiida.daemon.client.DaemonClient` + :raises aiida.common.MissingConfigurationError: if the configuration file cannot be found :raises aiida.common.ProfileConfigurationError: if the given profile does not exist """ @@ -225,52 +278,57 @@ def get_daemon_client(self): return self._daemon_client - def get_process_controller(self): + def get_process_controller(self) -> 'RemoteProcessThreadController': """Return the process controller :return: the process controller instance - :rtype: :class:`plumpy.RemoteProcessThreadController` + """ - import plumpy + from plumpy.process_comms import RemoteProcessThreadController if self._process_controller is None: - self._process_controller = plumpy.RemoteProcessThreadController(self.get_communicator()) + self._process_controller = RemoteProcessThreadController(self.get_communicator()) return self._process_controller - def get_runner(self, **kwargs): + def get_runner(self, **kwargs) -> 'Runner': """Return a runner that is based on the current profile settings and can be used globally by the code. :return: the global runner - :rtype: :class:`aiida.engine.runners.Runner` + """ if self._runner is None: self._runner = self.create_runner(**kwargs) return self._runner - def set_runner(self, new_runner): + def set_runner(self, new_runner: 'Runner') -> None: """Set the currently used runner :param new_runner: the new runner to use - :type new_runner: :class:`aiida.engine.runners.Runner` + """ if self._runner is not None: self._runner.close() self._runner = new_runner - def create_runner(self, with_persistence=True, **kwargs): + def create_runner(self, with_persistence: bool = True, **kwargs: Any) -> 'Runner': """Create and return a new runner :param with_persistence: create a runner with persistence enabled - :type with_persistence: bool + :return: a new runner instance - :rtype: :class:`aiida.engine.runners.Runner` + """ + from aiida.common import ConfigurationError from aiida.engine import runners config = self.get_config() profile = self.get_profile() + if profile is None: + raise ConfigurationError( + 'Could not determine the current profile. Consider loading a profile using `aiida.load_profile()`.' + ) poll_interval = 0.0 if profile.is_test_profile else config.get_option('runner.poll.interval', profile.name) settings = {'rmq_submit': False, 'poll_interval': poll_interval} @@ -285,17 +343,17 @@ def create_runner(self, with_persistence=True, **kwargs): return runners.Runner(**settings) - def create_daemon_runner(self, loop=None): + def create_daemon_runner(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> 'Runner': """Create and return a new daemon runner. This is used by workers when the daemon is running and in testing. :param loop: the (optional) asyncio event loop to use - :type loop: the asyncio event loop + :return: a runner configured to work in the daemon configuration - :rtype: :class:`aiida.engine.runners.Runner` + """ - import plumpy + from plumpy.persistence import LoadSaveContext from aiida.engine import persistence from aiida.manage.external import rmq @@ -306,52 +364,27 @@ def create_daemon_runner(self, loop=None): task_receiver = rmq.ProcessLauncher( loop=runner_loop, persister=self.get_persister(), - load_context=plumpy.LoadSaveContext(runner=runner), + load_context=LoadSaveContext(runner=runner), loader=persistence.get_object_loader() ) + assert runner.communicator is not None, 'communicator not set for runner' runner.communicator.add_task_subscriber(task_receiver) return runner - def close(self): - """Reset the global settings entirely and release any global objects.""" - if self._communicator is not None: - self._communicator.close() - if self._runner is not None: - self._runner.stop() - - self._backend = None - self._backend_manager = None - self._config = None - self._profile = None - self._communicator = None - self._daemon_client = None - self._process_controller = None - self._persister = None - self._runner = None - def __init__(self): - super().__init__() - self._backend = None # type: aiida.orm.implementation.Backend - self._backend_manager = None # type: aiida.backend.manager.BackendManager - self._config = None # type: aiida.manage.configuration.config.Config - self._daemon_client = None # type: aiida.daemon.client.DaemonClient - self._profile = None # type: aiida.manage.configuration.profile.Profile - self._communicator = None # type: kiwipy.rmq.RmqThreadCommunicator - self._process_controller = None # type: plumpy.RemoteProcessThreadController - self._persister = None # type: aiida.engine.persistence.AiiDAPersister - self._runner = None # type: aiida.engine.runners.Runner +MANAGER: Optional[Manager] = None -def get_manager(): +def get_manager() -> Manager: global MANAGER # pylint: disable=global-statement if MANAGER is None: MANAGER = Manager() return MANAGER -def reset_manager(): +def reset_manager() -> None: global MANAGER # pylint: disable=global-statement if MANAGER is not None: MANAGER.close() diff --git a/aiida/sphinxext/process.py b/aiida/sphinxext/process.py index 4c80cefbeb..077b49af1c 100644 --- a/aiida/sphinxext/process.py +++ b/aiida/sphinxext/process.py @@ -117,9 +117,12 @@ def build_content(self): content += self.build_doctree(title='Outputs:', port_namespace=self.process_spec.outputs) if hasattr(self.process_spec, 'get_outline'): - outline = self.process_spec.get_outline() - if outline is not None: - content += self.build_outline_doctree(outline=outline) + try: + outline = self.process_spec.get_outline() + if outline is not None: + content += self.build_outline_doctree(outline=outline) + except AssertionError: + pass return content def build_doctree(self, title, port_namespace): diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index 9b290f994d..ecfdb106bb 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -19,20 +19,45 @@ py:class builtins.str py:class builtins.dict # typing -py:class traceback +py:class asyncio.events.AbstractEventLoop +py:class EntityType +py:class function py:class IO +py:class traceback ### AiiDA # issues with order of object processing and type hinting -py:class WorkChainSpec +py:class aiida.engine.runners.ResultAndNode +py:class aiida.engine.runners.ResultAndPk +py:class aiida.engine.processes.workchains.workchain.WorkChainSpec +py:class aiida.manage.manager.Manager +py:class aiida.orm.utils.links.LinkQuadruple py:class aiida.tools.importexport.dbexport.ExportReport py:class aiida.tools.importexport.dbexport.ArchiveData - -py:class EntityType py:class aiida.tools.groups.paths.WalkNodeResult -py:class aiida.orm.utils.links.LinkQuadruple +py:class Node +py:class ProcessSpec +py:class CalcJobNode +py:class ExitCode +py:class Process +py:class AuthInfo +py:class ProcessNode +py:class PortNamespace +py:class Runner +py:class TransportQueue +py:class PersistenceError +py:class Port +py:class Data +py:class JobInfo +py:class CalcJob +py:class WorkChainSpec + +py:class kiwipy.communications.Communicator +py:class plumpy.process_states.State +py:class plumpy.workchains._If +py:class plumpy.workchains._While ### python packages # Note: These exceptions are needed if diff --git a/environment.yml b/environment.yml index 6701fa52ce..6fea17cf94 100644 --- a/environment.yml +++ b/environment.yml @@ -25,7 +25,7 @@ dependencies: - numpy~=1.17 - pamqp~=2.3 - paramiko~=2.7 -- plumpy~=0.18.1 +- plumpy~=0.18.4 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/mypy.ini b/mypy.ini index 481b14c29f..073863bcca 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,6 +2,8 @@ [mypy] +show_error_codes = True + check_untyped_defs = True scripts_are_modules = True warn_unused_ignores = True @@ -37,13 +39,16 @@ follow_imports = skip [mypy-tests.*] check_untyped_defs = False +[mypy-circus.*] +ignore_missing_imports = True + [mypy-django.*] ignore_missing_imports = True -[mypy-numpy.*] +[mypy-kiwipy.*] ignore_missing_imports = True -[mypy-plumpy.*] +[mypy-numpy.*] ignore_missing_imports = True [mypy-scipy.*] @@ -54,3 +59,6 @@ ignore_missing_imports = True [mypy-tqdm.*] ignore_missing_imports = True + +[mypy-wrapt.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index a2a957cce8..97975b5c3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,24 +75,19 @@ envlist = py37-django [testenv] usedevelop=True - -[testenv:py{36,37,38,39}-{django,sqla}] deps = py36: -rrequirements/requirements-py-3.6.txt py37: -rrequirements/requirements-py-3.7.txt py38: -rrequirements/requirements-py-3.8.txt py39: -rrequirements/requirements-py-3.9.txt + +[testenv:py{36,37,38,39}-{django,sqla}] setenv = django: AIIDA_TEST_BACKEND = django sqla: AIIDA_TEST_BACKEND = sqlalchemy commands = pytest {posargs} [testenv:py{36,37,38,39}-verdi] -deps = - py36: -rrequirements/requirements-py-3.6.txt - py37: -rrequirements/requirements-py-3.7.txt - py38: -rrequirements/requirements-py-3.8.txt - py39: -rrequirements/requirements-py-3.9.txt setenv = AIIDA_TEST_BACKEND = django commands = verdi {posargs} @@ -101,11 +96,6 @@ commands = verdi {posargs} description = clean: Build the documentation (remove any existing build) update: Build the documentation (modify any existing build) -deps = - py36: -rrequirements/requirements-py-3.6.txt - py37: -rrequirements/requirements-py-3.7.txt - py38: -rrequirements/requirements-py-3.8.txt - py39: -rrequirements/requirements-py-3.9.txt passenv = RUN_APIDOC setenv = update: RUN_APIDOC = False @@ -134,6 +124,6 @@ commands = [testenv:py{36,37,38,39}-pre-commit] description = Run the pre-commit checks -extras = all +extras = pre-commit commands = pre-commit run {posargs} """ diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index cb5064c3e2..f1fc33ef04 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -86,7 +86,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.18.1 +plumpy==0.18.4 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 68ca66e169..703102b09d 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -85,7 +85,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.18.1 +plumpy==0.18.4 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 0bb77ebddc..c665a43992 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -80,7 +80,7 @@ pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 pluggy==0.13.1 -plumpy==0.18.1 +plumpy==0.18.4 prometheus-client==0.7.1 prompt-toolkit==3.0.4 psutil==5.7.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 32564e4c39..d8eec0286d 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -81,7 +81,7 @@ pika==1.1.0 Pillow==8.0.1 plotly==4.12.0 pluggy==0.13.1 -plumpy==0.18.1 +plumpy==0.18.4 prometheus-client==0.8.0 prompt-toolkit==3.0.8 psutil==5.7.3 diff --git a/setup.json b/setup.json index ff7303dc57..5df80dc5a1 100644 --- a/setup.json +++ b/setup.json @@ -40,7 +40,7 @@ "numpy~=1.17", "pamqp~=2.3", "paramiko~=2.7", - "plumpy~=0.18.1", + "plumpy~=0.18.4", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", diff --git a/tests/sphinxext/reference_results/workchain.xml b/tests/sphinxext/reference_results/workchain.xml index e7fae40799..8d487bb2f8 100644 --- a/tests/sphinxext/reference_results/workchain.xml +++ b/tests/sphinxext/reference_results/workchain.xml @@ -1,7 +1,7 @@ - +
sphinx-aiida demo This is a demo documentation to show off the features of the sphinx-aiida extension. @@ -74,16 +74,32 @@ finalize This module defines an example workchain for the aiida-workchain documentation directive. - class demo_workchain.DemoWorkChain*args**kwargs + class demo_workchain.DemoWorkChain*args: Any**kwargs: Any A demo workchain to show how the workchain auto-documentation works. + + + classmethod definespec + + Define the specification of the process, including its inputs, outputs and known exit codes. + A metadata input namespace is defined, with optional ports that are not stored in the database. + + - class demo_workchain.EmptyOutlineWorkChain*args**kwargs + class demo_workchain.EmptyOutlineWorkChain*args: Any**kwargs: Any Here we check that the directive works even if the outline is empty. + + + classmethod definespec + + Define the specification of the process, including its inputs, outputs and known exit codes. + A metadata input namespace is defined, with optional ports that are not stored in the database. + + From 2c5293ded4514c36a2754acbe67aa778e27c4886 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 26 Jan 2021 10:10:20 +0000 Subject: [PATCH 041/114] Fix `run_get_node`/`run_get_pk` namedtuples (#4677) Fix a regression made in #4669, whereby the namedtuple's were incorrectly named --- aiida/engine/runners.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index 7708c851b5..90e933dc56 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -38,12 +38,12 @@ class ResultAndNode(NamedTuple): - node: ProcessNode result: Dict[str, Any] + node: ProcessNode class ResultAndPk(NamedTuple): - node: ProcessNode + result: Dict[str, Any] pk: int From 9e584dd8451e16f40ffbe98a643908c5c1ef67f1 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 25 Jan 2021 17:18:14 +0100 Subject: [PATCH 042/114] REST API fixes - Use node_type in construct_full_type(). - Don't use try/except for determining full_type. - Remove unnecessary try/except in App for catch_internal_server. - Use proper API_CONFIG for configure_api. --- aiida/restapi/api.py | 9 ++------- aiida/restapi/common/identifiers.py | 2 +- aiida/restapi/run_api.py | 2 +- aiida/restapi/translator/nodes/node.py | 8 ++++---- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/aiida/restapi/api.py b/aiida/restapi/api.py index 796e9a074f..497d10e85c 100644 --- a/aiida/restapi/api.py +++ b/aiida/restapi/api.py @@ -25,13 +25,8 @@ class App(Flask): def __init__(self, *args, **kwargs): - # Decide whether or not to catch the internal server exceptions ( - # default is True) - catch_internal_server = True - try: - catch_internal_server = kwargs.pop('catch_internal_server') - except KeyError: - pass + # Decide whether or not to catch the internal server exceptions (default is True) + catch_internal_server = kwargs.pop('catch_internal_server', True) # Basic initialization super().__init__(*args, **kwargs) diff --git a/aiida/restapi/common/identifiers.py b/aiida/restapi/common/identifiers.py index eb7ea85207..870a904065 100644 --- a/aiida/restapi/common/identifiers.py +++ b/aiida/restapi/common/identifiers.py @@ -69,7 +69,7 @@ def construct_full_type(node_type, process_type): :return: the full type, which is a unique identifier """ if node_type is None: - process_type = '' + node_type = '' if process_type is None: process_type = '' diff --git a/aiida/restapi/run_api.py b/aiida/restapi/run_api.py index b551d12769..afbc4b1f67 100755 --- a/aiida/restapi/run_api.py +++ b/aiida/restapi/run_api.py @@ -121,4 +121,4 @@ def configure_api(flask_app=api_classes.App, flask_api=api_classes.AiidaApi, **k app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) # Instantiate and return a Flask RESTful API by associating its app - return flask_api(app, **API_CONFIG) + return flask_api(app, **config_module.API_CONFIG) diff --git a/aiida/restapi/translator/nodes/node.py b/aiida/restapi/translator/nodes/node.py index 69eaedb0dd..8b8e4d3d2c 100644 --- a/aiida/restapi/translator/nodes/node.py +++ b/aiida/restapi/translator/nodes/node.py @@ -563,10 +563,10 @@ def get_formatted_result(self, label): for node_entry in results[result_name]: # construct full_type and add it to every node - try: - node_entry['full_type'] = construct_full_type(node_entry['node_type'], node_entry['process_type']) - except KeyError: - node_entry['full_type'] = None + node_entry['full_type'] = ( + construct_full_type(node_entry.get('node_type'), node_entry.get('process_type')) + if node_entry.get('node_type') or node_entry.get('process_type') else None + ) return results From a4ec5122b9d2e79b183cd6fd73aa21a34c3f00a3 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 25 Jan 2021 17:57:27 +0100 Subject: [PATCH 043/114] New /querybuilder-endpoint with POST for REST API The POST endpoint returns what the QueryBuilder would return, when providing it with a proper queryhelp dictionary. Furthermore, it returns the entities/results in the "standard" REST API format - with the exception of `link_type` and `link_label` keys for links. However, these particular keys are still present as `type` and `label`, respectively. The special Node property `full_type` will be removed from any entity, if its value is `None`. There are two cases where this will be True: - If the entity is not a `Node`; and - If neither `node_type` or `process_type` are among the projected properties for any given `Node`. Concerning security: The /querybuilder-endpoint can be toggled on/off with the configuration parameter `CLI_DEFAULTS['POSTING']`. Added this to `verdi restapi` as `--posting/--no-posting` option. The option is hidden by default, as the naming may be changed in the future. Reviewed by @ltalirz. --- aiida/cmdline/commands/cmd_restapi.py | 11 +- aiida/restapi/api.py | 16 +- aiida/restapi/common/config.py | 1 + aiida/restapi/resources.py | 127 +++++++++++++++ aiida/restapi/run_api.py | 5 +- tests/restapi/test_config.py | 52 ++++++ tests/restapi/test_routes.py | 222 ++++++++++++++++++++++++++ 7 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 tests/restapi/test_config.py diff --git a/aiida/cmdline/commands/cmd_restapi.py b/aiida/cmdline/commands/cmd_restapi.py index 0ff7546b30..1f7ad1413e 100644 --- a/aiida/cmdline/commands/cmd_restapi.py +++ b/aiida/cmdline/commands/cmd_restapi.py @@ -38,7 +38,15 @@ help='Whether to enable WSGI profiler middleware for finding bottlenecks' ) @click.option('--hookup/--no-hookup', 'hookup', is_flag=True, default=None, help='Hookup app to flask server') -def restapi(hostname, port, config_dir, debug, wsgi_profile, hookup): +@click.option( + '--posting/--no-posting', + 'posting', + is_flag=True, + default=config.CLI_DEFAULTS['POSTING'], + help='Enable POST endpoints (currently only /querybuilder).', + hidden=True, +) +def restapi(hostname, port, config_dir, debug, wsgi_profile, hookup, posting): """ Run the AiiDA REST API server. @@ -55,4 +63,5 @@ def restapi(hostname, port, config_dir, debug, wsgi_profile, hookup): debug=debug, wsgi_profile=wsgi_profile, hookup=hookup, + posting=posting, ) diff --git a/aiida/restapi/api.py b/aiida/restapi/api.py index 497d10e85c..586e84c74c 100644 --- a/aiida/restapi/api.py +++ b/aiida/restapi/api.py @@ -90,12 +90,17 @@ def __init__(self, app=None, **kwargs): configuration and PREFIX """ - from aiida.restapi.resources import ProcessNode, CalcJobNode, Computer, User, Group, Node, ServerInfo + from aiida.restapi.common.config import CLI_DEFAULTS + from aiida.restapi.resources import ( + ProcessNode, CalcJobNode, Computer, User, Group, Node, ServerInfo, QueryBuilder + ) self.app = app super().__init__(app=app, prefix=kwargs['PREFIX'], catch_all_404s=True) + posting = kwargs.pop('posting', CLI_DEFAULTS['POSTING']) + self.add_resource( ServerInfo, '/', @@ -106,6 +111,15 @@ def __init__(self, app=None, **kwargs): resource_class_kwargs=kwargs ) + if posting: + self.add_resource( + QueryBuilder, + '/querybuilder/', + endpoint='querybuilder', + strict_slashes=False, + resource_class_kwargs=kwargs, + ) + ## Add resources and endpoints to the api self.add_resource( Computer, diff --git a/aiida/restapi/common/config.py b/aiida/restapi/common/config.py index 117cc95db4..0569640824 100644 --- a/aiida/restapi/common/config.py +++ b/aiida/restapi/common/config.py @@ -47,4 +47,5 @@ 'WSGI_PROFILE': False, 'HOOKUP_APP': True, 'CATCH_INTERNAL_SERVER': False, + 'POSTING': True, # Include POST endpoints (currently only /querybuilder) } diff --git a/aiida/restapi/resources.py b/aiida/restapi/resources.py index 18572264e9..b4f9083a57 100644 --- a/aiida/restapi/resources.py +++ b/aiida/restapi/resources.py @@ -207,6 +207,133 @@ def get(self, id=None, page=None): # pylint: disable=redefined-builtin,invalid- return self.utils.build_response(status=200, headers=headers, data=data) +class QueryBuilder(BaseResource): + """ + Representation of a QueryBuilder REST API resource (instantiated with a queryhelp JSON). + + It supports POST requests taking in JSON :py:func:`~aiida.orm.querybuilder.QueryBuilder.queryhelp` + objects and returning the :py:class:`~aiida.orm.querybuilder.QueryBuilder` result accordingly. + """ + from aiida.restapi.translator.nodes.node import NodeTranslator + + _translator_class = NodeTranslator + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # HTTP Request method decorators + if 'get_decorators' in kwargs and isinstance(kwargs['get_decorators'], (tuple, list, set)): + self.method_decorators.update({'post': list(kwargs['get_decorators'])}) + + def get(self): # pylint: disable=arguments-differ + """Static return to state information about this endpoint.""" + data = { + 'message': ( + 'Method Not Allowed. Use HTTP POST requests to use the AiiDA QueryBuilder. ' + 'POST JSON data, which MUST be a valid QueryBuilder.queryhelp dictionary as a JSON object. ' + 'See the documentation at https://aiida.readthedocs.io/projects/aiida-core/en/latest/topics/' + 'database.html?highlight=QueryBuilder#the-queryhelp for more information.' + ), + } + + headers = self.utils.build_headers(url=request.url, total_count=1) + return self.utils.build_response( + status=405, # Method Not Allowed + headers=headers, + data={ + 'method': request.method, + 'url': unquote(request.url), + 'url_root': unquote(request.url_root), + 'path': unquote(request.path), + 'query_string': request.query_string.decode('utf-8'), + 'resource_type': self.__class__.__name__, + 'data': data, + }, + ) + + def post(self): # pylint: disable=too-many-branches + """ + POST method to pass query help JSON. + + If the posted JSON is not a valid QueryBuilder queryhelp, the request will fail with an internal server error. + + This uses the NodeTranslator in order to best return Nodes according to the general AiiDA + REST API data format, while still allowing the return of other AiiDA entities. + + :return: QueryBuilder result of AiiDA entities in "standard" REST API format. + """ + # pylint: disable=protected-access + self.trans._query_help = request.get_json(force=True) + # While the data may be correct JSON, it MUST be a single JSON Object, + # equivalent of a QuieryBuilder.queryhelp dictionary. + assert isinstance(self.trans._query_help, dict), ( + 'POSTed data MUST be a valid QueryBuilder.queryhelp dictionary. ' + f'Got instead (type: {type(self.trans._query_help)}): {self.trans._query_help}' + ) + self.trans.__label__ = self.trans._result_type = self.trans._query_help['path'][-1]['tag'] + + # Handle empty list projections + number_projections = len(self.trans._query_help['project']) + empty_projections_counter = 0 + skip_tags = [] + for tag, projections in tuple(self.trans._query_help['project'].items()): + if projections == [{'*': {}}]: + self.trans._query_help['project'][tag] = self.trans._default + elif not projections: + empty_projections_counter += 1 + skip_tags.append(tag) + else: + # Use projections as given, no need to "correct" them. + pass + + if empty_projections_counter == number_projections: + # No projections have been specified in the queryhelp. + # To be true to the QueryBuilder response, the last entry in path + # is the only entry to be returned, all without edges/links. + self.trans._query_help['project'][self.trans.__label__] = self.trans._default + + self.trans.init_qb() + + data = {} + if self.trans.get_total_count(): + if empty_projections_counter == number_projections: + # "Normal" REST API retrieval can be used. + data = self.trans.get_results() + else: + # Since the "normal" REST API retrieval relies on single-tag retrieval, + # we must instead be more creative with how we retrieve the results here. + # So we opt for a dictionary, with the tags being the keys. + for tag in self.trans._query_help['project']: + if tag in skip_tags: + continue + self.trans.__label__ = tag + data.update(self.trans.get_formatted_result(tag)) + + # Remove 'full_type's when they're `None` + for tag, entities in list(data.items()): + updated_entities = [] + for entity in entities: + if entity.get('full_type') is None: + entity.pop('full_type', None) + updated_entities.append(entity) + data[tag] = updated_entities + + headers = self.utils.build_headers(url=request.url, total_count=self.trans.get_total_count()) + return self.utils.build_response( + status=200, + headers=headers, + data={ + 'method': request.method, + 'url': unquote(request.url), + 'url_root': unquote(request.url_root), + 'path': unquote(request.path), + 'query_string': request.query_string.decode('utf-8'), + 'resource_type': self.__class__.__name__, + 'data': data, + }, + ) + + class Node(BaseResource): """ Differs from BaseResource in trans.set_query() mostly because it takes diff --git a/aiida/restapi/run_api.py b/aiida/restapi/run_api.py index afbc4b1f67..dde845de70 100755 --- a/aiida/restapi/run_api.py +++ b/aiida/restapi/run_api.py @@ -39,6 +39,7 @@ def run_api(flask_app=api_classes.App, flask_api=api_classes.AiidaApi, **kwargs) :param wsgi_profile: use WSGI profiler middleware for finding bottlenecks in web application :param hookup: If true, hook up application to built-in server, else just return it. This parameter is deprecated as of AiiDA 1.2.1. If you don't intend to run the API (hookup=False) use `configure_api` instead. + :param posting: Whether or not to include POST-enabled endpoints (currently only `/querybuilder`). :returns: tuple (app, api) if hookup==False or runs app if hookup==True """ @@ -80,6 +81,7 @@ def configure_api(flask_app=api_classes.App, flask_api=api_classes.AiidaApi, **k :param catch_internal_server: If true, catch and print internal server errors with full python traceback. Useful during app development. :param wsgi_profile: use WSGI profiler middleware for finding bottlenecks in the web application + :param posting: Whether or not to include POST-enabled endpoints (currently only `/querybuilder`). :returns: Flask RESTful API :rtype: :py:class:`flask_restful.Api` @@ -89,6 +91,7 @@ def configure_api(flask_app=api_classes.App, flask_api=api_classes.AiidaApi, **k config = kwargs.pop('config', CLI_DEFAULTS['CONFIG_DIR']) catch_internal_server = kwargs.pop('catch_internal_server', CLI_DEFAULTS['CATCH_INTERNAL_SERVER']) wsgi_profile = kwargs.pop('wsgi_profile', CLI_DEFAULTS['WSGI_PROFILE']) + posting = kwargs.pop('posting', CLI_DEFAULTS['POSTING']) if kwargs: raise ValueError(f'Unknown keyword arguments: {kwargs}') @@ -121,4 +124,4 @@ def configure_api(flask_app=api_classes.App, flask_api=api_classes.AiidaApi, **k app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) # Instantiate and return a Flask RESTful API by associating its app - return flask_api(app, **config_module.API_CONFIG) + return flask_api(app, posting=posting, **config_module.API_CONFIG) diff --git a/tests/restapi/test_config.py b/tests/restapi/test_config.py new file mode 100644 index 0000000000..9742640730 --- /dev/null +++ b/tests/restapi/test_config.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Tests for the configuration options from `aiida.restapi.common.config` when running the REST API.""" +# pylint: disable=redefined-outer-name +import pytest + + +@pytest.fixture +def create_app(): + """Set up Flask App""" + from aiida.restapi.run_api import configure_api + + def _create_app(**kwargs): + catch_internal_server = kwargs.pop('catch_internal_server', True) + api = configure_api(catch_internal_server=catch_internal_server, **kwargs) + api.app.config['TESTING'] = True + return api.app + + return _create_app + + +def test_posting(create_app): + """Test CLI_DEFAULTS['POSTING'] configuration""" + from aiida.restapi.common.config import API_CONFIG + + app = create_app(posting=False) + + url = f'{API_CONFIG["PREFIX"]}/querybuilder' + for method in ('get', 'post'): + with app.test_client() as client: + response = getattr(client, method)(url) + + assert response.status_code == 404 + assert response.status == '404 NOT FOUND' + + del app + app = create_app(posting=True) + + url = f'{API_CONFIG["PREFIX"]}/querybuilder' + for method in ('get', 'post'): + with app.test_client() as client: + response = getattr(client, method)(url) + + assert response.status_code != 404 + assert response.status != '404 NOT FOUND' diff --git a/tests/restapi/test_routes.py b/tests/restapi/test_routes.py index ca6eada052..e38799829d 100644 --- a/tests/restapi/test_routes.py +++ b/tests/restapi/test_routes.py @@ -1147,3 +1147,225 @@ def test_download_formats(self): for key in ['cif', 'xsf', 'xyz']: self.assertIn(key, response['data']['data.structure.StructureData.|']) self.assertIn('cif', response['data']['data.cif.CifData.|']) + + ############### querybuilder ############### + def test_querybuilder(self): + """Test POSTing a queryhelp dictionary as JSON to /querybuilder + + This also checks that `full_type` is _not_ included in the result no matter the entity. + """ + queryhelp = orm.QueryBuilder().append( + orm.CalculationNode, + tag='calc', + project=['id', 'uuid', 'user_id'], + ).order_by({ + 'calc': [{ + 'id': { + 'order': 'desc' + } + }] + }).queryhelp + + expected_node_uuids = [] + # dummy data already ordered 'desc' by 'id' + for calc in self.get_dummy_data()['calculations']: + if calc['node_type'].startswith('process.calculation.'): + expected_node_uuids.append(calc['uuid']) + + with self.app.test_client() as client: + response = client.post(f'{self.get_url_prefix()}/querybuilder', json=queryhelp).json + + self.assertEqual('POST', response.get('method', '')) + self.assertEqual('QueryBuilder', response.get('resource_type', '')) + + self.assertEqual( + len(expected_node_uuids), + len(response.get('data', {}).get('calc', [])), + msg=json.dumps(response, indent=2), + ) + self.assertListEqual( + expected_node_uuids, + [_.get('uuid', '') for _ in response.get('data', {}).get('calc', [])], + ) + for entities in response.get('data', {}).values(): + for entity in entities: + # All are Nodes, but neither `node_type` or `process_type` are requested, + # hence `full_type` should not be present. + self.assertFalse('full_type' in entity) + + def test_get_querybuilder(self): + """Test GETting the /querybuilder endpoint + + This should return with 405 Method Not Allowed. + Otherwise, a "conventional" JSON response should be returned with a helpful message. + """ + with self.app.test_client() as client: + response_value = client.get(f'{self.get_url_prefix()}/querybuilder') + response = response_value.json + + self.assertEqual(response_value.status_code, 405) + self.assertEqual(response_value.status, '405 METHOD NOT ALLOWED') + + self.assertEqual('GET', response.get('method', '')) + self.assertEqual('QueryBuilder', response.get('resource_type', '')) + + message = ( + 'Method Not Allowed. Use HTTP POST requests to use the AiiDA QueryBuilder. ' + 'POST JSON data, which MUST be a valid QueryBuilder.queryhelp dictionary as a JSON object. ' + 'See the documentation at https://aiida.readthedocs.io/projects/aiida-core/en/latest/topics/' + 'database.html?highlight=QueryBuilder#the-queryhelp for more information.' + ) + self.assertEqual(message, response.get('data', {}).get('message', '')) + + def test_querybuilder_user(self): + """Retrieve a User through the use of the /querybuilder endpoint + + This also checks that `full_type` is _not_ included in the result no matter the entity. + """ + queryhelp = orm.QueryBuilder().append( + orm.CalculationNode, + tag='calc', + project=['id', 'user_id'], + ).append( + orm.User, + tag='users', + with_node='calc', + project=['id', 'email'], + ).order_by({ + 'calc': [{ + 'id': { + 'order': 'desc' + } + }] + }).queryhelp + + expected_user_ids = [] + for calc in self.get_dummy_data()['calculations']: + if calc['node_type'].startswith('process.calculation.'): + expected_user_ids.append(calc['user_id']) + + with self.app.test_client() as client: + response = client.post(f'{self.get_url_prefix()}/querybuilder', json=queryhelp).json + + self.assertEqual('POST', response.get('method', '')) + self.assertEqual('QueryBuilder', response.get('resource_type', '')) + + self.assertEqual( + len(expected_user_ids), + len(response.get('data', {}).get('users', [])), + msg=json.dumps(response, indent=2), + ) + self.assertListEqual( + expected_user_ids, + [_.get('id', '') for _ in response.get('data', {}).get('users', [])], + ) + self.assertListEqual( + expected_user_ids, + [_.get('user_id', '') for _ in response.get('data', {}).get('calc', [])], + ) + for entities in response.get('data', {}).values(): + for entity in entities: + # User is not a Node (no full_type) + self.assertFalse('full_type' in entity) + + def test_querybuilder_project_explicit(self): + """Expliticly project everything from the resulting entities + + Here "project" will use the wildcard (*). + This should result in both CalculationNodes and Data to be returned. + """ + queryhelp = orm.QueryBuilder().append( + orm.CalculationNode, + tag='calc', + project='*', + ).append( + orm.Data, + tag='data', + with_incoming='calc', + project='*', + ).order_by({'data': [{ + 'id': { + 'order': 'desc' + } + }]}) + + expected_calc_uuids = [] + expected_data_uuids = [] + for calc, data in queryhelp.all(): + expected_calc_uuids.append(calc.uuid) + expected_data_uuids.append(data.uuid) + + queryhelp = queryhelp.queryhelp + + with self.app.test_client() as client: + response = client.post(f'{self.get_url_prefix()}/querybuilder', json=queryhelp).json + + self.assertEqual('POST', response.get('method', '')) + self.assertEqual('QueryBuilder', response.get('resource_type', '')) + + self.assertEqual( + len(expected_calc_uuids), + len(response.get('data', {}).get('calc', [])), + msg=json.dumps(response, indent=2), + ) + self.assertEqual( + len(expected_data_uuids), + len(response.get('data', {}).get('data', [])), + msg=json.dumps(response, indent=2), + ) + self.assertListEqual( + expected_calc_uuids, + [_.get('uuid', '') for _ in response.get('data', {}).get('calc', [])], + ) + self.assertListEqual( + expected_data_uuids, + [_.get('uuid', '') for _ in response.get('data', {}).get('data', [])], + ) + for entities in response.get('data', {}).values(): + for entity in entities: + # All are Nodes, and all properties are projected, full_type should be present + self.assertTrue('full_type' in entity) + self.assertTrue('attributes' in entity) + + def test_querybuilder_project_implicit(self): + """Implicitly project everything from the resulting entities + + Here "project" will be an empty list, resulting in only the Data node being returned. + """ + queryhelp = orm.QueryBuilder().append(orm.CalculationNode, tag='calc').append( + orm.Data, + tag='data', + with_incoming='calc', + ).order_by({'data': [{ + 'id': { + 'order': 'desc' + } + }]}) + + expected_data_uuids = [] + for data in queryhelp.all(flat=True): + expected_data_uuids.append(data.uuid) + + queryhelp = queryhelp.queryhelp + + with self.app.test_client() as client: + response = client.post(f'{self.get_url_prefix()}/querybuilder', json=queryhelp).json + + self.assertEqual('POST', response.get('method', '')) + self.assertEqual('QueryBuilder', response.get('resource_type', '')) + + self.assertListEqual(['data'], list(response.get('data', {}).keys())) + self.assertEqual( + len(expected_data_uuids), + len(response.get('data', {}).get('data', [])), + msg=json.dumps(response, indent=2), + ) + self.assertListEqual( + expected_data_uuids, + [_.get('uuid', '') for _ in response.get('data', {}).get('data', [])], + ) + for entities in response.get('data', {}).values(): + for entity in entities: + # All are Nodes, and all properties are projected, full_type should be present + self.assertTrue('full_type' in entity) + self.assertTrue('attributes' in entity) From 4c9d44af4d8c2550444d9d528dce1b890c7772f6 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 26 Jan 2021 10:43:46 +0100 Subject: [PATCH 044/114] Use importlib in .ci folder --- .ci/polish/cli.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.ci/polish/cli.py b/.ci/polish/cli.py index 362c398a5c..3b149df500 100755 --- a/.ci/polish/cli.py +++ b/.ci/polish/cli.py @@ -102,26 +102,27 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout import uuid from aiida.orm import Code, Int, Str from aiida.engine import run_get_node, submit - from lib.expression import generate, validate, evaluate # pylint: disable=import-error - from lib.workchain import generate_outlines, format_outlines, write_workchain # pylint: disable=import-error + + lib_expression = importlib.import_module('lib.expression') + lib_workchain = importlib.import_module('lib.workchain') if use_calculations and not isinstance(code, Code): raise click.BadParameter('if you specify the -C flag, you have to specify a code as well') if expression is None: - expression = generate() + expression = lib_expression.generate() - valid, error = validate(expression) + valid, error = lib_expression.validate(expression) if not valid: click.echo(f"the expression '{expression}' is invalid: {error}") sys.exit(1) filename = f'polish_{str(uuid.uuid4().hex)}.py' - evaluated = evaluate(expression, modulo) - outlines, stack = generate_outlines(expression) - outlines_string = format_outlines(outlines, use_calculations, use_calcfunctions) - write_workchain(outlines_string, filename=filename) + evaluated = lib_expression.evaluate(expression, modulo) + outlines, stack = lib_workchain.generate_outlines(expression) + outlines_string = lib_workchain.format_outlines(outlines, use_calculations, use_calcfunctions) + lib_workchain.write_workchain(outlines_string, filename=filename) click.echo(f'Expression: {expression}') From 10a6d08cfef907a1368150e69728086c8f48e869 Mon Sep 17 00:00:00 2001 From: ramirezfranciscof Date: Tue, 26 Jan 2021 10:10:10 +0100 Subject: [PATCH 045/114] Fix: pre-store hash for -0. and 0. is now the same --- aiida/common/hashing.py | 2 ++ tests/common/test_hashing.py | 1 + 2 files changed, 3 insertions(+) diff --git a/aiida/common/hashing.py b/aiida/common/hashing.py index 02d7c6e95d..d4eb8e01f6 100644 --- a/aiida/common/hashing.py +++ b/aiida/common/hashing.py @@ -288,5 +288,7 @@ def float_to_text(value, sig): :param value: the float value to convert :param sig: choose how many digits after the comma should be output """ + if value == 0: + value = 0. # Identify value of -0. and overwrite with 0. fmt = f'{{:.{sig}g}}' return fmt.format(value) diff --git a/tests/common/test_hashing.py b/tests/common/test_hashing.py index 05f6f66260..efe4c1e843 100644 --- a/tests/common/test_hashing.py +++ b/tests/common/test_hashing.py @@ -36,6 +36,7 @@ class FloatToTextTest(unittest.TestCase): """ def test_subnormal(self): + self.assertEqual(float_to_text(-0.00, sig=2), '0') # 0 is always printed as '0' self.assertEqual(float_to_text(3.555, sig=2), '3.6') self.assertEqual(float_to_text(3.555, sig=3), '3.56') self.assertEqual(float_to_text(3.141592653589793238462643383279502884197, sig=14), '3.1415926535898') From d304dfc796926bcbed3fbd4e68aae431ea891365 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Wed, 27 Jan 2021 12:16:23 +0100 Subject: [PATCH 046/114] ci: update paramiko version (#4686) Now that the Github Action runners switched to Ubuntu 20.04, the default SSH key format of OpenSSH changed and is no longer supported by paramiko <=2.7.1. --- environment.yml | 2 +- requirements/requirements-py-3.6.txt | 2 +- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- setup.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/environment.yml b/environment.yml index 6fea17cf94..f2a3bf75c2 100644 --- a/environment.yml +++ b/environment.yml @@ -24,7 +24,7 @@ dependencies: - kiwipy[rmq]~=0.7.1 - numpy~=1.17 - pamqp~=2.3 -- paramiko~=2.7 +- paramiko>=2.7.2,~=2.7 - plumpy~=0.18.4 - pgsu~=0.1.0 - psutil~=5.6 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index f1fc33ef04..37f5d111a9 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -77,7 +77,7 @@ palettable==3.3.0 pamqp==2.3 pandas==0.25.3 pandocfilters==1.4.2 -paramiko==2.7.1 +paramiko==2.7.2 parso==0.6.2 pathspec==0.8.0 pexpect==4.8.0 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 703102b09d..2b693de529 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -76,7 +76,7 @@ palettable==3.3.0 pamqp==2.3 pandas==0.25.3 pandocfilters==1.4.2 -paramiko==2.7.1 +paramiko==2.7.2 parso==0.6.2 pathspec==0.8.0 pexpect==4.8.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index c665a43992..930f300a2e 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -72,7 +72,7 @@ palettable==3.3.0 pamqp==2.3 pandas==0.25.3 pandocfilters==1.4.2 -paramiko==2.7.1 +paramiko==2.7.2 parso==0.6.2 pexpect==4.8.0 pg8000==1.13.2 diff --git a/setup.json b/setup.json index 5df80dc5a1..43dcbed5f8 100644 --- a/setup.json +++ b/setup.json @@ -39,7 +39,7 @@ "kiwipy[rmq]~=0.7.1", "numpy~=1.17", "pamqp~=2.3", - "paramiko~=2.7", + "paramiko~=2.7,>=2.7.2", "plumpy~=0.18.4", "pgsu~=0.1.0", "psutil~=5.6", From 950d1a424ac5be54c4771f8aeb7dd7189bf23ec9 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 27 Jan 2021 11:42:05 +0000 Subject: [PATCH 047/114] Fix: release signal handlers after run execution (#4682) After a process has executed (when running rather than submitting), return the signal handlers to their original state. This fixes an issue whereby using `CTRL-C` after a process has run still calls the `process.kill`. It also releases the `kill_process` function's reference to the process, a step towards allowing the finished process to be garbage collected. --- aiida/engine/runners.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index 90e933dc56..2df3b12770 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -238,10 +238,17 @@ def kill_process(_num, _frame): LOGGER.critical('runner received interrupt, killing process %s', process_inited.pid) process_inited.kill(msg='Process was killed because the runner received an interrupt') - signal.signal(signal.SIGINT, kill_process) - signal.signal(signal.SIGTERM, kill_process) + original_handler_int = signal.getsignal(signal.SIGINT) + original_handler_term = signal.getsignal(signal.SIGTERM) + + try: + signal.signal(signal.SIGINT, kill_process) + signal.signal(signal.SIGTERM, kill_process) + process_inited.execute() + finally: + signal.signal(signal.SIGINT, original_handler_int) + signal.signal(signal.SIGTERM, original_handler_term) - process_inited.execute() return process_inited.outputs, process_inited.node def run(self, process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Dict[str, Any]: From 02ebeb88e231172cfebc5a510a72671da4ef061b Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 27 Jan 2021 12:01:39 +0000 Subject: [PATCH 048/114] Fix: `PluginVersionProvider` should cache process class (#4683) Currently, the `PluginVersionProvider` is caching process instance, rather than class. This commit fixes the bug, meaning the cache will now work correctly. Removing the reference to the process instance also is a step towards allowing it to be garbage collected. --- aiida/engine/processes/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiida/engine/processes/process.py b/aiida/engine/processes/process.py index 01cb51da30..bdf7c0850a 100644 --- a/aiida/engine/processes/process.py +++ b/aiida/engine/processes/process.py @@ -672,7 +672,7 @@ def _setup_db_record(self) -> None: def _setup_metadata(self) -> None: """Store the metadata on the ProcessNode.""" - version_info = self.runner.plugin_version_provider.get_version_info(self) + version_info = self.runner.plugin_version_provider.get_version_info(self.__class__) self.node.set_attribute_many(version_info) for name, metadata in self.metadata.items(): From cdb2e57906e10e31f04ccb58923643292f38b6c0 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Wed, 27 Jan 2021 13:25:47 +0100 Subject: [PATCH 049/114] remove leftover use of Computer.name (#4681) Remove leftover use of deprecated Computer.name attribute in `verdi computer list`. Also update minimum version of click dependency to 7.1, since click 7.1 introduces additional whitespace in the verdi autodocs (running with click 7.0 locally resulted in pre-commit check failing on CI). Co-authored-by: Chris Sewell --- aiida/cmdline/commands/cmd_computer.py | 6 +++--- environment.yml | 2 +- requirements/requirements-py-3.6.txt | 2 +- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- setup.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/aiida/cmdline/commands/cmd_computer.py b/aiida/cmdline/commands/cmd_computer.py index 3d83d66bd6..0ca30081c5 100644 --- a/aiida/cmdline/commands/cmd_computer.py +++ b/aiida/cmdline/commands/cmd_computer.py @@ -338,7 +338,7 @@ def computer_disable(computer, user): @verdi_computer.command('list') @options.ALL(help='Show also disabled or unconfigured computers.') -@options.RAW(help='Show only the computer names, one per line.') +@options.RAW(help='Show only the computer labels, one per line.') @with_dbenv() def computer_list(all_entries, raw): """List all available computers.""" @@ -346,7 +346,7 @@ def computer_list(all_entries, raw): if not raw: echo.echo_info('List of configured computers') - echo.echo_info("Use 'verdi computer show COMPUTERNAME' to display more detailed information") + echo.echo_info("Use 'verdi computer show COMPUTERLABEL' to display more detailed information") computers = Computer.objects.all() user = User.objects.get_default() @@ -357,7 +357,7 @@ def computer_list(all_entries, raw): sort = lambda computer: computer.label highlight = lambda comp: comp.is_user_configured(user) and comp.is_user_enabled(user) hide = lambda comp: not (comp.is_user_configured(user) and comp.is_user_enabled(user)) and not all_entries - echo.echo_formatted_list(computers, ['name'], sort=sort, highlight=highlight, hide=hide) + echo.echo_formatted_list(computers, ['label'], sort=sort, highlight=highlight, hide=hide) @verdi_computer.command('show') diff --git a/environment.yml b/environment.yml index f2a3bf75c2..71df1d2412 100644 --- a/environment.yml +++ b/environment.yml @@ -14,7 +14,7 @@ dependencies: - click-completion~=0.5.1 - click-config-file~=0.6.0 - click-spinner~=0.1.8 -- click~=7.0 +- click~=7.1 - dataclasses~=0.7 - django~=2.2 - ete3~=3.1 diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt index 37f5d111a9..a0c58557cc 100644 --- a/requirements/requirements-py-3.6.txt +++ b/requirements/requirements-py-3.6.txt @@ -17,7 +17,7 @@ certifi==2019.11.28 cffi==1.14.0 chardet==3.0.4 circus==0.17.1 -Click==7.0 +Click==7.1.2 click-completion==0.5.2 click-config-file==0.6.0 click-spinner==0.1.8 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 2b693de529..fe138a5333 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -17,7 +17,7 @@ certifi==2019.11.28 cffi==1.14.0 chardet==3.0.4 circus==0.17.1 -Click==7.0 +Click==7.1.2 click-completion==0.5.2 click-config-file==0.6.0 click-spinner==0.1.8 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 930f300a2e..d026394a97 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -16,7 +16,7 @@ certifi==2019.11.28 cffi==1.14.0 chardet==3.0.4 circus==0.17.1 -Click==7.0 +Click==7.1.2 click-completion==0.5.2 click-config-file==0.6.0 click-spinner==0.1.8 diff --git a/setup.json b/setup.json index 43dcbed5f8..79f25d1eb1 100644 --- a/setup.json +++ b/setup.json @@ -29,7 +29,7 @@ "click-completion~=0.5.1", "click-config-file~=0.6.0", "click-spinner~=0.1.8", - "click~=7.0", + "click~=7.1", "dataclasses~=0.7; python_version < '3.7.0'", "django~=2.2", "ete3~=3.1", From 3cf1d2ef9f0300a0ed9ef7cfade7c4f49ba09d91 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 27 Jan 2021 13:01:48 +0000 Subject: [PATCH 050/114] Add `to_aiida_type` to the public API (#4672) Since `to_aiida_type` is intended for public use, this commit makes it part of the public API, via `from aiida.orm import to_aiida_type`. --- aiida/orm/nodes/data/__init__.py | 4 ++-- docs/source/reference/api/public.rst | 1 + .../include/snippets/serialize/workchain_serialize.py | 5 +---- docs/source/topics/processes/usage.rst | 6 ++++-- tests/orm/data/test_to_aiida_type.py | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/aiida/orm/nodes/data/__init__.py b/aiida/orm/nodes/data/__init__.py index 0023f8a107..9734c08d2d 100644 --- a/aiida/orm/nodes/data/__init__.py +++ b/aiida/orm/nodes/data/__init__.py @@ -10,7 +10,7 @@ """Module with `Node` sub classes for data structures.""" from .array import ArrayData, BandsData, KpointsData, ProjectionData, TrajectoryData, XyData -from .base import BaseType +from .base import BaseType, to_aiida_type from .bool import Bool from .cif import CifData from .code import Code @@ -31,5 +31,5 @@ __all__ = ( 'Data', 'BaseType', 'ArrayData', 'BandsData', 'KpointsData', 'ProjectionData', 'TrajectoryData', 'XyData', 'Bool', 'CifData', 'Code', 'Float', 'FolderData', 'Int', 'List', 'OrbitalData', 'Dict', 'RemoteData', 'SinglefileData', - 'Str', 'StructureData', 'UpfData', 'NumericType' + 'Str', 'StructureData', 'UpfData', 'NumericType', 'to_aiida_type' ) diff --git a/docs/source/reference/api/public.rst b/docs/source/reference/api/public.rst index 66d30f5c87..0464d7a1ac 100644 --- a/docs/source/reference/api/public.rst +++ b/docs/source/reference/api/public.rst @@ -102,6 +102,7 @@ If a module is mentioned, then all the resources defined in its ``__all__`` are load_code load_computer load_group + to_aiida_type ``aiida.parsers`` diff --git a/docs/source/topics/processes/include/snippets/serialize/workchain_serialize.py b/docs/source/topics/processes/include/snippets/serialize/workchain_serialize.py index c8ce2b81dd..5980207626 100644 --- a/docs/source/topics/processes/include/snippets/serialize/workchain_serialize.py +++ b/docs/source/topics/processes/include/snippets/serialize/workchain_serialize.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- from aiida.engine import WorkChain -from aiida.orm.nodes.data import to_aiida_type -# The basic types need to be loaded such that they are registered with -# the 'to_aiida_type' function. -from aiida.orm.nodes.data.base import * +from aiida.orm import to_aiida_type class SerializeWorkChain(WorkChain): diff --git a/docs/source/topics/processes/usage.rst b/docs/source/topics/processes/usage.rst index a883be92f1..7ab5bfb043 100644 --- a/docs/source/topics/processes/usage.rst +++ b/docs/source/topics/processes/usage.rst @@ -201,12 +201,14 @@ This function, passed as ``serializer`` parameter to ``spec.input``, is invoked For inputs which are stored in the database (``non_db=False``), the serialization function should return an AiiDA data type. For ``non_db`` inputs, the function must be idempotent because it might be applied more than once. -The following example work chain takes three inputs ``a``, ``b``, ``c``, and simply returns the given inputs. The :func:`aiida.orm.nodes.data.base.to_aiida_type` function is used as serialization function. +The following example work chain takes three inputs ``a``, ``b``, ``c``, and simply returns the given inputs. +The :func:`~aiida.orm.nodes.data.base.to_aiida_type` function is used as serialization function. .. include:: include/snippets/serialize/workchain_serialize.py :code: python -This work chain can now be called with native Python types, which will automatically be converted to AiiDA types by the :func:`aiida.orm.nodes.data.base.to_aiida_type` function. Note that the module which defines the corresponding AiiDA type must be loaded for it to be recognized by :func:`aiida.orm.nodes.data.base.to_aiida_type`. +This work chain can now be called with native Python types, which will automatically be converted to AiiDA types by the :func:`~aiida.orm.nodes.data.base.to_aiida_type` function. +Note that the module which defines the corresponding AiiDA type must be loaded for it to be recognized by :func:`~aiida.orm.nodes.data.base.to_aiida_type`. .. include:: include/snippets/serialize/run_workchain_serialize.py :code: python diff --git a/tests/orm/data/test_to_aiida_type.py b/tests/orm/data/test_to_aiida_type.py index ea60034bc0..dd16d7f2e8 100644 --- a/tests/orm/data/test_to_aiida_type.py +++ b/tests/orm/data/test_to_aiida_type.py @@ -10,7 +10,7 @@ """ This module contains tests for the to_aiida_type serializer """ -from aiida.orm.nodes.data.base import to_aiida_type +from aiida.orm import to_aiida_type from aiida.orm import Dict, Int, Float, Bool, Str from aiida.backends.testbase import AiidaTestCase From e9f234ec256dd2f2b94c70be9826917bd9861ec0 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 27 Jan 2021 14:23:11 +0000 Subject: [PATCH 051/114] Add .dockerignore (#4564) This commit adds a `.dockerignore` file to inhibit any unecessary/unwanted files being copied into the Docker container, during the `COPY . aiida-core` command, and also reduces the build time. --- .dockerignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..84f915fed9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.benchmarks +.coverage +.mypy_cache +.pytest_cache +.tox +.vscode +aiida_core.egg-info +docs/build +pip-wheel-metadata +**/.DS_Store +**/*.pyc +**/__pycache__ From d2b255b713a82230ad4f298b112d7095c31c1f24 Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Wed, 27 Jan 2021 15:51:33 +0100 Subject: [PATCH 052/114] CI: Remove `--use-feature=2020-resolver` pip feature flag tests. (#4689) The feature is now on by default in the latest stable release. --- .github/workflows/test-install.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index 8c1c9d0393..d0b2432a91 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -42,11 +42,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 - continue-on-error: ${{ contains(matrix.pip-feature-flag, '2020-resolver') }} strategy: fail-fast: false matrix: - pip-feature-flag: [ '', '--use-feature=2020-resolver' ] extras: [ '', '[atomic_tools,docs,notebook,rest,tests]' ] steps: @@ -61,10 +59,9 @@ jobs: - name: Pip install id: pip_install - continue-on-error: ${{ contains(matrix.pip-feature-flag, '2020-resolver') }} run: | python -m pip --version - python -m pip install -e .${{ matrix.extras }} ${{ matrix.pip-feature-flag }} + python -m pip install -e .${{ matrix.extras }} python -m pip freeze - name: Test importing aiida @@ -72,11 +69,6 @@ jobs: run: python -c "import aiida" - - name: Warn about pip 2020 resolver issues. - if: steps.pip_install.outcome == 'failure' && contains(matrix.pip-feature-flag, '2020-resolver') - run: | - echo "::warning ::Encountered issues with the pip 2020-resolver." - install-with-conda: if: github.repository == 'aiidateam/aiida-core' From dcc80618368f405c02c9eaa6d122177e78d70a4b Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Wed, 27 Jan 2021 17:02:48 +0100 Subject: [PATCH 053/114] CI: Notify slack on failure of the test-install workflow. (#4690) --- .github/workflows/test-install.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index d0b2432a91..1934e4467a 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -69,6 +69,14 @@ jobs: run: python -c "import aiida" + - name: Send Slack notification + if: ${{ failure() && github.event_name == 'schedule' }} + uses: kpritam/slack-job-status-action@v1 + with: + job-status: ${{ job.status }} + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + channel: dev-aiida-core + install-with-conda: if: github.repository == 'aiidateam/aiida-core' @@ -101,6 +109,14 @@ jobs: source activate test-environment python -c "import aiida" + - name: Send Slack notification + if: ${{ failure() && github.event_name == 'schedule' }} + uses: kpritam/slack-job-status-action@v1 + with: + job-status: ${{ job.status }} + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + channel: dev-aiida-core + tests: needs: [install-with-pip, install-with-conda] @@ -170,6 +186,14 @@ jobs: run: .github/workflows/tests.sh + - name: Send Slack notification + if: ${{ failure() && github.event_name == 'schedule' }} + uses: kpritam/slack-job-status-action@v1 + with: + job-status: ${{ job.status }} + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + channel: dev-aiida-core + - name: Freeze test environment run: pip freeze | sed '1d' | tee requirements-py-${{ matrix.python-version }}.txt From 97cecd2ef57946dd53a0ecd2005f3d2d0a94a2aa Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 27 Jan 2021 16:27:28 +0000 Subject: [PATCH 054/114] Improve namedtuples in aiida/engine (#4688) This commit replaces old-style namedtuples with `typing.NamedTuple` sub-classes. This allows for typing of fields and better default value assignment. Note this feature requires python>=3.6.1, but it is anyhow intended that python 3.6 be dropped for the next release. --- aiida/engine/processes/calcjobs/calcjob.py | 2 +- aiida/engine/processes/exit_code.py | 26 +++++-------- aiida/engine/processes/functions.py | 2 +- aiida/engine/processes/workchains/utils.py | 39 +++++++++++-------- .../engine/processes/workchains/workchain.py | 2 +- aiida/engine/transports.py | 2 - setup.json | 2 +- 7 files changed, 36 insertions(+), 39 deletions(-) diff --git a/aiida/engine/processes/calcjobs/calcjob.py b/aiida/engine/processes/calcjobs/calcjob.py index 7fafe77a7c..b0bd6bf174 100644 --- a/aiida/engine/processes/calcjobs/calcjob.py +++ b/aiida/engine/processes/calcjobs/calcjob.py @@ -370,7 +370,7 @@ def parse(self, retrieved_temporary_folder: Optional[str] = None) -> ExitCode: for entry in self.node.get_outgoing(): self.out(entry.link_label, entry.node) - return exit_code or ExitCode(0) # type: ignore[call-arg] + return exit_code or ExitCode(0) def parse_scheduler_output(self, retrieved: orm.Node) -> Optional[ExitCode]: """Parse the output of the scheduler if that functionality has been implemented for the plugin.""" diff --git a/aiida/engine/processes/exit_code.py b/aiida/engine/processes/exit_code.py index cb13b0a765..c5baedebb7 100644 --- a/aiida/engine/processes/exit_code.py +++ b/aiida/engine/processes/exit_code.py @@ -8,38 +8,36 @@ # For further information please visit http://www.aiida.net # ########################################################################### """A namedtuple and namespace for ExitCodes that can be used to exit from Processes.""" -from collections import namedtuple +from typing import NamedTuple, Optional from aiida.common.extendeddicts import AttributeDict __all__ = ('ExitCode', 'ExitCodesNamespace') -class ExitCode(namedtuple('ExitCode', ['status', 'message', 'invalidates_cache'])): +class ExitCode(NamedTuple): """A simple data class to define an exit code for a :class:`~aiida.engine.processes.process.Process`. - When an instance of this clas is returned from a `Process._run()` call, it will be interpreted that the `Process` + When an instance of this class is returned from a `Process._run()` call, it will be interpreted that the `Process` should be terminated and that the exit status and message of the namedtuple should be set to the corresponding attributes of the node. - .. note:: this class explicitly sub-classes a namedtuple to not break backwards compatibility and to have it behave - exactly as a tuple. - :param status: positive integer exit status, where a non-zero value indicated the process failed, default is `0` - :type status: int - :param message: optional message with more details about the failure mode - :type message: str - :param invalidates_cache: optional flag, indicating that a process should not be used in caching - :type invalidates_cache: bool """ + status: int = 0 + message: Optional[str] = None + invalidates_cache: bool = False + def format(self, **kwargs: str) -> 'ExitCode': """Create a clone of this exit code where the template message is replaced by the keyword arguments. :param kwargs: replacement parameters for the template message - :return: `ExitCode` + """ + if self.message is None: + raise ValueError('message is None') try: message = self.message.format(**kwargs) except KeyError: @@ -49,10 +47,6 @@ def format(self, **kwargs: str) -> 'ExitCode': return ExitCode(self.status, message, self.invalidates_cache) -# Set the defaults for the `ExitCode` attributes -ExitCode.__new__.__defaults__ = (0, None, False) # type: ignore[attr-defined] - - class ExitCodesNamespace(AttributeDict): """A namespace of `ExitCode` instances that can be accessed through getattr as well as getitem. diff --git a/aiida/engine/processes/functions.py b/aiida/engine/processes/functions.py index 0dd6ef4759..4f8c9ef999 100644 --- a/aiida/engine/processes/functions.py +++ b/aiida/engine/processes/functions.py @@ -408,4 +408,4 @@ def run(self) -> Optional['ExitCode']: 'Must be a Data type or a mapping of {{string: Data}}'.format(result.__class__) ) - return ExitCode() # type: ignore[call-arg] + return ExitCode() diff --git a/aiida/engine/processes/workchains/utils.py b/aiida/engine/processes/workchains/utils.py index b25f15de20..e5cfdc6cc3 100644 --- a/aiida/engine/processes/workchains/utils.py +++ b/aiida/engine/processes/workchains/utils.py @@ -8,33 +8,36 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Utilities for `WorkChain` implementations.""" -from collections import namedtuple from functools import partial from inspect import getfullargspec from types import FunctionType # pylint: disable=no-name-in-module -from typing import List, Optional, Union +from typing import List, Optional, Union, NamedTuple from wrapt import decorator from ..exit_code import ExitCode __all__ = ('ProcessHandlerReport', 'process_handler') -ProcessHandlerReport = namedtuple('ProcessHandlerReport', 'do_break exit_code') -ProcessHandlerReport.__new__.__defaults__ = (False, ExitCode()) # type: ignore[attr-defined,call-arg] -"""A namedtuple to define a process handler report for a :class:`aiida.engine.BaseRestartWorkChain`. -This namedtuple should be returned by a process handler of a work chain instance if the condition of the handler was -met by the completed process. If no further handling should be performed after this method the `do_break` field should -be set to `True`. If the handler encountered a fatal error and the work chain needs to be terminated, an `ExitCode` with -non-zero exit status can be set. This exit code is what will be set on the work chain itself. This works because the -value of the `exit_code` field returned by the handler, will in turn be returned by the `inspect_process` step and -returning a non-zero exit code from any work chain step will instruct the engine to abort the work chain. +class ProcessHandlerReport(NamedTuple): + """A namedtuple to define a process handler report for a :class:`aiida.engine.BaseRestartWorkChain`. -:param do_break: boolean, set to `True` if no further process handlers should be called, default is `False` -:param exit_code: an instance of the :class:`~aiida.engine.processes.exit_code.ExitCode` tuple. If not explicitly set, - the default `ExitCode` will be instantiated which has status `0` meaning that the work chain step will be considered - successful and the work chain will continue to the next step. -""" + This namedtuple should be returned by a process handler of a work chain instance if the condition of the handler was + met by the completed process. If no further handling should be performed after this method the `do_break` field + should be set to `True`. + If the handler encountered a fatal error and the work chain needs to be terminated, an `ExitCode` with + non-zero exit status can be set. This exit code is what will be set on the work chain itself. This works because the + value of the `exit_code` field returned by the handler, will in turn be returned by the `inspect_process` step and + returning a non-zero exit code from any work chain step will instruct the engine to abort the work chain. + + :param do_break: boolean, set to `True` if no further process handlers should be called, default is `False` + :param exit_code: an instance of the :class:`~aiida.engine.processes.exit_code.ExitCode` tuple. + If not explicitly set, the default `ExitCode` will be instantiated, + which has status `0` meaning that the work chain step will be considered + successful and the work chain will continue to the next step. + """ + do_break: bool = False + exit_code: ExitCode = ExitCode() def process_handler( @@ -108,7 +111,9 @@ def wrapper(wrapped, instance, args, kwargs): # When the handler will be called by the `BaseRestartWorkChain` it will pass the node as the only argument node = args[0] - if exit_codes is not None and node.exit_status not in [exit_code.status for exit_code in exit_codes]: + if exit_codes is not None and node.exit_status not in [ + exit_code.status for exit_code in exit_codes # type: ignore[union-attr] + ]: result = None else: result = wrapped(*args, **kwargs) diff --git a/aiida/engine/processes/workchains/workchain.py b/aiida/engine/processes/workchains/workchain.py index 698ad9de44..aa105b6fe1 100644 --- a/aiida/engine/processes/workchains/workchain.py +++ b/aiida/engine/processes/workchains/workchain.py @@ -214,7 +214,7 @@ def _do_step(self) -> Any: else: # Set result to None unless stepper_result was non-zero positive integer or ExitCode with similar status if isinstance(stepper_result, int) and stepper_result > 0: - result = ExitCode(stepper_result) # type: ignore[call-arg] + result = ExitCode(stepper_result) elif isinstance(stepper_result, ExitCode) and stepper_result.status > 0: result = stepper_result else: diff --git a/aiida/engine/transports.py b/aiida/engine/transports.py index 8cd0204d40..3f7f259809 100644 --- a/aiida/engine/transports.py +++ b/aiida/engine/transports.py @@ -8,7 +8,6 @@ # For further information please visit http://www.aiida.net # ########################################################################### """A transport queue to batch process multiple tasks that require a Transport.""" -from collections import namedtuple import contextlib import logging import traceback @@ -41,7 +40,6 @@ class TransportQueue: up to that point. This way opening of transports (a costly operation) can be minimised. """ - AuthInfoEntry = namedtuple('AuthInfoEntry', ['authinfo', 'transport', 'callbacks', 'callback_handle']) def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None): """ diff --git a/setup.json b/setup.json index 79f25d1eb1..281c8ad3a3 100644 --- a/setup.json +++ b/setup.json @@ -7,7 +7,7 @@ "author_email": "developers@aiida.net", "description": "AiiDA is a workflow manager for computational science with a strong focus on provenance, performance and extensibility.", "include_package_data": true, - "python_requires": ">=3.6", + "python_requires": ">=3.6.1", "classifiers": [ "Framework :: AiiDA", "License :: OSI Approved :: MIT License", From a490fe07ab5752f04efaac338c1f1a9ef648426a Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Thu, 28 Jan 2021 11:35:19 +0100 Subject: [PATCH 055/114] test AiiDA ipython magics and remove copy-paste in docs (#4548) Adds tests for the AiiDA IPython extension. Also: * move some additional lines from the registration snippet to aiida-core (where we can adapt it if the IPython API ever changes) * rename and deprecate misnomer `load_ipython_extension` to `register_ipython_extension` (to be removed in aiida 3) * include the snippet to register the AiiDA ipython magics from the aiida-core codebase instead of the (already outdated) copy-pasted version. * revisit the corresponding section of the documentation, starting with the setup, and removing some generic information about jupyter. --- .ci/test_ipython_magics.py | 29 ++++++++++ .github/workflows/tests.sh | 1 + aiida/tools/ipython/aiida_magic_register.py | 13 ++--- aiida/tools/ipython/ipython_magics.py | 31 ++++++++--- docs/source/intro/installation.rst | 59 ++++++++------------- 5 files changed, 79 insertions(+), 54 deletions(-) create mode 100644 .ci/test_ipython_magics.py diff --git a/.ci/test_ipython_magics.py b/.ci/test_ipython_magics.py new file mode 100644 index 0000000000..6378f430e8 --- /dev/null +++ b/.ci/test_ipython_magics.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Test the AiiDA iPython magics.""" +from IPython.testing.globalipapp import get_ipython +from aiida.tools.ipython.ipython_magics import register_ipython_extension + + +def test_ipython_magics(): + """Test that the %aiida magic can be loaded and adds the QueryBuilder and Node variables.""" + ipy = get_ipython() + register_ipython_extension(ipy) + + cell = """ +%aiida +qb=QueryBuilder() +qb.append(Node) +qb.all() +Dict().store() +""" + result = ipy.run_cell(cell) + + assert result.success diff --git a/.github/workflows/tests.sh b/.github/workflows/tests.sh index 66ad9b5e76..ed4887a43e 100755 --- a/.github/workflows/tests.sh +++ b/.github/workflows/tests.sh @@ -24,6 +24,7 @@ verdi daemon stop # tests for the testing infrastructure pytest --noconftest .ci/test_test_manager.py +pytest --noconftest .ci/test_ipython_magics.py pytest --noconftest .ci/test_profile_manager.py python .ci/test_plugin_testcase.py # uses custom unittest test runner diff --git a/aiida/tools/ipython/aiida_magic_register.py b/aiida/tools/ipython/aiida_magic_register.py index b059e19384..f458ddc46a 100644 --- a/aiida/tools/ipython/aiida_magic_register.py +++ b/aiida/tools/ipython/aiida_magic_register.py @@ -15,20 +15,15 @@ The start up folder is usually at ``.ipython/profile_default/startup/`` """ +# DOCUMENTATION MARKER if __name__ == '__main__': try: import aiida del aiida except ImportError: + # AiiDA is not installed in this Python environment pass else: - import IPython - # pylint: disable=ungrouped-imports - from aiida.tools.ipython.ipython_magics import load_ipython_extension - - # Get the current Ipython session - IPYSESSION = IPython.get_ipython() - - # Register the line magic - load_ipython_extension(IPYSESSION) + from aiida.tools.ipython.ipython_magics import register_ipython_extension + register_ipython_extension() diff --git a/aiida/tools/ipython/ipython_magics.py b/aiida/tools/ipython/ipython_magics.py index 8ae158a726..114a1b1ae2 100644 --- a/aiida/tools/ipython/ipython_magics.py +++ b/aiida/tools/ipython/ipython_magics.py @@ -34,10 +34,8 @@ In [2]: %aiida """ -from IPython import version_info # pylint: disable=no-name-in-module -from IPython.core import magic # pylint: disable=no-name-in-module,import-error - -from aiida.common import json +from IPython import version_info, get_ipython +from IPython.core import magic def add_to_ns(local_ns, name, obj): @@ -99,6 +97,8 @@ def _repr_json_(self): """ Output in JSON format. """ + from aiida.common import json + obj = {'current_state': self.current_state} if version_info[0] >= 3: return obj @@ -130,11 +130,10 @@ def _repr_latex_(self): return latex - def _repr_pretty_(self, pretty_print, cycle): + def _repr_pretty_(self, pretty_print, cycle): # pylint: disable=unused-argument """ Output in text format. """ - # pylint: disable=unused-argument if self.is_warning: warning_str = '** ' else: @@ -146,6 +145,24 @@ def _repr_pretty_(self, pretty_print, cycle): def load_ipython_extension(ipython): """ - Triggers the load of all the AiiDA magic commands. + Registers the %aiida IPython extension. + + .. deprecated:: v3.0.0 + Use :py:func:`~aiida.tools.ipython.ipython_magics.register_ipython_extension` instead. + """ + register_ipython_extension(ipython) + + +def register_ipython_extension(ipython=None): """ + Registers the %aiida IPython extension. + + The %aiida IPython extension provides the same environment as the `verdi shell`. + + :param ipython: InteractiveShell instance. If omitted, the global InteractiveShell is used. + + """ + if ipython is None: + ipython = get_ipython() + ipython.register_magics(AiiDALoaderMagics) diff --git a/docs/source/intro/installation.rst b/docs/source/intro/installation.rst index 0f3e891d08..2bc09e1add 100644 --- a/docs/source/intro/installation.rst +++ b/docs/source/intro/installation.rst @@ -246,57 +246,40 @@ The AiiDA daemon is controlled using three simple commands: Using AiiDA in Jupyter ---------------------- -`Jupyter `_ is an open-source web application that allows you to create in-browser notebooks containing live code, visualizations and formatted text. + 1. Install the AiiDA ``notebook`` extra **inside** the AiiDA python environment, e.g. by running ``pip install aiida-core[notebook]``. -Originally born out of the iPython project, it now supports code written in many languages and customized iPython kernels. + 2. (optional) Register the ``%aiida`` IPython magic for loading the same environment as in the ``verdi shell``: -If you didn't already install AiiDA with the ``[notebook]`` option (during ``pip install``), run ``pip install jupyter`` **inside** the virtualenv, and then run **from within the virtualenv**: + Copy the following code snippet into ``/.ipython/profile_default/startup/aiida_magic_register.py`` -.. code-block:: console - - $ jupyter notebook - -This will open a tab in your browser. Click on ``New -> Python`` and type: - -.. code-block:: python - - import aiida - -followed by ``Shift-Enter``. If no exception is thrown, you can use AiiDA in Jupyter. + .. literalinclude:: ../../../aiida/tools/ipython/aiida_magic_register.py + :start-after: # DOCUMENTATION MARKER -If you want to set the same environment as in a ``verdi shell``, -add the following code to a ``.py`` file (create one if there isn't any) in ``/.ipython/profile_default/startup/``: + .. note:: Use ``ipython locate profile`` if you're unsure about the location of your ipython profile folder. -.. code-block:: python - - try: - import aiida - except ImportError: - pass - else: - import IPython - from aiida.tools.ipython.ipython_magics import load_ipython_extension - # Get the current Ipython session - ipython = IPython.get_ipython() +With this setup, you're ready to use AiiDA in Jupyter notebeooks. - # Register the line magic - load_ipython_extension(ipython) - -This file will be executed when the ipython kernel starts up and enable the line magic ``%aiida``. -Alternatively, if you have a ``aiida-core`` repository checked out locally, -you can just copy the file ``/aiida/tools/ipython/aiida_magic_register.py`` to the same folder. -The current ipython profile folder can be located using: +Start a Jupyter notebook server: .. code-block:: console - $ ipython locate profile + $ jupyter notebook + +This will open a tab in your browser. Click on ``New -> Python``. -After this, if you open a Jupyter notebook as explained above and type in a cell: +If you registered the ``%aiida`` IPython magic, simply run: .. code-block:: ipython %aiida -followed by ``Shift-Enter``. You should receive the message "Loaded AiiDA DB environment." -This line magic should also be enabled in standard ipython shells. +After executing the cell by ``Shift-Enter``, you should receive the message "Loaded AiiDA DB environment." +Otherwise, you can load the profile manually as you would in a Python script: + +.. code-block:: python + + from aiida import load_profile, orm + load_profile() + qb = orm.QueryBuilder() + # ... From 48fa47584c993cdac019c6199bc045a4c19da152 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 1 Feb 2021 16:58:01 +0100 Subject: [PATCH 056/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20typing=20failure?= =?UTF-8?q?=20(#4700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As of numpy v1.20, `numpy.inf` is no longer recognised as an integer type --- aiida/tools/graph/graph_traversers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiida/tools/graph/graph_traversers.py b/aiida/tools/graph/graph_traversers.py index 209f22bbc5..c731a4a672 100644 --- a/aiida/tools/graph/graph_traversers.py +++ b/aiida/tools/graph/graph_traversers.py @@ -209,7 +209,7 @@ def traverse_graph( # pylint: disable=too-many-locals,too-many-statements,too-many-branches if max_iterations is None: - max_iterations = inf + max_iterations = cast(int, inf) elif not (isinstance(max_iterations, int) or max_iterations is inf): raise TypeError('Max_iterations has to be an integer or infinity') From 998a9677d50407c41611366ecb6cd299c5dff788 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 7 Feb 2021 19:01:21 +0100 Subject: [PATCH 057/114] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20fix=20typo=20(#4?= =?UTF-8?q?711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/source/topics/provenance/caching.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/topics/provenance/caching.rst b/docs/source/topics/provenance/caching.rst index 4c9867c381..dd5e242ec3 100644 --- a/docs/source/topics/provenance/caching.rst +++ b/docs/source/topics/provenance/caching.rst @@ -82,7 +82,7 @@ Controlling Caching Although you can't directly controll the hashing mechanism of the process node when implementing a plugin, there are ways in which you can control its caching: -* The :meth:` spec.exit_code ` has a keyword argument ``invalidates_cache``. +* The :meth:`spec.exit_code ` has a keyword argument ``invalidates_cache``. If this is set to ``True``, that means that a calculation with this exit code will not be used as a cache source for another one, even if their hashes match. * The :class:`Process ` parent class from which calcjobs inherit has an :meth:`is_valid_cache ` method, which can be overriden in the plugin to implement custom ways of invalidating the cache. When doing this, make sure to call :meth:`super().is_valid_cache(node)` and respect its output: if it is `False`, your implementation should also return `False`. From 285ca45c41db75fbb0ed7eae5f6cdd3afd652da3 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Mon, 8 Feb 2021 11:28:09 +0100 Subject: [PATCH 058/114] BUILD: drop support for python 3.6 (#4701) Following our support table, we drop python 3.6 support. --- .github/workflows/ci-code.yml | 6 +- .github/workflows/test-install.yml | 2 +- aiida/engine/daemon/runner.py | 13 +- aiida/orm/implementation/django/comments.py | 4 +- aiida/orm/implementation/django/nodes.py | 6 +- docs/source/intro/install_system.rst | 2 +- environment.yml | 1 - pyproject.toml | 2 - requirements/requirements-py-3.6.txt | 170 -------------------- setup.json | 4 +- utils/dependency_management.py | 3 +- 11 files changed, 14 insertions(+), 199 deletions(-) delete mode 100644 requirements/requirements-py-3.6.txt diff --git a/.github/workflows/ci-code.yml b/.github/workflows/ci-code.yml index 95f2c8676a..704dce0d1c 100644 --- a/.github/workflows/ci-code.yml +++ b/.github/workflows/ci-code.yml @@ -50,7 +50,7 @@ jobs: fail-fast: false matrix: backend: ['django', 'sqlalchemy'] - python-version: [3.6, 3.8] + python-version: [3.7, 3.8] services: postgres: @@ -110,10 +110,10 @@ jobs: .github/workflows/tests.sh - name: Upload coverage report - if: matrix.python-version == 3.6 && github.repository == 'aiidateam/aiida-core' + if: matrix.python-version == 3.7 && github.repository == 'aiidateam/aiida-core' uses: codecov/codecov-action@v1 with: - name: aiida-pytests-py3.6-${{ matrix.backend }} + name: aiida-pytests-py3.7-${{ matrix.backend }} flags: ${{ matrix.backend }} file: ./coverage.xml fail_ci_if_error: false # don't fail job, if coverage upload fails diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index 1934e4467a..b9fb994ce9 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -126,7 +126,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] backend: ['django', 'sqlalchemy'] services: diff --git a/aiida/engine/daemon/runner.py b/aiida/engine/daemon/runner.py index 1826da38c2..c807c54953 100644 --- a/aiida/engine/daemon/runner.py +++ b/aiida/engine/daemon/runner.py @@ -22,17 +22,10 @@ async def shutdown_runner(runner: Runner) -> None: """Cleanup tasks tied to the service's shutdown.""" - LOGGER.info('Received signal to shut down the daemon runner') - - try: - from asyncio import all_tasks - from asyncio import current_task - except ImportError: - # Necessary for Python 3.6 as `asyncio.all_tasks` and `asyncio.current_task` were introduced in Python 3.7. The - # Standalone functions should be used as the classmethods are removed as of Python 3.9. - all_tasks = asyncio.Task.all_tasks - current_task = asyncio.Task.current_task + from asyncio import all_tasks + from asyncio import current_task + LOGGER.info('Received signal to shut down the daemon runner') tasks = [task for task in all_tasks() if task is not current_task()] for task in tasks: diff --git a/aiida/orm/implementation/django/comments.py b/aiida/orm/implementation/django/comments.py index abdcf798ab..1e6f2b0521 100644 --- a/aiida/orm/implementation/django/comments.py +++ b/aiida/orm/implementation/django/comments.py @@ -67,9 +67,7 @@ def store(self): if self._dbmodel.dbnode.id is None or self._dbmodel.user.id is None: raise exceptions.ModificationNotAllowed('The corresponding node and/or user are not stored') - # `contextlib.suppress` provides empty context and can be replaced with `contextlib.nullcontext` after we drop - # support for python 3.6 - with suppress_auto_now([(models.DbComment, ['mtime'])]) if self.mtime else contextlib.suppress(): + with suppress_auto_now([(models.DbComment, ['mtime'])]) if self.mtime else contextlib.nullcontext(): super().store() @property diff --git a/aiida/orm/implementation/django/nodes.py b/aiida/orm/implementation/django/nodes.py index d8f527e5fd..af47942246 100644 --- a/aiida/orm/implementation/django/nodes.py +++ b/aiida/orm/implementation/django/nodes.py @@ -201,10 +201,8 @@ def store(self, links=None, with_transaction=True, clean=True): # pylint: disab if clean: self.clean_values() - # `contextlib.suppress` provides empty context and can be replaced with `contextlib.nullcontext` after we drop - # support for python 3.6 - with transaction.atomic() if with_transaction else contextlib.suppress(): - with suppress_auto_now([(models.DbNode, ['mtime'])]) if self.mtime else contextlib.suppress(): + with transaction.atomic() if with_transaction else contextlib.nullcontext(): + with suppress_auto_now([(models.DbNode, ['mtime'])]) if self.mtime else contextlib.nullcontext(): # We need to save the node model instance itself first such that it has a pk # that can be used in the foreign keys that will be needed for setting the # attributes and links diff --git a/docs/source/intro/install_system.rst b/docs/source/intro/install_system.rst index 39550fd1eb..5df3023a12 100644 --- a/docs/source/intro/install_system.rst +++ b/docs/source/intro/install_system.rst @@ -15,7 +15,7 @@ This is the *recommended* installation method to setup AiiDA on a personal lapto **Install prerequisite services** - AiiDA is designed to run on `Unix `_ operating systems and requires a `bash `_ or `zsh `_ shell, and Python >= 3.6. + AiiDA is designed to run on `Unix `_ operating systems and requires a `bash `_ or `zsh `_ shell, and Python >= 3.7. .. tabbed:: Ubuntu diff --git a/environment.yml b/environment.yml index 71df1d2412..c079a48523 100644 --- a/environment.yml +++ b/environment.yml @@ -15,7 +15,6 @@ dependencies: - click-config-file~=0.6.0 - click-spinner~=0.1.8 - click~=7.1 -- dataclasses~=0.7 - django~=2.2 - ete3~=3.1 - python-graphviz~=0.13 diff --git a/pyproject.toml b/pyproject.toml index 97975b5c3f..70abc15eb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,6 @@ envlist = py37-django [testenv] usedevelop=True deps = - py36: -rrequirements/requirements-py-3.6.txt py37: -rrequirements/requirements-py-3.7.txt py38: -rrequirements/requirements-py-3.8.txt py39: -rrequirements/requirements-py-3.9.txt @@ -109,7 +108,6 @@ commands = # tip: remove apidocs before using this feature (`cd docs; make clean`) description = Build the documentation and launch browser (with live updates) deps = - py36: -rrequirements/requirements-py-3.6.txt py37: -rrequirements/requirements-py-3.7.txt py38: -rrequirements/requirements-py-3.8.txt py39: -rrequirements/requirements-py-3.9.txt diff --git a/requirements/requirements-py-3.6.txt b/requirements/requirements-py-3.6.txt deleted file mode 100644 index a0c58557cc..0000000000 --- a/requirements/requirements-py-3.6.txt +++ /dev/null @@ -1,170 +0,0 @@ -aiida-export-migration-tests==0.9.0 -alabaster==0.7.12 -aldjemy==0.9.1 -alembic==1.4.1 -aio-pika==6.6.1 -aniso8601==8.0.0 -appdirs==1.4.4 -appnope==0.1.0 -archive-path==0.2.1 -ase==3.19.0 -attrs==19.3.0 -Babel==2.8.0 -backcall==0.1.0 -bcrypt==3.1.7 -bleach==3.1.4 -certifi==2019.11.28 -cffi==1.14.0 -chardet==3.0.4 -circus==0.17.1 -Click==7.1.2 -click-completion==0.5.2 -click-config-file==0.6.0 -click-spinner==0.1.8 -configobj==5.0.6 -coverage==4.5.4 -cryptography==3.2 -cycler==0.10.0 -dataclasses==0.7 -decorator==4.4.2 -defusedxml==0.6.0 -Django==2.2.11 -docutils==0.15.2 -entrypoints==0.3 -ete3==3.1.1 -flake8==3.8.3 -Flask==1.1.1 -Flask-Cors==3.0.8 -Flask-RESTful==0.3.8 -frozendict==1.2 -furl==2.1.0 -future==0.18.2 -graphviz==0.13.2 -idna==2.9 -imagesize==1.2.0 -importlib-metadata==1.5.0 -iniconfig==1.1.1 -ipykernel==5.1.4 -ipython==7.13.0 -ipython-genutils==0.2.0 -ipywidgets==7.5.1 -itsdangerous==1.1.0 -jedi==0.16.0 -Jinja2==2.11.1 -jsonschema==3.2.0 -jupyter==1.0.0 -jupyter-client==6.0.0 -jupyter-console==6.1.0 -jupyter-core==4.6.3 -kiwipy==0.7.1 -kiwisolver==1.1.0 -Mako==1.1.2 -MarkupSafe==1.1.1 -matplotlib==3.2.0 -mccabe==0.6.1 -mistune==0.8.4 -monty==3.0.2 -more-itertools==8.2.0 -mpmath==1.1.0 -nbconvert==5.6.1 -nbformat==5.0.4 -networkx==2.4 -notebook==6.1.5 -numpy==1.17.5 -orderedmultidict==1.0.1 -packaging==20.3 -palettable==3.3.0 -pamqp==2.3 -pandas==0.25.3 -pandocfilters==1.4.2 -paramiko==2.7.2 -parso==0.6.2 -pathspec==0.8.0 -pexpect==4.8.0 -pg8000==1.13.2 -pgsu==0.1.0 -pgtest==1.3.2 -pickleshare==0.7.5 -pluggy==0.13.1 -plumpy==0.18.4 -prometheus-client==0.7.1 -prompt-toolkit==3.0.4 -psutil==5.7.0 -psycopg2-binary==2.8.4 -ptyprocess==0.6.0 -py==1.9.0 -py-cpuinfo==7.0.0 -PyCifRW==4.4.1 -pycparser==2.20 -pydata-sphinx-theme==0.4.1 -PyDispatcher==2.0.5 -pyflakes==2.2.0 -Pygments==2.6.1 -pymatgen==2020.3.2 -PyMySQL==0.9.3 -PyNaCl==1.3.0 -pyparsing==2.4.6 -pyrsistent==0.15.7 -pytest==6.0.0 -pytest-benchmark==3.2.3 -pytest-cov==2.8.1 -pytest-rerunfailures==9.1.1 -pytest-timeout==1.3.4 -pytest-asyncio==0.12.0 -python-dateutil==2.8.1 -python-editor==1.0.4 -python-memcached==1.59 -pytz==2019.3 -PyYAML==5.1.2 -pyzmq==19.0.0 -qtconsole==4.7.1 -QtPy==1.9.0 -reentry==1.3.1 -regex==2020.7.14 -requests==2.23.0 -ruamel.yaml==0.16.10 -ruamel.yaml.clib==0.2.0 -scipy==1.4.1 -scramp==1.1.0 -seekpath==1.9.4 -Send2Trash==1.5.0 -shellingham==1.3.2 -shortuuid==1.0.1 -simplejson==3.17.0 -six==1.14.0 -snowballstemmer==2.0.0 -spglib==1.14.1.post0 -Sphinx==3.2.1 -sphinx-copybutton==0.3.0 -sphinx-notfound-page==0.5 -sphinx-panels==0.5.2 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-contentui==0.2.4 -sphinxcontrib-details-directive==0.1.0 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==1.0.3 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.4 -sphinxext-rediraffe==0.2.4 -SQLAlchemy==1.3.13 -sqlalchemy-diff==0.1.3 -SQLAlchemy-Utils==0.34.2 -sqlparse==0.3.1 -sympy==1.5.1 -tabulate==0.8.6 -terminado==0.8.3 -testpath==0.4.4 -toml==0.10.1 -tqdm==4.45.0 -traitlets==4.3.3 -typed-ast==1.4.1 -tzlocal==2.0.0 -upf-to-json==0.9.2 -urllib3==1.25.8 -wcwidth==0.1.8 -webencodings==0.5.1 -Werkzeug==1.0.0 -widgetsnbextension==3.5.1 -wrapt==1.11.2 -zipp==3.1.0 diff --git a/setup.json b/setup.json index 281c8ad3a3..bafdd8085d 100644 --- a/setup.json +++ b/setup.json @@ -7,14 +7,13 @@ "author_email": "developers@aiida.net", "description": "AiiDA is a workflow manager for computational science with a strong focus on provenance, performance and extensibility.", "include_package_data": true, - "python_requires": ">=3.6.1", + "python_requires": ">=3.7", "classifiers": [ "Framework :: AiiDA", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Programming Language :: Python", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -30,7 +29,6 @@ "click-config-file~=0.6.0", "click-spinner~=0.1.8", "click~=7.1", - "dataclasses~=0.7; python_version < '3.7.0'", "django~=2.2", "ete3~=3.1", "graphviz~=0.13", diff --git a/utils/dependency_management.py b/utils/dependency_management.py index 14f30c5280..79782e504e 100755 --- a/utils/dependency_management.py +++ b/utils/dependency_management.py @@ -247,7 +247,8 @@ def validate_environment_yml(): # pylint: disable=too-many-branches # The Python version should be specified as supported in 'setup.json'. if not any(spec.version >= other_spec.version for other_spec in python_requires.specifier): raise DependencySpecificationError( - "Required Python version between 'setup.json' and 'environment.yml' not consistent." + f"Required Python version {spec.version} from 'environment.yaml' is not consistent with " + + "required version in 'setup.json'." ) break From 5d5fd4f4970743ddb2956a553db967e017fbbb0e Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Mon, 8 Feb 2021 12:17:44 +0100 Subject: [PATCH 059/114] BUILD: bump jenkins dockerimage to 20.04 (#4714) Despite python3.7 being installed on the Jenkins dockerimage, pip install failed after dropping python 3.6 support (likely because pip from python 3.6 was being used). We update ubuntu to 20.04, which comes with python 3.8.2 by default. --- .ci/Dockerfile | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.ci/Dockerfile b/.ci/Dockerfile index e8458d74eb..27884ed493 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 +FROM ubuntu:20.04 MAINTAINER AiiDA Team # This is necessary such that the setup of `tzlocal` is non-interactive @@ -34,15 +34,12 @@ RUN apt-get update \ git \ vim \ openssh-client \ - postgresql-client-10 \ - postgresql-10 \ - postgresql-server-dev-10 \ + postgresql \ + postgresql-client \ && apt-get -y install \ - python3.7 python3.7-dev \ python3-pip \ - ipython \ texlive-base \ - texlive-generic-recommended \ + texlive-plain-generic \ texlive-fonts-recommended \ texlive-latex-base \ texlive-latex-recommended \ @@ -57,8 +54,8 @@ RUN apt-get update \ # Disable password requests for requests coming from localhost # Of course insecure, but ok for testing -RUN cp /etc/postgresql/10/main/pg_hba.conf /etc/postgresql/10/main/pg_hba.conf~ && \ - perl -npe 's/^([^#]*)md5$/$1trust/' /etc/postgresql/10/main/pg_hba.conf~ > /etc/postgresql/10/main/pg_hba.conf +RUN cp /etc/postgresql/12/main/pg_hba.conf /etc/postgresql/12/main/pg_hba.conf~ && \ + perl -npe 's/^([^#]*)md5$/$1trust/' /etc/postgresql/12/main/pg_hba.conf~ > /etc/postgresql/12/main/pg_hba.conf # install sudo otherwise tests for quicksetup fail, # see #1382. I think this part should be removed in the From 443dc01f0fdaba11060a2d6ddcd9cebf911d22c6 Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Mon, 8 Feb 2021 14:03:28 +0100 Subject: [PATCH 060/114] Switch matrix order in continuous-integration tests job. (#4713) To harmonize with test-install workflow. --- .github/workflows/ci-code.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-code.yml b/.github/workflows/ci-code.yml index 704dce0d1c..6ffa0248b7 100644 --- a/.github/workflows/ci-code.yml +++ b/.github/workflows/ci-code.yml @@ -49,8 +49,8 @@ jobs: strategy: fail-fast: false matrix: - backend: ['django', 'sqlalchemy'] python-version: [3.7, 3.8] + backend: ['django', 'sqlalchemy'] services: postgres: From a8e6b89e0765c120d3947c4b7165055d02f16d3e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 8 Feb 2021 16:31:26 +0100 Subject: [PATCH 061/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20REFACTOR:=20verdi?= =?UTF-8?q?=20export/import=20->=20verdi=20archive=20(#4710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit deprecates `verdi export` and `verdi import` and combines them into `verdi archive`. --- aiida/cmdline/commands/__init__.py | 6 +- aiida/cmdline/commands/cmd_archive.py | 490 ++++++++++++++++++ aiida/cmdline/commands/cmd_export.py | 174 +------ aiida/cmdline/commands/cmd_import.py | 171 +----- docs/source/howto/data.rst | 2 +- docs/source/howto/share_data.rst | 20 +- .../visualising_graphs/visualising_graphs.rst | 2 +- docs/source/internals/data_storage.rst | 2 +- docs/source/reference/command_line.rst | 27 +- docs/source/topics/cli.rst | 6 +- docs/source/topics/provenance/consistency.rst | 2 +- ...{test_export.py => test_archive_export.py} | 46 +- ...{test_import.py => test_archive_import.py} | 43 +- 13 files changed, 613 insertions(+), 378 deletions(-) create mode 100644 aiida/cmdline/commands/cmd_archive.py rename tests/cmdline/commands/{test_export.py => test_archive_export.py} (88%) rename tests/cmdline/commands/{test_import.py => test_archive_import.py} (87%) diff --git a/aiida/cmdline/commands/__init__.py b/aiida/cmdline/commands/__init__.py index 93ebb48cc7..c80c47b6e8 100644 --- a/aiida/cmdline/commands/__init__.py +++ b/aiida/cmdline/commands/__init__.py @@ -16,7 +16,7 @@ # Import to populate the `verdi` sub commands from aiida.cmdline.commands import ( - cmd_calcjob, cmd_code, cmd_comment, cmd_completioncommand, cmd_computer, cmd_config, cmd_data, cmd_database, - cmd_daemon, cmd_devel, cmd_export, cmd_graph, cmd_group, cmd_help, cmd_import, cmd_node, cmd_plugin, cmd_process, - cmd_profile, cmd_rehash, cmd_restapi, cmd_run, cmd_setup, cmd_shell, cmd_status, cmd_user + cmd_archive, cmd_calcjob, cmd_code, cmd_comment, cmd_completioncommand, cmd_computer, cmd_config, cmd_data, + cmd_database, cmd_daemon, cmd_devel, cmd_export, cmd_graph, cmd_group, cmd_help, cmd_import, cmd_node, cmd_plugin, + cmd_process, cmd_profile, cmd_rehash, cmd_restapi, cmd_run, cmd_setup, cmd_shell, cmd_status, cmd_user ) diff --git a/aiida/cmdline/commands/cmd_archive.py b/aiida/cmdline/commands/cmd_archive.py new file mode 100644 index 0000000000..43878ca126 --- /dev/null +++ b/aiida/cmdline/commands/cmd_archive.py @@ -0,0 +1,490 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +# pylint: disable=too-many-arguments,import-error,too-many-locals,broad-except +"""`verdi archive` command.""" +from enum import Enum +from typing import List, Tuple +import traceback +import urllib.request + +import click +import tabulate + +from aiida.cmdline.commands.cmd_verdi import verdi +from aiida.cmdline.params import arguments, options +from aiida.cmdline.params.types import GroupParamType, PathOrUrl +from aiida.cmdline.utils import decorators, echo +from aiida.common.links import GraphTraversalRules + +EXTRAS_MODE_EXISTING = ['keep_existing', 'update_existing', 'mirror', 'none', 'ask'] +EXTRAS_MODE_NEW = ['import', 'none'] +COMMENT_MODE = ['newest', 'overwrite'] + + +@verdi.group('archive') +def verdi_archive(): + """Create, inspect and import AiiDA archives.""" + + +@verdi_archive.command('inspect') +@click.argument('archive', nargs=1, type=click.Path(exists=True, readable=True)) +@click.option('-v', '--version', is_flag=True, help='Print the archive format version and exit.') +@click.option('-d', '--data', hidden=True, is_flag=True, help='Print the data contents and exit.') +@click.option('-m', '--meta-data', is_flag=True, help='Print the meta data contents and exit.') +def inspect(archive, version, data, meta_data): + """Inspect contents of an archive without importing it. + + By default a summary of the archive contents will be printed. The various options can be used to change exactly what + information is displayed. + + .. deprecated:: 1.5.0 + Support for the --data flag + + """ + import dataclasses + from aiida.tools.importexport import CorruptArchive, detect_archive_type, get_reader + + reader_cls = get_reader(detect_archive_type(archive)) + + with reader_cls(archive) as reader: + try: + if version: + echo.echo(reader.export_version) + elif data: + # data is an internal implementation detail + echo.echo_deprecated('--data is deprecated and will be removed in v2.0.0') + echo.echo_dictionary(reader._get_data()) # pylint: disable=protected-access + elif meta_data: + echo.echo_dictionary(dataclasses.asdict(reader.metadata)) + else: + statistics = { + 'Version aiida': reader.metadata.aiida_version, + 'Version format': reader.metadata.export_version, + 'Computers': reader.entity_count('Computer'), + 'Groups': reader.entity_count('Group'), + 'Links': reader.link_count, + 'Nodes': reader.entity_count('Node'), + 'Users': reader.entity_count('User'), + } + if reader.metadata.conversion_info: + statistics['Conversion info'] = '\n'.join(reader.metadata.conversion_info) + + echo.echo(tabulate.tabulate(statistics.items())) + except CorruptArchive as exception: + echo.echo_critical(f'corrupt archive: {exception}') + + +@verdi_archive.command('create') +@arguments.OUTPUT_FILE(type=click.Path(exists=False)) +@options.CODES() +@options.COMPUTERS() +@options.GROUPS() +@options.NODES() +@options.ARCHIVE_FORMAT( + type=click.Choice(['zip', 'zip-uncompressed', 'zip-lowmemory', 'tar.gz', 'null']), +) +@options.FORCE(help='Overwrite output file if it already exists.') +@click.option( + '-v', + '--verbosity', + default='INFO', + type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'CRITICAL']), + help='Control the verbosity of console logging' +) +@options.graph_traversal_rules(GraphTraversalRules.EXPORT.value) +@click.option( + '--include-logs/--exclude-logs', + default=True, + show_default=True, + help='Include or exclude logs for node(s) in export.' +) +@click.option( + '--include-comments/--exclude-comments', + default=True, + show_default=True, + help='Include or exclude comments for node(s) in export. (Will also export extra users who commented).' +) +# will only be useful when moving to a new archive format, that does not store all data in memory +# @click.option( +# '-b', +# '--batch-size', +# default=1000, +# type=int, +# help='Batch database query results in sub-collections to reduce memory usage.' +# ) +@decorators.with_dbenv() +def create( + output_file, codes, computers, groups, nodes, archive_format, force, input_calc_forward, input_work_forward, + create_backward, return_backward, call_calc_backward, call_work_backward, include_comments, include_logs, verbosity +): + """ + Export subsets of the provenance graph to file for sharing. + + Besides Nodes of the provenance graph, you can export Groups, Codes, Computers, Comments and Logs. + + By default, the archive file will include not only the entities explicitly provided via the command line but also + their provenance, according to the rules outlined in the documentation. + You can modify some of those rules using options of this command. + """ + # pylint: disable=too-many-branches + from aiida.common.log import override_log_formatter_context + from aiida.common.progress_reporter import set_progress_bar_tqdm, set_progress_reporter + from aiida.tools.importexport import export, ExportFileFormat, EXPORT_LOGGER + from aiida.tools.importexport.common.exceptions import ArchiveExportError + + entities = [] + + if codes: + entities.extend(codes) + + if computers: + entities.extend(computers) + + if groups: + entities.extend(groups) + + if nodes: + entities.extend(nodes) + + kwargs = { + 'input_calc_forward': input_calc_forward, + 'input_work_forward': input_work_forward, + 'create_backward': create_backward, + 'return_backward': return_backward, + 'call_calc_backward': call_calc_backward, + 'call_work_backward': call_work_backward, + 'include_comments': include_comments, + 'include_logs': include_logs, + 'overwrite': force, + } + + if archive_format == 'zip': + export_format = ExportFileFormat.ZIP + kwargs.update({'writer_init': {'use_compression': True}}) + elif archive_format == 'zip-uncompressed': + export_format = ExportFileFormat.ZIP + kwargs.update({'writer_init': {'use_compression': False}}) + elif archive_format == 'zip-lowmemory': + export_format = ExportFileFormat.ZIP + kwargs.update({'writer_init': {'cache_zipinfo': True}}) + elif archive_format == 'tar.gz': + export_format = ExportFileFormat.TAR_GZIPPED + elif archive_format == 'null': + export_format = 'null' + + if verbosity in ['DEBUG', 'INFO']: + set_progress_bar_tqdm(leave=(verbosity == 'DEBUG')) + else: + set_progress_reporter(None) + EXPORT_LOGGER.setLevel(verbosity) + + try: + with override_log_formatter_context('%(message)s'): + export(entities, filename=output_file, file_format=export_format, **kwargs) + except ArchiveExportError as exception: + echo.echo_critical(f'failed to write the archive file. Exception: {exception}') + else: + echo.echo_success(f'wrote the export archive file to {output_file}') + + +@verdi_archive.command('migrate') +@arguments.INPUT_FILE() +@arguments.OUTPUT_FILE(required=False) +@options.ARCHIVE_FORMAT() +@options.FORCE(help='overwrite output file if it already exists') +@click.option('-i', '--in-place', is_flag=True, help='Migrate the archive in place, overwriting the original file.') +@options.SILENT(hidden=True) +@click.option( + '-v', + '--version', + type=click.STRING, + required=False, + metavar='VERSION', + # Note: Adding aiida.tools.EXPORT_VERSION as a default value explicitly would result in a slow import of + # aiida.tools and, as a consequence, aiida.orm. As long as this is the case, better determine the latest export + # version inside the function when needed. + help='Archive format version to migrate to (defaults to latest version).', +) +@click.option( + '--verbosity', + default='INFO', + type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'CRITICAL']), + help='Control the verbosity of console logging' +) +def migrate(input_file, output_file, force, silent, in_place, archive_format, version, verbosity): + """Migrate an export archive to a more recent format version. + + .. deprecated:: 1.5.0 + Support for the --silent flag, replaced by --verbosity + + """ + from aiida.common.log import override_log_formatter_context + from aiida.common.progress_reporter import set_progress_bar_tqdm, set_progress_reporter + from aiida.tools.importexport import detect_archive_type, EXPORT_VERSION + from aiida.tools.importexport.archive.migrators import get_migrator, MIGRATE_LOGGER + + if silent is True: + echo.echo_deprecated('the --silent option is deprecated, use --verbosity') + + if in_place: + if output_file: + echo.echo_critical('output file specified together with --in-place flag') + output_file = input_file + force = True + elif not output_file: + echo.echo_critical( + 'no output file specified. Please add --in-place flag if you would like to migrate in place.' + ) + + if verbosity in ['DEBUG', 'INFO']: + set_progress_bar_tqdm(leave=(verbosity == 'DEBUG')) + else: + set_progress_reporter(None) + MIGRATE_LOGGER.setLevel(verbosity) + + if version is None: + version = EXPORT_VERSION + + migrator_cls = get_migrator(detect_archive_type(input_file)) + migrator = migrator_cls(input_file) + + try: + with override_log_formatter_context('%(message)s'): + migrator.migrate(version, output_file, force=force, out_compression=archive_format) + except Exception as error: # pylint: disable=broad-except + if verbosity == 'DEBUG': + raise + echo.echo_critical( + 'failed to migrate the archive file (use `--verbosity DEBUG` to see traceback): ' + f'{error.__class__.__name__}:{error}' + ) + + if verbosity in ['DEBUG', 'INFO']: + echo.echo_success(f'migrated the archive to version {version}') + + +class ExtrasImportCode(Enum): + """Exit codes for the verdi command line.""" + keep_existing = 'kcl' + update_existing = 'kcu' + mirror = 'ncu' + none = 'knl' + ask = 'kca' + + +@verdi_archive.command('import') +@click.argument('archives', nargs=-1, type=PathOrUrl(exists=True, readable=True)) +@click.option( + '-w', + '--webpages', + type=click.STRING, + cls=options.MultipleValueOption, + help='Discover all URL targets pointing to files with the .aiida extension for these HTTP addresses. ' + 'Automatically discovered archive URLs will be downloaded and added to ARCHIVES for importing.' +) +@options.GROUP( + type=GroupParamType(create_if_not_exist=True), + help='Specify group to which all the import nodes will be added. If such a group does not exist, it will be' + ' created automatically.' +) +@click.option( + '-e', + '--extras-mode-existing', + type=click.Choice(EXTRAS_MODE_EXISTING), + default='keep_existing', + help='Specify which extras from the export archive should be imported for nodes that are already contained in the ' + 'database: ' + 'ask: import all extras and prompt what to do for existing extras. ' + 'keep_existing: import all extras and keep original value of existing extras. ' + 'update_existing: import all extras and overwrite value of existing extras. ' + 'mirror: import all extras and remove any existing extras that are not present in the archive. ' + 'none: do not import any extras.' +) +@click.option( + '-n', + '--extras-mode-new', + type=click.Choice(EXTRAS_MODE_NEW), + default='import', + help='Specify whether to import extras of new nodes: ' + 'import: import extras. ' + 'none: do not import extras.' +) +@click.option( + '--comment-mode', + type=click.Choice(COMMENT_MODE), + default='newest', + help='Specify the way to import Comments with identical UUIDs: ' + 'newest: Only the newest Comments (based on mtime) (default).' + 'overwrite: Replace existing Comments with those from the import file.' +) +@click.option( + '--migration/--no-migration', + default=True, + show_default=True, + help='Force migration of archive file archives, if needed.' +) +@click.option( + '-v', + '--verbosity', + default='INFO', + type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'CRITICAL']), + help='Control the verbosity of console logging' +) +@options.NON_INTERACTIVE() +@decorators.with_dbenv() +@click.pass_context +def import_archive( + ctx, archives, webpages, group, extras_mode_existing, extras_mode_new, comment_mode, migration, non_interactive, + verbosity +): + """Import data from an AiiDA archive file. + + The archive can be specified by its relative or absolute file path, or its HTTP URL. + """ + # pylint: disable=unused-argument + from aiida.common.log import override_log_formatter_context + from aiida.common.progress_reporter import set_progress_bar_tqdm, set_progress_reporter + from aiida.tools.importexport.dbimport.utils import IMPORT_LOGGER + from aiida.tools.importexport.archive.migrators import MIGRATE_LOGGER + + if verbosity in ['DEBUG', 'INFO']: + set_progress_bar_tqdm(leave=(verbosity == 'DEBUG')) + else: + set_progress_reporter(None) + IMPORT_LOGGER.setLevel(verbosity) + MIGRATE_LOGGER.setLevel(verbosity) + + all_archives = _gather_imports(archives, webpages) + + # Preliminary sanity check + if not all_archives: + echo.echo_critical('no valid exported archives were found') + + # Shared import key-word arguments + import_kwargs = { + 'group': group, + 'extras_mode_existing': ExtrasImportCode[extras_mode_existing].value, + 'extras_mode_new': extras_mode_new, + 'comment_mode': comment_mode, + } + + with override_log_formatter_context('%(message)s'): + for archive, web_based in all_archives: + _import_archive(archive, web_based, import_kwargs, migration) + + +def _echo_exception(msg: str, exception, warn_only: bool = False): + """Correctly report and exception. + + :param msg: The message prefix + :param exception: the exception raised + :param warn_only: If True only print a warning, otherwise calls sys.exit with a non-zero exit status + + """ + from aiida.tools.importexport import IMPORT_LOGGER + message = f'{msg}: {exception.__class__.__name__}: {str(exception)}' + if warn_only: + echo.echo_warning(message) + else: + IMPORT_LOGGER.debug('%s', traceback.format_exc()) + echo.echo_critical(message) + + +def _gather_imports(archives, webpages) -> List[Tuple[str, bool]]: + """Gather archives to import and sort into local files and URLs. + + :returns: list of (archive path, whether it is web based) + + """ + from aiida.tools.importexport.common.utils import get_valid_import_links + + final_archives = [] + + # Build list of archives to be imported + for archive in archives: + if archive.startswith('http://') or archive.startswith('https://'): + final_archives.append((archive, True)) + else: + final_archives.append((archive, False)) + + # Discover and retrieve *.aiida files at URL(s) + if webpages is not None: + for webpage in webpages: + try: + echo.echo_info(f'retrieving archive URLS from {webpage}') + urls = get_valid_import_links(webpage) + except Exception as error: + echo.echo_critical( + f'an exception occurred while trying to discover archives at URL {webpage}:\n{error}' + ) + else: + echo.echo_success(f'{len(urls)} archive URLs discovered and added') + final_archives.extend([(u, True) for u in urls]) + + return final_archives + + +def _import_archive(archive: str, web_based: bool, import_kwargs: dict, try_migration: bool): + """Perform the archive import. + + :param archive: the path or URL to the archive + :param web_based: If the archive needs to be downloaded first + :param import_kwargs: keyword arguments to pass to the import function + :param try_migration: whether to try a migration if the import raises IncompatibleArchiveVersionError + + """ + from aiida.common.folders import SandboxFolder + from aiida.tools.importexport import ( + detect_archive_type, EXPORT_VERSION, import_data, IncompatibleArchiveVersionError + ) + from aiida.tools.importexport.archive.migrators import get_migrator + + with SandboxFolder() as temp_folder: + + archive_path = archive + + if web_based: + echo.echo_info(f'downloading archive: {archive}') + try: + response = urllib.request.urlopen(archive) + except Exception as exception: + _echo_exception(f'downloading archive {archive} failed', exception) + temp_folder.create_file_from_filelike(response, 'downloaded_archive.zip') + archive_path = temp_folder.get_abs_path('downloaded_archive.zip') + echo.echo_success('archive downloaded, proceeding with import') + + echo.echo_info(f'starting import: {archive}') + try: + import_data(archive_path, **import_kwargs) + except IncompatibleArchiveVersionError as exception: + if try_migration: + + echo.echo_info(f'incompatible version detected for {archive}, trying migration') + try: + migrator = get_migrator(detect_archive_type(archive_path))(archive_path) + archive_path = migrator.migrate( + EXPORT_VERSION, None, out_compression='none', work_dir=temp_folder.abspath + ) + except Exception as exception: + _echo_exception(f'an exception occurred while migrating the archive {archive}', exception) + + echo.echo_info('proceeding with import of migrated archive') + try: + import_data(archive_path, **import_kwargs) + except Exception as exception: + _echo_exception( + f'an exception occurred while trying to import the migrated archive {archive}', exception + ) + else: + _echo_exception(f'an exception occurred while trying to import the archive {archive}', exception) + except Exception as exception: + _echo_exception(f'an exception occurred while trying to import the archive {archive}', exception) + + echo.echo_success(f'imported archive {archive}') diff --git a/aiida/cmdline/commands/cmd_export.py b/aiida/cmdline/commands/cmd_export.py index add1f9641e..0e959de06c 100644 --- a/aiida/cmdline/commands/cmd_export.py +++ b/aiida/cmdline/commands/cmd_export.py @@ -7,30 +7,32 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### -# pylint: disable=too-many-arguments,import-error,too-many-locals +# pylint: disable=too-many-arguments,import-error,too-many-locals,unused-argument """`verdi export` command.""" import click -import tabulate from aiida.cmdline.commands.cmd_verdi import verdi -from aiida.cmdline.params import arguments -from aiida.cmdline.params import options +from aiida.cmdline.params import arguments, options from aiida.cmdline.utils import decorators -from aiida.cmdline.utils import echo from aiida.common.links import GraphTraversalRules +from aiida.cmdline.commands import cmd_archive -@verdi.group('export') + +@verdi.group('export', hidden=True) +@decorators.deprecated_command("This command has been deprecated. Please use 'verdi archive' instead.") def verdi_export(): - """Create and manage export archives.""" + """Deprecated, use `verdi archive`.""" @verdi_export.command('inspect') +@decorators.deprecated_command("This command has been deprecated. Please use 'verdi archive inspect' instead.") @click.argument('archive', nargs=1, type=click.Path(exists=True, readable=True)) @click.option('-v', '--version', is_flag=True, help='Print the archive format version and exit.') -@click.option('-d', '--data', is_flag=True, help='Print the data contents and exit.') +@click.option('-d', '--data', hidden=True, is_flag=True, help='Print the data contents and exit.') @click.option('-m', '--meta-data', is_flag=True, help='Print the meta data contents and exit.') -def inspect(archive, version, data, meta_data): +@click.pass_context +def inspect(ctx, archive, version, data, meta_data): """Inspect contents of an exported archive without importing it. By default a summary of the archive contents will be printed. The various options can be used to change exactly what @@ -40,40 +42,11 @@ def inspect(archive, version, data, meta_data): Support for the --data flag """ - import dataclasses - from aiida.tools.importexport import CorruptArchive, detect_archive_type, get_reader - - reader_cls = get_reader(detect_archive_type(archive)) - - with reader_cls(archive) as reader: - try: - if version: - echo.echo(reader.export_version) - elif data: - # data is an internal implementation detail - echo.echo_deprecated('--data is deprecated and will be removed in v2.0.0') - echo.echo_dictionary(reader._get_data()) # pylint: disable=protected-access - elif meta_data: - echo.echo_dictionary(dataclasses.asdict(reader.metadata)) - else: - statistics = { - 'Version aiida': reader.metadata.aiida_version, - 'Version format': reader.metadata.export_version, - 'Computers': reader.entity_count('Computer'), - 'Groups': reader.entity_count('Group'), - 'Links': reader.link_count, - 'Nodes': reader.entity_count('Node'), - 'Users': reader.entity_count('User'), - } - if reader.metadata.conversion_info: - statistics['Conversion info'] = '\n'.join(reader.metadata.conversion_info) - - echo.echo(tabulate.tabulate(statistics.items())) - except CorruptArchive as exception: - echo.echo_critical(f'corrupt archive: {exception}') + ctx.forward(cmd_archive.inspect) @verdi_export.command('create') +@decorators.deprecated_command("This command has been deprecated. Please use 'verdi archive create' instead.") @arguments.OUTPUT_FILE(type=click.Path(exists=False)) @options.CODES() @options.COMPUTERS() @@ -103,17 +76,10 @@ def inspect(archive, version, data, meta_data): show_default=True, help='Include or exclude comments for node(s) in export. (Will also export extra users who commented).' ) -# will only be useful when moving to a new archive format, that does not store all data in memory -# @click.option( -# '-b', -# '--batch-size', -# default=1000, -# type=int, -# help='Batch database query results in sub-collections to reduce memory usage.' -# ) +@click.pass_context @decorators.with_dbenv() def create( - output_file, codes, computers, groups, nodes, archive_format, force, input_calc_forward, input_work_forward, + ctx, output_file, codes, computers, groups, nodes, archive_format, force, input_calc_forward, input_work_forward, create_backward, return_backward, call_calc_backward, call_work_backward, include_comments, include_logs, verbosity ): """ @@ -125,74 +91,17 @@ def create( their provenance, according to the rules outlined in the documentation. You can modify some of those rules using options of this command. """ - # pylint: disable=too-many-branches - from aiida.common.log import override_log_formatter_context - from aiida.common.progress_reporter import set_progress_bar_tqdm, set_progress_reporter - from aiida.tools.importexport import export, ExportFileFormat, EXPORT_LOGGER - from aiida.tools.importexport.common.exceptions import ArchiveExportError - - entities = [] - - if codes: - entities.extend(codes) - - if computers: - entities.extend(computers) - - if groups: - entities.extend(groups) - - if nodes: - entities.extend(nodes) - - kwargs = { - 'input_calc_forward': input_calc_forward, - 'input_work_forward': input_work_forward, - 'create_backward': create_backward, - 'return_backward': return_backward, - 'call_calc_backward': call_calc_backward, - 'call_work_backward': call_work_backward, - 'include_comments': include_comments, - 'include_logs': include_logs, - 'overwrite': force, - } - - if archive_format == 'zip': - export_format = ExportFileFormat.ZIP - kwargs.update({'writer_init': {'use_compression': True}}) - elif archive_format == 'zip-uncompressed': - export_format = ExportFileFormat.ZIP - kwargs.update({'writer_init': {'use_compression': False}}) - elif archive_format == 'zip-lowmemory': - export_format = ExportFileFormat.ZIP - kwargs.update({'writer_init': {'cache_zipinfo': True}}) - elif archive_format == 'tar.gz': - export_format = ExportFileFormat.TAR_GZIPPED - elif archive_format == 'null': - export_format = 'null' - - if verbosity in ['DEBUG', 'INFO']: - set_progress_bar_tqdm(leave=(verbosity == 'DEBUG')) - else: - set_progress_reporter(None) - EXPORT_LOGGER.setLevel(verbosity) - - try: - with override_log_formatter_context('%(message)s'): - export(entities, filename=output_file, file_format=export_format, **kwargs) - except ArchiveExportError as exception: - echo.echo_critical(f'failed to write the archive file. Exception: {exception}') - else: - echo.echo_success(f'wrote the export archive file to {output_file}') + ctx.forward(cmd_archive.create) @verdi_export.command('migrate') +@decorators.deprecated_command("This command has been deprecated. Please use 'verdi archive migrate' instead.") @arguments.INPUT_FILE() @arguments.OUTPUT_FILE(required=False) @options.ARCHIVE_FORMAT() @options.FORCE(help='overwrite output file if it already exists') @click.option('-i', '--in-place', is_flag=True, help='Migrate the archive in place, overwriting the original file.') -@options.SILENT() +@options.SILENT(hidden=True) @click.option( '-v', '--version', @@ -210,53 +119,12 @@ def create( type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'CRITICAL']), help='Control the verbosity of console logging' ) -def migrate(input_file, output_file, force, silent, in_place, archive_format, version, verbosity): +@click.pass_context +def migrate(ctx, input_file, output_file, force, silent, in_place, archive_format, version, verbosity): """Migrate an export archive to a more recent format version. .. deprecated:: 1.5.0 Support for the --silent flag, replaced by --verbosity """ - from aiida.common.log import override_log_formatter_context - from aiida.common.progress_reporter import set_progress_bar_tqdm, set_progress_reporter - from aiida.tools.importexport import detect_archive_type, EXPORT_VERSION - from aiida.tools.importexport.archive.migrators import get_migrator, MIGRATE_LOGGER - - if silent is True: - echo.echo_deprecated('the --silent option is deprecated, use --verbosity') - - if in_place: - if output_file: - echo.echo_critical('output file specified together with --in-place flag') - output_file = input_file - force = True - elif not output_file: - echo.echo_critical( - 'no output file specified. Please add --in-place flag if you would like to migrate in place.' - ) - - if verbosity in ['DEBUG', 'INFO']: - set_progress_bar_tqdm(leave=(verbosity == 'DEBUG')) - else: - set_progress_reporter(None) - MIGRATE_LOGGER.setLevel(verbosity) - - if version is None: - version = EXPORT_VERSION - - migrator_cls = get_migrator(detect_archive_type(input_file)) - migrator = migrator_cls(input_file) - - try: - with override_log_formatter_context('%(message)s'): - migrator.migrate(version, output_file, force=force, out_compression=archive_format) - except Exception as error: # pylint: disable=broad-except - if verbosity == 'DEBUG': - raise - echo.echo_critical( - 'failed to migrate the archive file (use `--verbosity DEBUG` to see traceback): ' - f'{error.__class__.__name__}:{error}' - ) - - if verbosity in ['DEBUG', 'INFO']: - echo.echo_success(f'migrated the archive to version {version}') + ctx.forward(cmd_archive.migrate) diff --git a/aiida/cmdline/commands/cmd_import.py b/aiida/cmdline/commands/cmd_import.py index 251006375e..1dad604063 100644 --- a/aiida/cmdline/commands/cmd_import.py +++ b/aiida/cmdline/commands/cmd_import.py @@ -8,34 +8,19 @@ # For further information please visit http://www.aiida.net # ########################################################################### """`verdi import` command.""" -# pylint: disable=broad-except -from enum import Enum -from typing import List, Tuple -import traceback -import urllib.request - +# pylint: disable=broad-except,unused-argument import click from aiida.cmdline.commands.cmd_verdi import verdi from aiida.cmdline.params import options from aiida.cmdline.params.types import GroupParamType, PathOrUrl -from aiida.cmdline.utils import decorators, echo - -EXTRAS_MODE_EXISTING = ['keep_existing', 'update_existing', 'mirror', 'none', 'ask'] -EXTRAS_MODE_NEW = ['import', 'none'] -COMMENT_MODE = ['newest', 'overwrite'] +from aiida.cmdline.utils import decorators +from aiida.cmdline.commands.cmd_archive import import_archive, EXTRAS_MODE_EXISTING, EXTRAS_MODE_NEW, COMMENT_MODE -class ExtrasImportCode(Enum): - """Exit codes for the verdi command line.""" - keep_existing = 'kcl' - update_existing = 'kcu' - mirror = 'ncu' - none = 'knl' - ask = 'kca' - -@verdi.command('import') +@verdi.command('import', hidden=True) +@decorators.deprecated_command("This command has been deprecated. Please use 'verdi archive import' instead.") @click.argument('archives', nargs=-1, type=PathOrUrl(exists=True, readable=True)) @click.option( '-w', @@ -100,147 +85,5 @@ def cmd_import( ctx, archives, webpages, group, extras_mode_existing, extras_mode_new, comment_mode, migration, non_interactive, verbosity ): - """Import data from an AiiDA archive file. - - The archive can be specified by its relative or absolute file path, or its HTTP URL. - """ - # pylint: disable=unused-argument - from aiida.common.log import override_log_formatter_context - from aiida.common.progress_reporter import set_progress_bar_tqdm, set_progress_reporter - from aiida.tools.importexport.dbimport.utils import IMPORT_LOGGER - from aiida.tools.importexport.archive.migrators import MIGRATE_LOGGER - - if verbosity in ['DEBUG', 'INFO']: - set_progress_bar_tqdm(leave=(verbosity == 'DEBUG')) - else: - set_progress_reporter(None) - IMPORT_LOGGER.setLevel(verbosity) - MIGRATE_LOGGER.setLevel(verbosity) - - all_archives = _gather_imports(archives, webpages) - - # Preliminary sanity check - if not all_archives: - echo.echo_critical('no valid exported archives were found') - - # Shared import key-word arguments - import_kwargs = { - 'group': group, - 'extras_mode_existing': ExtrasImportCode[extras_mode_existing].value, - 'extras_mode_new': extras_mode_new, - 'comment_mode': comment_mode, - } - - with override_log_formatter_context('%(message)s'): - for archive, web_based in all_archives: - _import_archive(archive, web_based, import_kwargs, migration) - - -def _echo_exception(msg: str, exception, warn_only: bool = False): - """Correctly report and exception. - - :param msg: The message prefix - :param exception: the exception raised - :param warn_only: If True only print a warning, otherwise calls sys.exit with a non-zero exit status - - """ - from aiida.tools.importexport import IMPORT_LOGGER - message = f'{msg}: {exception.__class__.__name__}: {str(exception)}' - if warn_only: - echo.echo_warning(message) - else: - IMPORT_LOGGER.debug('%s', traceback.format_exc()) - echo.echo_critical(message) - - -def _gather_imports(archives, webpages) -> List[Tuple[str, bool]]: - """Gather archives to import and sort into local files and URLs. - - :returns: list of (archive path, whether it is web based) - - """ - from aiida.tools.importexport.common.utils import get_valid_import_links - - final_archives = [] - - # Build list of archives to be imported - for archive in archives: - if archive.startswith('http://') or archive.startswith('https://'): - final_archives.append((archive, True)) - else: - final_archives.append((archive, False)) - - # Discover and retrieve *.aiida files at URL(s) - if webpages is not None: - for webpage in webpages: - try: - echo.echo_info(f'retrieving archive URLS from {webpage}') - urls = get_valid_import_links(webpage) - except Exception as error: - echo.echo_critical( - f'an exception occurred while trying to discover archives at URL {webpage}:\n{error}' - ) - else: - echo.echo_success(f'{len(urls)} archive URLs discovered and added') - final_archives.extend([(u, True) for u in urls]) - - return final_archives - - -def _import_archive(archive: str, web_based: bool, import_kwargs: dict, try_migration: bool): - """Perform the archive import. - - :param archive: the path or URL to the archive - :param web_based: If the archive needs to be downloaded first - :param import_kwargs: keyword arguments to pass to the import function - :param try_migration: whether to try a migration if the import raises IncompatibleArchiveVersionError - - """ - from aiida.common.folders import SandboxFolder - from aiida.tools.importexport import ( - detect_archive_type, EXPORT_VERSION, import_data, IncompatibleArchiveVersionError - ) - from aiida.tools.importexport.archive.migrators import get_migrator - - with SandboxFolder() as temp_folder: - - archive_path = archive - - if web_based: - echo.echo_info(f'downloading archive: {archive}') - try: - response = urllib.request.urlopen(archive) - except Exception as exception: - _echo_exception(f'downloading archive {archive} failed', exception) - temp_folder.create_file_from_filelike(response, 'downloaded_archive.zip') - archive_path = temp_folder.get_abs_path('downloaded_archive.zip') - echo.echo_success('archive downloaded, proceeding with import') - - echo.echo_info(f'starting import: {archive}') - try: - import_data(archive_path, **import_kwargs) - except IncompatibleArchiveVersionError as exception: - if try_migration: - - echo.echo_info(f'incompatible version detected for {archive}, trying migration') - try: - migrator = get_migrator(detect_archive_type(archive_path))(archive_path) - archive_path = migrator.migrate( - EXPORT_VERSION, None, out_compression='none', work_dir=temp_folder.abspath - ) - except Exception as exception: - _echo_exception(f'an exception occurred while migrating the archive {archive}', exception) - - echo.echo_info('proceeding with import of migrated archive') - try: - import_data(archive_path, **import_kwargs) - except Exception as exception: - _echo_exception( - f'an exception occurred while trying to import the migrated archive {archive}', exception - ) - else: - _echo_exception(f'an exception occurred while trying to import the archive {archive}', exception) - except Exception as exception: - _echo_exception(f'an exception occurred while trying to import the archive {archive}', exception) - - echo.echo_success(f'imported archive {archive}') + """Deprecated, use `verdi archive import`.""" + ctx.forward(import_archive) diff --git a/docs/source/howto/data.rst b/docs/source/howto/data.rst index 1431733700..3c0a34719c 100644 --- a/docs/source/howto/data.rst +++ b/docs/source/howto/data.rst @@ -11,7 +11,7 @@ Importing data ============== AiiDA allows users to export data from their database into an export archive file, which can be imported into any other AiiDA database. -If you have an AiiDA export archive that you would like to import, you can use the ``verdi import`` command (see :ref:`the reference section` for details). +If you have an AiiDA export archive that you would like to import, you can use the ``verdi archive import`` command (see :ref:`the reference section` for details). .. note:: For information on exporting and importing data via AiiDA archives, see :ref:`"How to share data"`. diff --git a/docs/source/howto/share_data.rst b/docs/source/howto/share_data.rst index 6c8319023b..37174415e8 100644 --- a/docs/source/howto/share_data.rst +++ b/docs/source/howto/share_data.rst @@ -24,13 +24,13 @@ Exporting those results together with their provenance is as easy as: .. code-block:: console - $ verdi export create my-calculations.aiida --nodes 12 123 1234 + $ verdi archive create my-calculations.aiida --nodes 12 123 1234 As usual, you can use any identifier (label, PK or UUID) to specify the nodes to be exported. The resulting archive file ``my-calculations.aiida`` contains all information pertaining to the exported nodes. The default traversal rules make sure to include the complete provenance of any node specified and should be sufficient for most cases. -See ``verdi export create --help`` for ways to modify the traversal rules. +See ``verdi archive create --help`` for ways to modify the traversal rules. .. tip:: @@ -53,7 +53,7 @@ Then export the group: .. code-block:: console - $ verdi export create my-calculations.aiida --groups my-results + $ verdi archive create my-calculations.aiida --groups my-results Publishing AiiDA archive files ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -69,27 +69,27 @@ When publishing AiiDA archives on the `Materials Cloud Archive`_, you also get a Importing an archive ^^^^^^^^^^^^^^^^^^^^ -Use ``verdi import`` to import AiiDA archives into your current AiiDA profile. -``verdi import`` accepts URLs, e.g.: +Use ``verdi archive import`` to import AiiDA archives into your current AiiDA profile. +``verdi archive import`` accepts URLs, e.g.: .. code-block:: console - $ verdi import "https://archive.materialscloud.org/record/file?file_id=2a59c9e7-9752-47a8-8f0e-79bcdb06842c&filename=SSSP_1.1_PBE_efficiency.aiida&record_id=23" + $ verdi archive import "https://archive.materialscloud.org/record/file?file_id=2a59c9e7-9752-47a8-8f0e-79bcdb06842c&filename=SSSP_1.1_PBE_efficiency.aiida&record_id=23" During import, AiiDA will avoid identifier collisions and node duplication based on UUIDs (and email comparisons for :py:class:`~aiida.orm.users.User` entries). By default, existing entities will be updated with the most recent changes. -Node extras and comments have special modes for determining how to import them - for more details, see ``verdi import --help``. +Node extras and comments have special modes for determining how to import them - for more details, see ``verdi archive import --help``. .. tip:: The AiiDA archive format has evolved over time, but you can still import archives created with previous AiiDA versions. If an outdated archive version is detected during import, the archive file will be automatically migrated to the newest version (within a temporary folder) and the import retried. - You can also use ``verdi export migrate`` to create updated archive files from existing archive files (or update them in place). + You can also use ``verdi archive migrate`` to create updated archive files from existing archive files (or update them in place). -.. tip:: In order to get a quick overview of an archive file *without* importing it into your AiiDA profile, use ``verdi export inspect``: +.. tip:: In order to get a quick overview of an archive file *without* importing it into your AiiDA profile, use ``verdi archive inspect``: .. code-block:: console - $ verdi export inspect sssp-efficiency.aiida + $ verdi archive inspect sssp-efficiency.aiida -------------- ----- Version aiida 1.2.1 Version format 0.9 diff --git a/docs/source/howto/visualising_graphs/visualising_graphs.rst b/docs/source/howto/visualising_graphs/visualising_graphs.rst index 3de6facea6..26163a4b9b 100644 --- a/docs/source/howto/visualising_graphs/visualising_graphs.rst +++ b/docs/source/howto/visualising_graphs/visualising_graphs.rst @@ -41,7 +41,7 @@ It can then be imported into the database: .. code:: ipython - !verdi import -n graph1.aiida + !verdi archive import -n graph1.aiida .. code:: python diff --git a/docs/source/internals/data_storage.rst b/docs/source/internals/data_storage.rst index a3ca0e0e19..d60caf6d03 100644 --- a/docs/source/internals/data_storage.rst +++ b/docs/source/internals/data_storage.rst @@ -72,7 +72,7 @@ The corresponding entity names appear nested next to the properties to show this .. note:: - If you supply an old archive file that the current AiiDA code does not support, ``verdi import`` will automatically try to migrate the archive by calling ``verdi export migrate``. + If you supply an old archive file that the current AiiDA code does not support, ``verdi archive import`` will automatically try to migrate the archive by calling ``verdi archive migrate``. .. _internal_architecture:orm:archive:data-json: diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 3832c50941..65fdc38927 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -10,6 +10,27 @@ Commands ======== Below is a list with all available subcommands. +.. _reference:command-line:verdi-archive: + +``verdi archive`` +----------------- + +.. code:: console + + Usage: [OPTIONS] COMMAND [ARGS]... + + Create, inspect and import AiiDA archives. + + Options: + --help Show this message and exit. + + Commands: + create Export subsets of the provenance graph to file for sharing. + import Import data from an AiiDA archive file. + inspect Inspect contents of an archive without importing it. + migrate Migrate an export archive to a more recent format version. + + .. _reference:command-line:verdi-calcjob: ``verdi calcjob`` @@ -234,7 +255,7 @@ Below is a list with all available subcommands. Usage: [OPTIONS] COMMAND [ARGS]... - Create and manage export archives. + Deprecated, use `verdi archive`. Options: --help Show this message and exit. @@ -314,9 +335,7 @@ Below is a list with all available subcommands. Usage: [OPTIONS] [--] [ARCHIVES]... - Import data from an AiiDA archive file. - - The archive can be specified by its relative or absolute file path, or its HTTP URL. + Deprecated, use `verdi archive import`. Options: -w, --webpages TEXT... Discover all URL targets pointing to files with the diff --git a/docs/source/topics/cli.rst b/docs/source/topics/cli.rst index f3743b4dbf..26d4159a64 100644 --- a/docs/source/topics/cli.rst +++ b/docs/source/topics/cli.rst @@ -25,11 +25,11 @@ Multi-value options Some ``verdi`` commands provide *options* that can take multiple values. This allows to avoid repetition and e.g. write:: - verdi export create -N 10 11 12 -- archive.aiida + verdi archive create -N 10 11 12 -- archive.aiida instead of the more lengthy:: - verdi export create -N 10 -N 11 -N 12 archive.aiida + verdi archive create -N 10 -N 11 -N 12 archive.aiida Note the use of the so-called 'endopts' marker ``--`` that is necessary to mark the end of the ``-N`` option and distinguish it from the ``archive.aiida`` argument. @@ -68,7 +68,7 @@ The ``Usage:`` line encodes information on the command's parameters, e.g.: Multi-value options are followed by ``...`` in the help string and the ``Usage:`` line of the corresponding command will contain the 'endopts' marker. For example:: - Usage: verdi export create [OPTIONS] [--] OUTPUT_FILE + Usage: verdi archive create [OPTIONS] [--] OUTPUT_FILE Export various entities, such as Codes, Computers, Groups and Nodes, to an archive file for backup or sharing purposes. diff --git a/docs/source/topics/provenance/consistency.rst b/docs/source/topics/provenance/consistency.rst index 2c76733393..a79b57815d 100644 --- a/docs/source/topics/provenance/consistency.rst +++ b/docs/source/topics/provenance/consistency.rst @@ -33,7 +33,7 @@ In the following section we will explain in more detail the criteria for includi Traversal Rules =============== -When you run ``verdi node delete [NODE_IDS]`` or ``verdi export create -N [NODE_IDS]``, AiiDA will look at the links incoming or outgoing from the nodes that you specified and decide if there are other nodes that are critical to keep. +When you run ``verdi node delete [NODE_IDS]`` or ``verdi archive create -N [NODE_IDS]``, AiiDA will look at the links incoming or outgoing from the nodes that you specified and decide if there are other nodes that are critical to keep. For this decision, it is not only important to consider the type of link, but also if we are following it along its direction (we will call this ``forward`` direction) or in the reversed direction (``backward`` direction). To clarify this, in the example above, when deleting data node |D_1|, AiiDA will follow the ``input_calc`` link in the ``forward`` direction (in this case, it will decide that the linked node (|C_1|) must then also be deleted). diff --git a/tests/cmdline/commands/test_export.py b/tests/cmdline/commands/test_archive_export.py similarity index 88% rename from tests/cmdline/commands/test_export.py rename to tests/cmdline/commands/test_archive_export.py index 4b2cfa16bf..659403be53 100644 --- a/tests/cmdline/commands/test_export.py +++ b/tests/cmdline/commands/test_archive_export.py @@ -19,7 +19,7 @@ from click.testing import CliRunner from aiida.backends.testbase import AiidaTestCase -from aiida.cmdline.commands import cmd_export +from aiida.cmdline.commands import cmd_archive from aiida.tools.importexport import EXPORT_VERSION, ReaderJsonZip from tests.utils.archives import get_archive_file @@ -40,6 +40,14 @@ def delete_temporary_file(filepath): pass +def test_cmd_export_deprecation(): + """Test that the deprecated `verdi export` commands can still be called.""" + from aiida.cmdline.commands import cmd_export + for command in [cmd_export.inspect, cmd_export.create, cmd_export.migrate]: + result = CliRunner().invoke(command, '--help') + assert result.exit_code == 0 + + class TestVerdiExport(AiidaTestCase): """Tests for `verdi export`.""" @@ -79,7 +87,7 @@ def test_create_file_already_exists(self): """Test that using a file that already exists, which is the case when using NamedTemporaryFile, will raise.""" with tempfile.NamedTemporaryFile() as handle: options = [handle.name] - result = self.cli_runner.invoke(cmd_export.create, options) + result = self.cli_runner.invoke(cmd_archive.create, options) self.assertIsNotNone(result.exception) def test_create_force(self): @@ -89,11 +97,11 @@ def test_create_force(self): """ with tempfile.NamedTemporaryFile() as handle: options = ['-f', handle.name] - result = self.cli_runner.invoke(cmd_export.create, options) + result = self.cli_runner.invoke(cmd_archive.create, options) self.assertIsNone(result.exception, result.output) options = ['--force', handle.name] - result = self.cli_runner.invoke(cmd_export.create, options) + result = self.cli_runner.invoke(cmd_archive.create, options) self.assertIsNone(result.exception, result.output) def test_create_zip(self): @@ -104,7 +112,7 @@ def test_create_zip(self): '-X', self.code.pk, '-Y', self.computer.pk, '-G', self.group.pk, '-N', self.node.pk, '-F', 'zip', filename ] - result = self.cli_runner.invoke(cmd_export.create, options) + result = self.cli_runner.invoke(cmd_archive.create, options) self.assertIsNone(result.exception, ''.join(traceback.format_exception(*result.exc_info))) self.assertTrue(os.path.isfile(filename)) self.assertFalse(zipfile.ZipFile(filename).testzip(), None) @@ -119,7 +127,7 @@ def test_create_zip_uncompressed(self): '-X', self.code.pk, '-Y', self.computer.pk, '-G', self.group.pk, '-N', self.node.pk, '-F', 'zip-uncompressed', filename ] - result = self.cli_runner.invoke(cmd_export.create, options) + result = self.cli_runner.invoke(cmd_archive.create, options) self.assertIsNone(result.exception, ''.join(traceback.format_exception(*result.exc_info))) self.assertTrue(os.path.isfile(filename)) self.assertFalse(zipfile.ZipFile(filename).testzip(), None) @@ -134,7 +142,7 @@ def test_create_tar_gz(self): '-X', self.code.pk, '-Y', self.computer.pk, '-G', self.group.pk, '-N', self.node.pk, '-F', 'tar.gz', filename ] - result = self.cli_runner.invoke(cmd_export.create, options) + result = self.cli_runner.invoke(cmd_archive.create, options) self.assertIsNone(result.exception, ''.join(traceback.format_exception(*result.exc_info))) self.assertTrue(os.path.isfile(filename)) self.assertTrue(tarfile.is_tarfile(filename)) @@ -154,7 +162,7 @@ def test_migrate_versions_old(self): try: options = ['--verbosity', 'DEBUG', filename_input, filename_output] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNone(result.exception, result.output) self.assertTrue(os.path.isfile(filename_output)) self.assertEqual(zipfile.ZipFile(filename_output).testzip(), None) @@ -171,7 +179,7 @@ def test_migrate_version_specific(self): try: options = [filename_input, filename_output, '--version', target_version] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNone(result.exception, result.output) self.assertTrue(os.path.isfile(filename_output)) self.assertEqual(zipfile.ZipFile(filename_output).testzip(), None) @@ -188,7 +196,7 @@ def test_migrate_force(self): # Using the context manager will create the file and so the command should fail with tempfile.NamedTemporaryFile() as file_output: options = [filename_input, file_output.name] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNotNone(result.exception) for option in ['-f', '--force']: @@ -196,7 +204,7 @@ def test_migrate_force(self): with tempfile.NamedTemporaryFile() as file_output: filename_output = file_output.name options = [option, filename_input, filename_output] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNone(result.exception, result.output) self.assertTrue(os.path.isfile(filename_output)) self.assertEqual(zipfile.ZipFile(filename_output).testzip(), None) @@ -214,17 +222,17 @@ def test_migrate_in_place(self): # specifying both output and in-place should except options = [filename_tmp, '--in-place', '--output-file', 'test.aiida'] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNotNone(result.exception, result.output) # specifying neither output nor in-place should except options = [filename_tmp] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNotNone(result.exception, result.output) # check that in-place migration produces a valid archive in place of the old file options = [filename_tmp, '--in-place', '--version', target_version] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNone(result.exception, result.output) self.assertTrue(os.path.isfile(filename_tmp)) # check that files in zip file are ok @@ -244,7 +252,7 @@ def test_migrate_low_verbosity(self): for option in ['--verbosity']: try: options = [option, 'WARNING', filename_input, filename_output] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertEqual(result.output, '') self.assertIsNone(result.exception, result.output) self.assertTrue(os.path.isfile(filename_output)) @@ -260,7 +268,7 @@ def test_migrate_tar_gz(self): for option in ['-F', '--archive-format']: try: options = [option, 'tar.gz', filename_input, filename_output] - result = self.cli_runner.invoke(cmd_export.migrate, options) + result = self.cli_runner.invoke(cmd_archive.migrate, options) self.assertIsNone(result.exception, result.output) self.assertTrue(os.path.isfile(filename_output)) self.assertTrue(tarfile.is_tarfile(filename_output)) @@ -280,12 +288,12 @@ def test_inspect(self): # Testing the options that will print the meta data and data respectively for option in ['-m', '-d']: options = [option, filename_input] - result = self.cli_runner.invoke(cmd_export.inspect, options) + result = self.cli_runner.invoke(cmd_archive.inspect, options) self.assertIsNone(result.exception, result.output) # Test the --version option which should print the archive format version options = ['--version', filename_input] - result = self.cli_runner.invoke(cmd_export.inspect, options) + result = self.cli_runner.invoke(cmd_archive.inspect, options) self.assertIsNone(result.exception, result.output) self.assertEqual(result.output.strip()[-len(version_number):], version_number) @@ -294,6 +302,6 @@ def test_inspect_empty_archive(self): filename_input = get_archive_file('empty.aiida', filepath=self.fixture_archive) options = [filename_input] - result = self.cli_runner.invoke(cmd_export.inspect, options) + result = self.cli_runner.invoke(cmd_archive.inspect, options) self.assertIsNotNone(result.exception, result.output) self.assertIn('corrupt archive', result.output) diff --git a/tests/cmdline/commands/test_import.py b/tests/cmdline/commands/test_archive_import.py similarity index 87% rename from tests/cmdline/commands/test_import.py rename to tests/cmdline/commands/test_archive_import.py index d14ec96f06..7523a0cacf 100644 --- a/tests/cmdline/commands/test_import.py +++ b/tests/cmdline/commands/test_archive_import.py @@ -14,13 +14,20 @@ import pytest from aiida.backends.testbase import AiidaTestCase -from aiida.cmdline.commands import cmd_import +from aiida.cmdline.commands import cmd_archive from aiida.orm import Group from aiida.tools.importexport import EXPORT_VERSION from tests.utils.archives import get_archive_file +def test_cmd_import_deprecation(): + """Test that the deprecated `verdi import` command can still be called.""" + from aiida.cmdline.commands import cmd_import + result = CliRunner().invoke(cmd_import.cmd_import, '--help') + assert result.exit_code == 0 + + class TestVerdiImport(AiidaTestCase): """Tests for `verdi import`.""" @@ -40,7 +47,7 @@ def setUp(self): def test_import_no_archives(self): """Test that passing no valid archives will lead to command failure.""" options = [] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNotNone(result.exception, result.output) self.assertIn('Critical', result.output) @@ -49,7 +56,7 @@ def test_import_no_archives(self): def test_import_non_existing_archives(self): """Test that passing a non-existing archive will lead to command failure.""" options = ['non-existing-archive.aiida'] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNotNone(result.exception, result.output) self.assertNotEqual(result.exit_code, 0, result.output) @@ -64,7 +71,7 @@ def test_import_archive(self): ] options = [] + archives - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, result.output) self.assertEqual(result.exit_code, 0, result.output) @@ -86,7 +93,7 @@ def test_import_to_group(self): # Invoke `verdi import`, making sure there are no exceptions options = ['-G', group.label] + [archives[0]] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -96,7 +103,7 @@ def test_import_to_group(self): # Invoke `verdi import` again, making sure Group count doesn't change options = ['-G', group.label] + [archives[0]] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -108,7 +115,7 @@ def test_import_to_group(self): # Invoke `verdi import` again with new archive, making sure Group count is upped options = ['-G', group.label] + [archives[1]] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -134,7 +141,7 @@ def test_import_make_new_group(self): # Invoke `verdi import`, making sure there are no exceptions options = ['-G', group_label] + archives - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -143,7 +150,7 @@ def test_import_make_new_group(self): self.assertFalse(new_group, msg='The Group should not have been created now, but instead when it was imported.') self.assertFalse(group.is_empty, msg='The Group should not be empty.') - @pytest.mark.skip('Due to summary being logged, this can not be checked against `results.output`.') + @pytest.mark.skip('Due to summary being logged, this can not be checked against `results.output`.') # pylint: disable=not-callable def test_comment_mode(self): """Test toggling comment mode flag""" import re @@ -151,7 +158,7 @@ def test_comment_mode(self): for mode in ['newest', 'overwrite']: options = ['--comment-mode', mode] + archives - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, result.output) self.assertTrue( any([re.fullmatch(r'Comment rules[\s]*{}'.format(mode), line) for line in result.output.split('\n')]), @@ -169,7 +176,7 @@ def test_import_old_local_archives(self): for archive, version in archives: options = [get_archive_file(archive, filepath=self.archive_path)] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -184,7 +191,7 @@ def test_import_old_url_archives(self): version = '0.3' options = [self.url_path + archive] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -200,7 +207,7 @@ def test_import_url_and_local_archives(self): get_archive_file(local_archive, filepath=self.archive_path), self.url_path + url_archive, get_archive_file(local_archive, filepath=self.archive_path) ] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, result.output) self.assertEqual(result.exit_code, 0, result.output) @@ -222,7 +229,7 @@ def test_raise_malformed_url(self): """Test the correct error is raised when supplying a malformed URL""" malformed_url = 'htp://www.aiida.net' - result = self.cli_runner.invoke(cmd_import.cmd_import, [malformed_url]) + result = self.cli_runner.invoke(cmd_archive.import_archive, [malformed_url]) self.assertIsNotNone(result.exception, result.output) self.assertNotEqual(result.exit_code, 0, result.output) @@ -243,7 +250,7 @@ def test_non_interactive_and_migration(self): # Import "normally", but explicitly specifying `--migration`, make sure confirm message is present # `migration` = True (default), `non_interactive` = False (default), Expected: Query user, migrate options = ['--migration', archive] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -254,7 +261,7 @@ def test_non_interactive_and_migration(self): # Import using non-interactive, make sure confirm message has gone # `migration` = True (default), `non_interactive` = True, Expected: No query, migrate options = ['--non-interactive', archive] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNone(result.exception, msg=result.output) self.assertEqual(result.exit_code, 0, msg=result.output) @@ -264,7 +271,7 @@ def test_non_interactive_and_migration(self): # Import using `--no-migration`, make sure confirm message has gone # `migration` = False, `non_interactive` = False (default), Expected: No query, no migrate options = ['--no-migration', archive] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNotNone(result.exception, msg=result.output) self.assertNotEqual(result.exit_code, 0, msg=result.output) @@ -275,7 +282,7 @@ def test_non_interactive_and_migration(self): # Import using `--no-migration` and `--non-interactive`, make sure confirm message has gone # `migration` = False, `non_interactive` = True, Expected: No query, no migrate options = ['--no-migration', '--non-interactive', archive] - result = self.cli_runner.invoke(cmd_import.cmd_import, options) + result = self.cli_runner.invoke(cmd_archive.import_archive, options) self.assertIsNotNone(result.exception, msg=result.output) self.assertNotEqual(result.exit_code, 0, msg=result.output) From e6ba4657d1b77597afff333a172ef5379cb0786a Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Mon, 8 Feb 2021 17:08:33 +0100 Subject: [PATCH 062/114] Dependencies: Require `ipython~=7.20` (#4715) * Dependencies: Require `ipython~=7.20` Package jedi version 0.18 introduces backwards incompatible changes that break compatibility with ipython<7.20. Fixes issue #4668. * Automated update of requirements/ files. (#4716) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- environment.yml | 2 +- requirements/requirements-py-3.7.txt | 215 ++++++++++++++------------- requirements/requirements-py-3.8.txt | 203 +++++++++++++------------ requirements/requirements-py-3.9.txt | 137 +++++++++-------- setup.json | 2 +- 5 files changed, 293 insertions(+), 266 deletions(-) diff --git a/environment.yml b/environment.yml index c079a48523..f6c8d5bb50 100644 --- a/environment.yml +++ b/environment.yml @@ -18,7 +18,7 @@ dependencies: - django~=2.2 - ete3~=3.1 - python-graphviz~=0.13 -- ipython~=7.0 +- ipython~=7.20 - jinja2~=2.10 - kiwipy[rmq]~=0.7.1 - numpy~=1.17 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index fe138a5333..b5289a465a 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -1,170 +1,175 @@ aiida-export-migration-tests==0.9.0 +aio-pika==6.7.1 +aiormq==3.3.1 alabaster==0.7.12 aldjemy==0.9.1 -alembic==1.4.1 -aio-pika==6.6.1 -aniso8601==8.0.0 -appdirs==1.4.4 -appnope==0.1.0 +alembic==1.5.4 +aniso8601==8.1.1 archive-path==0.2.1 -ase==3.19.0 -attrs==19.3.0 -Babel==2.8.0 -backcall==0.1.0 -bcrypt==3.1.7 -bleach==3.1.4 -certifi==2019.11.28 -cffi==1.14.0 -chardet==3.0.4 +argon2-cffi==20.1.0 +ase==3.21.1 +async-generator==1.10 +attrs==20.3.0 +Babel==2.9.0 +backcall==0.2.0 +bcrypt==3.2.0 +bleach==3.3.0 +certifi==2020.12.5 +cffi==1.14.4 +chardet==4.0.0 circus==0.17.1 -Click==7.1.2 +click==7.1.2 click-completion==0.5.2 click-config-file==0.6.0 -click-spinner==0.1.8 +click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.2 +cryptography==3.4.1 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 -Django==2.2.11 +deprecation==2.1.0 +Django==2.2.18 docutils==0.15.2 entrypoints==0.3 -ete3==3.1.1 -flake8==3.8.3 -Flask==1.1.1 -Flask-Cors==3.0.8 +ete3==3.1.2 +Flask==1.1.2 +Flask-Cors==3.0.10 Flask-RESTful==0.3.8 frozendict==1.2 -furl==2.1.0 future==0.18.2 -graphviz==0.13.2 -idna==2.9 +graphviz==0.16 +idna==2.10 imagesize==1.2.0 -importlib-metadata==1.5.0 +importlib-metadata==3.4.0 iniconfig==1.1.1 -ipykernel==5.1.4 -ipython==7.13.0 +ipykernel==5.4.3 +ipython==7.20.0 ipython-genutils==0.2.0 -ipywidgets==7.5.1 +ipywidgets==7.6.3 itsdangerous==1.1.0 -jedi==0.16.0 -Jinja2==2.11.1 +jedi==0.18.0 +Jinja2==2.11.3 jsonschema==3.2.0 jupyter==1.0.0 -jupyter-client==6.0.0 -jupyter-console==6.1.0 -jupyter-core==4.6.3 +jupyter-client==6.1.11 +jupyter-console==6.2.0 +jupyter-core==4.7.1 +jupyterlab-pygments==0.1.2 +jupyterlab-widgets==1.0.0 kiwipy==0.7.1 -kiwisolver==1.1.0 -Mako==1.1.2 +kiwisolver==1.3.1 +Mako==1.1.4 MarkupSafe==1.1.1 -matplotlib==3.2.0 -mccabe==0.6.1 +matplotlib==3.3.4 mistune==0.8.4 -monty==3.0.2 -more-itertools==8.2.0 +monty==4.0.2 mpmath==1.1.0 -nbconvert==5.6.1 -nbformat==5.0.4 -networkx==2.4 -notebook==6.1.5 -numpy==1.17.5 -orderedmultidict==1.0.1 -packaging==20.3 +multidict==5.1.0 +nbclient==0.5.1 +nbconvert==6.0.7 +nbformat==5.1.2 +nest-asyncio==1.4.3 +networkx==2.5 +notebook==6.2.0 +numpy==1.20.1 +packaging==20.9 palettable==3.3.0 -pamqp==2.3 -pandas==0.25.3 -pandocfilters==1.4.2 +pamqp==2.3.0 +pandas==1.2.1 +pandocfilters==1.4.3 paramiko==2.7.2 -parso==0.6.2 -pathspec==0.8.0 +parso==0.8.1 pexpect==4.8.0 -pg8000==1.13.2 +pg8000==1.17.0 pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 +Pillow==8.1.0 +plotly==4.14.3 pluggy==0.13.1 plumpy==0.18.4 -prometheus-client==0.7.1 -prompt-toolkit==3.0.4 -psutil==5.7.0 -psycopg2-binary==2.8.4 -ptyprocess==0.6.0 -py==1.9.0 +prometheus-client==0.9.0 +prompt-toolkit==3.0.14 +psutil==5.8.0 +psycopg2-binary==2.8.6 +ptyprocess==0.7.0 +py==1.10.0 py-cpuinfo==7.0.0 -PyCifRW==4.4.1 -pycodestyle==2.6.0 +PyCifRW==4.4.2 pycparser==2.20 -pydata-sphinx-theme==0.4.1 -PyDispatcher==2.0.5 -pyflakes==2.2.0 -Pygments==2.6.1 -pymatgen==2020.3.2 +pydata-sphinx-theme==0.4.3 +Pygments==2.7.4 +pymatgen==2020.12.31 PyMySQL==0.9.3 -PyNaCl==1.3.0 -pyparsing==2.4.6 -pyrsistent==0.15.7 -pytest==6.0.0 +PyNaCl==1.4.0 +pyparsing==2.4.7 +pyrsistent==0.17.3 +pytest==6.2.2 +pytest-asyncio==0.14.0 pytest-benchmark==3.2.3 -pytest-cov==2.8.1 +pytest-cov==2.10.1 pytest-rerunfailures==9.1.1 -pytest-timeout==1.3.4 -pytest-asyncio==0.12.0 +pytest-timeout==1.4.2 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 +pytray==0.3.1 pytz==2019.3 PyYAML==5.1.2 -pyzmq==19.0.0 -qtconsole==4.7.1 +pyzmq==22.0.2 +qtconsole==5.0.2 QtPy==1.9.0 reentry==1.3.1 -regex==2020.7.14 -requests==2.23.0 -ruamel.yaml==0.16.10 -ruamel.yaml.clib==0.2.0 -scipy==1.4.1 -scramp==1.1.0 -seekpath==1.9.4 +requests==2.25.1 +retrying==1.3.3 +ruamel.yaml==0.16.12 +ruamel.yaml.clib==0.2.2 +scipy==1.6.0 +scramp==1.2.0 +seekpath==1.9.7 +semantic-version==2.8.5 Send2Trash==1.5.0 -shellingham==1.3.2 +setuptools-rust==0.11.6 +shellingham==1.4.0 shortuuid==1.0.1 -simplejson==3.17.0 -six==1.14.0 -snowballstemmer==2.0.0 -spglib==1.14.1.post0 +simplejson==3.17.2 +six==1.15.0 +snowballstemmer==2.1.0 +spglib==1.16.1 Sphinx==3.2.1 -sphinx-copybutton==0.3.0 -sphinx-notfound-page==0.5 +sphinx-copybutton==0.3.1 +sphinx-notfound-page==0.6 sphinx-panels==0.5.2 sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-contentui==0.2.4 sphinxcontrib-details-directive==0.1.0 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==1.0.3 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 -sphinxext-rediraffe==0.2.4 -SQLAlchemy==1.3.13 +sphinxext-rediraffe==0.2.5 +SQLAlchemy==1.3.23 sqlalchemy-diff==0.1.3 SQLAlchemy-Utils==0.34.2 -sqlparse==0.3.1 -sympy==1.5.1 -tabulate==0.8.6 -terminado==0.8.3 +sqlparse==0.4.1 +sympy==1.7.1 +tabulate==0.8.7 +terminado==0.9.2 testpath==0.4.4 -toml==0.10.1 -tqdm==4.45.0 -traitlets==4.3.3 -typed-ast==1.4.1 -tzlocal==2.0.0 +toml==0.10.2 +tornado==6.1 +tqdm==4.56.0 +traitlets==5.0.5 +typing-extensions==3.7.4.3 +tzlocal==2.1 +uncertainties==3.1.5 upf-to-json==0.9.2 -urllib3==1.25.8 -wcwidth==0.1.8 +urllib3==1.26.3 +wcwidth==0.2.5 webencodings==0.5.1 -Werkzeug==1.0.0 +Werkzeug==1.0.1 widgetsnbextension==3.5.1 wrapt==1.11.2 -zipp==3.1.0 +yarl==1.6.3 +zipp==3.4.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index d026394a97..fcddaebd33 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -1,161 +1,172 @@ aiida-export-migration-tests==0.9.0 +aio-pika==6.7.1 +aiormq==3.3.1 alabaster==0.7.12 aldjemy==0.9.1 -aio-pika==6.6.1 -alembic==1.4.1 -aniso8601==8.0.0 -appnope==0.1.0 +alembic==1.5.4 +aniso8601==8.1.1 archive-path==0.2.1 -ase==3.19.0 -attrs==19.3.0 -Babel==2.8.0 -backcall==0.1.0 -bcrypt==3.1.7 -bleach==3.1.4 -certifi==2019.11.28 -cffi==1.14.0 -chardet==3.0.4 +argon2-cffi==20.1.0 +ase==3.21.1 +async-generator==1.10 +attrs==20.3.0 +Babel==2.9.0 +backcall==0.2.0 +bcrypt==3.2.0 +bleach==3.3.0 +certifi==2020.12.5 +cffi==1.14.4 +chardet==4.0.0 circus==0.17.1 -Click==7.1.2 +click==7.1.2 click-completion==0.5.2 click-config-file==0.6.0 -click-spinner==0.1.8 +click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.2 +cryptography==3.4.1 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 -Django==2.2.11 +deprecation==2.1.0 +Django==2.2.18 docutils==0.15.2 entrypoints==0.3 -ete3==3.1.1 -Flask==1.1.1 -Flask-Cors==3.0.8 +ete3==3.1.2 +Flask==1.1.2 +Flask-Cors==3.0.10 Flask-RESTful==0.3.8 frozendict==1.2 -furl==2.1.0 future==0.18.2 -graphviz==0.13.2 -idna==2.9 +graphviz==0.16 +idna==2.10 imagesize==1.2.0 iniconfig==1.1.1 -ipykernel==5.1.4 -ipython==7.13.0 +ipykernel==5.4.3 +ipython==7.20.0 ipython-genutils==0.2.0 -ipywidgets==7.5.1 +ipywidgets==7.6.3 itsdangerous==1.1.0 -jedi==0.16.0 -Jinja2==2.11.1 +jedi==0.18.0 +Jinja2==2.11.3 jsonschema==3.2.0 jupyter==1.0.0 -jupyter-client==6.0.0 -jupyter-console==6.1.0 -jupyter-core==4.6.3 +jupyter-client==6.1.11 +jupyter-console==6.2.0 +jupyter-core==4.7.1 +jupyterlab-pygments==0.1.2 +jupyterlab-widgets==1.0.0 kiwipy==0.7.1 -kiwisolver==1.1.0 -Mako==1.1.2 +kiwisolver==1.3.1 +Mako==1.1.4 MarkupSafe==1.1.1 -matplotlib==3.2.0 +matplotlib==3.3.4 mistune==0.8.4 -monty==3.0.2 -more-itertools==8.2.0 +monty==4.0.2 mpmath==1.1.0 -nbconvert==5.6.1 -nbformat==5.0.4 -networkx==2.4 -notebook==6.1.5 -numpy==1.17.5 -orderedmultidict==1.0.1 -packaging==20.3 +multidict==5.1.0 +nbclient==0.5.1 +nbconvert==6.0.7 +nbformat==5.1.2 +nest-asyncio==1.4.3 +networkx==2.5 +notebook==6.2.0 +numpy==1.20.1 +packaging==20.9 palettable==3.3.0 -pamqp==2.3 -pandas==0.25.3 -pandocfilters==1.4.2 +pamqp==2.3.0 +pandas==1.2.1 +pandocfilters==1.4.3 paramiko==2.7.2 -parso==0.6.2 +parso==0.8.1 pexpect==4.8.0 -pg8000==1.13.2 +pg8000==1.17.0 pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 +Pillow==8.1.0 +plotly==4.14.3 pluggy==0.13.1 plumpy==0.18.4 -prometheus-client==0.7.1 -prompt-toolkit==3.0.4 -psutil==5.7.0 -psycopg2-binary==2.8.4 -ptyprocess==0.6.0 -py==1.9.0 +prometheus-client==0.9.0 +prompt-toolkit==3.0.14 +psutil==5.8.0 +psycopg2-binary==2.8.6 +ptyprocess==0.7.0 +py==1.10.0 py-cpuinfo==7.0.0 -PyCifRW==4.4.1 +PyCifRW==4.4.2 pycparser==2.20 -pydata-sphinx-theme==0.4.1 -PyDispatcher==2.0.5 -Pygments==2.6.1 -pymatgen==2020.3.2 +pydata-sphinx-theme==0.4.3 +Pygments==2.7.4 +pymatgen==2020.12.31 PyMySQL==0.9.3 -PyNaCl==1.3.0 -pyparsing==2.4.6 -pyrsistent==0.15.7 -pytest==6.0.0 +PyNaCl==1.4.0 +pyparsing==2.4.7 +pyrsistent==0.17.3 +pytest==6.2.2 +pytest-asyncio==0.14.0 pytest-benchmark==3.2.3 -pytest-cov==2.8.1 +pytest-cov==2.10.1 pytest-rerunfailures==9.1.1 -pytest-timeout==1.3.4 -pytest-asyncio==0.12.0 +pytest-timeout==1.4.2 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 +pytray==0.3.1 pytz==2019.3 PyYAML==5.1.2 -pyzmq==19.0.0 -qtconsole==4.7.1 +pyzmq==22.0.2 +qtconsole==5.0.2 QtPy==1.9.0 reentry==1.3.1 -requests==2.23.0 -rope==0.17.0 -ruamel.yaml==0.16.10 -ruamel.yaml.clib==0.2.0 -scipy==1.4.1 -scramp==1.1.0 -seekpath==1.9.4 +requests==2.25.1 +retrying==1.3.3 +ruamel.yaml==0.16.12 +ruamel.yaml.clib==0.2.2 +scipy==1.6.0 +scramp==1.2.0 +seekpath==1.9.7 +semantic-version==2.8.5 Send2Trash==1.5.0 -shellingham==1.3.2 +setuptools-rust==0.11.6 +shellingham==1.4.0 shortuuid==1.0.1 -simplejson==3.17.0 -six==1.14.0 -snowballstemmer==2.0.0 -spglib==1.14.1.post0 +simplejson==3.17.2 +six==1.15.0 +snowballstemmer==2.1.0 +spglib==1.16.1 Sphinx==3.2.1 -sphinx-copybutton==0.3.0 -sphinx-notfound-page==0.5 +sphinx-copybutton==0.3.1 +sphinx-notfound-page==0.6 sphinx-panels==0.5.2 sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-contentui==0.2.4 sphinxcontrib-details-directive==0.1.0 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==1.0.3 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 -sphinxext-rediraffe==0.2.4 -SQLAlchemy==1.3.13 +sphinxext-rediraffe==0.2.5 +SQLAlchemy==1.3.23 sqlalchemy-diff==0.1.3 SQLAlchemy-Utils==0.34.2 -sqlparse==0.3.1 -sympy==1.5.1 -tabulate==0.8.6 -terminado==0.8.3 +sqlparse==0.4.1 +sympy==1.7.1 +tabulate==0.8.7 +terminado==0.9.2 testpath==0.4.4 -toml==0.10.1 -tqdm==4.45.0 -traitlets==4.3.3 -tzlocal==2.0.0 +toml==0.10.2 +tornado==6.1 +tqdm==4.56.0 +traitlets==5.0.5 +tzlocal==2.1 +uncertainties==3.1.5 upf-to-json==0.9.2 -urllib3==1.25.8 -wcwidth==0.1.8 +urllib3==1.26.3 +wcwidth==0.2.5 webencodings==0.5.1 -Werkzeug==1.0.0 +Werkzeug==1.0.1 widgetsnbextension==3.5.1 wrapt==1.11.2 +yarl==1.6.3 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index d8eec0286d..62584c4f60 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -1,19 +1,22 @@ aiida-export-migration-tests==0.9.0 +aio-pika==6.7.1 +aiormq==3.3.1 alabaster==0.7.12 aldjemy==0.9.1 -aio-pika==6.6.1 -alembic==1.4.3 -aniso8601==8.0.0 +alembic==1.5.4 +aniso8601==8.1.1 archive-path==0.2.1 -ase==3.20.1 -attrs==20.2.0 -Babel==2.8.0 +argon2-cffi==20.1.0 +ase==3.21.1 +async-generator==1.10 +attrs==20.3.0 +Babel==2.9.0 backcall==0.2.0 bcrypt==3.2.0 -bleach==3.2.1 -certifi==2020.6.20 -cffi==1.14.3 -chardet==3.0.4 +bleach==3.3.0 +certifi==2020.12.5 +cffi==1.14.4 +chardet==4.0.0 circus==0.17.1 click==7.1.2 click-completion==0.5.2 @@ -21,114 +24,120 @@ click-config-file==0.6.0 click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.2 +cryptography==3.4.1 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 -Django==2.2.17 +deprecation==2.1.0 +Django==2.2.18 docutils==0.15.2 entrypoints==0.3 ete3==3.1.2 Flask==1.1.2 -Flask-Cors==3.0.9 +Flask-Cors==3.0.10 Flask-RESTful==0.3.8 frozendict==1.2 -furl==2.1.0 future==0.18.2 -graphviz==0.14.2 +graphviz==0.16 idna==2.10 imagesize==1.2.0 iniconfig==1.1.1 -ipykernel==5.3.4 -ipython==7.19.0 +ipykernel==5.4.3 +ipython==7.20.0 ipython-genutils==0.2.0 -ipywidgets==7.5.1 +ipywidgets==7.6.3 itsdangerous==1.1.0 -jedi==0.17.2 -Jinja2==2.11.2 +jedi==0.18.0 +Jinja2==2.11.3 jsonschema==3.2.0 jupyter==1.0.0 -jupyter-client==6.1.7 +jupyter-client==6.1.11 jupyter-console==6.2.0 -jupyter-core==4.6.3 +jupyter-core==4.7.1 +jupyterlab-pygments==0.1.2 +jupyterlab-widgets==1.0.0 kiwipy==0.7.1 kiwisolver==1.3.1 -Mako==1.1.3 +Mako==1.1.4 MarkupSafe==1.1.1 -matplotlib==3.3.2 +matplotlib==3.3.4 mistune==0.8.4 monty==4.0.2 mpmath==1.1.0 -nbconvert==5.6.1 -nbformat==5.0.8 +multidict==5.1.0 +nbclient==0.5.1 +nbconvert==6.0.7 +nbformat==5.1.2 +nest-asyncio==1.4.3 networkx==2.5 -notebook==6.1.5 -numpy==1.19.4 -orderedmultidict==1.0.1 -packaging==20.4 +notebook==6.2.0 +numpy==1.20.1 +packaging==20.9 palettable==3.3.0 -pamqp==2.3 -pandas==1.1.4 +pamqp==2.3.0 +pandas==1.2.1 pandocfilters==1.4.3 paramiko==2.7.2 -parso==0.7.1 +parso==0.8.1 pexpect==4.8.0 -pg8000==1.16.6 +pg8000==1.17.0 pgsu==0.1.0 pgtest==1.3.2 pickleshare==0.7.5 -pika==1.1.0 -Pillow==8.0.1 -plotly==4.12.0 +Pillow==8.1.0 +plotly==4.14.3 pluggy==0.13.1 plumpy==0.18.4 -prometheus-client==0.8.0 -prompt-toolkit==3.0.8 -psutil==5.7.3 +prometheus-client==0.9.0 +prompt-toolkit==3.0.14 +psutil==5.8.0 psycopg2-binary==2.8.6 -ptyprocess==0.6.0 -py==1.9.0 +ptyprocess==0.7.0 +py==1.10.0 py-cpuinfo==7.0.0 -PyCifRW==4.4.1 +PyCifRW==4.4.2 pycparser==2.20 -pydata-sphinx-theme==0.4.1 -Pygments==2.7.2 -pymatgen==2020.10.20 +pydata-sphinx-theme==0.4.3 +Pygments==2.7.4 +pymatgen==2020.12.31 PyMySQL==0.9.3 PyNaCl==1.4.0 pyparsing==2.4.7 pyrsistent==0.17.3 -pytest==6.1.2 +pytest==6.2.2 +pytest-asyncio==0.14.0 pytest-benchmark==3.2.3 pytest-cov==2.10.1 pytest-rerunfailures==9.1.1 pytest-timeout==1.4.2 -pytest-asyncio==0.12.0 python-dateutil==2.8.1 python-editor==1.0.4 python-memcached==1.59 +pytray==0.3.1 pytz==2019.3 PyYAML==5.1.2 -pyzmq==19.0.2 -qtconsole==4.7.7 +pyzmq==22.0.2 +qtconsole==5.0.2 QtPy==1.9.0 reentry==1.3.1 -requests==2.24.0 +requests==2.25.1 retrying==1.3.3 ruamel.yaml==0.16.12 -scipy==1.5.3 +scipy==1.6.0 scramp==1.2.0 seekpath==1.9.7 +semantic-version==2.8.5 Send2Trash==1.5.0 -shellingham==1.3.2 +setuptools-rust==0.11.6 +shellingham==1.4.0 shortuuid==1.0.1 simplejson==3.17.2 six==1.15.0 -snowballstemmer==2.0.0 -spglib==1.16.0 +snowballstemmer==2.1.0 +spglib==1.16.1 Sphinx==3.2.1 sphinx-copybutton==0.3.1 -sphinx-notfound-page==0.5 +sphinx-notfound-page==0.6 sphinx-panels==0.5.2 sphinxcontrib-applehelp==1.0.2 sphinxcontrib-details-directive==0.1.0 @@ -137,24 +146,26 @@ sphinxcontrib-htmlhelp==1.0.3 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 -sphinxext-rediraffe==0.2.4 -SQLAlchemy==1.3.20 +sphinxext-rediraffe==0.2.5 +SQLAlchemy==1.3.23 sqlalchemy-diff==0.1.3 SQLAlchemy-Utils==0.34.2 sqlparse==0.4.1 -sympy==1.6.2 +sympy==1.7.1 tabulate==0.8.7 -terminado==0.9.1 +terminado==0.9.2 testpath==0.4.4 toml==0.10.2 -tqdm==4.51.0 +tornado==6.1 +tqdm==4.56.0 traitlets==5.0.5 tzlocal==2.1 -uncertainties==3.1.4 +uncertainties==3.1.5 upf-to-json==0.9.2 -urllib3==1.25.11 +urllib3==1.26.3 wcwidth==0.2.5 webencodings==0.5.1 Werkzeug==1.0.1 widgetsnbextension==3.5.1 wrapt==1.11.2 +yarl==1.6.3 diff --git a/setup.json b/setup.json index bafdd8085d..02c3e18b10 100644 --- a/setup.json +++ b/setup.json @@ -32,7 +32,7 @@ "django~=2.2", "ete3~=3.1", "graphviz~=0.13", - "ipython~=7.0", + "ipython~=7.20", "jinja2~=2.10", "kiwipy[rmq]~=0.7.1", "numpy~=1.17", From 99f988b556c14c51dbc8ba37867fd67882730aae Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Feb 2021 11:11:48 +0100 Subject: [PATCH 063/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20REFACTOR:=20`ci/`?= =?UTF-8?q?=20folder=20(#4565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit looks to address two issues: 1. The `ci/` folder has become cluttered; it contains configuration and scripts for both the GitHub Actions and Jenkins CI and it is not easily clear which is for which. 2. The Jenkins tests are somewhat of a black-box to most, since it is certainly not trivial to set up and run them locally. This has lead to them essentially not being touched since they were first written. The changes are as follows: 1. Moved the GH actions specific scripts to `.github/system_tests` 2. Refactored the Jenkins setup/tests to use [molecule](https://molecule.readthedocs.io) in the `.molecule/` folder (note we use molecule for testing all the quantum mobile code). You can read about this setup in `.molecule/README.md`, but essentially if you just run `tox -e molecule-django` locally it will create/launch a docker container, setup and run the tests within that container, then destroy the container. Locally, it additionally records and prints an analysis of queries made to the database during the workchain runs. 3. Moved the Jenkins configuration to `.jenkins/`, which is now mainly a thin wrapper around (2). This makes these tests more portable and easier to understand, modify or add to. --- .ci/Dockerfile | 108 ----------- .ci/Jenkinsfile | 179 ------------------ .ci/polish/polish_workchains/__init__.py | 9 - .ci/setup.sh | 34 ---- .ci/test_rpn.sh | 32 ---- .coveragerc | 2 +- {.ci => .docker}/docker-rabbitmq.yml | 9 +- .dockerignore | 1 + {.ci => .github/config}/doubler.sh | 0 .github/system_tests/README.md | 3 + .../pytest/test_pytest_fixtures.py | 0 .../pytest/test_unittest_example.py | 0 {.ci => .github/system_tests}/test_daemon.py | 0 .../system_tests}/test_ipython_magics.py | 0 .../system_tests}/test_plugin_testcase.py | 0 .../system_tests}/test_profile_manager.py | 0 .../system_tests}/test_test_manager.py | 0 .../system_tests}/test_verdi_load_time.sh | 0 {.ci => .github/system_tests}/workchains.py | 0 .github/workflows/setup.sh | 2 +- .github/workflows/tests.sh | 18 +- .gitignore | 2 +- .jenkins/Dockerfile | 25 +++ .jenkins/Jenkinsfile | 135 +++++++++++++ {.ci => .jenkins}/check-jenkinsfile.sh | 0 .molecule/README.md | 61 ++++++ .molecule/default/Dockerfile | 11 ++ .molecule/default/config_jenkins.yml | 53 ++++++ .molecule/default/config_local.yml | 69 +++++++ .molecule/default/create_docker.yml | 120 ++++++++++++ .../default/files}/polish/__init__.py | 0 .../default/files}/polish/cli.py | 9 +- .../default/files}/polish/lib/__init__.py | 0 .../default/files}/polish/lib/expression.py | 0 .../files}/polish/lib/template/base.tpl | 0 .../files}/polish/lib/template/workchain.tpl | 0 .../default/files}/polish/lib/workchain.py | 76 ++++---- .molecule/default/run_tests.yml | 1 + .molecule/default/setup_aiida.yml | 95 ++++++++++ .molecule/default/setup_python.yml | 27 +++ .molecule/default/tasks/log_query_stats.yml | 59 ++++++ .molecule/default/tasks/reset_query_stats.yml | 7 + .molecule/default/test_polish_workchains.yml | 82 ++++++++ pyproject.toml | 16 ++ tests/cmdline/commands/test_profile.py | 2 +- tests/engine/test_process_function.py | 2 +- 46 files changed, 829 insertions(+), 420 deletions(-) delete mode 100644 .ci/Dockerfile delete mode 100644 .ci/Jenkinsfile delete mode 100644 .ci/polish/polish_workchains/__init__.py delete mode 100755 .ci/setup.sh delete mode 100755 .ci/test_rpn.sh rename {.ci => .docker}/docker-rabbitmq.yml (77%) rename {.ci => .github/config}/doubler.sh (100%) create mode 100644 .github/system_tests/README.md rename {.ci => .github/system_tests}/pytest/test_pytest_fixtures.py (100%) rename {.ci => .github/system_tests}/pytest/test_unittest_example.py (100%) rename {.ci => .github/system_tests}/test_daemon.py (100%) rename {.ci => .github/system_tests}/test_ipython_magics.py (100%) rename {.ci => .github/system_tests}/test_plugin_testcase.py (100%) rename {.ci => .github/system_tests}/test_profile_manager.py (100%) rename {.ci => .github/system_tests}/test_test_manager.py (100%) rename {.ci => .github/system_tests}/test_verdi_load_time.sh (100%) rename {.ci => .github/system_tests}/workchains.py (100%) create mode 100644 .jenkins/Dockerfile create mode 100644 .jenkins/Jenkinsfile rename {.ci => .jenkins}/check-jenkinsfile.sh (100%) create mode 100644 .molecule/README.md create mode 100644 .molecule/default/Dockerfile create mode 100644 .molecule/default/config_jenkins.yml create mode 100644 .molecule/default/config_local.yml create mode 100644 .molecule/default/create_docker.yml rename {.ci => .molecule/default/files}/polish/__init__.py (100%) rename {.ci => .molecule/default/files}/polish/cli.py (95%) rename {.ci => .molecule/default/files}/polish/lib/__init__.py (100%) rename {.ci => .molecule/default/files}/polish/lib/expression.py (100%) rename {.ci => .molecule/default/files}/polish/lib/template/base.tpl (100%) rename {.ci => .molecule/default/files}/polish/lib/template/workchain.tpl (100%) rename {.ci => .molecule/default/files}/polish/lib/workchain.py (83%) create mode 100644 .molecule/default/run_tests.yml create mode 100644 .molecule/default/setup_aiida.yml create mode 100644 .molecule/default/setup_python.yml create mode 100644 .molecule/default/tasks/log_query_stats.yml create mode 100644 .molecule/default/tasks/reset_query_stats.yml create mode 100644 .molecule/default/test_polish_workchains.yml diff --git a/.ci/Dockerfile b/.ci/Dockerfile deleted file mode 100644 index 27884ed493..0000000000 --- a/.ci/Dockerfile +++ /dev/null @@ -1,108 +0,0 @@ -FROM ubuntu:20.04 -MAINTAINER AiiDA Team - -# This is necessary such that the setup of `tzlocal` is non-interactive -ENV DEBIAN_FRONTEND=noninteractive - -ARG uid=1000 -ARG gid=1000 - -# Set correct locale -# For something more complex, as reported by https://hub.docker.com/_/ubuntu/ -# and taken from postgres: -# make the "en_US.UTF-8" locale so postgres will be utf-8 enabled by default -# The `software-properties-common` is necessary to get the command `add-apt-repository` -RUN apt-get update && apt-get install -y locales software-properties-common && rm -rf /var/lib/apt/lists/* \ - && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 -ENV LANG en_US.utf8 - -# Putting the LANG also in the root .bashrc, so that the DB is later -# Created with the UTF8 locale -RUN sed -i '/interactively/iexport LANG=en_US.utf8' /root/.bashrc -# This is probably the right command to issue to make sure all users see it as the default locale -RUN update-locale LANG=en_US.utf8 - -# I don't define it for now (should use the one of ubuntu by default, anyway -# jenkins will replace it with 'cat') -#CMD ["/bin/true"] - -RUN add-apt-repository ppa:deadsnakes/ppa - -# install required software -RUN apt-get update \ - && apt-get -y install \ - git \ - vim \ - openssh-client \ - postgresql \ - postgresql-client \ - && apt-get -y install \ - python3-pip \ - texlive-base \ - texlive-plain-generic \ - texlive-fonts-recommended \ - texlive-latex-base \ - texlive-latex-recommended \ - texlive-latex-extra \ - dvipng \ - dvidvi \ - graphviz \ - bc \ - time \ - && rm -rf /var/lib/apt/lists/* \ - && apt-get clean all - -# Disable password requests for requests coming from localhost -# Of course insecure, but ok for testing -RUN cp /etc/postgresql/12/main/pg_hba.conf /etc/postgresql/12/main/pg_hba.conf~ && \ - perl -npe 's/^([^#]*)md5$/$1trust/' /etc/postgresql/12/main/pg_hba.conf~ > /etc/postgresql/12/main/pg_hba.conf - -# install sudo otherwise tests for quicksetup fail, -# see #1382. I think this part should be removed in the -# future and AiiDA should work also without sudo. -## Also install openssh-server needed for AiiDA tests, -## and openmpi-bin to have 'mpirun', -## and rabbitmq-server needed by AiiDA as the event queue -## and libkrb5-dev for gssapi.h -RUN apt-get update \ - && apt-get -y install \ - sudo \ - locate \ - openssh-server \ - openmpi-bin \ - rabbitmq-server \ - libkrb5-dev \ - && rm -rf /var/lib/apt/lists/* \ - && apt-get clean all - -# locate will not find anything if the DB is not updated. -# Should take ~3-4 secs, so ok -RUN updatedb - -# update pip and setuptools to get a relatively-recent version -# This can be updated in the future -RUN pip3 install pip==19.2.3 setuptools==42.0.2 - -# Put the doubler script -COPY doubler.sh /usr/local/bin/ - -# Use messed-up filename to test quoting robustness -RUN mv /usr/local/bin/doubler.sh /usr/local/bin/d\"o\'ub\ ler.sh - -# add USER (no password); 1000 is the uid of the user in the jenkins docker -RUN groupadd -g ${gid} jenkins && useradd -m -s /bin/bash -u ${uid} -g ${gid} jenkins - -# add to sudoers and don't ask password -RUN adduser jenkins sudo && adduser jenkins adm -RUN echo "%sudo ALL=(ALL:ALL) NOPASSWD:ALL" > /etc/sudoers.d/nopwd -RUN mkdir -p /scratch/jenkins/ && chown jenkins /scratch/jenkins/ && chmod o+rX /scratch/ - -########################################## -############ Installation Setup ########## -########################################## - -# install rest of the packages as normal user -USER jenkins - -# set $HOME, create git directory -ENV HOME /home/jenkins diff --git a/.ci/Jenkinsfile b/.ci/Jenkinsfile deleted file mode 100644 index 74c6a2078f..0000000000 --- a/.ci/Jenkinsfile +++ /dev/null @@ -1,179 +0,0 @@ -// Note: this part might happen on a different node than -// the one that will run the pipeline below, see -// https://stackoverflow.com/questions/44805076 -// but it should be ok for us as we only have one node -def user_id -def group_id -node { - user_id = sh(returnStdout: true, script: 'id -u').trim() - group_id = sh(returnStdout: true, script: 'id -g').trim() -} - -pipeline { - /* The tutorial was setting here agent none, and setting the - agent in each stage, using therefore different agents in each - stage. I think that for what we are trying to achieve, having - a single agent and running all in the same docker image is better, - but we need to check this for more advanced usages. */ - // agent none - agent { - // Documentation: https://jenkins.io/doc/book/pipeline/syntax/#agent - // Note: we reuse the pip cache for speed - // TMPFS: we make sure that postgres is different for every run, - // but also runs fast - dockerfile { - filename 'Dockerfile' - dir '.ci' - args '-v jenkins-pip-cache:/home/jenkins/.cache/pip/ --tmpfs /var/lib/postgresql-tmp --tmpfs /tmp:exec' - additionalBuildArgs "--build-arg uid=${user_id} --build-arg gid=${group_id}" - } - } - environment { - WORKSPACE_PATH="." - COMPUTER_SETUP_TYPE="jenkins" - // The following two variables allow to run selectively tests only for one backend - RUN_ALSO_DJANGO="true" - RUN_ALSO_SQLALCHEMY="true" - // To avoid that different pipes (stderr, stdout, different processes) get in the wrong order - PYTHONUNBUFFERED="yes" - } - stages { - stage('Pre-build') { - steps { - // Clean work dir (often runs reshare the same folder, and it might - // contain old data from previous runs - this is particularly - // problematic when a folder is deleted from git but .pyc files - // are left in) - sh 'git clean -fdx' - sh 'sudo /etc/init.d/ssh restart' - sh 'sudo chown -R jenkins:jenkins /home/jenkins/.cache/' - // (re)start rabbitmq (both to start it or to reload the configuration) - sh 'sudo /etc/init.d/rabbitmq-server restart' - - // Make sure the tmpfs folder is owned by postgres, and that it - // contains the right data - sh 'sudo chown postgres:postgres /var/lib/postgresql-tmp' - sh 'sudo mv /var/lib/postgresql/* /var/lib/postgresql-tmp/' - sh 'sudo rmdir /var/lib/postgresql/' - sh 'sudo ln -s /var/lib/postgresql-tmp/ /var/lib/postgresql' - - // (re)start postgres (both to start it or to reload the configuration) - sh 'sudo /etc/init.d/postgresql restart' - - // rerun updatedb otherwise 'locate' prints a warning that the DB is old... - sh 'sudo updatedb' - - // Debug: check that I can connect without password - sh 'echo "SELECT datname FROM pg_database" | psql -h localhost -U postgres -w' - - // Add the line to the .bashrc, but before it stops when non-interactive - // So it can find the location of 'verdi' - sh "sed -i '/interactively/iexport PATH=\${PATH}:~/.local/bin' ~/.bashrc" - // Add path needed by the daemon to find the workchains - sh "sed -i '/interactively/iexport PYTHONPATH=\${PYTHONPATH}:'`pwd`'/.ci/' ~/.bashrc" - sh "cat ~/.bashrc" - } - } - stage('Build') { - steps { - sh 'pip install --upgrade --user pip' - sh 'pip install --user .[all]' - // To be able to do ssh localhost - sh 'ssh-keygen -t rsa -N "" -f ~/.ssh/id_rsa' - sh 'cp ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys' - sh 'ssh-keyscan -H localhost >> ~/.ssh/known_hosts' - } - post { - always { - sh 'pip freeze > pip-freeze.txt' - archiveArtifacts artifacts: 'pip-freeze.txt', fingerprint: true - } - } - } - stage('Test') { - failFast false // It is the default, but I still put it for future reference - // failFast would stop as soon as there is a failing test - parallel { - stage('Test-Django') { - environment { - AIIDA_TEST_BACKEND="django" - // I run the two tests in two different folders, otherwise - // they might get at the point of writing the config.json at the - // same time and one of the two would crash - AIIDA_PATH="/tmp/aiida-django-folder" - } - when { - // This allows to selectively run only one backend - environment name: 'RUN_ALSO_DJANGO', value: 'true' - } - steps { - sh '.ci/setup.sh' - sh '.ci/test_rpn.sh' - } - } - stage('Test-SQLAlchemy') { - environment { - AIIDA_TEST_BACKEND="sqlalchemy" - AIIDA_PATH="/tmp/aiida-sqla-folder" - } - when { - // This allows to selectively run only one backend - environment name: 'RUN_ALSO_SQLALCHEMY', value: 'true' - } - steps { - sh '.ci/setup.sh' - sh '.ci/test_rpn.sh' - } - } - } - } - } - post { - always { - // Some debug stuff - sh 'whoami ; pwd; echo $AIIDA_TEST_BACKEND' - cleanWs() - } - success { - echo 'The run finished successfully!' - } - unstable { - echo 'This run is unstable...' - } - failure { - echo "This run failed..." - } - // You can trigger actions when the status change (e.g. it starts failing, - // or it starts working again - e.g. sending emails or similar) - // possible variables: see e.g. https://qa.nuxeo.org/jenkins/pipeline-syntax/globals - // Other valid names: fixed, regression (opposite of fixed), aborted (by user, typically) - // Note that I had problems with email, I don't know if it is a configuration problem - // or a missing plugin. - changed { - script { - if (currentBuild.getPreviousBuild()) { - echo "The state changed from ${currentBuild.getPreviousBuild().result} to ${currentBuild.currentResult}." - } - else { - echo "This is the first build, and its status is: ${currentBuild.currentResult}." - } - } - } - } - options { - // we do not want the whole run to hang forever - - // we set a total timeout of 1 hour - timeout(time: 60, unit: 'MINUTES') - } -} - - -// Other things to add possibly: -// global options (or per-stage options) with timeout: https://jenkins.io/doc/book/pipeline/syntax/#options-example -// retry-on-failure for some specific tasks: https://jenkins.io/doc/book/pipeline/syntax/#available-stage-options -// parameters: https://jenkins.io/doc/book/pipeline/syntax/#parameters -// input: interesting for user input before continuing: https://jenkins.io/doc/book/pipeline/syntax/#input -// when conditions, e.g. to depending on details on the commit (e.g. only when specific -// files are changed, where there is a string in the commit log, for a specific branch, -// for a Pull Request,for a specific environment variable, ...): -// https://jenkins.io/doc/book/pipeline/syntax/#when diff --git a/.ci/polish/polish_workchains/__init__.py b/.ci/polish/polish_workchains/__init__.py deleted file mode 100644 index 2776a55f97..0000000000 --- a/.ci/polish/polish_workchains/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################### -# Copyright (c), The AiiDA team. All rights reserved. # -# This file is part of the AiiDA code. # -# # -# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # -# For further information on the license, see the LICENSE.txt file # -# For further information please visit http://www.aiida.net # -########################################################################### diff --git a/.ci/setup.sh b/.ci/setup.sh deleted file mode 100755 index c28fdcadc9..0000000000 --- a/.ci/setup.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -ev - -# The following is needed on jenkins, for some reason bashrc is not reloaded automatically -if [ -e ~/.bashrc ] ; then source ~/.bashrc ; fi - -# Add the .ci and the polish folder to the python path such that defined workchains can be found by the daemon -export PYTHONPATH="${PYTHONPATH}:${WORKSPACE_PATH}/.ci" -export PYTHONPATH="${PYTHONPATH}:${WORKSPACE_PATH}/.ci/polish" - -PSQL_COMMAND="CREATE DATABASE $AIIDA_TEST_BACKEND ENCODING \"UTF8\" LC_COLLATE=\"en_US.UTF-8\" LC_CTYPE=\"en_US.UTF-8\" TEMPLATE=template0;" -psql -h localhost -c "${PSQL_COMMAND}" -U postgres -w - -verdi setup --profile $AIIDA_TEST_BACKEND \ - --email="aiida@localhost" --first-name=AiiDA --last-name=test --institution="AiiDA Team" \ - --db-engine 'postgresql_psycopg2' --db-backend=$AIIDA_TEST_BACKEND --db-host="localhost" --db-port=5432 \ - --db-name="$AIIDA_TEST_BACKEND" --db-username=postgres --db-password='' \ - --repository="/tmp/repository_${AIIDA_TEST_BACKEND}/" --non-interactive - -verdi profile setdefault $AIIDA_TEST_BACKEND -verdi config runner.poll.interval 0 - -# Start the daemon for the correct profile and add four additional workers to prevent deadlock with integration tests -verdi -p $AIIDA_TEST_BACKEND daemon start -verdi -p $AIIDA_TEST_BACKEND daemon incr 4 - -verdi -p $AIIDA_TEST_BACKEND computer setup --non-interactive --label=localhost --hostname=localhost --transport=local \ - --scheduler=direct --mpiprocs-per-machine=1 --prepend-text="" --append-text="" -verdi -p $AIIDA_TEST_BACKEND computer configure local localhost --non-interactive --safe-interval=0 - -# Configure the 'add' code inside localhost -verdi -p $AIIDA_TEST_BACKEND code setup -n -L add \ - -D "simple script that adds two numbers" --on-computer -P arithmetic.add \ - -Y localhost --remote-abs-path=/bin/bash diff --git a/.ci/test_rpn.sh b/.ci/test_rpn.sh deleted file mode 100755 index 7af950b26a..0000000000 --- a/.ci/test_rpn.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash - -# Be verbose, and stop with error as soon there's one -set -ev - -declare -a EXPRESSIONS=("1 -2 -1 4 -5 -5 * * * * +" "2 1 3 3 -1 + ^ ^ +" "3 -5 -1 -4 + * ^" "2 4 2 -4 * * +" "3 1 1 5 ^ ^ ^" "3 1 3 4 -4 2 * + + ^ ^") -NUMBER_WORKCHAINS=5 -TIMEOUT=600 -CODE='add!' # Note the exclamation point is necessary to force the value to be interpreted as LABEL type identifier - -# Needed on Jenkins -if [ -e ~/.bashrc ] ; then source ~/.bashrc ; fi - -# Define the absolute path to the RPN cli script -DATA_DIR="${WORKSPACE_PATH}/.ci" -CLI_SCRIPT="${DATA_DIR}/polish/cli.py" - -# Export the polish module to the python path so generated workchains can be imported -export PYTHONPATH="${PYTHONPATH}:${DATA_DIR}/polish" - -# Get the absolute path for verdi -VERDI=$(which verdi) - -if [ -n "$EXPRESSIONS" ]; then - for expression in "${EXPRESSIONS[@]}"; do - $VERDI -p ${AIIDA_TEST_BACKEND} run "${CLI_SCRIPT}" -X $CODE -C -F -d -t $TIMEOUT "$expression" - done -else - for i in $(seq 1 $NUMBER_WORKCHAINS); do - $VERDI -p ${AIIDA_TEST_BACKEND} run "${CLI_SCRIPT}" -X $CODE -C -F -d -t $TIMEOUT - done -fi diff --git a/.coveragerc b/.coveragerc index b27dfc7b30..6b8baa55af 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,4 +2,4 @@ source = aiida [html] -directory = .ci/coverage/html +directory = .coverage_html diff --git a/.ci/docker-rabbitmq.yml b/.docker/docker-rabbitmq.yml similarity index 77% rename from .ci/docker-rabbitmq.yml rename to .docker/docker-rabbitmq.yml index 894f81f587..da266790ff 100644 --- a/.ci/docker-rabbitmq.yml +++ b/.docker/docker-rabbitmq.yml @@ -2,10 +2,10 @@ # if you wish to control the rabbitmq used. # Simply install docker, then run: -# $ docker-compose -f .ci/docker-rabbitmq.yml up -d +# $ docker-compose -f .docker/docker-rabbitmq.yml up -d # and to power down, after testing: -# $ docker-compose -f .ci/docker-rabbitmq.yml down +# $ docker-compose -f .docker/docker-rabbitmq.yml down # you can monitor rabbitmq use at: http://localhost:15672 @@ -27,3 +27,8 @@ services: interval: 30s timeout: 30s retries: 5 + networks: + - aiida-rmq + +networks: + aiida-rmq: diff --git a/.dockerignore b/.dockerignore index 84f915fed9..dfe06bad59 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .benchmarks +.cache .coverage .mypy_cache .pytest_cache diff --git a/.ci/doubler.sh b/.github/config/doubler.sh similarity index 100% rename from .ci/doubler.sh rename to .github/config/doubler.sh diff --git a/.github/system_tests/README.md b/.github/system_tests/README.md new file mode 100644 index 0000000000..de7976fc15 --- /dev/null +++ b/.github/system_tests/README.md @@ -0,0 +1,3 @@ +This folder contains tests that must be run directly in the GitHub Actions container environment. + +This is usually because they require an active daemon or have other specific environment requirements. diff --git a/.ci/pytest/test_pytest_fixtures.py b/.github/system_tests/pytest/test_pytest_fixtures.py similarity index 100% rename from .ci/pytest/test_pytest_fixtures.py rename to .github/system_tests/pytest/test_pytest_fixtures.py diff --git a/.ci/pytest/test_unittest_example.py b/.github/system_tests/pytest/test_unittest_example.py similarity index 100% rename from .ci/pytest/test_unittest_example.py rename to .github/system_tests/pytest/test_unittest_example.py diff --git a/.ci/test_daemon.py b/.github/system_tests/test_daemon.py similarity index 100% rename from .ci/test_daemon.py rename to .github/system_tests/test_daemon.py diff --git a/.ci/test_ipython_magics.py b/.github/system_tests/test_ipython_magics.py similarity index 100% rename from .ci/test_ipython_magics.py rename to .github/system_tests/test_ipython_magics.py diff --git a/.ci/test_plugin_testcase.py b/.github/system_tests/test_plugin_testcase.py similarity index 100% rename from .ci/test_plugin_testcase.py rename to .github/system_tests/test_plugin_testcase.py diff --git a/.ci/test_profile_manager.py b/.github/system_tests/test_profile_manager.py similarity index 100% rename from .ci/test_profile_manager.py rename to .github/system_tests/test_profile_manager.py diff --git a/.ci/test_test_manager.py b/.github/system_tests/test_test_manager.py similarity index 100% rename from .ci/test_test_manager.py rename to .github/system_tests/test_test_manager.py diff --git a/.ci/test_verdi_load_time.sh b/.github/system_tests/test_verdi_load_time.sh similarity index 100% rename from .ci/test_verdi_load_time.sh rename to .github/system_tests/test_verdi_load_time.sh diff --git a/.ci/workchains.py b/.github/system_tests/workchains.py similarity index 100% rename from .ci/workchains.py rename to .github/system_tests/workchains.py diff --git a/.github/workflows/setup.sh b/.github/workflows/setup.sh index 6ff5c4c6e0..8e890cc36d 100755 --- a/.github/workflows/setup.sh +++ b/.github/workflows/setup.sh @@ -15,7 +15,7 @@ sed -i "s|PLACEHOLDER_PROFILE|test_${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.ya sed -i "s|PLACEHOLDER_DATABASE_NAME|test_${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.yaml" sed -i "s|PLACEHOLDER_REPOSITORY|/tmp/test_repository_test_${AIIDA_TEST_BACKEND}/|" "${CONFIG}/profile.yaml" sed -i "s|PLACEHOLDER_WORK_DIR|${GITHUB_WORKSPACE}|" "${CONFIG}/localhost.yaml" -sed -i "s|PLACEHOLDER_REMOTE_ABS_PATH_DOUBLER|${GITHUB_WORKSPACE}/.ci/doubler.sh|" "${CONFIG}/doubler.yaml" +sed -i "s|PLACEHOLDER_REMOTE_ABS_PATH_DOUBLER|${CONFIG}/doubler.sh|" "${CONFIG}/doubler.yaml" verdi setup --config "${CONFIG}/profile.yaml" verdi computer setup --config "${CONFIG}/localhost.yaml" diff --git a/.github/workflows/tests.sh b/.github/workflows/tests.sh index ed4887a43e..7ce9855d86 100755 --- a/.github/workflows/tests.sh +++ b/.github/workflows/tests.sh @@ -2,7 +2,9 @@ set -ev # Make sure the folder containing the workchains is in the python path before the daemon is started -export PYTHONPATH="${PYTHONPATH}:${GITHUB_WORKSPACE}/.ci" +SYSTEM_TESTS="${GITHUB_WORKSPACE}/.github/system_tests" + +export PYTHONPATH="${PYTHONPATH}:${SYSTEM_TESTS}" # pytest options: # - report timings of tests @@ -19,18 +21,18 @@ export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov=aiida" # daemon tests verdi daemon start 4 -verdi -p test_${AIIDA_TEST_BACKEND} run .ci/test_daemon.py +verdi -p test_${AIIDA_TEST_BACKEND} run ${SYSTEM_TESTS}/test_daemon.py verdi daemon stop # tests for the testing infrastructure -pytest --noconftest .ci/test_test_manager.py -pytest --noconftest .ci/test_ipython_magics.py -pytest --noconftest .ci/test_profile_manager.py -python .ci/test_plugin_testcase.py # uses custom unittest test runner +pytest --noconftest ${SYSTEM_TESTS}/test_test_manager.py +pytest --noconftest ${SYSTEM_TESTS}/test_ipython_magics.py +pytest --noconftest ${SYSTEM_TESTS}/test_profile_manager.py +python ${SYSTEM_TESTS}/test_plugin_testcase.py # uses custom unittest test runner -# Until the `.ci/pytest` tests are moved within `tests` we have to run them separately and pass in the path to the +# Until the `${SYSTEM_TESTS}/pytest` tests are moved within `tests` we have to run them separately and pass in the path to the # `conftest.py` explicitly, because otherwise it won't be able to find the fixtures it provides -AIIDA_TEST_PROFILE=test_$AIIDA_TEST_BACKEND pytest tests/conftest.py .ci/pytest +AIIDA_TEST_PROFILE=test_$AIIDA_TEST_BACKEND pytest tests/conftest.py ${SYSTEM_TESTS}/pytest # main aiida-core tests AIIDA_TEST_PROFILE=test_$AIIDA_TEST_BACKEND pytest tests diff --git a/.gitignore b/.gitignore index 624d61e194..52e6f940fe 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ coverage.xml # Files created by RPN tests -.ci/polish/polish_workchains/polish* +**/polish_workchains/polish* # Build files dist/ diff --git a/.jenkins/Dockerfile b/.jenkins/Dockerfile new file mode 100644 index 0000000000..40dd9d0cdd --- /dev/null +++ b/.jenkins/Dockerfile @@ -0,0 +1,25 @@ +FROM aiidateam/aiida-prerequisites:0.2.1 + +# to run the tests +RUN pip install ansible~=2.10.0 molecule~=3.1.0 + +RUN apt-get update && \ + apt-get install -y sudo && \ + apt-get autoclean + +ARG uid=1000 +ARG gid=1000 + +# add USER (no password); 1000 is the uid of the user in the jenkins docker +RUN groupadd -g ${gid} jenkins && useradd -m -s /bin/bash -u ${uid} -g ${gid} jenkins + +# add to sudoers and don't ask password +RUN adduser jenkins sudo && adduser jenkins adm && adduser jenkins root +RUN echo "%sudo ALL=(ALL:ALL) NOPASSWD:ALL" > /etc/sudoers.d/nopwd +RUN mkdir -p /scratch/jenkins/ && chown jenkins /scratch/jenkins/ && chmod o+rX /scratch/ + +# set $HOME to the directory where the repository is mounted +ENV HOME /home/jenkins + +# this is added since otherwise jenkins prints /etc/profile contents for all sh commands +RUN echo 'set +x' | cat - /etc/profile > temp && mv temp /etc/profile diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile new file mode 100644 index 0000000000..61d5fc43cf --- /dev/null +++ b/.jenkins/Jenkinsfile @@ -0,0 +1,135 @@ +// Note: this part might happen on a different node than +// the one that will run the pipeline below, see +// https://stackoverflow.com/questions/44805076 +// but it should be ok for us as we only have one node +def user_id +def group_id +node { + user_id = sh(returnStdout: true, script: 'id -u').trim() + group_id = sh(returnStdout: true, script: 'id -g').trim() +} + +pipeline { + agent { + dockerfile { + filename 'Dockerfile' + dir '.jenkins' + args '-u root:root -v jenkins-pip-cache:/home/jenkins/.cache/pip/' + additionalBuildArgs "--build-arg uid=${user_id} --build-arg gid=${group_id}" + } + } + environment { + MOLECULE_GLOB = ".molecule/*/config_jenkins.yml" + AIIDA_TEST_WORKERS = 2 + RUN_ALSO_DJANGO = "true" + RUN_ALSO_SQLALCHEMY = "true" + } + stages { + stage ('Init services') { + steps { + // we must run /sbin/my_init directly (rather than in a separate process) + // see: https://github.com/phusion/baseimage-docker/blob/18.04-1.0.0/image/bin/my_init + sh '/etc/my_init.d/00_regen_ssh_host_keys.sh' + sh '/etc/my_init.d/10_create-system-user.sh' + // we cannot run this task because it tries to write to the jenkins log file without permission: + // Cannot contact : java.io.FileNotFoundException: /var/jenkins_home/workspace/aiida_core_aiidateam_PR-4565@2@tmp/durable-65ec45aa/jenkins-log.txt (Permission denied) + // sh '/etc/my_init.d/10_syslog-ng.init' + sh '/etc/my_init.d/20_start-rabbitmq.sh' + sh '/etc/my_init.d/30_start-postgres.sh' + sh '/sbin/my_init --skip-startup-files --no-kill-all-on-exit 2> /dev/null &' + } + } + stage ('Prepare environment') { + steps { + // Clean work dir + // often runs reshare the same folder, and it might contain old data from previous runs + // this is particularly problematic when a folder is deleted from git but .pyc files are left in + sh 'git clean -fdx' + // this folder is mounted from a volume so will have wrong permissions + sh 'sudo chown root:root /home/jenkins/.cache' + // prepare environment (install python dependencies etc) + sh 'pip install -r requirements/requirements-py-3.7.txt --cache-dir /home/jenkins/.cache/pip' + sh 'pip install --no-deps .' + // for some reason if we don't change permissions here then python can't import the modules + sh 'sudo chmod -R a+rwX /opt/conda/lib/python3.7/site-packages/' + } + } + stage('Test') { + failFast false // Do not kill one if the other fails + parallel { + stage('Test-Django') { + environment { + AIIDA_TEST_BACKEND="django" + } + when { + environment name: 'RUN_ALSO_DJANGO', value: 'true' + } + steps { + sh 'molecule test --parallel' + } + } + stage('Test-SQLAlchemy') { + environment { + AIIDA_TEST_BACKEND="sqlalchemy" + } + when { + environment name: 'RUN_ALSO_SQLALCHEMY', value: 'true' + } + steps { + sh 'molecule test --parallel' + } + } + } + } + } + post { + always { + // Some debug stuff + sh ''' + whoami + pwd + ''' + cleanWs() + } + success { + echo 'The run finished successfully!' + } + unstable { + echo 'This run is unstable...' + } + failure { + echo "This run failed..." + } + // You can trigger actions when the status change (e.g. it starts failing, + // or it starts working again - e.g. sending emails or similar) + // possible variables: see e.g. https://qa.nuxeo.org/jenkins/pipeline-syntax/globals + // Other valid names: fixed, regression (opposite of fixed), aborted (by user, typically) + // Note that I had problems with email, I don't know if it is a configuration problem + // or a missing plugin. + changed { + script { + if (currentBuild.getPreviousBuild()) { + echo "The state changed from ${currentBuild.getPreviousBuild().result} to ${currentBuild.currentResult}." + } + else { + echo "This is the first build, and its status is: ${currentBuild.currentResult}." + } + } + } + } + options { + // we do not want the whole run to hang forever - + timeout(time: 40, unit: 'MINUTES') + } +} + + +// Other things to add possibly: +// global options (or per-stage options) with timeout: https://jenkins.io/doc/book/pipeline/syntax/#options-example +// retry-on-failure for some specific tasks: https://jenkins.io/doc/book/pipeline/syntax/#available-stage-options +// parameters: https://jenkins.io/doc/book/pipeline/syntax/#parameters +// input: interesting for user input before continuing: https://jenkins.io/doc/book/pipeline/syntax/#input +// when conditions, e.g. to depending on details on the commit (e.g. only when specific +// files are changed, where there is a string in the commit log, for a specific branch, +// for a Pull Request,for a specific environment variable, ...): +// https://jenkins.io/doc/book/pipeline/syntax/#when diff --git a/.ci/check-jenkinsfile.sh b/.jenkins/check-jenkinsfile.sh similarity index 100% rename from .ci/check-jenkinsfile.sh rename to .jenkins/check-jenkinsfile.sh diff --git a/.molecule/README.md b/.molecule/README.md new file mode 100644 index 0000000000..21dbcd94a5 --- /dev/null +++ b/.molecule/README.md @@ -0,0 +1,61 @@ +# Molecule System Integration/Stress Testing + +This folder contains configuration for running automated system integration tests against an isolated AiiDA environment. + +This utilises [molecule](https://molecule.readthedocs.io) to automate the creation/destruction of a docker container environment and the setup and testing within it. + +The tests are currently set up to stress-test the AiiDA engine by launching a number of workchains of varying complexity, defined by [reverse polish notation](https://en.wikipedia.org/wiki/Reverse_Polish_notation). +They are part of the continuous integration pipeline of AiiDA and are run using [Jenkins](https://www.jenkins.io/) on our own test runner. + +## Running the tests locally + +The simplest way to run these tests is to use the `tox` environment provided in this repository's `pyproject.toml` file: + +```console +$ pip install tox +$ tox -e molecule-django +``` + +**NOTE**: if you wan to run molecule directly, ensure that you set `export MOLECULE_GLOB=.molecule/*/config_local.yml`. + +This runs the `test` scenario (defined in `config_local.yml`) which: + +1. Deletes any existing container with the same label +2. Creates a docker container, based on the `Dockerfile` in this folder, which also copies the repository code into the container (see `create_docker.yml`). +3. Installs aiida-core (see `setup_python.yml`) +4. Sets up an AiiDA profile and computer (see `setup_aiida.yml`). +5. Sets up a number of workchains of varying complexity,defined by [reverse polish notation](https://en.wikipedia.org/wiki/Reverse_Polish_notation), and runs them (see `run_tests.yml`). +6. Deletes the container. + +If you wish to setup the container for manual inspection (i.e. only run steps 2 - 4) you can run: + +```console +$ tox -e molecule-django converge +``` + +Then you can jump into this container or run the tests (step 5) separately with: + +```console +$ tox -e molecule-django validate +``` + +and finally run step 6: + +```console +$ tox -e molecule-django destroy +``` + +You can set up the aiida profile with either django or sqla, +and even run both in parallel: + +```console +$ tox -e molecule-django,molecule-sqla -p -- test --parallel +``` + +## Additional variables + +You can specify the number of daemon workers to spawn using the `AIIDA_TEST_WORKERS` environment variable: + +```console +$ AIIDA_TEST_WORKERS=4 tox -e molecule-django +``` diff --git a/.molecule/default/Dockerfile b/.molecule/default/Dockerfile new file mode 100644 index 0000000000..9746373762 --- /dev/null +++ b/.molecule/default/Dockerfile @@ -0,0 +1,11 @@ +FROM aiidateam/aiida-prerequisites:0.2.1 + +# allow for collection of query statistics +# (must also be intialised on each database) +RUN sed -i '/.*initdb -D.*/a echo "shared_preload_libraries='pg_stat_statements'" >> /home/${SYSTEM_USER}/.postgresql/postgresql.conf' /opt/start-postgres.sh +# other options +# pg_stat_statements.max = 10000 +# pg_stat_statements.track = all + +# Copy AiiDA repository +COPY . aiida-core diff --git a/.molecule/default/config_jenkins.yml b/.molecule/default/config_jenkins.yml new file mode 100644 index 0000000000..dff5dd8ed7 --- /dev/null +++ b/.molecule/default/config_jenkins.yml @@ -0,0 +1,53 @@ +# On Jenkins we are already inside the container, +# so we simply run the playbooks in the local environment + +scenario: + converge_sequence: + - prepare + - converge + test_sequence: + - converge + - verify +# connect to local environment +driver: + name: delegated + options: + managed: False + ansible_connection_options: + ansible_connection: local +platforms: +- name: molecule-aiida-${AIIDA_TEST_BACKEND:-django} +# configuration for how to run the playbooks +provisioner: + name: ansible + # log: true # for debugging + playbooks: + prepare: setup_python.yml + converge: setup_aiida.yml + verify: run_tests.yml + config_options: + defaults: + # nicer stdout printing + stdout_callback: yaml + bin_ansible_callbacks: true + # add timing to tasks + callback_whitelist: timer, profile_tasks + # reduce CPU load + internal_poll_interval: 0.002 + ssh_connection: + # reduce network operations + pipelining: True + inventory: + hosts: + all: + vars: + become_method: sudo + aiida_user: aiida + aiida_core_dir: $WORKSPACE + aiida_pip_cache: /home/jenkins/.cache/pip + aiida_pip_editable: false + venv_bin: /opt/conda/bin + ansible_python_interpreter: "{{ venv_bin }}/python" + aiida_backend: ${AIIDA_TEST_BACKEND:-django} + aiida_workers: ${AIIDA_TEST_WORKERS:-2} + aiida_path: /tmp/.aiida_${AIIDA_TEST_BACKEND:-django} diff --git a/.molecule/default/config_local.yml b/.molecule/default/config_local.yml new file mode 100644 index 0000000000..c9168f35ac --- /dev/null +++ b/.molecule/default/config_local.yml @@ -0,0 +1,69 @@ +# when we run locally, we must first create a docker container +# then we run the playbooks inside that + +scenario: + create_sequence: + - create + - prepare + converge_sequence: + - create + - prepare + - converge + destroy_sequence: + - destroy + test_sequence: + - destroy + - create + - prepare + - converge + - verify + - destroy +# configuration for building the isolated container +driver: + name: docker +platforms: +- name: molecule-aiida-${AIIDA_TEST_BACKEND:-django} + image: molecule_tests + context: "../.." + command: /sbin/my_init + healthcheck: + test: wait-for-services + volumes: + - molecule-pip-cache-${AIIDA_TEST_BACKEND:-django}:/home/.cache/pip + privileged: true + retries: 3 +# configuration for how to run the playbooks +provisioner: + name: ansible + # log: true # for debugging + playbooks: + create: create_docker.yml + prepare: setup_python.yml + converge: setup_aiida.yml + verify: run_tests.yml + config_options: + defaults: + # nicer stdout printing + stdout_callback: yaml + bin_ansible_callbacks: true + # add timing to tasks + callback_whitelist: timer, profile_tasks + # reduce CPU load + internal_poll_interval: 0.002 + ssh_connection: + # reduce network operations + pipelining: True + inventory: + hosts: + all: + vars: + become_method: su + aiida_user: aiida + aiida_core_dir: /aiida-core + aiida_pip_cache: /home/.cache/pip + venv_bin: /opt/conda/bin + ansible_python_interpreter: "{{ venv_bin }}/python" + aiida_backend: ${AIIDA_TEST_BACKEND:-django} + aiida_workers: ${AIIDA_TEST_WORKERS:-2} + aiida_path: /tmp/.aiida_${AIIDA_TEST_BACKEND:-django} + aiida_query_stats: true diff --git a/.molecule/default/create_docker.yml b/.molecule/default/create_docker.yml new file mode 100644 index 0000000000..2bef943879 --- /dev/null +++ b/.molecule/default/create_docker.yml @@ -0,0 +1,120 @@ +# this is mainly a copy of https://github.com/ansible-community/molecule-docker/blob/master/molecule_docker/playbooks/create.yml +# with fix: https://github.com/ansible-community/molecule-docker/pull/30 +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + molecule_labels: + owner: molecule + tasks: + + - name: Discover local Docker images + docker_image_info: + name: "molecule_local/{{ item.name }}" + docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + with_items: "{{ molecule_yml.platforms }}" + register: docker_images + + - name: Build the container image + when: + - docker_images.results | map(attribute='images') | select('equalto', []) | list | count >= 0 + docker_image: + build: + path: "{{ item.context | default(molecule_ephemeral_directory) }}" + dockerfile: "{{ item.dockerfile | default(molecule_scenario_directory + '/Dockerfile') }}" + pull: "{{ item.pull | default(true) }}" + network: "{{ item.network_mode | default(omit) }}" + args: "{{ item.buildargs | default(omit) }}" + name: "molecule_local/{{ item.image }}" + docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + force_source: "{{ item.force | default(true) }}" + source: build + with_items: "{{ molecule_yml.platforms }}" + loop_control: + label: "molecule_local/{{ item.image }}" + no_log: false + register: result + until: result is not failed + retries: "{{ item.retries | default(3) }}" + delay: 30 + + - debug: + var: result + + - name: Determine the CMD directives + set_fact: + command_directives_dict: >- + {{ command_directives_dict | default({}) | + combine({ item.name: item.command | default('bash -c "while true; do sleep 10000; done"') }) + }} + with_items: "{{ molecule_yml.platforms }}" + when: item.override_command | default(true) + + - name: Create molecule instance(s) + docker_container: + name: "{{ item.name }}" + docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + hostname: "{{ item.hostname | default(item.name) }}" + image: "{{ item.pre_build_image | default(false) | ternary('', 'molecule_local/') }}{{ item.image }}" + pull: "{{ item.pull | default(omit) }}" + memory: "{{ item.memory | default(omit) }}" + memory_swap: "{{ item.memory_swap | default(omit) }}" + state: started + recreate: false + log_driver: json-file + command: "{{ (command_directives_dict | default({}))[item.name] | default(omit) }}" + user: "{{ item.user | default(omit) }}" + pid_mode: "{{ item.pid_mode | default(omit) }}" + privileged: "{{ item.privileged | default(omit) }}" + security_opts: "{{ item.security_opts | default(omit) }}" + devices: "{{ item.devices | default(omit) }}" + volumes: "{{ item.volumes | default(omit) }}" + tmpfs: "{{ item.tmpfs | default(omit) }}" + capabilities: "{{ item.capabilities | default(omit) }}" + sysctls: "{{ item.sysctls | default(omit) }}" + exposed_ports: "{{ item.exposed_ports | default(omit) }}" + published_ports: "{{ item.published_ports | default(omit) }}" + ulimits: "{{ item.ulimits | default(omit) }}" + networks: "{{ item.networks | default(omit) }}" + network_mode: "{{ item.network_mode | default(omit) }}" + networks_cli_compatible: "{{ item.networks_cli_compatible | default(true) }}" + purge_networks: "{{ item.purge_networks | default(omit) }}" + dns_servers: "{{ item.dns_servers | default(omit) }}" + etc_hosts: "{{ item.etc_hosts | default(omit) }}" + env: "{{ item.env | default(omit) }}" + restart_policy: "{{ item.restart_policy | default(omit) }}" + restart_retries: "{{ item.restart_retries | default(omit) }}" + tty: "{{ item.tty | default(omit) }}" + labels: "{{ molecule_labels | combine(item.labels | default({})) }}" + container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" + healthcheck: "{{ item.healthcheck | default(omit) }}" + register: server + with_items: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + no_log: false + async: 7200 + poll: 0 + + - name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: docker_jobs + until: docker_jobs.finished + retries: 300 + with_items: "{{ server.results }}" + no_log: false diff --git a/.ci/polish/__init__.py b/.molecule/default/files/polish/__init__.py similarity index 100% rename from .ci/polish/__init__.py rename to .molecule/default/files/polish/__init__.py diff --git a/.ci/polish/cli.py b/.molecule/default/files/polish/cli.py similarity index 95% rename from .ci/polish/cli.py rename to .molecule/default/files/polish/cli.py index 3b149df500..ea9762421c 100755 --- a/.ci/polish/cli.py +++ b/.molecule/default/files/polish/cli.py @@ -99,7 +99,6 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout import importlib import sys import time - import uuid from aiida.orm import Code, Int, Str from aiida.engine import run_get_node, submit @@ -118,11 +117,10 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout click.echo(f"the expression '{expression}' is invalid: {error}") sys.exit(1) - filename = f'polish_{str(uuid.uuid4().hex)}.py' evaluated = lib_expression.evaluate(expression, modulo) outlines, stack = lib_workchain.generate_outlines(expression) outlines_string = lib_workchain.format_outlines(outlines, use_calculations, use_calcfunctions) - lib_workchain.write_workchain(outlines_string, filename=filename) + filename = lib_workchain.write_workchain(outlines_string).name click.echo(f'Expression: {expression}') @@ -149,6 +147,7 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout if workchain.is_terminated: timed_out = False + total_time = time.time() - start_time break if timed_out: @@ -166,7 +165,9 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout sys.exit(1) else: + start_time = time.time() results, workchain = run_get_node(workchains.Polish00WorkChain, **inputs) + total_time = time.time() - start_time result = results['result'] click.echo(f'Evaluated : {evaluated}') @@ -180,7 +181,7 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout sys.exit(1) else: click.secho('Success: ', fg='green', bold=True, nl=False) - click.secho('the workchain accurately reproduced the evaluated value', bold=True) + click.secho(f'the workchain accurately reproduced the evaluated value in {total_time:.2f}s', bold=True) sys.exit(0) diff --git a/.ci/polish/lib/__init__.py b/.molecule/default/files/polish/lib/__init__.py similarity index 100% rename from .ci/polish/lib/__init__.py rename to .molecule/default/files/polish/lib/__init__.py diff --git a/.ci/polish/lib/expression.py b/.molecule/default/files/polish/lib/expression.py similarity index 100% rename from .ci/polish/lib/expression.py rename to .molecule/default/files/polish/lib/expression.py diff --git a/.ci/polish/lib/template/base.tpl b/.molecule/default/files/polish/lib/template/base.tpl similarity index 100% rename from .ci/polish/lib/template/base.tpl rename to .molecule/default/files/polish/lib/template/base.tpl diff --git a/.ci/polish/lib/template/workchain.tpl b/.molecule/default/files/polish/lib/template/workchain.tpl similarity index 100% rename from .ci/polish/lib/template/workchain.tpl rename to .molecule/default/files/polish/lib/template/workchain.tpl diff --git a/.ci/polish/lib/workchain.py b/.molecule/default/files/polish/lib/workchain.py similarity index 83% rename from .ci/polish/lib/workchain.py rename to .molecule/default/files/polish/lib/workchain.py index e20a9a3308..7dd4072d1a 100644 --- a/.ci/polish/lib/workchain.py +++ b/.molecule/default/files/polish/lib/workchain.py @@ -10,8 +10,9 @@ """Functions to dynamically generate a WorkChain from a reversed polish notation expression.""" import collections -import errno +import hashlib import os +from pathlib import Path from string import Template from .expression import OPERATORS # pylint: disable=relative-beyond-top-level @@ -185,9 +186,11 @@ def format_indent(level=0, width=INDENTATION_WIDTH): return ' ' * level * width -def write_workchain(outlines, directory=None, filename=None): +def write_workchain(outlines, directory=None) -> Path: """ Given a list of string formatted outlines, write the corresponding workchains to file + + :returns: file path """ dirpath = os.path.dirname(os.path.realpath(__file__)) template_dir = os.path.join(dirpath, 'template') @@ -197,22 +200,10 @@ def write_workchain(outlines, directory=None, filename=None): if directory is None: directory = os.path.join(dirpath, os.path.pardir, 'polish_workchains') - if filename is None: - filename = os.path.join(directory, 'polish.py') - else: - filename = os.path.join(directory, filename) - - try: - os.makedirs(directory) - except OSError as exception: - if exception.errno != errno.EEXIST: - raise + directory = Path(directory) - try: - init_file = os.path.join(directory, '__init__.py') - os.utime(init_file, None) - except OSError: - open(init_file, 'a').close() + directory.mkdir(parents=True, exist_ok=True) + (directory / '__init__.py').touch() with open(template_file_base, 'r') as handle: template_base = handle.readlines() @@ -220,32 +211,39 @@ def write_workchain(outlines, directory=None, filename=None): with open(template_file_workchain, 'r') as handle: template_workchain = Template(handle.read()) - with open(filename, 'w') as handle: + code_strings = [] - for line in template_base: - handle.write(line) - handle.write('\n') + for line in template_base: + code_strings.append(line) + code_strings.append('\n') - counter = len(outlines) - 1 - for outline in outlines: + counter = len(outlines) - 1 + for outline in outlines: - outline_string = '' - for subline in outline.split('\n'): - outline_string += f'\t\t\t{subline}\n' + outline_string = '' + for subline in outline.split('\n'): + outline_string += f'\t\t\t{subline}\n' - if counter == len(outlines) - 1: - child_class = None - else: - child_class = f'Polish{counter + 1:02d}WorkChain' + if counter == len(outlines) - 1: + child_class = None + else: + child_class = f'Polish{counter + 1:02d}WorkChain' + + subs = { + 'class_name': f'Polish{counter:02d}WorkChain', + 'child_class': child_class, + 'outline': outline_string, + } + code_strings.append(template_workchain.substitute(**subs)) + code_strings.append('\n\n') + + counter -= 1 + + code_string = '\n'.join(code_strings) + hashed = hashlib.md5(code_string.encode('utf8')).hexdigest() - subs = { - 'class_name': f'Polish{counter:02d}WorkChain', - 'child_class': child_class, - 'outline': outline_string, - } - handle.write(template_workchain.substitute(**subs)) - handle.write('\n\n') + filepath = directory / f'polish_{hashed}.py' - counter -= 1 + filepath.write_text(code_string) - return filename + return filepath diff --git a/.molecule/default/run_tests.yml b/.molecule/default/run_tests.yml new file mode 100644 index 0000000000..a1a617ca4b --- /dev/null +++ b/.molecule/default/run_tests.yml @@ -0,0 +1 @@ +- import_playbook: test_polish_workchains.yml diff --git a/.molecule/default/setup_aiida.yml b/.molecule/default/setup_aiida.yml new file mode 100644 index 0000000000..5faca0f399 --- /dev/null +++ b/.molecule/default/setup_aiida.yml @@ -0,0 +1,95 @@ +- name: Set up AiiDa Environment + hosts: all + gather_facts: false + + # run as aiida user + become: true + become_method: "{{ become_method }}" + become_user: "{{ aiida_user | default('aiida') }}" + + environment: + AIIDA_PATH: "{{ aiida_path }}" + + tasks: + + - name: reentry scan + command: "{{ venv_bin }}/reentry scan" + changed_when: false + + - name: Create a new database with name "{{ aiida_backend }}" + postgresql_db: + name: "{{ aiida_backend }}" + login_host: localhost + login_user: aiida + login_password: '' + encoding: UTF8 + lc_collate: en_US.UTF-8 + lc_ctype: en_US.UTF-8 + template: template0 + + - name: Add pg_stat_statements extension to the database + when: aiida_query_stats | default(false) | bool + postgresql_ext: + name: pg_stat_statements + login_host: localhost + login_user: aiida + login_password: '' + db: "{{ aiida_backend }}" + + - name: verdi setup for "{{ aiida_backend }}" + command: > + {{ venv_bin }}/verdi setup + --non-interactive + --profile "{{ aiida_backend }}" + --email "aiida@localhost" + --first-name "ringo" + --last-name "starr" + --institution "the beatles" + --db-backend "{{ aiida_backend }}" + --db-host=localhost + --db-name="{{ aiida_backend }}" + --db-username=aiida + --db-password='' + args: + creates: "{{ aiida_path }}/.aiida/config.json" + + - name: "Check if computer is already present" + command: "{{ venv_bin }}/verdi -p {{ aiida_backend }} computer show localhost" + ignore_errors: true + changed_when: false + no_log: true + register: aiida_check_computer + + - name: verdi computer setup localhost + when: aiida_check_computer.rc != 0 + command: > + {{ venv_bin }}/verdi -p {{ aiida_backend }} computer setup + --non-interactive + --label "localhost" + --description "this computer" + --hostname "localhost" + --transport local + --scheduler direct + --work-dir {{ aiida_path }}/local_work_dir/ + --mpirun-command "mpirun -np {tot_num_mpiprocs}" + --mpiprocs-per-machine 1 + + - name: verdi computer configure localhost + when: aiida_check_computer.rc != 0 + command: > + {{ venv_bin }}/verdi -p {{ aiida_backend }} computer configure local "localhost" + --non-interactive + --safe-interval 0.0 + + # we restart the daemon in run_tests.yml, so no need to start here + # - name: verdi start daemon with {{ aiida_workers }} workers + # command: "{{ venv_bin }}/verdi -p {{ aiida_backend }} daemon start {{ aiida_workers }}" + + - name: get verdi status + command: "{{ venv_bin }}/verdi -p {{ aiida_backend }} status" + register: verdi_status + changed_when: false + + - name: print verdi status + debug: + var: verdi_status.stdout diff --git a/.molecule/default/setup_python.yml b/.molecule/default/setup_python.yml new file mode 100644 index 0000000000..eba59ea303 --- /dev/null +++ b/.molecule/default/setup_python.yml @@ -0,0 +1,27 @@ +- name: Set up Python Environment + hosts: all + gather_facts: false + + # run as root user + become: true + become_method: "{{ become_method }}" + become_user: root + + tasks: + + - name: pip install aiida-core requirements + pip: + chdir: "{{ aiida_core_dir }}" + # TODO dynamically change for python version + requirements: requirements/requirements-py-3.7.txt + executable: "{{ venv_bin }}/pip" + extra_args: --cache-dir {{ aiida_pip_cache }} + register: pip_install_deps + + - name: pip install aiida-core + pip: + chdir: "{{ aiida_core_dir }}" + name: . + executable: "{{ venv_bin }}/pip" + editable: "{{ aiida_pip_editable | default(true) }}" + extra_args: --no-deps diff --git a/.molecule/default/tasks/log_query_stats.yml b/.molecule/default/tasks/log_query_stats.yml new file mode 100644 index 0000000000..b62c53e2d5 --- /dev/null +++ b/.molecule/default/tasks/log_query_stats.yml @@ -0,0 +1,59 @@ +- name: Get DB summary statistics + postgresql_query: + login_host: localhost + login_user: "{{ aiida_user | default('aiida') }}" + login_password: '' + db: "{{ aiida_backend }}" + query: | + SELECT + CAST(sum(calls) AS INTEGER) as calls, + CAST(sum(rows) AS INTEGER) as rows, + to_char(sum(total_time), '9.99EEEE') as time_ms + FROM pg_stat_statements + WHERE query !~* 'pg_stat_statements'; + register: db_query_stats_summary + +- debug: + var: db_query_stats_summary.query_result + +- name: Get DB statistics for largest queries by time + postgresql_query: + login_host: localhost + login_user: "{{ aiida_user | default('aiida') }}" + login_password: '' + db: "{{ aiida_backend }}" + query: | + SELECT + to_char(total_time, '9.99EEEE') AS time_ms, + calls, + rows, + query + FROM pg_stat_statements + WHERE query !~* 'pg_stat_statements' + ORDER BY calls DESC + LIMIT {{ query_stats_limit | default(5) }}; + register: db_query_stats_time + +- debug: + var: db_query_stats_time.query_result + +- name: Get DB statistics for largest queries by calls + postgresql_query: + login_host: localhost + login_user: "{{ aiida_user | default('aiida') }}" + login_password: '' + db: "{{ aiida_backend }}" + query: | + SELECT + to_char(total_time, '9.99EEEE') AS time_ms, + calls, + rows, + query + FROM pg_stat_statements + WHERE query !~* 'pg_stat_statements' + ORDER BY calls DESC + LIMIT {{ query_stats_limit | default(5) }}; + register: db_query_stats_calls + +- debug: + var: db_query_stats_calls.query_result diff --git a/.molecule/default/tasks/reset_query_stats.yml b/.molecule/default/tasks/reset_query_stats.yml new file mode 100644 index 0000000000..44fd9e3827 --- /dev/null +++ b/.molecule/default/tasks/reset_query_stats.yml @@ -0,0 +1,7 @@ +- name: Reset database query statistics + postgresql_query: + login_host: localhost + login_user: "{{ aiida_user | default('aiida') }}" + login_password: '' + db: "{{ aiida_backend }}" + query: SELECT pg_stat_statements_reset(); diff --git a/.molecule/default/test_polish_workchains.yml b/.molecule/default/test_polish_workchains.yml new file mode 100644 index 0000000000..72fcaf1530 --- /dev/null +++ b/.molecule/default/test_polish_workchains.yml @@ -0,0 +1,82 @@ +- name: Test the runnning of complex polish notation workchains + hosts: all + gather_facts: false + + # run as aiida user + become: true + become_method: "{{ become_method }}" + become_user: "{{ aiida_user | default('aiida') }}" + + environment: + AIIDA_PATH: "{{ aiida_path }}" + + tasks: + + - name: "Check if add code is already present" + command: "{{ venv_bin }}/verdi -p {{ aiida_backend }} code show add@localhost" + ignore_errors: true + changed_when: false + no_log: true + register: aiida_check_code + + - name: verdi add code setup + when: aiida_check_code.rc != 0 + command: > + {{ venv_bin }}/verdi -p {{ aiida_backend }} code setup + -D "simple script that adds two numbers" + -n -L add -P arithmetic.add + -Y localhost --remote-abs-path=/bin/bash + + - name: Copy workchain files + copy: + src: polish + dest: "${HOME}/{{ aiida_backend }}" + + - name: get python path including workchains + command: echo "${PYTHONPATH}:${HOME}/{{ aiida_backend }}/polish" + register: echo_pythonpath + + - set_fact: + aiida_pythonpath: "{{ echo_pythonpath.stdout }}" + + - name: Reset pythonpath of daemon ({{ aiida_workers }} workers) + # note `verdi daemon restart` did not seem to update the environmental variables? + shell: | + {{ venv_bin }}/verdi -p {{ aiida_backend }} daemon stop + {{ venv_bin }}/verdi -p {{ aiida_backend }} daemon start {{ aiida_workers }} + environment: + PYTHONPATH: "{{ aiida_pythonpath }}" + + - when: aiida_query_stats | default(false) | bool + include_tasks: tasks/reset_query_stats.yml + + - name: "run polish workchains" + # Note the exclamation point after the code is necessary to force the value to be interpreted as LABEL type identifier + shell: | + set -e + declare -a EXPRESSIONS=({{ polish_expressions | map('quote') | join(' ') }}) + for expression in "${EXPRESSIONS[@]}"; do + {{ venv_bin }}/verdi -p {{ aiida_backend }} run --auto-group -l polish -- "{{ polish_script }}" -X add! -C -F -d -t {{ polish_timeout }} "$expression" + done + args: + executable: /bin/bash + vars: + polish_script: "${HOME}/{{ aiida_backend }}/polish/cli.py" + polish_timeout: 600 + polish_expressions: + - "1 -2 -1 4 -5 -5 * * * * +" + - "2 1 3 3 -1 + ^ ^ +" + - "3 -5 -1 -4 + * ^" + - "2 4 2 -4 * * +" + - "3 1 1 5 ^ ^ ^" + # - "3 1 3 4 -4 2 * + + ^ ^" # this takes a longer time to run + environment: + PYTHONPATH: "{{ aiida_pythonpath }}" + register: polish_output + + - name: print polish workchain output + debug: + msg: "{{ polish_output.stdout }}" + + - when: aiida_query_stats | default(false) | bool + include_tasks: tasks/log_query_stats.yml diff --git a/pyproject.toml b/pyproject.toml index 70abc15eb5..cabb245f39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,4 +124,20 @@ commands = description = Run the pre-commit checks extras = pre-commit commands = pre-commit run {posargs} + +[testenv:molecule-{django,sqla}] +description = Run the molecule containerised tests +skip_install = true +parallel_show_output = true +deps = + ansible~=2.10.0 + docker~=4.2 + molecule[docker]~=3.1.0 +setenv = + MOLECULE_GLOB = .molecule/*/config_local.yml + django: AIIDA_TEST_BACKEND = django + sqla: AIIDA_TEST_BACKEND = sqlalchemy +passenv = + AIIDA_TEST_WORKERS +commands = molecule {posargs:test} """ diff --git a/tests/cmdline/commands/test_profile.py b/tests/cmdline/commands/test_profile.py index 120b09d762..ddfbdd1183 100644 --- a/tests/cmdline/commands/test_profile.py +++ b/tests/cmdline/commands/test_profile.py @@ -131,7 +131,7 @@ def test_delete_partial(self): """Test the `verdi profile delete` command. .. note:: we skip deleting the database as this might require sudo rights and this is tested in the CI tests - defined in the file `.ci/test_profile.py` + defined in the file `.github/system_tests/test_profile.py` """ self.mock_profiles() diff --git a/tests/engine/test_process_function.py b/tests/engine/test_process_function.py index b07d8e941a..5cf6713d60 100644 --- a/tests/engine/test_process_function.py +++ b/tests/engine/test_process_function.py @@ -352,7 +352,7 @@ def test_launchers(self): self.assertTrue(isinstance(node, orm.CalcFunctionNode)) # Process function can be submitted and will be run by a daemon worker as long as the function is importable - # Note that the actual running is not tested here but is done so in `.ci/test_daemon.py`. + # Note that the actual running is not tested here but is done so in `.github/system_tests/test_daemon.py`. node = submit(add_multiply, x=orm.Int(1), y=orm.Int(2), z=orm.Int(3)) assert isinstance(node, orm.WorkFunctionNode) From 25d07d8fe0161c6f42067a906e50b617b7eb2f43 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Feb 2021 13:31:48 +0100 Subject: [PATCH 064/114] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20drop=20setup?= =?UTF-8?q?tools=20upper=20pinning=20(#4725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- utils/dependency_management.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cabb245f39..0a683f8807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=40.8.0,<50", "wheel", "reentry~=1.3", "fastentrypoints~=0.12"] +requires = ["setuptools>=40.8.0", "wheel", "reentry~=1.3", "fastentrypoints~=0.12"] build-backend = "setuptools.build_meta:__legacy__" [tool.pylint.master] diff --git a/utils/dependency_management.py b/utils/dependency_management.py index 79782e504e..f317453a6e 100755 --- a/utils/dependency_management.py +++ b/utils/dependency_management.py @@ -188,7 +188,7 @@ def update_pyproject_toml(): # update the build-system key pyproject.setdefault('build-system', {}) pyproject['build-system'].update({ - 'requires': ['setuptools>=40.8.0,<50', 'wheel', + 'requires': ['setuptools>=40.8.0', 'wheel', str(reentry_requirement), 'fastentrypoints~=0.12'], 'build-backend': 'setuptools.build_meta:__legacy__', From e7223ae9596809efe44e50e371a67cdc9216120b Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Feb 2021 13:48:25 +0100 Subject: [PATCH 065/114] CI: Improve polish workchain failure debugging (#4729) --- .molecule/default/files/polish/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/.molecule/default/files/polish/cli.py b/.molecule/default/files/polish/cli.py index ea9762421c..7bebd53b08 100755 --- a/.molecule/default/files/polish/cli.py +++ b/.molecule/default/files/polish/cli.py @@ -162,6 +162,7 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout except AttributeError: click.secho('Failed: ', fg='red', bold=True, nl=False) click.secho(f'the workchain<{workchain.pk}> did not return a result output node', bold=True) + click.echo(str(workchain.attributes)) sys.exit(1) else: From b07841ad5503085913850c96aed85eb89b226d3b Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Tue, 9 Feb 2021 14:43:59 +0100 Subject: [PATCH 066/114] fix: don't pass process stack via context (#4699) This PR fixes a memory leak: when running `CalcJob`s over an SSH connection, the first CalcJob that was run remained in memory indefinitely. `plumpy` uses the `contextvars` module to provide a reference to the `current_process` anywhere in a task launched by a process. When using any of `asyncio`'s `call_soon`, `call_later` or `call_at` methods, each individual function execution gets their own copy of this context. This means that as long as a handle to these scheduled executions remains in memory, the copy of the `'process stack'` context var (and thus the process itself) remain in memory, In this particular case, a handle to such a task (`do_open` a `transport`) remained in memory and caused the whole process to remain in memory as well via the 'process stack' context variable. This is fixed by explicitly passing an empty context to the execution of `do_open` (which anyhow does not need access to the `current_process`). An explicit test is added to make sure that no references to processes are leaked after running process via the interpreter as well as in the daemon tests. This PR adds the empty context in two other invocations of `call_later`, but there are more places in the code where these methods are used. As such it is a bit of a workaround. Eventually, this problem should likely be addressed by converting any functions that use `call_soon`, `call_later` or `call_at` and all their parents in the call stack to coroutines. Co-authored-by: Chris Sewell --- .../system_tests/pytest/test_memory_leaks.py | 39 ++++++++ .github/system_tests/test_daemon.py | 95 ++++++++++++------- aiida/engine/processes/calcjobs/manager.py | 11 ++- aiida/engine/transports.py | 9 +- requirements/requirements-py-3.7.txt | 1 + requirements/requirements-py-3.8.txt | 1 + requirements/requirements-py-3.9.txt | 1 + setup.json | 1 + tests/engine/test_run.py | 1 - tests/utils/memory.py | 31 ++++++ 10 files changed, 154 insertions(+), 36 deletions(-) create mode 100644 .github/system_tests/pytest/test_memory_leaks.py create mode 100644 tests/utils/memory.py diff --git a/.github/system_tests/pytest/test_memory_leaks.py b/.github/system_tests/pytest/test_memory_leaks.py new file mode 100644 index 0000000000..7a4edd1d1d --- /dev/null +++ b/.github/system_tests/pytest/test_memory_leaks.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Utilities for testing memory leakage.""" +from tests.utils import processes as test_processes # pylint: disable=no-name-in-module,import-error +from tests.utils.memory import get_instances # pylint: disable=no-name-in-module,import-error +from aiida.engine import processes, run +from aiida.plugins import CalculationFactory +from aiida import orm + +ArithmeticAddCalculation = CalculationFactory('arithmetic.add') + + +def test_leak_run_process(): + """Test whether running a dummy process leaks memory.""" + inputs = {'a': orm.Int(2), 'b': orm.Str('test')} + run(test_processes.DummyProcess, **inputs) + + # check that no reference to the process is left in memory + # some delay is necessary in order to allow for all callbacks to finish + process_instances = get_instances(processes.Process, delay=0.2) + assert not process_instances, f'Memory leak: process instances remain in memory: {process_instances}' + + +def test_leak_local_calcjob(aiida_local_code_factory): + """Test whether running a local CalcJob leaks memory.""" + inputs = {'x': orm.Int(1), 'y': orm.Int(2), 'code': aiida_local_code_factory('arithmetic.add', '/usr/bin/diff')} + run(ArithmeticAddCalculation, **inputs) + + # check that no reference to the process is left in memory + # some delay is necessary in order to allow for all callbacks to finish + process_instances = get_instances(processes.Process, delay=0.2) + assert not process_instances, f'Memory leak: process instances remain in memory: {process_instances}' diff --git a/.github/system_tests/test_daemon.py b/.github/system_tests/test_daemon.py index 17c3ca66ba..631d7a1784 100644 --- a/.github/system_tests/test_daemon.py +++ b/.github/system_tests/test_daemon.py @@ -18,6 +18,7 @@ from aiida.engine.daemon.client import get_daemon_client from aiida.engine.persistence import ObjectLoader from aiida.manage.caching import enable_caching +from aiida.engine.processes import Process from aiida.orm import CalcJobNode, load_node, Int, Str, List, Dict, load_code from aiida.plugins import CalculationFactory, WorkflowFactory from aiida.workflows.arithmetic.add_multiply import add_multiply, add @@ -26,6 +27,8 @@ WorkFunctionRunnerWorkChain, NestedInputNamespace, SerializeWorkChain, ArithmeticAddBaseWorkChain ) +from tests.utils.memory import get_instances # pylint: disable=import-error + CODENAME_ADD = 'add@localhost' CODENAME_DOUBLER = 'doubler@localhost' TIMEOUTSECS = 4 * 60 # 4 minutes @@ -389,9 +392,12 @@ def run_multiply_add_workchain(): assert results['result'].value == 5 -def main(): - """Launch a bunch of calculation jobs and workchains.""" - # pylint: disable=too-many-locals,too-many-statements,too-many-branches +def launch_all(): + """Launch a bunch of calculation jobs and workchains. + + :returns: dictionary with expected results and pks of all launched calculations and workchains + """ + # pylint: disable=too-many-locals,too-many-statements expected_results_process_functions = {} expected_results_calculations = {} expected_results_workchains = {} @@ -437,8 +443,8 @@ def main(): builder = NestedWorkChain.get_builder() input_val = 4 builder.inp = Int(input_val) - proc = submit(builder) - expected_results_workchains[proc.pk] = input_val + pk = submit(builder).pk + expected_results_workchains[pk] = input_val print('Submitting a workchain with a nested input namespace.') value = Int(-12) @@ -483,9 +489,46 @@ def main(): calculation_pks = sorted(expected_results_calculations.keys()) workchains_pks = sorted(expected_results_workchains.keys()) process_functions_pks = sorted(expected_results_process_functions.keys()) - pks = calculation_pks + workchains_pks + process_functions_pks - print('Wating for end of execution...') + return { + 'pks': calculation_pks + workchains_pks + process_functions_pks, + 'calculations': expected_results_calculations, + 'process_functions': expected_results_process_functions, + 'workchains': expected_results_workchains, + } + + +def relaunch_cached(results): + """Launch the same calculations but with caching enabled -- these should be FINISHED immediately.""" + code_doubler = load_code(CODENAME_DOUBLER) + cached_calcs = [] + with enable_caching(identifier='aiida.calculations:templatereplacer'): + for counter in range(1, NUMBER_CALCULATIONS + 1): + inputval = counter + calc, expected_result = run_calculation(code=code_doubler, counter=counter, inputval=inputval) + cached_calcs.append(calc) + results['calculations'][calc.pk] = expected_result + + if not ( + validate_calculations(results['calculations']) and validate_workchains(results['workchains']) and + validate_cached(cached_calcs) and validate_process_functions(results['process_functions']) + ): + print_daemon_log() + print('') + print('ERROR! Some return values are different from the expected value') + sys.exit(3) + + print_daemon_log() + print('') + print('OK, all calculations have the expected parsed result') + + +def main(): + """Launch a bunch of calculation jobs and workchains.""" + + results = launch_all() + + print('Waiting for end of execution...') start_time = time.time() exited_with_timeout = True while time.time() - start_time < TIMEOUTSECS: @@ -515,7 +558,7 @@ def main(): except subprocess.CalledProcessError as exception: print(f'Note: the command failed, message: {exception}') - if jobs_have_finished(pks): + if jobs_have_finished(results['pks']): print('Calculation terminated its execution') exited_with_timeout = False break @@ -525,30 +568,18 @@ def main(): print('') print(f'Timeout!! Calculation did not complete after {TIMEOUTSECS} seconds') sys.exit(2) - else: - # Launch the same calculations but with caching enabled -- these should be FINISHED immediately - cached_calcs = [] - with enable_caching(identifier='aiida.calculations:templatereplacer'): - for counter in range(1, NUMBER_CALCULATIONS + 1): - inputval = counter - calc, expected_result = run_calculation(code=code_doubler, counter=counter, inputval=inputval) - cached_calcs.append(calc) - expected_results_calculations[calc.pk] = expected_result - - if ( - validate_calculations(expected_results_calculations) and - validate_workchains(expected_results_workchains) and validate_cached(cached_calcs) and - validate_process_functions(expected_results_process_functions) - ): - print_daemon_log() - print('') - print('OK, all calculations have the expected parsed result') - sys.exit(0) - else: - print_daemon_log() - print('') - print('ERROR! Some return values are different from the expected value') - sys.exit(3) + + relaunch_cached(results) + + # Check that no references to processes remain in memory + # Note: This tests only processes that were `run` in the same interpreter, not those that were `submitted` + del results + processes = get_instances(Process, delay=1.0) + if processes: + print(f'Memory leak! Process instances remained in memory: {processes}') + sys.exit(4) + + sys.exit(0) if __name__ == '__main__': diff --git a/aiida/engine/processes/calcjobs/manager.py b/aiida/engine/processes/calcjobs/manager.py index 46d3c057e6..3c3cb6229c 100644 --- a/aiida/engine/processes/calcjobs/manager.py +++ b/aiida/engine/processes/calcjobs/manager.py @@ -10,6 +10,7 @@ """Module containing utilities and classes relating to job calculations running on systems that require transport.""" import asyncio import contextlib +import contextvars import logging import time from typing import Any, Dict, Hashable, Iterator, List, Optional, TYPE_CHECKING @@ -180,7 +181,10 @@ async def updating(): # Any outstanding requests? if self._update_requests_outstanding(): self._update_handle = self._loop.call_later( - self._get_next_update_delay(), asyncio.ensure_future, updating() + self._get_next_update_delay(), + asyncio.ensure_future, + updating(), + context=contextvars.Context(), # type: ignore[call-arg] ) else: self._update_handle = None @@ -188,7 +192,10 @@ async def updating(): # Check if we're already updating if self._update_handle is None: self._update_handle = self._loop.call_later( - self._get_next_update_delay(), asyncio.ensure_future, updating() + self._get_next_update_delay(), + asyncio.ensure_future, + updating(), + context=contextvars.Context(), # type: ignore[call-arg] ) @staticmethod diff --git a/aiida/engine/transports.py b/aiida/engine/transports.py index 3f7f259809..b722140834 100644 --- a/aiida/engine/transports.py +++ b/aiida/engine/transports.py @@ -13,6 +13,7 @@ import traceback from typing import Awaitable, Dict, Hashable, Iterator, Optional import asyncio +import contextvars from aiida.orm import AuthInfo from aiida.transports import Transport @@ -96,7 +97,13 @@ def do_open(): transport_request.future.set_result(transport) # Save the handle so that we can cancel the callback if the user no longer wants it - open_callback_handle = self._loop.call_later(safe_open_interval, do_open) + # Note: Don't pass the Process context, since (a) it is not needed by `do_open` and (b) the transport is + # passed around to many places, including outside aiida-core (e.g. paramiko). Anyone keeping a reference + # to this handle would otherwise keep the Process context (and thus the process itself) in memory. + # See https://github.com/aiidateam/aiida-core/issues/4698 + open_callback_handle = self._loop.call_later( + safe_open_interval, do_open, context=contextvars.Context() + ) # type: ignore[call-arg] try: transport_request.count += 1 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index b5289a465a..4b245a66c1 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -101,6 +101,7 @@ pycparser==2.20 pydata-sphinx-theme==0.4.3 Pygments==2.7.4 pymatgen==2020.12.31 +pympler==0.9 PyMySQL==0.9.3 PyNaCl==1.4.0 pyparsing==2.4.7 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index fcddaebd33..dfb8ed0cf1 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -100,6 +100,7 @@ pycparser==2.20 pydata-sphinx-theme==0.4.3 Pygments==2.7.4 pymatgen==2020.12.31 +pympler==0.9 PyMySQL==0.9.3 PyNaCl==1.4.0 pyparsing==2.4.7 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 62584c4f60..eaba807b9b 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -100,6 +100,7 @@ pycparser==2.20 pydata-sphinx-theme==0.4.3 Pygments==2.7.4 pymatgen==2020.12.31 +pympler==0.9 PyMySQL==0.9.3 PyNaCl==1.4.0 pyparsing==2.4.7 diff --git a/setup.json b/setup.json index 02c3e18b10..ca67d3806a 100644 --- a/setup.json +++ b/setup.json @@ -109,6 +109,7 @@ "pytest-cov~=2.7", "pytest-rerunfailures~=9.1,>=9.1.1", "pytest-benchmark~=3.2", + "pympler~=0.9", "coverage<5.0", "sqlalchemy-diff~=0.1.3" ], diff --git a/tests/engine/test_run.py b/tests/engine/test_run.py index 84981a6b7a..a9ce5641c9 100644 --- a/tests/engine/test_run.py +++ b/tests/engine/test_run.py @@ -8,7 +8,6 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the `run` functions.""" - from aiida.backends.testbase import AiidaTestCase from aiida.engine import run, run_get_node from aiida.orm import Int, Str, ProcessNode diff --git a/tests/utils/memory.py b/tests/utils/memory.py new file mode 100644 index 0000000000..9689108bfa --- /dev/null +++ b/tests/utils/memory.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Utilities for testing memory leakage.""" +import asyncio +from pympler import muppy + + +def get_instances(classes, delay=0.0): + """Return all instances of provided classes that are in memory. + + Useful for investigating memory leaks. + + :param classes: A class or tuple of classes to check (passed to `isinstance`). + :param delay: How long to sleep (seconds) before collecting the memory dump. + This is a convenience function for tests involving Processes. For example, :py:func:`~aiida.engine.run` returns + before all futures are resolved/cleaned up. Dumping memory too early would catch those and the references they + carry, although they may not actually be leaking memory. + """ + if delay > 0: + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.sleep(delay)) + + all_objects = muppy.get_objects() # this also calls gc.collect() + return [o for o in all_objects if hasattr(o, '__class__') and isinstance(o, classes)] From b5cc416be182ae0801363fca793d07170cd23905 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Feb 2021 15:46:14 +0100 Subject: [PATCH 067/114] CI: Add retry for polish workchains (#4733) To mitigate failures on Jenkins --- .molecule/default/files/polish/cli.py | 81 +++++++++++++------- .molecule/default/test_polish_workchains.yml | 2 +- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/.molecule/default/files/polish/cli.py b/.molecule/default/files/polish/cli.py index 7bebd53b08..69942a326e 100755 --- a/.molecule/default/files/polish/cli.py +++ b/.molecule/default/files/polish/cli.py @@ -9,6 +9,9 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Command line interface to dynamically create and run a WorkChain that can evaluate a reversed polish expression.""" +import importlib +import sys +import time import click @@ -71,8 +74,16 @@ default=False, help='Only evaluate the expression and generate the workchain but do not launch it' ) +@click.option( + '-r', + '--retries', + type=click.INT, + default=1, + show_default=True, + help='Number of retries for running via the daemon' +) @decorators.with_dbenv() -def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout, modulo, dry_run, daemon): +def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout, modulo, dry_run, daemon, retries): """ Evaluate the expression in Reverse Polish Notation in both a normal way and by procedurally generating a workchain that encodes the sequence of operators and gets the stack of operands as an input. Multiplications @@ -96,11 +107,8 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout If no expression is specified, a random one will be generated that adheres to these rules """ # pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches - import importlib - import sys - import time from aiida.orm import Code, Int, Str - from aiida.engine import run_get_node, submit + from aiida.engine import run_get_node lib_expression = importlib.import_module('lib.expression') lib_workchain = importlib.import_module('lib.workchain') @@ -138,32 +146,15 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout inputs['code'] = code if daemon: - workchain = submit(workchains.Polish00WorkChain, **inputs) - start_time = time.time() - timed_out = True - - while time.time() - start_time < timeout: - time.sleep(sleep) - - if workchain.is_terminated: - timed_out = False - total_time = time.time() - start_time + # the daemon tests have been known to fail on Jenkins, when the result node cannot be found + # to mitigate this, we can retry multiple times + for _ in range(retries): + output = run_via_daemon(workchains, inputs, sleep, timeout) + if output is not None: break - - if timed_out: - click.secho('Failed: ', fg='red', bold=True, nl=False) - click.secho( - f'the workchain<{workchain.pk}> did not finish in time and the operation timed out', bold=True - ) - sys.exit(1) - - try: - result = workchain.outputs.result - except AttributeError: - click.secho('Failed: ', fg='red', bold=True, nl=False) - click.secho(f'the workchain<{workchain.pk}> did not return a result output node', bold=True) - click.echo(str(workchain.attributes)) + if output is None: sys.exit(1) + result, workchain, total_time = output else: start_time = time.time() @@ -186,5 +177,37 @@ def launch(expression, code, use_calculations, use_calcfunctions, sleep, timeout sys.exit(0) +def run_via_daemon(workchains, inputs, sleep, timeout): + """Run via the daemon, polling until it is terminated or timeout.""" + from aiida.engine import submit + + workchain = submit(workchains.Polish00WorkChain, **inputs) + start_time = time.time() + timed_out = True + + while time.time() - start_time < timeout: + time.sleep(sleep) + + if workchain.is_terminated: + timed_out = False + total_time = time.time() - start_time + break + + if timed_out: + click.secho('Failed: ', fg='red', bold=True, nl=False) + click.secho(f'the workchain<{workchain.pk}> did not finish in time and the operation timed out', bold=True) + return None + + try: + result = workchain.outputs.result + except AttributeError: + click.secho('Failed: ', fg='red', bold=True, nl=False) + click.secho(f'the workchain<{workchain.pk}> did not return a result output node', bold=True) + click.echo(str(workchain.attributes)) + return None + + return result, workchain, total_time + + if __name__ == '__main__': launch() # pylint: disable=no-value-for-parameter diff --git a/.molecule/default/test_polish_workchains.yml b/.molecule/default/test_polish_workchains.yml index 72fcaf1530..95b060a182 100644 --- a/.molecule/default/test_polish_workchains.yml +++ b/.molecule/default/test_polish_workchains.yml @@ -56,7 +56,7 @@ set -e declare -a EXPRESSIONS=({{ polish_expressions | map('quote') | join(' ') }}) for expression in "${EXPRESSIONS[@]}"; do - {{ venv_bin }}/verdi -p {{ aiida_backend }} run --auto-group -l polish -- "{{ polish_script }}" -X add! -C -F -d -t {{ polish_timeout }} "$expression" + {{ venv_bin }}/verdi -p {{ aiida_backend }} run --auto-group -l polish -- "{{ polish_script }}" -X add! -C -F -d -t {{ polish_timeout }} -r 2 "$expression" done args: executable: /bin/bash From e99227b1b9c13df637cf8f9079134182a8c18102 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Feb 2021 16:21:25 +0100 Subject: [PATCH 068/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Standardise=20tra?= =?UTF-8?q?nsport=20task=20interrupt=20handling=20(#4692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For all transport tasks (upload, submit, update, retrieve), both `plumpy.futures.CancelledError` and `plumpy.process_states.Interruption` exceptions should be ignored by the exponential backoff mechanism (i.e. the task should not be retried) and raised directly (as opposed to as a `TransportTaskException`), so that they can be correctly caught by the `Waiting.execute` method. As an example, this fixes a known bug, whereby the upload task could not be cancelled via `CTRL-C` in an ipython shell. --- aiida/engine/processes/calcjobs/tasks.py | 35 +++++++++--------------- aiida/engine/utils.py | 6 ++-- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 8a837a189c..721b5317f9 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -91,14 +91,14 @@ async def do_upload(): try: logger.info(f'scheduled request to upload CalcJob<{node.pk}>') - ignore_exceptions = (plumpy.futures.CancelledError, PreSubmitException) + ignore_exceptions = (plumpy.futures.CancelledError, PreSubmitException, plumpy.process_states.Interruption) skip_submit = await exponential_backoff_retry( do_upload, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) except PreSubmitException: raise - except plumpy.futures.CancelledError: - pass + except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): + raise except Exception: logger.warning(f'uploading CalcJob<{node.pk}> failed') raise TransportTaskException(f'upload_calculation failed {max_attempts} times consecutively') @@ -139,15 +139,12 @@ async def do_submit(): try: logger.info(f'scheduled request to submit CalcJob<{node.pk}>') + ignore_exceptions = (plumpy.futures.CancelledError, plumpy.process_states.Interruption) result = await exponential_backoff_retry( - do_submit, - initial_interval, - max_attempts, - logger=node.logger, - ignore_exceptions=plumpy.process_states.Interruption + do_submit, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) - except plumpy.process_states.Interruption: - pass + except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): # pylint: disable=try-except-raise + raise except Exception: logger.warning(f'submitting CalcJob<{node.pk}> failed') raise TransportTaskException(f'submit_calculation failed {max_attempts} times consecutively') @@ -201,14 +198,11 @@ async def do_update(): try: logger.info(f'scheduled request to update CalcJob<{node.pk}>') + ignore_exceptions = (plumpy.futures.CancelledError, plumpy.process_states.Interruption) job_done = await exponential_backoff_retry( - do_update, - initial_interval, - max_attempts, - logger=node.logger, - ignore_exceptions=plumpy.process_states.Interruption + do_update, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) - except plumpy.process_states.Interruption: + except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): # pylint: disable=try-except-raise raise except Exception: logger.warning(f'updating CalcJob<{node.pk}> failed') @@ -270,14 +264,11 @@ async def do_retrieve(): try: logger.info(f'scheduled request to retrieve CalcJob<{node.pk}>') + ignore_exceptions = (plumpy.futures.CancelledError, plumpy.process_states.Interruption) result = await exponential_backoff_retry( - do_retrieve, - initial_interval, - max_attempts, - logger=node.logger, - ignore_exceptions=plumpy.process_states.Interruption + do_retrieve, initial_interval, max_attempts, logger=node.logger, ignore_exceptions=ignore_exceptions ) - except plumpy.process_states.Interruption: + except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): # pylint: disable=try-except-raise raise except Exception: logger.warning(f'retrieving CalcJob<{node.pk}> failed') diff --git a/aiida/engine/utils.py b/aiida/engine/utils.py index d76d55443e..3cbef87015 100644 --- a/aiida/engine/utils.py +++ b/aiida/engine/utils.py @@ -14,7 +14,7 @@ import contextlib from datetime import datetime import logging -from typing import Any, Awaitable, Callable, Iterator, List, Optional, Type, Union, TYPE_CHECKING +from typing import Any, Awaitable, Callable, Iterator, List, Optional, Tuple, Type, Union, TYPE_CHECKING if TYPE_CHECKING: from .processes import Process, ProcessBuilder @@ -160,7 +160,7 @@ async def exponential_backoff_retry( initial_interval: Union[int, float] = 10.0, max_attempts: int = 5, logger: Optional[logging.Logger] = None, - ignore_exceptions=None + ignore_exceptions: Union[None, Type[Exception], Tuple[Type[Exception], ...]] = None ) -> Any: """ Coroutine to call a function, recalling it with an exponential backoff in the case of an exception @@ -173,7 +173,7 @@ async def exponential_backoff_retry( :param fct: the function to call, which will be turned into a coroutine first if it is not already :param initial_interval: the time to wait after the first caught exception before calling the coroutine again :param max_attempts: the maximum number of times to call the coroutine before re-raising the exception - :param ignore_exceptions: list or tuple of exceptions to ignore, i.e. when caught do nothing and simply re-raise + :param ignore_exceptions: exceptions to ignore, i.e. when caught do nothing and simply re-raise :return: result if the ``coro`` call completes within ``max_attempts`` retries without raising """ if logger is None: From 6b6481d31f34cd07b0011651fad2c19f42739621 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Tue, 9 Feb 2021 22:18:54 +0100 Subject: [PATCH 069/114] Update use of various deprecated APIs (#4719) This replaces the use of various deprecated APIs pointed out by warnings thrown during runs of the test suite. It also introduces one new feature and a bug fix. Features: * Add non-zero exit code for failure to most `verdi daemon` commands, so tests will catch possible errors. Bug fixes: * A couple of files were opened but not closed Updates of deprecated APIs: * np.int is deprecated alias of int * np.float is deprecated alias of float * put_object_from_filelike: force is deprecated * archive import/export: `silent` keyword is deprecated in favor of logger * computer name => label * Fix tests writing to the repository of nodes after they had been stored by replacing all times we use `.open` with `'w'` or `'wb'` mode with a correct call to `put_object_from_filelike` *before* the node is stored. In one case, the data comes from a small archive file. In this case, I recreated the (zipped) .aiida file adding two additional (binary) files obtained by gzipping a short string. This was used to ensure that `inputcat` and `outputcat` work also when binary data was requested. Actually, this is better than before, where the actual input or output of the calculation were overwritten and then replaced back. * communicator: replace deprecated stop() by close() * silence some deprecation warnings in tests of APIs that will be removed in 2.0 Note that while unmuting the `ResourceWarning` was good to spot some issues (bug fix above), the warning is raised in a couple more places where it's less obvious to fix (typically related to the daemon starting some process in the background - or being started itself - and not being stopped before the test actually finished). I think this is an acceptable compromise - maybe we'll figure out how to selectively silence those, and keeping warnings visible will help us figure out possible leaks in the future. Co-authored-by: Giovanni Pizzi --- aiida/calculations/transfer.py | 2 +- aiida/cmdline/commands/cmd_daemon.py | 39 +++++-- aiida/cmdline/commands/cmd_process.py | 2 +- aiida/cmdline/commands/cmd_status.py | 2 +- aiida/cmdline/utils/daemon.py | 18 ++- aiida/engine/daemon/client.py | 8 +- aiida/manage/tests/pytest_fixtures.py | 2 +- aiida/orm/computers.py | 3 +- aiida/orm/nodes/data/array/kpoints.py | 4 +- aiida/transports/plugins/ssh.py | 3 +- pyproject.toml | 2 + tests/cmdline/commands/test_calcjob.py | 39 ++++--- tests/cmdline/commands/test_node.py | 58 ++++++---- tests/cmdline/commands/test_process.py | 6 +- tests/cmdline/commands/test_restapi.py | 7 +- tests/engine/test_calc_job.py | 33 +++--- tests/orm/data/test_code.py | 48 ++++---- tests/orm/data/test_upf.py | 8 +- tests/orm/node/test_calcjob.py | 87 ++++++-------- tests/orm/node/test_node.py | 3 + tests/orm/test_groups.py | 18 ++- tests/orm/test_querybuilder.py | 8 +- tests/restapi/test_routes.py | 4 +- tests/static/calcjob/arithmetic.add.aiida | Bin 10836 -> 11418 bytes tests/tools/groups/test_paths.py | 1 + tests/tools/importexport/__init__.py | 25 +++++ tests/tools/importexport/orm/__init__.py | 1 + .../tools/importexport/orm/test_attributes.py | 8 +- .../importexport/orm/test_calculations.py | 12 +- tests/tools/importexport/orm/test_codes.py | 16 +-- tests/tools/importexport/orm/test_comments.py | 52 ++++----- .../tools/importexport/orm/test_computers.py | 36 +++--- tests/tools/importexport/orm/test_extras.py | 24 ++-- tests/tools/importexport/orm/test_groups.py | 27 ++--- tests/tools/importexport/orm/test_links.py | 5 +- tests/tools/importexport/orm/test_logs.py | 43 +++---- tests/tools/importexport/orm/test_users.py | 16 +-- tests/tools/importexport/test_complex.py | 12 +- tests/tools/importexport/test_deprecation.py | 106 +++++++++--------- .../tools/importexport/test_prov_redesign.py | 20 ++-- .../importexport/test_specific_import.py | 30 +++-- tests/utils/archives.py | 2 +- 42 files changed, 477 insertions(+), 363 deletions(-) diff --git a/aiida/calculations/transfer.py b/aiida/calculations/transfer.py index 04811f6443..def70db1fb 100644 --- a/aiida/calculations/transfer.py +++ b/aiida/calculations/transfer.py @@ -74,7 +74,7 @@ def validate_transfer_inputs(inputs, _): for node_label, node_object in source_nodes.items(): if isinstance(node_object, orm.RemoteData): - if computer.name != node_object.computer.name: + if computer.label != node_object.computer.label: error_message = ( f' > remote node `{node_label}` points to computer `{node_object.computer}`, ' f'not the one being used (`{computer}`)' diff --git a/aiida/cmdline/commands/cmd_daemon.py b/aiida/cmdline/commands/cmd_daemon.py index faf720e436..5fbac9e013 100644 --- a/aiida/cmdline/commands/cmd_daemon.py +++ b/aiida/cmdline/commands/cmd_daemon.py @@ -54,6 +54,8 @@ def start(foreground, number): If the NUMBER of desired workers is not specified, the default is used, which is determined by the configuration option `daemon.default_workers`, which if not explicitly changed defaults to 1. + + Returns exit code 0 if the daemon is OK, non-zero if there was an error. """ from aiida.engine.daemon.client import get_daemon_client @@ -78,7 +80,9 @@ def start(foreground, number): time.sleep(1) response = client.get_status() - print_client_response_status(response) + retcode = print_client_response_status(response) + if retcode: + sys.exit(retcode) @verdi_daemon.command() @@ -115,24 +119,34 @@ def status(all_profiles): @click.argument('number', default=1, type=int) @decorators.only_if_daemon_running() def incr(number): - """Add NUMBER [default=1] workers to the running daemon.""" + """Add NUMBER [default=1] workers to the running daemon. + + Returns exit code 0 if the daemon is OK, non-zero if there was an error. + """ from aiida.engine.daemon.client import get_daemon_client client = get_daemon_client() response = client.increase_workers(number) - print_client_response_status(response) + retcode = print_client_response_status(response) + if retcode: + sys.exit(retcode) @verdi_daemon.command() @click.argument('number', default=1, type=int) @decorators.only_if_daemon_running() def decr(number): - """Remove NUMBER [default=1] workers from the running daemon.""" + """Remove NUMBER [default=1] workers from the running daemon. + + Returns exit code 0 if the daemon is OK, non-zero if there was an error. + """ from aiida.engine.daemon.client import get_daemon_client client = get_daemon_client() response = client.decrease_workers(number) - print_client_response_status(response) + retcode = print_client_response_status(response) + if retcode: + sys.exit(retcode) @verdi_daemon.command() @@ -154,7 +168,10 @@ def logshow(): @click.option('--no-wait', is_flag=True, help='Do not wait for confirmation.') @click.option('--all', 'all_profiles', is_flag=True, help='Stop all daemons.') def stop(no_wait, all_profiles): - """Stop the daemon.""" + """Stop the daemon. + + Returns exit code 0 if the daemon was shut down successfully (or was not running), non-zero if there was an error. + """ from aiida.engine.daemon.client import get_daemon_client config = get_config() @@ -190,7 +207,9 @@ def stop(no_wait, all_profiles): if response['status'] == client.DAEMON_ERROR_NOT_RUNNING: click.echo('The daemon was not running.') else: - print_client_response_status(response) + retcode = print_client_response_status(response) + if retcode: + sys.exit(retcode) @verdi_daemon.command() @@ -205,6 +224,8 @@ def restart(ctx, reset, no_wait): By default will only reset the workers of the running daemon. After the restart the same amount of workers will be running. If the `--reset` flag is passed, however, the full daemon will be stopped and restarted with the default number of workers that is started when calling `verdi daemon start` manually. + + Returns exit code 0 if the result is OK, non-zero if there was an error. """ from aiida.engine.daemon.client import get_daemon_client @@ -230,7 +251,9 @@ def restart(ctx, reset, no_wait): response = client.restart_daemon(wait) if wait: - print_client_response_status(response) + retcode = print_client_response_status(response) + if retcode: + sys.exit(retcode) @verdi_daemon.command(hidden=True) diff --git a/aiida/cmdline/commands/cmd_process.py b/aiida/cmdline/commands/cmd_process.py index ae18c2f553..8eb0e72cb2 100644 --- a/aiida/cmdline/commands/cmd_process.py +++ b/aiida/cmdline/commands/cmd_process.py @@ -305,7 +305,7 @@ def _print(communicator, body, sender, subject, correlation_id): # pylint: disa echo.echo('') # add a new line after the interrupt character echo.echo_info('received interrupt, exiting...') try: - communicator.stop() + communicator.close() except RuntimeError: pass diff --git a/aiida/cmdline/commands/cmd_status.py b/aiida/cmdline/commands/cmd_status.py index a5599ee630..c12344de35 100644 --- a/aiida/cmdline/commands/cmd_status.py +++ b/aiida/cmdline/commands/cmd_status.py @@ -113,7 +113,7 @@ def verdi_status(print_traceback, no_rmq): with Capturing(capture_stderr=True): with override_log_level(): # temporarily suppress noisy logging comm = manager.create_communicator(with_orm=False) - comm.stop() + comm.close() except Exception as exc: message = f'Unable to connect to rabbitmq with URL: {profile.get_rmq_url()}' print_status(ServiceStatus.ERROR, 'rabbitmq', message, exception=exc, print_traceback=print_traceback) diff --git a/aiida/cmdline/utils/daemon.py b/aiida/cmdline/utils/daemon.py index 552501ee39..afd7bed95a 100644 --- a/aiida/cmdline/utils/daemon.py +++ b/aiida/cmdline/utils/daemon.py @@ -21,23 +21,29 @@ def print_client_response_status(response): Print the response status of a call to the CircusClient through the DaemonClient :param response: the response object + :return: an integer error code; non-zero means there was an error (FAILED, TIMEOUT), zero means OK (OK, RUNNING) """ from aiida.engine.daemon.client import DaemonClient if 'status' not in response: - return + return 1 if response['status'] == 'active': click.secho('RUNNING', fg='green', bold=True) - elif response['status'] == 'ok': + return 0 + if response['status'] == 'ok': click.secho('OK', fg='green', bold=True) - elif response['status'] == DaemonClient.DAEMON_ERROR_NOT_RUNNING: + return 0 + if response['status'] == DaemonClient.DAEMON_ERROR_NOT_RUNNING: click.secho('FAILED', fg='red', bold=True) click.echo('Try to run \'verdi daemon start --foreground\' to potentially see the exception') - elif response['status'] == DaemonClient.DAEMON_ERROR_TIMEOUT: + return 2 + if response['status'] == DaemonClient.DAEMON_ERROR_TIMEOUT: click.secho('TIMEOUT', fg='red', bold=True) - else: - click.echo(response['status']) + return 3 + # Unknown status, I will consider it as failed + click.echo(response['status']) + return -1 def get_daemon_status(client): diff --git a/aiida/engine/daemon/client.py b/aiida/engine/daemon/client.py index 1215bb5056..428e702d19 100644 --- a/aiida/engine/daemon/client.py +++ b/aiida/engine/daemon/client.py @@ -184,7 +184,9 @@ def get_circus_socket_directory(self) -> str: """ if self.is_daemon_running: try: - return open(self.circus_socket_file, 'r', encoding='utf8').read().strip() + with open(self.circus_socket_file, 'r', encoding='utf8') as fhandle: + content = fhandle.read().strip() + return content except (ValueError, IOError): raise RuntimeError('daemon is running so sockets file should have been there but could not read it') else: @@ -208,7 +210,9 @@ def get_daemon_pid(self) -> Optional[int]: """ if os.path.isfile(self.circus_pid_file): try: - return int(open(self.circus_pid_file, 'r', encoding='utf8').read().strip()) + with open(self.circus_pid_file, 'r', encoding='utf8') as fhandle: + content = fhandle.read().strip() + return int(content) except (ValueError, IOError): return None else: diff --git a/aiida/manage/tests/pytest_fixtures.py b/aiida/manage/tests/pytest_fixtures.py index 191cbbc8f9..4cb7c0518b 100644 --- a/aiida/manage/tests/pytest_fixtures.py +++ b/aiida/manage/tests/pytest_fixtures.py @@ -98,7 +98,7 @@ def aiida_localhost(temp_dir): Usage:: def test_1(aiida_localhost): - name = aiida_localhost.get_name() + label = aiida_localhost.get_label() # proceed to set up code or use 'aiida_local_code_factory' instead diff --git a/aiida/orm/computers.py b/aiida/orm/computers.py index 4e83ac05c4..d151ae2d54 100644 --- a/aiida/orm/computers.py +++ b/aiida/orm/computers.py @@ -721,7 +721,8 @@ def get_configuration(self, user=None): config = {} try: - authinfo = backend.authinfos.get(self, user) + # Need to pass the backend entity here, not just self + authinfo = backend.authinfos.get(self._backend_entity, user) config = authinfo.get_auth_params() except exceptions.NotExistent: pass diff --git a/aiida/orm/nodes/data/array/kpoints.py b/aiida/orm/nodes/data/array/kpoints.py index e0709a3692..a3aa1630d0 100644 --- a/aiida/orm/nodes/data/array/kpoints.py +++ b/aiida/orm/nodes/data/array/kpoints.py @@ -372,7 +372,7 @@ def _validate_kpoints_weights(self, kpoints, weights): else: raise ValueError(f'kpoints must be a list of lists in {self._dimension}D case') - if kpoints.dtype != numpy.dtype(numpy.float): + if kpoints.dtype != numpy.dtype(float): raise ValueError(f'kpoints must be an array of type floats. Found instead {kpoints.dtype}') if kpoints.shape[1] < self._dimension: @@ -385,7 +385,7 @@ def _validate_kpoints_weights(self, kpoints, weights): weights = numpy.array(weights) if weights.shape[0] != kpoints.shape[0]: raise ValueError(f'Found {weights.shape[0]} weights but {kpoints.shape[0]} kpoints') - if weights.dtype != numpy.dtype(numpy.float): + if weights.dtype != numpy.dtype(float): raise ValueError(f'weights must be an array of type floats. Found instead {weights.dtype}') return kpoints, weights diff --git a/aiida/transports/plugins/ssh.py b/aiida/transports/plugins/ssh.py index f5e1ca021f..9f73bdbc14 100644 --- a/aiida/transports/plugins/ssh.py +++ b/aiida/transports/plugins/ssh.py @@ -36,7 +36,8 @@ def parse_sshconfig(computername): import paramiko config = paramiko.SSHConfig() try: - config.parse(open(os.path.expanduser('~/.ssh/config'), encoding='utf8')) + with open(os.path.expanduser('~/.ssh/config'), encoding='utf8') as fhandle: + config.parse(fhandle) except IOError: # No file found, so empty configuration pass diff --git a/pyproject.toml b/pyproject.toml index 0a683f8807..f281690e5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ filterwarnings = [ "ignore::DeprecationWarning:jsonbackend:", "ignore::DeprecationWarning:reentry:", "ignore::DeprecationWarning:pkg_resources:", + "ignore::pytest.PytestCollectionWarning", + "default::ResourceWarning", ] markers = [ "sphinx: set parameters for the sphinx `app` fixture" diff --git a/tests/cmdline/commands/test_calcjob.py b/tests/cmdline/commands/test_calcjob.py index b62485a790..c243fe7618 100644 --- a/tests/cmdline/commands/test_calcjob.py +++ b/tests/cmdline/commands/test_calcjob.py @@ -130,10 +130,13 @@ def test_calcjob_inputls(self): options = [self.arithmetic_job.uuid] result = self.cli_runner.invoke(command.calcjob_inputls, options) self.assertIsNone(result.exception, result.output) - self.assertEqual(len(get_result_lines(result)), 3) + # There is also an additional fourth file added by hand to test retrieval of binary content + # see comments in test_calcjob_inputcat + self.assertEqual(len(get_result_lines(result)), 4) self.assertIn('.aiida', get_result_lines(result)) self.assertIn('aiida.in', get_result_lines(result)) self.assertIn('_aiidasubmit.sh', get_result_lines(result)) + self.assertIn('in_gzipped_data', get_result_lines(result)) options = [self.arithmetic_job.uuid, '.aiida'] result = self.cli_runner.invoke(command.calcjob_inputls, options) @@ -156,10 +159,13 @@ def test_calcjob_outputls(self): options = [self.arithmetic_job.uuid] result = self.cli_runner.invoke(command.calcjob_outputls, options) self.assertIsNone(result.exception, result.output) - self.assertEqual(len(get_result_lines(result)), 3) + # There is also an additional fourth file added by hand to test retrieval of binary content + # see comments in test_calcjob_outputcat + self.assertEqual(len(get_result_lines(result)), 4) self.assertIn('_scheduler-stderr.txt', get_result_lines(result)) self.assertIn('_scheduler-stdout.txt', get_result_lines(result)) self.assertIn('aiida.out', get_result_lines(result)) + self.assertIn('gzipped_data', get_result_lines(result)) options = [self.arithmetic_job.uuid, 'non-existing-folder'] result = self.cli_runner.invoke(command.calcjob_inputls, options) @@ -186,16 +192,13 @@ def test_calcjob_inputcat(self): self.assertEqual(get_result_lines(result)[0], '2 3') # Test cat binary files - with self.arithmetic_job.open('aiida.in', 'wb') as fh_out: - fh_out.write(gzip.compress(b'COMPRESS')) - - options = [self.arithmetic_job.uuid, 'aiida.in'] + # I manually added, in the export file, in the files of the arithmetic_job, + # a file called 'in_gzipped_data' whose content has been generated with + # with open('in_gzipped_data', 'wb') as f: + # f.write(gzip.compress(b'COMPRESS-INPUT')) + options = [self.arithmetic_job.uuid, 'in_gzipped_data'] result = self.cli_runner.invoke(command.calcjob_inputcat, options) - assert gzip.decompress(result.stdout_bytes) == b'COMPRESS' - - # Replace the file - with self.arithmetic_job.open('aiida.in', 'w') as fh_out: - fh_out.write('2 3\n') + assert gzip.decompress(result.stdout_bytes) == b'COMPRESS-INPUT' def test_calcjob_outputcat(self): """Test verdi calcjob outputcat""" @@ -217,18 +220,14 @@ def test_calcjob_outputcat(self): self.assertEqual(get_result_lines(result)[0], '5') # Test cat binary files - retrieved = self.arithmetic_job.outputs.retrieved - with retrieved.open('aiida.out', 'wb') as fh_out: - fh_out.write(gzip.compress(b'COMPRESS')) - - options = [self.arithmetic_job.uuid, 'aiida.out'] + # I manually added, in the export file, in the files of the output retrieved node of the arithmetic_job, + # a file called 'gzipped_data' whose content has been generated with + # with open('gzipped_data', 'wb') as f: + # f.write(gzip.compress(b'COMPRESS')) + options = [self.arithmetic_job.uuid, 'gzipped_data'] result = self.cli_runner.invoke(command.calcjob_outputcat, options) assert gzip.decompress(result.stdout_bytes) == b'COMPRESS' - # Replace the file - with retrieved.open('aiida.out', 'w') as fh_out: - fh_out.write('5\n') - def test_calcjob_cleanworkdir(self): """Test verdi calcjob cleanworkdir""" diff --git a/tests/cmdline/commands/test_node.py b/tests/cmdline/commands/test_node.py index 619241e9b0..41e0ef6f5d 100644 --- a/tests/cmdline/commands/test_node.py +++ b/tests/cmdline/commands/test_node.py @@ -15,6 +15,7 @@ import pathlib import tempfile import gzip +import warnings from click.testing import CliRunner @@ -22,6 +23,7 @@ from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_node from aiida.common.utils import Capturing +from aiida.common.warnings import AiidaDeprecationWarning def get_result_lines(result): @@ -55,7 +57,15 @@ def setUpClass(cls, *args, **kwargs): cls.node = node - # Set up a FolderData for the node repo cp tests. + def setUp(self): + self.cli_runner = CliRunner() + + @classmethod + def get_unstored_folder_node(cls): + """Get a "default" folder node with some data. + + The node is unstored so one can add more content to it before storing it. + """ folder_node = orm.FolderData() cls.content_file1 = 'nobody expects' cls.content_file2 = 'the minister of silly walks' @@ -63,27 +73,31 @@ def setUpClass(cls, *args, **kwargs): cls.key_file2 = 'some_other_file.txt' folder_node.put_object_from_filelike(io.StringIO(cls.content_file1), cls.key_file1) folder_node.put_object_from_filelike(io.StringIO(cls.content_file2), cls.key_file2) - folder_node.store() - cls.folder_node = folder_node - - def setUp(self): - self.cli_runner = CliRunner() + return folder_node def test_node_tree(self): """Test `verdi node tree`""" options = [str(self.node.pk)] - result = self.cli_runner.invoke(cmd_node.tree, options) + + # This command (and so the test as well) will go away in 2.0 + # Note: I cannot use simply pytest.mark.filterwarnings as below, as the warning is issued in an invoked command + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=AiidaDeprecationWarning) + result = self.cli_runner.invoke(cmd_node.tree, options) self.assertClickResultNoException(result) + # This command (and so this test as well) will go away in 2.0 def test_node_tree_printer(self): """Test the `NodeTreePrinter` utility.""" from aiida.cmdline.utils.ascii_vis import NodeTreePrinter + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=AiidaDeprecationWarning) - with Capturing(): - NodeTreePrinter.print_node_tree(self.node, max_depth=1) + with Capturing(): + NodeTreePrinter.print_node_tree(self.node, max_depth=1) - with Capturing(): - NodeTreePrinter.print_node_tree(self.node, max_depth=1, follow_links=()) + with Capturing(): + NodeTreePrinter.print_node_tree(self.node, max_depth=1, follow_links=()) def test_node_show(self): """Test `verdi node show`""" @@ -187,12 +201,14 @@ def test_node_extras(self): def test_node_repo_ls(self): """Test 'verdi node repo ls' command.""" - options = [str(self.folder_node.pk), 'some/nested/folder'] + folder_node = self.get_unstored_folder_node().store() + + options = [str(folder_node.pk), 'some/nested/folder'] result = self.cli_runner.invoke(cmd_node.repo_ls, options, catch_exceptions=False) self.assertClickResultNoException(result) self.assertIn('filename.txt', result.output) - options = [str(self.folder_node.pk), 'some/non-existing-folder'] + options = [str(folder_node.pk), 'some/non-existing-folder'] result = self.cli_runner.invoke(cmd_node.repo_ls, options, catch_exceptions=False) self.assertIsNotNone(result.exception) self.assertIn('does not exist for the given node', result.output) @@ -200,19 +216,21 @@ def test_node_repo_ls(self): def test_node_repo_cat(self): """Test 'verdi node repo cat' command.""" # Test cat binary files - with self.folder_node.open('filename.txt.gz', 'wb') as fh_out: - fh_out.write(gzip.compress(b'COMPRESS')) + folder_node = self.get_unstored_folder_node() + folder_node.put_object_from_filelike(io.BytesIO(gzip.compress(b'COMPRESS')), 'filename.txt.gz', mode='wb') + folder_node.store() - options = [str(self.folder_node.pk), 'filename.txt.gz'] + options = [str(folder_node.pk), 'filename.txt.gz'] result = self.cli_runner.invoke(cmd_node.repo_cat, options) assert gzip.decompress(result.stdout_bytes) == b'COMPRESS' def test_node_repo_dump(self): """Test 'verdi node repo dump' command.""" + folder_node = self.get_unstored_folder_node().store() with tempfile.TemporaryDirectory() as tmp_dir: out_path = pathlib.Path(tmp_dir) / 'out_dir' - options = [str(self.folder_node.uuid), str(out_path)] + options = [str(folder_node.uuid), str(out_path)] res = self.cli_runner.invoke(cmd_node.repo_dump, options, catch_exceptions=False) self.assertFalse(res.stdout) @@ -226,10 +244,11 @@ def test_node_repo_dump(self): def test_node_repo_dump_to_nested_folder(self): """Test 'verdi node repo dump' command, with an output folder whose parent does not exist.""" + folder_node = self.get_unstored_folder_node().store() with tempfile.TemporaryDirectory() as tmp_dir: out_path = pathlib.Path(tmp_dir) / 'out_dir' / 'nested' / 'path' - options = [str(self.folder_node.uuid), str(out_path)] + options = [str(folder_node.uuid), str(out_path)] res = self.cli_runner.invoke(cmd_node.repo_dump, options, catch_exceptions=False) self.assertFalse(res.stdout) @@ -243,6 +262,7 @@ def test_node_repo_dump_to_nested_folder(self): def test_node_repo_existing_out_dir(self): """Test 'verdi node repo dump' command, check that an existing output directory is not overwritten.""" + folder_node = self.get_unstored_folder_node().store() with tempfile.TemporaryDirectory() as tmp_dir: out_path = pathlib.Path(tmp_dir) / 'out_dir' @@ -252,7 +272,7 @@ def test_node_repo_existing_out_dir(self): some_file_content = 'ni!' with some_file.open('w') as file_handle: file_handle.write(some_file_content) - options = [str(self.folder_node.uuid), str(out_path)] + options = [str(folder_node.uuid), str(out_path)] res = self.cli_runner.invoke(cmd_node.repo_dump, options, catch_exceptions=False) self.assertIn('exists', res.stdout) self.assertIn('Critical:', res.stdout) diff --git a/tests/cmdline/commands/test_process.py b/tests/cmdline/commands/test_process.py index bc41c83f0b..937c52c70b 100644 --- a/tests/cmdline/commands/test_process.py +++ b/tests/cmdline/commands/test_process.py @@ -50,9 +50,9 @@ def setUp(self): profile = get_config().current_profile self.daemon_client = DaemonClient(profile) - self.daemon_pid = subprocess.Popen( + self.daemon = subprocess.Popen( self.daemon_client.cmd_string.split(), stderr=sys.stderr, stdout=sys.stdout, env=env - ).pid + ) self.runner = get_manager().create_runner(rmq_submit=True) self.cli_runner = CliRunner() @@ -60,7 +60,7 @@ def tearDown(self): import os import signal - os.kill(self.daemon_pid, signal.SIGTERM) + os.kill(self.daemon.pid, signal.SIGTERM) super().tearDown() def test_pause_play_kill(self): diff --git a/tests/cmdline/commands/test_restapi.py b/tests/cmdline/commands/test_restapi.py index ab3d54eca0..9b0c2cb46a 100644 --- a/tests/cmdline/commands/test_restapi.py +++ b/tests/cmdline/commands/test_restapi.py @@ -10,6 +10,7 @@ """Tests for `verdi restapi`.""" from click.testing import CliRunner +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands.cmd_restapi import restapi @@ -22,8 +23,12 @@ def setUp(self): super().setUp() self.cli_runner = CliRunner() + @pytest.mark.filterwarnings('ignore::aiida.common.warnings.AiidaDeprecationWarning') def test_run_restapi(self): - """Test `verdi restapi`.""" + """Test `verdi restapi`. + + Note: This test will need to be changed/removed once the hookup parameter is dropped from the CLI. + """ options = ['--no-hookup', '--hostname', 'localhost', '--port', '6000', '--debug', '--wsgi-profile'] diff --git a/tests/engine/test_calc_job.py b/tests/engine/test_calc_job.py index 3894998eb7..ceb4fc93b4 100644 --- a/tests/engine/test_calc_job.py +++ b/tests/engine/test_calc_job.py @@ -11,6 +11,7 @@ """Test for the `CalcJob` process sub class.""" from copy import deepcopy from functools import partial +import io import os from unittest.mock import patch @@ -443,20 +444,16 @@ def test_parse_not_implemented(generate_process): Here we check explicitly that the parsing does not except even if the scheduler does not implement the method. """ process = generate_process() - - retrieved = orm.FolderData().store() - retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) - - process.node.set_attribute('detailed_job_info', {}) - filename_stderr = process.node.get_option('scheduler_stderr') filename_stdout = process.node.get_option('scheduler_stdout') - with retrieved.open(filename_stderr, 'w') as handle: - handle.write('\n') + retrieved = orm.FolderData() + retrieved.put_object_from_filelike(io.StringIO('\n'), filename_stderr, mode='w') + retrieved.put_object_from_filelike(io.StringIO('\n'), filename_stdout, mode='w') + retrieved.store() + retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) - with retrieved.open(filename_stdout, 'w') as handle: - handle.write('\n') + process.node.set_attribute('detailed_job_info', {}) process.parse() @@ -478,20 +475,16 @@ def test_parse_scheduler_excepted(generate_process, monkeypatch): from aiida.schedulers.plugins.direct import DirectScheduler process = generate_process() - - retrieved = orm.FolderData().store() - retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) - - process.node.set_attribute('detailed_job_info', {}) - filename_stderr = process.node.get_option('scheduler_stderr') filename_stdout = process.node.get_option('scheduler_stdout') - with retrieved.open(filename_stderr, 'w') as handle: - handle.write('\n') + retrieved = orm.FolderData() + retrieved.put_object_from_filelike(io.StringIO('\n'), filename_stderr, mode='w') + retrieved.put_object_from_filelike(io.StringIO('\n'), filename_stdout, mode='w') + retrieved.store() + retrieved.add_incoming(process.node, link_label='retrieved', link_type=LinkType.CREATE) - with retrieved.open(filename_stdout, 'w') as handle: - handle.write('\n') + process.node.set_attribute('detailed_job_info', {}) msg = 'crash' diff --git a/tests/orm/data/test_code.py b/tests/orm/data/test_code.py index 6bc5ab1cf3..515f7eaa8d 100644 --- a/tests/orm/data/test_code.py +++ b/tests/orm/data/test_code.py @@ -9,8 +9,11 @@ ########################################################################### """Tests for the `Code` class.""" # pylint: disable=redefined-outer-name +import warnings + import pytest +from aiida.common.warnings import AiidaDeprecationWarning from aiida.orm import Code @@ -32,24 +35,27 @@ def create_codes(tmpdir, aiida_localhost): @pytest.mark.usefixtures('clear_database_before_test') def test_get_full_text_info(create_codes): """Test the `Code.get_full_text_info` method.""" - for code in create_codes: - full_text_info = code.get_full_text_info() - - assert isinstance(full_text_info, list) - assert ['PK', code.pk] in full_text_info - assert ['UUID', code.uuid] in full_text_info - assert ['Label', code.label] in full_text_info - assert ['Description', code.description] in full_text_info - - if code.is_local(): - assert ['Type', 'local'] in full_text_info - assert ['Exec name', code.get_execname()] in full_text_info - assert ['List of files/folders:', ''] in full_text_info - else: - assert ['Type', 'remote'] in full_text_info - assert ['Remote machine', code.computer.label] in full_text_info - assert ['Remote absolute path', code.get_remote_exec_path()] in full_text_info - - for code in create_codes: - full_text_info = code.get_full_text_info(verbose=True) - assert ['Calculations', 0] in full_text_info + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=AiidaDeprecationWarning) + + for code in create_codes: + full_text_info = code.get_full_text_info() + + assert isinstance(full_text_info, list) + assert ['PK', code.pk] in full_text_info + assert ['UUID', code.uuid] in full_text_info + assert ['Label', code.label] in full_text_info + assert ['Description', code.description] in full_text_info + + if code.is_local(): + assert ['Type', 'local'] in full_text_info + assert ['Exec name', code.get_execname()] in full_text_info + assert ['List of files/folders:', ''] in full_text_info + else: + assert ['Type', 'remote'] in full_text_info + assert ['Remote machine', code.computer.label] in full_text_info + assert ['Remote absolute path', code.get_remote_exec_path()] in full_text_info + + for code in create_codes: + full_text_info = code.get_full_text_info(verbose=True) + assert ['Calculations', 0] in full_text_info diff --git a/tests/orm/data/test_upf.py b/tests/orm/data/test_upf.py index 70094f46ed..34d37b1dd0 100644 --- a/tests/orm/data/test_upf.py +++ b/tests/orm/data/test_upf.py @@ -28,7 +28,7 @@ def isnumeric(vector): """Check if elements of iterable `x` are numbers.""" # pylint: disable=invalid-name - numeric_types = (float, int, numpy.float, numpy.float64, numpy.int64, numpy.int) + numeric_types = (float, int, numpy.float64, numpy.int64) for xi in vector: if isinstance(xi, numeric_types): yield True @@ -327,7 +327,8 @@ def test_upf1_to_json_carbon(self): # pylint: disable=protected-access json_string, _ = self.pseudo_carbon._prepare_json() filepath_base = os.path.abspath(os.path.join(STATIC_DIR, 'pseudos')) - reference_dict = json.load(open(os.path.join(filepath_base, 'C.json'), 'r')) + with open(os.path.join(filepath_base, 'C.json'), 'r') as fhandle: + reference_dict = json.load(fhandle) pp_dict = json.loads(json_string.decode('utf-8')) # remove path information pp_dict['pseudo_potential']['header']['original_upf_file'] = '' @@ -339,7 +340,8 @@ def test_upf2_to_json_barium(self): # pylint: disable=protected-access json_string, _ = self.pseudo_barium._prepare_json() filepath_base = os.path.abspath(os.path.join(STATIC_DIR, 'pseudos')) - reference_dict = json.load(open(os.path.join(filepath_base, 'Ba.json'), 'r')) + with open(os.path.join(filepath_base, 'Ba.json'), 'r') as fhandle: + reference_dict = json.load(fhandle) pp_dict = json.loads(json_string.decode('utf-8')) # remove path information pp_dict['pseudo_potential']['header']['original_upf_file'] = '' diff --git a/tests/orm/node/test_calcjob.py b/tests/orm/node/test_calcjob.py index c457c98dc4..2ad0844043 100644 --- a/tests/orm/node/test_calcjob.py +++ b/tests/orm/node/test_calcjob.py @@ -8,8 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the `CalcJobNode` node sub class.""" - -import tempfile +import io from aiida.backends.testbase import AiidaTestCase from aiida.common import LinkType, CalcJobState @@ -40,30 +39,24 @@ def test_get_scheduler_stdout(self): option_value = '_scheduler-output.txt' stdout = 'some\nstandard output' - node = CalcJobNode(computer=self.computer,) - node.set_option('resources', {'num_machines': 1, 'num_mpiprocs_per_machine': 1}) - retrieved = FolderData() - - # No scheduler output filename option so should return `None` - self.assertEqual(node.get_scheduler_stdout(), None) - - # No retrieved folder so should return `None` - node.set_option(option_key, option_value) - self.assertEqual(node.get_scheduler_stdout(), None) - - # Now it has retrieved folder, but file does not actually exist in it, should not except but return `None - node.store() - retrieved.store() - retrieved.add_incoming(node, link_type=LinkType.CREATE, link_label='retrieved') - self.assertEqual(node.get_scheduler_stdout(), None) - - # Add the file to the retrieved folder - with tempfile.NamedTemporaryFile(mode='w+') as handle: - handle.write(stdout) - handle.flush() - handle.seek(0) - retrieved.put_object_from_filelike(handle, option_value, force=True) - self.assertEqual(node.get_scheduler_stdout(), stdout) + # Note: cannot use pytest.mark.parametrize in unittest classes, so I just do a loop here + for with_file in [True, False]: + for with_option in [True, False]: + node = CalcJobNode(computer=self.computer,) + node.set_option('resources', {'num_machines': 1, 'num_mpiprocs_per_machine': 1}) + retrieved = FolderData() + + if with_file: + retrieved.put_object_from_filelike(io.StringIO(stdout), option_value) + if with_option: + node.set_option(option_key, option_value) + node.store() + retrieved.store() + retrieved.add_incoming(node, link_type=LinkType.CREATE, link_label='retrieved') + + # It should return `None` if no scheduler output is there (file not there, or option not set), + # while it should return the content if both are set + self.assertEqual(node.get_scheduler_stdout(), stdout if with_file and with_option else None) def test_get_scheduler_stderr(self): """Verify that the repository sandbox folder is cleaned after the node instance is garbage collected.""" @@ -71,27 +64,21 @@ def test_get_scheduler_stderr(self): option_value = '_scheduler-error.txt' stderr = 'some\nstandard error' - node = CalcJobNode(computer=self.computer,) - node.set_option('resources', {'num_machines': 1, 'num_mpiprocs_per_machine': 1}) - retrieved = FolderData() - - # No scheduler error filename option so should return `None` - self.assertEqual(node.get_scheduler_stderr(), None) - - # No retrieved folder so should return `None` - node.set_option(option_key, option_value) - self.assertEqual(node.get_scheduler_stderr(), None) - - # Now it has retrieved folder, but file does not actually exist in it, should not except but return `None - node.store() - retrieved.store() - retrieved.add_incoming(node, link_type=LinkType.CREATE, link_label='retrieved') - self.assertEqual(node.get_scheduler_stderr(), None) - - # Add the file to the retrieved folder - with tempfile.NamedTemporaryFile(mode='w+') as handle: - handle.write(stderr) - handle.flush() - handle.seek(0) - retrieved.put_object_from_filelike(handle, option_value, force=True) - self.assertEqual(node.get_scheduler_stderr(), stderr) + # Note: cannot use pytest.mark.parametrize in unittest classes, so I just do a loop here + for with_file in [True, False]: + for with_option in [True, False]: + node = CalcJobNode(computer=self.computer,) + node.set_option('resources', {'num_machines': 1, 'num_mpiprocs_per_machine': 1}) + retrieved = FolderData() + + if with_file: + retrieved.put_object_from_filelike(io.StringIO(stderr), option_value) + if with_option: + node.set_option(option_key, option_value) + node.store() + retrieved.store() + retrieved.add_incoming(node, link_type=LinkType.CREATE, link_label='retrieved') + + # It should return `None` if no scheduler output is there (file not there, or option not set), + # while it should return the content if both are set + self.assertEqual(node.get_scheduler_stderr(), stderr if with_file and with_option else None) diff --git a/tests/orm/node/test_node.py b/tests/orm/node/test_node.py index 34cc60940b..eda683aeeb 100644 --- a/tests/orm/node/test_node.py +++ b/tests/orm/node/test_node.py @@ -845,6 +845,9 @@ def test_store_from_cache(): assert data.get_hash() == clone.get_hash() +# Ignoring the resource errors as we are indeed testing the wrong way of using these (for backward-compatibility) +@pytest.mark.filterwarnings('ignore::ResourceWarning') +@pytest.mark.filterwarnings('ignore::aiida.common.warnings.AiidaDeprecationWarning') @pytest.mark.usefixtures('clear_database_before_test') def test_open_wrapper(): """Test the wrapper around the return value of ``Node.open``. diff --git a/tests/orm/test_groups.py b/tests/orm/test_groups.py index e2833967c9..342806331a 100644 --- a/tests/orm/test_groups.py +++ b/tests/orm/test_groups.py @@ -342,7 +342,13 @@ def test_loading_unregistered(): assert isinstance(loaded, orm.Group) + # Removing it as other methods might get a warning instead + group_pk = group.pk + del group + orm.Group.objects.delete(id=group_pk) + @staticmethod + @pytest.mark.filterwarnings('ignore::UserWarning') def test_explicit_type_string(): """Test that passing explicit `type_string` to `Group` constructor is still possible despite being deprecated. @@ -369,6 +375,11 @@ def test_explicit_type_string(): assert queried.pk == group.pk assert queried.type_string == group.type_string + # Removing it as other methods might get a warning instead + group_pk = group.pk + del group + orm.Group.objects.delete(id=group_pk) + @staticmethod def test_querying(): """Test querying for groups with and without subclassing.""" @@ -386,6 +397,11 @@ def test_querying(): assert orm.QueryBuilder().append(orm.Group).count() == 3 assert orm.QueryBuilder().append(orm.Group, filters={'type_string': 'custom.group'}).count() == 1 + # Removing it as other methods might get a warning instead + group_pk = group.pk + del group + orm.Group.objects.delete(id=group_pk) + @staticmethod def test_querying_node_subclasses(): """Test querying for groups with multiple types for nodes it contains.""" @@ -407,7 +423,7 @@ def test_querying_node_subclasses(): @staticmethod def test_query_with_group(): - """Docs.""" + """Test that querying a data node in a group works.""" group = orm.Group(label='group').store() data = orm.Data().store() diff --git a/tests/orm/test_querybuilder.py b/tests/orm/test_querybuilder.py index 8c320bdb6d..1180b56221 100644 --- a/tests/orm/test_querybuilder.py +++ b/tests/orm/test_querybuilder.py @@ -27,10 +27,12 @@ def setUp(self): def test_date_filters_support(self): """Verify that `datetime.date` is supported in filters.""" - from datetime import datetime, date, timedelta + from datetime import date, timedelta + from aiida.common import timezone - orm.Data(ctime=datetime.now() - timedelta(days=3)).store() - orm.Data(ctime=datetime.now() - timedelta(days=1)).store() + # Using timezone.now() rather than datetime.now() to get a timezone-aware object rather than a naive one + orm.Data(ctime=timezone.now() - timedelta(days=3)).store() + orm.Data(ctime=timezone.now() - timedelta(days=1)).store() builder = orm.QueryBuilder().append(orm.Node, filters={'ctime': {'>': date.today() - timedelta(days=1)}}) self.assertEqual(builder.count(), 1) diff --git a/tests/restapi/test_routes.py b/tests/restapi/test_routes.py index e38799829d..fca6cbb0fa 100644 --- a/tests/restapi/test_routes.py +++ b/tests/restapi/test_routes.py @@ -82,7 +82,7 @@ def setUpClass(cls, *args, **kwargs): # pylint: disable=too-many-locals, too-ma handle.write(aiida_in) handle.flush() handle.seek(0) - calc.put_object_from_filelike(handle, 'calcjob_inputs/aiida.in', force=True) + calc.put_object_from_filelike(handle, 'calcjob_inputs/aiida.in') calc.store() # create log message for calcjob @@ -110,7 +110,7 @@ def setUpClass(cls, *args, **kwargs): # pylint: disable=too-many-locals, too-ma handle.write(aiida_out) handle.flush() handle.seek(0) - retrieved_outputs.put_object_from_filelike(handle, 'calcjob_outputs/aiida.out', force=True) + retrieved_outputs.put_object_from_filelike(handle, 'calcjob_outputs/aiida.out') retrieved_outputs.store() retrieved_outputs.add_incoming(calc, link_type=LinkType.CREATE, link_label='retrieved') diff --git a/tests/static/calcjob/arithmetic.add.aiida b/tests/static/calcjob/arithmetic.add.aiida index 21319e1b6667d641b8e8fd0804242c53cb6db9d7..7d988a68396c8628ae4410e6ed1167b3f05786d5 100644 GIT binary patch delta 816 zcmcZ-GAnX|0gHF2Qo=?P4jz^hA)Y~#9e8{=I3ko1KzQ;&AxYj-K+)-1iM~n<3=B$> zck$SHW#+}FS7jCyq^88DB$gzGhHx@4^FXzfR&X;gvV3J^U|J?EE(2k^$%5jhju?IcnGP}li(f!e2*0o~g!;4hb4>m$mI`+K<{0s0Hb^iY zmW^To2ce;?v;s;H0_}BUkN}bpu%vM@&*YEtmJ%4D$_fe9$%=B8Os>2jHIskv2u@xm z=fG$;`GdUGWJP%|#@#@c)6bYd<7vrQ+*DmgITn>6ygjh3Wy!I+6^Nt0eR$D`_|}PX@D1C(qGvVEQjP`JRR^ plathBUCjWd4ynoYnuSd7K@`Zt@{=93*!VOVfKZTuAzB&~Spe4w@?Zb} delta 534 zcmYL{K}Zx)9LC?9r&2mQJL_({Gdtt#sxyx3%FOPv1Bo7nO|m4HC8X#Oh-SD@b_j1l zhc4YRzYZcE3+fym~aYOX6mDF%O){HK&8T-|Ox6+>oKD>)Rpe<4pT00Iqv#6)bD0Y<*q^Dcq zna%iDLs2(%9T%^6tEjbbg9z(U?<>oJ1Q z;$30@=}yy>s8uMvC5nLZuEj9C|+CJ~qo_9aR1BjsRMuVQ^jqQjt8 z39qdQVKs%H)|9ZH!q`AV=)DALyZAI List[dict]: From 11cb38cf14b4903ca8af53127ddf78c6034c9cae Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 10 Feb 2021 14:23:17 +0100 Subject: [PATCH 070/114] =?UTF-8?q?=E2=9C=A8=20NEW:=20Add=20`verdi=20datab?= =?UTF-8?q?ase=20summary`=20(#4737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prints a summary of the count of each entity and, with `-v` flag, additional summary of the unique identifiers for some entities. --- aiida/cmdline/commands/cmd_database.py | 45 +++++++++++++++++++++++++ aiida/cmdline/utils/echo.py | 24 +++++++++---- docs/source/reference/command_line.rst | 1 + tests/cmdline/commands/test_database.py | 10 ++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/aiida/cmdline/commands/cmd_database.py b/aiida/cmdline/commands/cmd_database.py index e3f1a63776..6da318ea34 100644 --- a/aiida/cmdline/commands/cmd_database.py +++ b/aiida/cmdline/commands/cmd_database.py @@ -194,3 +194,48 @@ def detect_invalid_nodes(): echo.echo_success('no integrity violations detected') else: echo.echo_critical('one or more integrity violations detected') + + +@verdi_database.command('summary') +@options.VERBOSE() +def database_summary(verbose): + """Summarise the entities in the database.""" + from aiida.orm import QueryBuilder, Node, Group, Computer, Comment, Log, User + data = {} + + # User + query_user = QueryBuilder().append(User, project=['email']) + data['Users'] = {'count': query_user.count()} + if verbose: + data['Users']['emails'] = query_user.distinct().all(flat=True) + + # Computer + query_comp = QueryBuilder().append(Computer, project=['name']) + data['Computers'] = {'count': query_comp.count()} + if verbose: + data['Computers']['names'] = query_comp.distinct().all(flat=True) + + # Node + count = QueryBuilder().append(Node).count() + data['Nodes'] = {'count': count} + if verbose: + node_types = QueryBuilder().append(Node, project=['node_type']).distinct().all(flat=True) + data['Nodes']['node_types'] = node_types + process_types = QueryBuilder().append(Node, project=['process_type']).distinct().all(flat=True) + data['Nodes']['process_types'] = [p for p in process_types if p] + + # Group + query_group = QueryBuilder().append(Group, project=['type_string']) + data['Groups'] = {'count': query_group.count()} + if verbose: + data['Groups']['type_strings'] = query_group.distinct().all(flat=True) + + # Comment + count = QueryBuilder().append(Comment).count() + data['Comments'] = {'count': count} + + # Log + count = QueryBuilder().append(Log).count() + data['Logs'] = {'count': count} + + echo.echo_dictionary(data, sort_keys=False, fmt='yaml') diff --git a/aiida/cmdline/utils/echo.py b/aiida/cmdline/utils/echo.py index 7a7c3210cf..248a01a2db 100644 --- a/aiida/cmdline/utils/echo.py +++ b/aiida/cmdline/utils/echo.py @@ -187,7 +187,7 @@ def echo_formatted_list(collection, attributes, sort=None, highlight=None, hide= click.secho(template.format(symbol=' ', *values)) -def _format_dictionary_json_date(dictionary): +def _format_dictionary_json_date(dictionary, sort_keys=True): """Return a dictionary formatted as a string using the json format and converting dates to strings.""" from aiida.common import json @@ -201,19 +201,31 @@ def default_jsondump(data): raise TypeError(f'{repr(data)} is not JSON serializable') - return json.dumps(dictionary, indent=4, sort_keys=True, default=default_jsondump) + return json.dumps(dictionary, indent=4, sort_keys=sort_keys, default=default_jsondump) -VALID_DICT_FORMATS_MAPPING = OrderedDict((('json+date', _format_dictionary_json_date), ('yaml', yaml.dump), - ('yaml_expanded', lambda d: yaml.dump(d, default_flow_style=False)))) +def _format_yaml(dictionary, sort_keys=True): + """Return a dictionary formatted as a string using the YAML format.""" + return yaml.dump(dictionary, sort_keys=sort_keys) -def echo_dictionary(dictionary, fmt='json+date'): +def _format_yaml_expanded(dictionary, sort_keys=True): + """Return a dictionary formatted as a string using the expanded YAML format.""" + return yaml.dump(dictionary, sort_keys=sort_keys, default_flow_style=False) + + +VALID_DICT_FORMATS_MAPPING = OrderedDict( + (('json+date', _format_dictionary_json_date), ('yaml', _format_yaml), ('yaml_expanded', _format_yaml_expanded)) +) + + +def echo_dictionary(dictionary, fmt='json+date', sort_keys=True): """ Print the given dictionary to stdout in the given format :param dictionary: the dictionary :param fmt: the format to use for printing + :param sort_keys: Whether to automatically sort keys """ try: format_function = VALID_DICT_FORMATS_MAPPING[fmt] @@ -221,7 +233,7 @@ def echo_dictionary(dictionary, fmt='json+date'): formats = ', '.join(VALID_DICT_FORMATS_MAPPING.keys()) raise ValueError(f'Unrecognised printing format. Valid formats are: {formats}') - echo(format_function(dictionary)) + echo(format_function(dictionary, sort_keys=sort_keys)) def is_stdout_redirected(): diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 65fdc38927..72ee2c4940 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -220,6 +220,7 @@ Below is a list with all available subcommands. Commands: integrity Check the integrity of the database and fix potential issues. migrate Migrate the database to the latest schema version. + summary Summarise the entities in the database. version Show the version of the database. diff --git a/tests/cmdline/commands/test_database.py b/tests/cmdline/commands/test_database.py index 90b7917f98..baa6939cba 100644 --- a/tests/cmdline/commands/test_database.py +++ b/tests/cmdline/commands/test_database.py @@ -180,3 +180,13 @@ def tests_database_version(run_cli_command, manager): result = run_cli_command(cmd_database.database_version) assert result.output_lines[0].endswith(backend_manager.get_schema_generation_database()) assert result.output_lines[1].endswith(backend_manager.get_schema_version_database()) + + +@pytest.mark.usefixtures('clear_database_before_test') +def tests_database_summary(aiida_localhost, run_cli_command): + """Test the ``verdi database summary -v`` command.""" + from aiida import orm + node = orm.Dict().store() + result = run_cli_command(cmd_database.database_summary, ['--verbose']) + assert aiida_localhost.label in result.output + assert node.node_type in result.output From 7272b46dbd8aadb4be42dc6ce757a03126fedc4f Mon Sep 17 00:00:00 2001 From: Giovanni Pizzi Date: Wed, 10 Feb 2021 16:19:09 +0100 Subject: [PATCH 071/114] Upgrading dependency of sqlalchemy-utils (#4724) * Upgrading dependency of sqlalchemy-utils In sqlalchemy-utils 0.35, imports from collections where correctly fixed to import from collections.abc (where this is needed). This removes a few deprecation warnings (claiming that this will not work in py 3.9, even if in reality this will stop working in py 3.10). This partially addresses #4723. We are actually pinning to >=0.36 since in 0.36 a feature was dropped that we were planning to use (see #3845). In this way, we avoid relying on a feature that is removed in later versions (risking to implement something that then we have to remove, or even worse remain "pinned" to an old version of sqlalchemy-utils because nobody has the time to fix it with a different implementation [which is tricky, requires some knowledge of how SqlAlchemy and PosgreSQL work]). * Automated update of requirements/ files. (#4734) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Carl Simon Adorf --- environment.yml | 2 +- requirements/requirements-py-3.7.txt | 10 ++++------ requirements/requirements-py-3.8.txt | 10 ++++------ requirements/requirements-py-3.9.txt | 10 ++++------ setup.json | 2 +- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/environment.yml b/environment.yml index f6c8d5bb50..eadec13b65 100644 --- a/environment.yml +++ b/environment.yml @@ -33,7 +33,7 @@ dependencies: - pyyaml~=5.1.2 - reentry~=1.3 - simplejson~=3.16 -- sqlalchemy-utils~=0.34.2 +- sqlalchemy-utils~=0.36.0 - sqlalchemy>=1.3.10,~=1.3 - tabulate~=0.8.5 - tqdm~=4.45 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 4b245a66c1..ce2526ddfa 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -24,7 +24,7 @@ click-config-file==0.6.0 click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.4.1 +cryptography==3.4.3 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 @@ -76,7 +76,7 @@ numpy==1.20.1 packaging==20.9 palettable==3.3.0 pamqp==2.3.0 -pandas==1.2.1 +pandas==1.2.2 pandocfilters==1.4.3 paramiko==2.7.2 parso==0.8.1 @@ -100,7 +100,7 @@ PyCifRW==4.4.2 pycparser==2.20 pydata-sphinx-theme==0.4.3 Pygments==2.7.4 -pymatgen==2020.12.31 +pymatgen==2021.2.8.1 pympler==0.9 PyMySQL==0.9.3 PyNaCl==1.4.0 @@ -129,9 +129,7 @@ ruamel.yaml.clib==0.2.2 scipy==1.6.0 scramp==1.2.0 seekpath==1.9.7 -semantic-version==2.8.5 Send2Trash==1.5.0 -setuptools-rust==0.11.6 shellingham==1.4.0 shortuuid==1.0.1 simplejson==3.17.2 @@ -152,7 +150,7 @@ sphinxcontrib-serializinghtml==1.1.4 sphinxext-rediraffe==0.2.5 SQLAlchemy==1.3.23 sqlalchemy-diff==0.1.3 -SQLAlchemy-Utils==0.34.2 +SQLAlchemy-Utils==0.36.8 sqlparse==0.4.1 sympy==1.7.1 tabulate==0.8.7 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index dfb8ed0cf1..11f4d182d7 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -24,7 +24,7 @@ click-config-file==0.6.0 click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.4.1 +cryptography==3.4.3 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 @@ -75,7 +75,7 @@ numpy==1.20.1 packaging==20.9 palettable==3.3.0 pamqp==2.3.0 -pandas==1.2.1 +pandas==1.2.2 pandocfilters==1.4.3 paramiko==2.7.2 parso==0.8.1 @@ -99,7 +99,7 @@ PyCifRW==4.4.2 pycparser==2.20 pydata-sphinx-theme==0.4.3 Pygments==2.7.4 -pymatgen==2020.12.31 +pymatgen==2021.2.8.1 pympler==0.9 PyMySQL==0.9.3 PyNaCl==1.4.0 @@ -128,9 +128,7 @@ ruamel.yaml.clib==0.2.2 scipy==1.6.0 scramp==1.2.0 seekpath==1.9.7 -semantic-version==2.8.5 Send2Trash==1.5.0 -setuptools-rust==0.11.6 shellingham==1.4.0 shortuuid==1.0.1 simplejson==3.17.2 @@ -151,7 +149,7 @@ sphinxcontrib-serializinghtml==1.1.4 sphinxext-rediraffe==0.2.5 SQLAlchemy==1.3.23 sqlalchemy-diff==0.1.3 -SQLAlchemy-Utils==0.34.2 +SQLAlchemy-Utils==0.36.8 sqlparse==0.4.1 sympy==1.7.1 tabulate==0.8.7 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index eaba807b9b..e8020bf416 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -24,7 +24,7 @@ click-config-file==0.6.0 click-spinner==0.1.10 configobj==5.0.6 coverage==4.5.4 -cryptography==3.4.1 +cryptography==3.4.3 cycler==0.10.0 decorator==4.4.2 defusedxml==0.6.0 @@ -75,7 +75,7 @@ numpy==1.20.1 packaging==20.9 palettable==3.3.0 pamqp==2.3.0 -pandas==1.2.1 +pandas==1.2.2 pandocfilters==1.4.3 paramiko==2.7.2 parso==0.8.1 @@ -99,7 +99,7 @@ PyCifRW==4.4.2 pycparser==2.20 pydata-sphinx-theme==0.4.3 Pygments==2.7.4 -pymatgen==2020.12.31 +pymatgen==2021.2.8.1 pympler==0.9 PyMySQL==0.9.3 PyNaCl==1.4.0 @@ -127,9 +127,7 @@ ruamel.yaml==0.16.12 scipy==1.6.0 scramp==1.2.0 seekpath==1.9.7 -semantic-version==2.8.5 Send2Trash==1.5.0 -setuptools-rust==0.11.6 shellingham==1.4.0 shortuuid==1.0.1 simplejson==3.17.2 @@ -150,7 +148,7 @@ sphinxcontrib-serializinghtml==1.1.4 sphinxext-rediraffe==0.2.5 SQLAlchemy==1.3.23 sqlalchemy-diff==0.1.3 -SQLAlchemy-Utils==0.34.2 +SQLAlchemy-Utils==0.36.8 sqlparse==0.4.1 sympy==1.7.1 tabulate==0.8.7 diff --git a/setup.json b/setup.json index ca67d3806a..03873c2074 100644 --- a/setup.json +++ b/setup.json @@ -47,7 +47,7 @@ "pyyaml~=5.1.2", "reentry~=1.3", "simplejson~=3.16", - "sqlalchemy-utils~=0.34.2", + "sqlalchemy-utils~=0.36.0", "sqlalchemy~=1.3,>=1.3.10", "tabulate~=0.8.5", "tqdm~=4.45", From 13358ed975614c0b033a37293382ed80326fe0fa Mon Sep 17 00:00:00 2001 From: Aliaksandr Yakutovich Date: Wed, 10 Feb 2021 16:53:52 +0100 Subject: [PATCH 072/114] Bump aiida-prerequisites base image to 0.3.0 (#4738) Changes in the new image: - Updated conda (4.9.2) - Start ssh-agent at user's startup Co-authored-by: Chris Sewell --- .jenkins/Dockerfile | 2 +- .molecule/default/Dockerfile | 2 +- Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.jenkins/Dockerfile b/.jenkins/Dockerfile index 40dd9d0cdd..33cf8b1057 100644 --- a/.jenkins/Dockerfile +++ b/.jenkins/Dockerfile @@ -1,4 +1,4 @@ -FROM aiidateam/aiida-prerequisites:0.2.1 +FROM aiidateam/aiida-prerequisites:0.3.0 # to run the tests RUN pip install ansible~=2.10.0 molecule~=3.1.0 diff --git a/.molecule/default/Dockerfile b/.molecule/default/Dockerfile index 9746373762..1dff46e6b2 100644 --- a/.molecule/default/Dockerfile +++ b/.molecule/default/Dockerfile @@ -1,4 +1,4 @@ -FROM aiidateam/aiida-prerequisites:0.2.1 +FROM aiidateam/aiida-prerequisites:0.3.0 # allow for collection of query statistics # (must also be intialised on each database) diff --git a/Dockerfile b/Dockerfile index 37fce8a720..3c076051b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM aiidateam/aiida-prerequisites:0.2.1 +FROM aiidateam/aiida-prerequisites:0.3.0 USER root From 2e18f5bc092a7746c3d5cc151e59c52866354f10 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Wed, 10 Feb 2021 17:47:50 +0100 Subject: [PATCH 073/114] Add CalcJob test over SSH (#4732) Adds a configuration for a remote computer (slurm docker container) and uses it to run a CalcJob test over SSH. This is a follow-up on the memory leak tests, since the leak of the process instance was discovered to occur only when running CalcJobs on a remote computer via an SSH connection. Co-authored-by: Chris Sewell --- .github/config/README.md | 5 +++ .github/config/slurm-ssh-config.yaml | 7 +++++ .github/config/slurm-ssh.yaml | 12 +++++++ .github/config/slurm_rsa | 27 ++++++++++++++++ .../system_tests/pytest/test_memory_leaks.py | 31 ++++++++++++++++--- .github/workflows/ci-code.yml | 4 +++ .github/workflows/setup.sh | 10 ++++++ .github/workflows/test-install.yml | 4 +++ 8 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 .github/config/README.md create mode 100644 .github/config/slurm-ssh-config.yaml create mode 100644 .github/config/slurm-ssh.yaml create mode 100644 .github/config/slurm_rsa diff --git a/.github/config/README.md b/.github/config/README.md new file mode 100644 index 0000000000..6b43da3d5b --- /dev/null +++ b/.github/config/README.md @@ -0,0 +1,5 @@ +# AiiDA configuration files + +This folder contains configuration files for AiiDA computers, codes etc. + + - `slurm_rsa`: private key that provides access to the `slurm-ssh` container diff --git a/.github/config/slurm-ssh-config.yaml b/.github/config/slurm-ssh-config.yaml new file mode 100644 index 0000000000..48332209de --- /dev/null +++ b/.github/config/slurm-ssh-config.yaml @@ -0,0 +1,7 @@ +--- +safe_interval: 0 +username: xenon +look_for_keys: true +key_filename: "PLACEHOLDER_SSH_KEY" +key_policy: AutoAddPolicy +port: 5001 diff --git a/.github/config/slurm-ssh.yaml b/.github/config/slurm-ssh.yaml new file mode 100644 index 0000000000..43e5919e5b --- /dev/null +++ b/.github/config/slurm-ssh.yaml @@ -0,0 +1,12 @@ +--- +label: slurm-ssh +description: slurm container +hostname: localhost +transport: ssh +scheduler: slurm +shebang: "#!/bin/bash" +work_dir: /home/{username}/workdir +mpirun_command: "mpirun -np {tot_num_mpiprocs}" +mpiprocs_per_machine: 1 +prepend_text: "" +append_text: "" diff --git a/.github/config/slurm_rsa b/.github/config/slurm_rsa new file mode 100644 index 0000000000..20123b7d8c --- /dev/null +++ b/.github/config/slurm_rsa @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAnCqpTQFbmi1WPX4uTUFCHAvf61AhvqXUFoJEHQEvtDYibWJZ +bI7LueA2eEKw68oynIfPeinr4+DOnejMG1+HKCWi03DzWoorBOYc0e9i3nxkU93j +hZZsiQZfBgcCenqh2t1ZLbEFdFnCqLDw6gbDH0F3W3NJW0Q30a8HQ01lqdSKyVdf +UghVLCx1HM53BxXEYGU2m2ii+uyoMIsz9TSCJdKXIAb5N4tZYqKPF8q0vf1eP2BB +SUsn4bAHpPqvx3I0HkyR6qV5UT4K91FteULLTJHjK3Y0bBUMOmNQPh0JTmfj/KNB +EtJdlGYE0Tce1XINvhHItSpdFZs8GTnmOzUaVQIDAQABAoIBAEpWsILcm5tX646Y +KzhRUUQCjxP38ChNzhjs57ma3/d8MYU6ZPEdRHN1/Nfgf1Guzcrfh29S11yBnjlj +IQ4CulbtG4ZlZSJ7VSEe3Sc+OiVIt4WIwY7M3VuY8dDvs0lUaQnDhnkOpFcPh28/ +017D20xcoJGi3o+YeK3TELUD+doOeaot4+5TvR0PiLEmyjlnWB1FRkYpGAVDRKKa +F3dSAGf41ygoDOaGmtNmpH/Fn1k9cSDZsRsMKjZQTjgKfX+y/H6eOpORgHYHVmlu +eFIK8+yVVBy5k+m7nTIAUzXm01yJ5fQuT/75EcILUvjloTwmykaTfO1Ez6rNf+BC +VCdD9H0CgYEAyBjEB9vbZ5gDnnkdG0WCr34xPtBztTuVADWz5HorHYFircBUIaJ0 +XOIUioXMmpgSRTzbryAXVznh+g3LeS8QgiGQJoRhIknN8rrRUWd25tgImCMte0eb +bTieJYpvUk8RPan/Arb6f1MLZjWYfJelSw8qQS6R4ydk1L2M78sri/8CgYEAx8vy +KP1e5gGfA42Q0aHvocH7vqbEAOfDK8J+RpT/EoSJ6kSu2oPvblF1CBqHo/nQMhfK +AGbAtWIfy8rs1Md2k+Y+8PXtY8sJJ/HA8laVnEvTHbPSt4X7TtrLx27a8ZWtTNYu +JH/kK8rFBHEGqLnS6VJmqvHKqglp7FIQmHNNaasCgYEApGSMcXR0zqh6mLEic6xp +EOtZZCT4WzZHVTPJxvWEBKqvOtbfh/6jIUhw3dnNXll/8ThtuHRiGLyqZrj8qWQ8 +aN1QRATQlM4UEM7hd8LMUh28+dk03arYDCTO8ULJ8NKa9JF8vGs+ZGsC24c+72Xb +XE5qRcEQBJLx6UKNztiZv1sCgYACHBEuhZ5e5116eCAzVnZlStsRpEkliUzyRVd3 +/1LCK0wZgSgnfoUksQ9/SmhsPtMH9GBZqLwYLjUPvdDKXmDOJvw7Jx2elCJAnbjf +1jI2OEa+ZYuwDGYe6wiDzpPZQS9XRFuwXvlVzQpPhbIAThYACLK002DEctz/dc5f +DbifiQKBgQCdXgr7tdEAmusvIcTRA1KMIOGE5pMGYfbMnDTTIihUfRMJbCnn9sHe +PrDKVVgD3W4hjOABN24KOlCZPtWZfKUKe893ali7mFAIwKNV/AKhQhDgGzJPidqc +6DIL2GhDwqtPIf3b6sI21ZvyAFDROZMKnoL5Q1xbbp5EADi2wPO55Q== +-----END RSA PRIVATE KEY----- diff --git a/.github/system_tests/pytest/test_memory_leaks.py b/.github/system_tests/pytest/test_memory_leaks.py index 7a4edd1d1d..b9f57a7e6a 100644 --- a/.github/system_tests/pytest/test_memory_leaks.py +++ b/.github/system_tests/pytest/test_memory_leaks.py @@ -10,17 +10,23 @@ """Utilities for testing memory leakage.""" from tests.utils import processes as test_processes # pylint: disable=no-name-in-module,import-error from tests.utils.memory import get_instances # pylint: disable=no-name-in-module,import-error -from aiida.engine import processes, run +from aiida.engine import processes, run_get_node from aiida.plugins import CalculationFactory from aiida import orm ArithmeticAddCalculation = CalculationFactory('arithmetic.add') +def run_finished_ok(*args, **kwargs): + """Convenience function to check that run worked fine.""" + _, node = run_get_node(*args, **kwargs) + assert node.is_finished_ok, (node.exit_status, node.exit_message) + + def test_leak_run_process(): """Test whether running a dummy process leaks memory.""" inputs = {'a': orm.Int(2), 'b': orm.Str('test')} - run(test_processes.DummyProcess, **inputs) + run_finished_ok(test_processes.DummyProcess, **inputs) # check that no reference to the process is left in memory # some delay is necessary in order to allow for all callbacks to finish @@ -30,8 +36,25 @@ def test_leak_run_process(): def test_leak_local_calcjob(aiida_local_code_factory): """Test whether running a local CalcJob leaks memory.""" - inputs = {'x': orm.Int(1), 'y': orm.Int(2), 'code': aiida_local_code_factory('arithmetic.add', '/usr/bin/diff')} - run(ArithmeticAddCalculation, **inputs) + inputs = {'x': orm.Int(1), 'y': orm.Int(2), 'code': aiida_local_code_factory('arithmetic.add', '/bin/bash')} + run_finished_ok(ArithmeticAddCalculation, **inputs) + + # check that no reference to the process is left in memory + # some delay is necessary in order to allow for all callbacks to finish + process_instances = get_instances(processes.Process, delay=0.2) + assert not process_instances, f'Memory leak: process instances remain in memory: {process_instances}' + + +def test_leak_ssh_calcjob(): + """Test whether running a CalcJob over SSH leaks memory. + + Note: This relies on the 'slurm-ssh' computer being set up. + """ + code = orm.Code( + input_plugin_name='arithmetic.add', remote_computer_exec=[orm.load_computer('slurm-ssh'), '/bin/bash'] + ) + inputs = {'x': orm.Int(1), 'y': orm.Int(2), 'code': code} + run_finished_ok(ArithmeticAddCalculation, **inputs) # check that no reference to the process is left in memory # some delay is necessary in order to allow for all callbacks to finish diff --git a/.github/workflows/ci-code.yml b/.github/workflows/ci-code.yml index 6ffa0248b7..8ff88267ec 100644 --- a/.github/workflows/ci-code.yml +++ b/.github/workflows/ci-code.yml @@ -70,6 +70,10 @@ jobs: image: rabbitmq:latest ports: - 5672:5672 + slurm: + image: xenonmiddleware/slurm:17 + ports: + - 5001:22 steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/setup.sh b/.github/workflows/setup.sh index 8e890cc36d..ab508b0897 100755 --- a/.github/workflows/setup.sh +++ b/.github/workflows/setup.sh @@ -10,18 +10,28 @@ chmod 755 "${HOME}" # Replace the placeholders in configuration files with actual values CONFIG="${GITHUB_WORKSPACE}/.github/config" +cp "${CONFIG}/slurm_rsa" "${HOME}/.ssh/slurm_rsa" sed -i "s|PLACEHOLDER_BACKEND|${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.yaml" sed -i "s|PLACEHOLDER_PROFILE|test_${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.yaml" sed -i "s|PLACEHOLDER_DATABASE_NAME|test_${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.yaml" sed -i "s|PLACEHOLDER_REPOSITORY|/tmp/test_repository_test_${AIIDA_TEST_BACKEND}/|" "${CONFIG}/profile.yaml" sed -i "s|PLACEHOLDER_WORK_DIR|${GITHUB_WORKSPACE}|" "${CONFIG}/localhost.yaml" sed -i "s|PLACEHOLDER_REMOTE_ABS_PATH_DOUBLER|${CONFIG}/doubler.sh|" "${CONFIG}/doubler.yaml" +sed -i "s|PLACEHOLDER_SSH_KEY|${HOME}/.ssh/slurm_rsa|" "${CONFIG}/slurm-ssh-config.yaml" verdi setup --config "${CONFIG}/profile.yaml" + +# set up localhost computer verdi computer setup --config "${CONFIG}/localhost.yaml" verdi computer configure local localhost --config "${CONFIG}/localhost-config.yaml" +verdi computer test localhost verdi code setup --config "${CONFIG}/doubler.yaml" verdi code setup --config "${CONFIG}/add.yaml" +# set up slurm-ssh computer +verdi computer setup --config "${CONFIG}/slurm-ssh.yaml" +verdi computer configure ssh slurm-ssh --config "${CONFIG}/slurm-ssh-config.yaml" -n # needs slurm container +verdi computer test slurm-ssh --print-traceback + verdi profile setdefault test_${AIIDA_TEST_BACKEND} verdi config runner.poll.interval 0 diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index b9fb994ce9..a51c64015b 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -147,6 +147,10 @@ jobs: image: rabbitmq:latest ports: - 5672:5672 + slurm: + image: xenonmiddleware/slurm:17 + ports: + - 5001:22 steps: - uses: actions/checkout@v2 From f8f79b29ce05b927bf061d99f918a56bb102dbe7 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 10 Feb 2021 23:46:33 +0100 Subject: [PATCH 074/114] =?UTF-8?q?=F0=9F=A7=AA=20TESTS:=20Add=20pytest=20?= =?UTF-8?q?`requires=5Frmq`=20marker=20(#4739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/rabbitmq.yml | 2 +- pyproject.toml | 1 + tests/calculations/arithmetic/test_add.py | 2 ++ tests/calculations/test_templatereplacer.py | 2 ++ tests/calculations/test_transfer.py | 3 +++ tests/cmdline/commands/test_data.py | 4 +++- tests/cmdline/commands/test_process.py | 4 +++- tests/cmdline/commands/test_run.py | 3 +++ tests/cmdline/commands/test_status.py | 1 + tests/engine/daemon/test_runner.py | 1 + tests/engine/processes/workchains/test_restart.py | 6 ++++++ tests/engine/processes/workchains/test_utils.py | 3 +++ tests/engine/test_calc_job.py | 8 ++++++++ tests/engine/test_calcfunctions.py | 2 ++ tests/engine/test_futures.py | 3 +++ tests/engine/test_launch.py | 4 ++++ tests/engine/test_persistence.py | 3 +++ tests/engine/test_process.py | 3 +++ tests/engine/test_process_function.py | 2 ++ tests/engine/test_rmq.py | 2 ++ tests/engine/test_run.py | 3 +++ tests/engine/test_runners.py | 1 + tests/engine/test_utils.py | 1 + tests/engine/test_work_chain.py | 10 ++++++++++ tests/engine/test_workfunctions.py | 2 ++ tests/manage/external/test_rmq.py | 3 +++ tests/orm/test_querybuilder.py | 1 + tests/parsers/test_parser.py | 3 +++ tests/test_dataclasses.py | 8 ++++++++ tests/tools/importexport/orm/test_calculations.py | 3 +++ tests/tools/importexport/test_prov_redesign.py | 3 +++ tests/workflows/arithmetic/test_add_multiply.py | 1 + 32 files changed, 95 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rabbitmq.yml b/.github/workflows/rabbitmq.yml index dbcc7320d9..674977945f 100644 --- a/.github/workflows/rabbitmq.yml +++ b/.github/workflows/rabbitmq.yml @@ -64,4 +64,4 @@ jobs: pip freeze - name: Run tests - run: pytest -sv tests/manage/external/test_rmq.py + run: pytest -sv -k 'requires_rmq' diff --git a/pyproject.toml b/pyproject.toml index f281690e5a..fdfc741a1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ filterwarnings = [ "default::ResourceWarning", ] markers = [ + "requires_rmq: requires a connection (on port 5672) to RabbitMQ", "sphinx: set parameters for the sphinx `app` fixture" ] diff --git a/tests/calculations/arithmetic/test_add.py b/tests/calculations/arithmetic/test_add.py index f976945d75..af4e479716 100644 --- a/tests/calculations/arithmetic/test_add.py +++ b/tests/calculations/arithmetic/test_add.py @@ -15,6 +15,7 @@ from aiida.calculations.arithmetic.add import ArithmeticAddCalculation +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_add_default(fixture_sandbox, aiida_localhost, generate_calc_job): """Test a default `ArithmeticAddCalculation`.""" @@ -43,6 +44,7 @@ def test_add_default(fixture_sandbox, aiida_localhost, generate_calc_job): assert input_written == f"echo $(({inputs['x'].value} + {inputs['y'].value}))\n" +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_add_custom_filenames(fixture_sandbox, aiida_localhost, generate_calc_job): """Test an `ArithmeticAddCalculation` with non-default input and output filenames.""" diff --git a/tests/calculations/test_templatereplacer.py b/tests/calculations/test_templatereplacer.py index 3330a3ae46..61e5d85046 100644 --- a/tests/calculations/test_templatereplacer.py +++ b/tests/calculations/test_templatereplacer.py @@ -15,6 +15,7 @@ from aiida.common import datastructures +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_base_template(fixture_sandbox, aiida_localhost, generate_calc_job): """Test a base template that emulates the arithmetic add.""" @@ -70,6 +71,7 @@ def test_base_template(fixture_sandbox, aiida_localhost, generate_calc_job): assert input_written == f"echo $(({inputs['parameters']['x']} + {inputs['parameters']['y']}))" +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_file_usage(fixture_sandbox, aiida_localhost, generate_calc_job): """Test a base template that uses two files.""" diff --git a/tests/calculations/test_transfer.py b/tests/calculations/test_transfer.py index 92ec0f7afc..63650e40c0 100644 --- a/tests/calculations/test_transfer.py +++ b/tests/calculations/test_transfer.py @@ -15,6 +15,7 @@ from aiida.common import datastructures +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_get_transfer(fixture_sandbox, aiida_localhost, generate_calc_job, tmp_path): """Test a default `TransferCalculation`.""" @@ -64,6 +65,7 @@ def test_get_transfer(fixture_sandbox, aiida_localhost, generate_calc_job, tmp_p assert sorted(calc_info.retrieve_list) == sorted(retrieve_list) +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_put_transfer(fixture_sandbox, aiida_localhost, generate_calc_job, tmp_path): """Test a default `TransferCalculation`.""" @@ -199,6 +201,7 @@ def test_validate_transfer_inputs(aiida_localhost, tmp_path, temp_dir): assert result == expected +@pytest.mark.requires_rmq def test_integration_transfer(aiida_localhost, tmp_path): """Test a default `TransferCalculation`.""" from aiida.calculations.transfer import TransferCalculation diff --git a/tests/cmdline/commands/test_data.py b/tests/cmdline/commands/test_data.py index 96b5828799..b27fcbcd93 100644 --- a/tests/cmdline/commands/test_data.py +++ b/tests/cmdline/commands/test_data.py @@ -17,9 +17,10 @@ import unittest import tempfile import subprocess as sp -import numpy as np from click.testing import CliRunner +import numpy as np +import pytest from aiida import orm from aiida.backends.testbase import AiidaTestCase @@ -229,6 +230,7 @@ def test_arrayshow(self): self.assertEqual(res.exit_code, 0, 'The command did not finish correctly') +@pytest.mark.requires_rmq class TestVerdiDataBands(AiidaTestCase, DummyVerdiDataListable): """Testing verdi data bands.""" diff --git a/tests/cmdline/commands/test_process.py b/tests/cmdline/commands/test_process.py index 937c52c70b..eb829df86f 100644 --- a/tests/cmdline/commands/test_process.py +++ b/tests/cmdline/commands/test_process.py @@ -15,8 +15,9 @@ from concurrent.futures import Future from click.testing import CliRunner -import plumpy import kiwipy +import plumpy +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_process @@ -63,6 +64,7 @@ def tearDown(self): os.kill(self.daemon.pid, signal.SIGTERM) super().tearDown() + @pytest.mark.requires_rmq def test_pause_play_kill(self): """ Test the pause/play/kill commands diff --git a/tests/cmdline/commands/test_run.py b/tests/cmdline/commands/test_run.py index b151f49639..5e1c49e867 100644 --- a/tests/cmdline/commands/test_run.py +++ b/tests/cmdline/commands/test_run.py @@ -13,6 +13,7 @@ import warnings from click.testing import CliRunner +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_run @@ -25,6 +26,7 @@ def setUp(self): super().setUp() self.cli_runner = CliRunner() + @pytest.mark.requires_rmq def test_run_workfunction(self): """Regression test for #2165 @@ -181,6 +183,7 @@ def test_no_autogroup(self): all_auto_groups = queryb.all() self.assertEqual(len(all_auto_groups), 0, 'There should be no autogroup generated') + @pytest.mark.requires_rmq def test_autogroup_filter_class(self): # pylint: disable=too-many-locals """Check if the autogroup is properly generated but filtered classes are skipped.""" from aiida.orm import Code, QueryBuilder, Node, AutoGroup, load_node diff --git a/tests/cmdline/commands/test_status.py b/tests/cmdline/commands/test_status.py index 4818be3d39..275d3b8926 100644 --- a/tests/cmdline/commands/test_status.py +++ b/tests/cmdline/commands/test_status.py @@ -14,6 +14,7 @@ from aiida.cmdline.utils.echo import ExitCode +@pytest.mark.requires_rmq def test_status(run_cli_command): """Test `verdi status`.""" options = [] diff --git a/tests/engine/daemon/test_runner.py b/tests/engine/daemon/test_runner.py index b4730ad7cf..044ec3349f 100644 --- a/tests/engine/daemon/test_runner.py +++ b/tests/engine/daemon/test_runner.py @@ -13,6 +13,7 @@ from aiida.engine.daemon.runner import shutdown_runner +@pytest.mark.requires_rmq @pytest.mark.asyncio async def test_shutdown_runner(manager): """Test the ``shutdown_runner`` method.""" diff --git a/tests/engine/processes/workchains/test_restart.py b/tests/engine/processes/workchains/test_restart.py index dbea7970b4..034a244bba 100644 --- a/tests/engine/processes/workchains/test_restart.py +++ b/tests/engine/processes/workchains/test_restart.py @@ -49,6 +49,7 @@ def test_get_process_handler(): assert [handler.__name__ for handler in SomeWorkChain.get_process_handlers()] == ['handler_a', 'handler_b'] +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_excepted_process(generate_work_chain, generate_calculation_node): """Test that the workchain aborts if the sub process was excepted.""" @@ -58,6 +59,7 @@ def test_excepted_process(generate_work_chain, generate_calculation_node): assert process.inspect_process() == engine.BaseRestartWorkChain.exit_codes.ERROR_SUB_PROCESS_EXCEPTED +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_killed_process(generate_work_chain, generate_calculation_node): """Test that the workchain aborts if the sub process was killed.""" @@ -67,6 +69,7 @@ def test_killed_process(generate_work_chain, generate_calculation_node): assert process.inspect_process() == engine.BaseRestartWorkChain.exit_codes.ERROR_SUB_PROCESS_KILLED +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_unhandled_failure(generate_work_chain, generate_calculation_node): """Test the unhandled failure mechanism. @@ -85,6 +88,7 @@ def test_unhandled_failure(generate_work_chain, generate_calculation_node): ) == engine.BaseRestartWorkChain.exit_codes.ERROR_SECOND_CONSECUTIVE_UNHANDLED_FAILURE # pylint: disable=no-member +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_unhandled_reset_after_success(generate_work_chain, generate_calculation_node): """Test `ctx.unhandled_failure` is reset to `False` in `inspect_process` after a successful process.""" @@ -99,6 +103,7 @@ def test_unhandled_reset_after_success(generate_work_chain, generate_calculation assert process.ctx.unhandled_failure is False +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_unhandled_reset_after_handled(generate_work_chain, generate_calculation_node): """Test `ctx.unhandled_failure` is reset to `False` in `inspect_process` after a handled failed process.""" @@ -120,6 +125,7 @@ def test_unhandled_reset_after_handled(generate_work_chain, generate_calculation assert process.ctx.unhandled_failure is False +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_run_process(generate_work_chain, generate_calculation_node, monkeypatch): """Test the `run_process` method.""" diff --git a/tests/engine/processes/workchains/test_utils.py b/tests/engine/processes/workchains/test_utils.py index efcc28537b..5da7f6e156 100644 --- a/tests/engine/processes/workchains/test_utils.py +++ b/tests/engine/processes/workchains/test_utils.py @@ -9,6 +9,8 @@ ########################################################################### # pylint: disable=no-self-use,unused-argument,unused-variable,function-redefined,missing-class-docstring,missing-function-docstring """Tests for `aiida.engine.processes.workchains.utils` module.""" +import pytest + from aiida.backends.testbase import AiidaTestCase from aiida.engine import ExitCode, ProcessState from aiida.engine.processes.workchains.restart import BaseRestartWorkChain @@ -19,6 +21,7 @@ ArithmeticAddCalculation = CalculationFactory('arithmetic.add') +@pytest.mark.requires_rmq class TestRegisterProcessHandler(AiidaTestCase): """Tests for the `process_handler` decorator.""" diff --git a/tests/engine/test_calc_job.py b/tests/engine/test_calc_job.py index ceb4fc93b4..6b67541b80 100644 --- a/tests/engine/test_calc_job.py +++ b/tests/engine/test_calc_job.py @@ -35,6 +35,7 @@ def raise_exception(exception): raise exception() +@pytest.mark.requires_rmq class FileCalcJob(CalcJob): """Example `CalcJob` implementation to test the `provenance_exclude_list` functionality. @@ -72,6 +73,7 @@ def prepare_for_submission(self, folder): return calcinfo +@pytest.mark.requires_rmq class TestCalcJob(AiidaTestCase): """Test for the `CalcJob` process sub class.""" @@ -388,6 +390,7 @@ def _generate_process(inputs=None): return _generate_process +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') def test_parse_insufficient_data(generate_process): """Test the scheduler output parsing logic in `CalcJob.parse`. @@ -418,6 +421,7 @@ def test_parse_insufficient_data(generate_process): assert log in logs +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') def test_parse_non_zero_retval(generate_process): """Test the scheduler output parsing logic in `CalcJob.parse`. @@ -437,6 +441,7 @@ def test_parse_non_zero_retval(generate_process): assert 'could not parse scheduler output: return value of `detailed_job_info` is non-zero' in logs +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') def test_parse_not_implemented(generate_process): """Test the scheduler output parsing logic in `CalcJob.parse`. @@ -466,6 +471,7 @@ def test_parse_not_implemented(generate_process): assert log in logs +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test', 'override_logging') def test_parse_scheduler_excepted(generate_process, monkeypatch): """Test the scheduler output parsing logic in `CalcJob.parse`. @@ -501,6 +507,7 @@ def raise_exception(*args, **kwargs): assert log in logs +@pytest.mark.requires_rmq @pytest.mark.parametrize(('exit_status_scheduler', 'exit_status_retrieved', 'final'), ( (None, None, 0), (100, None, 100), @@ -568,6 +575,7 @@ def parse_retrieved_output(_, __): assert result.status == final +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_additional_retrieve_list(generate_process, fixture_sandbox): """Test the ``additional_retrieve_list`` option.""" diff --git a/tests/engine/test_calcfunctions.py b/tests/engine/test_calcfunctions.py index 5ff6329db2..95e2b55bf3 100644 --- a/tests/engine/test_calcfunctions.py +++ b/tests/engine/test_calcfunctions.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the calcfunction decorator and CalcFunctionNode.""" +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.common import exceptions @@ -37,6 +38,7 @@ def execution_counter_calcfunction(data): return Int(data.value + 1) +@pytest.mark.requires_rmq class TestCalcFunction(AiidaTestCase): """Tests for calcfunctions. diff --git a/tests/engine/test_futures.py b/tests/engine/test_futures.py index b3e2babee7..dba89e6c94 100644 --- a/tests/engine/test_futures.py +++ b/tests/engine/test_futures.py @@ -10,6 +10,8 @@ """Module to test process futures.""" import asyncio +import pytest + from aiida.backends.testbase import AiidaTestCase from aiida.engine import processes, run from aiida.manage.manager import get_manager @@ -17,6 +19,7 @@ from tests.utils import processes as test_processes +@pytest.mark.requires_rmq class TestWf(AiidaTestCase): """Test process futures.""" TIMEOUT = 5.0 # seconds diff --git a/tests/engine/test_launch.py b/tests/engine/test_launch.py index d259ee5121..7a53712127 100644 --- a/tests/engine/test_launch.py +++ b/tests/engine/test_launch.py @@ -8,6 +8,8 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module to test processess launch.""" +import pytest + from aiida import orm from aiida.backends.testbase import AiidaTestCase from aiida.common import exceptions @@ -62,6 +64,7 @@ def add(self): self.out('result', orm.Int(self.inputs.term_a + self.inputs.term_b).store()) +@pytest.mark.requires_rmq class TestLaunchers(AiidaTestCase): """Class to test process launchers.""" @@ -142,6 +145,7 @@ def test_submit_store_provenance_false(self): launch.submit(AddWorkChain, term_a=self.term_a, term_b=self.term_b, metadata={'store_provenance': False}) +@pytest.mark.requires_rmq class TestLaunchersDryRun(AiidaTestCase): """Test the launchers when performing a dry-run.""" diff --git a/tests/engine/test_persistence.py b/tests/engine/test_persistence.py index 343bd868b3..7a451c1d0f 100644 --- a/tests/engine/test_persistence.py +++ b/tests/engine/test_persistence.py @@ -9,6 +9,7 @@ ########################################################################### """Test persisting via the AiiDAPersister.""" import plumpy +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.engine.persistence import AiiDAPersister @@ -17,6 +18,7 @@ from tests.utils.processes import DummyProcess +@pytest.mark.requires_rmq class TestProcess(AiidaTestCase): """Test the basic saving and loading of process states.""" @@ -40,6 +42,7 @@ def test_save_load(self): self.assertEqual(loaded_process.state, plumpy.ProcessState.FINISHED) +@pytest.mark.requires_rmq class TestAiiDAPersister(AiidaTestCase): """Test AiiDAPersister.""" maxDiff = 1024 diff --git a/tests/engine/test_process.py b/tests/engine/test_process.py index 22f2c0391e..8116fbc6e0 100644 --- a/tests/engine/test_process.py +++ b/tests/engine/test_process.py @@ -13,6 +13,7 @@ import plumpy from plumpy.utils import AttributesFrozendict +import pytest from aiida import orm from aiida.backends.testbase import AiidaTestCase @@ -36,6 +37,7 @@ def define(cls, spec): spec.input('some.name.space.a', valid_type=orm.Int) +@pytest.mark.requires_rmq class TestProcessNamespace(AiidaTestCase): """Test process namespace""" @@ -91,6 +93,7 @@ def on_stop(self): assert self._thread_id is threading.current_thread().ident +@pytest.mark.requires_rmq class TestProcess(AiidaTestCase): """Test AiiDA process.""" diff --git a/tests/engine/test_process_function.py b/tests/engine/test_process_function.py index 5cf6713d60..fe911685ea 100644 --- a/tests/engine/test_process_function.py +++ b/tests/engine/test_process_function.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the process_function decorator.""" +import pytest from aiida import orm from aiida.backends.testbase import AiidaTestCase @@ -22,6 +23,7 @@ CUSTOM_DESCRIPTION = 'Custom description' +@pytest.mark.requires_rmq class TestProcessFunction(AiidaTestCase): """ Note that here we use `@workfunctions` and `@calculations`, the concrete versions of the diff --git a/tests/engine/test_rmq.py b/tests/engine/test_rmq.py index 23074d983f..7fc842b119 100644 --- a/tests/engine/test_rmq.py +++ b/tests/engine/test_rmq.py @@ -11,6 +11,7 @@ import asyncio import plumpy +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.engine import ProcessState @@ -20,6 +21,7 @@ from tests.utils import processes as test_processes +@pytest.mark.requires_rmq class TestProcessControl(AiidaTestCase): """Test AiiDA's RabbitMQ functionalities.""" diff --git a/tests/engine/test_run.py b/tests/engine/test_run.py index a9ce5641c9..36535c64b7 100644 --- a/tests/engine/test_run.py +++ b/tests/engine/test_run.py @@ -8,6 +8,8 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the `run` functions.""" +import pytest + from aiida.backends.testbase import AiidaTestCase from aiida.engine import run, run_get_node from aiida.orm import Int, Str, ProcessNode @@ -15,6 +17,7 @@ from tests.utils.processes import DummyProcess +@pytest.mark.requires_rmq class TestRun(AiidaTestCase): """Tests for the `run` functions.""" diff --git a/tests/engine/test_runners.py b/tests/engine/test_runners.py index a7a5f407ab..774f3ee9a1 100644 --- a/tests/engine/test_runners.py +++ b/tests/engine/test_runners.py @@ -44,6 +44,7 @@ def the_hans_klok_comeback(loop): loop.stop() +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test') def test_call_on_process_finish(create_runner): """Test call on calculation finish.""" diff --git a/tests/engine/test_utils.py b/tests/engine/test_utils.py index d2b3ac724e..6bbec75d96 100644 --- a/tests/engine/test_utils.py +++ b/tests/engine/test_utils.py @@ -170,6 +170,7 @@ async def task(): self.assertTrue(interruptable.done()) +@pytest.mark.requires_rmq class TestInterruptableTask(AiidaTestCase): """ Tests for InterruptableFuture and interruptable_task.""" diff --git a/tests/engine/test_work_chain.py b/tests/engine/test_work_chain.py index 141284726c..0ebf6048af 100644 --- a/tests/engine/test_work_chain.py +++ b/tests/engine/test_work_chain.py @@ -187,6 +187,7 @@ def success(self): self.out(self.OUTPUT_LABEL, Int(self.OUTPUT_VALUE).store()) +@pytest.mark.requires_rmq class TestExitStatus(AiidaTestCase): """ This class should test the various ways that one can exit from the outline flow of a WorkChain, other than @@ -268,6 +269,7 @@ def step2(self): self.ctx.s2 = True +@pytest.mark.requires_rmq class TestContext(AiidaTestCase): def test_attributes(self): @@ -289,6 +291,7 @@ def test_dict(self): wc.ctx['new_attr'] # pylint: disable=pointless-statement +@pytest.mark.requires_rmq class TestWorkchain(AiidaTestCase): # pylint: disable=too-many-public-methods @@ -850,6 +853,7 @@ def _run_with_checkpoints(wf_class, inputs=None): return proc.finished_steps +@pytest.mark.requires_rmq class TestWorkChainAbort(AiidaTestCase): """ Test the functionality to abort a workchain @@ -926,6 +930,7 @@ async def run_async(): self.assertEqual(process.node.is_killed, True) +@pytest.mark.requires_rmq class TestWorkChainAbortChildren(AiidaTestCase): """ Test the functionality to abort a workchain and verify that children @@ -1018,6 +1023,7 @@ async def run_async(): self.assertEqual(process.node.is_killed, True) +@pytest.mark.requires_rmq class TestImmutableInputWorkchain(AiidaTestCase): """ Test that inputs cannot be modified @@ -1123,6 +1129,7 @@ def do_test(self): assert self.inputs.test == self.inputs.reference +@pytest.mark.requires_rmq class TestSerializeWorkChain(AiidaTestCase): """ Test workchains with serialized input / output. @@ -1249,6 +1256,7 @@ def do_run(self): self.out('c', self.inputs.c) +@pytest.mark.requires_rmq class TestWorkChainExpose(AiidaTestCase): """ Test the expose inputs / outputs functionality @@ -1360,6 +1368,7 @@ def step1(self): launch.run(Child) +@pytest.mark.requires_rmq class TestWorkChainMisc(AiidaTestCase): class PointlessWorkChain(WorkChain): @@ -1396,6 +1405,7 @@ def test_global_submit_raises(self): launch.run(TestWorkChainMisc.IllegalSubmitWorkChain) +@pytest.mark.requires_rmq class TestDefaultUniqueness(AiidaTestCase): """Test that default inputs of exposed nodes will get unique UUIDS.""" diff --git a/tests/engine/test_workfunctions.py b/tests/engine/test_workfunctions.py index 9f62d54a77..16b4d0f83a 100644 --- a/tests/engine/test_workfunctions.py +++ b/tests/engine/test_workfunctions.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the workfunction decorator and WorkFunctionNode.""" +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.common.links import LinkType @@ -16,6 +17,7 @@ from aiida.orm import Int, WorkFunctionNode, CalcFunctionNode +@pytest.mark.requires_rmq class TestWorkFunction(AiidaTestCase): """Tests for workfunctions. diff --git a/tests/manage/external/test_rmq.py b/tests/manage/external/test_rmq.py index 5dac0105ee..940f4f5681 100644 --- a/tests/manage/external/test_rmq.py +++ b/tests/manage/external/test_rmq.py @@ -37,6 +37,7 @@ def test_get_rmq_url(args, kwargs, expected): rmq.get_rmq_url(*args, **kwargs) +@pytest.mark.requires_rmq @pytest.mark.parametrize('url', ('amqp://guest:guest@127.0.0.1:5672',)) def test_communicator(url): """Test the instantiation of a ``kiwipy.rmq.RmqThreadCommunicator``. @@ -46,11 +47,13 @@ def test_communicator(url): RmqThreadCommunicator.connect(connection_params={'url': url}) +@pytest.mark.requires_rmq def test_add_rpc_subscriber(communicator): """Test ``add_rpc_subscriber``.""" communicator.add_rpc_subscriber(None) +@pytest.mark.requires_rmq def test_add_broadcast_subscriber(communicator): """Test ``add_broadcast_subscriber``.""" communicator.add_broadcast_subscriber(None) diff --git a/tests/orm/test_querybuilder.py b/tests/orm/test_querybuilder.py index 1180b56221..598474fa06 100644 --- a/tests/orm/test_querybuilder.py +++ b/tests/orm/test_querybuilder.py @@ -135,6 +135,7 @@ def test_get_group_type_filter(self): # Tracked in issue #4281 @pytest.mark.flaky(reruns=2) + @pytest.mark.requires_rmq def test_process_query(self): """ Test querying for a process class. diff --git a/tests/parsers/test_parser.py b/tests/parsers/test_parser.py index 934a7c8230..a9b625d5f6 100644 --- a/tests/parsers/test_parser.py +++ b/tests/parsers/test_parser.py @@ -11,6 +11,8 @@ import io +import pytest + from aiida import orm from aiida.backends.testbase import AiidaTestCase from aiida.common import LinkType @@ -86,6 +88,7 @@ def test_parser_get_outputs_for_parsing(self): self.assertIn('output', outputs_for_parsing) self.assertEqual(outputs_for_parsing['output'].uuid, output.uuid) + @pytest.mark.requires_rmq def test_parse_from_node(self): """Test that the `parse_from_node` returns a tuple of the parsed output nodes and a calculation node. diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 75f73c0d21..7c4e939848 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -13,6 +13,8 @@ import tempfile import unittest +import pytest + from aiida.backends.testbase import AiidaTestCase from aiida.common.exceptions import ModificationNotAllowed from aiida.common.utils import Capturing @@ -188,6 +190,7 @@ def test_change_cifdata_file(self): @unittest.skipIf(not has_ase(), 'Unable to import ase') @unittest.skipIf(not has_pycifrw(), 'Unable to import PyCifRW') + @pytest.mark.requires_rmq def test_get_structure(self): """Test `CifData.get_structure`.""" with tempfile.NamedTemporaryFile(mode='w+') as tmpf: @@ -225,6 +228,7 @@ def test_get_structure(self): @unittest.skipIf(not has_ase(), 'Unable to import ase') @unittest.skipIf(not has_pycifrw(), 'Unable to import PyCifRW') + @pytest.mark.requires_rmq def test_ase_primitive_and_conventional_cells_ase(self): """Checking the number of atoms per primitive/conventional cell returned by ASE ase.io.read() method. Test input is @@ -270,6 +274,7 @@ def test_ase_primitive_and_conventional_cells_ase(self): @unittest.skipIf(not has_ase(), 'Unable to import ase') @unittest.skipIf(not has_pycifrw(), 'Unable to import PyCifRW') @unittest.skipIf(not has_pymatgen(), 'Unable to import pymatgen') + @pytest.mark.requires_rmq def test_ase_primitive_and_conventional_cells_pymatgen(self): """Checking the number of atoms per primitive/conventional cell returned by ASE ase.io.read() method. Test input is @@ -530,6 +535,7 @@ def test_attached_hydrogens(self): @unittest.skipIf(not has_ase(), 'Unable to import ase') @unittest.skipIf(not has_pycifrw(), 'Unable to import PyCifRW') @unittest.skipIf(not has_spglib(), 'Unable to import spglib') + @pytest.mark.requires_rmq def test_refine(self): """ Test case for refinement (space group determination) for a @@ -1643,6 +1649,7 @@ def test_get_formula_unknown(self): @unittest.skipIf(not has_ase(), 'Unable to import ase') @unittest.skipIf(not has_pycifrw(), 'Unable to import PyCifRW') + @pytest.mark.requires_rmq def test_get_cif(self): """ Tests the conversion to CifData @@ -2823,6 +2830,7 @@ def test_creation(self): # Step 66 does not exist n.get_index_from_stepid(66) + @pytest.mark.requires_rmq def test_conversion_to_structure(self): """ Check the methods to export a given time step to a StructureData node. diff --git a/tests/tools/importexport/orm/test_calculations.py b/tests/tools/importexport/orm/test_calculations.py index e30f2d1a9b..b3058b5399 100644 --- a/tests/tools/importexport/orm/test_calculations.py +++ b/tests/tools/importexport/orm/test_calculations.py @@ -12,6 +12,8 @@ import os +import pytest + from aiida import orm from aiida.tools.importexport import import_data, export @@ -30,6 +32,7 @@ def tearDown(self): self.reset_database() super().tearDown() + @pytest.mark.requires_rmq @with_temp_dir def test_calcfunction(self, temp_dir): """Test @calcfunction""" diff --git a/tests/tools/importexport/test_prov_redesign.py b/tests/tools/importexport/test_prov_redesign.py index cbf4653fc2..51e54734e6 100644 --- a/tests/tools/importexport/test_prov_redesign.py +++ b/tests/tools/importexport/test_prov_redesign.py @@ -12,6 +12,8 @@ import os +import pytest + from aiida import orm from aiida.tools.importexport import import_data, export @@ -86,6 +88,7 @@ def test_base_data_type_change(self, temp_dir): msg = f"type of node ('{nlist.node_type}') is not updated according to db schema v0.4" self.assertEqual(nlist.node_type, 'data.list.List.', msg=msg) + @pytest.mark.requires_rmq @with_temp_dir def test_node_process_type(self, temp_dir): """ Column `process_type` added to `Node` entity DB table """ diff --git a/tests/workflows/arithmetic/test_add_multiply.py b/tests/workflows/arithmetic/test_add_multiply.py index 297c4440fd..b172930094 100644 --- a/tests/workflows/arithmetic/test_add_multiply.py +++ b/tests/workflows/arithmetic/test_add_multiply.py @@ -21,6 +21,7 @@ def test_factory(): assert loaded.is_process_function +@pytest.mark.requires_rmq @pytest.mark.usefixtures('clear_database_before_test', 'temporary_event_loop') def test_run(): """Test running the work function.""" From cba7d6d58baeac67d5860234cf296b60f2d23ef6 Mon Sep 17 00:00:00 2001 From: Marnik Bercx Date: Thu, 11 Feb 2021 21:50:37 +0100 Subject: [PATCH 075/114] Work on `verdi group remove-nodes` command (#4728). * `verdi group remove-nodes`: Add warning when nodes are not in the group Currently, the `verdi group remove-nodes` command does not raise any warning when the nodes that the user wants to remove are not in the group. It also says it removed the number of requested nodes from the group, even when none of them is in the group specified. Here we: * Have the command fail with a `Critical` message when none of the requested nodes are in the group. * Raise a warning when any of the nodes requested are not in the specified group, and list the PK's of the nodes that are missing. Note that the Group.remove_nodes() command still does not raise any warning when the requested nodes are not in the group. * Fix bug and improve API Fixes a bug when the user actually doesn't provide any nodes. In case the `--clear` flag is also not provided, the command will fail since there is nothing to remove. In case it is provided, the command will ask for confirmation to remove all nodes unless the force flag is also set. * Fail if both the `--clear` flag and nodes are provided In the current API, it doesn't make sense to provide *both* the `--clear` flag and a list of node identifiers. Here we check if both are provided and abort the command in this case. * Add tests. --- aiida/cmdline/commands/cmd_group.py | 37 +++++++++++++++++++++++++--- tests/cmdline/commands/test_group.py | 33 +++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/aiida/cmdline/commands/cmd_group.py b/aiida/cmdline/commands/cmd_group.py index f623fe74c0..20b8303f0e 100644 --- a/aiida/cmdline/commands/cmd_group.py +++ b/aiida/cmdline/commands/cmd_group.py @@ -47,12 +47,41 @@ def group_add_nodes(group, force, nodes): @with_dbenv() def group_remove_nodes(group, nodes, clear, force): """Remove nodes from a group.""" - if clear: - message = f'Do you really want to remove ALL the nodes from Group<{group.label}>?' - else: - message = f'Do you really want to remove {len(nodes)} nodes from Group<{group.label}>?' + from aiida.orm import QueryBuilder, Group, Node + + label = group.label + klass = group.__class__.__name__ + + if nodes and clear: + echo.echo_critical( + 'Specify either the `--clear` flag to remove all nodes or the identifiers of the nodes you want to remove.' + ) if not force: + + if nodes: + node_pks = [node.pk for node in nodes] + + query = QueryBuilder() + query.append(Group, filters={'id': group.pk}, tag='group') + query.append(Node, with_group='group', filters={'id': {'in': node_pks}}, project='id') + + group_node_pks = query.all(flat=True) + + if not group_node_pks: + echo.echo_critical(f'None of the specified nodes are in {klass}<{label}>.') + + if len(node_pks) > len(group_node_pks): + node_pks = set(node_pks).difference(set(group_node_pks)) + echo.echo_warning(f'{len(node_pks)} nodes with PK {node_pks} are not in {klass}<{label}>.') + + message = f'Are you sure you want to remove {len(group_node_pks)} nodes from {klass}<{label}>?' + + elif clear: + message = f'Are you sure you want to remove ALL the nodes from {klass}<{label}>?' + else: + echo.echo_critical(f'No nodes were provided for removal from {klass}<{label}>.') + click.confirm(message, abort=True) if clear: diff --git a/tests/cmdline/commands/test_group.py b/tests/cmdline/commands/test_group.py index 0a4f3c0933..db0cf51949 100644 --- a/tests/cmdline/commands/test_group.py +++ b/tests/cmdline/commands/test_group.py @@ -12,6 +12,7 @@ from aiida.backends.testbase import AiidaTestCase from aiida.common import exceptions from aiida.cmdline.commands import cmd_group +from aiida.cmdline.utils.echo import ExitCode class TestVerdiGroup(AiidaTestCase): @@ -156,7 +157,6 @@ def test_delete(self): self.assertEqual(group.count(), 2) result = self.cli_runner.invoke(cmd_group.group_delete, ['--force', 'group_test_delete_02']) - self.assertClickResultNoException(result) with self.assertRaises(exceptions.NotExistent): orm.load_group(label='group_test_delete_02') @@ -265,7 +265,7 @@ def test_add_remove_nodes(self): result = self.cli_runner.invoke(cmd_group.group_remove_nodes, ['--force', '--group=dummygroup1', node_01.uuid]) self.assertIsNone(result.exception, result.output) - # Check if node is added in group using group show command + # Check that the node is no longer in the group result = self.cli_runner.invoke(cmd_group.group_show, ['-r', 'dummygroup1']) self.assertClickResultNoException(result) self.assertNotIn('CalculationNode', result.output) @@ -280,6 +280,35 @@ def test_add_remove_nodes(self): self.assertClickResultNoException(result) self.assertEqual(group.count(), 0) + # Try to remove node that isn't in the group + result = self.cli_runner.invoke(cmd_group.group_remove_nodes, ['--group=dummygroup1', node_01.uuid]) + self.assertEqual(result.exit_code, ExitCode.CRITICAL) + + # Try to remove no nodes nor clear the group + result = self.cli_runner.invoke(cmd_group.group_remove_nodes, ['--group=dummygroup1']) + self.assertEqual(result.exit_code, ExitCode.CRITICAL) + + # Try to remove both nodes and clear the group + result = self.cli_runner.invoke(cmd_group.group_remove_nodes, ['--group=dummygroup1', '--clear', node_01.uuid]) + self.assertEqual(result.exit_code, ExitCode.CRITICAL) + + # Add a node with confirmation + result = self.cli_runner.invoke(cmd_group.group_add_nodes, ['--group=dummygroup1', node_01.uuid], input='y') + self.assertEqual(group.count(), 1) + + # Try to remove two nodes, one that isn't in the group, but abort + result = self.cli_runner.invoke( + cmd_group.group_remove_nodes, ['--group=dummygroup1', node_01.uuid, node_02.uuid], input='N' + ) + self.assertIn('Warning', result.output) + self.assertEqual(group.count(), 1) + + # Try to clear all nodes from the group, but abort + result = self.cli_runner.invoke(cmd_group.group_remove_nodes, ['--group=dummygroup1', '--clear'], input='N') + self.assertIn('Are you sure you want to remove ALL', result.output) + self.assertIn('Aborted', result.output) + self.assertEqual(group.count(), 1) + def test_copy_existing_group(self): """Test user is prompted to continue if destination group exists and is not empty""" source_label = 'source_copy_existing_group' From dca50f47a340ffa14302d7bd0714ba86227b4b57 Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Fri, 12 Feb 2021 10:12:20 +0100 Subject: [PATCH 076/114] CI: Increase output verbosity of tests suite. (#4740) --- .github/workflows/tests.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.sh b/.github/workflows/tests.sh index 7ce9855d86..db69cfa29c 100755 --- a/.github/workflows/tests.sh +++ b/.github/workflows/tests.sh @@ -18,6 +18,7 @@ export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov-config=${GITHUB_WORKSPACE}/.cover export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov-report xml" export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov-append" export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov=aiida" +export PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --verbose" # daemon tests verdi daemon start 4 From 28447f0cf5dd626d3eb350a93d821501df77346a Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Tue, 16 Feb 2021 15:54:13 +0100 Subject: [PATCH 077/114] Skip test 'TestVerdiProcessDaemon::test_pause_play_kill'. (#4747) The test randomly fails to complete within a reasonable amount of time leading to a significant disruption of our CI pipeline. Investigated in issue #4731. --- tests/cmdline/commands/test_process.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/cmdline/commands/test_process.py b/tests/cmdline/commands/test_process.py index eb829df86f..39d8a7aabb 100644 --- a/tests/cmdline/commands/test_process.py +++ b/tests/cmdline/commands/test_process.py @@ -64,6 +64,7 @@ def tearDown(self): os.kill(self.daemon.pid, signal.SIGTERM) super().tearDown() + @pytest.mark.skip(reason='fails to complete randomly (see issue #4731)') @pytest.mark.requires_rmq def test_pause_play_kill(self): """ From 4a7d3bb0294ea24bb55d7c793ccf4ad87e3949a1 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 17 Feb 2021 12:32:26 +0100 Subject: [PATCH 078/114] =?UTF-8?q?=F0=9F=A7=AA=20TESTS:=20Fix=20pre-commi?= =?UTF-8?q?t=20(pin=20astroid)=20(#4757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary fix for https://github.com/PyCQA/astroid/issues/895 --- setup.json | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.json b/setup.json index 03873c2074..b249a2528a 100644 --- a/setup.json +++ b/setup.json @@ -92,6 +92,7 @@ "notebook~=6.1,>=6.1.5" ], "pre-commit": [ + "astroid<2.5", "mypy==0.790", "packaging==20.3", "pre-commit~=2.2", From 3c5abcf9b07742514f0a4e167ef4d2fb235e9a43 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 17 Feb 2021 12:49:24 +0100 Subject: [PATCH 079/114] =?UTF-8?q?=F0=9F=A7=AA=20TESTS:=20Fix=20plumpy=20?= =?UTF-8?q?incompatibility=20(#4751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As of https://github.com/aiidateam/plumpy/commit/7004bd96bbaa678b5486a62677e139216877deef, a paused workchain will hang if it is closed then played. This test violated that rule and also was faulty, in that it should test that the reloaded workchain can be played, not the original workchain. --- tests/engine/test_work_chain.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/engine/test_work_chain.py b/tests/engine/test_work_chain.py index 0ebf6048af..49166c9872 100644 --- a/tests/engine/test_work_chain.py +++ b/tests/engine/test_work_chain.py @@ -680,7 +680,8 @@ def do_run(self): run_and_check_success(MainWorkChain) def test_if_block_persistence(self): - """ + """Test a reloaded `If` conditional can be resumed. + This test was created to capture issue #902 """ runner = get_manager().get_runner() @@ -688,11 +689,13 @@ def test_if_block_persistence(self): runner.schedule(wc) async def run_async(workchain): + + # run the original workchain until paused await run_until_paused(workchain) self.assertTrue(workchain.ctx.s1) self.assertFalse(workchain.ctx.s2) - # Now bundle the thing + # Now bundle the workchain bundle = plumpy.Bundle(workchain) # Need to close the process before recreating a new instance workchain.close() @@ -702,13 +705,20 @@ async def run_async(workchain): self.assertTrue(workchain2.ctx.s1) self.assertFalse(workchain2.ctx.s2) + # check bundling again creates the same saved state bundle2 = plumpy.Bundle(workchain2) self.assertDictEqual(bundle, bundle2) - workchain.play() - await workchain.future() - self.assertTrue(workchain.ctx.s1) - self.assertTrue(workchain.ctx.s2) + # run the loaded workchain to completion + runner.schedule(workchain2) + workchain2.play() + await workchain2.future() + self.assertTrue(workchain2.ctx.s1) + self.assertTrue(workchain2.ctx.s2) + + # ensure the original paused workchain future is finalised + # to avoid warnings + workchain.future().set_result(None) runner.loop.run_until_complete(run_async(wc)) From f4a537d0eb4618e90d56ca8dc2ccecd74ef3958c Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 17 Feb 2021 16:14:35 +0100 Subject: [PATCH 080/114] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20Reduce=20tes?= =?UTF-8?q?t=20warnings=20(#4742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit reduces the number of pytest warnings of the test suite, from 719 to 122: - Replace `collections` with `collections.abc` - pytest-asyncio does not work with `unittest.TestCase` derived tests (https://github.com/pytest-dev/pytest-asyncio/issues/77). - `ProcessFuture` already closed via polling should not set a result via a broadcast event. - Upgrade kiwipy and plumpy to fix: - https://github.com/aiidateam/kiwipy/pull/98 - https://github.com/aiidateam/plumpy/pull/204 - https://github.com/aiidateam/plumpy/pull/206 --- aiida/cmdline/utils/common.py | 4 ++-- aiida/engine/processes/futures.py | 7 ++++++- aiida/engine/processes/ports.py | 4 ++-- aiida/engine/processes/process.py | 5 +++-- aiida/engine/processes/workchains/workchain.py | 6 +++--- aiida/manage/external/rmq.py | 4 ++-- aiida/orm/implementation/sqlalchemy/groups.py | 4 ++-- aiida/orm/nodes/data/array/trajectory.py | 4 ++-- environment.yml | 4 ++-- pyproject.toml | 2 ++ requirements/requirements-py-3.7.txt | 4 ++-- requirements/requirements-py-3.8.txt | 4 ++-- requirements/requirements-py-3.9.txt | 4 ++-- setup.json | 4 ++-- tests/engine/test_rmq.py | 4 ++++ tests/engine/test_utils.py | 14 +++++++------- 16 files changed, 45 insertions(+), 33 deletions(-) diff --git a/aiida/cmdline/utils/common.py b/aiida/cmdline/utils/common.py index 89287e047b..df37ffd49d 100644 --- a/aiida/cmdline/utils/common.py +++ b/aiida/cmdline/utils/common.py @@ -197,7 +197,7 @@ def format_nested_links(links, headers): :param headers: headers to use :return: nested formatted string """ - import collections + from collections.abc import Mapping import tabulate as tb tb.PRESERVE_WHITESPACE = True @@ -208,7 +208,7 @@ def format_recursive(links, depth=0): """Recursively format a dictionary of nodes into indented strings.""" rows = [] for label, value in links.items(): - if isinstance(value, collections.Mapping): + if isinstance(value, Mapping): rows.append([depth, label, '', '']) rows.extend(format_recursive(value, depth=depth + 1)) else: diff --git a/aiida/engine/processes/futures.py b/aiida/engine/processes/futures.py index 1c3d06b67b..dc110fbf5a 100644 --- a/aiida/engine/processes/futures.py +++ b/aiida/engine/processes/futures.py @@ -59,7 +59,12 @@ def __init__( # Try setting up a filtered broadcast subscriber if self._communicator is not None: - broadcast_filter = kiwipy.BroadcastFilter(lambda *args, **kwargs: self.set_result(node), sender=pk) + + def _subscriber(*args, **kwargs): # pylint: disable=unused-argument + if not self.done(): + self.set_result(node) + + broadcast_filter = kiwipy.BroadcastFilter(_subscriber, sender=pk) for state in [ProcessState.FINISHED, ProcessState.KILLED, ProcessState.EXCEPTED]: broadcast_filter.add_subject_filter(f'state_changed.*.{state.value}') self._broadcast_identifier = self._communicator.add_broadcast_subscriber(broadcast_filter) diff --git a/aiida/engine/processes/ports.py b/aiida/engine/processes/ports.py index b288747138..7a66de915e 100644 --- a/aiida/engine/processes/ports.py +++ b/aiida/engine/processes/ports.py @@ -8,7 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """AiiDA specific implementation of plumpy Ports and PortNamespaces for the ProcessSpec.""" -import collections +from collections.abc import Mapping import re from typing import Any, Callable, Dict, Optional, Sequence import warnings @@ -206,7 +206,7 @@ def serialize(self, mapping: Optional[Dict[str, Any]], breadcrumbs: Sequence[str breadcrumbs = (*breadcrumbs, self.name) - if not isinstance(mapping, collections.Mapping): + if not isinstance(mapping, Mapping): port_name = breadcrumbs_to_port(breadcrumbs) raise TypeError(f'port namespace `{port_name}` received `{type(mapping)}` instead of a dictionary') diff --git a/aiida/engine/processes/process.py b/aiida/engine/processes/process.py index bdf7c0850a..6b3c29780f 100644 --- a/aiida/engine/processes/process.py +++ b/aiida/engine/processes/process.py @@ -10,6 +10,7 @@ """The AiiDA process class""" import asyncio import collections +from collections.abc import Mapping import enum import inspect import logging @@ -755,7 +756,7 @@ def _flatten_inputs( if (port is None and isinstance(port_value, orm.Node)) or (isinstance(port, InputPort) and not port.non_db): return [(parent_name, port_value)] - if port is None and isinstance(port_value, collections.Mapping) or isinstance(port, PortNamespace): + if port is None and isinstance(port_value, Mapping) or isinstance(port, PortNamespace): items = [] for name, value in port_value.items(): @@ -796,7 +797,7 @@ def _flatten_outputs( if port is None and isinstance(port_value, orm.Node) or isinstance(port, OutputPort): return [(parent_name, port_value)] - if (port is None and isinstance(port_value, collections.Mapping) or isinstance(port, PortNamespace)): + if (port is None and isinstance(port_value, Mapping) or isinstance(port, PortNamespace)): items = [] for name, value in port_value.items(): diff --git a/aiida/engine/processes/workchains/workchain.py b/aiida/engine/processes/workchains/workchain.py index aa105b6fe1..72c673f0ce 100644 --- a/aiida/engine/processes/workchains/workchain.py +++ b/aiida/engine/processes/workchains/workchain.py @@ -8,7 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Components for the WorkChain concept of the workflow engine.""" -import collections +import collections.abc import functools import logging from typing import Any, List, Optional, Sequence, Union, TYPE_CHECKING @@ -239,10 +239,10 @@ def _store_nodes(self, data: Any) -> None: """ if isinstance(data, Node) and not data.is_stored: data.store() - elif isinstance(data, collections.Mapping): + elif isinstance(data, collections.abc.Mapping): for _, value in data.items(): self._store_nodes(value) - elif isinstance(data, collections.Sequence) and not isinstance(data, str): + elif isinstance(data, collections.abc.Sequence) and not isinstance(data, str): for value in data: self._store_nodes(value) diff --git a/aiida/manage/external/rmq.py b/aiida/manage/external/rmq.py index a62590b547..8bfaf100f0 100644 --- a/aiida/manage/external/rmq.py +++ b/aiida/manage/external/rmq.py @@ -9,7 +9,7 @@ ########################################################################### # pylint: disable=cyclic-import """Components to communicate tasks to RabbitMQ.""" -import collections +from collections.abc import Mapping import logging from kiwipy import communications, Future @@ -126,7 +126,7 @@ def _store_inputs(inputs): try: node.store() except AttributeError: - if isinstance(node, collections.Mapping): + if isinstance(node, Mapping): _store_inputs(node) diff --git a/aiida/orm/implementation/sqlalchemy/groups.py b/aiida/orm/implementation/sqlalchemy/groups.py index 8cc831b850..5284720f0d 100644 --- a/aiida/orm/implementation/sqlalchemy/groups.py +++ b/aiida/orm/implementation/sqlalchemy/groups.py @@ -9,7 +9,7 @@ ########################################################################### """SQLA groups""" -import collections +from collections.abc import Iterable import logging from aiida.backends import sqlalchemy as sa @@ -317,7 +317,7 @@ def query( if past_days is not None: filters.append(DbGroup.time >= past_days) if nodes: - if not isinstance(nodes, collections.Iterable): + if not isinstance(nodes, Iterable): nodes = [nodes] if not all(isinstance(n, (SqlaNode, DbNode)) for n in nodes): diff --git a/aiida/orm/nodes/data/array/trajectory.py b/aiida/orm/nodes/data/array/trajectory.py index ce43a4452e..5bc4d90e17 100644 --- a/aiida/orm/nodes/data/array/trajectory.py +++ b/aiida/orm/nodes/data/array/trajectory.py @@ -11,7 +11,7 @@ AiiDA class to deal with crystal structure trajectories. """ -import collections +import collections.abc from .array import ArrayData @@ -35,7 +35,7 @@ def _internal_validate(self, stepids, cells, symbols, positions, times, velociti """ import numpy - if not isinstance(symbols, collections.Iterable): + if not isinstance(symbols, collections.abc.Iterable): raise TypeError('TrajectoryData.symbols must be of type list') if any([not isinstance(i, str) for i in symbols]): raise TypeError('TrajectoryData.symbols must be a 1d list of strings') diff --git a/environment.yml b/environment.yml index eadec13b65..c4b0a30cc7 100644 --- a/environment.yml +++ b/environment.yml @@ -20,11 +20,11 @@ dependencies: - python-graphviz~=0.13 - ipython~=7.20 - jinja2~=2.10 -- kiwipy[rmq]~=0.7.1 +- kiwipy[rmq]~=0.7.2 - numpy~=1.17 - pamqp~=2.3 - paramiko>=2.7.2,~=2.7 -- plumpy~=0.18.4 +- plumpy~=0.18.5 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/pyproject.toml b/pyproject.toml index fdfc741a1a..fc69637151 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,8 @@ deps = py39: -rrequirements/requirements-py-3.9.txt [testenv:py{36,37,38,39}-{django,sqla}] +passenv = + PYTHONASYNCIODEBUG setenv = django: AIIDA_TEST_BACKEND = django sqla: AIIDA_TEST_BACKEND = sqlalchemy diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index ce2526ddfa..0cbb425b2e 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -57,7 +57,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.1 +kiwipy==0.7.2 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -88,7 +88,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.4 +plumpy==0.18.5 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 11f4d182d7..66d4262c6b 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -56,7 +56,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.1 +kiwipy==0.7.2 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -87,7 +87,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.4 +plumpy==0.18.5 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index e8020bf416..c30871a5f6 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -56,7 +56,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.1 +kiwipy==0.7.2 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -87,7 +87,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.4 +plumpy==0.18.5 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/setup.json b/setup.json index b249a2528a..dbe31e3525 100644 --- a/setup.json +++ b/setup.json @@ -34,11 +34,11 @@ "graphviz~=0.13", "ipython~=7.20", "jinja2~=2.10", - "kiwipy[rmq]~=0.7.1", + "kiwipy[rmq]~=0.7.2", "numpy~=1.17", "pamqp~=2.3", "paramiko~=2.7,>=2.7.2", - "plumpy~=0.18.4", + "plumpy~=0.18.5", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", diff --git a/tests/engine/test_rmq.py b/tests/engine/test_rmq.py index 7fc842b119..aa9f863220 100644 --- a/tests/engine/test_rmq.py +++ b/tests/engine/test_rmq.py @@ -36,6 +36,10 @@ def setUp(self): manager = get_manager() self.runner = manager.get_runner() + def tearDown(self): + self.runner.close() + super().tearDown() + def test_submit_simple(self): """"Launch the process.""" diff --git a/tests/engine/test_utils.py b/tests/engine/test_utils.py index 6bbec75d96..6b29e6321a 100644 --- a/tests/engine/test_utils.py +++ b/tests/engine/test_utils.py @@ -171,7 +171,7 @@ async def task(): @pytest.mark.requires_rmq -class TestInterruptableTask(AiidaTestCase): +class TestInterruptableTask(): """ Tests for InterruptableFuture and interruptable_task.""" @pytest.mark.asyncio @@ -189,9 +189,9 @@ async def coro(): task_fut = interruptable_task(task_fn) result = await task_fut - self.assertTrue(isinstance(task_fut, InterruptableFuture)) - self.assertTrue(task_fut.done()) - self.assertEqual(result, 'I am done') + assert isinstance(task_fut, InterruptableFuture) + assert task_fut.done() + assert result == 'I am done' @pytest.mark.asyncio async def test_interrupted(self): @@ -204,9 +204,9 @@ async def task_fn(cancellable): try: await task_fut except RuntimeError as err: - self.assertEqual(str(err), 'STOP') + assert str(err) == 'STOP' else: - self.fail('ExpectedException not raised') + raise AssertionError('ExpectedException not raised') @pytest.mark.asyncio async def test_future_already_set(self): @@ -225,4 +225,4 @@ async def coro(): task_fut = interruptable_task(task_fn) result = await task_fut - self.assertEqual(result, 'NOT ME!!!') + assert result == 'NOT ME!!!' From e794287218a5d3b85a85ba36eed6d6928a849a26 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 17 Feb 2021 16:29:45 +0100 Subject: [PATCH 081/114] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Add=20`BaseResta?= =?UTF-8?q?rtWorkchain`=20how-to=20(#4709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This section is adapted from: https://github.com/aiidateam/aiida-tutorials/blob/master/docs/pages/2020_Intro_Week/sections/workflows_adv.rst --- .../images/workflow_error_handling_basic.svg | 1558 +++++++++++++++++ .../workflow_error_handling_basic_failed.png | Bin 0 -> 28698 bytes .../workflow_error_handling_basic_success.png | Bin 0 -> 27721 bytes .../workflow_error_handling_flow_base.png | Bin 0 -> 30309 bytes .../workflow_error_handling_flow_base.svg | 349 ++++ .../workflow_error_handling_flow_loop.png | Bin 0 -> 169949 bytes docs/source/howto/index.rst | 1 + docs/source/howto/workchains_restart.rst | 402 +++++ docs/source/topics/processes/concepts.rst | 5 +- docs/source/topics/processes/functions.rst | 1 + docs/source/topics/processes/usage.rst | 12 +- docs/source/topics/workflows/usage.rst | 5 +- 12 files changed, 2330 insertions(+), 3 deletions(-) create mode 100644 docs/source/howto/include/images/workflow_error_handling_basic.svg create mode 100644 docs/source/howto/include/images/workflow_error_handling_basic_failed.png create mode 100644 docs/source/howto/include/images/workflow_error_handling_basic_success.png create mode 100644 docs/source/howto/include/images/workflow_error_handling_flow_base.png create mode 100644 docs/source/howto/include/images/workflow_error_handling_flow_base.svg create mode 100644 docs/source/howto/include/images/workflow_error_handling_flow_loop.png create mode 100644 docs/source/howto/workchains_restart.rst diff --git a/docs/source/howto/include/images/workflow_error_handling_basic.svg b/docs/source/howto/include/images/workflow_error_handling_basic.svg new file mode 100644 index 0000000000..13c3b7c833 --- /dev/null +++ b/docs/source/howto/include/images/workflow_error_handling_basic.svg @@ -0,0 +1,1558 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + W1 + PHONONS + + + + C1 pw.x + + + C2 ph.x + + + C3 q2r.x + + + C4 matdyn.x + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/howto/include/images/workflow_error_handling_basic_failed.png b/docs/source/howto/include/images/workflow_error_handling_basic_failed.png new file mode 100644 index 0000000000000000000000000000000000000000..13406b8fddba294604f30fc8fb54aff0254818cf GIT binary patch literal 28698 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⁢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

)B2L^MVV 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̢e7uG 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¬{RS(o)atyo>RK)Y*#@;5w1wWCU{-z~rLx8DcMPTjVb zv>)4K)S)fzJ_2_*Fqlje&aO9mN^W@oXBl$CgD`{_!~~Ej$xQwT=2;M0tFLEfoT8S! z2&bs)!Wf4(#txBOl8FI#M9cHT&A;|u=h)o*>?af4Xm+^yeylD=0GJj9!G0&nbLsUT zLc>?jphLRA$lqXq+v?}Rg|o*%zR9KGzG@n~{LeA=fsn1`kgcwn<7>=u@yv2s!+Io_ zv_TV^gd`NcEMsJX%F}al910xl1MfmM;eTFPxvaQ0p_3YJE&?hdX`Ie7P2V$gQ=d6V z6C}S@dOIB;%wwLTW+1k%pZI;t{;AEVfmb$uKo)};RJ#C7*ZElS=9nv|gy!5cka-y^2xkc&A>};h^a`#A==Dk#cN9Nl!=vRF} zjhj+HY-B#RZ`;K=Kpv*fJB@wG8G7mPB`S3)DDB3raa1}1`Jp2qZ!UroSoyl+R5BYRTV&{)a*V4K~)|tB0k+X`M zZ31*?1|8mTTBClV*w*xomKeJ=J3$lUY|>GN`I4OVrx_0G@xOcI_XPY>1r1^lcZE1; zZNK!=`uJCWgqPK^x#rKes=E0(IQ()%X{eByEZul5bv!b-Yp3V;Q#1E;Fb^^M*+E5f z9apN#4tAW~+=Z5dLuZe6eKyqVsh)bY;`7aY=?u5OnB5}|>(kiS?ol@@acs|?OH#2X z#a0*6w#RDxte!rOcahs{LnNmDC@{p=>`OYc*i*`}Ed@B|?SQzs>_<0GJd>KL9M2ay zTk<&OS+8!i*^%)8)J3#@67aIbvewU~SKm;KN7)!Zj@iuoQ4;$NMy~WaQ|YFF4dfkb zb7!G8KL&|X4bigqy-aBGNijGj!KJEOnvw*VnS)jrS$J~d%6B$zA%^3&vk*F}K4pxgw3m~V>#nLk81A+v~+lXT=C?uE2 z#oRs4ulErK+qzWVa*c_C>m1vBvi*0l${`h2Q4BJ9Bza3Y{MLmO*wi;I~mx<@B^Ww37&wv4u) zFi|lWvqe@!iB_!_X%q{sSYO`7YFiw{AQUII-zRs>-X4tMppskd>t!;M_v6 zz3_26${gg~$aQd<>+5w$DvyY3oBy(}wSygj>@T<0tLj5ixt1~sHZgp+q2of^yhxEt zpn=OxhA#h9UsZxUl&-q$ zf`ZV>I~MP6Hy#ICe~kY?lVEZ`@Yy80HWL>3w2@^`>>LL)re;tx>e_{9zc}IEHnq*S zYKd-`KE~CT2M3XA65yjVK*eA!On_luo%F9AlDXT)c7OHH)?;q(Ir{K#bPf0?G~t7n zHosZgaGxRZ=I1pYfnCB<%J#j67z?vXpr4Hcf8V4AE13qPXf6G zT%#B53Y@YNJE-Y{3Qyr`d*4s3^%hIF1j9#nczTfNJ#@FC@D(01UsJJVk@R?1?8z$) zwL$q)pdza66BXyjui8lS(Hz|-UMO~SbYO@AY~;*E@S)VD@BEYQLd!UrM-2tRE#;6;Yc0 zsv;l97gsllJ;Q{6;?ss#C$Ot>#Oj`(Q=l*A<@5!|S}CT!YB5xfNr5|@B^GH2iq2eP6P;Y09x~zmRHasm``ErHj^#IL3Ocd;{$WJwz67zf;$w90uVB-_ zGkQiPuOG_KOyYVV5=K51xRxakgRM3WL4j# zJ`LG7q{7#)j}@jr*Brk;B#;}L^o~{@k@EwY@Lec==fu%O1@F<4!2P_B$x%P~7<L>K7DFV!!)*;V)Wdz}J}|S$balr_>zyvlwI9Fz zqW*~oy?l@LDegnJH;WR+97r%`KlH}e9xMbKcD8Zq%iCU>aRr)6q?jW9Z9VA7zO{-F zwZn+bSW^n!p_QVQ>5X>K_B*1k?S8>f69$TWKH1PDF3cSJlbS{|AnL^G`xchj@piym zYVlb4{j}1J-VjlJ`^)9U^ix^nTK{9!nt@6Ff^m<)fKTSu*4vT@$)}_e^o{H_&Z~3m z_u>)J2MO=#k<@+hMvxA~qSB+rWd$wP9(4wF`;KfwbExXRN-Ih!IPhdu=Ha3n7!`7i z_H;koJ*+}+^1JswkJ?|6zv@sxln`7S*I5Ecl)`NbJwtBemyR=3M$ zbk7HYYRb6(fJi-S^A8>_t`x3MF{<-XTqdp#W?ynu+*uZmx2&?roOVFrMgNK&cQad= zT%KqI+29DE6>(68^uTB8ox!Ck`ns5&*za->y)V9+gv$%RVOkee57}BehfkZH^Xey8 zI#vtQ@sd*#`HQ11aYf(_RqKiLQKz3_HE`oPD}7xIKla*nyy~| z!cUEUPBClCZD9iW4e530$eh>lv&Kp%+m&m$>(jiijxpw2l&b*;1jsH?XVavd)C;C7 zs$y27l_c6(3AaD4uDY$SZxdZB<&Zt0j94>yMveJr#e&y%Je6q%`e!jj8GmU=6+V(z z)8$F{;mzoYW;c<0 z#o@G4`XZSUMdiQ~$K`n6mlOaP_J&wW>`fhGV<7_UR&ML{U1GU!S~{FsCwOq#?wtRN z`A8NaM$bcZ{1W_5WW`@MhGyXO3Wy-862AW-M1o1QiIUcip5NUrA#}BuoSU=Zx${hn zjK++s%m!zi7z`Z)CR>A9``vrR{30(8HKi!6$k$5!5cbwz^+y~PV^1HUXK-Q}s2-|~ zcCbLNeK*t<_$}p0F|o8%F~XXlPH2yQEZ@FX8Q>c?`|2oPHZOVOg}5JAGkbcCO0QH* zU3`N7*sIGYeAZ$2ATJ^FA99O|yk3v_uN+={c>-}_oMTl@Ug0B}L0!=^`6G{Vm7uyT z2Ufo{<4!dZ@6Q{Rc3)!NKqSH~*YpkT4mcn5kThmI;Oj~zpdJ|B9ql!rJfqrudHN-l z+V;jf;=)i-A@HP^y$GI7>+Q(0%@TjTbM1s(_fsT-Y(k z@-$a+)SQp|p`<*OhUJ?s+~KWg1sHrQ{q^gJ>d5RtXBU?RM45HyC%9B<^uqzNS-wtL z{#k!XMhaD4zVv?E#IZ+h}u|+SwF+wTSOp z)_xQMXy;yC6rco{cQmc*#LtS+I+a%25o0QVv{)f6NHD`CVPGT zCBOhw>J|qHOd;CGJO=@eIK?EfR0<+pbK|8+sJW~mb&xrHhSSjFSG>f}hZiLdJh|}d z2O_^af%$MU#2>{utK6@RtG2d`sC^1n#?J%Z7OMB_V~fCDP>&ejyEK$a zf9R-iE2tK(g_dhGAP@x^2$e8*!qAqlET3if?{Wg&kB*KGBC(TbHcfW7;lTZzTi83? ze4M-=@&)sRW#qV{^Co_|VS@DFy?AmaH0=dX+;Tp}ZGLrJA3ZG@F|+qN4XhyLOdLfl zkY|t;hx_j;!B%QvMnyO)cwpGB!4I{xV2(Qhz14+MKY#u!8${FeqiKjMhd>6g`YTgD z(WnL0HgdWd}hC)Nzn(Ep0B04l)l_#x6M1VF10?)P9|B@f+YVLu@%8o;2=Tf+@~1x+&)0iPgS zjJ03m7~g$~{qdjcH!0MQw~`BBiehMte;A_7WFv#evDIdi;mq5%8gfVb{*$gmfIhkT zpZZzAl@>N29dlQL*wZMcSF|m|0$FB{K@W*56z;Pk@0$o_ovm_Mg-a}u!JN;}U2fny^m zR87wcjpqV+dzlkZS)rEBy0VwMOI6S{!w5V5oD9Al(izJOD}{SGwQxwZPNrjYeGqa1 z=~IFXiV4s$leo#&m~a^wGdtzxKz`^ntsqy=F&DnTVE7CVn=ltRWpaLWlLN99d_u~e)WbnnP&4V?&p$)aAlyaPc5k!E}JdJ#3kSc!p z^5sq$jmoB-sDwTiNEa1BBzC#{Ba;yd`hK1VYd6+Z@y-a<>+8{HF`~3D4O_koqbDO? z4D_C*&|24iqyG*xx&FNJ>|sO%waHdpi49>FJv8@(sZta3^3JowH zUKbUEn!5y4a1SjQvWa#m4T6;@>!12CL+IR{JzV`a)G5!oDYGx11*qT5(k_T z9c;3W^OEWDKvMEW%pbzXA=e;E&vC%F%hH^sUos;Ube6|a7Q0>?sL6$m{|Sgj*rz`7 z@+JQh9c_ueh501SYKNoYt$lG|QOq5dm;9agt#W1VD1xn6dEhkIQ@V8$SrPXBysZMG zp71QhATUrZ45J~#(vNB=bPLR|PG)KzGunuaMR#{|p*AoABCS2LcOnUI-lJ zdjVq8vwDfLZ!|EYbkCKqee31G!isHoRXtfgJcXyQdF3!3@I?6UMH1 z0<1eDwfaiaB`bhd94g+NKSxS~-G}Ak#fu@6{#GDQb9`4?S_L%3FpvVPknMSXPol8(;M^;-Kdzv@U|Y)=x3BFf$9# z4=vXX5DzFe@`d0B(|t>UD@pJQn#vw1~FVL zfMB#~#G^R!K&!VD25>hhdyXfEP}f9Qy0s^m9hy@up(xs*PCm#VY*k_H4+b*CX}QF? zJz^|BaBHb+9(YLGk@|SLL)`aunnf?K5SX~ImdO`hjS2;?{`~SyvN{F=GE3Z){#wo@*G4#gK=0D3Ejt0-AP*E5;RHEwy18*7%$IJ87 zgoV$DanblR7Z?LH!h6Q0YjfI;n%RlG}l&Ur6hhi*e_o)+@ zjw)#S?{rAT7yy-O1vQ7-A9B8_g~jG(+pCLWd-~=0?(mWs&%zF8WYLwkKOVH4H${Cy z2oXg)IBY+8$rzpRhIl4+_&roqT(lHmNX(&>?ngA!TevjzDtGLN4ejQS>~>=I3{&A; z`}_XZa6Ss@^2PadR+ZB!wWX*Atrr5tv8K@}G7(CG=6^yHm$8>N-d8Owl;P-qk^XJ5 zLhjMmBa*6D2)^ zO@Xka^+N=jb&5bZVE{BP8gGv3GXv9@`eon1#pIbm)XVal+9e_EbbGsRN{5}JN38rW zR=TxNH(?(7*Yj6q!U=sen_S6F28cJepnzkI06mOuBYYnQL{e#wC$ za!DNhK&$j2jcN(KV>JA23P;!J0NWorJ2_oT=$+qwC#VG8{z^cL@F9>#W?&6$Pq_vy zKN5}pt*|;2_6i6XWdxE%7@j`a@zpjMSy=%pU@9!*3RxrDK=|#1k1RmRSJHTn>&TAV zH1)p&`V495?G0F}Tp1`AC(!&4!om%YF1)&gL?fp_HY^}${BLU5Z|9pgFU1KcEgwUq z^t?@IcpKRI`~j0nV)E2$`OhD3QqE$1*?~~oiXEO}&)9K7?hFF3OGd27rFLb0WXEeM z2OEz8f}bSk{pT6{o)@|K}K$ak2f^hfpOR^GlWc;M``xBa~U%^s;nZzGTJM$8ApeFlvloKcwACFR+_Qw!OKnbZv_OOlHf+5U0b~3e)*Hp>IO+?4j8x3F0~yt#A;5d<{06xE7!bpF^C+NQnXH z9aK#Lh`c?Jum7!w13nCvX=pTgx{FZ%6_L8xEyb*Ud*40l=z76sLoPI77tlzinEbf9 z;gE)Yciu10uuIC@lPpNdL2`s`GRIJLFJ*Lg3DVcuOd`qr=~8=8ZhLCp+$?jWe#NjDzb?*e5$_~K^=RQ!ecAEOj911hu-9s|ScKc^aU1>?l9Zq#U+zKgZV|f@3 zkPaD;Fy8w7`Lp$v&RNu6WH1IJ90#tlPOulq7%%YvvB0bnq_z|bl~rlkGjM0p3P*T? z^i9xJG}C#$ujZwf;;uQ0yuNz%sy^t^&AUFBTQf>&V5_C!rLAcJKK(&G1%p*zigKk9 zst}Sr@eK7>-serog3$G9`Ed}N3r}#wzJox53f~l!+P@1^JhVWEYv{61)>ZuKy6NQ% zHIAqlNl{TUcTHD7_h8`9ND2I-fNS*7b(z2u>Fo0*2Hb)GsYzB+K3?Td z?j+e`T2=rKe0)qG43hL89+vt2eWN^X+PYwLV)6UCnwjYRh4-)|;x7<(@KMbzJss}C3zthe)+XFW2B zzyr1YAs<~jx_x-LQ-hX0*owdPFWWm)SMG9LI4u=U)ld*h_)z_?BkD2<6@w7hjY?*+ zvA`{>0GbsK9t%|+Ock9?Rdp}GB%@6Oy7WfSPtoFy_8`0<_6|_Hw?cWa$C6Uo2(FUogcUg_l^9N8aaC9>TLlXsNs$7qi+=d-WE8(bBE<;!jV%4Cln(J8r?M z${Z0eXc&a6DL>WEN&+EVntN8`VdVAdhTrk{a}_OCAz8H3{>l{@mFlaooR93^Bhd0k zjzQhuf6Wf|RA7w#+GROrvw`?4fV3!+a-wcW5?H8j2n$2wp>|ep@kbvpu$J^donL?~ zTx}${-%uP|c@;xGgZez)nwbQ@e$JBf81Nnd>%9wnnL}JLd-|k$T@IMSi)CV%moRG+ zD|`)re=sUO`n>t$BdoZndd=p=d?x(m6SB-T4Snm!L6-Rev^uv|2Lo%R+Q7H3JJOs1 z4>SQx5N;-pO}a{O8p==%YNgJ?B@El^4$X`DlT*cXHL&`=A$Oo|=u&Pr?>OH++47ZD z5fh}28EH%YJqTZBT3XK<_QOoVoe$O(tSR>(VeJlF?*i;|dEC}HdcU^Cib;6nLOpdW zZOA-GVaB0)D5ZnL7C630-LZTZ6O;L+Rb zn=0tF!C2MeG5?;NR1Xkm6lYFNr`*g`9fGa2=EWrY@0NbB!9?Ktb1wpf+cjKk&P#r% zB0BJA8}~BA+V({{u=&f1vo}nC+(J?{M!6$P2;UbA*1zL!0JHA)VC!%L3Ff#ZA~@p| z2uG2Jqi^JNP-4hmQyQ=0BPIL%+>rrI2c4YCzau9~ADvfNSlJMj;q-0UTWcP%Y;gPB zsv6`qy~cHR7v)NAE|wrOi`IxIZ6ArMY^grmo@JocGHAV=`q2ZnSN+J|C_)7Bf1|{- z*3OYW73KQP?7;@9CR_@xDrYo)Hy{;LLQHk!{lbPQR-R)C9GED)guOp?N>SKL0q^01 z8{Ehs5=Bd3HkNCTIE8f1f!_8PrR8GM`50hSIpgZA`v#%x-Dm(C3_4!KqHAj&Dvw`~M!YxP8&<0& zK&=$OT|N>6*zj@y{dRfN8(^zJ=d}R>GnN_*auM9^y`|IMsANguW(b8;Yf6&yxI5;* zokpC=h8sklRu7sZ`{`HHA!$%2vsE&xo&kaTH2h`w?x#+4b;Bz?+=1ks69_Nz(*x=h_cA1ila96+7UIC`$X1ealK1YCa?x0W3^UTVy4wurl%OLiIuaXy{qoc=A z-NPr0FshIbN1bMbY$}ZQ@vSoE8V%}oGpq+(T2nxI2*fj`$&G#6&GZM!%Orau(Ozcl zbL_Hi2Pe?|Rb&1F_t`ZtSJy~Ohxpkg)kk?w;J<&8Ngb=qA#Ov{6r#Fa37cM4d474F zFJFW$z+DvB=)Y#A1(R#fPr>t_7xp@fk9$Le0?`0*?cJz71KPZ0{rwT^Vm1VcSPH1W4-< z{Nh&o4FkRiv|(oss*l6w{E7X1EJH4cI2;XwLeOuV3EsZVuyo&MoarTSJJX=<^+vDh zD?iG!q1(OaX&!8)2Btx&7h=D3R;!Y)I5;M>e25S*>9hJn2XFT=x6f{E*Hs1WI~SOeq6=EJt|z(U(7vA z>6e`@0s&R}MUaOls@}kONvP*W^Wuc~H_;Kqsw2^5 znLBL^v4|f$tGkC5v0@H$tmn3yMlR7>yU9>P7k=bWU4YAY6wQQr`8LN4v+*ab-eRcx zi)`a4d#`HTcY$5H^d}glJ}*`%A^cq$8(s1K4Au(xxYYt{@GaC8=@w9%`-6nN$lIx4 za(zz>K=gHw%}Jh;kWlqm82Oro^VIlt%>G!FLW75z8HNgAjYyBkZTr!PCZ?Hc%!pz0 z;{F;2bdPJ%=m$VE)kY+M7>SHO-5ND4pY(v!bDA-ASibsC?w5t%-wvWI9<8L#j#iG{ zMo42fL-yBXDx9H)H-*g3kl?{l)kc2Jlvq{=2N?yrfg`j>K%jFj@?J0HN2lr;!MN?i z9{tC*J5O#)&Qwl7+Aj1Nas?10mfHPnWiKNP6mhfcvCqcpMTDVw?6RCg@1<$GT z$5Z~skEI5>7w9fU)zbtSYO5g(YDa~iAQ-FxYEBrL>ML7(m|PPC8#x*_K{`C7EX=2l zsjv@aBKjwRJ6FaJj?6OGbio-#|MUi%?x>Rs9sWA@-Gc)k?-5fIf;^!N$by>{-fhjR zy7iXOv)0O|D-F`~J&#xjB4cE0*4j@xjJe$&g#2+!%My=9_FHs#;wZbeRr2Ceb8#q( zy~I<7xkv21rKB{D2aCGiGr^dbZUay(YzzrYi)di45rE(rVv(}`P%u%kIqh& z0Sz}k23kz})@LWg&dVxAg<~BCKXpOU;xDp!6Ocp}CFgBxcxaK|hh^#zzYYk7Ud@Uw z>dnvKj<0k$v?pA1cJ$qo&9g{wJ`u!vv+)XDn`0nrrG@M%nWS7g_KY~ORIe<&x1ROB z!M^wczf{s$e)h36mkbRW`muGFV8Qr8acrqaKT+;i?Q!1&NYmO-7>8mK(T7F#j{0sS z`Ff%Q)zF9HrWj#6<@*k{@W|13{ecr@-&MWnH#nh2i~Rkr6?S<`Bogl4K~A9_1dRw` zM!dn>OB{~Vq(1=g#5yB-?V@nJr5f!Jzu1161%T=A1@R<_gzA2hRQ$OZ`UjOHpO`U!k_i?&?dxhK9PcNY zaB1Kv#Jebgtj`PWT%K3ISm7)X#Ld?Y0X?#Z+`0=7{}vK(EVx=F&(EIL))NTep%)^y z`MDP4y^GENz!Ohe1DNhdVG=DC?~S)Tvh9U}77p#2)R z{>h4X^4EP%<;eG-W4l0WS9yTh=>(-)V9=Z5l~vN8EoH~f74q90p^rj*-0_krK5gfP z=3Vpl#$$t%q(SY%#U7bfbZT-p+Mhik@*z@_2p232mf?CTRhv1AtaBzvMY`p}GP`SL zkqX$H6~7hDygMZZx!cQ0q=3{qM@xMM0U%P>cnMq`>3}zG(m@k0R)6XJ zY1Ra>*W2{!-5w-!Y{4~>yQLjTRe?>u;szvv{kc*r)tmIse%glrWvWZqE1hr;IrQYh z4ap2m(C1ni8X9u#31_E+i%RhxbwJe_(Ectp)(oW7Ew|tBfkHYXad6 zRs}@35C!+xmiL#xRpunwH4R|)O2KVs-Y+=Jh$FRX<$0Y2RuX!)@xEYa=7fL}?ymrt z;sB`j-w-1YFYYgtGhUjvm+4749@>h0ZaJE97yM+?{T!5h(BYJ3x=pLh(SrX`REG8G zsTJ{6Z$(lToWO3#H*!zGy8il-qKwhy8C#(B8WF)i`aygaJNGDb=)$zYVE#o1|G)i% zW+nC-hFt|}g#!~EIUonngFYjJ8mXJ{Kc3=U{-o2;Kh`l`i*rhom6Ib?%zb15zAldLXR{Dj$KLHV5y<5G gfBThTwYW{s;GeRXTbuL-u7pL-YwKh3HEr(xFF~w2xc~qF literal 0 HcmV?d00001 diff --git a/docs/source/howto/include/images/workflow_error_handling_flow_base.svg b/docs/source/howto/include/images/workflow_error_handling_flow_base.svg new file mode 100644 index 0000000000..9293811e6d --- /dev/null +++ b/docs/source/howto/include/images/workflow_error_handling_flow_base.svg @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + SUCCESS? + NO + + + YES + + + + FINALIZE + + + HANDLEERRORS + + LAUNCHPROCESS + START + + diff --git a/docs/source/howto/include/images/workflow_error_handling_flow_loop.png b/docs/source/howto/include/images/workflow_error_handling_flow_loop.png new file mode 100644 index 0000000000000000000000000000000000000000..22934a6bd67aaa0632b6d80abae5274b1c49e287 GIT binary patch literal 169949 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 Mn8?BZ`DcfCW+#Fi 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 PliWHmmX_b3(#Gx@s^SzQ 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 we discussed how to write a simple multi-step workflow using work chains. +However, there is one thing that we did not consider there: + + What if a calculation step fails? + +For example with the :py:class:`~aiida.workflows.arithmetic.multiply_add.MultiplyAddWorkChain`; it launches a :py:class:`~aiida.calculations.arithmetic.add.ArithmeticAddCalculation`. +If that were to fail, the work chain would except because the line ``self.ctx.addition.outputs.sum`` will raise an ``AttributeError``. +In this case, where the work chain just runs a single calculation, that is not such a big deal but for real-life work chains that run a number of calculations in sequence, having the work chain except will cause all the work up to that point to be lost. +Take as an example a workflow that computes the phonons of a crystal structure using Quantum ESPRESSO: + +.. figure:: include/images/workflow_error_handling_basic_success.png + + Schematic diagram of a workflow that computes the phonons of a crystal structure using Quantum ESPRESSO. + The workflow consists of four consecutive calculations using the ``pw.x``, ``ph.x``, ``q2r.x`` and ``matdyn.x`` code, respectively. + +If all calculations run without problems, the workflow itself will of course also run fine and produce the desired final result. +But, now imagine the third calculation actually fails. +If the workflow does not explicitly check for this failure, but instead blindly assumes that the calculation have produced the required results, it will fail itself, losing the progress it made with the first two calculations. + +.. figure:: include/images/workflow_error_handling_basic_failed.png + + Example execution of the Quantum ESPRESSO phonon workflow where the third step, the ``q2r.x`` code, failed, and because the workflow blindly assumed it would have finished without errors also fails. + +The solution seems simple then. +After each calculation, we simply add a check to verify that it finished successfully and produced the required outputs before continuing with the next calculation. +What do we do, though, when the calculation failed? +Depending on the cause of the failure, we might actually be able to fix the problem, and re-run the calculation, potentially with corrected inputs. +A common example is that the calculation ran out of wall time (requested time from the job scheduler) and was cancelled by the job scheduler. +In this case, simply restarting the calculation (if the code supports restarts), and optionally giving the job more wall time or resources, may fix the problem. + +You might be tempted to add this error handling directly into the workflow. +However, this requires implementing the same error-handling code many times in other workflows that just happen to run the same codes. +For example, we could add the error handling for the ``pw.x`` code directly in our phonon workflow, but a structure optimization workflow will also have to run ``pw.x`` and will have to implement the same error-handling logic. +Is there a way that we can implement this once and easily reuse it in various workflows? + +Yes! Instead of directly running a calculation in a workflow, one should rather run a work chain that is explicitly designed to run the calculation to completion. +This *base* work chain knows about the various failure modes of the calculation and can try to fix the problem and restart the calculation whenever it fails, until it finishes successfully. +This logic of such a base work chain is very generic and can be applied to any calculation, and actually any process: + +.. figure:: include/images/workflow_error_handling_flow_base.png + :align: center + :height: 500px + + Schematic flow diagram of the logic of a *base* work chain, whose job it is to run a subprocess repeatedly, fixing any potential errors, until it finishes successfully. + +The work chain runs the subprocess. +Once it has finished, it then inspects the status. +If the subprocess finished successfully, the work chain returns the results and its job is done. +If, instead, the subprocess failed, the work chain should inspect the cause of failure, and attempt to fix the problem and restart the subprocess. +This cycle is repeated until the subprocess finishes successfully. +Of course this runs the risk of entering into an infinite loop if the work chain never manages to fix the problem, so we want to build in a limit to the maximum number of calculations that can be re-run: + +.. _workflow-error-handling-flow-loop: +.. figure:: include/images/workflow_error_handling_flow_loop.png + :align: center + :height: 500px + + An improved flow diagram for the base work chain that limits the maximum number of iterations that the work chain can try and get the calculation to finish successfully. + +Since this is such a common logical flow for a base work chain that is to wrap another :py:class:`~aiida.engine.processes.process.Process` and restart it until it is finished successfully, we have implemented it as an abstract base class in ``aiida-core``. +The :py:class:`~aiida.engine.processes.workchains.restart.BaseRestartWorkChain` implements the logic of the flow diagram shown above. +Although the ``BaseRestartWorkChain`` is a subclass of :py:class:`~aiida.engine.processes.workchains.workchain.WorkChain` itself, you cannot launch it. +The reason is that it is completely general and so does not know which :py:class:`~aiida.engine.processes.process.Process` class it should run. +Instead, to make use of the base restart work chain, you should subclass it for the process class that you want to wrap. + + +Writing a base restart work chain +================================= + +In this how-to, we will show how to implement the ``BaseRestartWorkChain`` for the :py:class:`~aiida.calculations.arithmetic.add.ArithmeticAddCalculation`. +We start by importing the relevant base classes and create a subclass: + +.. code-block:: python + + from aiida.engine import BaseRestartWorkChain + from aiida.plugins import CalculationFactory + + ArithmeticAddCalculation = CalculationFactory('arithmetic.add') + + class ArithmeticAddBaseWorkChain(BaseRestartWorkChain): + + _process_class = ArithmeticAddCalculation + + +As you can see, all we had to do is create a subclass of the ``BaseRestartWorkChain`` class, which we called ``ArithmeticAddBaseWorkChain``, and set the ``_process_class`` class attribute to ``ArithmeticAddCalculation``. +The latter instructs the work chain what type of process it should launch. +Next, as with all work chains, we should *define* its process specification: + +.. code-block:: python + + from aiida import orm + from aiida.engine import while_ + + @classmethod + def define(cls, spec): + """Define the process specification.""" + super().define(spec) + spec.input('x', valid_type=(orm.Int, orm.Float), help='The left operand.') + spec.input('y', valid_type=(orm.Int, orm.Float), help='The right operand.') + spec.input('code', valid_type=orm.Code, help='The code to use to perform the summation.') + spec.output('sum', valid_type=(orm.Int, orm.Float), help='The sum of the left and right operand.') + spec.outline( + cls.setup, + while_(cls.should_run_process)( + cls.run_process, + cls.inspect_process, + ), + cls.results, + ) + +The inputs and output that we define are essentially determined by the sub process that the work chain will be running. +Since the ``ArithmeticAddCalculation`` requires the inputs ``x`` and ``y``, and produces the ``sum`` as output, we `mirror` those in the specification of the work chain, otherwise we wouldn't be able to pass the necessary inputs. +Finally, we define the logical outline, which if you look closely, resembles the logical flow chart presented in :numref:`workflow-error-handling-flow-loop` a lot. +We start by *setting up* the work chain and then enter a loop: *while* the subprocess has not yet finished successfully *and* we haven't exceeded the maximum number of iterations, we *run* another instance of the process and then *inspect* the results. +The while conditions are implemented in the ``should_run_process`` outline step. +When the process finishes successfully or we have to abandon, we report the *results*. +Now unlike with normal work chain implementations, we *do not* have to implement these outline steps ourselves. +They have already been implemented by the ``BaseRestartWorkChain`` so that we don't have to. +This is why the base restart work chain is so useful, as it saves us from writing and repeating a lot of `boilerplate code `__. + +.. warning:: + + This minimal outline definition is required for the work chain to work properly. + If you change the logic, the names of the steps or omit some steps, the work chain will not run. + Adding extra outline steps to add custom functionality, however, is fine and actually encouraged if it makes sense. + +The last part of the puzzle is to define in the setup what inputs the work chain should pass to the subprocess. +You might wonder why this is necessary, because we already define the inputs in the specification, but those are not the only inputs that will be passed. +The ``BaseRestartWorkChain`` also defines some inputs of its own, such as ``max_iterations`` as you can see in its :py:meth:`~aiida.engine.processes.workchains.restart.BaseRestartWorkChain.define` method. +To make it absolutely clear what inputs are intended for the subprocess, we define them as a dictionary in the context under the key ``inputs``. +One way of doing this is to reuse the :py:meth:`~aiida.engine.processes.workchains.restart.BaseRestartWorkChain.setup` method: + +.. code-block:: python + + def setup(self): + """Call the `setup` of the `BaseRestartWorkChain` and then create the inputs dictionary in `self.ctx.inputs`. + + This `self.ctx.inputs` dictionary will be used by the `BaseRestartWorkChain` to submit the process in the + internal loop. + """ + super().setup() + self.ctx.inputs = {'x': self.inputs.x, 'y': self.inputs.y, 'code': self.inputs.code} + +Note that, as explained before, the ``setup`` step forms a crucial part of the logical outline of any base restart work chain. +Omitting it from the outline will break the work chain, but so will overriding it completely, except as long as we call the ``super``. + +This is all the code we have to write to have a functional work chain. +We can now launch it like any other work chain and the ``BaseRestartWorkChain`` will work its magic: + +.. code-block:: python + + submit(ArithmeticAddBaseWorkChain, x=Int(3), y=Int(4), code=load_code('add@tutor')) + +Once the work chain finished, we can inspect what has happened with, for example, ``verdi process status``: + +.. code-block:: console + + $ verdi process status 1909 + ArithmeticAddBaseWorkChain<1909> Finished [0] [2:results] + └── ArithmeticAddCalculation<1910> Finished [0] + +As you can see the work chain launched a single instance of the ``ArithmeticAddCalculation`` which finished successfully, so the job of the work chain was done as well. + +.. note:: + + If the work chain excepted, make sure the directory containing the WorkChain definition is in the ``PYTHONPATH``. + + You can add the folder in which you have your Python file defining the WorkChain to the ``PYTHONPATH`` through: + + .. code-block:: bash + + $ export PYTHONPATH=/path/to/workchain/directory/:$PYTHONPATH + + After this, it is **very important** to restart the daemon: + + .. code-block:: bash + + $ verdi daemon restart --reset + + Indeed, when updating an existing work chain file or adding a new one, it is **necessary** to restart the daemon **every time** after all changes have taken place. + +Exposing inputs and outputs +=========================== + +Any base restart work chain *needs* to *expose* the inputs of the subprocess it wraps, and most likely *wants* to do the same for the outputs it produces, although the latter is not necessary. +For the simple example presented in the previous section, simply copy-pasting the input and output port definitions of the subprocess ``ArithmeticAddCalculation`` was not too troublesome. +However, this quickly becomes tedious, and more importantly, error-prone once you start to wrap processes with quite a few more inputs. +To prevent the copy-pasting of input and output specifications, the :class:`~aiida.engine.processes.process_spec.ProcessSpec` class provides the :meth:`~plumpy.ProcessSpec.expose_inputs` and :meth:`~plumpy.ProcessSpec.expose_outputs` methods: + +.. code-block:: python + + @classmethod + def define(cls, spec): + """Define the process specification.""" + super().define(spec) + spec.expose_inputs(ArithmeticAddCalculation, namespace='add') + spec.expose_outputs(ArithmeticAddCalculation) + ... + +.. seealso:: + + For more detail on exposing inputs and outputs, see the basic :ref:`Workchain usage section `. + +That takes care of exposing the port specification of the wrapped process class in a very efficient way. +To efficiently retrieve the inputs that have been passed to the process, one can use the :meth:`~aiida.engine.processes.process.Process.exposed_inputs` method. +Note the past tense of the method name. +The method takes a process class and an optional namespace as arguments, and will return the inputs that have been passed into that namespace when it was launched. +This utility now allows us to simplify the ``setup`` outline step that we have shown before: + +.. code-block:: python + + def setup(self): + """Call the `setup` of the `BaseRestartWorkChain` and then create the inputs dictionary in `self.ctx.inputs`. + + This `self.ctx.inputs` dictionary will be used by the `BaseRestartWorkChain` to submit the process in the + internal loop. + """ + super().setup() + self.ctx.inputs = self.exposed_inputs(ArithmeticAddCalculation, 'add') + +This way we don't have to manually fish out all the individual inputs from the ``self.inputs`` but have to just call this single method, saving a lot of time and lines of code. + +When submitting or running the work chain using namespaced inputs (``add`` in the example above), it is important to use the namespace: + +.. code-block:: python + + inputs = { + 'add': { + 'x': Int(3), + 'y': Int(4), + 'code': load_code('add@tutor') + } + } + submit(ArithmeticAddBaseWorkChain, **inputs) + +.. important:: + + Every time you make changes to the ``ArithmeticAddBaseWorkChain``, don't forget to restart the daemon with: + + .. code-block:: bash + + $ verdi daemon restart --reset + +Error handling +============== + +So far you have seen how easy it is to get a work chain up and running that will run a subprocess using the ``BaseRestartWorkChain``. +However, the whole point of this exercise, as described in the introduction, was for the work chain to be able to deal with *failing* processes, yet in the previous example it finished without any problems. + + What would have happened if the subprocess had failed? + +If the computed sum of the inputs ``x`` and ``y`` is negative, the ``ArithmeticAddCalculation`` fails with exit code ``410`` which corresponds to ``ERROR_NEGATIVE_NUMBER``. + +.. seealso:: + + The :ref:`exit code usage section`, for a more detailed explanation of exit codes. + +Let's launch the work chain with inputs that will cause the calculation to fail, e.g. by making one of the operands negative, and see what happens: + +.. code-block:: python + + submit(ArithmeticAddBaseWorkChain, add={'x': Int(3), 'y': Int(-4), 'code': load_code('add@tutor')}) + +This time we will see that the work chain takes quite a different path: + +.. code-block:: console + + $ verdi process status 1930 + ArithmeticAddBaseWorkChain<1930> Finished [402] [1:while_(should_run_process)(1:inspect_process)] + ├── ArithmeticAddCalculation<1931> Finished [410] + └── ArithmeticAddCalculation<1934> Finished [410] + +As expected, the ``ArithmeticAddCalculation`` failed this time with a ``410``. +The work chain noticed the failure when inspecting the result of the subprocess in ``inspect_process``, and in keeping with its name and design, restarted the calculation. +However, since the inputs were not changed, the calculation inevitably and wholly expectedly failed once more with the exact same error code. +Unlike after the first iteration, however, the work chain did not restart again, but gave up and returned the exit code ``402`` itself, which stands for ``ERROR_SECOND_CONSECUTIVE_UNHANDLED_FAILURE``. +As the name suggests, the work chain tried to run the subprocess but it failed twice in a row without the problem being *handled*. +The obvious question now of course is: "How exactly can we instruct the base work chain to handle certain problems?" + +Since the problems are necessarily dependent on the subprocess that the work chain will run, it cannot be implemented by the ``BaseRestartWorkChain`` class itself, but rather will have to be implemented by the subclass. +If the subprocess fails, the ``BaseRestartWorkChain`` calls a set of *process handlers* in the ``inspect_process`` step. +Each process handler gets passed the node of the subprocess that was just run, such that it can inspect the results and potentially fix any problems that it finds. +To "register" a process handler for a base restart work chain implementation, you simply define a method that takes a node as its single argument and decorate it with the :func:`~aiida.engine.processes.workchains.utils.process_handler` decorator: + +.. code-block:: python + + from aiida.engine import process_handler, ProcessHandlerReport + + class ArithmeticAddBaseWorkChain(BaseRestartWorkChain): + + _process_class = ArithmeticAddCalculation + + ... + + @process_handler + def handle_negative_sum(self, node): + """Check if the calculation failed with `ERROR_NEGATIVE_NUMBER`. + + If this is the case, simply make the inputs positive by taking the absolute value. + + :param node: the node of the subprocess that was ran in the current iteration. + :return: optional :class:`~aiida.engine.processes.workchains.utils.ProcessHandlerReport` instance to signal + that a problem was detected and potentially handled. + """ + if node.exit_status == ArithmeticAddCalculation.exit_codes.ERROR_NEGATIVE_NUMBER.status: + self.ctx.inputs['x'] = orm.Int(abs(node.inputs.x.value)) + self.ctx.inputs['y'] = orm.Int(abs(node.inputs.y.value)) + return ProcessHandlerReport() + +The method name can be anything as long as it is a valid Python method name and does not overlap with one of the base work chain's methods. +For better readability, it is, however, recommended to have the method name start with ``handle_``. +In this example, we want to specifically check for a particular failure mode of the ``ArithmeticAddCalculation``, so we compare the :meth:`~aiida.orm.nodes.process.process.ProcessNode.exit_status` of the node with that of the spec of the process. +If the exit code matches, we know that the problem was due to the sum being negative. +Fixing this fictitious problem for this example is as simple as making sure that the inputs are all positive, which we can do by taking the absolute value of them. +We assign the new values to the ``self.ctx.inputs`` just as where we defined the original inputs in the ``setup`` step. +Finally, to indicate that we have handled the problem, we return an instance of :class:`~aiida.engine.processes.workchains.utils.ProcessHandlerReport`. +This will instruct the work chain to restart the subprocess, taking the updated inputs from the context. +With this simple addition, we can now launch the work chain again: + +.. code-block:: console + + $ verdi process status 1941 + ArithmeticAddBaseWorkChain<1941> Finished [0] [2:results] + ├── ArithmeticAddCalculation<1942> Finished [410] + └── ArithmeticAddCalculation<1947> Finished [0] + +This time around, although the first subprocess fails again with a ``410``, the new process handler is called. +It "fixes" the inputs, and when the work chain restarts the subprocess with the new inputs it finishes successfully. +With this simple process you can add as many process handlers as you would like to deal with any potential problem that might occur for the specific subprocess type of the work chain implementation. +To make the code even more readable, the :func:`~aiida.engine.processes.workchains.utils.process_handler` decorator comes with various syntactic sugar. +Instead of having a conditional at the start of each handler to compare the exit status of the node to a particular exit code of the subprocess, you can define it through the ``exit_codes`` keyword argument of the decorator: + +.. code-block:: python + + @process_handler(exit_codes=ArithmeticAddCalculation.exit_codes.ERROR_NEGATIVE_NUMBER) + def handle_negative_sum(self, node): + """Handle the `ERROR_NEGATIVE_NUMBER` failure mode of the `ArithmeticAddCalculation`.""" + self.ctx.inputs['x'] = orm.Int(abs(node.inputs.x.value)) + self.ctx.inputs['y'] = orm.Int(abs(node.inputs.y.value)) + return ProcessHandlerReport() + +If the ``exit_codes`` keyword is defined, which can be either a single instance of :class:`~aiida.engine.processes.exit_code.ExitCode` or a list thereof, the process handler will only be called if the exit status of the node corresponds to one of those exit codes, otherwise it will simply be skipped. + +Multiple process handlers +========================= + +Since typically a base restart work chain implementation will have more than one process handler, one might want to control the order in which they are called. +This can be done through the ``priority`` keyword: + +.. code-block:: python + + @process_handler(priority=400, exit_codes=ArithmeticAddCalculation.exit_codes.ERROR_NEGATIVE_NUMBER) + def handle_negative_sum(self, node): + """Handle the `ERROR_NEGATIVE_NUMBER` failure mode of the `ArithmeticAddCalculation`.""" + self.ctx.inputs['x'] = orm.Int(abs(node.inputs.x.value)) + self.ctx.inputs['y'] = orm.Int(abs(node.inputs.y.value)) + return ProcessHandlerReport() + +The process handlers with a higher priority will be called first. +In this scenario, in addition to controlling the order with which the handlers are called, you may also want to stop the process handling once you have determined the problem. +This can be achieved by setting the ``do_break`` argument of the ``ProcessHandler`` to ``True``: + +.. code-block:: python + + @process_handler(priority=400, exit_codes=ArithmeticAddCalculation.exit_codes.ERROR_NEGATIVE_NUMBER) + def handle_negative_sum(self, node): + """Handle the `ERROR_NEGATIVE_NUMBER` failure mode of the `ArithmeticAddCalculation`.""" + self.ctx.inputs['x'] = orm.Int(abs(node.inputs.x.value)) + self.ctx.inputs['y'] = orm.Int(abs(node.inputs.y.value)) + return ProcessHandlerReport(do_break=True) + +Finally, sometimes one detects a problem that simply cannot or should not be corrected by the work chain. +In this case, the handler can signal that the work chain should abort by setting an :class:`~aiida.engine.processes.exit_code.ExitCode` instance on the ``exit_code`` argument of the ``ProcessHandler``: + +.. code-block:: python + + from aiida.engine import ExitCode + + @process_handler(priority=400, exit_codes=ArithmeticAddCalculation.exit_codes.ERROR_NEGATIVE_NUMBER) + def handle_negative_sum(self, node): + """Handle the `ERROR_NEGATIVE_NUMBER` failure mode of the `ArithmeticAddCalculation`.""" + return ProcessHandlerReport(exit_code=ExitCode(450, 'Inputs lead to a negative sum but I will not correct them')) + +The base restart work chain will detect this exit code and abort the work chain, setting the corresponding status and message on the node as usual: + +.. code-block:: console + + $ verdi process status 1951 + ArithmeticAddBaseWorkChain<1951> Finished [450] [1:while_(should_run_process)(1:inspect_process)] + └── ArithmeticAddCalculation<1952> Finished [410] + +With these basic tools, a broad range of use-cases can be addressed while preventing a lot of boilerplate code. diff --git a/docs/source/topics/processes/concepts.rst b/docs/source/topics/processes/concepts.rst index 166c601406..950453ba64 100644 --- a/docs/source/topics/processes/concepts.rst +++ b/docs/source/topics/processes/concepts.rst @@ -105,6 +105,7 @@ When you load a calculation node from the database, you can use these property m Process exit codes ================== + The previous section about the process state showed that a process that is ``Finished`` does not say anything about whether the result is 'successful' or 'failed'. The ``Finished`` state means nothing more than that the engine succeeded in running the process to the end of execution, without it encountering exceptions or being killed. To distinguish between a 'successful' and 'failed' process, an 'exit status' can be defined. @@ -112,8 +113,10 @@ The `exit status is a common concept in programming ` and :ref:`workflow` development sections. +.. seealso:: + + For how exit codes can be defined and returned see the :ref:`exit code usage section `. .. _topics:processes:concepts:lifetime: diff --git a/docs/source/topics/processes/functions.rst b/docs/source/topics/processes/functions.rst index c62ee97b6b..deda0601b9 100644 --- a/docs/source/topics/processes/functions.rst +++ b/docs/source/topics/processes/functions.rst @@ -128,6 +128,7 @@ As always, all the values returned by a calculation function have to be storable Because of the calculation/workflow duality in AiiDA, a ``calcfunction``, which is a calculation-like process, can only *create* and not *return* data nodes. This means that if a node is returned from a ``calcfunction`` that *is already stored*, the engine will throw an exception. +.. _topics:processes:functions:exit_codes: Exit codes ========== diff --git a/docs/source/topics/processes/usage.rst b/docs/source/topics/processes/usage.rst index 7ab5bfb043..e9653be16c 100644 --- a/docs/source/topics/processes/usage.rst +++ b/docs/source/topics/processes/usage.rst @@ -225,7 +225,7 @@ To clearly communicate to the caller what went wrong, the ``Process`` supports s This ``exit_status``, a positive integer, is an attribute of the process node and by convention, when it is zero means the process was successful, whereas any other value indicates failure. This concept of an exit code, with a positive integer as the exit status, `is a common concept in programming `_ and a standard way for programs to communicate the result of their execution. -Potential exit codes for the ``Process`` can be defined through the ``ProcessSpec``, just like inputs and ouputs. +Potential exit codes for the ``Process`` can be defined through the ``ProcessSpec``, just like inputs and outputs. Any exit code consists of a positive non-zero integer, a string label to reference it and a more detailed description of the problem that triggers the exit code. Consider the following example: @@ -252,6 +252,16 @@ This is useful, because the caller can now programmatically, based on the ``exit This is an infinitely more robust way of communicating specific errors to a non-human than parsing text-based logs or reports. Additionally, the exit codes make it very easy to query for failed processes with specific error codes. +.. seealso:: + + Additional documentation, specific to certain process types, can be found in the following sections: + + - :ref:`Process functions` + - :ref:`Work functions` + - :ref:`CalcJob parsers` + - :ref:`Workchain exit code specification` + - :ref:`External code plugins` + - :ref:`Restart workchains` .. _topics:processes:usage:exit_code_conventions: diff --git a/docs/source/topics/workflows/usage.rst b/docs/source/topics/workflows/usage.rst index c83f863117..4de24a9f7c 100644 --- a/docs/source/topics/workflows/usage.rst +++ b/docs/source/topics/workflows/usage.rst @@ -272,7 +272,7 @@ Converting the resulting flow diagram in a one-to-one fashion into an outline, o Exit codes ---------- There is one more property of a work chain that is specified through its process specification, in addition to its inputs, outputs and outline. -Any work chain may have one to multiple failure modes, which are modeled by :ref:`exit codes`. +Any work chain may have one to multiple failure modes, which are modelled by :ref:`exit codes`. A work chain can be stopped at any time, simply by returning an exit code from an outline method. To retrieve an exit code that is defined on the spec, one can use the :py:meth:`~aiida.engine.processes.process.Process.exit_codes` property. This returns an attribute dictionary where the exit code labels map to their corresponding exit code. @@ -587,6 +587,9 @@ Of course, we then need to explicitly pass the input ``a``. Finally, we use :meth:`~aiida.engine.processes.process.Process.exposed_outputs` and :meth:`~aiida.engine.processes.process.Process.out_many` to forward the outputs of the children to the outputs of the parent. Again, the ``namespace`` and ``agglomerate`` options can be used to select which outputs are returned by the :meth:`~aiida.engine.processes.process.Process.exposed_outputs` method. +.. seealso:: + + For further practical examples of creating workflows, see the :ref:`how to run multi-step workflows` and :ref:`how to write error resistant workflows ` sections. .. rubric:: Footnotes From 8dfe6e7f730ce02371011114d7edb7aa2cdf6706 Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Wed, 17 Feb 2021 17:43:50 +0100 Subject: [PATCH 082/114] CI: Bump reentry to v1.3.2 (#4746) --- requirements/requirements-py-3.7.txt | 2 +- requirements/requirements-py-3.8.txt | 2 +- requirements/requirements-py-3.9.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 0cbb425b2e..b10dd1f8d9 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -121,7 +121,7 @@ PyYAML==5.1.2 pyzmq==22.0.2 qtconsole==5.0.2 QtPy==1.9.0 -reentry==1.3.1 +reentry==1.3.2 requests==2.25.1 retrying==1.3.3 ruamel.yaml==0.16.12 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 66d4262c6b..d50bb75dcd 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -120,7 +120,7 @@ PyYAML==5.1.2 pyzmq==22.0.2 qtconsole==5.0.2 QtPy==1.9.0 -reentry==1.3.1 +reentry==1.3.2 requests==2.25.1 retrying==1.3.3 ruamel.yaml==0.16.12 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index c30871a5f6..0153178b0d 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -120,8 +120,8 @@ PyYAML==5.1.2 pyzmq==22.0.2 qtconsole==5.0.2 QtPy==1.9.0 -reentry==1.3.1 requests==2.25.1 +reentry==1.3.2 retrying==1.3.3 ruamel.yaml==0.16.12 scipy==1.6.0 From d63c9c10ed660d8f745796d6043e277ef76f60a9 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 17 Feb 2021 20:02:05 +0100 Subject: [PATCH 083/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Node=20comments?= =?UTF-8?q?=20API=20(#4760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aiida/orm/nodes/node.py | 17 +++++++------- tests/orm/node/test_node.py | 47 ++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/aiida/orm/nodes/node.py b/aiida/orm/nodes/node.py index a1664d1f9a..bc8b114947 100644 --- a/aiida/orm/nodes/node.py +++ b/aiida/orm/nodes/node.py @@ -12,6 +12,7 @@ import importlib import warnings import traceback +from typing import List, Optional from aiida.common import exceptions from aiida.common.escaping import sql_string_match @@ -671,7 +672,7 @@ def delete_object(self, path=None, force=False, key=None): self._repository.delete_object(path, force) - def add_comment(self, content, user=None): + def add_comment(self, content: str, user: Optional[User] = None) -> Comment: """Add a new comment. :param content: string with comment @@ -681,7 +682,7 @@ def add_comment(self, content, user=None): user = user or User.objects.get_default() return Comment(node=self, user=user, content=content).store() - def get_comment(self, identifier): + def get_comment(self, identifier: int) -> Comment: """Return a comment corresponding to the given identifier. :param identifier: the comment pk @@ -689,16 +690,16 @@ def get_comment(self, identifier): :raise aiida.common.MultipleObjectsError: if the id cannot be uniquely resolved to a comment :return: the comment """ - return Comment.objects.get(dbnode_id=self.pk, pk=identifier) + return Comment.objects.get(dbnode_id=self.pk, id=identifier) - def get_comments(self): + def get_comments(self) -> List[Comment]: """Return a sorted list of comments for this node. :return: the list of comments, sorted by pk """ return Comment.objects.find(filters={'dbnode_id': self.pk}, order_by=[{'id': 'asc'}]) - def update_comment(self, identifier, content): + def update_comment(self, identifier: int, content: str) -> None: """Update the content of an existing comment. :param identifier: the comment pk @@ -706,15 +707,15 @@ def update_comment(self, identifier, content): :raise aiida.common.NotExistent: if the comment with the given id does not exist :raise aiida.common.MultipleObjectsError: if the id cannot be uniquely resolved to a comment """ - comment = Comment.objects.get(dbnode_id=self.pk, pk=identifier) + comment = Comment.objects.get(dbnode_id=self.pk, id=identifier) comment.set_content(content) - def remove_comment(self, identifier): + def remove_comment(self, identifier: int) -> None: # pylint: disable=no-self-use """Delete an existing comment. :param identifier: the comment pk """ - Comment.objects.delete(dbnode_id=self.pk, comment=identifier) + Comment.objects.delete(identifier) def add_incoming(self, source, link_type, link_label): """Add a link of the given type from a given node to ourself. diff --git a/tests/orm/node/test_node.py b/tests/orm/node/test_node.py index eda683aeeb..d856e16450 100644 --- a/tests/orm/node/test_node.py +++ b/tests/orm/node/test_node.py @@ -7,7 +7,7 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### -# pylint: disable=too-many-public-methods +# pylint: disable=too-many-public-methods,no-self-use """Tests for the Node ORM class.""" import io import os @@ -824,6 +824,51 @@ def test_delete_collection_outgoing_link(self): Node.objects.delete(calculation.pk) +@pytest.mark.usefixtures('clear_database_before_test') +class TestNodeComments: + """Tests for creating comments on nodes.""" + + def test_add_comment(self): + """Test comment addition.""" + data = Data().store() + content = 'whatever Trevor' + comment = data.add_comment(content) + assert comment.content == content + assert comment.node.pk == data.pk + + def test_get_comment(self): + """Test retrieve single comment.""" + data = Data().store() + content = 'something something dark side' + add_comment = data.add_comment(content) + get_comment = data.get_comment(add_comment.pk) + assert get_comment.content == content + assert get_comment.pk == add_comment.pk + + def test_get_comments(self): + """Test retrieve multiple comments.""" + data = Data().store() + data.add_comment('one') + data.add_comment('two') + comments = data.get_comments() + assert {c.content for c in comments} == {'one', 'two'} + + def test_update_comment(self): + """Test update a comment.""" + data = Data().store() + comment = data.add_comment('original') + data.update_comment(comment.pk, 'new') + assert comment.content == 'new' + + def test_remove_comment(self): + """Test remove a comment.""" + data = Data().store() + comment = data.add_comment('original') + assert len(data.get_comments()) == 1 + data.remove_comment(comment.pk) + assert len(data.get_comments()) == 0 + + @pytest.mark.usefixtures('clear_database_before_test') def test_store_from_cache(): """Regression test for storing a Node with (nested) repository content with caching.""" From 652dee3bbfea5a7ab160da56c0ec80608a29dc96 Mon Sep 17 00:00:00 2001 From: Giovanni Pizzi Date: Thu, 18 Feb 2021 08:33:43 +0100 Subject: [PATCH 084/114] Fix hanging direct scheduler+ssh (#4735) * Fix hanging direct scheduler+ssh The fix is very simple: in the ssh transport, to emulate 'chdir', we keep the current directory in memory, and we prepend every command with a `cd FOLDER_NAME && ACTUALCOMMAND`. One could put `;` instead of `&&`, but then if the folder does not exist the ACTUALCOMMAND would still be run in the wrong folder, which is very bad (imagine you are removing files...). Now, in general this is not a problem. However, the direct scheduler inserts a complex-syntax bash command to run the command in the background and immediately get the PID of that process without waiting. When combined with SSH, this hangs until the whole process is completed, unless the actual command is wrapped in brackets. A simple way to check this is running these two commands, that reproduce the issue with plain ssh, without paramiko: This hangs for 5 seconds: ``` ssh localhost 'cd tmp && sleep 5 > /dev/null 2>&1 & echo $!' ``` This returns immediately, as we want: ``` ssh localhost 'cd tmp && ( sleep 5 > /dev/null 2>&1 & echo $! )' ``` Also, adding a regression test for the hanging direct+ssh combination This test checks that submitting a long job over the direct scheduler does not "hang" with any plugin. Co-authored-by: Leopold Talirz --- aiida/transports/plugins/ssh.py | 2 +- tests/transports/test_all_plugins.py | 190 +++++++++++++-------------- 2 files changed, 94 insertions(+), 98 deletions(-) diff --git a/aiida/transports/plugins/ssh.py b/aiida/transports/plugins/ssh.py index 9f73bdbc14..5f1a765608 100644 --- a/aiida/transports/plugins/ssh.py +++ b/aiida/transports/plugins/ssh.py @@ -1265,7 +1265,7 @@ def _exec_command_internal(self, command, combine_stderr=False, bufsize=-1): # if self.getcwd() is not None: escaped_folder = escape_for_bash(self.getcwd()) - command_to_execute = (f'cd {escaped_folder} && {command}') + command_to_execute = (f'cd {escaped_folder} && ( {command} )') else: command_to_execute = command diff --git a/tests/transports/test_all_plugins.py b/tests/transports/test_all_plugins.py index 2920126cd2..7470dd15c9 100644 --- a/tests/transports/test_all_plugins.py +++ b/tests/transports/test_all_plugins.py @@ -15,7 +15,19 @@ Plugin specific tests will be written in the plugin itself. """ import io +import os +import random +import tempfile +import signal +import shutil +import string +import time import unittest +import uuid + +import psutil + +from aiida.plugins import SchedulerFactory # TODO : test for copy with pattern # TODO : test for copy with/without patterns, overwriting folder @@ -35,7 +47,6 @@ def get_all_custom_transports(): it was found) """ import importlib - import os modulename = __name__.rpartition('.')[0] this_full_fname = __file__ @@ -133,11 +144,6 @@ def test_makedirs(self, custom_transport): """ Verify the functioning of makedirs command """ - # Imports required later - import random - import string - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) directory = 'temp_dir_test' @@ -176,11 +182,6 @@ def test_rmtree(self, custom_transport): """ Verify the functioning of rmtree command """ - # Imports required later - import random - import string - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) directory = 'temp_dir_test' @@ -221,12 +222,6 @@ def test_listdir(self, custom_transport): """ create directories, verify listdir, delete a folder with subfolders """ - # Imports required later - import tempfile - import random - import string - import os - with custom_transport as trans: # We cannot use tempfile.mkdtemp because we're on a remote folder location = trans.normalize(os.path.join('/', 'tmp')) @@ -270,11 +265,6 @@ def test_listdir_withattributes(self, custom_transport): """ create directories, verify listdir_withattributes, delete a folder with subfolders """ - # Imports required later - import tempfile - import random - import string - import os def simplify_attributes(data): """ @@ -340,11 +330,6 @@ def simplify_attributes(data): @run_for_all_plugins def test_dir_creation_deletion(self, custom_transport): """Test creating and deleting directories.""" - # Imports required later - import random - import string - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) directory = 'temp_dir_test' @@ -370,11 +355,6 @@ def test_dir_copy(self, custom_transport): Verify if in the copy of a directory also the protection bits are carried over """ - # Imports required later - import random - import string - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) directory = 'temp_dir_test' @@ -403,11 +383,6 @@ def test_dir_permissions_creation_modification(self, custom_transport): # pylin verify if chmod raises IOError when trying to change bits on a non-existing folder """ - # Imports required later - import random - import string - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) directory = 'temp_dir_test' @@ -460,11 +435,6 @@ def test_dir_reading_permissions(self, custom_transport): Try to enter a directory with no read permissions. Verify that the cwd has not changed after failed try. """ - # Imports required later - import random - import string - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) directory = 'temp_dir_test' @@ -503,8 +473,6 @@ def test_isfile_isdir_to_empty_string(self, custom_transport): I check that isdir or isfile return False when executed on an empty string """ - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) transport.chdir(location) @@ -517,8 +485,6 @@ def test_isfile_isdir_to_non_existing_string(self, custom_transport): I check that isdir or isfile return False when executed on an empty string """ - import os - with custom_transport as transport: location = transport.normalize(os.path.join('/', 'tmp')) transport.chdir(location) @@ -535,8 +501,6 @@ def test_chdir_to_empty_string(self, custom_transport): not change (this is a paramiko default behavior), but getcwd() is still correctly defined. """ - import os - with custom_transport as transport: new_dir = transport.normalize(os.path.join('/', 'tmp')) transport.chdir(new_dir) @@ -555,10 +519,6 @@ class TestPutGetFile(unittest.TestCase): @run_for_all_plugins def test_put_and_get(self, custom_transport): """Test putting and getting files.""" - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -605,10 +565,6 @@ def test_put_get_abs_path(self, custom_transport): """ test of exception for non existing files and abs path """ - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -669,10 +625,6 @@ def test_put_get_empty_string(self, custom_transport): test of exception put/get of empty strings """ # TODO : verify the correctness of \n at the end of a file - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -752,10 +704,6 @@ class TestPutGetTree(unittest.TestCase): @run_for_all_plugins def test_put_and_get(self, custom_transport): """Test putting and getting files.""" - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -807,8 +755,6 @@ def test_put_and_get(self, custom_transport): self.assertTrue('file.txt' in list_pushed_file) self.assertTrue('file.txt' in list_retrieved_file) - import shutil - shutil.rmtree(local_subfolder) shutil.rmtree(retrieved_subfolder) transport.rmtree(remote_subfolder) @@ -819,11 +765,6 @@ def test_put_and_get(self, custom_transport): @run_for_all_plugins def test_put_and_get_overwrite(self, custom_transport): """Test putting and getting files with overwrites.""" - import os - import random - import shutil - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -877,10 +818,6 @@ def test_put_and_get_overwrite(self, custom_transport): @run_for_all_plugins def test_copy(self, custom_transport): """Test copying.""" - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -952,10 +889,6 @@ def test_put(self, custom_transport): # pylint: disable=too-many-statements # exactly the same tests of copy, just with the put function # and therefore the local path must be absolute - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -1033,11 +966,6 @@ def test_get(self, custom_transport): # pylint: disable=too-many-statements # exactly the same tests of copy, just with the put function # and therefore the local path must be absolute - import os - import random - import shutil - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -1119,10 +1047,6 @@ def test_put_get_abs_path(self, custom_transport): """ test of exception for non existing files and abs path """ - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -1194,10 +1118,6 @@ def test_put_get_empty_string(self, custom_transport): test of exception put/get of empty strings """ # TODO : verify the correctness of \n at the end of a file - import os - import random - import string - local_dir = os.path.join('/', 'tmp') remote_dir = local_dir directory = 'tmp_try' @@ -1263,9 +1183,6 @@ def test_put_get_empty_string(self, custom_transport): @run_for_all_plugins def test_gettree_nested_directory(self, custom_transport): # pylint: disable=no-self-use """Test `gettree` for a nested directory.""" - import os - import tempfile - with tempfile.TemporaryDirectory() as dir_remote, tempfile.TemporaryDirectory() as dir_local: content = b'dummy\ncontent' filepath = os.path.join(dir_remote, 'sub', 'path', 'filename.txt') @@ -1294,8 +1211,6 @@ def test_exec_pwd(self, custom_transport): creation (which should be done by paramiko) and in the command execution (done in this module, in the _exec_command_internal function). """ - import os - # Start value delete_at_end = False @@ -1365,3 +1280,84 @@ def test_exec_with_wrong_stdin(self, custom_transport): with custom_transport as transport: with self.assertRaises(ValueError): transport.exec_command_wait('cat', stdin=1) + + +class TestDirectScheduler(unittest.TestCase): + """ + Test how the direct scheduler works. + + While this is technically a scheduler test, I put it under the transport tests + because 1) in reality I am testing the interaction of each transport with the + direct scheduler; 2) the direct scheduler is always available; 3) I am reusing + the infrastructure to test on multiple transport plugins. + """ + + @run_for_all_plugins + def test_asynchronous_execution(self, custom_transport): + """Test that the execution of a long(ish) command via the direct scheduler does not block. + + This is a regression test for #3094, where running a long job on the direct scheduler + (via SSH) would lock the interpreter until the job was done. + """ + # Use a unique name, using a UUID, to avoid concurrent tests (or very rapid + # tests that follow each other) to overwrite the same destination + script_fname = f'sleep-submit-{uuid.uuid4().hex}-{custom_transport.__class__.__name__}.sh' + + scheduler = SchedulerFactory('direct')() + scheduler.set_transport(custom_transport) + with custom_transport as transport: + try: + with tempfile.NamedTemporaryFile() as tmpf: + # Put a submission script that sleeps 10 seconds + tmpf.write(b'#!/bin/bash\nsleep 10\n') + tmpf.flush() + + transport.chdir('/tmp') + transport.putfile(tmpf.name, script_fname) + + timestamp_before = time.time() + job_id_string = scheduler.submit_from_script('/tmp', script_fname) + + elapsed_time = time.time() - timestamp_before + # We want to get back control. If it takes < 5 seconds, it means that it is not blocking + # as the job is taking at least 10 seconds. I put 5 as the machine could be slow (including the + # SSH connection etc.) and I don't want to have false failures. + # Actually, if the time is short, it could mean also that the execution failed! + # So I double check later that the execution was successful. + self.assertTrue( + elapsed_time < 5, 'Getting back control after remote execution took more than 5 seconds! ' + 'Probably submission is blocking' + ) + + # Check that the job is still running + # Wait 0.2 more seconds, so that I don't do a super-quick check that might return True + # even if it's not sleeping + time.sleep(0.2) + # Check that the job is still running - IMPORTANT, I'm assuming that all transports actually act + # on the *same* local machine, and that the job_id is actually the process PID. + # This needs to be adapted if: + # - a new transport plugin is tested and this does not test the same machine + # - a new scheduler is used and does not use the process PID, or the job_id of the 'direct' scheduler + # is not anymore simply the job PID + job_id = int(job_id_string) + self.assertTrue( + psutil.pid_exists(job_id), 'The job is not there after a bit more than 1 second! Probably it failed' + ) + finally: + # Clean up by killing the remote job. + # This assumes it's on the same machine; if we add tests on a different machine, + # we need to call 'kill' via the transport instead. + # In reality it's not critical to remove it since it will end after 10 seconds of + # sleeping, but this might avoid warnings (e.g. ResourceWarning) + try: + os.kill(job_id, signal.SIGTERM) + except ProcessLookupError: + # If the process is already dead (or has never run), I just ignore the error + pass + + # Also remove the script + try: + transport.remove(f'/tmp/{script_fname}') + except FileNotFoundError: + # If the file wasn't even created, I just ignore this error + pass From b5a35a683f3b323e787c96d4e1776d6cce0d59e0 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 18 Feb 2021 14:36:54 +0100 Subject: [PATCH 085/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20REFACTOR:=20config?= =?UTF-8?q?uration=20management=20API=20and=20CLI=20(#4712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit primarily refactors the `verdi config` command and merges the `cache_config.yml` into the `config.json`. `config.json` changes: - A jsonschema is added to validate the `config.json`, and also provide the options/defaults previously in `aiida/manage/configuration/options.py`. - Rename option keys (with migration), for consistency with the internal representation (also rename `user.` fields to `autofill.user.`) - Allow the `config.json` to contain a `$schema` key, that is preserved when storing new data - Deprecated `cache_config.yml`: auto-merged into `config.json`, with deprecation warning, then renamed - An `rmq.task_timeout` option has also been added (with default increased from 5 to 10 seconds), to fix timeout errors at high process loads. `verdi config` changes: - Refactor `verdi config` into separate commands: list/get/set/show/unset - Include deprecation for current `verdi config ` - `verdi caching` lists all process entry points that are enabled/disabled for caching Also, code in `aiida/manage/caching.py` now utilises the `get_config_option` function to retrieve caching configuration. --- MANIFEST.in | 1 + aiida/cmdline/commands/cmd_config.py | 224 +++++++++-- aiida/cmdline/commands/cmd_setup.py | 8 +- .../cmdline/params/options/commands/setup.py | 16 +- aiida/manage/caching.py | 228 +++++------ aiida/manage/configuration/__init__.py | 40 +- aiida/manage/configuration/config.py | 122 ++++-- .../configuration/migrations/migrations.py | 42 +- aiida/manage/configuration/options.py | 347 ++++++----------- aiida/manage/configuration/profile.py | 5 +- aiida/manage/configuration/schema/__init__.py | 9 + .../schema/config-v5.schema.json | 363 ++++++++++++++++++ aiida/manage/manager.py | 1 + .../source/developer_guide/core/internals.rst | 2 +- docs/source/howto/faq.rst | 2 +- docs/source/howto/installation.rst | 157 ++++++-- docs/source/howto/run_codes.rst | 135 +++++-- docs/source/nitpick-exceptions | 2 + docs/source/reference/command_line.rst | 16 +- docs/source/topics/provenance/caching.rst | 4 +- environment.yml | 1 + pyproject.toml | 1 + setup.json | 1 + tests/cmdline/commands/test_config.py | 205 +++++++--- tests/cmdline/commands/test_status.py | 2 +- tests/conftest.py | 56 ++- .../migrations/test_migrations.py | 7 + .../migrations/test_samples/input/4.json | 1 + .../migrations/test_samples/reference/5.json | 1 + .../test_samples/reference/final.json | 2 +- tests/manage/configuration/test_config.py | 4 +- tests/manage/configuration/test_options.py | 26 +- tests/manage/configuration/test_profile.py | 6 +- tests/manage/test_caching_config.py | 193 +++++----- 34 files changed, 1556 insertions(+), 674 deletions(-) create mode 100644 aiida/manage/configuration/schema/__init__.py create mode 100644 aiida/manage/configuration/schema/config-v5.schema.json create mode 100644 tests/manage/configuration/migrations/test_samples/input/4.json create mode 100644 tests/manage/configuration/migrations/test_samples/reference/5.json diff --git a/MANIFEST.in b/MANIFEST.in index 0c13bf8b4d..845905c022 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include aiida/cmdline/templates/*.tpl include aiida/manage/backup/backup_info.json.tmpl +include aiida/manage/configuration/schema/*.json include setup.json include AUTHORS.txt include CHANGELOG.md diff --git a/aiida/cmdline/commands/cmd_config.py b/aiida/cmdline/commands/cmd_config.py index 5eb87cf376..3cc517fed5 100644 --- a/aiida/cmdline/commands/cmd_config.py +++ b/aiida/cmdline/commands/cmd_config.py @@ -8,46 +8,224 @@ # For further information please visit http://www.aiida.net # ########################################################################### """`verdi config` command.""" +import textwrap import click from aiida.cmdline.commands.cmd_verdi import verdi from aiida.cmdline.params import arguments -from aiida.cmdline.utils import echo +from aiida.cmdline.utils import decorators, echo -@verdi.command('config') +class _DeprecateConfigCommandsGroup(click.Group): + """Overloads the get_command with one that identifies deprecated commands.""" + + def get_command(self, ctx, cmd_name): + """Override the default click.Group get_command with one that identifies deprecated commands.""" + cmd = click.Group.get_command(self, ctx, cmd_name) + + if cmd is not None: + return cmd + + if cmd_name in [ + 'daemon.default_workers', 'logging.plumpy_loglevel', 'daemon.timeout', 'logging.sqlalchemy_loglevel', + 'daemon.worker_process_slots', 'logging.tornado_loglevel', 'db.batch_size', 'runner.poll.interval', + 'logging.aiida_loglevel', 'user.email', 'logging.alembic_loglevel', 'user.first_name', + 'logging.circus_loglevel', 'user.institution', 'logging.db_loglevel', 'user.last_name', + 'logging.kiwipy_loglevel', 'verdi.shell.auto_import', 'logging.paramiko_loglevel', + 'warnings.showdeprecations', 'autofill.user.email', 'autofill.user.first_name', 'autofill.user.last_name', + 'autofill.user.institution' + ]: + ctx.obj.deprecated_name = cmd_name + cmd = click.Group.get_command(self, ctx, '_deprecated') + return cmd + + ctx.fail(f"'{cmd_name}' is not a verdi config command.") + + return None + + +@verdi.group('config', cls=_DeprecateConfigCommandsGroup) +def verdi_config(): + """Manage the AiiDA configuration.""" + + +@verdi_config.command('list') +@click.argument('prefix', metavar='PREFIX', required=False, default='') +@click.option('-d', '--description', is_flag=True, help='Include description of options') +@click.pass_context +def verdi_config_list(ctx, prefix, description: bool): + """List AiiDA options for the current profile. + + Optionally filtered by a prefix. + """ + from tabulate import tabulate + + from aiida.manage.configuration import Config, Profile + + config: Config = ctx.obj.config + profile: Profile = ctx.obj.profile + + option_values = config.get_options(profile.name) + + def _join(val): + """split arrays into multiple lines.""" + if isinstance(val, list): + return '\n'.join(str(v) for v in val) + return val + + if description: + table = [[name, source, _join(value), '\n'.join(textwrap.wrap(c.description))] + for name, (c, source, value) in option_values.items() + if name.startswith(prefix)] + headers = ['name', 'source', 'value', 'description'] + else: + table = [[name, source, _join(value)] + for name, (c, source, value) in option_values.items() + if name.startswith(prefix)] + headers = ['name', 'source', 'value'] + + # sort by name + table = sorted(table, key=lambda x: x[0]) + echo.echo(tabulate(table, headers=headers)) + + +@verdi_config.command('show') @arguments.CONFIG_OPTION(metavar='OPTION_NAME') -@click.argument('value', metavar='OPTION_VALUE', required=False) -@click.option('--global', 'globally', is_flag=True, help='Apply the option configuration wide.') -@click.option('--unset', is_flag=True, help='Remove the line matching the option name from the config file.') @click.pass_context -def verdi_config(ctx, option, value, globally, unset): - """Configure profile-specific or global AiiDA options.""" - config = ctx.obj.config - profile = ctx.obj.profile +def verdi_config_show(ctx, option): + """Show details of an AiiDA option for the current profile.""" + from aiida.manage.configuration import Config, Profile + from aiida.manage.configuration.options import NO_DEFAULT + + config: Config = ctx.obj.config + profile: Profile = ctx.obj.profile + + dct = { + 'schema': option.schema, + 'values': { + 'default': '' if option.default is NO_DEFAULT else option.default, + 'global': config.options.get(option.name, ''), + 'profile': profile.options.get(option.name, ''), + } + } + + echo.echo_dictionary(dct, fmt='yaml', sort_keys=False) + + +@verdi_config.command('get') +@arguments.CONFIG_OPTION(metavar='OPTION_NAME') +def verdi_config_get(option): + """Get the value of an AiiDA option for the current profile.""" + from aiida import get_config_option + + value = get_config_option(option.name) + echo.echo(str(value)) + + +@verdi_config.command('set') +@arguments.CONFIG_OPTION(metavar='OPTION_NAME') +@click.argument('value', metavar='OPTION_VALUE') +@click.option('-g', '--global', 'globally', is_flag=True, help='Apply the option configuration wide.') +@click.option('-a', '--append', is_flag=True, help='Append the value to an existing array.') +@click.option('-r', '--remove', is_flag=True, help='Remove the value from an existing array.') +@click.pass_context +def verdi_config_set(ctx, option, value, globally, append, remove): + """Set an AiiDA option. + + List values are split by whitespace, e.g. "a b" becomes ["a", "b"]. + """ + from aiida.manage.configuration import Config, Profile, ConfigValidationError + + if append and remove: + echo.echo_critical('Cannot flag both append and remove') + + config: Config = ctx.obj.config + profile: Profile = ctx.obj.profile if option.global_only: globally = True # Define the string that determines the scope: for specific profile or globally scope = profile.name if (not globally and profile) else None - scope_text = f'for {profile.name}' if (not globally and profile) else 'globally' + scope_text = f"for '{profile.name}' profile" if (not globally and profile) else 'globally' + + if append or remove: + try: + current = config.get_option(option.name, scope=scope) + except ConfigValidationError as error: + echo.echo_critical(str(error)) + if not isinstance(current, list): + echo.echo_critical(f'cannot append/remove to value: {current}') + if append: + value = current + [value] + else: + value = [item for item in current if item != value] + + # Set the specified option + try: + value = config.set_option(option.name, value, scope=scope) + except ConfigValidationError as error: + echo.echo_critical(str(error)) + + config.store() + echo.echo_success(f"'{option.name}' set to {value} {scope_text}") + + +@verdi_config.command('unset') +@arguments.CONFIG_OPTION(metavar='OPTION_NAME') +@click.option('-g', '--global', 'globally', is_flag=True, help='Unset the option configuration wide.') +@click.pass_context +def verdi_config_unset(ctx, option, globally): + """Unset an AiiDA option.""" + from aiida.manage.configuration import Config, Profile + + config: Config = ctx.obj.config + profile: Profile = ctx.obj.profile + + if option.global_only: + globally = True + + # Define the string that determines the scope: for specific profile or globally + scope = profile.name if (not globally and profile) else None + scope_text = f"for '{profile.name}' profile" if (not globally and profile) else 'globally' # Unset the specified option - if unset: - config.unset_option(option.name, scope=scope) - config.store() - echo.echo_success(f'{option.name} unset {scope_text}') + config.unset_option(option.name, scope=scope) + config.store() + echo.echo_success(f"'{option.name}' unset {scope_text}") - # Get the specified option - elif value is None: - option_value = config.get_option(option.name, scope=scope, default=False) - if option_value: - echo.echo(f'{option_value}') - # Set the specified option +@verdi_config.command('caching') +@click.option('-d', '--disabled', is_flag=True, help='List disabled types instead.') +def verdi_config_caching(disabled): + """List caching-enabled process types for the current profile.""" + from aiida.plugins.entry_point import ENTRY_POINT_STRING_SEPARATOR, get_entry_point_names + from aiida.manage.caching import get_use_cache + + for group in ['aiida.calculations', 'aiida.workflows']: + for entry_point in get_entry_point_names(group): + identifier = ENTRY_POINT_STRING_SEPARATOR.join([group, entry_point]) + if get_use_cache(identifier=identifier): + if not disabled: + echo.echo(identifier) + elif disabled: + echo.echo(identifier) + + +@verdi_config.command('_deprecated', hidden=True) +@decorators.deprecated_command("This command has been deprecated. Please use 'verdi config show/set/unset' instead.") +@click.argument('value', metavar='OPTION_VALUE', required=False) +@click.option('--global', 'globally', is_flag=True, help='Apply the option configuration wide.') +@click.option('--unset', is_flag=True, help='Remove the line matching the option name from the config file.') +@click.pass_context +def verdi_config_deprecated(ctx, value, globally, unset): + """"This command has been deprecated. Please use 'verdi config show/set/unset' instead.""" + from aiida.manage.configuration import get_option + option = get_option(ctx.obj.deprecated_name) + if unset: + ctx.invoke(verdi_config_unset, option=option, globally=globally) + elif value is not None: + ctx.invoke(verdi_config_set, option=option, value=value, globally=globally) else: - config.set_option(option.name, value, scope=scope) - config.store() - echo.echo_success(f'{option.name} set to {value} {scope_text}') + ctx.invoke(verdi_config_get, option=option) diff --git a/aiida/cmdline/commands/cmd_setup.py b/aiida/cmdline/commands/cmd_setup.py index 28b34effdd..241e048bb8 100644 --- a/aiida/cmdline/commands/cmd_setup.py +++ b/aiida/cmdline/commands/cmd_setup.py @@ -90,10 +90,10 @@ def setup( echo.echo_success('database migration completed.') # Optionally setting configuration default user settings - config.set_option('user.email', email, override=False) - config.set_option('user.first_name', first_name, override=False) - config.set_option('user.last_name', last_name, override=False) - config.set_option('user.institution', institution, override=False) + config.set_option('autofill.user.email', email, override=False) + config.set_option('autofill.user.first_name', first_name, override=False) + config.set_option('autofill.user.last_name', last_name, override=False) + config.set_option('autofill.user.institution', institution, override=False) # Create the user if it does not yet exist created, user = orm.User.objects.get_or_create( diff --git a/aiida/cmdline/params/options/commands/setup.py b/aiida/cmdline/params/options/commands/setup.py index b600c73e83..b5cb9f974d 100644 --- a/aiida/cmdline/params/options/commands/setup.py +++ b/aiida/cmdline/params/options/commands/setup.py @@ -159,32 +159,32 @@ def get_quicksetup_password(ctx, param, value): # pylint: disable=unused-argume SETUP_USER_EMAIL = options.USER_EMAIL.clone( prompt='Email Address (for sharing data)', - default=get_config_option('user.email'), - required_fn=lambda x: get_config_option('user.email') is None, + default=get_config_option('autofill.user.email'), + required_fn=lambda x: get_config_option('autofill.user.email') is None, required=True, cls=options.interactive.InteractiveOption ) SETUP_USER_FIRST_NAME = options.USER_FIRST_NAME.clone( prompt='First name', - default=get_config_option('user.first_name'), - required_fn=lambda x: get_config_option('user.first_name') is None, + default=get_config_option('autofill.user.first_name'), + required_fn=lambda x: get_config_option('autofill.user.first_name') is None, required=True, cls=options.interactive.InteractiveOption ) SETUP_USER_LAST_NAME = options.USER_LAST_NAME.clone( prompt='Last name', - default=get_config_option('user.last_name'), - required_fn=lambda x: get_config_option('user.last_name') is None, + default=get_config_option('autofill.user.last_name'), + required_fn=lambda x: get_config_option('autofill.user.last_name') is None, required=True, cls=options.interactive.InteractiveOption ) SETUP_USER_INSTITUTION = options.USER_INSTITUTION.clone( prompt='Institution', - default=get_config_option('user.institution'), - required_fn=lambda x: get_config_option('user.institution') is None, + default=get_config_option('autofill.user.institution'), + required_fn=lambda x: get_config_option('autofill.user.institution') is None, required=True, cls=options.interactive.InteractiveOption ) diff --git a/aiida/manage/caching.py b/aiida/manage/caching.py index 6e81cf4bff..d387b9eb15 100644 --- a/aiida/manage/caching.py +++ b/aiida/manage/caching.py @@ -8,19 +8,15 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Definition of caching mechanism and configuration for calculations.""" -import os import re -import copy import keyword from enum import Enum from collections import namedtuple from contextlib import contextmanager, suppress -import yaml -from wrapt import decorator - from aiida.common import exceptions from aiida.common.lang import type_check +from aiida.manage.configuration import get_config_option from aiida.plugins.entry_point import ENTRY_POINT_STRING_SEPARATOR, ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP @@ -30,91 +26,117 @@ class ConfigKeys(Enum): """Valid keys for caching configuration.""" - DEFAULT = 'default' - ENABLED = 'enabled' - DISABLED = 'disabled' + DEFAULT = 'caching.default_enabled' + ENABLED = 'caching.enabled_for' + DISABLED = 'caching.disabled_for' -DEFAULT_CONFIG = { - ConfigKeys.DEFAULT.value: False, - ConfigKeys.ENABLED.value: [], - ConfigKeys.DISABLED.value: [], -} +class _ContextCache: + """Cache options, accounting for when in enable_caching or disable_caching contexts.""" + def __init__(self): + self._default_all = None + self._enable = [] + self._disable = [] -def _get_config(config_file): - """Return the caching configuration. + def clear(self): + """Clear caching overrides.""" + self.__init__() - :param config_file: the absolute path to the caching configuration file - :return: the configuration dictionary - """ - from aiida.manage.configuration import get_profile + def enable_all(self): + self._default_all = 'enable' + + def disable_all(self): + self._default_all = 'disable' + + def enable(self, identifier): + self._enable.append(identifier) + with suppress(ValueError): + self._disable.remove(identifier) + + def disable(self, identifier): + self._disable.append(identifier) + with suppress(ValueError): + self._enable.remove(identifier) + + def get_options(self): + """Return the options, applying any context overrides.""" + + if self._default_all == 'disable': + return False, [], [] + + if self._default_all == 'enable': + return True, [], [] - profile = get_profile() + default = get_config_option(ConfigKeys.DEFAULT.value) + enabled = get_config_option(ConfigKeys.ENABLED.value)[:] + disabled = get_config_option(ConfigKeys.DISABLED.value)[:] - if profile is None: - exceptions.ConfigurationError('no profile has been loaded') + for ident in self._disable: + disabled.append(ident) + with suppress(ValueError): + enabled.remove(ident) + + for ident in self._enable: + enabled.append(ident) + with suppress(ValueError): + disabled.remove(ident) - try: - with open(config_file, 'r', encoding='utf8') as handle: - config = yaml.safe_load(handle)[profile.name] - except (OSError, IOError, KeyError): - # No config file, or no config for this profile - return DEFAULT_CONFIG + # Check validity of enabled and disabled entries + try: + for identifier in enabled + disabled: + _validate_identifier_pattern(identifier=identifier) + except ValueError as exc: + raise exceptions.ConfigurationError('Invalid identifier pattern in enable or disable list.') from exc - # Validate configuration - for key in config: - if key not in DEFAULT_CONFIG: - raise exceptions.ConfigurationError(f"Configuration error: Invalid key '{key}' in cache_config.yml") + return default, enabled, disabled - # Add defaults where key is either completely missing or specifies no values in which case it will be `None` - for key, default_config in DEFAULT_CONFIG.items(): - if key not in config or config[key] is None: - config[key] = default_config - try: - type_check(config[ConfigKeys.DEFAULT.value], bool) - type_check(config[ConfigKeys.ENABLED.value], list) - type_check(config[ConfigKeys.DISABLED.value], list) - except TypeError as exc: - raise exceptions.ConfigurationError('Invalid type in caching configuration file.') from exc +_CONTEXT_CACHE = _ContextCache() - # Check validity of enabled and disabled entries - try: - for identifier in config[ConfigKeys.ENABLED.value] + config[ConfigKeys.DISABLED.value]: - _validate_identifier_pattern(identifier=identifier) - except ValueError as exc: - raise exceptions.ConfigurationError('Invalid identifier pattern in enable or disable list.') from exc - return config +@contextmanager +def enable_caching(*, identifier=None): + """Context manager to enable caching, either for a specific node class, or globally. + .. warning:: this does not affect the behavior of the daemon, only the local Python interpreter. -_CONFIG = {} + :param identifier: Process type string of the node, or a pattern with '*' wildcard that matches it. + If not provided, caching is enabled for all classes. + :type identifier: str + """ + type_check(identifier, str, allow_none=True) + if identifier is None: + _CONTEXT_CACHE.enable_all() + else: + _validate_identifier_pattern(identifier=identifier) + _CONTEXT_CACHE.enable(identifier) + yield + _CONTEXT_CACHE.clear() -def configure(config_file=None): - """Reads the caching configuration file and sets the _CONFIG variable.""" - # pylint: disable=global-statement - if config_file is None: - from aiida.manage.configuration import get_config - config = get_config() - config_file = os.path.join(config.dirpath, 'cache_config.yml') +@contextmanager +def disable_caching(*, identifier=None): + """Context manager to disable caching, either for a specific node class, or globally. - global _CONFIG - _CONFIG.clear() - _CONFIG.update(_get_config(config_file=config_file)) + .. warning:: this does not affect the behavior of the daemon, only the local Python interpreter. + :param identifier: Process type string of the node, or a pattern with '*' wildcard that matches it. + If not provided, caching is disabled for all classes. + :type identifier: str + """ + type_check(identifier, str, allow_none=True) -@decorator -def _with_config(wrapped, _, args, kwargs): - """Function decorator to load the caching configuration for the scope of the wrapped function.""" - if not _CONFIG: - configure() - return wrapped(*args, **kwargs) + if identifier is None: + _CONTEXT_CACHE.disable_all() + else: + _validate_identifier_pattern(identifier=identifier) + _CONTEXT_CACHE.disable(identifier) + yield + _CONTEXT_CACHE.clear() -@_with_config def get_use_cache(*, identifier=None): """Return whether the caching mechanism should be used for the given process type according to the configuration. @@ -126,17 +148,13 @@ def get_use_cache(*, identifier=None): """ type_check(identifier, str, allow_none=True) + default, enabled, disabled = _CONTEXT_CACHE.get_options() + if identifier is not None: type_check(identifier, str) - enable_matches = [ - pattern for pattern in _CONFIG[ConfigKeys.ENABLED.value] - if _match_wildcard(string=identifier, pattern=pattern) - ] - disable_matches = [ - pattern for pattern in _CONFIG[ConfigKeys.DISABLED.value] - if _match_wildcard(string=identifier, pattern=pattern) - ] + enable_matches = [pattern for pattern in enabled if _match_wildcard(string=identifier, pattern=pattern)] + disable_matches = [pattern for pattern in disabled if _match_wildcard(string=identifier, pattern=pattern)] if enable_matches and disable_matches: # If both enable and disable have matching identifier, we search for @@ -172,65 +190,7 @@ def get_use_cache(*, identifier=None): return True if disable_matches: return False - return _CONFIG[ConfigKeys.DEFAULT.value] - - -@contextmanager -@_with_config -def _reset_config(): - """Reset the configuration by clearing the contents of the global config variable.""" - # pylint: disable=global-statement - global _CONFIG - config_copy = copy.deepcopy(_CONFIG) - yield - _CONFIG.clear() - _CONFIG.update(config_copy) - - -@contextmanager -def enable_caching(*, identifier=None): - """Context manager to enable caching, either for a specific node class, or globally. - - .. warning:: this does not affect the behavior of the daemon, only the local Python interpreter. - - :param identifier: Process type string of the node, or a pattern with '*' wildcard that matches it. - :type identifier: str - """ - - type_check(identifier, str, allow_none=True) - with _reset_config(): - if identifier is None: - _CONFIG[ConfigKeys.DEFAULT.value] = True - _CONFIG[ConfigKeys.DISABLED.value] = [] - else: - _validate_identifier_pattern(identifier=identifier) - _CONFIG[ConfigKeys.ENABLED.value].append(identifier) - with suppress(ValueError): - _CONFIG[ConfigKeys.DISABLED.value].remove(identifier) - yield - - -@contextmanager -def disable_caching(*, identifier=None): - """Context manager to disable caching, either for a specific node class, or globally. - - .. warning:: this does not affect the behavior of the daemon, only the local Python interpreter. - - :param identifier: Process type string of the node, or a pattern with '*' wildcard that matches it. - :type identifier: str - """ - type_check(identifier, str, allow_none=True) - - with _reset_config(): - if identifier is None: - _CONFIG[ConfigKeys.DEFAULT.value] = False - _CONFIG[ConfigKeys.ENABLED.value] = [] - else: - _validate_identifier_pattern(identifier=identifier) - _CONFIG[ConfigKeys.DISABLED.value].append(identifier) - with suppress(ValueError): - _CONFIG[ConfigKeys.ENABLED.value].remove(identifier) - yield + return default def _match_wildcard(*, string, pattern): diff --git a/aiida/manage/configuration/__init__.py b/aiida/manage/configuration/__init__.py index 942b9ac5c2..568fa9992e 100644 --- a/aiida/manage/configuration/__init__.py +++ b/aiida/manage/configuration/__init__.py @@ -9,6 +9,8 @@ ########################################################################### # pylint: disable=undefined-variable,wildcard-import,global-statement,redefined-outer-name,cyclic-import """Modules related to the configuration of an AiiDA instance.""" +import os +import shutil import warnings from aiida.common.warnings import AiidaDeprecationWarning @@ -68,7 +70,6 @@ def load_profile(profile=None): def get_config_path(): """Returns path to .aiida configuration directory.""" - import os from .settings import AIIDA_CONFIG_FOLDER, DEFAULT_CONFIG_FILE_NAME return os.path.join(AIIDA_CONFIG_FOLDER, DEFAULT_CONFIG_FILE_NAME) @@ -87,7 +88,6 @@ def load_config(create=False): :rtype: :class:`~aiida.manage.configuration.config.Config` :raises aiida.common.MissingConfigurationError: if the configuration file could not be found and create=False """ - import os from aiida.common import exceptions from .config import Config @@ -101,9 +101,45 @@ def load_config(create=False): except ValueError: raise exceptions.ConfigurationError(f'configuration file {filepath} contains invalid JSON') + _merge_deprecated_cache_yaml(config, filepath) + return config +def _merge_deprecated_cache_yaml(config, filepath): + """Merge the deprecated cache_config.yml into the config.""" + from aiida.common import timezone + cache_path = os.path.join(os.path.dirname(filepath), 'cache_config.yml') + if not os.path.exists(cache_path): + return + + cache_path_backup = None + # Keep generating a new backup filename based on the current time until it does not exist + while not cache_path_backup or os.path.isfile(cache_path_backup): + cache_path_backup = f"{cache_path}.{timezone.now().strftime('%Y%m%d-%H%M%S.%f')}" + + warnings.warn( + f'cache_config.yml use is deprecated, merging into config.json and moving to: {cache_path_backup}', + AiidaDeprecationWarning + ) + import yaml + with open(cache_path, 'r', encoding='utf8') as handle: + cache_config = yaml.safe_load(handle) + for profile_name, data in cache_config.items(): + if profile_name not in config.profile_names: + warnings.warn(f"Profile '{profile_name}' from cache_config.yml not in config.json, skipping", UserWarning) + continue + for key, option_name in [('default', 'caching.default_enabled'), ('enabled', 'caching.enabled_for'), + ('disabled', 'caching.disabled_for')]: + if key in data: + value = data[key] + # in case of empty key + value = [] if value is None and key != 'default' else value + config.set_option(option_name, value, scope=profile_name) + config.store() + shutil.move(cache_path, cache_path_backup) + + def get_profile(): """Return the currently loaded profile. diff --git a/aiida/manage/configuration/config.py b/aiida/manage/configuration/config.py index f8ac82fc1c..cb4d50f630 100644 --- a/aiida/manage/configuration/config.py +++ b/aiida/manage/configuration/config.py @@ -8,16 +8,50 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module that defines the configuration file of an AiiDA instance and functions to create and load it.""" +from functools import lru_cache +from importlib import resources import os import shutil import tempfile +from typing import Any, Dict, Optional, Sequence, Tuple + +import jsonschema from aiida.common import json +from aiida.common.exceptions import ConfigurationError -from .options import get_option, parse_option, NO_DEFAULT +from . import schema as schema_module +from .options import get_option, get_option_names, Option, parse_option, NO_DEFAULT from .profile import Profile -__all__ = ('Config',) +__all__ = ('Config', 'config_schema', 'ConfigValidationError') + +SCHEMA_FILE = 'config-v5.schema.json' + + +@lru_cache(1) +def config_schema() -> Dict[str, Any]: + """Return the configuration schema.""" + return json.loads(resources.read_text(schema_module, SCHEMA_FILE, encoding='utf8')) + + +class ConfigValidationError(ConfigurationError): + """Configuration error raised when the file contents fails validation.""" + + def __init__( + self, message: str, keypath: Sequence[Any] = (), schema: Optional[dict] = None, filepath: Optional[str] = None + ): + super().__init__(message) + self._message = message + self._keypath = keypath + self._filepath = filepath + self._schema = schema + + def __str__(self) -> str: + prefix = f'{self._filepath}:' if self._filepath else '' + path = '/' + '/'.join(str(k) for k in self._keypath) + ': ' if self._keypath else '' + schema = f'\n schema:\n {self._schema}' if self._schema else '' + return f'Validation Error: {prefix}{path}{self._message}{schema}' class Config: # pylint: disable=too-many-public-methods @@ -29,6 +63,7 @@ class Config: # pylint: disable=too-many-public-methods KEY_DEFAULT_PROFILE = 'default_profile' KEY_PROFILES = 'profiles' KEY_OPTIONS = 'options' + KEY_SCHEMA = '$schema' @classmethod def from_file(cls, filepath): @@ -86,26 +121,40 @@ def _backup(cls, filepath): return filepath_backup - def __init__(self, filepath, config): + @staticmethod + def validate(config: dict, filepath: Optional[str] = None): + """Validate a configuration dictionary.""" + try: + jsonschema.validate(instance=config, schema=config_schema()) + except jsonschema.ValidationError as error: + raise ConfigValidationError( + message=error.message, keypath=error.path, schema=error.schema, filepath=filepath + ) + + def __init__(self, filepath: str, config: dict, validate: bool = True): """Instantiate a configuration object from a configuration dictionary and its filepath. If an empty dictionary is passed, the constructor will create the skeleton configuration dictionary. :param filepath: the absolute filepath of the configuration file :param config: the content of the configuration file in dictionary form + :param validate: validate the dictionary against the schema """ from .migrations import CURRENT_CONFIG_VERSION, OLDEST_COMPATIBLE_CONFIG_VERSION - version = config.get(self.KEY_VERSION, {}) - current_version = version.get(self.KEY_VERSION_CURRENT, CURRENT_CONFIG_VERSION) - compatible_version = version.get(self.KEY_VERSION_OLDEST_COMPATIBLE, OLDEST_COMPATIBLE_CONFIG_VERSION) + if validate: + self.validate(config, filepath) self._filepath = filepath - self._current_version = current_version - self._oldest_compatible_version = compatible_version + self._schema = config.get(self.KEY_SCHEMA, None) + version = config.get(self.KEY_VERSION, {}) + self._current_version = version.get(self.KEY_VERSION_CURRENT, CURRENT_CONFIG_VERSION) + self._oldest_compatible_version = version.get( + self.KEY_VERSION_OLDEST_COMPATIBLE, OLDEST_COMPATIBLE_CONFIG_VERSION + ) self._profiles = {} - known_keys = [self.KEY_VERSION, self.KEY_PROFILES, self.KEY_OPTIONS, self.KEY_DEFAULT_PROFILE] + known_keys = [self.KEY_SCHEMA, self.KEY_VERSION, self.KEY_PROFILES, self.KEY_OPTIONS, self.KEY_DEFAULT_PROFILE] unknown_keys = set(config.keys()) - set(known_keys) if unknown_keys: @@ -148,15 +197,17 @@ def handle_invalid(self, message): echo.echo_warning(f'backup of the original config file written to: `{filepath_backup}`') @property - def dictionary(self): + def dictionary(self) -> dict: """Return the dictionary representation of the config as it would be written to file. :return: dictionary representation of config as it should be written to file """ - config = { - self.KEY_VERSION: self.version_settings, - self.KEY_PROFILES: {name: profile.dictionary for name, profile in self._profiles.items()} - } + config = {} + if self._schema: + config[self.KEY_SCHEMA] = self._schema + + config[self.KEY_VERSION] = self.version_settings + config[self.KEY_PROFILES] = {name: profile.dictionary for name, profile in self._profiles.items()} if self._default_profile: config[self.KEY_DEFAULT_PROFILE] = self._default_profile @@ -321,6 +372,8 @@ def set_option(self, option_name, option_value, scope=None, override=True): :param option_value: the option value :param scope: set the option for this profile or globally if not specified :param override: boolean, if False, will not override the option if it already exists + + :returns: the parsed value (potentially cast to a valid type) """ option, parsed_value = parse_option(option_name, option_value) @@ -332,12 +385,14 @@ def set_option(self, option_name, option_value, scope=None, override=True): return if not option.global_only and scope is not None: - self.get_profile(scope).set_option(option.key, value, override=override) + self.get_profile(scope).set_option(option.name, value, override=override) else: - if option.key not in self.options or override: - self.options[option.key] = value + if option.name not in self.options or override: + self.options[option.name] = value - def unset_option(self, option_name, scope=None): + return value + + def unset_option(self, option_name: str, scope=None): """Unset a configuration option for a certain scope. :param option_name: the name of the configuration option @@ -346,9 +401,9 @@ def unset_option(self, option_name, scope=None): option = get_option(option_name) if scope is not None: - self.get_profile(scope).unset_option(option.key) + self.get_profile(scope).unset_option(option.name) else: - self.options.pop(option.key, None) + self.options.pop(option.name, None) def get_option(self, option_name, scope=None, default=True): """Get a configuration option for a certain scope. @@ -364,12 +419,35 @@ def get_option(self, option_name, scope=None, default=True): default_value = option.default if default and option.default is not NO_DEFAULT else None if scope is not None: - value = self.get_profile(scope).get_option(option.key, default_value) + value = self.get_profile(scope).get_option(option.name, default_value) else: - value = self.options.get(option.key, default_value) + value = self.options.get(option.name, default_value) return value + def get_options(self, scope=None) -> Dict[str, Tuple[Option, str, Any]]: + """Return a dictionary of all option values and their source ('profile', 'global', or 'default'). + + :returns: (option, source, value) + """ + profile = self.get_profile(scope) if scope else None + output = {} + for name in get_option_names(): + option = get_option(name) + if name in profile.options: + value = profile.options.get(name) + source = 'profile' + elif name in self.options: + value = self.options.get(name) + source = 'global' + elif 'default' in option.schema: + value = option.default + source = 'default' + else: + continue + output[name] = (option, source, value) + return output + def store(self): """Write the current config to file. diff --git a/aiida/manage/configuration/migrations/migrations.py b/aiida/manage/configuration/migrations/migrations.py index 7d094e74d3..54bd123e5a 100644 --- a/aiida/manage/configuration/migrations/migrations.py +++ b/aiida/manage/configuration/migrations/migrations.py @@ -15,8 +15,8 @@ # If the configuration file format is changed, the current version number should be upped and a migration added. # When the configuration file format is changed in a backwards-incompatible way, the oldest compatible version should # be set to the new current version. -CURRENT_CONFIG_VERSION = 4 -OLDEST_COMPATIBLE_CONFIG_VERSION = 3 +CURRENT_CONFIG_VERSION = 5 +OLDEST_COMPATIBLE_CONFIG_VERSION = 5 class ConfigMigration: @@ -98,10 +98,46 @@ def _3_add_message_broker(config): return config +def _4_simplify_options(config): + """Remove unnecessary difference between file/internal representation of options""" + conversions = { + 'runner_poll_interval': 'runner.poll.interval', + 'daemon_default_workers': 'daemon.default_workers', + 'daemon_timeout': 'daemon.timeout', + 'daemon_worker_process_slots': 'daemon.worker_process_slots', + 'db_batch_size': 'db.batch_size', + 'verdi_shell_auto_import': 'verdi.shell.auto_import', + 'logging_aiida_log_level': 'logging.aiida_loglevel', + 'logging_db_log_level': 'logging.db_loglevel', + 'logging_plumpy_log_level': 'logging.plumpy_loglevel', + 'logging_kiwipy_log_level': 'logging.kiwipy_loglevel', + 'logging_paramiko_log_level': 'logging.paramiko_loglevel', + 'logging_alembic_log_level': 'logging.alembic_loglevel', + 'logging_sqlalchemy_loglevel': 'logging.sqlalchemy_loglevel', + 'logging_circus_log_level': 'logging.circus_loglevel', + 'user_email': 'autofill.user.email', + 'user_first_name': 'autofill.user.first_name', + 'user_last_name': 'autofill.user.last_name', + 'user_institution': 'autofill.user.institution', + 'show_deprecations': 'warnings.showdeprecations', + 'task_retry_initial_interval': 'transport.task_retry_initial_interval', + 'task_maximum_attempts': 'transport.task_maximum_attempts' + } + for current, new in conversions.items(): + for profile in config.get('profiles', {}).values(): + if current in profile.get('options', {}): + profile['options'][new] = profile['options'].pop(current) + if current in config.get('options', {}): + config['options'][new] = config['options'].pop(current) + + return config + + # Maps the initial config version to the ConfigMigration which updates it. _MIGRATION_LOOKUP = { 0: ConfigMigration(migrate_function=lambda x: x, version=1, version_oldest_compatible=0), 1: ConfigMigration(migrate_function=_1_add_profile_uuid, version=2, version_oldest_compatible=0), 2: ConfigMigration(migrate_function=_2_simplify_default_profiles, version=3, version_oldest_compatible=3), - 3: ConfigMigration(migrate_function=_3_add_message_broker, version=4, version_oldest_compatible=3) + 3: ConfigMigration(migrate_function=_3_add_message_broker, version=4, version_oldest_compatible=3), + 4: ConfigMigration(migrate_function=_4_simplify_options, version=5, version_oldest_compatible=5) } diff --git a/aiida/manage/configuration/options.py b/aiida/manage/configuration/options.py index 0c29be1923..281880eb9f 100644 --- a/aiida/manage/configuration/options.py +++ b/aiida/manage/configuration/options.py @@ -8,252 +8,131 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Definition of known configuration options and methods to parse and get option values.""" -import collections +from typing import Any, Dict, List, Tuple -__all__ = ('get_option', 'get_option_names', 'parse_option') - -NO_DEFAULT = () -DEFAULT_DAEMON_WORKERS = 1 -DEFAULT_DAEMON_TIMEOUT = 20 # Default timeout in seconds for circus client calls -DEFAULT_DAEMON_WORKER_PROCESS_SLOTS = 200 -VALID_LOG_LEVELS = ['CRITICAL', 'ERROR', 'WARNING', 'REPORT', 'INFO', 'DEBUG'] - -Option = collections.namedtuple( - 'Option', ['name', 'key', 'valid_type', 'valid_values', 'default', 'description', 'global_only'] -) - -CONFIG_OPTIONS = { - 'runner.poll.interval': { - 'key': 'runner_poll_interval', - 'valid_type': 'int', - 'valid_values': None, - 'default': 60, - 'description': 'The polling interval in seconds to be used by process runners', - 'global_only': False, - }, - 'daemon.default_workers': { - 'key': 'daemon_default_workers', - 'valid_type': 'int', - 'valid_values': None, - 'default': DEFAULT_DAEMON_WORKERS, - 'description': 'The default number of workers to be launched by `verdi daemon start`', - 'global_only': False, - }, - 'daemon.timeout': { - 'key': 'daemon_timeout', - 'valid_type': 'int', - 'valid_values': None, - 'default': DEFAULT_DAEMON_TIMEOUT, - 'description': 'The timeout in seconds for calls to the circus client', - 'global_only': False, - }, - 'daemon.worker_process_slots': { - 'key': 'daemon_worker_process_slots', - 'valid_type': 'int', - 'valid_values': None, - 'default': DEFAULT_DAEMON_WORKER_PROCESS_SLOTS, - 'description': 'The maximum number of concurrent process tasks that each daemon worker can handle', - 'global_only': False, - }, - 'db.batch_size': { - 'key': 'db_batch_size', - 'valid_type': 'int', - 'valid_values': None, - 'default': 100000, - 'description': - 'Batch size for bulk CREATE operations in the database. Avoids hitting MaxAllocSize of PostgreSQL' - '(1GB) when creating large numbers of database records in one go.', - 'global_only': False, - }, - 'verdi.shell.auto_import': { - 'key': 'verdi_shell_auto_import', - 'valid_type': 'string', - 'valid_values': None, - 'default': '', - 'description': 'Additional modules/functions/classes to be automatically loaded in `verdi shell`', - 'global_only': False, - }, - 'logging.aiida_loglevel': { - 'key': 'logging_aiida_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'REPORT', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `aiida` logger', - 'global_only': False, - }, - 'logging.db_loglevel': { - 'key': 'logging_db_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'REPORT', - 'description': 'Minimum level to log to the DbLog table', - 'global_only': False, - }, - 'logging.plumpy_loglevel': { - 'key': 'logging_plumpy_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'WARNING', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `plumpy` logger', - 'global_only': False, - }, - 'logging.kiwipy_loglevel': { - 'key': 'logging_kiwipy_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'WARNING', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `kiwipy` logger', - 'global_only': False, - }, - 'logging.paramiko_loglevel': { - 'key': 'logging_paramiko_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'WARNING', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `paramiko` logger', - 'global_only': False, - }, - 'logging.alembic_loglevel': { - 'key': 'logging_alembic_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'WARNING', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `alembic` logger', - 'global_only': False, - }, - 'logging.sqlalchemy_loglevel': { - 'key': 'logging_sqlalchemy_loglevel', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'WARNING', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `sqlalchemy` logger', - 'global_only': False, - }, - 'logging.circus_loglevel': { - 'key': 'logging_circus_log_level', - 'valid_type': 'string', - 'valid_values': VALID_LOG_LEVELS, - 'default': 'INFO', - 'description': 'Minimum level to log to daemon log and the `DbLog` table for the `circus` logger', - 'global_only': False, - }, - 'user.email': { - 'key': 'user_email', - 'valid_type': 'string', - 'valid_values': None, - 'default': NO_DEFAULT, - 'description': 'Default user email to use when creating new profiles.', - 'global_only': True, - }, - 'user.first_name': { - 'key': 'user_first_name', - 'valid_type': 'string', - 'valid_values': None, - 'default': NO_DEFAULT, - 'description': 'Default user first name to use when creating new profiles.', - 'global_only': True, - }, - 'user.last_name': { - 'key': 'user_last_name', - 'valid_type': 'string', - 'valid_values': None, - 'default': NO_DEFAULT, - 'description': 'Default user last name to use when creating new profiles.', - 'global_only': True, - }, - 'user.institution': { - 'key': 'user_institution', - 'valid_type': 'string', - 'valid_values': None, - 'default': NO_DEFAULT, - 'description': 'Default user institution to use when creating new profiles.', - 'global_only': True, - }, - 'warnings.showdeprecations': { - 'key': 'show_deprecations', - 'valid_type': 'bool', - 'valid_values': None, - 'default': True, - 'description': 'Boolean whether to print AiiDA deprecation warnings', - 'global_only': False, - }, - 'transport.task_retry_initial_interval': { - 'key': 'task_retry_initial_interval', - 'valid_type': 'int', - 'valid_values': None, - 'default': 20, - 'description': 'Initial time interval for the exponential backoff mechanism.', - 'global_only': False, - }, - 'transport.task_maximum_attempts': { - 'key': 'task_maximum_attempts', - 'valid_type': 'int', - 'valid_values': None, - 'default': 5, - 'description': 'Maximum number of transport task attempts before a Process is Paused.', - 'global_only': False, - }, -} - - -def get_option(option_name): - """Return a configuration option.configuration - - :param option_name: the name of the configuration option - :return: the configuration option - :raises ValueError: if the configuration option does not exist - """ - try: - option = Option(option_name, **CONFIG_OPTIONS[option_name]) - except KeyError: - raise ValueError(f'the option {option_name} does not exist') - else: - return option +import jsonschema +from aiida.common.exceptions import ConfigurationError -def get_option_names(): - """Return a list of available option names. +__all__ = ('get_option', 'get_option_names', 'parse_option', 'Option') - :return: list of available option names - """ - return CONFIG_OPTIONS.keys() +NO_DEFAULT = () -def parse_option(option_name, option_value): +class Option: + """Represent a configuration option schema.""" + + def __init__(self, name: str, schema: Dict[str, Any]): + self._name = name + self._schema = schema + + def __str__(self) -> str: + return f'Option(name={self._name})' + + @property + def name(self) -> str: + return self._name + + @property + def schema(self) -> Dict[str, Any]: + return self._schema + + @property + def valid_type(self) -> Any: + return self._schema.get('type', None) + + @property + def default(self) -> Any: + return self._schema.get('default', NO_DEFAULT) + + @property + def description(self) -> str: + return self._schema.get('description', '') + + @property + def global_only(self) -> bool: + return self._schema.get('global_only', False) + + def validate(self, value: Any, cast: bool = True) -> Any: + """Validate a value + + :param value: The input value + :param cast: Attempt to cast the value to the required type + + :return: The output value + :raise: ConfigValidationError + + """ + # pylint: disable=too-many-branches + from .config import ConfigValidationError + from aiida.manage.caching import _validate_identifier_pattern + + if cast: + try: + if self.valid_type == 'boolean': + if isinstance(value, str): + if value.strip().lower() in ['0', 'false', 'f']: + value = False + elif value.strip().lower() in ['1', 'true', 't']: + value = True + else: + value = bool(value) + elif self.valid_type == 'string': + value = str(value) + elif self.valid_type == 'integer': + value = int(value) + elif self.valid_type == 'number': + value = float(value) + elif self.valid_type == 'array' and isinstance(value, str): + value = value.split() + except ValueError: + pass + + try: + jsonschema.validate(instance=value, schema=self.schema) + except jsonschema.ValidationError as exc: + raise ConfigValidationError(message=exc.message, keypath=[self.name, *(exc.path or [])], schema=exc.schema) + + # special caching validation + if self.name in ('caching.enabled_for', 'caching.disabled_for'): + for i, identifier in enumerate(value): + try: + _validate_identifier_pattern(identifier=identifier) + except ValueError as exc: + raise ConfigValidationError(message=str(exc), keypath=[self.name, str(i)]) + + return value + + +def get_schema_options() -> Dict[str, Dict[str, Any]]: + """Return schema for options.""" + from .config import config_schema + schema = config_schema() + return schema['definitions']['options']['properties'] + + +def get_option_names() -> List[str]: + """Return a list of available option names.""" + return list(get_schema_options()) + + +def get_option(name: str) -> Option: + """Return option.""" + options = get_schema_options() + if name not in options: + raise ConfigurationError(f'the option {name} does not exist') + return Option(name, options[name]) + + +def parse_option(option_name: str, option_value: Any) -> Tuple[Option, Any]: """Parse and validate a value for a configuration option. :param option_name: the name of the configuration option :param option_value: the option value :return: a tuple of the option and the parsed value + """ option = get_option(option_name) - - value = False - - if option.valid_type == 'bool': - if isinstance(option_value, str): - if option_value.strip().lower() in ['0', 'false', 'f']: - value = False - elif option_value.strip().lower() in ['1', 'true', 't']: - value = True - else: - raise ValueError(f'option {option.name} expects a boolean value') - else: - value = bool(option_value) - elif option.valid_type == 'string': - value = str(option_value) - elif option.valid_type == 'int': - value = int(option_value) - elif option.valid_type == 'list_of_str': - value = option_value.split() - else: - raise NotImplementedError(f'Type string {option.valid_type} not implemented yet') - - if option.valid_values is not None: - if value not in option.valid_values: - raise ValueError( - '{} is not among the list of accepted values for option {}.\nThe valid values are: ' - '{}'.format(value, option.name, ', '.join(option.valid_values)) - ) + value = option.validate(option_value, cast=True) return option, value diff --git a/aiida/manage/configuration/profile.py b/aiida/manage/configuration/profile.py index e8276d3f06..593302116a 100644 --- a/aiida/manage/configuration/profile.py +++ b/aiida/manage/configuration/profile.py @@ -12,6 +12,8 @@ import os from aiida.common import exceptions + +from .options import parse_option from .settings import DAEMON_DIR, DAEMON_LOG_DIR __all__ = ('Profile',) @@ -270,8 +272,9 @@ def set_option(self, option_key, value, override=True): :param option_value: the option value :param override: boolean, if False, will not override the option if it already exists """ + _, parsed_value = parse_option(option_key, value) # ensure the value is validated if option_key not in self.options or override: - self.options[option_key] = value + self.options[option_key] = parsed_value def unset_option(self, option_key): self.options.pop(option_key, None) diff --git a/aiida/manage/configuration/schema/__init__.py b/aiida/manage/configuration/schema/__init__.py new file mode 100644 index 0000000000..2776a55f97 --- /dev/null +++ b/aiida/manage/configuration/schema/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### diff --git a/aiida/manage/configuration/schema/config-v5.schema.json b/aiida/manage/configuration/schema/config-v5.schema.json new file mode 100644 index 0000000000..7a63492747 --- /dev/null +++ b/aiida/manage/configuration/schema/config-v5.schema.json @@ -0,0 +1,363 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "description": "Schema for AiiDA configuration files, format version 5", + "type": "object", + "definitions": { + "options": { + "type": "object", + "properties": { + "runner.poll.interval": { + "type": "integer", + "default": 60, + "minimum": 0, + "description": "Polling interval in seconds to be used by process runners" + }, + "daemon.default_workers": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Default number of workers to be launched by `verdi daemon start`" + }, + "daemon.timeout": { + "type": "integer", + "default": 20, + "minimum": 0, + "description": "Timeout in seconds for calls to the circus client" + }, + "daemon.worker_process_slots": { + "type": "integer", + "default": 200, + "minimum": 1, + "description": "Maximum number of concurrent process tasks that each daemon worker can handle" + }, + "db.batch_size": { + "type": "integer", + "default": 100000, + "minimum": 1, + "description": "Batch size for bulk CREATE operations in the database. Avoids hitting MaxAllocSize of PostgreSQL (1GB) when creating large numbers of database records in one go." + }, + "verdi.shell.auto_import": { + "type": "string", + "default": "", + "description": "Additional modules/functions/classes to be automatically loaded in `verdi shell`, split by ':'" + }, + "logging.aiida_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "REPORT", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `aiida` logger" + }, + "logging.db_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "REPORT", + "description": "Minimum level to log to the DbLog table" + }, + "logging.plumpy_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "WARNING", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `plumpy` logger" + }, + "logging.kiwipy_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "WARNING", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `kiwipy` logger" + }, + "logging.paramiko_loglevel": { + "key": "logging_paramiko_log_level", + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "WARNING", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `paramiko` logger" + }, + "logging.alembic_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "WARNING", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `alembic` logger" + }, + "logging.sqlalchemy_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "WARNING", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `sqlalchemy` logger" + }, + "logging.circus_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "INFO", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `circus` logger" + }, + "warnings.showdeprecations": { + "type": "boolean", + "default": true, + "description": "Whether to print AiiDA deprecation warnings" + }, + "transport.task_retry_initial_interval": { + "type": "integer", + "default": 20, + "minimum": 1, + "description": "Initial time interval for the exponential backoff mechanism." + }, + "transport.task_maximum_attempts": { + "type": "integer", + "default": 5, + "minimum": 1, + "description": "Maximum number of transport task attempts before a Process is Paused." + }, + "rmq.task_timeout": { + "type": "integer", + "default": 10, + "minimum": 1, + "description": "Timeout in seconds for communications with RabbitMQ" + }, + "caching.default_enabled": { + "type": "boolean", + "default": false, + "description": "Enable calculation caching by default" + }, + "caching.enabled_for": { + "description": "Calculation entry points to enable caching on", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "caching.disabled_for": { + "description": "Calculation entry points to disable caching on", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "autofill.user.email": { + "type": "string", + "global_only": true, + "description": "Default user email to use when creating new profiles." + }, + "autofill.user.first_name": { + "type": "string", + "global_only": true, + "description": "Default user first name to use when creating new profiles." + }, + "autofill.user.last_name": { + "type": "string", + "global_only": true, + "description": "Default user last name to use when creating new profiles." + }, + "autofill.user.institution": { + "type": "string", + "global_only": true, + "description": "Default user institution to use when creating new profiles." + } + } + }, + "profile": { + "type": "object", + "required": [ + "AIIDADB_REPOSITORY_URI", + "AIIDADB_BACKEND", + "AIIDADB_ENGINE", + "AIIDADB_HOST", + "AIIDADB_NAME", + "AIIDADB_PASS", + "AIIDADB_PORT", + "AIIDADB_USER" + ], + "properties": { + "PROFILE_UUID": { + "description": "The profile's unique key", + "type": "string" + }, + "AIIDADB_REPOSITORY_URI": { + "type": "string", + "description": "URI to the AiiDA object store" + }, + "AIIDADB_ENGINE": { + "type": "string", + "default": "postgresql_psycopg2" + }, + "AIIDADB_BACKEND": { + "type": "string", + "enum": [ + "django", + "sqlalchemy" + ], + "default": "django" + }, + "AIIDADB_NAME": { + "type": "string" + }, + "AIIDADB_PORT": { + "type": ["integer", "string"], + "minimum": 1, + "pattern": "\\d+", + "default": 5432 + }, + "AIIDADB_HOST": { + "type": [ + "string", + "null" + ], + "default": null + }, + "AIIDADB_USER": { + "type": "string" + }, + "AIIDADB_PASS": { + "type": [ + "string", + "null" + ], + "default": null + }, + "broker_protocol": { + "description": "Protocol for connecting to the RabbitMQ server", + "type": "string", + "enum": [ + "amqp", + "amqps" + ], + "default": "amqp" + }, + "broker_username": { + "description": "Username for RabbitMQ authentication", + "type": "string", + "default": "guest" + }, + "broker_password": { + "description": "Password for RabbitMQ authentication", + "type": "string", + "default": "guest" + }, + "broker_host": { + "description": "Hostname of the RabbitMQ server", + "type": "string", + "default": "127.0.0.1" + }, + "broker_port": { + "description": "Port of the RabbitMQ server", + "type": "integer", + "minimum": 1, + "default": 5672 + }, + "broker_virtual_host": { + "description": "RabbitMQ virtual host to connect to", + "type": "string", + "default": "" + }, + "default_user_email": { + "type": [ + "string", + "null" + ], + "default": null + }, + "options": { + "description": "Profile specific options", + "$ref": "#/definitions/options" + } + } + } + }, + "required": [], + "properties": { + "CONFIG_VERSION": { + "description": "The configuration version", + "type": "object", + "required": [ + "CURRENT", + "OLDEST_COMPATIBLE" + ], + "properties": { + "CURRENT": { + "description": "Version number of configuration file format", + "type": "integer", + "const": 5 + }, + "OLDEST_COMPATIBLE": { + "description": "Version number of oldest configuration file format this file is compatible with", + "type": "integer", + "const": 5 + } + } + }, + "profiles": { + "description": "Configured profiles", + "type": "object", + "patternProperties": { + ".+": { + "$ref": "#/definitions/profile" + } + } + }, + "default_profile": { + "description": "Default profile to use", + "type": "string" + }, + "options": { + "description": "Global options", + "$ref": "#/definitions/options" + } + } +} diff --git a/aiida/manage/manager.py b/aiida/manage/manager.py index 1c88029764..8f8bdfd1f1 100644 --- a/aiida/manage/manager.py +++ b/aiida/manage/manager.py @@ -258,6 +258,7 @@ def create_communicator( task_exchange=rmq.get_task_exchange_name(prefix), task_queue=rmq.get_launch_queue_name(prefix), task_prefetch_count=task_prefetch_count, + async_task_timeout=self.get_config().get_option('rmq.task_timeout', profile.name), # This is needed because the verdi commands will call this function and when called in unit tests the # testing_mode cannot be set. testing_mode=profile.is_test_profile, diff --git a/docs/source/developer_guide/core/internals.rst b/docs/source/developer_guide/core/internals.rst index ce32443512..efb9168270 100644 --- a/docs/source/developer_guide/core/internals.rst +++ b/docs/source/developer_guide/core/internals.rst @@ -405,7 +405,7 @@ In case a method is renamed or removed, this is the procedure to follow: - Our ``AiidaDeprecationWarning`` does not inherit from ``DeprecationWarning``, so it will not be "hidden" by python - User can disable our warnings (and only those) by using AiiDA properties with:: - verdi config warnings.showdeprecations False + verdi config set warnings.showdeprecations False Changing the config.json structure ++++++++++++++++++++++++++++++++++ diff --git a/docs/source/howto/faq.rst b/docs/source/howto/faq.rst index 92337b5131..e86cfa0982 100644 --- a/docs/source/howto/faq.rst +++ b/docs/source/howto/faq.rst @@ -71,7 +71,7 @@ To determine exactly what might be going wrong, first :ref:`set the loglevel ` command. -To set a configurational option, simply pass the name of the option and the value to set ``verdi config OPTION_NAME OPTION_VALUE``. -The available options are tab-completed, so simply type ``verdi config`` and thit twice to list them. - -For example, if you want to change the default number of workers that are created when you start the daemon, you can run: - -.. code:: bash - - $ verdi config daemon.default_workers 5 - Success: daemon.default_workers set to 5 for profile-one - -You can check the currently defined value of any option by simply calling the command without specifying a value, for example: -.. code:: bash +AiiDA provides various configurational options for profiles, which can be controlled with the :ref:`verdi config` command. - $ verdi config daemon.default_workers +To view all configuration options set for the current profile: + +.. code:: console + + $ verdi config list + name source value + ------------------------------------- -------- ------------ + autofill.user.email global abc@test.com + autofill.user.first_name global chris + autofill.user.institution global epfl + autofill.user.last_name global sewell + caching.default_enabled default False + caching.disabled_for default + caching.enabled_for default + daemon.default_workers default 1 + daemon.timeout profile 20 + daemon.worker_process_slots default 200 + db.batch_size default 100000 + logging.aiida_loglevel default REPORT + logging.alembic_loglevel default WARNING + logging.circus_loglevel default INFO + logging.db_loglevel default REPORT + logging.kiwipy_loglevel default WARNING + logging.paramiko_loglevel default WARNING + logging.plumpy_loglevel default WARNING + logging.sqlalchemy_loglevel default WARNING + rmq.task_timeout default 10 + runner.poll.interval profile 50 + transport.task_maximum_attempts global 6 + transport.task_retry_initial_interval default 20 + verdi.shell.auto_import default + warnings.showdeprecations default True + +Configuration option values are taken, in order of priority, from either the profile specific setting, the global setting (applies to all profiles), or the default value. + +You can also filter by a prefix: + +.. code:: console + + $ verdi config list transport + name source value + ------------------------------------- -------- ------------ + transport.task_maximum_attempts global 6 + transport.task_retry_initial_interval default 20 + +To show the full information for a configuration option or get its current value: + +.. code:: console + + $ verdi config show transport.task_maximum_attempts + schema: + default: 5 + description: Maximum number of transport task attempts before a Process is Paused. + minimum: 1 + type: integer + values: + default: 5 + global: 6 + profile: + $ verdi config get transport.task_maximum_attempts + 6 + +You can also retrieve the value *via* the API: + +.. code-block:: ipython + + In [1]: from aiida import get_config_option + In [2]: get_config_option('transport.task_maximum_attempts') + Out[2]: 6 + +To set a value, at the profile or global level: + +.. code-block:: console + + $ verdi config set transport.task_maximum_attempts 10 + Success: 'transport.task_maximum_attempts' set to 10 for 'quicksetup' profile + $ verdi config set --global transport.task_maximum_attempts 20 + Success: 'transport.task_maximum_attempts' set to 20 globally + $ verdi config show transport.task_maximum_attempts + schema: + type: integer + default: 5 + minimum: 1 + description: Maximum number of transport task attempts before a Process is Paused. + values: + default: 5 + global: 20 + profile: 10 + $ verdi config get transport.task_maximum_attempts + 10 + +.. tip:: + + By default any option set through ``verdi config`` will be applied to the current default profile. + To change the profile you can use the :ref:`profile option`. + +Similarly to unset a value: + +.. code-block:: console + + $ verdi config unset transport.task_maximum_attempts + Success: 'transport.task_maximum_attempts' unset for 'quicksetup' profile + $ verdi config unset --global transport.task_maximum_attempts + Success: 'transport.task_maximum_attempts' unset globally + $ verdi config show transport.task_maximum_attempts + schema: + type: integer + default: 5 + minimum: 1 + description: Maximum number of transport task attempts before a Process is Paused. + values: + default: 5 + global: + profile: + $ verdi config get transport.task_maximum_attempts 5 -If no value is displayed, it means that no value has ever explicitly been set for this particular option and the default will always be used. -By default any option set through ``verdi config`` will be applied to the current default profile. -To change the profile you can use the :ref:`profile option`. - -To undo the configuration of a particular option and reset it so the default value is used, you can use the ``--unset`` option: - -.. code:: bash - - $ verdi config daemon.default_workers --unset - Success: daemon.default_workers unset for profile-one - -If you want to set a particular option that should be applied to all profiles, you can use the ``--global`` flag: - -.. code:: bash - - $ verdi config daemon.default_workers 5 --global - Success: daemon.default_workers set to 5 globally - -and just as on a per-profile basis, this can be undone with the ``--unset`` flag: - -.. code:: bash - - $ verdi config daemon.default_workers --unset --global - Success: daemon.default_workers unset globally - .. important:: Changes that affect the daemon (e.g. ``logging.aiida_loglevel``) will only take affect after restarting the daemon. +.. seealso:: :ref:`How-to configure caching ` + .. _how-to:installation:configure:instance-isolation: diff --git a/docs/source/howto/run_codes.rst b/docs/source/howto/run_codes.rst index 8468fe62f8..716ed8b192 100644 --- a/docs/source/howto/run_codes.rst +++ b/docs/source/howto/run_codes.rst @@ -403,30 +403,57 @@ How to save computational resources using caching ================================================= There are numerous reasons why you might need to re-run calculations you have already run before. -Maybe you run a great number of complex workflows in high-throughput that each may repeat the same calculation, or you may have to restart an entire workflow that failed somewhere half-way through. + + If you run a great number of complex workflows in high-throughput, that each may repeat the same calculation, or you have to restart an entire workflow that failed somewhere half-way through. + Since AiiDA stores the full provenance of each calculation, it can detect whether a calculation has been run before and, instead of running it again, simply reuse its outputs, thereby saving valuable computational resources. This is what we mean by **caching** in AiiDA. +.. versionchanged:: 1.6.0 + + Caching configuration has moved from ``cache_config.yml`` to ``config.json`` (this will be migrated automatically). + To manipulate the caching configuration it is now advised to use the ``verdi config`` CLI, rather than directly changing the file. + .. _how-to:run-codes:caching:enable: How to enable caching --------------------- -Caching is **not enabled by default**. -The reason is that it is designed to work in an unobtrusive way and simply save time and valuable computational resources. +.. important:: Caching is **not** enabled by default. + +Caching is designed to work in an unobtrusive way and simply save time and valuable computational resources. However, this design is a double-egded sword, in that a user that might not be aware of this functionality, can be caught off guard by the results of their calculations. -.. important:: +The caching mechanism comes with some limitations and caveats that are important to understand. +Refer to the :ref:`topics:provenance:caching:limitations` section for more details. + +You can view and alter your current caching configuration through the command-line: + +.. code-block:: console + + $ verdi config list caching + name source value + ----------------------- -------- ------- + caching.default_enabled default False + caching.disabled_for default + caching.enabled_for default - The caching mechanism comes with some limitations and caveats that are important to understand. - Refer to the :ref:`topics:provenance:caching:limitations` section for more details. +.. seealso:: :ref:`how-to:installation:configure:options` -In order to enable caching for your profile (here called ``aiida_profile``), place the following ``cache_config.yml`` file in your ``.aiida`` configuration folder: +You can enable caching for your current profile or globally (for all profiles) by: -.. code-block:: yaml +.. code-block:: console - aiida_profile: - default: True + $ verdi config set caching.default_enabled True + Success: 'caching.default_enabled' set to True for 'quicksetup' profile + $ verdi config set -g caching.default_enabled True + Success: 'caching.default_enabled' set to True globally + $ verdi config list caching + name source value + ----------------------- -------- ------- + caching.default_enabled profile True + caching.disabled_for default + caching.enabled_for default From this point onwards, when you launch a new calculation, AiiDA will compare its hash (depending both on the type of calculation and its inputs, see :ref:`topics:provenance:caching:hashing`) against other calculations already present in your database. If another calculation with the same hash is found, AiiDA will reuse its results without repeating the actual calculation. @@ -455,48 +482,80 @@ The caching mechanism can be configured on a process class level, meaning the ru Class level ........... -Besides an on/off switch per profile, the ``.aiida/cache_config.yml`` provides control over caching at the level of specific calculations using their corresponding entry point strings (see the output of ``verdi plugin list aiida.calculations``): +Besides the on/off switch set by ``caching.default_enabled``, caching can be controlled at the level of specific calculations using their corresponding entry point strings (see the output of ``verdi plugin list aiida.calculations``): + +.. code-block:: console + + $ verdi config set caching.disabled_for aiida.calculations:templatereplacer + Success: 'caching.disabled_for' set to ['aiida.calculations:templatereplacer'] for 'quicksetup' profile + $ verdi config set caching.enabled_for aiida.calculations:quantumespresso.pw + Success: 'caching.enabled_for' set to ['aiida.calculations:quantumespresso.pw'] for 'quicksetup' profile + $ verdi config set --append caching.enabled_for aiida.calculations:other + Success: 'caching.enabled_for' set to ['aiida.calculations:quantumespresso.pw', 'aiida.calculations:other'] for 'quicksetup' profile + $ verdi config list caching + name source value + ----------------------- -------- ------------------------------------- + caching.default_enabled profile True + caching.disabled_for profile aiida.calculations:templatereplacer + caching.enabled_for profile aiida.calculations:quantumespresso.pw + aiida.calculations:other + +In this example, caching is enabled by default, but explicitly disabled for calculations of the ``TemplatereplacerCalculation`` class, identified by its corresponding ``aiida.calculations:templatereplacer`` entry point string. +It also shows how to enable caching for particular calculations (which has no effect here due to the profile-wide default). -.. code-block:: yaml +.. tip:: To set multiple entry-points at once, use a ``,`` delimiter. - aiida_profile: - default: False - enabled: - - aiida.calculations:quantumespresso.pw - disabled: - - aiida.calculations:templatereplacer +For the available entry-points in your environment, you can list which are enabled/disabled using: -In this example, where ``aiida_profile`` is the name of the profile, caching is disabled by default, but explicitly enabled for calculaions of the ``PwCalculation`` class, identified by its corresponding ``aiida.calculations:quantumespresso.pw`` entry point string. -It also shows how to disable caching for particular calculations (which has no effect here due to the profile-wide default). +.. code-block:: console + + $ verdi config caching + aiida.calculations:arithmetic.add + aiida.calculations:core.transfer + aiida.workflows:arithmetic.add_multiply + aiida.workflows:arithmetic.multiply_add + $ verdi config caching --disabled + aiida.calculations:templatereplacer For calculations which do not have an entry point, you need to specify the fully qualified Python name instead. -For example, the ``seekpath_structure_analysis`` calcfunction defined in ``aiida_quantumespresso.workflows.functions.seekpath_structure_analysis`` is labeled as ``aiida_quantumespresso.workflows.functions.seekpath_structure_analysis.seekpath_structure_analysis``. +For example, the ``seekpath_structure_analysis`` calcfunction defined in ``aiida_quantumespresso.workflows.functions.seekpath_structure_analysis`` is labelled as ``aiida_quantumespresso.workflows.functions.seekpath_structure_analysis.seekpath_structure_analysis``. From an existing :class:`~aiida.orm.nodes.process.calculation.CalculationNode`, you can get the identifier string through the ``process_type`` attribute. The caching configuration also accepts ``*`` wildcards. -For example, the following configuration enables caching for all calculation entry points defined by ``aiida-quantumespresso``, and the ``seekpath_structure_analysis`` calcfunction. -Note that the ``*.seekpath_structure_analysis`` entry needs to be quoted, because it starts with ``*`` which is a special character in YAML. +For example, the following configuration disables caching for all calculation entry points. -.. code-block:: yaml +.. code-block:: console - aiida_profile: - default: False - enabled: - - aiida.calculations:quantumespresso.* - - '*.seekpath_structure_analysis' + $ verdi config set caching.disabled_for 'aiida.calculations:*' + Success: 'caching.disabled_for' set to ['aiida.calculations:*'] for 'quicksetup' profile + $ verdi config caching + aiida.workflows:arithmetic.add_multiply + aiida.workflows:arithmetic.multiply_add + $ verdi config caching --disabled + aiida.calculations:arithmetic.add + aiida.calculations:core.transfer + aiida.calculations:templatereplacer Any entry with a wildcard is overridden by a more specific entry. -The following configuration enables caching for all ``aiida.calculation`` entry points, except those of ``aiida-quantumespresso``: +The following configuration disables caching for all ``aiida.calculation`` entry points, except those of ``arithmetic``: -.. code-block:: yaml - - aiida_profile: - default: False - enabled: - - aiida.calculations:* - disabled: - - aiida.calculations:quantumespresso.* +.. code-block:: console + $ verdi config set caching.enabled_for 'aiida.calculations:arithmetic.*' + Success: 'caching.enabled_for' set to ['aiida.calculations:arithmetic.*'] for 'quicksetup' profile + $ verdi config list caching + name source value + ----------------------- -------- ------------------------------- + caching.default_enabled profile True + caching.disabled_for profile aiida.calculations:* + caching.enabled_for profile aiida.calculations:arithmetic.* + $ verdi config caching + aiida.calculations:arithmetic.add + aiida.workflows:arithmetic.add_multiply + aiida.workflows:arithmetic.multiply_add + $ verdi config caching --disabled + aiida.calculations:core.transfer + aiida.calculations:templatereplacer Instance level .............. diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index ecfdb106bb..845391ba44 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -144,3 +144,5 @@ py:class alembic.config.Config py:class pgsu.PGSU py:meth pgsu.PGSU.__init__ + +py:class jsonschema.exceptions._Error diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 72ee2c4940..88065b134f 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -154,14 +154,20 @@ Below is a list with all available subcommands. .. code:: console - Usage: [OPTIONS] OPTION_NAME OPTION_VALUE + Usage: [OPTIONS] COMMAND [ARGS]... - Configure profile-specific or global AiiDA options. + Manage the AiiDA configuration. Options: - --global Apply the option configuration wide. - --unset Remove the line matching the option name from the config file. - --help Show this message and exit. + --help Show this message and exit. + + Commands: + caching List caching-enabled process types for the current profile. + get Get the value of an AiiDA option for the current profile. + list List AiiDA options for the current profile. + set Set an AiiDA option. + show Show details of an AiiDA option for the current profile. + unset Unset an AiiDA option. .. _reference:command-line:verdi-daemon: diff --git a/docs/source/topics/provenance/caching.rst b/docs/source/topics/provenance/caching.rst index dd5e242ec3..f790b27a16 100644 --- a/docs/source/topics/provenance/caching.rst +++ b/docs/source/topics/provenance/caching.rst @@ -80,11 +80,11 @@ The only way in which these can be influenced by plugin designers is indirectly Controlling Caching ------------------- -Although you can't directly controll the hashing mechanism of the process node when implementing a plugin, there are ways in which you can control its caching: +Although you can't directly control the hashing mechanism of the process node when implementing a plugin, there are ways in which you can control its caching: * The :meth:`spec.exit_code ` has a keyword argument ``invalidates_cache``. If this is set to ``True``, that means that a calculation with this exit code will not be used as a cache source for another one, even if their hashes match. -* The :class:`Process ` parent class from which calcjobs inherit has an :meth:`is_valid_cache ` method, which can be overriden in the plugin to implement custom ways of invalidating the cache. +* The :class:`Process ` parent class from which calcjobs inherit has an :meth:`is_valid_cache ` method, which can be overridden in the plugin to implement custom ways of invalidating the cache. When doing this, make sure to call :meth:`super().is_valid_cache(node)` and respect its output: if it is `False`, your implementation should also return `False`. If you do not comply with this, the 'invalidates_cache' keyword on exit codes will not work. diff --git a/environment.yml b/environment.yml index c4b0a30cc7..bac8daa6ae 100644 --- a/environment.yml +++ b/environment.yml @@ -20,6 +20,7 @@ dependencies: - python-graphviz~=0.13 - ipython~=7.20 - jinja2~=2.10 +- jsonschema~=3.0 - kiwipy[rmq]~=0.7.2 - numpy~=1.17 - pamqp~=2.3 diff --git a/pyproject.toml b/pyproject.toml index fc69637151..aac39bf675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ commands = pytest {posargs} [testenv:py{36,37,38,39}-verdi] setenv = AIIDA_TEST_BACKEND = django + AIIDA_PATH = {toxinidir}/.tox/.aiida commands = verdi {posargs} [testenv:py{36,37,38,39}-docs-{clean,update}] diff --git a/setup.json b/setup.json index dbe31e3525..bbd64a6b02 100644 --- a/setup.json +++ b/setup.json @@ -34,6 +34,7 @@ "graphviz~=0.13", "ipython~=7.20", "jinja2~=2.10", + "jsonschema~=3.0", "kiwipy[rmq]~=0.7.2", "numpy~=1.17", "pamqp~=2.3", diff --git a/tests/cmdline/commands/test_config.py b/tests/cmdline/commands/test_config.py index 5aec1ed3c5..93fb03fb46 100644 --- a/tests/cmdline/commands/test_config.py +++ b/tests/cmdline/commands/test_config.py @@ -7,25 +7,22 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### +# pylint: disable=no-self-use """Tests for `verdi config`.""" +import pytest -from click.testing import CliRunner - -from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_verdi from aiida.manage.configuration import get_config -from tests.utils.configuration import with_temporary_config_instance - -class TestVerdiConfig(AiidaTestCase): - """Tests for `verdi config`.""" +class TestVerdiConfigDeprecated: + """Tests for deprecated `verdi config `.""" - def setUp(self): - self.cli_runner = CliRunner() + @pytest.fixture(autouse=True) + def setup_fixture(self, config_with_profile_factory): + config_with_profile_factory() - @with_temporary_config_instance - def test_config_set_option(self): + def test_config_set_option(self, run_cli_command): """Test the `verdi config` command when setting an option.""" config = get_config() @@ -34,67 +31,187 @@ def test_config_set_option(self): for option_value in option_values: options = ['config', option_name, str(option_value)] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) + run_cli_command(cmd_verdi.verdi, options) - self.assertClickSuccess(result) - self.assertEqual(str(config.get_option(option_name, scope=config.current_profile.name)), option_value) + assert str(config.get_option(option_name, scope=config.current_profile.name)) == option_value - @with_temporary_config_instance - def test_config_get_option(self): + def test_config_get_option(self, run_cli_command): """Test the `verdi config` command when getting an option.""" option_name = 'daemon.timeout' option_value = str(30) options = ['config', option_name, option_value] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) - self.assertClickResultNoException(result) + result = run_cli_command(cmd_verdi.verdi, options) options = ['config', option_name] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) - self.assertIn(option_value, result.output.strip()) + result = run_cli_command(cmd_verdi.verdi, options) + + assert option_value in result.output.strip() - @with_temporary_config_instance - def test_config_unset_option(self): + def test_config_unset_option(self, run_cli_command): """Test the `verdi config` command when unsetting an option.""" option_name = 'daemon.timeout' option_value = str(30) options = ['config', option_name, str(option_value)] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) + result = run_cli_command(cmd_verdi.verdi, options) options = ['config', option_name] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) - self.assertIn(option_value, result.output.strip()) + result = run_cli_command(cmd_verdi.verdi, options) + + assert option_value in result.output.strip() options = ['config', option_name, '--unset'] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) - self.assertIn(f'{option_name} unset', result.output.strip()) + result = run_cli_command(cmd_verdi.verdi, options) + + assert f"'{option_name}' unset" in result.output.strip() options = ['config', option_name] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) - self.assertEqual(result.output, '') + result = run_cli_command(cmd_verdi.verdi, options) + + # assert result.output == '' # now has deprecation warning - @with_temporary_config_instance - def test_config_set_option_global_only(self): + def test_config_set_option_global_only(self, run_cli_command): """Test that `global_only` options are only set globally even if the `--global` flag is not set.""" config = get_config() - option_name = 'user.email' + option_name = 'autofill.user.email' option_value = 'some@email.com' options = ['config', option_name, str(option_value)] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) - self.assertClickSuccess(result) + result = run_cli_command(cmd_verdi.verdi, options) options = ['config', option_name] - result = self.cli_runner.invoke(cmd_verdi.verdi, options) + result = run_cli_command(cmd_verdi.verdi, options) + + # Check that the current profile name is not in the output + + assert option_value in result.output.strip() + assert config.current_profile.name not in result.output.strip() + + +class TestVerdiConfig: + """Tests for `verdi config`.""" + + @pytest.fixture(autouse=True) + def setup_fixture(self, config_with_profile_factory): + config_with_profile_factory() + + def test_config_set_option(self, run_cli_command): + """Test the `verdi config set` command when setting an option.""" + config = get_config() + + option_name = 'daemon.timeout' + option_values = [str(10), str(20)] + + for option_value in option_values: + options = ['config', 'set', option_name, str(option_value)] + run_cli_command(cmd_verdi.verdi, options) + assert str(config.get_option(option_name, scope=config.current_profile.name)) == option_value + + def test_config_append_option(self, run_cli_command): + """Test the `verdi config set --append` command when appending an option value.""" + config = get_config() + option_name = 'caching.enabled_for' + for value in ['x', 'y']: + options = ['config', 'set', '--append', option_name, value] + run_cli_command(cmd_verdi.verdi, options) + assert config.get_option(option_name, scope=config.current_profile.name) == ['x', 'y'] + + def test_config_remove_option(self, run_cli_command): + """Test the `verdi config set --remove` command when removing an option value.""" + config = get_config() + + option_name = 'caching.disabled_for' + config.set_option(option_name, ['x', 'y'], scope=config.current_profile.name) + + options = ['config', 'set', '--remove', option_name, 'x'] + run_cli_command(cmd_verdi.verdi, options) + assert config.get_option(option_name, scope=config.current_profile.name) == ['y'] + + def test_config_get_option(self, run_cli_command): + """Test the `verdi config show` command when getting an option.""" + option_name = 'daemon.timeout' + option_value = str(30) + + options = ['config', 'set', option_name, option_value] + result = run_cli_command(cmd_verdi.verdi, options) + + options = ['config', 'get', option_name] + result = run_cli_command(cmd_verdi.verdi, options) + assert option_value in result.output.strip() + + def test_config_unset_option(self, run_cli_command): + """Test the `verdi config` command when unsetting an option.""" + option_name = 'daemon.timeout' + option_value = str(30) + + options = ['config', 'set', option_name, str(option_value)] + result = run_cli_command(cmd_verdi.verdi, options) + + options = ['config', 'get', option_name] + result = run_cli_command(cmd_verdi.verdi, options) + assert option_value in result.output.strip() + + options = ['config', 'unset', option_name] + result = run_cli_command(cmd_verdi.verdi, options) + assert f"'{option_name}' unset" in result.output.strip() + + options = ['config', 'get', option_name] + result = run_cli_command(cmd_verdi.verdi, options) + assert result.output.strip() == str(20) # back to the default + + def test_config_set_option_global_only(self, run_cli_command): + """Test that `global_only` options are only set globally even if the `--global` flag is not set.""" + config = get_config() + option_name = 'autofill.user.email' + option_value = 'some@email.com' + + options = ['config', 'set', option_name, str(option_value)] + result = run_cli_command(cmd_verdi.verdi, options) + + options = ['config', 'get', option_name] + result = run_cli_command(cmd_verdi.verdi, options) # Check that the current profile name is not in the output - self.assertClickSuccess(result) - self.assertIn(option_value, result.output.strip()) - self.assertNotIn(config.current_profile.name, result.output.strip()) + assert option_value in result.output.strip() + assert config.current_profile.name not in result.output.strip() + + def test_config_list(self, run_cli_command): + """Test `verdi config list`""" + options = ['config', 'list'] + result = run_cli_command(cmd_verdi.verdi, options) + + assert 'daemon.timeout' in result.output + assert 'Timeout in seconds' not in result.output + + def test_config_list_description(self, run_cli_command): + """Test `verdi config list --description`""" + for flag in ['-d', '--description']: + options = ['config', 'list', flag] + result = run_cli_command(cmd_verdi.verdi, options) + + assert 'daemon.timeout' in result.output + assert 'Timeout in seconds' in result.output + + def test_config_show(self, run_cli_command): + """Test `verdi config show`""" + options = ['config', 'show', 'daemon.timeout'] + result = run_cli_command(cmd_verdi.verdi, options) + assert 'schema' in result.output + + def test_config_caching(self, run_cli_command): + """Test `verdi config caching`""" + result = run_cli_command(cmd_verdi.verdi, ['config', 'caching']) + assert result.output.strip() == '' + + result = run_cli_command(cmd_verdi.verdi, ['config', 'caching', '--disabled']) + assert 'arithmetic.add' in result.output.strip() + + config = get_config() + config.set_option('caching.default_enabled', True, scope=config.current_profile.name) + + result = run_cli_command(cmd_verdi.verdi, ['config', 'caching']) + assert 'arithmetic.add' in result.output.strip() + + result = run_cli_command(cmd_verdi.verdi, ['config', 'caching', '--disabled']) + assert result.output.strip() == '' diff --git a/tests/cmdline/commands/test_status.py b/tests/cmdline/commands/test_status.py index 275d3b8926..4e7f2ca02d 100644 --- a/tests/cmdline/commands/test_status.py +++ b/tests/cmdline/commands/test_status.py @@ -28,7 +28,7 @@ def test_status(run_cli_command): assert string in result.output -@pytest.mark.usefixtures('create_empty_config_instance') +@pytest.mark.usefixtures('empty_config') def test_status_no_profile(run_cli_command): """Test `verdi status` when there is no profile.""" options = [] diff --git a/tests/conftest.py b/tests/conftest.py index 79790bc128..1a1c7e1f00 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ import pytest -from aiida.manage.configuration import Config, Profile, get_config +from aiida.manage.configuration import Config, Profile, get_config, load_profile pytest_plugins = ['aiida.manage.tests.pytest_fixtures', 'sphinx.testing.fixtures'] # pylint: disable=invalid-name @@ -152,7 +152,7 @@ def _generate_calculation_node(process_state=ProcessState.FINISHED, exit_status= @pytest.fixture -def create_empty_config_instance(tmp_path) -> Config: +def empty_config(tmp_path) -> Config: """Create a temporary configuration instance. This creates a temporary directory with a clean `.aiida` folder and basic configuration file. The currently loaded @@ -162,7 +162,7 @@ def create_empty_config_instance(tmp_path) -> Config: """ from aiida.common.utils import Capturing from aiida.manage import configuration - from aiida.manage.configuration import settings, load_profile, reset_profile + from aiida.manage.configuration import settings, reset_profile # Store the current configuration instance and config directory path current_config = configuration.CONFIG @@ -193,7 +193,7 @@ def create_empty_config_instance(tmp_path) -> Config: @pytest.fixture -def create_profile() -> Profile: +def profile_factory() -> Profile: """Create a new profile instance. :return: the profile instance. @@ -220,6 +220,54 @@ def _create_profile(name, **kwargs): return _create_profile +@pytest.fixture +def config_with_profile_factory(empty_config, profile_factory) -> Config: + """Create a temporary configuration instance with one profile. + + This fixture builds on the `empty_config` fixture, to add a single profile. + + The defaults of the profile can be overridden in the callable, as well as whether it should be set as default. + + Example:: + + def test_config_with_profile(config_with_profile_factory): + config = config_with_profile_factory(set_as_default=True, name='default', database_backend='django') + assert config.current_profile.name == 'default' + + As with `empty_config`, the currently loaded configuration and profile are stored in memory, + and are automatically restored at the end of this context manager. + + This fixture should be used by tests that modify aspects of the AiiDA configuration or profile + and require a preconfigured profile, but do not require an actual configured database. + """ + + def _config_with_profile_factory(set_as_default=True, load=True, name='default', **kwargs): + """Create a temporary configuration instance with one profile. + + :param set_as_default: whether to set the one profile as the default. + :param load: whether to load the profile. + :param name: the profile name + :param kwargs: parameters that are forwarded to the `Profile` constructor. + + :return: a config instance with a configured profile. + """ + profile = profile_factory(name=name, **kwargs) + config = empty_config + config.add_profile(profile) + + if set_as_default: + config.set_default_profile(profile.name, overwrite=True) + + config.store() + + if load: + load_profile(profile.name) + + return config + + return _config_with_profile_factory + + @pytest.fixture def manager(aiida_profile): # pylint: disable=unused-argument """Get the ``Manager`` instance of the currently loaded profile.""" diff --git a/tests/manage/configuration/migrations/test_migrations.py b/tests/manage/configuration/migrations/test_migrations.py index 4251b3116a..d4e43203e7 100644 --- a/tests/manage/configuration/migrations/test_migrations.py +++ b/tests/manage/configuration/migrations/test_migrations.py @@ -70,3 +70,10 @@ def test_3_4_migration(self): config_reference = self.load_config_sample('reference/4.json') config_migrated = _MIGRATION_LOOKUP[3].apply(config_initial) self.assertEqual(config_migrated, config_reference) + + def test_4_5_migration(self): + """Test the step between config versions 4 and 5.""" + config_initial = self.load_config_sample('input/4.json') + config_reference = self.load_config_sample('reference/5.json') + config_migrated = _MIGRATION_LOOKUP[4].apply(config_initial) + self.assertEqual(config_migrated, config_reference) diff --git a/tests/manage/configuration/migrations/test_samples/input/4.json b/tests/manage/configuration/migrations/test_samples/input/4.json new file mode 100644 index 0000000000..0f3df0ec40 --- /dev/null +++ b/tests/manage/configuration/migrations/test_samples/input/4.json @@ -0,0 +1 @@ +{"CONFIG_VERSION": {"CURRENT": 4, "OLDEST_COMPATIBLE": 3}, "default_profile": "default", "profiles": {"default": {"PROFILE_UUID": "00000000000000000000000000000000", "AIIDADB_ENGINE": "postgresql_psycopg2", "AIIDADB_PASS": "some_random_password", "AIIDADB_NAME": "aiidadb_qs_some_user", "AIIDADB_HOST": "localhost", "AIIDADB_BACKEND": "django", "AIIDADB_PORT": "5432", "default_user_email": "email@aiida.net", "AIIDADB_REPOSITORY_URI": "file:////home/some_user/.aiida/repository-quicksetup/", "AIIDADB_USER": "aiida_qs_greschd", "broker_protocol": "amqp", "broker_username": "guest", "broker_password": "guest", "broker_host": "127.0.0.1", "broker_port": 5672, "broker_virtual_host": ""}}} diff --git a/tests/manage/configuration/migrations/test_samples/reference/5.json b/tests/manage/configuration/migrations/test_samples/reference/5.json new file mode 100644 index 0000000000..14f0942535 --- /dev/null +++ b/tests/manage/configuration/migrations/test_samples/reference/5.json @@ -0,0 +1 @@ +{"CONFIG_VERSION": {"CURRENT": 5, "OLDEST_COMPATIBLE": 5}, "default_profile": "default", "profiles": {"default": {"PROFILE_UUID": "00000000000000000000000000000000", "AIIDADB_ENGINE": "postgresql_psycopg2", "AIIDADB_PASS": "some_random_password", "AIIDADB_NAME": "aiidadb_qs_some_user", "AIIDADB_HOST": "localhost", "AIIDADB_BACKEND": "django", "AIIDADB_PORT": "5432", "default_user_email": "email@aiida.net", "AIIDADB_REPOSITORY_URI": "file:////home/some_user/.aiida/repository-quicksetup/", "AIIDADB_USER": "aiida_qs_greschd", "broker_protocol": "amqp", "broker_username": "guest", "broker_password": "guest", "broker_host": "127.0.0.1", "broker_port": 5672, "broker_virtual_host": ""}}} diff --git a/tests/manage/configuration/migrations/test_samples/reference/final.json b/tests/manage/configuration/migrations/test_samples/reference/final.json index 0f3df0ec40..14f0942535 100644 --- a/tests/manage/configuration/migrations/test_samples/reference/final.json +++ b/tests/manage/configuration/migrations/test_samples/reference/final.json @@ -1 +1 @@ -{"CONFIG_VERSION": {"CURRENT": 4, "OLDEST_COMPATIBLE": 3}, "default_profile": "default", "profiles": {"default": {"PROFILE_UUID": "00000000000000000000000000000000", "AIIDADB_ENGINE": "postgresql_psycopg2", "AIIDADB_PASS": "some_random_password", "AIIDADB_NAME": "aiidadb_qs_some_user", "AIIDADB_HOST": "localhost", "AIIDADB_BACKEND": "django", "AIIDADB_PORT": "5432", "default_user_email": "email@aiida.net", "AIIDADB_REPOSITORY_URI": "file:////home/some_user/.aiida/repository-quicksetup/", "AIIDADB_USER": "aiida_qs_greschd", "broker_protocol": "amqp", "broker_username": "guest", "broker_password": "guest", "broker_host": "127.0.0.1", "broker_port": 5672, "broker_virtual_host": ""}}} +{"CONFIG_VERSION": {"CURRENT": 5, "OLDEST_COMPATIBLE": 5}, "default_profile": "default", "profiles": {"default": {"PROFILE_UUID": "00000000000000000000000000000000", "AIIDADB_ENGINE": "postgresql_psycopg2", "AIIDADB_PASS": "some_random_password", "AIIDADB_NAME": "aiidadb_qs_some_user", "AIIDADB_HOST": "localhost", "AIIDADB_BACKEND": "django", "AIIDADB_PORT": "5432", "default_user_email": "email@aiida.net", "AIIDADB_REPOSITORY_URI": "file:////home/some_user/.aiida/repository-quicksetup/", "AIIDADB_USER": "aiida_qs_greschd", "broker_protocol": "amqp", "broker_username": "guest", "broker_password": "guest", "broker_host": "127.0.0.1", "broker_port": 5672, "broker_virtual_host": ""}}} diff --git a/tests/manage/configuration/test_config.py b/tests/manage/configuration/test_config.py index 71c02e1528..50a91f1643 100644 --- a/tests/manage/configuration/test_config.py +++ b/tests/manage/configuration/test_config.py @@ -374,7 +374,7 @@ def test_option(self): def test_option_global_only(self): """Test that `global_only` options are only set globally even if a profile specific scope is set.""" - option_name = 'user.email' + option_name = 'autofill.user.email' option_value = 'some@email.com' config = Config(self.config_filepath, self.config_dictionary) @@ -390,7 +390,7 @@ def test_option_global_only(self): def test_set_option_override(self): """Test that `global_only` options are only set globally even if a profile specific scope is set.""" - option_name = 'user.email' + option_name = 'autofill.user.email' option_value_one = 'first@email.com' option_value_two = 'second@email.com' diff --git a/tests/manage/configuration/test_options.py b/tests/manage/configuration/test_options.py index 972b7fb280..bf55730f77 100644 --- a/tests/manage/configuration/test_options.py +++ b/tests/manage/configuration/test_options.py @@ -10,8 +10,9 @@ """Tests for the configuration options.""" from aiida.backends.testbase import AiidaTestCase -from aiida.manage.configuration.options import get_option, get_option_names, parse_option, Option, CONFIG_OPTIONS -from aiida.manage.configuration import get_config, get_config_option +from aiida.common.exceptions import ConfigurationError +from aiida.manage.configuration.options import get_option, get_option_names, parse_option, Option +from aiida.manage.configuration import get_config, get_config_option, ConfigValidationError from tests.utils.configuration import with_temporary_config_instance @@ -21,14 +22,15 @@ class TestConfigurationOptions(AiidaTestCase): def test_get_option_names(self): """Test `get_option_names` function.""" - self.assertEqual(get_option_names(), CONFIG_OPTIONS.keys()) + self.assertIsInstance(get_option_names(), list) + self.assertEqual(len(get_option_names()), 25) def test_get_option(self): """Test `get_option` function.""" - with self.assertRaises(ValueError): + with self.assertRaises(ConfigurationError): get_option('no_existing_option') - option_name = list(CONFIG_OPTIONS)[0] + option_name = get_option_names()[0] option = get_option(option_name) self.assertIsInstance(option, Option) self.assertEqual(option.name, option_name) @@ -36,22 +38,20 @@ def test_get_option(self): def test_parse_option(self): """Test `parse_option` function.""" - with self.assertRaises(ValueError): + with self.assertRaises(ConfigValidationError): parse_option('logging.aiida_loglevel', 1) - with self.assertRaises(ValueError): + with self.assertRaises(ConfigValidationError): parse_option('logging.aiida_loglevel', 'INVALID_LOG_LEVEL') def test_options(self): """Test that all defined options can be converted into Option namedtuples.""" - for option_name, set_optiontings in CONFIG_OPTIONS.items(): + for option_name in get_option_names(): option = get_option(option_name) self.assertEqual(option.name, option_name) - self.assertEqual(option.key, set_optiontings['key']) - self.assertEqual(option.valid_type, set_optiontings['valid_type']) - self.assertEqual(option.valid_values, set_optiontings['valid_values']) - self.assertEqual(option.default, set_optiontings['default']) - self.assertEqual(option.description, set_optiontings['description']) + self.assertIsInstance(option.description, str) + option.valid_type # pylint: disable=pointless-statement + option.default # pylint: disable=pointless-statement @with_temporary_config_instance def test_get_config_option_default(self): diff --git a/tests/manage/configuration/test_profile.py b/tests/manage/configuration/test_profile.py index f60140b2d6..9009ef25cd 100644 --- a/tests/manage/configuration/test_profile.py +++ b/tests/manage/configuration/test_profile.py @@ -68,9 +68,9 @@ def test_is_test_profile(self): def test_set_option(self): """Test the `set_option` method.""" - option_key = 'user_email' - option_value_one = 'first@email.com' - option_value_two = 'second@email.com' + option_key = 'daemon.timeout' + option_value_one = 999 + option_value_two = 666 # Setting an option if it does not exist should work self.profile.set_option(option_key, option_value_one) diff --git a/tests/manage/test_caching_config.py b/tests/manage/test_caching_config.py index 1e6881d7e4..265d6a3d5e 100644 --- a/tests/manage/test_caching_config.py +++ b/tests/manage/test_caching_config.py @@ -11,74 +11,87 @@ # pylint: disable=redefined-outer-name -import tempfile import contextlib +import json +from pathlib import Path import yaml import pytest from aiida.common import exceptions -from aiida.manage.configuration import get_profile -from aiida.manage.caching import configure, get_use_cache, enable_caching, disable_caching +from aiida.manage.caching import get_use_cache, enable_caching, disable_caching @pytest.fixture -def configure_caching(): +def configure_caching(config_with_profile_factory): """ Fixture to set the caching configuration in the test profile to a specific dictionary. This is done by creating a temporary caching configuration file. """ + config = config_with_profile_factory() @contextlib.contextmanager def inner(config_dict): - with tempfile.NamedTemporaryFile() as handle: - yaml.dump({get_profile().name: config_dict}, handle, encoding='utf-8') - configure(config_file=handle.name) + for key, value in config_dict.items(): + config.set_option(f'caching.{key}', value) yield # reset the configuration - configure() + for key in config_dict.keys(): + config.unset_option(f'caching.{key}') return inner -@pytest.fixture -def use_default_configuration(configure_caching): # pylint: disable=redefined-outer-name - """ - Fixture to load a default caching configuration. - """ - with configure_caching( - config_dict={ - 'default': True, - 'enabled': ['aiida.calculations:arithmetic.add'], - 'disabled': ['aiida.calculations:templatereplacer'] - } - ): - yield +def test_merge_deprecated_yaml(tmp_path): + """Test that an existing 'cache_config.yml' is correctly merged into the main config. - -def test_empty_enabled_disabled(configure_caching): - """Test that `aiida.manage.caching.configure` does not except when either `enabled` or `disabled` is `None`. - - This will happen when the configuration file specifies either one of the keys but no actual values, e.g.:: - - profile_name: - default: False - enabled: - - In this case, the dictionary parsed by yaml will contain `None` for the `enabled` key. - Now this will be unlikely, but the same holds when all values are commented:: - - profile_name: - default: False - enabled: - # - aiida.calculations:templatereplacer - - which is not unlikely to occurr in the wild. + An AiidaDeprecationWarning should also be raised. """ - with configure_caching(config_dict={'default': True, 'enabled': None, 'disabled': None}): - # Check that `get_use_cache` also does not except, and works as expected - assert get_use_cache(identifier='aiida.calculations:templatereplacer') + from aiida.common.warnings import AiidaDeprecationWarning + from aiida.manage import configuration + from aiida.manage.configuration import settings, load_profile, reset_profile, get_config_option + + # Store the current configuration instance and config directory path + current_config = configuration.CONFIG + current_config_path = current_config.dirpath + current_profile_name = configuration.PROFILE.name + + try: + reset_profile() + configuration.CONFIG = None + + # Create a temporary folder, set it as the current config directory path + settings.AIIDA_CONFIG_FOLDER = str(tmp_path) + config_dictionary = json.loads( + Path(__file__).parent.joinpath('configuration/migrations/test_samples/reference/5.json').read_text() + ) + config_dictionary['profiles']['default']['AIIDADB_REPOSITORY_URI'] = f"file:///{tmp_path/'repo'}" + cache_dictionary = { + 'default': { + 'default': True, + 'enabled': ['aiida.calculations:quantumespresso.pw'], + 'disabled': ['aiida.calculations:templatereplacer'] + } + } + tmp_path.joinpath('config.json').write_text(json.dumps(config_dictionary)) + tmp_path.joinpath('cache_config.yml').write_text(yaml.dump(cache_dictionary)) + with pytest.warns(AiidaDeprecationWarning, match='cache_config.yml'): + configuration.CONFIG = configuration.load_config() + load_profile('default') + + assert get_config_option('caching.default_enabled') is True + assert get_config_option('caching.enabled_for') == ['aiida.calculations:quantumespresso.pw'] + assert get_config_option('caching.disabled_for') == ['aiida.calculations:templatereplacer'] + # should have now been moved to cache_config.yml. + assert not tmp_path.joinpath('cache_config.yml').exists() + finally: + # Reset the config folder path and the config instance. Note this will always be executed after the yield no + # matter what happened in the test that used this fixture. + reset_profile() + settings.AIIDA_CONFIG_FOLDER = current_config_path + configuration.CONFIG = current_config + load_profile(current_profile_name) def test_no_enabled_disabled(configure_caching): @@ -89,7 +102,7 @@ def test_no_enabled_disabled(configure_caching): profile_name: default: False """ - with configure_caching(config_dict={'default': False}): + with configure_caching(config_dict={'default_enabled': False}): # Check that `get_use_cache` also does not except, and works as expected assert not get_use_cache(identifier='aiida.calculations:templatereplacer') @@ -98,24 +111,24 @@ def test_no_enabled_disabled(configure_caching): 'config_dict', [{ 'wrong_key': ['foo'] }, { - 'default': 2 + 'default_enabled': 'x' }, { - 'enabled': 4 + 'enabled_for': 4 }, { - 'default': 'string' + 'default_enabled': 'string' }, { - 'enabled': ['aiida.spam:Ni'] + 'enabled_for': ['aiida.spam:Ni'] }, { - 'default': True, - 'enabled': ['aiida.calculations:With:second_separator'] + 'default_enabled': True, + 'enabled_for': ['aiida.calculations:With:second_separator'] }, { - 'enabled': ['aiida.sp*:Ni'] + 'enabled_for': ['aiida.sp*:Ni'] }, { - 'disabled': ['aiida.sp*!bar'] + 'disabled_for': ['aiida.sp*!bar'] }, { - 'enabled': ['startswith.number.2bad'] + 'enabled_for': ['startswith.number.2bad'] }, { - 'enabled': ['some.thing.in.this.is.a.keyword'] + 'enabled_for': ['some.thing.in.this.is.a.keyword'] }] ) def test_invalid_configuration_dict(configure_caching, config_dict): @@ -126,71 +139,73 @@ def test_invalid_configuration_dict(configure_caching, config_dict): pass -def test_invalid_identifier(use_default_configuration): # pylint: disable=unused-argument +def test_invalid_identifier(configure_caching): """Test `get_use_cache` raises a `TypeError` if identifier is not a string.""" - with pytest.raises(TypeError): - get_use_cache(identifier=int) + with configure_caching({}): + with pytest.raises(TypeError): + get_use_cache(identifier=int) -def test_default(use_default_configuration): # pylint: disable=unused-argument +def test_default(configure_caching): """Verify that when not specifying any specific identifier, the `default` is used, which is set to `True`.""" - assert get_use_cache() + with configure_caching({'default_enabled': True}): + assert get_use_cache() -@pytest.mark.parametrize(['config_dict', 'enabled', 'disabled'], [ +@pytest.mark.parametrize(['config_dict', 'enabled_for', 'disabled_for'], [ ({ - 'default': True, - 'enabled': ['aiida.calculations:arithmetic.add'], - 'disabled': ['aiida.calculations:templatereplacer'] + 'default_enabled': True, + 'enabled_for': ['aiida.calculations:arithmetic.add'], + 'disabled_for': ['aiida.calculations:templatereplacer'] }, ['some_identifier', 'aiida.calculations:arithmetic.add', 'aiida.calculations:TEMPLATEREPLACER' ], ['aiida.calculations:templatereplacer']), ({ - 'default': False, - 'enabled': ['aiida.calculations:arithmetic.add'], - 'disabled': ['aiida.calculations:templatereplacer'] + 'default_enabled': False, + 'enabled_for': ['aiida.calculations:arithmetic.add'], + 'disabled_for': ['aiida.calculations:templatereplacer'] }, ['aiida.calculations:arithmetic.add'], ['aiida.calculations:templatereplacer', 'some_identifier']), ({ - 'default': False, - 'enabled': ['aiida.calculations:*'], + 'default_enabled': False, + 'enabled_for': ['aiida.calculations:*'], }, ['aiida.calculations:templatereplacer', 'aiida.calculations:arithmetic.add'], ['some_identifier']), ({ - 'default': False, - 'enabled': ['aiida.calcul*'], + 'default_enabled': False, + 'enabled_for': ['aiida.calcul*'], }, ['aiida.calculations:templatereplacer', 'aiida.calculations:arithmetic.add'], ['some_identifier']), ({ - 'default': False, - 'enabled': ['aiida.calculations:*'], - 'disabled': ['aiida.calculations:arithmetic.add'] + 'default_enabled': False, + 'enabled_for': ['aiida.calculations:*'], + 'disabled_for': ['aiida.calculations:arithmetic.add'] }, ['aiida.calculations:templatereplacer', 'aiida.calculations:ARIthmetic.add' ], ['some_identifier', 'aiida.calculations:arithmetic.add']), ({ - 'default': False, - 'enabled': ['aiida.calculations:ar*thmetic.add'], - 'disabled': ['aiida.calculations:*'], + 'default_enabled': False, + 'enabled_for': ['aiida.calculations:ar*thmetic.add'], + 'disabled_for': ['aiida.calculations:*'], }, ['aiida.calculations:arithmetic.add', 'aiida.calculations:arblarghthmetic.add' ], ['some_identifier', 'aiida.calculations:templatereplacer']), ]) -def test_configuration(configure_caching, config_dict, enabled, disabled): +def test_configuration(configure_caching, config_dict, enabled_for, disabled_for): """Check that different caching configurations give the expected result. """ with configure_caching(config_dict=config_dict): - for identifier in enabled: + for identifier in enabled_for: assert get_use_cache(identifier=identifier) - for identifier in disabled: + for identifier in disabled_for: assert not get_use_cache(identifier=identifier) @pytest.mark.parametrize( ['config_dict', 'valid_identifiers', 'invalid_identifiers'], [({ - 'default': False, - 'enabled': ['aiida.calculations:*thmetic.add'], - 'disabled': ['aiida.calculations:arith*ic.add'] + 'default_enabled': False, + 'enabled_for': ['aiida.calculations:*thmetic.add'], + 'disabled_for': ['aiida.calculations:arith*ic.add'] }, ['some_identifier', 'aiida.calculations:templatereplacer'], ['aiida.calculations:arithmetic.add']), ({ - 'default': False, - 'enabled': ['aiida.calculations:arithmetic.add'], - 'disabled': ['aiida.calculations:arithmetic.add'] + 'default_enabled': False, + 'enabled_for': ['aiida.calculations:arithmetic.add'], + 'disabled_for': ['aiida.calculations:arithmetic.add'] }, ['some_identifier', 'aiida.calculations:templatereplacer'], ['aiida.calculations:arithmetic.add'])] ) def test_ambiguous_configuration(configure_caching, config_dict, valid_identifiers, invalid_identifiers): @@ -211,7 +226,7 @@ def test_enable_caching_specific(configure_caching): Check that using enable_caching for a specific identifier works. """ identifier = 'some_ident' - with configure_caching({'default': False}): + with configure_caching({'default_enabled': False}): with enable_caching(identifier=identifier): assert get_use_cache(identifier=identifier) @@ -221,7 +236,7 @@ def test_enable_caching_global(configure_caching): Check that using enable_caching for a specific identifier works. """ specific_identifier = 'some_ident' - with configure_caching(config_dict={'default': False, 'disabled': [specific_identifier]}): + with configure_caching(config_dict={'default_enabled': False, 'disabled_for': [specific_identifier]}): with enable_caching(): assert get_use_cache(identifier='some_other_ident') assert get_use_cache(identifier=specific_identifier) @@ -232,7 +247,7 @@ def test_disable_caching_specific(configure_caching): Check that using disable_caching for a specific identifier works. """ identifier = 'some_ident' - with configure_caching({'default': True}): + with configure_caching({'default_enabled': True}): with disable_caching(identifier=identifier): assert not get_use_cache(identifier=identifier) @@ -242,7 +257,7 @@ def test_disable_caching_global(configure_caching): Check that using disable_caching for a specific identifier works. """ specific_identifier = 'some_ident' - with configure_caching(config_dict={'default': True, 'enabled': [specific_identifier]}): + with configure_caching(config_dict={'default_enabled': True, 'enabled_for': [specific_identifier]}): with disable_caching(): assert not get_use_cache(identifier='some_other_ident') assert not get_use_cache(identifier=specific_identifier) From 705648de8a8fcce1e88ca5b304e90e88257c93a8 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Fri, 19 Feb 2021 08:59:16 +0100 Subject: [PATCH 086/114] =?UTF-8?q?=F0=9F=A7=AA=20TESTS:=20add=20=20`confi?= =?UTF-8?q?g=5Fwith=5Fprofile`=20fixture=20(#4764)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows for the removal of `temporary_config_instance` and `with_temporary_config_instance` from `tests/utils/configuration.py` --- tests/cmdline/commands/test_help.py | 7 +-- tests/cmdline/commands/test_profile.py | 14 ++--- tests/cmdline/commands/test_setup.py | 8 +-- tests/cmdline/commands/test_verdi.py | 7 +-- tests/conftest.py | 6 ++ tests/manage/configuration/test_options.py | 9 ++- tests/utils/configuration.py | 66 ---------------------- 7 files changed, 19 insertions(+), 98 deletions(-) diff --git a/tests/cmdline/commands/test_help.py b/tests/cmdline/commands/test_help.py index be2231c2f2..82310bbdb6 100644 --- a/tests/cmdline/commands/test_help.py +++ b/tests/cmdline/commands/test_help.py @@ -8,15 +8,14 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for `verdi help`.""" - from click.testing import CliRunner +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_verdi -from tests.utils.configuration import with_temporary_config_instance - +@pytest.mark.usefixtures('config_with_profile') class TestVerdiHelpCommand(AiidaTestCase): """Tests for `verdi help`.""" @@ -24,7 +23,6 @@ def setUp(self): super().setUp() self.cli_runner = CliRunner() - @with_temporary_config_instance def test_without_arg(self): """ Ensure we get the same help for `verdi` (which gives the same as `verdi --help`) @@ -36,7 +34,6 @@ def test_without_arg(self): result_verdi = self.cli_runner.invoke(cmd_verdi.verdi, [], catch_exceptions=False) self.assertEqual(result_help.output, result_verdi.output) - @with_temporary_config_instance def test_cmd_help(self): """Ensure we get the same help for `verdi user --help` and `verdi help user`""" result_help = self.cli_runner.invoke(cmd_verdi.verdi, ['help', 'user'], catch_exceptions=False) diff --git a/tests/cmdline/commands/test_profile.py b/tests/cmdline/commands/test_profile.py index ddfbdd1183..7eb262eeb3 100644 --- a/tests/cmdline/commands/test_profile.py +++ b/tests/cmdline/commands/test_profile.py @@ -10,14 +10,16 @@ """Tests for `verdi profile`.""" from click.testing import CliRunner +import pytest from aiida.backends.testbase import AiidaPostgresTestCase from aiida.cmdline.commands import cmd_profile, cmd_verdi from aiida.manage import configuration -from tests.utils.configuration import create_mock_profile, with_temporary_config_instance +from tests.utils.configuration import create_mock_profile +@pytest.mark.usefixtures('config_with_profile') class TestVerdiProfileSetup(AiidaPostgresTestCase): """Tests for `verdi profile`.""" @@ -32,8 +34,7 @@ def mock_profiles(self, **kwargs): """Create mock profiles and a runner object to invoke the CLI commands. Note: this cannot be done in the `setUp` or `setUpClass` methods, because the temporary configuration instance - is not generated until the test function is entered, which calls the `with_temporary_config_instance` - decorator. + is not generated until the test function is entered, which calls the `config_with_profile` test fixture. """ self.config = configuration.get_config() self.profile_list = ['mock_profile1', 'mock_profile2', 'mock_profile3', 'mock_profile4'] @@ -44,7 +45,6 @@ def mock_profiles(self, **kwargs): self.config.set_default_profile(self.profile_list[0], overwrite=True).store() - @with_temporary_config_instance def test_help(self): """Tests help text for all `verdi profile` commands.""" self.mock_profiles() @@ -67,7 +67,6 @@ def test_help(self): self.assertClickSuccess(result) self.assertIn('Usage', result.output) - @with_temporary_config_instance def test_list(self): """Test the `verdi profile list` command.""" self.mock_profiles() @@ -78,7 +77,6 @@ def test_list(self): self.assertIn(f'* {self.profile_list[0]}', result.output) self.assertIn(self.profile_list[1], result.output) - @with_temporary_config_instance def test_setdefault(self): """Test the `verdi profile setdefault` command.""" self.mock_profiles() @@ -93,7 +91,6 @@ def test_setdefault(self): self.assertIn(f'* {self.profile_list[1]}', result.output) self.assertClickSuccess(result) - @with_temporary_config_instance def test_show(self): """Test the `verdi profile show` command.""" self.mock_profiles() @@ -109,7 +106,6 @@ def test_show(self): self.assertIn(key.lower(), result.output) self.assertIn(value, result.output) - @with_temporary_config_instance def test_show_with_profile_option(self): """Test the `verdi profile show` command in combination with `-p/--profile.""" self.mock_profiles() @@ -126,7 +122,6 @@ def test_show_with_profile_option(self): self.assertClickSuccess(result) self.assertTrue(profile_name_non_default not in result.output) - @with_temporary_config_instance def test_delete_partial(self): """Test the `verdi profile delete` command. @@ -142,7 +137,6 @@ def test_delete_partial(self): self.assertClickSuccess(result) self.assertNotIn(self.profile_list[1], result.output) - @with_temporary_config_instance def test_delete(self): """Test for verdi profile delete command.""" from aiida.cmdline.commands.cmd_profile import profile_delete, profile_list diff --git a/tests/cmdline/commands/test_setup.py b/tests/cmdline/commands/test_setup.py index d48061e3ee..8515be996e 100644 --- a/tests/cmdline/commands/test_setup.py +++ b/tests/cmdline/commands/test_setup.py @@ -20,9 +20,8 @@ from aiida.manage import configuration from aiida.manage.external.postgres import Postgres -from tests.utils.configuration import with_temporary_config_instance - +@pytest.mark.usefixtures('config_with_profile') class TestVerdiSetup(AiidaPostgresTestCase): """Tests for `verdi setup` and `verdi quicksetup`.""" @@ -34,7 +33,6 @@ def setUp(self): self.backend = configuration.PROFILE.database_backend self.cli_runner = CliRunner() - @with_temporary_config_instance def test_help(self): """Check that the `--help` option is eager, is not overruled and will properly display the help message. @@ -44,7 +42,6 @@ def test_help(self): self.cli_runner.invoke(cmd_setup.setup, ['--help'], catch_exceptions=False) self.cli_runner.invoke(cmd_setup.quicksetup, ['--help'], catch_exceptions=False) - @with_temporary_config_instance def test_quicksetup(self): """Test `verdi quicksetup`.""" configuration.reset_profile() @@ -79,7 +76,6 @@ def test_quicksetup(self): self.assertEqual(user.last_name, user_last_name) self.assertEqual(user.institution, user_institution) - @with_temporary_config_instance def test_quicksetup_from_config_file(self): """Test `verdi quicksetup` from configuration file.""" import tempfile @@ -99,7 +95,6 @@ def test_quicksetup_from_config_file(self): result = self.cli_runner.invoke(cmd_setup.quicksetup, ['--config', os.path.realpath(handle.name)]) self.assertClickResultNoException(result) - @with_temporary_config_instance def test_quicksetup_wrong_port(self): """Test `verdi quicksetup` exits if port is wrong.""" configuration.reset_profile() @@ -119,7 +114,6 @@ def test_quicksetup_wrong_port(self): result = self.cli_runner.invoke(cmd_setup.quicksetup, options) self.assertIsNotNone(result.exception, ''.join(traceback.format_exception(*result.exc_info))) - @with_temporary_config_instance def test_setup(self): """Test `verdi setup` (non-interactive).""" postgres = Postgres(interactive=False, quiet=True, dbinfo=self.pg_test.dsn) diff --git a/tests/cmdline/commands/test_verdi.py b/tests/cmdline/commands/test_verdi.py index 0791150dca..ed3aa88204 100644 --- a/tests/cmdline/commands/test_verdi.py +++ b/tests/cmdline/commands/test_verdi.py @@ -9,14 +9,14 @@ ########################################################################### """Tests for `verdi`.""" from click.testing import CliRunner +import pytest from aiida import get_version from aiida.backends.testbase import AiidaTestCase from aiida.cmdline.commands import cmd_verdi -from tests.utils.configuration import with_temporary_config_instance - +@pytest.mark.usefixtures('config_with_profile') class TestVerdi(AiidaTestCase): """Tests for `verdi`.""" @@ -30,7 +30,6 @@ def test_verdi_version(self): self.assertIsNone(result.exception, result.output) self.assertIn(get_version(), result.output) - @with_temporary_config_instance def test_verdi_with_empty_profile_list(self): """Regression test for #2424: verify that verdi remains operable even if profile list is empty""" from aiida.manage.configuration import CONFIG @@ -40,7 +39,6 @@ def test_verdi_with_empty_profile_list(self): result = self.cli_runner.invoke(cmd_verdi.verdi, []) self.assertIsNone(result.exception, result.output) - @with_temporary_config_instance def test_invalid_cmd_matches(self): """Test that verdi with an invalid command will return matches if somewhat close""" result = self.cli_runner.invoke(cmd_verdi.verdi, ['usr']) @@ -49,7 +47,6 @@ def test_invalid_cmd_matches(self): self.assertIn('user', result.output) self.assertNotEqual(result.exit_code, 0) - @with_temporary_config_instance def test_invalid_cmd_no_matches(self): """Test that verdi with an invalid command with no matches returns an appropriate message""" result = self.cli_runner.invoke(cmd_verdi.verdi, ['foobar']) diff --git a/tests/conftest.py b/tests/conftest.py index 1a1c7e1f00..0b0e379c11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -268,6 +268,12 @@ def _config_with_profile_factory(set_as_default=True, load=True, name='default', return _config_with_profile_factory +@pytest.fixture +def config_with_profile(config_with_profile_factory): + """Create a temporary configuration instance with one default, loaded profile.""" + yield config_with_profile_factory() + + @pytest.fixture def manager(aiida_profile): # pylint: disable=unused-argument """Get the ``Manager`` instance of the currently loaded profile.""" diff --git a/tests/manage/configuration/test_options.py b/tests/manage/configuration/test_options.py index bf55730f77..86deb45999 100644 --- a/tests/manage/configuration/test_options.py +++ b/tests/manage/configuration/test_options.py @@ -8,14 +8,13 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for the configuration options.""" +import pytest from aiida.backends.testbase import AiidaTestCase from aiida.common.exceptions import ConfigurationError from aiida.manage.configuration.options import get_option, get_option_names, parse_option, Option from aiida.manage.configuration import get_config, get_config_option, ConfigValidationError -from tests.utils.configuration import with_temporary_config_instance - class TestConfigurationOptions(AiidaTestCase): """Tests for the Options class.""" @@ -53,7 +52,7 @@ def test_options(self): option.valid_type # pylint: disable=pointless-statement option.default # pylint: disable=pointless-statement - @with_temporary_config_instance + @pytest.mark.usefixtures('config_with_profile') def test_get_config_option_default(self): """Tests that `get_option` return option default if not specified globally or for current profile.""" option_name = 'logging.aiida_loglevel' @@ -63,7 +62,7 @@ def test_get_config_option_default(self): option_value = get_config_option(option_name) self.assertEqual(option_value, option.default) - @with_temporary_config_instance + @pytest.mark.usefixtures('config_with_profile') def test_get_config_option_profile_specific(self): """Tests that `get_option` correctly gets a configuration option if specified for the current profile.""" config = get_config() @@ -77,7 +76,7 @@ def test_get_config_option_profile_specific(self): option_value = get_config_option(option_name) self.assertEqual(option_value, option_value_profile) - @with_temporary_config_instance + @pytest.mark.usefixtures('config_with_profile') def test_get_config_option_global(self): """Tests that `get_option` correctly agglomerates upwards and so retrieves globally set config options.""" config = get_config() diff --git a/tests/utils/configuration.py b/tests/utils/configuration.py index ae922cef12..e3767af946 100644 --- a/tests/utils/configuration.py +++ b/tests/utils/configuration.py @@ -43,72 +43,6 @@ def create_mock_profile(name, repository_dirpath=None, **kwargs): return Profile(name, profile_dictionary) -@contextlib.contextmanager -def temporary_config_instance(): - """Create a temporary AiiDA instance.""" - current_config = None - current_config_path = None - current_profile_name = None - temporary_config_directory = None - - from aiida.common.utils import Capturing - from aiida.manage import configuration - from aiida.manage.configuration import settings, load_profile, reset_profile - - try: - from aiida.manage.configuration.settings import create_instance_directories - - # Store the current configuration instance and config directory path - current_config = configuration.CONFIG - current_config_path = current_config.dirpath - current_profile_name = configuration.PROFILE.name - - reset_profile() - configuration.CONFIG = None - - # Create a temporary folder, set it as the current config directory path and reset the loaded configuration - profile_name = 'test_profile_1234' - temporary_config_directory = tempfile.mkdtemp() - settings.AIIDA_CONFIG_FOLDER = temporary_config_directory - - # Create the instance base directory structure, the config file and a dummy profile - create_instance_directories() - - # The constructor of `Config` called by `load_config` will print warning messages about migrating it - with Capturing(): - configuration.CONFIG = configuration.load_config(create=True) - - profile = create_mock_profile(name=profile_name, repository_dirpath=temporary_config_directory) - - # Add the created profile and set it as the default - configuration.CONFIG.add_profile(profile) - configuration.CONFIG.set_default_profile(profile_name, overwrite=True) - configuration.CONFIG.store() - load_profile() - - yield configuration.CONFIG - finally: - # Reset the config folder path and the config instance - reset_profile() - settings.AIIDA_CONFIG_FOLDER = current_config_path - configuration.CONFIG = current_config - load_profile(current_profile_name) - - # Destroy the temporary instance directory - if temporary_config_directory and os.path.isdir(temporary_config_directory): - shutil.rmtree(temporary_config_directory) - - -def with_temporary_config_instance(function): - """Create a temporary AiiDA instance for the duration of the wrapped function.""" - - def decorated_function(*args, **kwargs): - with temporary_config_instance(): - function(*args, **kwargs) - - return decorated_function - - @contextlib.contextmanager def temporary_directory(): """Create a temporary directory.""" From 90a1987c4e29b51adee7408b08b71b0e79e66940 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Fri, 19 Feb 2021 14:46:03 +0100 Subject: [PATCH 087/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20`verdi=20conf?= =?UTF-8?q?ig=20list/show`=20(#4762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure these commands still work before a profile has been configured. --- aiida/cmdline/commands/cmd_config.py | 11 +++++++++-- aiida/manage/configuration/config.py | 5 +++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/aiida/cmdline/commands/cmd_config.py b/aiida/cmdline/commands/cmd_config.py index 3cc517fed5..94415a1c22 100644 --- a/aiida/cmdline/commands/cmd_config.py +++ b/aiida/cmdline/commands/cmd_config.py @@ -66,7 +66,10 @@ def verdi_config_list(ctx, prefix, description: bool): config: Config = ctx.obj.config profile: Profile = ctx.obj.profile - option_values = config.get_options(profile.name) + if not profile: + echo.echo_warning('no profiles configured: run `verdi setup` to create one') + + option_values = config.get_options(profile.name if profile else None) def _join(val): """split arrays into multiple lines.""" @@ -106,10 +109,14 @@ def verdi_config_show(ctx, option): 'values': { 'default': '' if option.default is NO_DEFAULT else option.default, 'global': config.options.get(option.name, ''), - 'profile': profile.options.get(option.name, ''), } } + if not profile: + echo.echo_warning('no profiles configured: run `verdi setup` to create one') + else: + dct['values']['profile'] = profile.options.get(option.name, '') + echo.echo_dictionary(dct, fmt='yaml', sort_keys=False) diff --git a/aiida/manage/configuration/config.py b/aiida/manage/configuration/config.py index cb4d50f630..04f281b0a0 100644 --- a/aiida/manage/configuration/config.py +++ b/aiida/manage/configuration/config.py @@ -425,16 +425,17 @@ def get_option(self, option_name, scope=None, default=True): return value - def get_options(self, scope=None) -> Dict[str, Tuple[Option, str, Any]]: + def get_options(self, scope: Optional[str] = None) -> Dict[str, Tuple[Option, str, Any]]: """Return a dictionary of all option values and their source ('profile', 'global', or 'default'). + :param scope: the profile name or globally if not specified :returns: (option, source, value) """ profile = self.get_profile(scope) if scope else None output = {} for name in get_option_names(): option = get_option(name) - if name in profile.options: + if profile and name in profile.options: value = profile.options.get(name) source = 'profile' elif name in self.options: From f93c5a3f7107bac2f5e7483d03c27c42468abc8d Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 22 Feb 2021 10:42:28 +0100 Subject: [PATCH 088/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Add=20config?= =?UTF-8?q?=20`logging.aiopika=5Floglevel`=20(#4768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aiida/common/log.py | 5 +++++ .../configuration/schema/config-v5.schema.json | 13 +++++++++++++ tests/manage/configuration/test_options.py | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/aiida/common/log.py b/aiida/common/log.py index 05679cc98e..d916071511 100644 --- a/aiida/common/log.py +++ b/aiida/common/log.py @@ -103,6 +103,11 @@ def filter(self, record): 'level': lambda: get_config_option('logging.alembic_loglevel'), 'propagate': False, }, + 'aio_pika': { + 'handlers': ['console'], + 'level': lambda: get_config_option('logging.aiopika_loglevel'), + 'propagate': False, + }, 'sqlalchemy': { 'handlers': ['console'], 'level': lambda: get_config_option('logging.sqlalchemy_loglevel'), diff --git a/aiida/manage/configuration/schema/config-v5.schema.json b/aiida/manage/configuration/schema/config-v5.schema.json index 7a63492747..3993a42939 100644 --- a/aiida/manage/configuration/schema/config-v5.schema.json +++ b/aiida/manage/configuration/schema/config-v5.schema.json @@ -146,6 +146,19 @@ "default": "INFO", "description": "Minimum level to log to daemon log and the `DbLog` table for the `circus` logger" }, + "logging.aiopika_loglevel": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "REPORT", + "INFO", + "DEBUG" + ], + "default": "WARNING", + "description": "Minimum level to log to daemon log and the `DbLog` table for the `aio_pika` logger" + }, "warnings.showdeprecations": { "type": "boolean", "default": true, diff --git a/tests/manage/configuration/test_options.py b/tests/manage/configuration/test_options.py index 86deb45999..b498922e3a 100644 --- a/tests/manage/configuration/test_options.py +++ b/tests/manage/configuration/test_options.py @@ -22,7 +22,7 @@ class TestConfigurationOptions(AiidaTestCase): def test_get_option_names(self): """Test `get_option_names` function.""" self.assertIsInstance(get_option_names(), list) - self.assertEqual(len(get_option_names()), 25) + self.assertEqual(len(get_option_names()), 26) def test_get_option(self): """Test `get_option` function.""" From 889b50f13402e90bdc0b2f267974889b7a68c9f5 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 23 Feb 2021 05:04:48 +0100 Subject: [PATCH 089/114] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Add=20process=20?= =?UTF-8?q?submit=20diagram=20(#4766)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📚 DOCS: Add process submit diagram * Create submit_sysml.pptx --- docs/source/topics/processes/concepts.rst | 4 ++++ .../processes/include/images/submit_sysml.png | Bin 0 -> 292865 bytes .../processes/include/images/submit_sysml.pptx | Bin 0 -> 51254 bytes 3 files changed, 4 insertions(+) create mode 100644 docs/source/topics/processes/include/images/submit_sysml.png create mode 100644 docs/source/topics/processes/include/images/submit_sysml.pptx diff --git a/docs/source/topics/processes/concepts.rst b/docs/source/topics/processes/concepts.rst index 950453ba64..a0a2b50700 100644 --- a/docs/source/topics/processes/concepts.rst +++ b/docs/source/topics/processes/concepts.rst @@ -159,6 +159,10 @@ Processes, whose task is in the queue and not with any runner, though technicall While a process is not actually being run, i.e. it is not in memory with a runner, one cannot interact with it. Similarly, as soon as the task disappears, either because the process was intentionally terminated (or unintentionally), the process will never continue running again. +.. figure:: include/images/submit_sysml.png + + A systems modelling representation of submitting a process. + .. _topics:processes:concepts:checkpoints: diff --git a/docs/source/topics/processes/include/images/submit_sysml.png b/docs/source/topics/processes/include/images/submit_sysml.png new file mode 100644 index 0000000000000000000000000000000000000000..df2d361dcfe082c0fe748f9e83201ac7b1361390 GIT binary patch literal 292865 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�px7>+9ORt(*1uHRubbtk%Iuc_WCoZ^@WXqhE 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�_GI&LLs%bHV+q|j}Vdc$q%oZ`+Hw4BM z52ZdhR&7>!YWcqwic?XD`s3)_jofz7HW9S#*Lf?kZS-s4mGR;nP8qN-zmEIaX*#ri zGug9RuD)@@ut-XeKxM>tNyj+IrxZF>HWN+<-{!}qrf*BZ+?gV&F5~xT`0wrKAL*U{ z2?piTA`vtltzL^L)@z`&!q!s9HUhoMm0QQNJ>MZBBV%@qTCNCcYwim4_wn>`n$O%Y zq2jK1hV~WY6}9O*0+(OGg&)dwx7D6};`nD;iEyw+s`la2f;qCRM4rJGSo{qrFQU1| z@5f1x#)L(>h1HVI>r0EpzEn-|@<>XmAepd*I2xnxbz(`KlWLF!=N$s62{CNZ zcgE{M0mG=QpSWq4QGDFs@bmx&oj+D{v_6?7o`SDw>$fiA!UdbO1l;fTt6gI})c)RM z|Bs9O`ZQaHlb)_rVfI%Yg%RE>d@Tz-a=c%)ctRs{$=oHTl)hUG+%|ICMPgaZxO(L? zIwoqxI>zMPEY$x~r!&Ad!{=JJXV)*CeOAA9#V-_J;c?;kN4Us?mp-EN-;wEmV%+~4 zk5z)mr^F_{rgwiw;Y4tdIwXYg#fXAusN2|*dL>f|4w3(y)c+du-%si94_`HaYdA+G zcKK`KKCS12eOzg=+U`~&0Gg5duQ=#G@y7poFCgMyB{gjF?8yHbi>^Yt4a_-Jk4bs_ z^EmZi*CPisc`|8N1}pF%yp1)!JX%W4kx!I*1RVPE0H6cQT=izu#KeRnP{&o)*H3Bu z{shngYERIn*TF3Safs_YmDhn53N#ky)CZyiHEXG-Hvf|o`^=qo3}HD8!LuKBmdpTU zsps|_KqX^IXhyx9HssQx-i-a0%0_Z{_-RH9c_h(~o?bqLH*I4WM z9q53cLn77H)arDb-dx%o%vL!Y4Q+m6QmOyy0f!$2B0%=CedVNWix@&hAL`lYpa#w zY73X(!_^u`Bbyl9lf|X;Wf%VC5l}UMV|h4TpjK!G*mfVjjj{5&J>c2F4P|TV1w1rq z1g$AM+5p>YdB7?5O|fp>M>6tvwjyr)-j@q5j=*&6<9i4~spN*=-68(Vc;iCLwpZ~9$E|+EkIy+p@2`K@JLHw-^Cji(bV<3@d3^3v|VFLXDEMIxT z=d@QQb!@T?yzkXeOS?UH8AHE)V-LIm0JwR1dEM`v)gXXlLD*P#>D-Se>sk&Q##u_% zBhDcKYC*7p>jv(VmH9dnh9;=)T@W`L1E3gLHYaN;UIX-?u5SO<8MD|eSElO zjMuuttmTgioo1)@*;*R+1i?8C>ZvAeOu z^!IABYi?lu_cEGBkAi@Rpwc`@ZKk=Z`2o<#m#>i0y`@X~z<>%I>_b2Pt{MK-A5$qj z-{AgcYIqwVk^6wX>#RjDj=-&t4T5@GO zyYlibUtjnreEPPz!9<>j)z#JS+;rpPc zs0*rd%vG3TP`)}vd6OhkI|#^<;@jHViuCrwp@eJsxhYh^u`4SpPafW~O$4>(>Tnz}5XmKShhbx5CslW%2uibwq4K;Q%@_g{ppvzl zKzHhz(l6FVn3&e4cbo{A3fOnR^*W8}f87P!L=h9$^R^#pUnz6YDd8U9G;00Xb>dQW zuo9uUFPTz{mLBhXJHlAk|34dA8X}5g_@M%4I5f4sYo=N875+#h_S#Q#;MnfiwcKa! zGAVUq(N0{+YGeAMBrexl0lpnOf=BE(A`-&K%Zqy;au_6TakxhG$=Lr`z)*S-S_Pwu z6VahjQdb>OXFUk6XIbyN1AyNLB{SNwKYxm0(#23N(VpZz+?@^;d^qgu?FC))m$&L1 z0|pU4eaQr<5@$(Ja)CED_0@h6SvIDAIb{UhNxPBX*X`bAyKijGw-*PT39Wmld!^04 zh&9Cs!otJ32X$U7hQkeZyP*07qbU<_9^6MI?>+i}LXyfqRXQJNYgc@E`J^6j!^uQT zzi)oLFSUDeH0aZjCy91~bA`=Zn;AsE|6B9GT%DH-{!dKT$LtixR zuSsxV`0z~iH+{*=PJeCtUlmdAPG)7Qn}heHh0ep5LL|@(D<&p$?$1tS_EC*;+NA$_ z0hgr_!gpL5?me?0p+fKGi`CA*kxSJmKvGf+>={j7Wz|ju5m#Bm<4$bkya1u2v6J4; zwbI`Y_y{00Oo^LdUu>W_k>8&U#8njFBfnE

L>Y_(xw*Mf54yW+_VFlJj6igI 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 ZYMkOpKPts9xdZ%@d@UndAgt~E{{Wu}Dlh;5 literal 0 HcmV?d00001 diff --git a/docs/source/topics/processes/include/images/submit_sysml.pptx b/docs/source/topics/processes/include/images/submit_sysml.pptx new file mode 100644 index 0000000000000000000000000000000000000000..a66e96d379cc2631606c55cadc579a23586c0f96 GIT binary patch literal 51254 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 Date: Tue, 23 Feb 2021 05:27:30 +0100 Subject: [PATCH 090/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20CTRL-C=20on?= =?UTF-8?q?=20running=20process=20(#4771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not call `kill` on a process that is already being killed. Also log a different message, so that the user can see that the original CTRL-C was actioned. --- aiida/engine/runners.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aiida/engine/runners.py b/aiida/engine/runners.py index 2df3b12770..93752c3bec 100644 --- a/aiida/engine/runners.py +++ b/aiida/engine/runners.py @@ -235,6 +235,9 @@ def _run(self, process: TYPE_RUN_PROCESS, *args: Any, **inputs: Any) -> Tuple[Di def kill_process(_num, _frame): """Send the kill signal to the process in the current scope.""" + if process_inited.is_killing: + LOGGER.warning('runner received interrupt, process %s already being killed', process_inited.pid) + return LOGGER.critical('runner received interrupt, killing process %s', process_inited.pid) process_inited.kill(msg='Process was killed because the runner received an interrupt') From 8c079175bbb32a6dc3b4ae663c586c7099cbb9a6 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 23 Feb 2021 07:13:37 +0100 Subject: [PATCH 091/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20kill=5Fcalculatio?= =?UTF-8?q?n=20before=20job=20submitted=20(#4770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `job_id` will not yet have been set, so we should not ask the scheduler to kill it. --- aiida/engine/daemon/execmanager.py | 32 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/aiida/engine/daemon/execmanager.py b/aiida/engine/daemon/execmanager.py index 6d1eecf5ca..fa086e4d94 100644 --- a/aiida/engine/daemon/execmanager.py +++ b/aiida/engine/daemon/execmanager.py @@ -32,7 +32,7 @@ REMOTE_WORK_DIRECTORY_LOST_FOUND = 'lost+found' -execlogger = AIIDA_LOGGER.getChild('execmanager') +EXEC_LOGGER = AIIDA_LOGGER.getChild('execmanager') def _find_data_node(inputs: MappingType[str, Any], uuid: str) -> Optional[Node]: @@ -77,7 +77,7 @@ def upload_calculation( # chance to perform the state transition. Upon reloading this calculation, it will re-attempt the upload. link_label = 'remote_folder' if node.get_outgoing(RemoteData, link_label_filter=link_label).first(): - execlogger.warning(f'CalcJobNode<{node.pk}> already has a `{link_label}` output: skipping upload') + EXEC_LOGGER.warning(f'CalcJobNode<{node.pk}> already has a `{link_label}` output: skipping upload') return calc_info computer = node.computer @@ -87,7 +87,7 @@ def upload_calculation( logger_extra = get_dblogger_extra(node) transport.set_logger_extra(logger_extra) - logger = LoggerAdapter(logger=execlogger, extra=logger_extra) + logger = LoggerAdapter(logger=EXEC_LOGGER, extra=logger_extra) if not dry_run and node.has_cached_links(): raise ValueError( @@ -346,15 +346,15 @@ def retrieve_calculation(calculation: CalcJobNode, transport: Transport, retriev logger_extra = get_dblogger_extra(calculation) workdir = calculation.get_remote_workdir() - execlogger.debug(f'Retrieving calc {calculation.pk}', extra=logger_extra) - execlogger.debug(f'[retrieval of calc {calculation.pk}] chdir {workdir}', extra=logger_extra) + EXEC_LOGGER.debug(f'Retrieving calc {calculation.pk}', extra=logger_extra) + EXEC_LOGGER.debug(f'[retrieval of calc {calculation.pk}] chdir {workdir}', extra=logger_extra) # If the calculation already has a `retrieved` folder, simply return. The retrieval was apparently already completed # before, which can happen if the daemon is restarted and it shuts down after retrieving but before getting the # chance to perform the state transition. Upon reloading this calculation, it will re-attempt the retrieval. link_label = calculation.link_label_retrieved if calculation.get_outgoing(FolderData, link_label_filter=link_label).first(): - execlogger.warning( + EXEC_LOGGER.warning( f'CalcJobNode<{calculation.pk}> already has a `{link_label}` output folder: skipping retrieval' ) return @@ -387,13 +387,13 @@ def retrieve_calculation(calculation: CalcJobNode, transport: Transport, retriev # Log the files that were retrieved in the temporary folder for filename in os.listdir(retrieved_temporary_folder): - execlogger.debug( + EXEC_LOGGER.debug( f"[retrieval of calc {calculation.pk}] Retrieved temporary file or folder '{filename}'", extra=logger_extra ) # Store everything - execlogger.debug( + EXEC_LOGGER.debug( f'[retrieval of calc {calculation.pk}] Storing retrieved_files={retrieved_files.pk}', extra=logger_extra ) retrieved_files.store() @@ -404,7 +404,7 @@ def retrieve_calculation(calculation: CalcJobNode, transport: Transport, retriev retrieved_files.add_incoming(calculation, link_type=LinkType.CREATE, link_label=calculation.link_label_retrieved) -def kill_calculation(calculation: CalcJobNode, transport: Transport) -> bool: +def kill_calculation(calculation: CalcJobNode, transport: Transport) -> None: """ Kill the calculation through the scheduler @@ -413,6 +413,10 @@ def kill_calculation(calculation: CalcJobNode, transport: Transport) -> bool: """ job_id = calculation.get_job_id() + if job_id is None: + # the calculation has not yet been submitted to the scheduler + return + # Get the scheduler plugin class and initialize it with the correct transport scheduler = calculation.computer.get_scheduler() scheduler.set_transport(transport) @@ -430,9 +434,9 @@ def kill_calculation(calculation: CalcJobNode, transport: Transport) -> bool: if job is not None and job.job_state != JobState.DONE: raise exceptions.RemoteOperationError(f'scheduler.kill({job_id}) was unsuccessful') else: - execlogger.warning('scheduler.kill() failed but job<{%s}> no longer seems to be running regardless', job_id) - - return True + EXEC_LOGGER.warning( + 'scheduler.kill() failed but job<{%s}> no longer seems to be running regardless', job_id + ) def _retrieve_singlefiles( @@ -445,7 +449,7 @@ def _retrieve_singlefiles( """Retrieve files specified through the singlefile list mechanism.""" singlefile_list = [] for (linkname, subclassname, filename) in retrieve_file_list: - execlogger.debug( + EXEC_LOGGER.debug( '[retrieval of calc {}] Trying ' "to retrieve remote singlefile '{}'".format(job.pk, filename), extra=logger_extra @@ -466,7 +470,7 @@ def _retrieve_singlefiles( singlefiles.append(singlefile) for fil in singlefiles: - execlogger.debug(f'[retrieval of calc {job.pk}] Storing retrieved_singlefile={fil.pk}', extra=logger_extra) + EXEC_LOGGER.debug(f'[retrieval of calc {job.pk}] Storing retrieved_singlefile={fil.pk}', extra=logger_extra) fil.store() From 310dcc240a54a1f81c2689a768ef0975433aa794 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 24 Feb 2021 11:12:01 +0100 Subject: [PATCH 092/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20`ModificationNotA?= =?UTF-8?q?llowed`=20on=20workchain=20kill=20(#4773)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `Process.kill` the parent is killed first, then the children. However, for workchains when entering the `Wait` state, awaitables (e.g. children) are each assigned to `WorkChain.on_process_finished` as a callback on termination. When the child is killed, this callback then calls `resolve_awaitable`, which tries to update the status of the parent. The parent is already terminated though and the node sealed -> `ModificationNotAllowed`. In this commit we therefore check if the parent is already in a terminal state, before attempting to update its status. --- aiida/engine/processes/workchains/workchain.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aiida/engine/processes/workchains/workchain.py b/aiida/engine/processes/workchains/workchain.py index 72c673f0ce..f853dd1daf 100644 --- a/aiida/engine/processes/workchains/workchain.py +++ b/aiida/engine/processes/workchains/workchain.py @@ -167,7 +167,10 @@ def resolve_awaitable(self, awaitable: Awaitable, value: Any) -> None: awaitable.resolved = True - self._update_process_status() + if not self.has_terminated: + # the process may be terminated, for example, if the process was killed or excepted + # then we should not try to update it + self._update_process_status() def to_context(self, **kwargs: Union[Awaitable, ProcessNode]) -> None: """Add a dictionary of awaitables to the context. From a61219d16530af93854f3525f4787a7897e6be97 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 24 Feb 2021 14:31:59 +0100 Subject: [PATCH 093/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20capture=20of?= =?UTF-8?q?=20node=20hashing=20errors=20(#4778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently all exceptions are caught and ignored. This commit adds a specific `HashingError` exception, for known failure modes. Only this exception is caught, if `ignore_errors=True`, and the exception logged. Also an `aiida_caplog` pytest fixture is added, to enable logs from `AiiDA_LOGGER` to be captured. --- aiida/common/exceptions.py | 8 +++++++- aiida/common/hashing.py | 5 +++-- aiida/manage/tests/pytest_fixtures.py | 10 ++++++++++ aiida/orm/nodes/node.py | 17 ++++++++++++++--- tests/common/test_hashing.py | 3 ++- tests/orm/node/test_node.py | 15 +++++++++++++++ 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/aiida/common/exceptions.py b/aiida/common/exceptions.py index 72ed077628..72909d73e8 100644 --- a/aiida/common/exceptions.py +++ b/aiida/common/exceptions.py @@ -17,7 +17,7 @@ 'PluginInternalError', 'ValidationError', 'ConfigurationError', 'ProfileConfigurationError', 'MissingConfigurationError', 'ConfigurationVersionError', 'IncompatibleDatabaseSchema', 'DbContentError', 'InputValidationError', 'FeatureNotAvailable', 'FeatureDisabled', 'LicensingException', 'TestsNotAllowedError', - 'UnsupportedSpeciesError', 'TransportTaskException', 'OutputParsingError' + 'UnsupportedSpeciesError', 'TransportTaskException', 'OutputParsingError', 'HashingError' ) @@ -250,3 +250,9 @@ class CircusCallError(AiidaException): """ Raised when an attempt to contact Circus returns an error in the response """ + + +class HashingError(AiidaException): + """ + Raised when an attempt to hash an object fails via a known failure mode + """ diff --git a/aiida/common/hashing.py b/aiida/common/hashing.py index d4eb8e01f6..1c688ef740 100644 --- a/aiida/common/hashing.py +++ b/aiida/common/hashing.py @@ -22,6 +22,8 @@ import pytz from aiida.common.constants import AIIDA_FLOAT_PRECISION +from aiida.common.exceptions import HashingError + from .folders import Folder # The prefix of the hashed using pbkdf2_sha256 algorithm in Django @@ -101,7 +103,6 @@ def make_hash(object_to_hash, **kwargs): hashing iteratively. Uses python's sorted function to sort unsorted sets and dictionaries by sorting the hashed keys. """ - hashes = _make_hash(object_to_hash, **kwargs) # pylint: disable=assignment-from-no-return # use the Unlimited fanout hashing protocol outlined in @@ -123,7 +124,7 @@ def _make_hash(object_to_hash, **_): Implementation of the ``make_hash`` function. The hash is created as a 28 byte integer, and only later converted to a string. """ - raise ValueError(f'Value of type {type(object_to_hash)} cannot be hashed') + raise HashingError(f'Value of type {type(object_to_hash)} cannot be hashed') def _single_digest(obj_type, obj_bytes=b''): diff --git a/aiida/manage/tests/pytest_fixtures.py b/aiida/manage/tests/pytest_fixtures.py index 4cb7c0518b..08c203b358 100644 --- a/aiida/manage/tests/pytest_fixtures.py +++ b/aiida/manage/tests/pytest_fixtures.py @@ -22,9 +22,19 @@ import tempfile import pytest +from aiida.common.log import AIIDA_LOGGER from aiida.manage.tests import test_manager, get_test_backend_name, get_test_profile_name +@pytest.fixture(scope='function') +def aiida_caplog(caplog): + """A copy of pytest's caplog fixture, which allows ``AIIDA_LOGGER`` to propagate.""" + propogate = AIIDA_LOGGER.propagate + AIIDA_LOGGER.propagate = True + yield caplog + AIIDA_LOGGER.propagate = propogate + + @pytest.fixture(scope='session', autouse=True) def aiida_profile(): """Set up AiiDA test profile for the duration of the tests. diff --git a/aiida/orm/nodes/node.py b/aiida/orm/nodes/node.py index bc8b114947..7a9b6fdd8a 100644 --- a/aiida/orm/nodes/node.py +++ b/aiida/orm/nodes/node.py @@ -1054,7 +1054,10 @@ def _add_outputs_from_cache(self, cache_node): new_node.store() def get_hash(self, ignore_errors=True, **kwargs): - """Return the hash for this node based on its attributes.""" + """Return the hash for this node based on its attributes. + + :param ignore_errors: return ``None`` on ``aiida.common.exceptions.HashingError`` (logging the exception) + """ if not self.is_stored: raise exceptions.InvalidOperation('You can get the hash only after having stored the node') @@ -1065,17 +1068,25 @@ def _get_hash(self, ignore_errors=True, **kwargs): Return the hash for this node based on its attributes. This will always work, even before storing. + + :param ignore_errors: return ``None`` on ``aiida.common.exceptions.HashingError`` (logging the exception) """ try: return make_hash(self._get_objects_to_hash(), **kwargs) - except Exception: # pylint: disable=broad-except + except exceptions.HashingError: if not ignore_errors: raise + self.logger.exception('Node hashing failed') def _get_objects_to_hash(self): """Return a list of objects which should be included in the hash.""" + top_level_module = self.__module__.split('.', 1)[0] + try: + version = importlib.import_module(top_level_module).__version__ + except (ImportError, AttributeError) as exc: + raise exceptions.HashingError("The node's package version could not be determined") from exc objects = [ - importlib.import_module(self.__module__.split('.', 1)[0]).__version__, + version, { key: val for key, val in self.attributes_items() diff --git a/tests/common/test_hashing.py b/tests/common/test_hashing.py index efe4c1e843..a3d1db3dbd 100644 --- a/tests/common/test_hashing.py +++ b/tests/common/test_hashing.py @@ -24,6 +24,7 @@ except ImportError: import unittest +from aiida.common.exceptions import HashingError from aiida.common.hashing import make_hash, float_to_text from aiida.common.folders import SandboxFolder from aiida.backends.testbase import AiidaTestCase @@ -178,7 +179,7 @@ def test_unhashable_type(self): class MadeupClass: pass - with self.assertRaises(ValueError): + with self.assertRaises(HashingError): make_hash(MadeupClass()) def test_folder(self): diff --git a/tests/orm/node/test_node.py b/tests/orm/node/test_node.py index d856e16450..4ac8ad387a 100644 --- a/tests/orm/node/test_node.py +++ b/tests/orm/node/test_node.py @@ -10,6 +10,7 @@ # pylint: disable=too-many-public-methods,no-self-use """Tests for the Node ORM class.""" import io +import logging import os import tempfile @@ -890,6 +891,20 @@ def test_store_from_cache(): assert data.get_hash() == clone.get_hash() +@pytest.mark.usefixtures('clear_database_before_test') +def test_hashing_errors(aiida_caplog): + """Tests that ``get_hash`` fails in an expected manner.""" + node = Data().store() + node.__module__ = 'unknown' # this will inhibit package version determination + result = node.get_hash(ignore_errors=True) + assert result is None + assert aiida_caplog.record_tuples == [(node.logger.name, logging.ERROR, 'Node hashing failed')] + + with pytest.raises(exceptions.HashingError, match='package version could not be determined'): + result = node.get_hash(ignore_errors=False) + assert result is None + + # Ignoring the resource errors as we are indeed testing the wrong way of using these (for backward-compatibility) @pytest.mark.filterwarnings('ignore::ResourceWarning') @pytest.mark.filterwarnings('ignore::aiida.common.warnings.AiidaDeprecationWarning') From d27e22d1eb1b41560800ce738209c4e44774023e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 24 Feb 2021 15:29:37 +0100 Subject: [PATCH 094/114] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20UPDATE:=20kiwipy/p?= =?UTF-8?q?lumpy=20(#4776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update to new patch versions: kiwipy v0.7.3: - 👌 IMPROVE: Add debug logging for sending task/rpc/broadcast to RMQ. - 👌 IMPROVE: Close created asyncio loop on RmqThreadCommunicator.close plumpy v0.18.6: - 👌 IMPROVE: Catch state change broadcast timeout When using an RMQ communicator, the broadcast can timeout on heavy loads to RMQ. This broadcast is not critical to the running of the process, and so a timeout should not except it. In aiida-core, the broadcast is subscribed to by `verdi process watch` (not critical), in `aiida/engine/processes/futures.py:ProcessFuture` (unused), and in `aiida/engine/runners.py:Runner.call_on_process_finish` which has a backup polling mechanism on the node. Also ensure the process PID is included in all log messages. --- environment.yml | 4 ++-- requirements/requirements-py-3.7.txt | 4 ++-- requirements/requirements-py-3.8.txt | 4 ++-- requirements/requirements-py-3.9.txt | 4 ++-- setup.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/environment.yml b/environment.yml index bac8daa6ae..09b03959f8 100644 --- a/environment.yml +++ b/environment.yml @@ -21,11 +21,11 @@ dependencies: - ipython~=7.20 - jinja2~=2.10 - jsonschema~=3.0 -- kiwipy[rmq]~=0.7.2 +- kiwipy[rmq]~=0.7.3 - numpy~=1.17 - pamqp~=2.3 - paramiko>=2.7.2,~=2.7 -- plumpy~=0.18.5 +- plumpy~=0.18.6 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index b10dd1f8d9..49c846ca68 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -57,7 +57,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.2 +kiwipy==0.7.3 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -88,7 +88,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.5 +plumpy==0.18.6 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index d50bb75dcd..8ab1f0cd09 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -56,7 +56,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.2 +kiwipy==0.7.3 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -87,7 +87,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.5 +plumpy==0.18.6 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index 0153178b0d..f050094ca4 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -56,7 +56,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.2 +kiwipy==0.7.3 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -87,7 +87,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.5 +plumpy==0.18.6 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/setup.json b/setup.json index bbd64a6b02..9dac042c00 100644 --- a/setup.json +++ b/setup.json @@ -35,11 +35,11 @@ "ipython~=7.20", "jinja2~=2.10", "jsonschema~=3.0", - "kiwipy[rmq]~=0.7.2", + "kiwipy[rmq]~=0.7.3", "numpy~=1.17", "pamqp~=2.3", "paramiko~=2.7,>=2.7.2", - "plumpy~=0.18.5", + "plumpy~=0.18.6", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", From 31cbd59d183592543dd244bd87e92d3e852362f2 Mon Sep 17 00:00:00 2001 From: ramirezfranciscof Date: Wed, 24 Feb 2021 18:15:17 +0100 Subject: [PATCH 095/114] Add fallback equality relationship based on uuid (#4753) Add fallback equality relationship based on node uuid . --- aiida/orm/nodes/node.py | 11 +++++++++++ tests/engine/test_ports.py | 2 +- tests/orm/node/test_node.py | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/aiida/orm/nodes/node.py b/aiida/orm/nodes/node.py index 7a9b6fdd8a..90350aa6a4 100644 --- a/aiida/orm/nodes/node.py +++ b/aiida/orm/nodes/node.py @@ -12,6 +12,7 @@ import importlib import warnings import traceback +from uuid import UUID from typing import List, Optional from aiida.common import exceptions @@ -180,6 +181,16 @@ def __init__(self, backend=None, user=None, computer=None, **kwargs): ) super().__init__(backend_entity) + def __eq__(self, other): + """Fallback equality comparison by uuid (can be overwritten by specific types)""" + if isinstance(other, Node) and self.uuid == other.uuid: + return True + return super().__eq__(other) + + def __hash__(self): + """Python-Hash: Implementation that is compatible with __eq__""" + return UUID(self.uuid).int + def __repr__(self): return f'<{self.__class__.__name__}: {str(self)}>' diff --git a/tests/engine/test_ports.py b/tests/engine/test_ports.py index d4cd6d6246..796571d407 100644 --- a/tests/engine/test_ports.py +++ b/tests/engine/test_ports.py @@ -92,7 +92,7 @@ def test_serialize_type_check(self): port_namespace.create_port_namespace(nested_namespace) with self.assertRaisesRegex(TypeError, f'.*{base_namespace}.*{nested_namespace}.*'): - port_namespace.serialize({'some': {'nested': {'namespace': {Dict()}}}}) + port_namespace.serialize({'some': {'nested': {'namespace': Dict()}}}) def test_lambda_default(self): """Test that an input port can specify a lambda as a default.""" diff --git a/tests/orm/node/test_node.py b/tests/orm/node/test_node.py index 4ac8ad387a..afa318772b 100644 --- a/tests/orm/node/test_node.py +++ b/tests/orm/node/test_node.py @@ -923,3 +923,21 @@ def test_open_wrapper(): iter(node.open(filename)) node.open(filename).__next__() node.open(filename).__iter__() + + +@pytest.mark.usefixtures('clear_database_before_test') +def test_uuid_equality_fallback(): + """Tests the fallback mechanism of checking equality by comparing uuids and hash.""" + node_0 = Data().store() + + nodepk = Data().store().pk + node_a = load_node(pk=nodepk) + node_b = load_node(pk=nodepk) + + assert node_a == node_b + assert node_a != node_0 + assert node_b != node_0 + + assert hash(node_a) == hash(node_b) + assert hash(node_a) != hash(node_0) + assert hash(node_b) != hash(node_0) From c07e3ef0b9e9fff64888115aaf30479bbd76392e Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Thu, 25 Feb 2021 12:00:17 +0100 Subject: [PATCH 096/114] Simplify AiidaTestCase implementation (#4779) This simplifies the `AiidaTestCase` implementation - not yet replacing it with pytest fixtures, but hopefully getting one step closer to doing so eventually. In particular * only truly backend-specific code is left in the backend-specific test classes * introduces `refurbish_db()` which includes the combination of cleaning the db and repopulating it with a user (which is a common combination) * move creation of default computer from `setUpClass` to "on demand" (not needed by many tests) * merges `reset_database` and `clean_db` function that basically did the same * factors out the `get_default_user` function so that it can be reused outside the AiidaTestCase (`verdi setup`, pytest fixtures, ...) in a follow-up PR * add `orm.Computer.objects.get_or_create` (in analogy to similar methods for user, group, ...) Note: While this change gets rid of unnecessary complexity, it does *not* switch to a mode where the database is cleaned between *every* test. While some subclasses of `AiidaTestCase` do this, the `AiidaTestCase` itself only cleans the database in `setupClass`. Some subclasses do significant test setup at the class level, which might slow things down if they had to be done for every test. --- aiida/backends/djsite/db/testbase.py | 18 --- aiida/backends/sqlalchemy/testbase.py | 16 --- aiida/backends/testbase.py | 128 ++++++++++++------ aiida/backends/testimplbase.py | 87 ++---------- aiida/orm/computers.py | 32 +++-- .../aiida_sqlalchemy/test_migrations.py | 2 +- tests/cmdline/commands/test_archive_export.py | 6 +- tests/cmdline/commands/test_database.py | 4 +- tests/cmdline/commands/test_graph.py | 4 +- tests/cmdline/commands/test_node.py | 4 +- tests/orm/data/test_folder.py | 2 +- tests/orm/test_querybuilder.py | 5 +- tests/restapi/test_routes.py | 2 +- tests/tools/graph/test_age.py | 4 +- tests/tools/importexport/__init__.py | 8 +- .../tools/importexport/orm/test_attributes.py | 2 +- .../importexport/orm/test_calculations.py | 14 +- tests/tools/importexport/orm/test_codes.py | 15 +- tests/tools/importexport/orm/test_comments.py | 19 +-- .../tools/importexport/orm/test_computers.py | 26 +--- tests/tools/importexport/orm/test_extras.py | 12 +- tests/tools/importexport/orm/test_groups.py | 17 +-- tests/tools/importexport/orm/test_links.py | 26 ++-- tests/tools/importexport/orm/test_logs.py | 26 +--- tests/tools/importexport/orm/test_users.py | 15 +- tests/tools/importexport/test_complex.py | 12 +- .../tools/importexport/test_prov_redesign.py | 8 +- .../importexport/test_specific_import.py | 17 +-- tests/tools/visualization/test_graph.py | 6 +- 29 files changed, 194 insertions(+), 343 deletions(-) diff --git a/aiida/backends/djsite/db/testbase.py b/aiida/backends/djsite/db/testbase.py index fe602e328d..a76aab5763 100644 --- a/aiida/backends/djsite/db/testbase.py +++ b/aiida/backends/djsite/db/testbase.py @@ -12,12 +12,6 @@ """ from aiida.backends.testimplbase import AiidaTestImplementation -from aiida.orm.implementation.django.backend import DjangoBackend - -# Add a new entry here if you add a file with tests under aiida.backends.djsite.db.subtests -# The key is the name to use in the 'verdi test' command (e.g., a key 'generic' -# can be run using 'verdi test db.generic') -# The value must be the module name containing the subclasses of unittest.TestCase # This contains the codebase for the setUpClass and tearDown methods used internally by the AiidaTestCase @@ -29,13 +23,6 @@ class DjangoTests(AiidaTestImplementation): Automatically takes care of the setUpClass and TearDownClass, when needed. """ - # pylint: disable=attribute-defined-outside-init - - # Note this is has to be a normal method, not a class method - def setUpClass_method(self): - self.clean_db() - self.backend = DjangoBackend() - def clean_db(self): from aiida.backends.djsite.db import models @@ -49,8 +36,3 @@ def clean_db(self): models.DbUser.objects.all().delete() # pylint: disable=no-member models.DbComputer.objects.all().delete() models.DbGroup.objects.all().delete() - - def tearDownClass_method(self): - """ - Backend-specific tasks for tearing down the test environment. - """ diff --git a/aiida/backends/sqlalchemy/testbase.py b/aiida/backends/sqlalchemy/testbase.py index 9b5bac8f01..3e1168740b 100644 --- a/aiida/backends/sqlalchemy/testbase.py +++ b/aiida/backends/sqlalchemy/testbase.py @@ -20,22 +20,6 @@ class SqlAlchemyTests(AiidaTestImplementation): """Base class to test SQLA-related functionalities.""" connection = None - _backend = None - - def setUpClass_method(self): - self.clean_db() - - def tearDownClass_method(self): - """Backend-specific tasks for tearing down the test environment.""" - - @property - def backend(self): - """Get the backend.""" - if self._backend is None: - from aiida.manage.manager import get_manager - self._backend = get_manager().get_backend() - - return self._backend def clean_db(self): from sqlalchemy.sql import table diff --git a/aiida/backends/testbase.py b/aiida/backends/testbase.py index 5d0f25a66b..871ce36931 100644 --- a/aiida/backends/testbase.py +++ b/aiida/backends/testbase.py @@ -13,9 +13,10 @@ import traceback from aiida.common.exceptions import ConfigurationError, TestsNotAllowedError, InternalError -from aiida.common.lang import classproperty from aiida.manage import configuration from aiida.manage.manager import get_manager, reset_manager +from aiida import orm +from aiida.common.lang import classproperty TEST_KEYWORD = 'test_' @@ -31,6 +32,8 @@ class AiidaTestCase(unittest.TestCase): """This is the base class for AiiDA tests, independent of the backend. Internally it loads the AiidaTestImplementation subclass according to the current backend.""" + _computer = None # type: aiida.orm.Computer + _user = None # type: aiida.orm.User _class_was_setup = False __backend_instance = None backend = None # type: aiida.orm.implementation.Backend @@ -63,35 +66,39 @@ def get_backend_class(cls): return cls.__impl_class @classmethod - def setUpClass(cls, *args, **kwargs): # pylint: disable=arguments-differ + def setUpClass(cls): + """Set up test class.""" # Note: this will raise an exception, that will be seen as a test # failure. To be safe, you should do the same check also in the tearDownClass # to avoid that it is run check_if_tests_can_run() # Force the loading of the backend which will load the required database environment - get_manager().get_backend() - + cls.backend = get_manager().get_backend() cls.__backend_instance = cls.get_backend_class()() - cls.__backend_instance.setUpClass_method(*args, **kwargs) - cls.backend = cls.__backend_instance.backend - cls._class_was_setup = True + cls.refurbish_db() + + @classmethod + def tearDownClass(cls): + """Tear down test class. + + Note: Also cleans file repository. + """ + # Double check for double security to avoid to run the tearDown + # if this is not a test profile + + check_if_tests_can_run() + if orm.autogroup.CURRENT_AUTOGROUP is not None: + orm.autogroup.CURRENT_AUTOGROUP.clear_group_cache() cls.clean_db() - cls.insert_data() + cls.clean_repository() def tearDown(self): reset_manager() - def reset_database(self): - """Reset the database to the default state deleting any content currently stored""" - from aiida.orm import autogroup - - self.clean_db() - if autogroup.CURRENT_AUTOGROUP is not None: - autogroup.CURRENT_AUTOGROUP.clear_group_cache() - self.insert_data() + ### Database/repository-related methods @classmethod def insert_data(cls): @@ -100,19 +107,9 @@ def insert_data(cls): inserts default data into the database (which is for the moment a default computer). """ - from aiida.orm import User - - cls.create_user() - User.objects.reset() - cls.create_computer() - - @classmethod - def create_user(cls): - cls.__backend_instance.create_user() - - @classmethod - def create_computer(cls): - cls.__backend_instance.create_computer() + orm.User.objects.reset() # clear Aiida's cache of the default user + # populate user cache of test clases + cls.user # pylint: disable=pointless-statement @classmethod def clean_db(cls): @@ -131,9 +128,23 @@ def clean_db(cls): raise InvalidOperation('You cannot call clean_db before running the setUpClass') cls.__backend_instance.clean_db() + cls._computer = None + cls._user = None + + if orm.autogroup.CURRENT_AUTOGROUP is not None: + orm.autogroup.CURRENT_AUTOGROUP.clear_group_cache() reset_manager() + @classmethod + def refurbish_db(cls): + """Clean up database and repopulate with initial data. + + Combines clean_db and insert_data. + """ + cls.clean_db() + cls.insert_data() + @classmethod def clean_repository(cls): """ @@ -164,24 +175,31 @@ def computer(cls): # pylint: disable=no-self-argument :return: the test computer :rtype: :class:`aiida.orm.Computer`""" - return cls.__backend_instance.get_computer() + if cls._computer is None: + created, computer = orm.Computer.objects.get_or_create( + label='localhost', + hostname='localhost', + transport_type='local', + scheduler_type='direct', + workdir='/tmp/aiida', + ) + if created: + computer.store() + cls._computer = computer + + return cls._computer @classproperty - def user_email(cls): # pylint: disable=no-self-argument - return cls.__backend_instance.get_user_email() + def user(cls): # pylint: disable=no-self-argument + if cls._user is None: + cls._user = get_default_user() + return cls._user - @classmethod - def tearDownClass(cls, *args, **kwargs): # pylint: disable=arguments-differ - # Double check for double security to avoid to run the tearDown - # if this is not a test profile - from aiida.orm import autogroup + @classproperty + def user_email(cls): # pylint: disable=no-self-argument + return cls.user.email # pylint: disable=no-member - check_if_tests_can_run() - if autogroup.CURRENT_AUTOGROUP is not None: - autogroup.CURRENT_AUTOGROUP.clear_group_cache() - cls.clean_db() - cls.clean_repository() - cls.__backend_instance.tearDownClass_method(*args, **kwargs) + ### Usability methods def assertClickSuccess(self, cli_result): # pylint: disable=invalid-name self.assertEqual(cli_result.exit_code, 0, cli_result.output) @@ -206,3 +224,27 @@ def tearDownClass(cls, *args, **kwargs): """Close the PGTest postgres test cluster.""" super().tearDownClass(*args, **kwargs) cls.pg_test.close() + + +def get_default_user(**kwargs): + """Creates and stores the default user in the database. + + Default user email is taken from current profile. + No-op if user already exists. + The same is done in `verdi setup`. + + :param kwargs: Additional information to use for new user, i.e. 'first_name', 'last_name' or 'institution'. + :returns: the :py:class:`~aiida.orm.User` + """ + from aiida.manage.configuration import get_config + email = get_config().current_profile.default_user + + if kwargs.pop('email', None): + raise ValueError('Do not specify the user email (must coincide with default user email of profile).') + + # Create the AiiDA user if it does not yet exist + created, user = orm.User.objects.get_or_create(email=email, **kwargs) + if created: + user.store() + + return user diff --git a/aiida/backends/testimplbase.py b/aiida/backends/testimplbase.py index 83603e9c42..6390b74949 100644 --- a/aiida/backends/testimplbase.py +++ b/aiida/backends/testimplbase.py @@ -10,87 +10,20 @@ """Implementation-dependednt base tests""" from abc import ABC, abstractmethod -from aiida import orm -from aiida.common import exceptions - class AiidaTestImplementation(ABC): - """For each implementation, define what to do at setUp and tearDown. - - Each subclass must reimplement two *standard* methods (i.e., *not* classmethods), called - respectively ``setUpClass_method`` and ``tearDownClass_method``. - It is also required to implement setUp_method and tearDown_method to be run for each single test - They can set local properties (e.g. ``self.xxx = yyy``) but remember that ``xxx`` - is not visible to the upper (calling) Test class. - - Moreover, it is required that they define in the setUpClass_method the two properties: - - - ``self.computer`` that must be a Computer object - - ``self.user_email`` that must be a string + """Backend-specific test implementations.""" + _backend = None - These two are then exposed by the ``self.get_computer()`` and ``self.get_user_email()`` - methods.""" - # This should be set by the implementing class in setUpClass_method() - backend = None # type: aiida.orm.implementation.Backend - computer = None # type: aiida.orm.Computer - user = None # type: aiida.orm.User - user_email = None # type: str + @property + def backend(self): + """Get the backend.""" + if self._backend is None: + from aiida.manage.manager import get_manager + self._backend = get_manager().get_backend() - @abstractmethod - def setUpClass_method(self): # pylint: disable=invalid-name - """This class prepares the database (cleans it up and installs some basic entries). - You have also to set a self.computer and a self.user_email as explained in the docstring of the - AiidaTestImplemention docstring.""" - - @abstractmethod - def tearDownClass_method(self): # pylint: disable=invalid-name - """Backend-specific tasks for tearing down the test environment.""" + return self._backend @abstractmethod def clean_db(self): - """This method implements the logic to fully clean the DB.""" - - def insert_data(self): - pass - - def create_user(self): - """This method creates and stores the default user. It has the same effect - as the verdi setup.""" - from aiida.manage.configuration import get_config - self.user_email = get_config().current_profile.default_user - - # Since the default user is needed for many operations in AiiDA, it is not deleted by clean_db. - # In principle, it should therefore always exist - if not we create it anyhow. - try: - self.user = orm.User.objects.get(email=self.user_email) - except exceptions.NotExistent: - self.user = orm.User(email=self.user_email).store() - - def create_computer(self): - """This method creates and stores a computer.""" - self.computer = orm.Computer( - label='localhost', - hostname='localhost', - transport_type='local', - scheduler_type='direct', - workdir='/tmp/aiida', - backend=self.backend - ).store() - - def get_computer(self): - """An ORM Computer object present in the DB.""" - try: - return self.computer - except AttributeError: - raise exceptions.InternalError( - 'The AiiDA Test implementation should define a self.computer in the setUpClass_method' - ) - - def get_user_email(self): - """A string with the email of the User.""" - try: - return self.user_email - except AttributeError: - raise exceptions.InternalError( - 'The AiiDA Test implementation should define a self.user_email in the setUpClass_method' - ) + """This method fully cleans the DB.""" diff --git a/aiida/orm/computers.py b/aiida/orm/computers.py index d151ae2d54..358163fd6f 100644 --- a/aiida/orm/computers.py +++ b/aiida/orm/computers.py @@ -26,17 +26,7 @@ class Computer(entities.Entity): """ - Base class to map a node in the DB + its permanent repository counterpart. - - Stores attributes starting with an underscore. - - Caches files and attributes before the first save, and saves everything only on store(). - After the call to store(), attributes cannot be changed. - - Only after storing (or upon loading from uuid) metadata can be modified - and in this case they are directly set on the db. - - In the plugin, also set the _plugin_type_string, to be set in the DB in the 'type' field. + Computer entity. """ # pylint: disable=too-many-public-methods @@ -68,6 +58,26 @@ def get(self, **filters): return super().get(**filters) + def get_or_create(self, label=None, **kwargs): + """ + Try to retrieve a Computer from the DB with the given arguments; + create (and store) a new Computer if such a Computer was not present yet. + + :param label: computer label + :type label: str + + :return: (computer, created) where computer is the computer (new or existing, + in any case already stored) and created is a boolean saying + :rtype: (:class:`aiida.orm.Computer`, bool) + """ + if not label: + raise ValueError('Computer label must be provided') + + try: + return False, self.get(label=label) + except exceptions.NotExistent: + return True, Computer(backend=self.backend, label=label, **kwargs) + def list_names(self): """Return a list with all the names of the computers in the DB. diff --git a/tests/backends/aiida_sqlalchemy/test_migrations.py b/tests/backends/aiida_sqlalchemy/test_migrations.py index 6b621b4763..2bbb3e3c2f 100644 --- a/tests/backends/aiida_sqlalchemy/test_migrations.py +++ b/tests/backends/aiida_sqlalchemy/test_migrations.py @@ -120,7 +120,7 @@ def _reset_database_and_schema(self): It is important to also reset the database content to avoid hanging of tests. """ - self.reset_database() + self.clean_db() self.migrate_db_up('head') @property diff --git a/tests/cmdline/commands/test_archive_export.py b/tests/cmdline/commands/test_archive_export.py index 659403be53..3f0e9bdb6a 100644 --- a/tests/cmdline/commands/test_archive_export.py +++ b/tests/cmdline/commands/test_archive_export.py @@ -52,8 +52,8 @@ class TestVerdiExport(AiidaTestCase): """Tests for `verdi export`.""" @classmethod - def setUpClass(cls, *args, **kwargs): - super().setUpClass(*args, **kwargs) + def setUpClass(cls): + super().setUpClass() from aiida import orm cls.computer = orm.Computer( @@ -76,7 +76,7 @@ def setUpClass(cls, *args, **kwargs): cls.penultimate_archive = 'export_v0.6_simple.aiida' @classmethod - def tearDownClass(cls, *args, **kwargs): + def tearDownClass(cls): os.chdir(cls.old_cwd) shutil.rmtree(cls.cwd, ignore_errors=True) diff --git a/tests/cmdline/commands/test_database.py b/tests/cmdline/commands/test_database.py index baa6939cba..019ab40737 100644 --- a/tests/cmdline/commands/test_database.py +++ b/tests/cmdline/commands/test_database.py @@ -49,11 +49,9 @@ def setUpClass(cls, *args, **kwargs): data_output.add_incoming(workflow_parent, link_label='output', link_type=LinkType.RETURN) def setUp(self): + self.refurbish_db() self.cli_runner = CliRunner() - def tearDown(self): - self.reset_database() - def test_detect_invalid_links_workflow_create(self): """Test `verdi database integrity detect-invalid-links` outgoing `create` from `workflow`.""" result = self.cli_runner.invoke(cmd_database.detect_invalid_links, []) diff --git a/tests/cmdline/commands/test_graph.py b/tests/cmdline/commands/test_graph.py index 322a6f5730..f9054943e3 100644 --- a/tests/cmdline/commands/test_graph.py +++ b/tests/cmdline/commands/test_graph.py @@ -39,7 +39,7 @@ class TestVerdiGraph(AiidaTestCase): """Tests for verdi graph""" @classmethod - def setUpClass(cls, *args, **kwargs): + def setUpClass(cls): super().setUpClass() from aiida.orm import Data @@ -52,7 +52,7 @@ def setUpClass(cls, *args, **kwargs): os.chdir(cls.cwd) @classmethod - def tearDownClass(cls, *args, **kwargs): + def tearDownClass(cls): os.chdir(cls.old_cwd) os.rmdir(cls.cwd) diff --git a/tests/cmdline/commands/test_node.py b/tests/cmdline/commands/test_node.py index 41e0ef6f5d..874f18ca7b 100644 --- a/tests/cmdline/commands/test_node.py +++ b/tests/cmdline/commands/test_node.py @@ -302,7 +302,7 @@ class TestVerdiGraph(AiidaTestCase): """Tests for the ``verdi node graph`` command.""" @classmethod - def setUpClass(cls, *args, **kwargs): + def setUpClass(cls): super().setUpClass() from aiida.orm import Data @@ -315,7 +315,7 @@ def setUpClass(cls, *args, **kwargs): os.chdir(cls.cwd) @classmethod - def tearDownClass(cls, *args, **kwargs): + def tearDownClass(cls): os.chdir(cls.old_cwd) os.rmdir(cls.cwd) diff --git a/tests/orm/data/test_folder.py b/tests/orm/data/test_folder.py index 9c4ec972eb..17aade1904 100644 --- a/tests/orm/data/test_folder.py +++ b/tests/orm/data/test_folder.py @@ -34,7 +34,7 @@ def setUpClass(cls, *args, **kwargs): handle.write(content) @classmethod - def tearDownClass(cls, *args, **kwargs): + def tearDownClass(cls): shutil.rmtree(cls.tempdir) def test_constructor_tree(self): diff --git a/tests/orm/test_querybuilder.py b/tests/orm/test_querybuilder.py index 598474fa06..62bc8925c4 100644 --- a/tests/orm/test_querybuilder.py +++ b/tests/orm/test_querybuilder.py @@ -22,8 +22,7 @@ class TestQueryBuilder(AiidaTestCase): def setUp(self): super().setUp() - self.clean_db() - self.insert_data() + self.refurbish_db() def test_date_filters_support(self): """Verify that `datetime.date` is supported in filters.""" @@ -736,6 +735,8 @@ def test_queryhelp(self): qb = orm.QueryBuilder().append((orm.Group,), filters={'label': 'helloworld'}) self.assertEqual(qb.count(), 1) + # populate computer + self.computer # pylint:disable=pointless-statement qb = orm.QueryBuilder().append(orm.Computer,) self.assertEqual(qb.count(), 1) diff --git a/tests/restapi/test_routes.py b/tests/restapi/test_routes.py index fca6cbb0fa..f2a3c249ff 100644 --- a/tests/restapi/test_routes.py +++ b/tests/restapi/test_routes.py @@ -30,7 +30,7 @@ class RESTApiTestCase(AiidaTestCase): _LIMIT_DEFAULT = 400 @classmethod - def setUpClass(cls, *args, **kwargs): # pylint: disable=too-many-locals, too-many-statements + def setUpClass(cls): # pylint: disable=too-many-locals, too-many-statements """ Add objects to the database for different requests/filters/orderings etc. """ diff --git a/tests/tools/graph/test_age.py b/tests/tools/graph/test_age.py index 369040036e..2d531e967a 100644 --- a/tests/tools/graph/test_age.py +++ b/tests/tools/graph/test_age.py @@ -87,7 +87,7 @@ class TestAiidaGraphExplorer(AiidaTestCase): def setUp(self): super().setUp() - self.reset_database() + self.refurbish_db() @staticmethod def _create_basic_graph(): @@ -670,7 +670,7 @@ class TestAiidaEntitySet(AiidaTestCase): def setUp(self): super().setUp() - self.reset_database() + self.refurbish_db() def test_class_mismatch(self): """ diff --git a/tests/tools/importexport/__init__.py b/tests/tools/importexport/__init__.py index 0d01b12ceb..acd0d20bf6 100644 --- a/tests/tools/importexport/__init__.py +++ b/tests/tools/importexport/__init__.py @@ -15,8 +15,12 @@ class AiidaArchiveTestCase(AiidaTestCase): """Testcase for tests of archive-related functionality (import, export).""" + def setUp(self): + super().setUp() + self.refurbish_db() + @classmethod - def setUpClass(cls, *args, **kwargs): + def setUpClass(cls): """Only run to prepare an archive file""" super().setUpClass() @@ -25,7 +29,7 @@ def setUpClass(cls, *args, **kwargs): IMPORT_LOGGER.setLevel('CRITICAL') @classmethod - def tearDownClass(cls, *args, **kwargs): + def tearDownClass(cls): """Only run to prepare an archive file""" super().tearDownClass() diff --git a/tests/tools/importexport/orm/test_attributes.py b/tests/tools/importexport/orm/test_attributes.py index 504a29bde6..bdd13cf811 100644 --- a/tests/tools/importexport/orm/test_attributes.py +++ b/tests/tools/importexport/orm/test_attributes.py @@ -51,7 +51,7 @@ def test_import_of_attributes(self, temp_dir): export([self.data], filename=self.export_file) # Clean db - self.reset_database() + self.clean_db() self.import_attributes() diff --git a/tests/tools/importexport/orm/test_calculations.py b/tests/tools/importexport/orm/test_calculations.py index b3058b5399..87118b6da6 100644 --- a/tests/tools/importexport/orm/test_calculations.py +++ b/tests/tools/importexport/orm/test_calculations.py @@ -24,14 +24,6 @@ class TestCalculations(AiidaArchiveTestCase): """Test ex-/import cases related to Calculations""" - def setUp(self): - super().setUp() - self.reset_database() - - def tearDown(self): - self.reset_database() - super().tearDown() - @pytest.mark.requires_rmq @with_temp_dir def test_calcfunction(self, temp_dir): @@ -60,8 +52,7 @@ def max_(**kwargs): # At this point we export the generated data filename1 = os.path.join(temp_dir, 'export1.aiida') export([res], filename=filename1, return_backward=True) - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(filename1) # Check that the imported nodes are correctly imported and that the value is preserved for uuid, value in uuids_values: @@ -97,8 +88,7 @@ def test_workcalculation(self, temp_dir): uuids_values = [(v.uuid, v.value) for v in (output_1,)] filename1 = os.path.join(temp_dir, 'export1.aiida') export([output_1], filename=filename1) - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(filename1) for uuid, value in uuids_values: diff --git a/tests/tools/importexport/orm/test_codes.py b/tests/tools/importexport/orm/test_codes.py index d3f0a34881..7c16d46f53 100644 --- a/tests/tools/importexport/orm/test_codes.py +++ b/tests/tools/importexport/orm/test_codes.py @@ -23,14 +23,6 @@ class TestCode(AiidaArchiveTestCase): """Test ex-/import cases related to Codes""" - def setUp(self): - super().setUp() - self.reset_database() - - def tearDown(self): - super().tearDown() - self.reset_database() - @with_temp_dir def test_that_solo_code_is_exported_correctly(self, temp_dir): """ @@ -49,7 +41,7 @@ def test_that_solo_code_is_exported_correctly(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export([code], filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) @@ -85,7 +77,7 @@ def test_input_code(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export([calc], filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) @@ -122,8 +114,7 @@ def test_solo_code(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export([code], filename=export_file) - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(export_file) diff --git a/tests/tools/importexport/orm/test_comments.py b/tests/tools/importexport/orm/test_comments.py index b0b0ffcf04..6a5a1a09ca 100644 --- a/tests/tools/importexport/orm/test_comments.py +++ b/tests/tools/importexport/orm/test_comments.py @@ -24,16 +24,11 @@ class TestComments(AiidaArchiveTestCase): def setUp(self): super().setUp() - self.reset_database() self.comments = [ "We're no strangers to love", 'You know the rules and so do I', "A full commitment's what I'm thinking of", "You wouldn't get this from any other guy" ] - def tearDown(self): - super().tearDown() - self.reset_database() - @with_temp_dir def test_multiple_imports_for_single_node(self, temp_dir): """Test multiple imports for single node with different comments are imported correctly""" @@ -61,7 +56,7 @@ def test_multiple_imports_for_single_node(self, temp_dir): export([node], filename=export_file_full) # Clean database and reimport "EXISTING" DB - self.reset_database() + self.clean_db() import_data(export_file_existing) # Check correct import @@ -128,7 +123,7 @@ def test_exclude_comments_flag(self, temp_dir): export([node], filename=export_file, include_comments=False) # Clean database and reimport exported file - self.reset_database() + self.clean_db() import_data(export_file) # Get node, users, and comments @@ -172,7 +167,7 @@ def test_calc_and_data_nodes_with_comments(self, temp_dir): export([calc_node, data_node], filename=export_file) # Clean database and reimport exported file - self.reset_database() + self.clean_db() import_data(export_file) # Get nodes and comments @@ -223,7 +218,7 @@ def test_multiple_user_comments_single_node(self, temp_dir): export([node], filename=export_file) # Clean database and reimport exported file - self.reset_database() + self.clean_db() import_data(export_file) # Get node, users, and comments @@ -309,7 +304,7 @@ def test_mtime_of_imported_comments(self, temp_dir): # Export, reset database and reimport export_file = os.path.join(temp_dir, 'export.aiida') export([calc], filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) # Retrieve node and comment @@ -497,7 +492,7 @@ def test_reimport_of_comments_for_single_node(self, temp_dir): export([calc], filename=export_file_full) # Clean database - self.reset_database() + self.clean_db() ## Part II # Reimport "EXISTING" DB @@ -536,7 +531,7 @@ def test_reimport_of_comments_for_single_node(self, temp_dir): export([calc], filename=export_file_new) # Clean database - self.reset_database() + self.clean_db() ## Part III # Reimport "EXISTING" DB diff --git a/tests/tools/importexport/orm/test_computers.py b/tests/tools/importexport/orm/test_computers.py index 712d3c6d41..c6b18d0c19 100644 --- a/tests/tools/importexport/orm/test_computers.py +++ b/tests/tools/importexport/orm/test_computers.py @@ -22,12 +22,6 @@ class TestComputer(AiidaArchiveTestCase): """Test ex-/import cases related to Computers""" - def setUp(self): - self.reset_database() - - def tearDown(self): - self.reset_database() - @with_temp_dir def test_same_computer_import(self, temp_dir): """ @@ -72,8 +66,7 @@ def test_same_computer_import(self, temp_dir): export([calc2], filename=filename2) # Clean the local database - self.clean_db() - self.create_user() + self.refurbish_db() # Check that there are no computers builder = orm.QueryBuilder() @@ -169,8 +162,7 @@ def test_same_computer_different_name_import(self, temp_dir): export([calc2], filename=filename2) # Clean the local database - self.clean_db() - self.create_user() + self.refurbish_db() # Check that there are no computers builder = orm.QueryBuilder() @@ -233,8 +225,7 @@ def test_different_computer_same_name_import(self, temp_dir): export([calc1], filename=filename1) # Reset the database - self.clean_db() - self.insert_data() + self.refurbish_db() # Set the computer name to the same name as before self.computer.label = comp1_name @@ -253,8 +244,7 @@ def test_different_computer_same_name_import(self, temp_dir): export([calc2], filename=filename2) # Reset the database - self.clean_db() - self.insert_data() + self.refurbish_db() # Set the computer name to the same name as before self.computer.label = comp1_name @@ -273,8 +263,7 @@ def test_different_computer_same_name_import(self, temp_dir): export([calc3], filename=filename3) # Clean the local database - self.clean_db() - self.create_user() + self.refurbish_db() # Check that there are no computers builder = orm.QueryBuilder() @@ -330,8 +319,7 @@ def test_import_of_computer_json_params(self, temp_dir): export([calc1], filename=filename1) # Clean the local database - self.clean_db() - self.create_user() + self.refurbish_db() # Import the data import_data(filename1) @@ -349,7 +337,7 @@ def test_import_of_django_sqla_export_file(self): for archive in ['django.aiida', 'sqlalchemy.aiida']: # Clean the database - self.reset_database() + self.refurbish_db() # Import the needed data import_archive(archive, filepath='export/compare') diff --git a/tests/tools/importexport/orm/test_extras.py b/tests/tools/importexport/orm/test_extras.py index d772fbbc69..7e6f93d061 100644 --- a/tests/tools/importexport/orm/test_extras.py +++ b/tests/tools/importexport/orm/test_extras.py @@ -23,7 +23,7 @@ class TestExtras(AiidaArchiveTestCase): """Test ex-/import cases related to Extras""" @classmethod - def setUpClass(cls, *args, **kwargs): + def setUpClass(cls): """Only run to prepare an archive file""" super().setUpClass() @@ -36,17 +36,12 @@ def setUpClass(cls, *args, **kwargs): export([data], filename=cls.export_file) @classmethod - def tearDownClass(cls, *args, **kwargs): + def tearDownClass(cls): """Remove tmp_folder""" super().tearDownClass() shutil.rmtree(cls.tmp_folder, ignore_errors=True) - def setUp(self): - """This function runs before every test execution""" - self.clean_db() - self.insert_data() - def import_extras(self, mode_new='import'): """Import an aiida database""" import_data(self.export_file, extras_mode_new=mode_new) @@ -69,9 +64,6 @@ def modify_extras(self, mode_existing): self.assertEqual(builder.count(), 1) return builder.all()[0][0] - def tearDown(self): - pass - def test_import_of_extras(self): """Check if extras are properly imported""" self.import_extras() diff --git a/tests/tools/importexport/orm/test_groups.py b/tests/tools/importexport/orm/test_groups.py index 59b7f4e38e..9fef09f69d 100644 --- a/tests/tools/importexport/orm/test_groups.py +++ b/tests/tools/importexport/orm/test_groups.py @@ -22,12 +22,6 @@ class TestGroups(AiidaArchiveTestCase): """Test ex-/import cases related to Groups""" - def setUp(self): - self.reset_database() - - def tearDown(self): - self.reset_database() - @with_temp_dir def test_nodes_in_group(self, temp_dir): """ @@ -66,8 +60,7 @@ def test_nodes_in_group(self, temp_dir): filename1 = os.path.join(temp_dir, 'export1.aiida') export([sd1, jc1, gr1], filename=filename1) n_uuids = [sd1.uuid, jc1.uuid] - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(filename1) # Check that the imported nodes are correctly imported and that @@ -105,8 +98,7 @@ def test_group_export(self, temp_dir): filename = os.path.join(temp_dir, 'export.aiida') export([group], filename=filename) n_uuids = [sd1.uuid] - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(filename) # Check that the imported nodes are correctly imported and that @@ -148,8 +140,7 @@ def test_group_import_existing(self, temp_dir): # At this point we export the generated data filename = os.path.join(temp_dir, 'export1.aiida') export([group], filename=filename) - self.clean_db() - self.insert_data() + self.refurbish_db() # Creating a group of the same name group = orm.Group(label='node_group_existing') @@ -189,7 +180,7 @@ def test_import_to_group(self, temp_dir): # Export Nodes filename = os.path.join(temp_dir, 'export.aiida') export([data1, data2], filename=filename) - self.reset_database() + self.refurbish_db() # Create Group, do not store group_label = 'import_madness' diff --git a/tests/tools/importexport/orm/test_links.py b/tests/tools/importexport/orm/test_links.py index 31d2501602..a7fd20f445 100644 --- a/tests/tools/importexport/orm/test_links.py +++ b/tests/tools/importexport/orm/test_links.py @@ -29,14 +29,6 @@ class TestLinks(AiidaArchiveTestCase): """Test ex-/import cases related to Links""" - def setUp(self): - self.reset_database() - super().setUp() - - def tearDown(self): - self.reset_database() - super().tearDown() - @with_temp_dir def test_links_to_unknown_nodes(self, temp_dir): """Test importing of nodes, that have links to unknown nodes.""" @@ -69,7 +61,7 @@ def test_links_to_unknown_nodes(self, temp_dir): with tarfile.open(filename, 'w:gz', format=tarfile.PAX_FORMAT) as tar: tar.add(unpack.abspath, arcname='') - self.reset_database() + self.clean_db() with self.assertRaises(DanglingLinkError): import_data(filename) @@ -97,7 +89,7 @@ def test_input_and_create_links(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export([node_output], filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) import_links = get_all_node_links() @@ -268,7 +260,7 @@ def test_complex_workflow_graph_links(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export(graph_nodes, filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) import_links = get_all_node_links() @@ -290,7 +282,7 @@ def test_complex_workflow_graph_export_sets(self, temp_dir): export([export_node], filename=export_file, overwrite=True) export_node_str = str(export_node) - self.reset_database() + self.clean_db() import_data(export_file) @@ -322,7 +314,7 @@ def test_high_level_workflow_links(self, temp_dir): for calcs in high_level_calc_nodes: for works in high_level_work_nodes: - self.reset_database() + self.refurbish_db() graph_nodes, _ = self.construct_complex_graph(calc_nodes=calcs, work_nodes=works) @@ -352,7 +344,7 @@ def test_high_level_workflow_links(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export(graph_nodes, filename=export_file, overwrite=True) - self.reset_database() + self.refurbish_db() import_data(export_file) import_links = get_all_node_links() @@ -387,7 +379,7 @@ def prepare_link_flags_export(nodes_to_export, test_data): def link_flags_import_helper(self, test_data): """Helper function""" for test, (export_file, _, expected_nodes) in test_data.items(): - self.reset_database() + self.clean_db() import_data(export_file) @@ -595,7 +587,7 @@ def test_double_return_links_for_workflows(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export([data_out, work1, work2, data_in], filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) @@ -689,7 +681,7 @@ def test_multiple_post_return_links(self, temp_dir): # pylint: disable=too-many export([data], filename=data_provenance, return_backward=False) export([data], filename=all_provenance, return_backward=True) - self.reset_database() + self.clean_db() # import data provenance import_data(data_provenance) diff --git a/tests/tools/importexport/orm/test_logs.py b/tests/tools/importexport/orm/test_logs.py index 00090ad144..2b191cacba 100644 --- a/tests/tools/importexport/orm/test_logs.py +++ b/tests/tools/importexport/orm/test_logs.py @@ -23,18 +23,6 @@ class TestLogs(AiidaArchiveTestCase): """Test ex-/import cases related to Logs""" - def setUp(self): - """Reset database prior to all tests""" - super().setUp() - self.reset_database() - - def tearDown(self): - """ - Delete all the created log entries - """ - super().tearDown() - orm.Log.objects.delete_all() - @with_temp_dir def test_critical_log_msg_and_metadata(self, temp_dir): """ Testing logging of critical message """ @@ -57,7 +45,7 @@ def test_critical_log_msg_and_metadata(self, temp_dir): export_file = os.path.join(temp_dir, 'export.aiida') export([calc], filename=export_file) - self.reset_database() + self.clean_db() import_data(export_file) @@ -89,7 +77,7 @@ def test_exclude_logs_flag(self, temp_dir): export([calc], filename=export_file, include_logs=False) # Clean database and reimport exported data - self.reset_database() + self.clean_db() import_data(export_file) # Finding all the log messages @@ -126,7 +114,7 @@ def test_export_of_imported_logs(self, temp_dir): export([calc], filename=export_file) # Clean database and reimport exported data - self.reset_database() + self.clean_db() import_data(export_file) # Finding all the log messages @@ -147,7 +135,7 @@ def test_export_of_imported_logs(self, temp_dir): export([calc], filename=re_export_file) # Clean database and reimport exported data - self.reset_database() + self.clean_db() import_data(re_export_file) # Finding all the log messages @@ -190,7 +178,7 @@ def test_multiple_imports_for_single_node(self, temp_dir): export([node], filename=export_file_full) # Clean database and reimport "EXISTING" DB - self.reset_database() + self.clean_db() import_data(export_file_existing) # Check correct import @@ -314,7 +302,7 @@ def test_reimport_of_logs_for_single_node(self, temp_dir): export([calc], filename=export_file_full) # Clean database - self.reset_database() + self.clean_db() ## Part II # Reimport "EXISTING" DB @@ -352,7 +340,7 @@ def test_reimport_of_logs_for_single_node(self, temp_dir): export([calc], filename=export_file_new) # Clean database - self.reset_database() + self.clean_db() ## Part III # Reimport "EXISTING" DB diff --git a/tests/tools/importexport/orm/test_users.py b/tests/tools/importexport/orm/test_users.py index 5fecbc01f3..e2e28a2d02 100644 --- a/tests/tools/importexport/orm/test_users.py +++ b/tests/tools/importexport/orm/test_users.py @@ -21,12 +21,6 @@ class TestUsers(AiidaArchiveTestCase): """Test ex-/import cases related to Users""" - def setUp(self): - self.reset_database() - - def tearDown(self): - self.reset_database() - @with_temp_dir def test_nodes_belonging_to_different_users(self, temp_dir): """ @@ -83,8 +77,7 @@ def test_nodes_belonging_to_different_users(self, temp_dir): filename = os.path.join(temp_dir, 'export.aiida') export([sd3], filename=filename) - self.clean_db() - self.create_user() + self.refurbish_db() import_data(filename) # Check that the imported nodes are correctly imported and that @@ -140,8 +133,7 @@ def test_non_default_user_nodes(self, temp_dir): # pylint: disable=too-many-sta filename1 = os.path.join(temp_dir, 'export1.aiidaz') export([sd2], filename=filename1) uuids1 = [sd1.uuid, jc1.uuid, sd2.uuid] - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(filename1) # Check that the imported nodes are correctly imported and that @@ -172,8 +164,7 @@ def test_non_default_user_nodes(self, temp_dir): # pylint: disable=too-many-sta filename2 = os.path.join(temp_dir, 'export2.aiida') export([sd3], filename=filename2) - self.clean_db() - self.insert_data() + self.refurbish_db() import_data(filename2) # Check that the imported nodes are correctly imported and that diff --git a/tests/tools/importexport/test_complex.py b/tests/tools/importexport/test_complex.py index c4be04a9ac..84e95f9458 100644 --- a/tests/tools/importexport/test_complex.py +++ b/tests/tools/importexport/test_complex.py @@ -23,12 +23,6 @@ class TestComplex(AiidaArchiveTestCase): """Test complex ex-/import cases""" - def setUp(self): - self.reset_database() - - def tearDown(self): - self.reset_database() - @with_temp_dir def test_complex_graph_import_export(self, temp_dir): """ @@ -91,8 +85,7 @@ def test_complex_graph_import_export(self, temp_dir): filename = os.path.join(temp_dir, 'export.aiida') export([fd1], filename=filename) - self.clean_db() - self.create_user() + self.refurbish_db() import_data(filename, ignore_unknown_nodes=True) @@ -203,8 +196,7 @@ def get_hash_from_db_content(grouplabel): # this also checks if group memberships are preserved! export([group] + list(group.nodes), filename=filename) # cleaning the DB! - self.clean_db() - self.create_user() + self.refurbish_db() # reimporting the data from the file import_data(filename, ignore_unknown_nodes=True) # creating the hash from db content diff --git a/tests/tools/importexport/test_prov_redesign.py b/tests/tools/importexport/test_prov_redesign.py index 51e54734e6..5a04caa1b6 100644 --- a/tests/tools/importexport/test_prov_redesign.py +++ b/tests/tools/importexport/test_prov_redesign.py @@ -64,7 +64,7 @@ def test_base_data_type_change(self, temp_dir): export(export_nodes, filename=filename) # Clean the database - self.reset_database() + self.clean_db() # Import nodes again import_data(filename) @@ -114,7 +114,7 @@ def test_node_process_type(self, temp_dir): export([node], filename=filename) # Clean the database and reimport data - self.reset_database() + self.clean_db() import_data(filename) # Retrieve node and check exactly one node is imported @@ -157,7 +157,7 @@ def test_code_type_change(self, temp_dir): export([code], filename=filename) # Clean the database and reimport - self.reset_database() + self.clean_db() import_data(filename) # Retrieve Code node and make sure exactly 1 is retrieved @@ -239,7 +239,7 @@ def test_group_name_and_type_change(self, temp_dir): export([group_user, group_upf], filename=filename) # Clean the database and reimport - self.reset_database() + self.clean_db() import_data(filename) # Retrieve Groups and make sure exactly 3 are retrieved (including the "import group") diff --git a/tests/tools/importexport/test_specific_import.py b/tests/tools/importexport/test_specific_import.py index da62a41aab..926f3956ac 100644 --- a/tests/tools/importexport/test_specific_import.py +++ b/tests/tools/importexport/test_specific_import.py @@ -30,13 +30,6 @@ class TestSpecificImport(AiidaArchiveTestCase): """Test specific ex-/import cases""" - def setUp(self): - super().setUp() - self.reset_database() - - def tearDown(self): - self.reset_database() - def test_simple_import(self): """ This is a very simple test which checks that an archive file with nodes @@ -72,8 +65,7 @@ def test_simple_import(self): self.assertEqual(orm.QueryBuilder().append(orm.Node).count(), len(nodes)) # Clean the database and verify there are no nodes left - self.clean_db() - self.create_user() + self.refurbish_db() self.assertEqual(orm.QueryBuilder().append(orm.Node).count(), 0) # After importing we should have the original number of nodes again @@ -133,8 +125,7 @@ def test_cycle_structure_data(self): self.assertEqual(orm.QueryBuilder().append(orm.Node).count(), len(nodes)) # Clean the database and verify there are no nodes left - self.clean_db() - self.create_user() + self.refurbish_db() self.assertEqual(orm.QueryBuilder().append(orm.Node).count(), 0) # After importing we should have the original number of nodes again @@ -233,7 +224,7 @@ def test_missing_node_repo_folder_import(self, temp_dir): # Export and reset db filename = os.path.join(temp_dir, 'export.aiida') export([node], filename=filename, file_format='tar.gz') - self.reset_database() + self.clean_db() # Untar archive file, remove repository folder, re-tar node_shard_uuid = export_shard_uuid(node_uuid) @@ -303,7 +294,7 @@ def test_empty_repo_folder_export(self, temp_dir): export([node], filename=archive_variants['zip archive'], file_format='zip') for variant, filename in archive_variants.items(): - self.reset_database() + self.clean_db() node_count = orm.QueryBuilder().append(orm.Dict, project='uuid').count() self.assertEqual(node_count, 0, msg=f'After DB reset {node_count} Dict Nodes was (wrongly) found') diff --git a/tests/tools/visualization/test_graph.py b/tests/tools/visualization/test_graph.py index a816d9e012..c3352441aa 100644 --- a/tests/tools/visualization/test_graph.py +++ b/tests/tools/visualization/test_graph.py @@ -23,11 +23,7 @@ class TestVisGraph(AiidaTestCase): def setUp(self): super().setUp() - self.reset_database() - - def tearDown(self): - super().tearDown() - self.reset_database() + self.refurbish_db() def create_provenance(self): """create an example provenance graph From 6044d81db04df70b6d5dc9e261658d6ca50f8630 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 1 Mar 2021 18:24:06 +0100 Subject: [PATCH 097/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20add=20broker?= =?UTF-8?q?=5Fparameters=20to=20config=20schema=20(#4785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../configuration/schema/config-v5.schema.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/aiida/manage/configuration/schema/config-v5.schema.json b/aiida/manage/configuration/schema/config-v5.schema.json index 3993a42939..43ff1e87fb 100644 --- a/aiida/manage/configuration/schema/config-v5.schema.json +++ b/aiida/manage/configuration/schema/config-v5.schema.json @@ -319,6 +319,21 @@ "type": "string", "default": "" }, + "broker_parameters": { + "description": "RabbitMQ arguments that will be encoded as query parameters", + "type": "object", + "default": { + "heartbeat": 600 + }, + "properties": { + "heartbeat": { + "description": "After how many seconds the peer TCP connection should be considered unreachable", + "type": "integer", + "default": 600, + "minimum": 0 + } + } + }, "default_user_email": { "type": [ "string", From a244618b56ea5d5c1d0ed5d5d1dc769fa190bbe9 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 3 Mar 2021 11:59:54 +0100 Subject: [PATCH 098/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Add=20'except?= =?UTF-8?q?ion'=20to=20projection=20mapping=20(#4786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds `exception` to the list of allowed projections, and also standardises the way the exception is set on the node (capturing both the type and message). --- aiida/cmdline/utils/query/calculation.py | 3 ++- aiida/cmdline/utils/query/mapping.py | 2 ++ aiida/engine/processes/process.py | 2 +- aiida/manage/external/rmq.py | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/aiida/cmdline/utils/query/calculation.py b/aiida/cmdline/utils/query/calculation.py index b2026baebf..d52ace1a34 100644 --- a/aiida/cmdline/utils/query/calculation.py +++ b/aiida/cmdline/utils/query/calculation.py @@ -21,7 +21,8 @@ class CalculationQueryBuilder: _default_projections = ('pk', 'ctime', 'process_label', 'state', 'process_status') _valid_projections = ( 'pk', 'uuid', 'ctime', 'mtime', 'state', 'process_state', 'process_status', 'exit_status', 'sealed', - 'process_label', 'label', 'description', 'node_type', 'paused', 'process_type', 'job_state', 'scheduler_state' + 'process_label', 'label', 'description', 'node_type', 'paused', 'process_type', 'job_state', 'scheduler_state', + 'exception' ) def __init__(self, mapper=None): diff --git a/aiida/cmdline/utils/query/mapping.py b/aiida/cmdline/utils/query/mapping.py index 17d46a8b43..56a62f1e20 100644 --- a/aiida/cmdline/utils/query/mapping.py +++ b/aiida/cmdline/utils/query/mapping.py @@ -91,6 +91,7 @@ def __init__(self, projections, projection_labels=None, projection_attributes=No process_state_key = f'attributes.{ProcessNode.PROCESS_STATE_KEY}' process_status_key = f'attributes.{ProcessNode.PROCESS_STATUS_KEY}' exit_status_key = f'attributes.{ProcessNode.EXIT_STATUS_KEY}' + exception_key = f'attributes.{ProcessNode.EXCEPTION_KEY}' default_labels = {'pk': 'PK', 'uuid': 'UUID', 'ctime': 'Created', 'mtime': 'Modified', 'state': 'Process State'} @@ -104,6 +105,7 @@ def __init__(self, projections, projection_labels=None, projection_attributes=No 'process_state': process_state_key, 'process_status': process_status_key, 'exit_status': exit_status_key, + 'exception': exception_key, } default_formatters = { diff --git a/aiida/engine/processes/process.py b/aiida/engine/processes/process.py index 6b3c29780f..bf5df7b6b7 100644 --- a/aiida/engine/processes/process.py +++ b/aiida/engine/processes/process.py @@ -414,7 +414,7 @@ def on_except(self, exc_info: Tuple[Any, Exception, TracebackType]) -> None: :param exc_info: the sys.exc_info() object (type, value, traceback) """ super().on_except(exc_info) - self.node.set_exception(''.join(traceback.format_exception(exc_info[0], exc_info[1], None))) + self.node.set_exception(''.join(traceback.format_exception(exc_info[0], exc_info[1], None)).rstrip()) self.report(''.join(traceback.format_exception(*exc_info))) @override diff --git a/aiida/manage/external/rmq.py b/aiida/manage/external/rmq.py index 8bfaf100f0..f2069603ab 100644 --- a/aiida/manage/external/rmq.py +++ b/aiida/manage/external/rmq.py @@ -11,6 +11,7 @@ """Components to communicate tasks to RabbitMQ.""" from collections.abc import Mapping import logging +import traceback from kiwipy import communications, Future import pamqp.encode @@ -154,7 +155,7 @@ def handle_continue_exception(node, exception, message): if not node.is_excepted and not node.is_sealed: node.logger.exception(message) - node.set_exception(str(exception)) + node.set_exception(''.join(traceback.format_exception(type(exception), exception, None)).rstrip()) node.set_process_state(ProcessState.EXCEPTED) node.seal() From 7c93dc89e2c6eee421fadfad476577a5b47a19a0 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Thu, 4 Mar 2021 10:37:14 +0100 Subject: [PATCH 099/114] docs: reorder/simplify caching howto (#4787) The howto on enabling caching has been reordered to move the concepts to the beginning and technical details to where they fit better. The figure has been simplified (complexity introduced by second input node unnecessary). Added explicit mention of the fact that hashing is enabled by default (which may not be obvious). Co-authored-by: Chris Sewell --- docs/source/howto/faq.rst | 11 + docs/source/howto/include/images/caching.png | Bin 25527 -> 18865 bytes docs/source/howto/include/images/caching.svg | 1441 +++++++++--------- docs/source/howto/run_codes.rst | 55 +- docs/source/topics/provenance/caching.rst | 33 +- 5 files changed, 760 insertions(+), 780 deletions(-) diff --git a/docs/source/howto/faq.rst b/docs/source/howto/faq.rst index e86cfa0982..1481a2e26c 100644 --- a/docs/source/howto/faq.rst +++ b/docs/source/howto/faq.rst @@ -78,3 +78,14 @@ Run the command ``verdi daemon logshow`` in a separate terminal to see the loggi If the root cause is indeed due to an import problem, it will probably appear as an ``ImportError`` exception in the daemon log. To solve these issues, make sure that all the Python code that is being run is properly importable, which means that it is part of the `PYTHONPATH `_. Make sure that the PYTHONPATH is correctly defined automatically when starting your shell, so for example if you are using bash, add it to your ``.bashrc``. + +.. _how-to:faq:caching-not-enabled: + +Why is caching not enabled by default? +====================================== + +Caching is designed to work in an unobtrusive way and simply save time and valuable computational resources. +However, this design is a double-egded sword, in that a user that might not be aware of this functionality, can be caught off guard by the results of their calculations. + +The caching mechanism comes with some limitations and caveats that are important to understand. +Refer to the :ref:`topics:provenance:caching:limitations` section for more details. diff --git a/docs/source/howto/include/images/caching.png b/docs/source/howto/include/images/caching.png index 02563f59339ee1f18a2548cb1da22c9a36cf4fd8..2c90688a3f00e430128eb06c484fa8f69f13ed35 100644 GIT binary patch literal 18865 zcmdSBkG ziSsL6Hvquye*8h`cPe-bJ|uCM(Q#LIvUK+}b+rIIJw4fN9PQl9O`R>+om{O_4@5`- zfEtj8N@{qe?JaovJ+rt+I~wuy7{fjFv_zmvqv$->( zu1xKtL!G%2S;JajB60nWdjuxVpa&qPAdwd1j=~9NwJ$J$sqVMm2xd z&CNX`BFz~$IM2Vs?Shu>pB`C=6od_sBE14s5}(ME(gg<#0R6x-L`K9cIB^8YbZ7L3 z6j`!NQ-3pDC$ismVRFk<^6(1TA|#FH+!r4_g#4z0f1`Eq0x2k$Y$8Aa%`ZIEges2n zx#NY1XDFAVVVtEE%<#QE`?;85xiTbc2s%GtO|XJa35X+Hs+{ap_n#8i3iljxKy{u( zg?KSp$y#xoB0w9cB*-Sbl}4YOBH_2MR?loIZJn9~??_06rBsDDSE;|znY&e@6~T6P z(iAKzfGBW3`5I~79J5#`6b;G3`}^;e?+!kxeQR&KWU>Mt^!^5Mj%=RYxk+6_@E2gy zK`Y+XP}7&D^Py)xAUR7iF7f|C7en2c6}et@O_S2meTA{o+hViNOHmHEt1~WgXrMDr zlHf2guj}2&S;?n*K(Ssx}S@m6g!lO?U7!FD@X-iZKd#Z5Sf6PQ;TogkKrA8^G ze(9EXHX6Cnxz~)Z*-!md4SpT_!&*v5Mg_x6%~1=LSlwh@H59jNq$vg=7DiDi1A9he z%aV!0Wij?e$ijkatdkh>eTp~B&uo}@4z}huAsJTiA&8(SN>G=`I&1`}!5p_sp%NA5 zgI$Wl;hGG%+4P8N5d4!o51L6aq#PM)qY<-vbuZ^b>0vRY@*Qd^4ee4)TDPH7J9jb; z#2iOzMBQ;Swsz}3Ndx^2nrOJ$LbjR^{3)g8P>C794CzaMRnn2BNK1XJ5`|%bP!vQ{ zb2a3<0rwrL;pzzWy2{g*=R$dVlXA1-YDGek5Y4QKHTh(_s-_*%b{wV%+w9$+ugw=d zs;Xrl7T%%P+6Jl`>z(-M8+HdGjAKfDeZ|SiRlm(rB#o|0Fu@E@)EZGZx2YR}T*jx< zVu^~lI7}LA%qpClH(R5T1r$H4q&3!TJdMVz>-^7V7{ zMc6bwGt8{$qf9TS+*Lx*6bI8?(s!Oor43h5({Cz}NpuWlVc~DX&!*BEXMPvCp{ou% zNxrqRnoHVxkfM;yRHvn)$QBBNtltv$I2FpFNzg+J|7vfl=*l! za|KIvuUBG%2KrW56Y^v?#TYF!zKeB*wZ}bU2aC2Uof+LscbDeY`4Gg7H7UWY!9#pk z;whHF;M>rC7yCopXXHO4rdDN=o1clr;#eH0O`B&!D2PInE{S`l6eV)_6jkz7-_D0i zPqp#`(`2qWM_Eto1q1;W%EQe^X9B8xdWW zk=aK)<|(ueo=5hoK(NZFDn^gp6ExU&1%kSAP^K@th3s!lDG1xQq^8I?y7tX)=o$-a z3Fj<7Tyt{&xD$Pc!4Md)9%-V$gz0*tj*^JlT&~5;e{EG50jM-(S#uM zEwlJ5@7mF42x=Sby%C=YO@>#UB&T2#E{L-A$F)V=@b7N}1BQ0b03L0MHG3H8gv^`~ z1tjrVL{gZ}Gb#?LV@o$9CW4f-5WAaN~6I9-)rb|HSeQQLE=pvSv z>I|J4AfDnb2g%x&eKEXq74{pO->+P2mrnxqF4<-S)KE$Fl5|h9!^g+ z&|cu^n=q%S8)M4I8+t2h`b969790i+X!0E1#gF(9$ZKo+mG<|BT@v#lRyji z`(X3StL_Y`G-k#Wmf1Uu!_909%!eqWo3w3ma`xN8`q=2uR)&N+co#Pkz&9B!CO2e#~&9Z0Z>p1sHt4RhXKaWnZWGI5T3T#JL3 zKnJh1Qm zf|pI4Ea(~{?FMk_icl4RFHn+vx^Br?KUC|(@D;^wG_P?pJO+=eJ~I)^eYvrufpXbp1Rf0X~t(xD&Zi% z2^!nofs)v#^|01u+V*y*Uq!h0mj~wg?`ijD294Lc{CFS%>J-_0Z5j1gWPv)Ho~f2& z&c}v{Bw|xM>p+lyiy{ma*PDlv4^*i-3(*5yhT|=nP`nYWJ3^h>R`$qbD*-IQ?2LM{WeMaS^ zRMA2#eGIc@gf2VT^#lU4N{e?7>WKt2A3mO+nz*;k3Smlee|{FGBJ~(dpoQ@kg(6Ft z-8~n%oTd7GbHJh@_kdNBq26k)5Yt5*JQV7;@Q7x-I;k*}HhWmxq# z9<=3`GYArNtQLI&6Q)JogcfqCln%eo!lQnj#Jnld!CDWXm~w$$j-U`FyhRJrIrrZW zH~irO*Qad6m%AXBz3*Ig-XzKyG(;`$$8;)0`-+FNveQS$M?+W{=>jjtkF})G%aqC2 zvK}oHwD-_2SPiE%kK940b)7*lao^eMlDDhY0vbfd5!l;U)|_mZu~;=huFKRJ1d_Mt z;w8h3-~C!mD8#0rohl5e@b)_d(A)LxWjIk=`P*(j(t>caCZq{5_lcf;DiMvNPM47^6%W%wEsFfdL}^#P;r#@T zY#@Ji(jy{AzmbROj&GHv(fNEhdX_-_G|2CFaZDtmxq)r?vtrHo)lMkJB(5qa-|rEK%RXHp1Kz44SR=8F#20wHPx6rX6BGPn#>en)v1<;QDX%04-1Wa$QXQyyGU1C# z5_(i-IMw%%yL)s;AbPaudsv#Y>oY&!=*Hrg23e+mi-G_Q$Lk);yNG;B?(1wp<09#h zBi$Z}k*BC_sY1IrTzZgF9IHauLv#)uSpTFN&iNseJd|@~k2l-Pe&_q3RMAv;#|6^# zv!55`FDU-R@@y=wHbrmg$GZYR^TGkbFT{@8!P~dabQrigG!&9)Y<3n}8m3q{CfR6! zK`4YsOOtb%dYcDlusn8`&&*%Zh)z`0pNG5sJw88O)9-axwkP(lX+orJB-0*aLpJwR zwx>PP2&XFK`Op4vz_-#7$Mx3~6gi6bF@?xuya7~T+M#~VcQ9R8TdRW5l*1ll#r?Be zz7hI@hbX==+8<^hlE!Q91GS#ipBH+%r$n(v0(&~t#9GE$#D9SM497( z#$FqU9g)KyhZv-M9Z8Zf?V}nG_^~CWx^4J`TETptAgR@ zVYrQ~`oU+rQ$(=sQ44~g8+gH+*ROxZw6usc7T2Q-t3lqcZEQFy%gaYHWV1wWg_jvN zw`HWJvgC`&=)@Ufy~CyuI(@FhdYhWL>r>ag3|CK=hmYx&QD1{JU$U|7qeOHc<4ou> zSKH6LEFZEYL;r&w#17?=htbn0Sv7`GXA?qs5IU$dtqR|JpKRBlAdL0cC&?%!%nsIL? zUMFNXXo!U0yZ-I(9~~XN&*b?C_ex+vr)7TNPPRo>WfvB{VY3V%3X+9ZgEO<5jIR$v z0Xq+hgfPlMa{znb*R*BzkcR;I%Pd7k)5Z|a8+1g#fL)s=$QR)Y;#T)yEK|&L+Ar%E zT^MxefODgPIR0;Agdq4=d1d8xDHuJj_c!V;9S@K50e}#pg8RfEqFdD%e5}i=4bhtJ z6?iHqBl8rg0!e}eaJ6YbX9DFCFAD)Ut%lQbpzc%vRZxJ9jSWum@N?P-a1b+a5DAdl z6!er8fT-qmFRQff)Wad*l!`x4|IGdNO$5p0Nf0qG<9~Brx}DuBrIEaju~JhwnZS&m zKURoDjQ9rkf7^oMNkwQXO7*-M8GJ0g{p9B5Z_19y@J#T1u4(Tryz1iq3ae@NL*c%i%|L04QG%8kmA+4m<$wr%12kFc_bD?4g3kk5fjL> znFRm~z|lp3JQkG2;E*c{?hjBaN#L9?Xqm-TuDXH{kk~azUnQkHvL^>wud1##Mn1v~ zVle{BK$G8 zl+)9jl?MBqR2oQrM_=&-6{6EO_<#t|`s4;a+%ITY;cb{ThJ3{BNq%(NXF-QRk8KLy zW{mIwTsvJp3A7t2S%{Thoqh6@W%W};XJQFk0|SFRaMB{+q+f;zOtpBQc)UDONz*BE zg!=egQ%@v^34&hm7I}Fo1O-#fY-1`|{mlf3IG@o2L7PcNGc747ux1UK0Co_Q4xI(E z3FPohhe6)AxtUo0Zs%ttAihFV0sffLUW@m}L6fQEIZVzMI`DU~JWc^BwYAlsVfoIz&*>6KrbV3kJtksJoCRHH$e zRGI*#D&ikfAhuZXc~>0xrUzFMNM!xbg_$$BF0g=#Uha2Ap`nBCxHKB51AagUw#mVQ z=hA|N%X3`Wf734D0~Y`vpu-8!o83QukVq)PX8-~2EwZY=+s2^a&%q0XBgBTGW#?$n z9m3E+Be>GAqx}?85RQUL=qC*wNxOAXd$_;j&>(#khVt0lt2fBnOg!=B)2F}6fH5Nc zHxFC0(^8u}sJz<$h7<&SaBly+6F5h(CL$vfHiXe^VE1j1xE z2;$@aG@gBKz&>R)C_)0jx6bbGZame}kMJ0u9DxLIKQM`k-my<4 zL#=h|KpEf?gJ$N;b@GVX^`M`7e%(=^bq6Q0-c0;51U;>%r$-JbK~Nb1(Q)MacR0vX zSoq_Z$TU`5h@1QMOYFa4ca%Ucz%J;qH!aTW8&iS$C?gxCWHekIOd|F{lp{uf^JHms zY|Kf8Ts|!5v3K^DXllffbyr0+PPCxD!9!4&c_6!gKzI3$PTK%3ZgPmz=$TJ~>eH3- z*W095JQHr5Es${yqKG)yf5GmI9$oY2&=5s2XsVPTm@3p_x4pw8DnmY6`$3iACP1E8 zI7k-)nuclsp|l5A$N)GN;t>sKdHf^G&g0tT^2ovei7uKtBnCkQ-2^9S5~v#Q!|gPA z^fBmGgF#s+aDea!>Enj8z{_WHcnFsN8mvLPEQfDheHOp>?Fd3xh)2aku=uYFS2ccz z8c^9(Hqy8nBfJ7S3&KB#g>E$(uxE^Hk|F-#4EmCkl#~b}VKAuLj{~g}UWct09taIo z0RHbI5W9TArdq3+$0lh|K>iBQazp@Ek&M$s0u9vKKIpD%u1>WW7#Mv>Iw;htQ)e^S z5p;SmdR4c7K2~t{&n<}oYAOj1S|25s=c<_hHEg;z@*#Z(087eMXMtW)IT4`>{&jh>fD zymnf+LGFMfFa>x?Inb&ag2Ob<3%7uRBL`yBZ;zS4RX+Wv$bUVYhL2WV8;wwo#FYG& z?zydhWrn|}i)8*!;UeB5U(o=2v|ppUX9-j-&mJ*`M!mFq42{aqSz+od7!Uy*VIUHN z&cX~v9Upz&!plK*-y{Fo2K8VE>VXK<_rE4wsMi}sMSsc;BEZbuFe4BY@1$zFJn?^? z&?1cGWYehysY2nB2-5>GEkm-h*(3j-Sl$w5UY;6U_8U(-RW-RABVS!YqVg(bNj` z!ot2ZxE2VY)+q#>O~`uVdgQ@v6xN>p{s)wfa^RD{*wSrubTlJ~-n^I~A}VY>x?yKz zvpWIOmjB*#Wj>4ddvt{VsQm=dlItV`At;$afINmw)9Ssu7AuIwNz)+WFH&dE2pkKx z&6OLsTNZK4y!@|EO!7bPs~oaS-FFu%y%gm|VY!b;ES#hVZ3x3<#S2OjB?e`9ET;T_ENon{WZbxI>^@o`DST{9`}|4T%`l z1T9Dl$SeMU?_Ht;u22<_!~s|s`~c|>(yt{Dkk!(eKIwo$MmFUF#cz!NyP6?z&|QFB z^b6{H>i?*yfYB~DkO06@J}%TdEj{mzqYbgZl-iKgi5lA;N5DdGMwhq&L+M12S9GvT zvb>EdxIXc7;~;cEPbI)r&}1UHWcZ&_F!d`;I`g5YP9PW-f&qU{M#K+AZ^WK|jZtru zlmf6ilmR@1!M61TeY2nSg5)u~Agpo6zpEzxZ?cZpr`wc@V+SZ7aWbu>q`FR~jC@P| zL3?>XJhHI2x2NjGWs}B`N*cYrI;n^Jb_S@k+iR?Un;CY8KYieN`yDb0Yi-=|(=xsG z=LG}rxFO4CrLmg7Qg92)rxHv62XN^p#=*UG;M&-xNvMJRu6y}%)Un=psoB#hnv@qK z0*9(EiObyGa4N@;Jy97ZR$X0;t)kyl7E701OF}|YcQPz|c&f++m@Nj}I4spU%(Z!4 zEO_RUv%Yp*|_9Jy5#M<=&ZR9uD#@L2x>n#$~e6vXLLo^L2g0Yv6toVClVnA-IA z$EQzV_m$JY79$Qm$NBG$_4)_T9cb=uE)Lg@y#j3J8;+4aGbqQfbD#n6$Vf?W^2uep zYy`43SYt%UfOX1wY^i>|k5-9}%Z}$@Gzqt5L*!O3=+lmB#LCD?c{Zgyio#V+OJX;m zo&F5C*{|oWV_;y|patxUkdUR6l$7NAV<<4E%8i#c^HLqF=Ir@cb*qq<{7!~k znim!Whf;VcCrD)etgWv*7*<;jr7|%w&G=p%e7IVP;%F*0XslwTp`j@P^A?>(*Ib9C zHh;n0$gHxxR2|9bw&Z`o z1|}O9KVEdkm4fl0gQgY4v;h1g{W0Mw1bTOV(=DX2eU6B3xsRdx&Pc&u8>bN%!)>Y2^?NKO`^1>0G^^?3@h zs~@M*4TC58pWZpmk=l!TG#tdOWH-?AxS$)ETdpKcO>BD_hHYq{J{3u@N zsQJW4%GP>BiRgucIrWcAdwcymSQuId5-%MjS!e2S)wTViGA{N?{<`@!#Z35mnl5zu z6MHP@N-8i6+8e6|44xPV{=OMtBV+gQ6*<=QHv$K!_jp)Tf|;$n|B0C`qvl%K-p{9~ zaC&p&Y~BTVDRXLZ3o5PiQb2-~HA?+GRvP@{#>$ErG+k(4O@%-Fw zA0i%*SK89m0$Cm!l@v<&e_FDfT>Aaa7cZ;r{7yTcw87ff)`d7<{YPArJtAz&nn(VX z)I!p<+5Na8~mYdwmeB|E7esSZPSA7HY-0EO1Yns zSeVcaO|>X3eCqh}zU=?p1$d6bt{zPdit0C&mAP`KOLu?I^#0aVqp+jx6iI7Jy{zB( zAsTg*sD)187SJ}|gdn~@8}mh0Q5si2sV5uOUgx)&xW1WYBVgFtaGAqz^vh*^XkWd~ zXfAJoM6P@4U`gNV$K+GCM8~D^LPgh5Br9?LPD*t@tb?_`8LQcQ2h~*gWeTbgr_y`e z;){cq^8vcWeBWCdsZyR_E;&TUzVDM6(p$;4WD6xwP11G($euDdT>NF1*t2Qro1_efCU zp5Z&B;6kT~deC;Kh#kp1ug+d_tBp^~kxlW5s~L=UEY&q}x%A(=><_UZ9*do=tc);* z301n}F~KYD+jk3^i5t<*uNNkp97PSL>ipsOPgt&&5)?Pd9GdS#u#54s&HRGBGB9`e zh2}*HhYLCWqMVUxiLCQShruLoo1FM>DG3mrEq3du-3GM?Ol`ZD@51Fuomw)-CM8-& z3ZlxVId@iA_>gzbq?k%Px#~1IH?!V+-k?#SE!i?rHT4f{pMsz6a#**Ek9&8i>Jas0 ztw?n{JYB()N`mUBs_GUlaj3S47G3TYzogUDA%Yf8*)_1y2+caDLNu2rVC~J*svf?& z1=V5273}a!##KB{p))3^rp%0eKvc(m;bqmqE4)b*RgU4y*K_{wt{%r_r1|;a7p9iM zf371R$4VH&$pH^P?&~Mf+FQi!dfxRO@2MrvOK$%bm-N8_xg;WJEJj)SrDMO=v-lrn z*#(UtJnVDLp3mgLD5R5L7`%f?))ak?)`ESya~$Ac>)cC=r%`%dvi5V*p`OIy#%AiA zQa^?~`cmD()DQwV8_r>e&*j}-67W&*4SnOmwY>Ity+C4Kop?RBF#)^TiY1y~wlIc{ z^kLqFc>T#9FqyjTXFdP2oF(JNY*?a&9{9-|U&Md5j=yEmY&8pMAC)5srVrXH;ZK9* z=X;phn(1%nCkj)S&(p8^e+4e4CLNz#IQxo*$Fg`fO!H{ZzK3}2y(dc`OFuvkT5k^6 zbvX&lTM!T2FFIlNWh)<%6ON^-9A_G>Xd}#-H`iVadNIeh0-t-3aeil!>vf;XadiFH z$ysK%ZfmB6m$Z+8nu(rJZeB(}%^;fMO73`d*kRh`mT@xXK6SYO3QgcU+abc)bN&5E zx4n$hK-Jc>vvMQwZl(L?WxKHtXRd0s!~QP6$S-QKEMnlCh~POTJNxTtk3HwliV-+< z+Y>FYgOd9Tf6^v#N$2EjYH&Q0oqZ(dCEvr$K`X!Dh2v2?zbYo24Ew*$6(dagv6NB~ zs2`9h)RV@?rWTr9@3N4FwD#^xePv>Wbu^~8inA3zw9PvDwWm__$MQY#l6a?ZGPTvq zrf2T6BxzN`tE;|?{SzjzqHuUhcIz@iSQ<@4RhWJE?keFrwVwyv&ezWHL=YqX5V zs74Ky9J7-r&BQ9Xd_N0SP5&hCIvQSeisG~efFc#oX#M7|Svp%?teWXoX!hP5VpWYx zBYGw!pyq3GwzzP)H2L(>dql}z$PbWvGi<0T!bFd{IZ+xr-Z$iIGr-r-7Sc!{6k^weP9vAJI90(NwTVi+=sZEMXzWd<%R4GbD-K}Ty z-`M^G4go?mK=IS3g|ihi(O|t4cJkyV>f6ZJ_ZZ)U6=-g4bn_kM-+Z1YV1X!jVo9On z3aNpik@Wu)A09#Hk5QRiG~xsaV(-D&YXoMEbm{*S*ZqGH-v9L%T!MB20g(;dx7Va4 zeM&vH?)pJ9T@H#G(ai;PhyW~x>{Go(C&=&D*)Jw}J^(>*9T*{Q9P3#tm>qL+BLtQ0 zCR}Giy;vR=B{67Q;saG)hCs43#9ZG!gL-{7#L5Z*^HVV648u@^(9<*%OQFp4LZpa= z9FR?!!0cZTmF8LX_{C%L-cKEd;fmGue2q9vMKVS*DQwRSodC?^@T#I{G(LuKJ^?#2 ze`gQ>@k$b|@(Ik8(<7M1gZQ`w_&DNA zkTA%E#z*u2VGqo6|Bd{OdQe&xLc8{sNE+Srl`LcfOu=aod#WD0K^cFbcZs}3rjb$t z8I5=rhAs$pi`X-RfBYQ6i9;d@{x-y-38Q&@8ciLB4jCW}BM*gSJ*PuI0h4@m!D6H5 zj3_ivK9FKQhERsaQjM(Ul4cj)vKyHm;Zev+I*0d)J%|k)7`QN7CRwso;?v z&YJ)g+Lj+&=>O9pZG-239--JHTUG=$28pkl{XX#?<&lMDMJ2bC2yhBC4E@xD z-Jc$L1eGPidL^xvbZsQ=9$IDKet37>1NnWRqD4;ioO9UG;whb(gJesQ2i z;A+(cxInP}IT!_$a0HTrd48ioJ0qNtl@UDhgK!&T0)qxns5W8)aR;;IefF&j^z`$< zx{tBypx(dq6j~FXoYD68CkrkaneTO;Fv5JJX*VjEAq5)i>duV4r^Uehd*#Qn?{QNy zoGv_y~$ zQ>f{TfE!!fRDriH^<}$OfoGRL7&2HJH^2HCX{J}!l{jOCv>F{!5pfs|qTx`An0JGx zsI5(94{7P?ttzT~rlw#LsJuVlSiROy6)3WLA%1&=RA)c40w$TtOz#OfwHVO{GsGX{ zniuZx1Mg2VM*l8>sU4G`Y_YintRCv#gR zfaedDhwTsdroFg!LwF#}gIRd&{q4!f_kjT7Q zQY!ybT%?X{nW*V~j|bF0ioL%PyYhr2GQnh*zuNX{c-(>jxjqKm|SZWKn z^Qqsl?`le5)9<4Ya{oTp;IjIW-LUCbjm_Bf>ahU0g~K;7gV=eRFR1Lz6JfOf(K_l5k(FQl_Y+b1r;0qd0S_l{5O%SUY4f z#+}DX3X_v`^7Jf8B|G?H>)U=^sE35y!Ljr{pWw~uG3rat%y$-&3VPf*40df~JilOA z2Elq?Bzf)WkJKn;@yKj4V&(m}UZF3lheb}gR^z{M*^)}%Gwj^LNKZw*Q+)D*+6axp z4)&}3@e1pUtwUW!Q+3G0HB9!+&_;8j1P-v3DWF8sk%9kP7^R^8dvc0Vo%=dsz5DX9KyAT=AeZ-`*tbI^jq4Ugh}j8x2dQ{2J;iElU}KzQ5GzTL@+br9!d6 z-M~ih<(W$%-HAp7ZtaDXqn4epGxx3^K`@@?rXX1;3F*4zRJ%y|n^31wriFaws80_x z>EJa?dB3;Rq4(IC>F=?4JAgne>yhZ)PnA+IVpV^R($v|${yO2TlL-?yg9s=Tty9M! zbN^~Sc?aw272h`$uHz|Byv#-_`YvLibEzK_+cHnN^SXMq`NORwNT5-ktV$^B%b28l z)($IgZ-=UNi*>(~MMkUZE?sWCI31^y*8kk9Ee8ZRG!9ApZsSI*$au4Ejtq-F!`)hI z94xCw_8T*F+r!;4`WL2b={{hGidOe~khJnVKP1}Y_u^vMMMPAjScE~KmiDgB-8H`eb?UMpPSgkM55JUm>t|4Q($ zcFNxAavs2eWdvrQ-(L1a(GBtcg7nWl4`^z~9 zf!h-8f7WVs{fU#FX6okR)RkS>f89c(YIUCGnvkTG+!!7dFb-H@9KB+m(A6+q@f)%W z^tO+U&_eqQL(+l_Dtw+)3Ve?q7}1+hRh;#KRJEO}kfgCFRA&aWQMc5gWA&TdsqrfY zh`Xi#eFHn4Vesra`m298Y(4VGlYeF|2YZQP3Svktfo9UCNzwD3zC3j?7#4sh^hv|& z2?G|ZVcWsLl)-kH9u-eHOj2Qn=S5x(k0l?1tFN&5<5lnN)#jK>FwkK6G3Z^LSa#J{ z{FRcT>{ZS>$~1Rl-RENPnlwl0*W?npcy>#bo`W0BC!J`0-(3u4Te5`S6DSNO`>?&b zp{s-)?BYF({?KN9Q;jbqs-rTNSa4j4xcRfh%h-K}K@D5ec_m83-|V2qv?wRZa3f`V zN+L(@8+lRCFUb?HdoH)RK~iPz0#(VkrGxKZ!+WJT*^9Tyeuxm)IIyCwK?(T|g2Lr{)am8Yl({8MXB=}5a#`_kkab+wCP%=kV} zDIR8i_GINA_@332)V$vRJ0q!awpVXIBq=TN3@AO!bD?+Udk+z1`{scC#p5hc$S8S@V*I zGP%*z7TmGXDx^-Ev+T1KvOs!C^P&UwXYy0?&#rk3=v2nVLJ{#0I<1RQFRQZZ*C%pC z8W4GzTy@~IK=b8tV&AE)<-w`OgkaauqrGI=;1mv<$_u3@7V!ILGUJqjSN}H@+a>c< z&~T8B#Ww2G_rC0EE16kVamx;{{xHc-eK}@X)?8?98b_R zH_?eW)-4v0wc4s0*SEzeb!Tqj7?`y3;zE=X`9XPj0l1g%n2Hx|zX?Uyf}*hegxw?u zi-C8Hw@?Qk7HrXkqx=&l{VnfNVOa3>SK`ByBIyFl6Yub!6UkZ9Lb=NGBDIyjJ-S0G zc13Wr8gt?WB9kK&htq9m;}h*zxG!owuufIe={{A&5Pz9PC3CpYhkTxV3qw@ZJ2HEM zlrlVv57O)M`669bjV@}|wlZUUJM!U3JfvdTDSQs9g8R$KY;l~u$SrJkEp19~`xl2+ zIvp)n%_ZZ$V_@nqQ*dPaumDxGWT)~AEQn5OrVzAXTklIKbq<=QU$osFMU;T&m&|Q^ zU|JgY^`%^NFvrGQ!%QK$f|f6d6VXZKDj;XL-QlBda=744^e>9)JJw-0QbccvE9}Cs zCTOH&teSqXUi_?iX=aex$jyClx|GhPcJ!qukE@dR5c*auw7R!|9AMn{&Xm$V1P$md7>^J#dICRvtb)k1)mte^F!p+K(9G4xG|M(}; zG=0?Lr(LV&^bneji{P%qcbUr`FCw8bc&|?j>@*|L35^4!>fz0?^g)+vZVZeJVL#AU zkau+Nojm^Zpu9Zt@hyb^&DkY;ypt<;LD_liFQ?*O0y#kB*RlHYtVvmvosSFq_c0e- z5iau?GbKwmBEfpG^2Mz2Bnj8?3|YmQF_uSKVIk>#XoxWna5>;*Hr8i#GR- z{WBeG&dA0XJhdySt4iW$!~t|rI>@;ju&#VEiyLoXPpNl$N0_dcJ@jt>*IG!*(l*EM z5p>#0Bd=&`0O+Aedt=onbc4lGi4>Xxx43$9mRiH2C$yYzj#g|U;U)A&5GRz_WXhb^#nHC`0nyFQD z=xEjz6ph?^Y!a;wMHqMXQB9(7sltyB(LR(&g_fu9U=h%e1sDA`W+A!@^XylSI4JhZ zXSy#LOFOsrt@Tt7iMhJ9AFi9h%yvNlUi4EjkFIVQZCzaWLX0n8ze4W_pGuJkGr}8kcCdnFp+m`<%JzDp}d!_wg+hRti*e&@5a^VhF>J+&1C?1)zPGP)7C?3<@3 z97-zLMB4qHoZ%_QBROYVd`_K|j!pG7{dLlPG^Ly&bMxkdcDEbRv(;9s%`jTqUtH=s z0kXAHgdm*?t{df0%oo9_`K!i_9j3Q| zp)Xh0owsXUBqRs3fr2-~Nuz!2N=)TBxy5|Xx-pFVDIFqKsX~?S+*S*Y(n8k8MA&#B z3c3&=(}t8>c`0IpLZ4J@LvD(nM&TLHZy0I4eU1<`)BVudP~9x)oW5lfW)~HpylB_p zzo2dBY~Ox0l82k~4z?6sk`yB!*DC_UyX50UPdPI|8M9WMhPuomhq8it^M;IOt7(58I| zLTPjBaUq>1MOi*1y>WdZ^MXbJkK8_v77ZAk!J|V@9SYwhUMg>y_eEw$J zWqA?!Qnp&t@6*QjuhP@irf7dJ)F7W2Bt;L^*cV9z#jD(3%00r?iVWZ9$ ztFY9x##Q$r4&cyd1IN)Cd8 z7Tayzhy=oC-4LtO<*R90Ov5b*+#jkw>ds#h)oe2aj z4+#&M`J&{dk&Iv-;HPUWVGMM{SBeU{6M{zGE%ym zm;TE8XZYbHsiSba+#Gj+BUl{4-gfiyw4sq-$YAm++5tv3DKjvmCG%n0-@+^jnSrp;TiyX~71riiiH{TXbc z#nYR8`KiEC557F5wMk;mGuCYBc8U&9w**qhL+}1vUY`XJSyV38_7p7pI`?fKxNVvO zRBVEPsu_{IjORzkZ!nkH&f4cC=DzN$w%ZIPkXVnc-l{(YNSwW8q3GxR;GHx3j91&Q z1f848n<5wWggm9rDMm>>pq-@u^Z zO$U|j?JO+i>v_7mzcTOBW?uT)Oaa*skM%UkF;;h9*_l0WlI}P6j5Oqa$1Pp7J)=@x zJb85^$66h=3|?_3?ujqgAh&!S^=8>;!;sbIx5Z_WN2_r5i6*bOk*4#}#i7AM-zK{w z*=s(k{kqS24kn)s_PyR-(AeL3b87`0iSlujH%zf_2fh$8oXG7vgn@N?8G-vQPbgG; zarEUga-o=nxnI!S(3OI$rGtW#-&zF?0ssG9=lwdXDqx3hj$=O)l;xu#VE% z0-a#@$T=aEzPMEJs`-1Rcb>$^V>d-UBwQ~WHC+0u&dQgESLeK#KB4W(*pJ_nBnY3I z`7qEMT6^#V_gPpj^zA(+(XbRWEt%V#EtCW2%$I?;APU5M6|aWO*Ue==hIXSQC{2z689Q+#CY$06^Ij#F3KAD%y(LU?VZoG@mt!~*YcikW^17A|=J4pJ0v z#|J9`B?AvbH62&8ebfzgxhV4$bKK96sOcUZXg^B1rndhnzL@vb9sXf5Bnr*wh7Pa$ zPXG!s@dzTfV7fOOIt>S$vi?*mR$s{=S6^=}ytx7ytOzyoBoU#+;Yod38CeP1tek=w zc@6d5{x&;fA6br(U~R|#dT3d%#r72PdTUW=@ZIX%2O;3k#Xw8sR^Z@mW|2xAqcGmi z3tGueQH;PV`xhIc$9zd7V96Ts`Oj_yisbR|jT8SYA?b?gz1t||Xyux|7$~2^^Kya%T!n}&B$^r>aEXg`{}*UAMgtW=pVlqAaQK72t8+(BcrS% z?TM4r#Wwu?hrZDA4`t4!osgUD-;Ov=niRA;l!_#kiM&-kAKnzLLIqo*)9vb!1Y9yt z76YG(uZD{<$H^KeKHL;SyJz6L!s>KE#@^M+VHu_<$a&l;ryjl;|KJCf53h|Q3~GIP9h^bvhcu;L z-62FrPeX{Kc&Dxtj|AYznYQgfItlSpkDlUd7{efVC=s#dI~9ZKQGEvPc24ucOnyHR z0QdBEp}WjWl3kB!9bbmiIH|*C!8etEwr5Pzalsm5nq-p4oM-;3H~ittvv99v{l5NH zp%~6K^%5D8(Ew*LTrDuuvwt)oJ86cDCb>7UsXohP45*nk(@#AmE0@LFAXMKX-*;Ax z1&ZZ2(XT7qAV7JWWoMDK0$a%4C<6za3pwdN%<^=G#eRLFR^*2JJKbEJn=oSUDM3n7 zD(uO&mjNus$ohRxrMtSE=yfD>rDowL|n)*F}J%K^=q>@;?uaRc!+6%`*1~0E9-nuJ2}bSL{_>ko9jj7QY=T5~Jx8IA^U*o#a|HxR8 zrbq}f*;(Hjg!Akh=Ir`oIVp4U!NM>*@+IHA81DRbX9F$ArJNAda5>*zoq*?65pKxE zv4!#6zT~sGjFp{ZX$g#!t&;YnHTWU^U@`{Un_yzDH2Cs4(B8U zqTjkgh(BNdvJ9d};fp__TOuMjOWJ`fcO?+t(c?xF1wKRQ%)Fp?_o?WbSYvKQN=Y!j zBwq`%_y}fVH<`3QayD^-&WGZoG7^A_!yRc1CgS7NM-ozSQR=iS1`F8n|q z@EaC1CYZX0E{AdaF`oAEXFz!6j;xK0^SCE;w{0ghn<93S9O+y;c*@Ry^lYO~XT{wG zu#^#N>O*_p9kpR}Ax!9&$Xd0iPFNrp`F&n@F??yn(DCVW{BU|~Yy9g7W@E{sdEWvu7O$ZK}+1vJ+S;oLw^h3AM z`*%j7ZFuwa`tgc2!J3}OGR~6idPD)eg-wj0AVHKuUp8-5HO>#aLMsk+M~H2m&@i)9 z!-w|2@2rb+X2Bz$Rdw0Y7mCgC)SE@c+kq$=PSZaonxVW9+>EK*MLQFtQlY3qYmL61 z2fvz=f_+{eAH^k4`=3Wtup^1@3Ioj97JE_6W63F$0sK1i1T+%AnDU<5EZFRsLXum#EGAkMA<_Luh;x2*+#lI#x%ZMOuF0t>e27DN>!q z?1r5`tR&H`KdnLgUUinRrg0IVKRvGV@Skh-GPTVyE@kA`8t4iDyK4Jd*2HfY9Nd#O z=JM(t91_Mx^cvyGU$}cR!2ea9B^)tul)bjU*Jk8UE?~>)PW*Mo`DD{S1JEH*uW543 zjAzDo9pO|R#HyH?Ud)+xkBzi6)mg#@VRv*))YFf&f8bzXUM_&9Y?@N1tTZMIKzQ6D zNS%BlJaqDsc@bopxl?;Hv_G+q(BGCVj4<(!@WsCx$X(f>EXY53?X)3GG!2Z<-_uMn z23Fd2-vBG$5i1-$w`xkGnCRHC%p5kiMGF~%hnU_O+chKL3mpgTjvbS8r(Vdc%?A+H z3qS@CGw7u{CH#0KFZ!aAbLmohLc5^fYFruOEyTEtmOy zW}Gi6VFD2*0Ei9ddnvX?hyL(mT}H`5Ct={FoEe>cnr{SjI!|?$U}1uffX@mKM!)Oq zGc0IR4MNhgnYnMY^Re$00&Wh)`-M;uGbR;j^`4XY4dKO+?XX?i`Nms7G>BP`SDh!2 zn2;0W)22VteR$B%9ENllHZf;}PAws~>1{;x6c{~2{r+a4wHYp@F7L%Qk%;N>oR+% z`nEQJK3o-Wg-Z8ajN}>_JuMI4dXvzFu|VLNBj(D(lb<_$e|!@vko__Y|=S=$(1KESGM+ zPty~GsVWe=O)othYSTuIye#4(gdpZCngA9l&3k9_KYr>}Wn?m2Vc*AYKY!3Wc&w)= z*=d~Rtb_O{k5y-G?fd4&`hNTeGxP7ppNzZnr^b#d>Ix!j1r!33 zN4!^_uwD{cg-rqfYWBq!3ok9Qr N002ovPDHLkV1jfbamWAw literal 25527 zcmd3MbyQVd8|R_Bq`L&^?nW-4bX}x7rKB4!g3_f(H%NCkNUD@bcT1Oa&*6Q)Z_TV( zGi%MC!&<;)bN1QKe)9MH_6}E5k;g(OLk9o=>-8&$1^~dJ0suTEDl$0o4QX8g{DtWH z^0gK!_~nOc5eEK^_U@IgD*#~kK7GS|VZ|l~2T9#zb=)+atlT_JT`U1lPfre8M>|(@ z(|48}PA=AIhhk&^Km)vnNNahe?=O1#5xC!=9FOw0?x3}M=HugHTp;i6=vKuIAxq<8 zz-^on;EJDnHg0)uG56trsmT3Z^BHF5)HO?}rSOdA)e(Eb+5Vwer{t%1Ir{|%hxRvv~QwJItI0xyu>2 z2GFILX!_NWJ-F%R)opkQ>?1f=4)SheJ;<+E!?^@~hxfzogmy70#oaS}nb6NovJtwJ zyTW8_VE6z!Kmi{o14)yvE1p)S|a=0|E;yhxbZ) ze8ou+;0#c*-C=^O$W@6_tRy-=mbRU;wgq!(y5?~idAt`zodtYX`9;R1kSktfQ^Udl zCo4(VBah98=!7rp_|!MzU3)mmRV=t1NrMfgZw}9aMTG8XU@lbXJ$aWZAfYC9(|tl5 zwMf2pfPv7~T_R3FBkJJ~{F|702R9OR-^Y>E#cw0Y zh$hc}#!!dUW@WD|DZ^{O2+ zg`6uJ2{zW6O;elq+W_ivTg5qS@SO>sS6y^lQiU%P$gypMl0OOWQNbSIvK5&c$=@Mz zB1Aek-ZKlU;4Kw6dbNvxe{f>Vhnez(rs=$EGCMqyPC? z=r76MpN4yBG3$&xUhh6BKQjag^vU(yO_Gzx>q^+yo$gkw4E{B+%_hCU*ga(9l}VZ; z#389ej;0rs++eC5m|=r9)FG^i=;*d)wB29;Q56+HcKh#OKyoQ_8$e0RaNZORBitJ@t$_QlET=+4VN{!!_r5~_VpP&>~r+U zv;G*FRnZq0RHyES+8TxM+`_I?H1N#;WZF{v91j119Aot0n-~L>x-IkE$B%nn!s-`4 zxUC>FGmRH#%J)?btVQmi&I3l=Te`DeM#x|tM|>~(txtV$VG};~ddTJ?br#Qi>@H{$ zc>zh6o1mk)AtOX1jaXS~L)jcvHQ zicX(>;Ag?*e7w-6uQaNpo?_-JvHxP$ff zZ!Q@js#AzBGR%=bf?*^a_Cr%}sQLFc#j2yr)c#qJqDjDT-gZ8QbIdd~M>99grt&scf$652z)_3*jy&G7rqc2vz z&RIW`sEeJTU$Xjh<6^2_&&?~#a?yw@p^2-9jBgac?CVj_Xjf7rB{V%0!|(<|2*+ ze5RlCVieUw;)y=ZtHK-M!eH8~%pbb0m4YH4#u%+foADsnd9D-q@dB-jN68dfNM^eq zb(sh%^(5J_Pccp2ejIVfTh+x;`jPHS zOyVztub7U(E2)`PaphK{Y5MCqD3xnC+N^`4P zIBJJ8fM?Mo>dV|$W#XaZ=)DPLy6tcVL^Y7^7azXdMvvgKKyDD^tZ@O7H%f!4u6j8V zE>H7%7O1e%mWKUjikdG(Rs8R0B0gSl)}1n7M^z z*D}k{zOnm=WwxMyeKlP?ZtF}wZ-Dd8m;PmjkQ+tj?-J_|u@RB)#k*s+uWpO@?qRtn}EtnU1_M|16CVQ+LdKGIC5C1;Ix83Hgvf3WSXY-}L7hV0fcb7$V}w&+R^ zC=#K1UE|2f7nQHwnGtLe(;t0*y>HNn)#-o63uH^5er!5!P;_1lBOl9_r4QDe!VY#U zxe7!Ap6Wh&cAjZC{CCY==|68eSI=(?OqOQeIQs=wjK%N1%^rh$FWb0s&|MhqoO)lStm|>@}=kW(k^}`!44l zdj&1Kdx;yd`TWIF@;h=0v!kFMYLd=A6A+Qk#@h*}NKP9>f#zvPpCqY1de4~;Mdh{P zm~XW>eAlbSPBAY3P_Y* z>&2>>^3#Jp+Se^l$}HiSB40f$fHm(fvkl2YWLN$;2OoFst((+lxh_R&1{mEK)+6_a zkd8laUqhy@Gg9juBF0>)dkAAnE>k;_p?l;Gp;UQ=-9$GYyWYyw3*4{ITcf7e`$iQz zG1PNmwwmMm1s`gY0)0#IV2N^DsLwS}JtbXnS`5|#UzR!8HLR>O+|w#R1r`?JWf7GFi+8E zdICatHj5mWfjS@D`&TniWD*#q+NQj-P`T!9^T~FM`nHaKH6YL`Xvl{kin3i<)o(AL z-X!|lX^J3^qxoR8`R=xvGc(* z$kk6_oV%8bfLAN>Y`@9`1rDf=UUOc{KB^_W9=Pj#ZPbWz>vCbHI;FDzV=f$_^C)=w z014>ji|X5Mjz!{g;@A1(Pxr2kv|nmM8@g_O7y3uCm~djW`dKsrudRN*%0oyF*sybLzT3I*U*_ z;5%ZuMoxa*`1UI{y!MUZ2ob5~d3&q^hT;edZY@>v$MRCwuKimajcND&`8Je62w-*-2bt(Q!7Lg%I=B*L1y#6tm?-9 z5Rmoa1MWE2McII7*}R9IbCvwuSoN~E&|^X5ug4MAUk86P3kkn~k!yCiy))D7sK3(v zrdukXQi>KBOgBgQ>4Af;dc%q9EP)QOL{lcuHZ()2+grp7^`z>aQ^jmw88kpqz8h0F zSry0JpgajYw^9n$$;u0G@F0JNav}5BS|Qaq=$kQVNs!6ZRanU}aH*&b2Pi};R9)>f zayic~o5W&X-6#G%pxFp2hthbIFJmw61={<)9Cv;EDPExh3GK)j>rj>Fv!A`1SZ7_n z8~Oz=ZJz_5^Ny;_6X}*&bjtDk*6-zK*T4&o=2LV`uN5mkhpk(>>6wqtBzODbRkIFP zR^Q$LFnUoOTHi^Z^gzw77h69yO}^q7HSbpnUinMVvA_?DX5DJ}B|R*TZGFU0VZ~kz zVn3C@BN)8nur|x6-brKY9IV@!Jxq^%aqphIyrwowP2GN4w{9;iY4JwV;(U|2N(0Q* z+ZMC|uWsj#crrNlH8%OlSLLkt?$o)@b~CnkbKJAazr`Dd-f@g?erhpZ#3dyEaN#eK zaWlJ2+A8v06G+GGPmS&x(z&_IWvOhH1kEFpzy!0OV%}&`NxU9{K4p17E{2{X9)VV@ z@aap8?-vOQ#x4X=;V$7SEHo4LB0F4PecjmtUzfW4DFS!jwC!=aC>GV;wz!QMWL7(l z4d&Uo@Zf>t{-)`YJI2nH*n5Af#}cqPKnYy-+}&byK#&wBuI}i;?*9X;6z=u7Yapan z8SIK#zN4mqhmp%IYHX5(b7|>$S#&a>3j-UJGmY#W-gbg)?|LXyi%~iQ&hRBt8wik~ zm>sq9b0?vpqb-t-}^)$5JrGhnUz8>8(MyHilzn5P@sV(hx*?tA=g<4jYs@oaGt_j1FmFJ=9MA;e+&ksMAj0u)X3S*w@$xHGiUA^U-_O^U7m2LQ~S%cR707L$*?+ z@{&RU1BYs+BI;0b4y*gd#GaPIZF?Rqmg50K&(qEhEWh->$o{F9Q@0r-5J;0pZ*k`HNFc=OP$*i!yzOF1GUxt!N^4ULN;^p`+t2Avl48Hqe_$K6U%e9G~VD-Q@ zJ(SamBi87wYn55ImiODXom-+*cvuYNXzwQXG2>0{7JoQYMf{DSe!xPAAax3ys_m@v zu7LR5YEDWl;<Y^5Lb*NE5;RI!!GDJ~Og(e|LS$YAiF;*eEH8+w`4A6CNt(!_cK6hkfWUYCVC(^aQ#Jptm+xLI&gP1_9#Yv~qdGUkBC zT4dh>=lrpq+|wKceX7GulV#=@QJJ?SYiS`S6_nlbgpa-@QZoxnp1@V7oL&e)aH6)B z3#(7fRla$qBe$eBaFOO@pqXj>qF$Zx4me2f_*9Ug_$0P@}s5r)ep7CCFeIqO-u&D7E*@h z1ieg$mS3oa%B7+dDUu`^>_*_ZVvF|@hkou|tmGvn-53_s6>X#sE}R`_cM+hzPs$!D zy^R?9b~tJ6LpX0Pw0^3SrDXuqnN=lS*1kQ*(igyn1SLyyI4{^Hv*k&RNP)bUto79&63N(ENcuqPlm+w?Nr&qrQi|5zBa3(KiVgRPgd2uDVydt?V>gFMEf#X{G;8#Uqro;SjIiE_^axj9 z%1CU9p8H1S0MP|JRoutby(!7wp9nekDvAqz?{}E+GWkX1#p4&V5g$()VaxEVN^S{r zZe$p%sQvE9I%UX8#1y$7?j|1J+jy*)h4T`? zyz)P%yW|)MzYunpRj2yIDjpneEfz0S^0Qg4#GQ;+Qee#)=$sV(ER^Jok3WF8*&ksg zAi>R#p%hD!(UUvH`U0brzpj1G)l7LXbsB1Y#0jfEM08}!l&l_2zKvGF8)38_@lNZa zj~X$hy*8?B47^tR$O84HnYii=4S4ZqVs7i1_4kuGdMbVq$jqsnBpb?4gfJT=_ecb_ zp7>9oqF$T-s!J_=W-YXK?sLSH#U*=n0_ns<^3eVSQ1FXH?%*-1Dk&N2)Fw^f@{UiG z%{syJ)f7|`ZX#$VKXBzZ?OuX}(`Q>(lB=L}RauzqB;xukIko7tJP;%Zsi1n#+JTjg zmWsg$RHg~in5RXGcE8HHD=%_wdEgdu<4%ZvPbLgnD*SwX8P)0Hr*1fLXEwkyVtjx-#K#ky8yIs9CWh1+VSb$#l5CJc_+aJZxFl`|rmT zgs=ozKo7-d(dt_WQuT3E`Cvy@Q3tVLd%Ve|mlZeH#5NnUNIvgJ)m_n>Q&68wjYe4y zduhXp^oq+!+kZ^TB%=s}cZ#2=QErh}{(StdQ>ZYjsh;&`tPbcO#g}}~9;7OTm|RsP ze8vz$Pw{aUenM1()wb?wcuh>OSZ^3KD57|CMw30=v@2-(E9I=wKKv~;%o{pBgo8ds`Jd&st1sA|1V z%sL0dA4!E>EY5y>ev!s**y5%-shBmds9V{nVG@WI<%`!n>JxWB+kJ%=o>#AC&zApa zGs0hMj&J6L$*UaVjbp|G!y){gMGd;$Rtb@cfJ$@OZu7d++>2NvJ7pUh zqww2AKQZ025$>wHuWqk8%%`+2Wj$IK%lCZr0mGDQT_6z02x9k6h~o8-7!i;xoKvxv z_RDu77I4gJGd;Y{-^Pc-@Y?MU(cpXG{%{L z(Gpi<$pB~o`}Ok&#B4@wI&wg!X%`Ic;?Ho}BXQRkfCtW?#8&TD z2frall7n^DIzqTNBKN26m9uR|tNhv7+mZDx$+qDYHlVvmN=ty>?$3u`4uV`K=RgG< zF7^B2KQJ4lpf^lkz~rd!SKkjyWUInJAlOMIANPH+&e)AgO5 zoq(g)YFR=QF+oFj48VsJ>Q}0&f@cB%XGPllh~udd&MyX~3520VI5(+-M=KScxI92u zM7eP@2#!ZZdC#g_$KurTF13s`8WF#a4LfAU#fx4UoAb9u4bv9S{AS89qOC>pbz zwMf#_3DpT-)RDA^2y6Ze7_-U?$80V#o&+ykV!XIfDT&~}glp?26elwWX<7T%D-wC9 z(;sayLUe^QR2W!U_cVb(po*V|=Wp%@4B|{aktFqvW%{-ci@FG*fh zf`Y;yvWy?EW@l$rbIOF8+zqK2`)J#eHw~)3+RxP$XRBsObi$LUK>U3!58GKqMMd|q z)sXbBxl=rWnPhJ>IXStHV=28m`BEg$Sy}DmkCxia!?)kkwNyJTcMR6l)a(uyk3N|^ ze%R{vndP*CUwUTdgLNKnc8R zfvzE(7cXAmaEoYiea+85GyU@e92(WNoDt^5VaR=v(p)*F3;)9f2{h81^cH^Q!CjYT zo@8$#0C!9Y3Kb>KU?%y9Lrqn<*McF139Ag>_Rix)ih~D+%E&=7itPLzYKxT3cJ22F~E)Fie{IIQ(B^?~l03p`bkR6Qj{Ab z)oJPJ%`x!AwI9~=H#RoXFtG?|N=iKX9uL$0))d$dMi1dXM+72rlXoMFSrS=sAzCM* zDSx9~nhl(IPT>J`ymnEqW0TsEHYA{FGf_Vm$cM85R)~Q>ZUOaQxHOcI8D3Wu^`NkR z_!-RX55rdaMI|M+D0O{(eeH-Vbij2)F6Ja(`G~7s2s2U~Hr;+odoTbfkN<_kL{Nh_ zq2X2K5z;6JgF!!h8xJt~!)whXwq90N1^8X(Km?&I>CHD3Y63C} zW&#U}5K^7uU;ri?NkmDXy9%@c$e@Er+Pg6ZYC+p$!ZMKos{l53c83hmVKPUsmIQJ z3C2toLKVCe0WeMJxuCv9{u7Y|&I>_`JmKKkS2FS!8{jsB6=cfUp)~0#h1e#DFnXXx zR$e}I(y*5jdjJs#2D|{wF+TR`l~*~8lp&EfaH`TPa%p+vJ5of z)2B~2Nuv{3RsnM3b<|NK763CS!z3sJDAS9fJ8IArDJ3NrTs*NQB_|QNY>2pME432- zH%i<*u|XEZJ(y(+(pomZ^M8B>0wA-L0|xZ+F;N0sBlf@|90ej*jo+IhiBC^@UXlj7 z(uG|YB61fnR-VI7F)%QUx#&WBR!NXdn0`i_yi*>>CW)t4dWB+&tSuhtTmS}WIfM@# zNWLEZ@#BZ*N0-~zuV04~wu%5ozxWO!)f^=;Uq3xdfwCGoxaq=un#R<+v|9cx7{wE_ z&qF?jg48fzftILCHJV)T!I!Cl{EtOZJ?TUexVP7@f6xOpaC{B3_D3JrejmSo|Naed zhVX8|sunAS*E&XYW}XkOS!WP)R}*Aosr`uY?gp$sfBx)*+V7zSJ%BwbQ(mTm0rtL{^7E4>cFlvH9N&uP z)GR?6+IXDlduU7g{Tzpcs|(u%4b~3`09>2T1*D&YcAIE4jQS}?HQEC-AlU4jcrCrW z4i&&n<+rp=&-T=G3?%4K2(6yO$eOLo_TbSg_q z0-xuiNzE>H^6>BoAeprj(XZ1CWlMPC0&$6n4XI%)*jf*w3=ErpTgoc^-h9m^f32vh z8dz9C%B~=S%3Cx_2|-F4JbwnmfrD(fbf`fmKi2EUj_+uJlEDMzz#SDSsqQkzY6Th? ztl1Kn^%ck$USc@#GdL?^bS0Bo?qDq$1)A`r!a=z(h&jYazsC{LKW#5GvcAN8w#G6v z*_#RAdnUkrX$75P&Zw5BP$c@Q*Xor~ECQ9OQ;us9*o~%YaqwBEkw2tT#D1+4dn343=1+ z2?LfCCda1Vuy4cUq6Y&_PD9x{o5-eHla?Z%YhThbh8?<|>B?SM^48euLGUM$TZ=)( zmz*J6mw#Wpl?awOTHaAWMHxEX{P(mc6ekv81s)e+r4wTD{W%}wQ=EPmLS05jM`r}C zu%u)F+maGFZLv2724eV9sz9iNz`UCBcpGvbAM2BzJ7V+Cwmz#$FvG1pm4Rn;Hz)v@ zKW_m{lrG5G$|nZJ3sREtGE_i}DwZJZ9r*gd)Pj5L74>X;KN$^;-%M5k^>;hW97%f! z>%UV{;QQw@MpN_lXX2zVW@ctH55Eva{p2B|$tN_^^_inv;|C|7yB}0j%6Kxi8>gDbzo{f7ihQB8k{V`iFYd zJ*xf+K%oPqDdYK188jkiN;|D7uR;szpaO{mX2M>9ZlCq$`ugnb%qeWU`7d?ha|Q-O zlADA-FDN0&rA<16Z#4ra@!-7hrR;$DWbcV)qTR*TOG!9CG#UkUf~RC1kJ}wpu<0hE zdQUlPF8~%_C6TaNVTzd7fFsHZ^1I3c+ZQAC>Ek;S2OhWW05-5qMoB5YWiS{%ZfR+m zA!4H8n=0!NCTHi>?1OtK7W=Ep({+` zcU;gBK?Q5sTHs;JUWHssMIl@m;<2rdPs{QTGZwIHGV`h;0d%SH2?-Ot$-={jGu7sx zqGbDYF-sTW%LVp5ZZUxjxb$80lG`~(43SJ3`2X$cYpS*ATgtgoa3%?{JB&6@s zfbaNM?doIF69cs**r7deE7A!Wxov?vzrTtiR4@IDxFiUqXkmjf)RGU=B}STA&!0aB z`N`kXAE{j<5O9}C5r%NUIQX@UvvI@A!&3u@VFN>Vb>Kd^tf=KS&czZQ7k3I(eW@6;6RDP>EaH2xLY%ED9z8ez=Tl%>~KSsev~i$p;}sQyUSpp zbUy{Quj`&ZN_={{-@hoZC$+!<`_Dxpr*UTC-VGP<6>uE+`7;$4ENIehZfC{Vx%v6M z|0aZU+oJlC5{K|@N)T%~*`{7NGBu>d$5$giror7BgV`D{;@~4|?0}{>MaWkPPcZ2c zT633mM5-^%bz)_c!q`C+7>K=j!LOVKzk={o^&rqm#+-!!q3ZdA!iN}8`(LF(Y9pL+ zwU20cczGXjy08ERH$zq34nJ?BFnX?^D#eEgzu>y|ah)XXwGL?_A|wA}E^3i55Y5_! zZ9jrhGGJ|Cac2d=2B8E333?X>jV^Rji{6D~*s3<;MiiV>J3;(;6Fp>7H0-HlFy8`{tzJdhx`5Xp7^9R0Re%|KTg%XMv2(Gc-W{m_4^2{ZS;7^ zpHF#e@w*g&v6c!>GCmu%vTyM(OI%RKnw-shmSd$r6IHpOtiBVXLU{KAz`BvGjNhx;H z`IK+N!i*5W(Esx3)9*!a9W`%a5C$uNbyT};AuYukej9EjZhDWh#5Q$$Td8AXb5k5{ zc zKBA>3x+Ge*B;Nht$}8zYrU*-?OPgkR)34Zc%*@5)yl*IueVN6@?_FgOpq~qDI}u(X z{p`0bD)9;Z7XQ9P1I6@abRp$w^E{?X*Avbh02&a0d@LADKN(hFYdd7%Ouym{wsyAW zZUrWw&uGvLb+aV-5)%+8A-scwtpguzQnlZ{%O{Hr4<|)2!H4|;RUBVZA^>(>!2|2@ zhM2Wg-J46;q4a27Iv}L227O-;>^ItO4_lD{3LpY7Kv^L*$43fv!(}wP7;F#4B>kP4 z!g?4k%quSLAD$Dm`)a%n3t;^6cqIshRtoZ{a)CgV&ZS)OtxPMhp<~*iQR*F{gw$FMXQI4gfWgbEQ=}FUl4dZgEjV5x03LwN z)`=GjwvpcZ^gb@UTI_lcAqs@>ZM5ut4sEazB99&+cy-i%x#Vm(9SXcdXo5l~<-su~ z9_((&keAcQQ?W$i<%G=Ik5z!hs}Xb$Oj1%(Nw_QD>sZc${Coooi%hBbr>Cc+g)&t7 z92+@-Uu0cVMMXvGV3$?U+uKVF_`%UAOrO`8_I%{$645jS(KVgb-cvkgAs(Xuj^Ntt z22IELV=3={?6~cEwE7Fd5P+7%goL5;UH(-S5mbC`O_ZFH-LH@{Pf*FPH`$vu*^Sz+ zXz-#Dv+?6)pp)^)@+J7pxPQzdYDqj7-QUmywg4UA_X&hX5n%lM+;s7mgJ}K?^kZjW z_?iT29mEeqeFW@fVFcW2ZT`xU83nGWq|z()IG|ATs1Yr4u&OD} zc;aG*np5!NNY4t6g!M%dQ*(=a#?vm>+pM0omjO25w|j=bitKZqCCMl*V2SaS2Agiwd2BPV2syV@v;w`rRcctKpopdr*l3bM z_{f1Un?G^1G9siD#d0iiM=#Msm_r>L+@7ymy<_BBP`IF}p;7Xt4X& zd;9in2-ugFuH8_7=lIARvdQtWXZ0OG5?hNxTum+3K>4nu?I)7XF7OYR6QOxqBJRNsjkd5GkZ$g#o$Yqil7IHTz z;yxt-gaJK}GWnoV$Xg~^=t9(E&}~&z57slnt~{yPPziQ+_JNgG1=+TG5&!z_C$FPH|NHWPUJznf0)ucf;WptVCCX7FZXBSfk*k!D|MuyFg)xJIERjFB zVYhJm@V}_KC>|rpWkgV2x$A<}pywiVK4f4MSq=7+?s6Md=*r_R;Wk15C+s;01<5jY zl%8H^N*Pj8G7QHCZ-NjPpP4BkIzd|YebWU>l_;SMoy5SzazlQSuL%1+Fof_6xyvzO zIZXpm?!S#X-#?9dyve(K&3vikNZNH8kZD2$Jb`1bQ(SOCnP&edl1&7L2gCr|M5LtG zSu94+ctG87&{Drn0g+i&%A;<_qBY24bmB}l?BOeqU+|ow0!k1Z5YvO_m55-v5%{)G zGtx2}`M-7I!nc-mYT!gk+FbAkNs4tz$=o_cwv4W>lKdi&YG zSuGWkc$Adw5((Y}vb0vnY&tY$Gt!9VQV=;g4@xmFt`_$_4^~(?P>>{f{=UH07}3YK zyYat`f7o_)gTuU|{Z!d3J-u8Mjp6is2v6h}&P#!NEG;PX{+Cs)VI`A-taO-_6_OMh zfCG578{zOSSJ=)qy3bA>ul6*8t$)So*CeBeSjmTEVm2MAmWm_0*_uu7%8r}2uPFlw z)iFv+0&E-Sotn;mD{_16>rjaM5UdZxPP-e%VopUFf<2ucU_5i+fdg>WVo4F;?F<=c zJ&^3RJcNUs10ualQE~vW%=CFUBq1d=_3a*rtv3sE0myKc(Qxpqe@$=zQ28Ett4RP zPa}{`X*TPNSOXu%`SN(`CHDQtkJa4VT#@D1K2KT9u~D?acD$gFYHzBXyg(`C*(CUq zK`_noLZR&y74vvdMFoZ39go)O2Ai?9pkmEJIc)+IAS@!{rJI{uamC)j`;?rzy0dC_ z{RZ$-yU?lY<>VXVFJPJgS0~l|HiU~_z=;cFGyODfbeR8R)b4X$JdjN4x;4U1mzxCg z5DgRV_BNS~p+zzg$G16!kb;^4`kePBQXg zP8Dz#RpJ%fa%9HuLpcyYFQOrPL_szJYQONIs}w8(nSfscNZm!hL!59O>o%?}M|^*tj;K=zpvEFHvI%iPv=0X=8|qzZEcr#fCw8wn^Na=HLxe z`GO7O=a}9W^scLSEVkFrj+i%N#SjqoQgE`wFuNql$;~-rHiZ@a#*QY-YASOWkZDhC z{K+d~m#vd4b=PE#A~BO5{$;*#Me5-POHypr?7P=#*iF@nI19ha1@-zFLTJ8n<;ftu z$g?VckT_{IocFPp^r*{|pDw*i3y%#OI$EqbKSB_7A0+i&wd|CiJ!q-4jh~==25T2S^mc#e{5);%Mtwbuj!!c)h=&aBhkvBC zoEhB5k~==`7<_m+?ap%HmCGurz2PiB$sd@VTq8xn%F;V-><=3aSk~Hd_jaQ^a?p$A zP);>n_&SY9lfl*MIrZSTG;ySj`ID8s*wxOvCO====c0w2s^e_VJ%db;857c>#$y;X z6R^*880bXnks)N|MqnyE8gRbK5Gi=)gU|bUOt(9z7`?Xwe}Iec-hqOxYKWT#PwwHU z6o*r{?jb1+^i~xmO&at?$0)syCcQq;irdQt;v7!Xqz=!>XgGM#WgP`$2no$j$AWd* zJ(JvM*7#1RA0{lvZj7BuMrye6@IHWsTbOIN9lTdIGsDQ+7jSYkmEK?Net*R-kJC8o zE!OC7ixsOM!a7b6_G8&U92|YqypSP&Yqs`9Fxw&cV?ffp+Xf9$nSS)8K)HO*JY`XG zcgRJC7@qj%JfSDo-H~=L1VX@g*|D-$SlPbwCeWG@eSx82)~@5|`k^1|-ZM*RO5!+N zW`F?6O!3f^O{WpOiYh=Sgt8Q2x^-DOCE_b!7ry1xL>bt!FVc2AycI_|a&zC*WLEdD|j64+`t|KTAqykPgLn!rK4&&e0}ITd>dB*TbvXlK|saNBTF zDD;-IY5#PNAQT-dH&?vgzkIAapzXx7wE=p4AW`r*srJg_8D!YM)7RHCa8xiXLil8h z75VD%V@*}n5lz4>c(i#sY_`r-<=Tk*6;Hc%&&yFXaMXA)=hP<<<@sKTvy##d;eqJA z-gPyR^9j_L6PM1^m?mKKJn+xjngC_KFK+!O*A2D1l%DiaH`~^jht8bcBL5ynS&Yv| zw?fJDMUhp@eL~tBfo^ZjBDHB1=*p}-m#Sw<5}Qk8wt{oMKdb2OkfJEhmEw!3_$yAd zmX5u#c`aU#22hfx9^HgXq2Izg*ED@UIz@{&bzM#4N?Dhc4UiPW6VNYSv^ot8SX=|= z_d?YHba9>l=?N7Bt^8%2SovvsvHkLClqT?>d@(1T7|yv7*fOZbdIN$?$CqNfo)|Qc@%*&wc|%ZXr&mSe5S0AN24+8%`@-b8HfEwWd-eeK81#Ld8_|tK0_a0 z=F1BT@J&q(=EO54B&hsH)6_2$3 zBbP0oavNuZBepu7DMQbV;U`&0fU2d+!$s+Q4AI8)VDJ}EcwpsQWJQrn%ImDoP~*#^ z;**y4AusF2QRs*uZP`w)z(LHV?by$w{efoL56U0e{XLIQ*4~pD|4s9L7Z@x`JWAQN zu2aD*f0mLfWiYuahPgd4dBpQVA%a_MowBX=(Aw*)q_Uj(#H-3}U1Xu-9Z@=~Jb|ra%PzjBF^!~`@fRkSS$cN?y7Mue z#tQ}a4>8fH8v`@+ZN8@}y@wLcYysy>iw6Q!OUtIW)eA9DWm9Y90%lwXa=u>H#^aRiHiQX0*Oq$cvaqpCnAFq5!>iKd`{^T^u@+Fdu z^1($m2Ghl?hudSs2%?^!b&Gp#9S`UBx`qvX+gsh%TP`A}A6-nRau60H#1E5qJ371# z?&2}@8|UvHW))t0E@8@EF;~g5j}RUBxqpS!9pC1gr$qoNw(n2UH8C%Z{h@7hvb#%Ls;_?qFDuepL z>$xAGWfzXAhxE-j2yh&+V3TApBy6y1{r?Vx{*Qod7EZ**4ON}rY=bm|R&C$tD`K`^ zp6maFjQ__^c&{wnU{Z=IDgn`9{cy1B*GYOwCEt2KCBG(Z!Aw?Pu} z`a3tODWWj)e`ZmF0~{Z@-V>mHjx)qGMV#&9B4x&kF6%XUxBax=QCp~$xGBM@P2|Ao9>SfT<iOi9@V^eVlKe6KA*c`{INOK&QtQXtj8a035C&Fo+82(g8iGM0 z)KaJm-V!Lkx6a?_XRFFF(#fF=%S_-9U*Ug-K)&+d*Mde1Df%=i@`jZNRZ;tC8u$#T zseiZpzsrz1{vWaa?=onM58K`tD?leZ;cfIE*3p0-xH_{xmvHtaIx%61HmMC$u(0m; zAnznTBnAM<&9JZvjh|RF=7s}&_qy)7i`*J8ntaNkzLxG()yQLsIV|ZOG z3b)YgeQHG^=5;J(YrC_I6~V;zeeb*iWZI7|b|(j?K%VBOsv+V^F4(xyfoctWje;x<6Ubb@mJ21A;sq}!+Nz45YsFXWSqGe5&01%%Q;E23 z+sRp%v9eMGo5T*B7H$qEov!wTs`{Fpto2bDP6s-JbSknXK9RPL&Q&AWeG)%jt;ZIM zKlI>8nt)8jQ&ZeYO2BSXrbYu^UAV|wbTq!Wn0HFj5^Jy?rSiMpOk!eTA+xg(Ff|-Z z;v8jVXZQabEx5$Q!OVQS{UtGRt-Nsw#P(wNiR}6w6DwivR^i|kECvRK*vG$voZm-A zkO7b*Aa`tCd;l-9_4@?ppeY}TR69ctp~wDxjs>&X-2IHVgmltWcPh96Us_fBCZ8$jWj1+$Re>_<7_J`CLSO2yz9L?uMP-j-Rmb&d?Jy@vEcm^Q!u?f zg2&kBd?LaBYcs49VLKqLw0P_TUX3|yso-PyKo0i`V8R}9Hi>|9h6|2m&Gtk7o3fLi z>*cwf*_8g`CYp&$q?3z!>t|ywdBdha%fS3g>)vNV=q*$TOwKehzrTHjC{)7}__}i- z&!UGZo)+~sIGH_;$w!SClY5)6Y@GMUh-v5HPk6+qBA zpe^%baXzc)`buOa=syTvX;x_WNAr2}d+Z~c!#5l4`E%|Z9k`?M*Rysg2k&Fb@a0<^ zov|sl(JT(7Fb3hdE$)Ix=<8x=DRUZw>l3Ybb0A#Ea|Zwo`20 zVFY>rKzD=Vlmh%Y5xtX##_u~59i;~65kcTwxzZlre9MJUa3vU5Lgz>$^NAZD+b9g!c+_vl0q5aU{-^Z0NSz!DJ8n3y;w+vqT&?*5%MyMrD0 zBkOz(f#QwMb0!8e6v~FKf7!85({CK)@EVoHkUvOgmMb!WbB8eK{{VC~B10`Oz#jq1 za})cdt~NT1sJWLn&g|r6+nvz)TCh2;Ln5NXU@Xs5_I>V5bcVc>4QYXxyzZSiC`Atw zVLX7~@8bmrGt(zv{rM-(AM=CJVMNuYY@Xc{CI8>vdB;aprG5POoI53xLcoMj1EE7g zC=x`hU;)9dz{0**kP?su7f8hNy2h1NmxQ1w3j?yKE4YLeSjF1<(X5S!ec4OA%7eGgMjp=%Lqby6)=JuU@N^2#4u4m!VmCfafnx~kRJ4qo= zT)sLD2`T{BteVq%`WCm0=js9Gr<=PFXb};QboE%8Kq!VnuXf08{@$1|+N~A+T!xt` zIb+$q5{ajnl|ZfaxCEm8LN70y-D6s=ONVh)$7yM4(uwHvF9P&3fB=UNct3lY#nXwg zll>{~G$eW7@=lPHnZ$_FT8qmORIM=55@RN>b5)@zml%lTeM=&QB>us`Fjw^N!Bm3j zg@o8C?`SQgDY77A@lcR1o)s9i*5X!(G6EHA>a%^GEmXa0wL4spVIIoBDgbR9HuhcC zNi~Lvv-(dc(ppGUAoXObNiGSVMhf4qwHB>l;u2xiy*;Pjq`hkPtD1peW=hFe_9_ux zfsduKYaoXN(POh?reta@#Fu$1)7(Jtg14uBEVnXt_}z&R~TawW8=TjT8l4{n!X}V5ptr|?$MqK3EE{r-h#$DS2fB& zEZn=YxuUQ7j6qFy?N=`dG8j`6ddI)7wG>|g135-LsK{s!1&xw|X zcG2Lyf~4K%K-p0JrQbNzg9|V6vgFLA6SS7%?WFEsb|(bQ^c#nIK_#_jKK{ZxCuf8TAq?+ene{_-b!lviZ~XS;VWK|?tnmD3(9I=m+2{P!!Z3lcVPX36URq1>7T(Gue?73me>~@al}A?w=ors?V%`RCgNPnV-M{QkXP&9I zfta7Qv>Sn!IWNnP4Z2BItN-uQ0R&5l8&wn5DKLM zeuos58S6jUuuV$((t9EE5Uq9i62u5ltdy9PnN%p0QxmbnV&ilOz)*; zE{oAxho|vYrnv)zn54B1pG2XK!Ak3A>eJMrfk@6;c8}kM+I27xC^LVM#7X}6m0FmY zbd2eKFG*KI!&9@(v5s2VeJPVN(+s5WyXuMZQx^op1!cCKsI?AHVPTeeI9T({=01-i z^HfLv5q1qkh*Fpcpr@*;{L~47-&(Zi?=7{~p*4R!up&eO{a$MwO#-I2mLPu6UK5)J z;;l?`2PV9zzA8U9xS_^qeN$@@S|iCdPy3@Y-3KW&Ci%#c2wV4U8VH@j$#73aD?ha` z^MnPNOT0Goa$`x&T-=p`p<3%`O0WVWs-*F@?$>D`QudpBf@qZMWcjgyP*}XIwFs?X zML*gfXW@Q``PO;A)a@Hl_Y6Y9OE&NMaVZ2mmYlVux7Iq`&4TpBkqVT7TI*<%FhrnU zJX+VS>x@A1zU7_380UIber!NsF;{C5?nV*%(FFhOK`Ug&kh=CFuAe0cWi9}nJ4E@Z z8)A%|pKk7=wGMZ&Fw5MAm~Yiuhr6k+tU~d*;;a6vs&re?tg9e~gsSUj5my<4MTZwP ztEs{hE=jQO#~E~jFaSe zHTb!V0`8Nt+Z=xJ^QAoqor3WoZH_kA&HDsrkDs>NMv?@KK>^SQ7@#xgU(`%;;2ZNshwTCa zP0;BCjLl5Y>-8|0^auzILRh>TcXi__+Y0%v>oGALh8uf2)+#G0Pu+MH(`htPD5^Gg8wto06QXYB@hj$*- zT28}Z(uK5;djYiuLSf(imD>BoF8uAOWLVCXXf36o^i4Tbz&I zwqoy>yYQ{~OB9?uYqy`w&HM1nmp`HZ{WrNnrpqAW9$ITq8zO4UTAXjzljbd(qwlZ* zHt**jIfbnse1o4q+XZ|7AJ!@>(q8!xGuONfam^1?=eTZoV9w)LUF&qCXV^_*NV(aC z5jvK*2RUDn%VW?PbQt)^Er`A6Mx_1eL+t-%kKOiPyuBWMh7W+G^Byl|X7V`$0b;MN zb>Cjeq-)ZaB%krd8J#v-QWUh-T8A1$5ABQb^Cvr8 zwtoNNf83-Oq_IlB%*Q4}{R zQU{roD6KW9g+VX*T=wpvlZQK;MI8RI@t&LRgqR`(1EE?AQJa{d_KSd3mR922@iT5x zqY|?3WO|mA8lOuka6{`bwCmNuZkrPa8Xr#JPMBa41o&Ll9ryIMX_uc)V4{%p?IPw_-AruPGT7xSHZQb1N z{W3f6oGi;Yb>JjU?)w$_NAgfs`_5ICRv;kIh-R&BK>L^u=zVuTwCdDGZRvGGY|?>! zXX<(-fuTWm4MbT{-S-we_B!$p*>{4;*?kDx)@+3-BoM>@@E~p-HxwjXdsNKiJKqy2 zY=VpbzO*2)nf=MQt#@wc`X8|VFQ33#Wwrfq`Z1(`y9ZyrvjM&C?2SiX8iyO&y4VAs z*d*YiDduV_tL@%5*!bly%0FN4F)A$O*zo2WeDcytcWiq4jDdhoXcFS>YOECvd*{ju zEcT7VHDqtiz}vrj6M5Muozmwl#3DckLJipWvRIcF+jqYXY;L<~SZJ#j=svg?y4=(Q zVeP}wtYs*IT7;myxC}-4=W%FT7JgX08I`4#0DwIkccOiChpSw3)rY({B=s7I6f48` zW)Ni#T>gVv*na191|6dMbwRhmz0kHtB$~Hrj*zfW2#HXXUx?q1cFr7@TpCsf>Y;EU19&b(BkKVL+6$GS@u1_TG- zhIV1-9M=s4AH4;OpGrZ=nIZtd=J&tGZR789xH;Njm~Ag$y$u!>@U@LE0KXl!zcdYM zQTIDH;Gw~|ZTwv@1k^pT3TzgPwml-z@BW+c`Cr%K`;RvPxbU9;{N*kTder{0enVmA z3IV`(Uc6dzcG-``CnAKkuY3Jw%Q#$1SY%6#nDOwX&!y)r$UopXfTt1o%=aWKT&cAN zwNaRnv+I!EHens=e&<3Xn%guES0X@oAmMR@MqJ(uJ>){)3MQ%$0DLOnAv0t9nyq%* zgm;WUP>aT&?smE9YA4jW(-!1IGYFlKD;&4-F4F(A2c?DfzoMvqbq5?egCt4l z)c^8vs>OxxobbgN#J;b0JIrqxeWyCn?c$o9cw3uLYQREx*&(_e6r)cW2y0a}zFGXa z-8MR-4&4UFs4||o!bx2p;JOoIg{VR>;9U;lcF4^>iM6l(!)_ZQ!oWw=sq%JoZ_cGZ zOFGG+CtYI@ief*O-HVW2qY? zs7Ci`doUF11oI*Gwgf75u=}$eSU+#I!~BymclAT-ZfzSqUdh>FeD%(TOP}K&9t=}6 z*PR&a0;=sVaJ`7aa>@<%frHNCGeuZEcLj2G+y81vA`G1{LX|PUUAhs4rwRZ729pst zKkk|hN&tXBu8xVkHoNBe+;cd*JsUrM@?Yc~bi6^R4=~`tgvTAWyNFz4MfDYD+ZC2_ z6y+BpXZIoeuxj%qN38bI9r0k|DChqN>QCOxZZmymiIEf+U!Ib&vdGpd z%s7spKi`f$U+k)v|H5s*zYDFp)ZhEDs;mNva^aLmw#q0uQ-m|ePGi@nKj2Vps(z9P zV_ts}&0SavMv^I4M?xHA507kI|HeNN5OU!pR;iIuUQ!Or*pP zGdBH#eU6ciHIX)t07-EtN(#F3o`pePD9E&mENRW*3%`O#{~z79!-ea8-OL=jmKp1rhf2qY1P zJ~Ir%rasi*nZvj@pSkXurJxwQ)^Ee6WnZDXq6+y(PGQN@3-HIZ7ZDnv{yv1llmXP% z#b98I>LX}KM5opm{n`^qSpTO68wXnmA<#Ll8{*&pBLeZQ(YRxM{{ z#ZJj^MaNo_&}Y&yBJ!DySDn#-n;yFb&wuu3jGXzHs>=!h2nh?t-IGS(4<9DNSbGFy zDJaHu^K6Fhq1}jgEafq5G}TXzzwh!9;|I=Z-hr8iHa0wgS<&(NEa-^VI=qdNYy5u#o2mQWk)ZD`{`r!Dutfj11%PW))c6>R zqTt|81?5ya?Sr{%<$l9sgHY&Rq_M9X9O&RrTnN`0tgUuR5%q6+1aQC4K1*5Z$cms9u6>mGRU1 zZTM;ZHkd*J(Y9xMgm;KQKyUzP>VY`FG%Q=`ICj+~zkA?GLip}cK-w8z-LJdL(J zBUKxlf$Y-gyp5`L=;0(pPuZ24vFro`u1l!l1u!#C=j7vbj{V#cI)e^(Pa1){r{1sX z_(IWrdhNCA90-`17Gy2k2ZnkFt6oCEsdG5+eFhHi$i|VK2T@sCcj65NS@8M zVeP}wcf%o+LdiuMd$J>k{z)fYN zJOiCUhhDeGUUGD*u$1H6i8ClYZ$WiMH4G*rOd%#jbZ+IaFi&F;60NPvW4BHh#I&?D z=|ps47J%+`-S^`%n>(GMv>E>Yqn5MOkCI=yRPRx`Ubs2~{e+I{l86y+n ze_gN4&oyKoUD~Og`Jtx{eHh}{J0RW<;^bG($lXBfNp%)!}QB8?iohB5?LNsZ&e^)~ia?2=houhUR z4aDqQW>*LWqd@SfYDk^~Z=4e|d5P94JdMO&lfHt$POWwLB!n7al~a>qk{kvf)$1^S zcAxlkFeNlznV%~m;DjLEQ@)rns<99em%{oU2xqj`;%g|3_v@QRpx)j-3B9NMjesWS zJmo@a6otpmj+t~`YayOWQq1&Hz2FoOJFZUSr(O{F$LC|?zo~C~r`!Q+D)B7*`w^#dg@^!;rn?p+yNn%nwKwj<`Jh1#0zop#l+To;7=^mCY>;fYp|m{3aKa z)L0T?<3I4V@(k{R2<4JadHrG{T4!ib#8G1Ov5Y6aQsauW82`^ms$ZofAEIhXNi8zo%tAq5raMdN?U&jAs`@^@xl)8KK~OaW%}B9eq!&bpF{9)5Q-hQZn_ZY-=d^+WMbbZ z%e2T9qEzWHY`LRuWTzpN*r@0FN{y(j?cp?lGvQ>JeF*ApYs8*6HdEfF*Bq?9RpF`pz z%#1%L#>OXUEyP!0W}2VA^!E~pQCe&97znurqyDv--JUt?xN&{DQ;|i77d5M{GMWL5 zRkL3&q=X2QYD4DzxR}(;B{wm|3|8Q}luKR!6Yqc=YB(%80009JNkl0C&gRq)jJ1C$Q7id zH1rtLVgIh^^dhO-QcZG5&|D(S1rXrMu{37C0_EA~dr$KxI`IG&rZ4X$3ppMNb<|pm zt6+jmz}KZ`4Id{Bo!~N81GiY&)Xew0$}GQ2z)sI{8po^zYOP02n2>Ii#j=@wC%gRQ(5=(30y6Va<+3~=}OjiefcH6&=> zoL*D>QLSD?@{uJGR4E<;FboX6)_R_k7)>Df@pzTcjkqOq-(_^NWI)22Dw_s+ozVrYMRIKt)K~jHambBIc03@h@ znRl=(HYN6*d{lEiS_`<6Bu)}RS?z`pE8_s%10c%H5~`T6gMiN^!s=Pk@%F2z`tb=; zGvB{Kk>xu`5^>CO4=|}$roBE8&H%hmDD?fT*eMyFl!DNs48*l8-2b=ERuV%6(Opcq z6O48$4^a(ZFEMXcB(b@yoVO*##rs3pn=muJTx5*uVCcy#`mo?wV(z52!Kj@jWU?Zs zSCc;d<=Dxm)$CtQJla59%fh`Yo3o*^J2UlUq8`lX0YW$d!C_Av(k;F3iC|gRP8K!oh|!LSS}F<$ z6NHfn0U$Oh;2;62W=175DoLP}fl5gc#fsoO;+j02kWN+C1m<~DRPX--M2j^FAi*V$ P00000NkvXXu0mjfkyMuH diff --git a/docs/source/howto/include/images/caching.svg b/docs/source/howto/include/images/caching.svg index a1b5acf03b..011145622b 100644 --- a/docs/source/howto/include/images/caching.svg +++ b/docs/source/howto/include/images/caching.svg @@ -7,782 +7,783 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.1" - id="svg4700" - viewBox="0 0 247.80323 507.58128" - height="143.25072mm" - width="69.935577mm" - inkscape:version="0.92.4 (5da689c313, 2019-01-14)" - sodipodi:docname="caching.svg" - inkscape:export-filename="caching.png" + inkscape:export-ydpi="96" inkscape:export-xdpi="96" - inkscape:export-ydpi="96"> + inkscape:export-filename="caching.png" + sodipodi:docname="caching.svg" + inkscape:version="1.0 (4035a4f, 2020-05-01)" + width="69.935577mm" + height="143.25072mm" + viewBox="0 0 247.80323 507.58128" + id="svg4700" + version="1.1"> + fit-margin-left="0" + fit-margin-top="0" + inkscape:current-layer="layer2" + inkscape:window-maximized="0" + inkscape:window-y="363" + inkscape:window-x="2042" + inkscape:cy="275.12116" + inkscape:cx="258.29552" + inkscape:zoom="0.64572445" + showgrid="false" + id="namedview162" + inkscape:window-height="785" + inkscape:window-width="1436" + inkscape:pageshadow="2" + inkscape:pageopacity="0" + guidetolerance="10" + gridtolerance="10" + objecttolerance="10" + borderopacity="1" + bordercolor="#666666" + pagecolor="#ffffff" /> - - - + style="fill:#ffffff;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt" + transform="matrix(-0.4,0,0,-0.4,1.8,0)" /> + id="path8345" /> + refX="0" + id="marker5157" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker10321" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker10257" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5624" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-9" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-5" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker4530-3" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0" + style="overflow:visible"> + + + + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-6-4" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4-2" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-4" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-5" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-8" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-7" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-61" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-4" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-5" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-76" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7-6" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7-3" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7-8" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7-9" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4-0" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4-5" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4-8" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4-1" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker14860-6-4-81" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7-6-4" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker11499-4-0-8-2-9-7-6-47" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-5" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-5-6" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-5-1" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-9" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-5-62" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-9-8" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-5-62-2" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-5-62-3" + style="overflow:visible"> + d="M 5.77,0 -2.88,5 V -5 Z" + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" /> + refX="0" + id="marker5157-4-0-2-3-2" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" + inkscape:connector-curvature="0" /> + refX="0" + id="marker5157-4-0-2-3-92" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" + inkscape:connector-curvature="0" /> + refX="0" + id="marker5157-4-0-2-3-5-62-2-3" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" + inkscape:connector-curvature="0" /> + refX="0" + id="marker5157-4-0-2-3-5-62-2-31" + style="overflow:visible"> + style="fill:#ffffff;fill-rule:evenodd;stroke:#555555;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,-1.8,0)" + inkscape:connector-curvature="0" /> image/svg+xml - + + transform="translate(-150.7275,-31.76242)"> + transform="translate(187.14431,727.24035)"> + style="display:inline" /> + style="display:inline;fill:none;fill-rule:evenodd;stroke:#555555;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#marker5157-4-0-2-3)" + d="m 201.28329,137.263 -0.59122,107.40039" + id="path8629-6-28-0-1-2-0-3-3-8-10-7" /> + transform="translate(0,2.9037231)" + id="g1826"> + width="100" + height="100" + x="150.72751" + y="249.64442" /> C + height="160" + x="380" + y="269.50507" />C + + id="g1757"> + height="100" + width="100" + id="rect1745" + style="display:inline;opacity:0.75;fill:#de707f;fill-opacity:1;stroke:none;stroke-width:1.41695988;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> C' C' + CREATE - - - - D2 - - + height="54.285713" + x="763.57141" + y="281.42496" />CREATE + transform="translate(143.33879,238.57849)" + id="g5405"> + id="g5222" + transform="translate(-341.77464,94.52841)"> + id="g6556" + transform="translate(313.0534,-594.45179)"> + cx="86.109955" + id="path7355-6-6-92" + style="display:inline;fill:#8cd499;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> D3 + height="160" + width="140" + id="rect5320-6-5" />D2 + + transform="translate(2.5085837,66.620272)" + id="g5331"> + style="display:inline"> + cx="86.109955" + id="path7355-6-6-92-7" + style="display:inline;fill:#8cd499;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> D1 + height="160" + width="140" + id="rect5320-6-5-36" />D1 + + transform="translate(332.47541,-301.6828)" + id="g6472"> + id="g6459" + transform="translate(0,-50)"> + transform="translate(586.3525,-579.82948)"> + cx="65.977531" + id="path7355-6-6-92-6" + style="display:inline;fill:#8cd499;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> D4 + height="160" + width="140" + id="rect5320-6-5-3" />D4 + RESULT + height="46.904217" + width="146.88426" + id="rect11760-2-6-5-6" />RESULT + + id="g6446" + transform="translate(6.19864,-50)"> + transform="translate(580.15386,-469.10115)"> + cx="65.977531" + id="path7355-6-6-92-6-2" + style="display:inline;fill:#8cd499;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> D5 + height="160" + width="140" + id="rect5320-6-5-3-9" />D5 + RETRIEVED + height="46.904217" + width="146.88426" + id="rect11760-2-6-5-6-2" />RETRIEVED + CREATE + height="54.285713" + x="763.57141" + y="281.42496" />CREATE - + id="path1830" /> + x="367.76187" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12.5px;line-height:1.25;font-family:Arial;-inkscape-font-specification:Arial;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.9375" + xml:space="preserve" /> + id="g1890"> + transform="translate(197.39203,238.57849)" + id="g1850"> + style="opacity:1"> + id="g1846" + transform="translate(313.0534,-594.45179)"> + cx="86.109955" + id="circle1834" + style="display:inline;fill:#8cd499;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> D3 + height="160" + width="140" + id="rect1836" />D2 + ' - + height="160" + x="380" + y="269.50507" />' + + + style="display:inline;fill:none;fill-rule:evenodd;stroke:#555555;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#marker5157-4-0-2-3)" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cc" /> diff --git a/docs/source/howto/run_codes.rst b/docs/source/howto/run_codes.rst index 716ed8b192..784a62ffbd 100644 --- a/docs/source/howto/run_codes.rst +++ b/docs/source/howto/run_codes.rst @@ -399,35 +399,38 @@ See :ref:`topics:processes:usage:launching` and :ref:`topics:processes:usage:mon .. _how-to:run-codes:caching: -How to save computational resources using caching -================================================= +How to save compute time with caching +===================================== -There are numerous reasons why you might need to re-run calculations you have already run before. - - If you run a great number of complex workflows in high-throughput, that each may repeat the same calculation, or you have to restart an entire workflow that failed somewhere half-way through. +Over the course of a project, you may end up re-running the same calculations multiple times - be it because two workflows include the same calculation or because one needs to restart a workflow that failed due to some infrastructure problem. Since AiiDA stores the full provenance of each calculation, it can detect whether a calculation has been run before and, instead of running it again, simply reuse its outputs, thereby saving valuable computational resources. This is what we mean by **caching** in AiiDA. -.. versionchanged:: 1.6.0 +With caching enabled, AiiDA searches the database for a calculation of the same :ref:`hash`. +If found, AiiDA creates a copy of the calculation node and its results, thus ensuring that the resulting provenance graph is independent of whether caching is enabled or not (see :numref:`fig_caching`). - Caching configuration has moved from ``cache_config.yml`` to ``config.json`` (this will be migrated automatically). - To manipulate the caching configuration it is now advised to use the ``verdi config`` CLI, rather than directly changing the file. +.. _fig_caching: +.. figure:: include/images/caching.png + :align: center + :height: 350px + + When reusing the results of a calculation **C** for a new calculation **C'**, AiiDA simply makes a copy of the result nodes and links them up as usual. + This diagram depicts the same input node **D1** being used for both calculations, but an input node **D1'** with the same *hash* as **D1** would trigger the cache as well. + +Caching happens on the *calculation* level (no caching at the workflow level, see :ref:`topics:provenance:caching:limitations`). +By default, both successful and failed calculations enter the cache (more details in :ref:`topics:provenance:caching:control-caching`). .. _how-to:run-codes:caching:enable: How to enable caching --------------------- -.. important:: Caching is **not** enabled by default. +.. important:: Caching is **not** enabled by default, see :ref:`the faq `. -Caching is designed to work in an unobtrusive way and simply save time and valuable computational resources. -However, this design is a double-egded sword, in that a user that might not be aware of this functionality, can be caught off guard by the results of their calculations. +Caching is controlled on a per-profile level via the :ref:`verdi config cli `. -The caching mechanism comes with some limitations and caveats that are important to understand. -Refer to the :ref:`topics:provenance:caching:limitations` section for more details. - -You can view and alter your current caching configuration through the command-line: +View your current caching configuration: .. code-block:: console @@ -438,16 +441,16 @@ You can view and alter your current caching configuration through the command-li caching.disabled_for default caching.enabled_for default -.. seealso:: :ref:`how-to:installation:configure:options` - -You can enable caching for your current profile or globally (for all profiles) by: +Enable caching for your current profile or globally (for all profiles): .. code-block:: console $ verdi config set caching.default_enabled True Success: 'caching.default_enabled' set to True for 'quicksetup' profile + $ verdi config set -g caching.default_enabled True Success: 'caching.default_enabled' set to True globally + $ verdi config list caching name source value ----------------------- -------- ------- @@ -455,22 +458,18 @@ You can enable caching for your current profile or globally (for all profiles) b caching.disabled_for default caching.enabled_for default -From this point onwards, when you launch a new calculation, AiiDA will compare its hash (depending both on the type of calculation and its inputs, see :ref:`topics:provenance:caching:hashing`) against other calculations already present in your database. -If another calculation with the same hash is found, AiiDA will reuse its results without repeating the actual calculation. +.. versionchanged:: 1.6.0 -In order to ensure that the provenance graph with and without caching is the same, AiiDA creates both a new calculation node and a copy of the output data nodes as shown in :numref:`fig_caching`. + Configuring caching via the ``cache_config.yml`` is deprecated as of AiiDA 1.6.0. + Existing ``cache_config.yml`` files will be migrated to the central ``config.json`` file automatically. -.. _fig_caching: -.. figure:: include/images/caching.png - :align: center - :height: 350px - When reusing the results of a calculation **C** for a new calculation **C'**, AiiDA simply makes a copy of the result nodes and links them up as usual. +From this point onwards, when you launch a new calculation, AiiDA will compare its hash (a fixed size string, unique for a calulation's type and inputs, see :ref:`topics:provenance:caching:hashing`) against other calculations already present in your database. +If another calculation with the same hash is found, AiiDA will reuse its results without repeating the actual calculation. .. note:: - AiiDA uses the *hashes* of the input nodes **D1** and **D2** when searching the calculation cache. - That is to say, if the input of **C'** were new nodes **D1'** and **D2'** with the same content (hash) as **D1**, **D2**, the cache would trigger as well. + In contrast to caching, hashing **is** enabled by default, i.e. hashes for all your calculations will already have been computed. .. _how-to:run-codes:caching:configure: diff --git a/docs/source/topics/provenance/caching.rst b/docs/source/topics/provenance/caching.rst index f790b27a16..2cb4e45e1e 100644 --- a/docs/source/topics/provenance/caching.rst +++ b/docs/source/topics/provenance/caching.rst @@ -58,29 +58,36 @@ In order to figure out why a calculation is *not* being reused, the :meth:`~aiid ] -.. _topics:provenance:caching:control: +.. _topics:provenance:caching:control-hashing: Controlling hashing ------------------- -There are a couple of ways in which you can customized what properties are being considered when calculating the hash for a new instance of a data node. -Most of this are established at the time of creating the data plugin extension: +Data nodes +.......... -* If you wish to ignore specific attributes, a :py:class:`~aiida.orm.nodes.Node` subclass can have a ``_hash_ignored_attributes`` attribute. - This is a list of attribute names, which are ignored when creating the hash. -* To add things which should be considered in the hash, you can override the :meth:`~aiida.orm.nodes.Node._get_objects_to_hash` method. - Note that doing so also overrides the behavior described above, so make sure to use the ``super()`` method in order to prevent this. -* You can pass a keyword argument to :meth:`~aiida.orm.nodes.Node.get_hash`. - These are, in turn, passed on to :meth:`~aiida.common.hashing.make_hash`. +The hashing of *Data nodes* can be customized both when implementing a new data node class and during runtime. -The process nodes have a fixed behavior that is internal to AiiDA and are not subclassable, so they can't be customized in a direct way. -To know more about the specifics of these internals you can visit the :ref:`corresponding section `. -The only way in which these can be influenced by plugin designers is indirectly via the hash criteria for the associated data types of their inputs. +In the :py:class:`~aiida.orm.nodes.Node` subclass: + +* Use the ``_hash_ignored_attributes`` to exclude a list of node attributes ``['attr1', 'attr2']`` from computing the hash. +* Include extra information in computing the hash by overriding the :meth:`~aiida.orm.nodes.Node._get_objects_to_hash` method. + Use the ``super()`` method, and then append to the list of objects to hash. + +You can also modify hashing behavior during runtime by passing a keyword argument to :meth:`~aiida.orm.nodes.Node.get_hash`, which are forwarded to :meth:`~aiida.common.hashing.make_hash`. + +Process nodes +............. + +The hashing of *Process nodes* is fixed and can only be influenced indirectly via the hashes of their inputs. +For implementation details of the hashing mechanism for process nodes, see :ref:`here `. + +.. _topics:provenance:caching:control-caching: Controlling Caching ------------------- -Although you can't directly control the hashing mechanism of the process node when implementing a plugin, there are ways in which you can control its caching: +Caching can be configured at runtime (see :ref:`how-to:run-codes:caching:configure`) and when implementing a new process class: * The :meth:`spec.exit_code ` has a keyword argument ``invalidates_cache``. If this is set to ``True``, that means that a calculation with this exit code will not be used as a cache source for another one, even if their hashes match. From 976876fced2ab89600b4e7538b116256e107184a Mon Sep 17 00:00:00 2001 From: DanielMarchand Date: Thu, 4 Mar 2021 15:09:07 +0100 Subject: [PATCH 100/114] docs: reference caching howto in workflow section (#4789) It might be helpful to people learning about AiiDA workflows to know that caching exists and point them in that direction. Co-authored-by: Leopold Talirz --- docs/source/howto/workflows.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/howto/workflows.rst b/docs/source/howto/workflows.rst index 3c5f992c75..844fc7b2c1 100644 --- a/docs/source/howto/workflows.rst +++ b/docs/source/howto/workflows.rst @@ -21,6 +21,10 @@ Here we present a brief introduction on how to write both workflow types. For more details on the concept of a workflow, and the difference between a work function and a work chain, please see the corresponding :ref:`topics section`. +.. note:: + + Developing workflows may involve running several lengthy calculations. Consider :ref:`enabling caching ` to help avoid repeating long workflow steps. + Work function ------------- From d00b4fa2792d6a02a2528ad5989505e17820aae5 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Fri, 5 Mar 2021 10:00:28 +0100 Subject: [PATCH 101/114] setup: move away from legacy build backend (#4790) The `pyproject.toml` was originally added in ca75832afb002b344b5854f2f049c74e80cad36b without specifying a backend, which implicitly defaults to the legacy `setuptools.build_meta:__legacy__` one. This choice was made explicit in a2bebb422f4a7b75e8ef65fd797f128abf12c6cc This can lead to issues when using a *system* version of setuptools < 40.8.0, see [1]. We believe there is no good reason for sticking with the legacy build system. I've tested that the `reentry_register` hook still works with the new build backend. [1] https://github.com/pypa/setuptools/issues/1694#issuecomment-466010982 --- pyproject.toml | 2 +- utils/dependency_management.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aac39bf675..d264c55af5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = ["setuptools>=40.8.0", "wheel", "reentry~=1.3", "fastentrypoints~=0.12"] -build-backend = "setuptools.build_meta:__legacy__" +build-backend = "setuptools.build_meta" [tool.pylint.master] load-plugins = "pylint_django" diff --git a/utils/dependency_management.py b/utils/dependency_management.py index f317453a6e..4ff62257e4 100755 --- a/utils/dependency_management.py +++ b/utils/dependency_management.py @@ -191,7 +191,7 @@ def update_pyproject_toml(): 'requires': ['setuptools>=40.8.0', 'wheel', str(reentry_requirement), 'fastentrypoints~=0.12'], 'build-backend': - 'setuptools.build_meta:__legacy__', + 'setuptools.build_meta', }) # write the new file From 765f05e8f47b59af5b5b82949d75ad65be3dff86 Mon Sep 17 00:00:00 2001 From: Leopold Talirz Date: Mon, 8 Mar 2021 19:19:41 +0100 Subject: [PATCH 102/114] fix pymatgen imports (#4794) pymatgen made a breaking change in v2021.3.4 that removed many classes from the top level of the package. The alternative imports were already available in previous versions, i.e. we don't need to upgrade the pymatgen dependency. --- aiida/orm/nodes/data/structure.py | 6 +-- .../dbimporters/plugins/materialsproject.py | 2 +- tests/test_dataclasses.py | 44 ++++++++++--------- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/aiida/orm/nodes/data/structure.py b/aiida/orm/nodes/data/structure.py index 8b2827d5e0..b2848369cb 100644 --- a/aiida/orm/nodes/data/structure.py +++ b/aiida/orm/nodes/data/structure.py @@ -1852,7 +1852,7 @@ def _get_object_pymatgen_structure(self, **kwargs): .. note:: Requires the pymatgen module (version >= 3.0.13, usage of earlier versions may cause errors) """ - from pymatgen import Structure + from pymatgen.core.structure import Structure if self.pbc != (True, True, True): raise ValueError('Periodic boundary conditions must apply in all three dimensions of real space') @@ -1862,7 +1862,7 @@ def _get_object_pymatgen_structure(self, **kwargs): if (kwargs.pop('add_spin', False) and any([n.endswith('1') or n.endswith('2') for n in self.get_kind_names()])): # case when spins are defined -> no partial occupancy allowed - from pymatgen import Specie + from pymatgen.core.periodic_table import Specie oxidation_state = 0 # now I always set the oxidation_state to zero for site in self.sites: kind = self.get_kind(site.kind_name) @@ -1907,7 +1907,7 @@ def _get_object_pymatgen_molecule(self, **kwargs): .. note:: Requires the pymatgen module (version >= 3.0.13, usage of earlier versions may cause errors) """ - from pymatgen import Molecule + from pymatgen.core.structure import Molecule if kwargs: raise ValueError(f'Unrecognized parameters passed to pymatgen converter: {kwargs.keys()}') diff --git a/aiida/tools/dbimporters/plugins/materialsproject.py b/aiida/tools/dbimporters/plugins/materialsproject.py index f4d1ced5c3..be16390e2f 100644 --- a/aiida/tools/dbimporters/plugins/materialsproject.py +++ b/aiida/tools/dbimporters/plugins/materialsproject.py @@ -12,7 +12,7 @@ import os import requests -from pymatgen import MPRester +from pymatgen.ext.matproj import MPRester from aiida.tools.dbimporters.baseclasses import CifEntry, DbImporter, DbSearchResults diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 7c4e939848..9a5fefef5b 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -2227,15 +2227,17 @@ def test_partial_occ_and_spin(self): Tests pymatgen -> StructureData, with partial occupancies and spins. This should raise a ValueError. """ - import pymatgen - - Fe_spin_up = pymatgen.Specie('Fe', 0, properties={'spin': 1}) - Mn_spin_up = pymatgen.Specie('Mn', 0, properties={'spin': 1}) - Fe_spin_down = pymatgen.Specie('Fe', 0, properties={'spin': -1}) - Mn_spin_down = pymatgen.Specie('Mn', 0, properties={'spin': -1}) - FeMn1 = pymatgen.Composition({Fe_spin_up: 0.5, Mn_spin_up: 0.5}) - FeMn2 = pymatgen.Composition({Fe_spin_down: 0.5, Mn_spin_down: 0.5}) - a = pymatgen.Structure( + from pymatgen.core.periodic_table import Specie + from pymatgen.core.composition import Composition + from pymatgen.core.structure import Structure + + Fe_spin_up = Specie('Fe', 0, properties={'spin': 1}) + Mn_spin_up = Specie('Mn', 0, properties={'spin': 1}) + Fe_spin_down = Specie('Fe', 0, properties={'spin': -1}) + Mn_spin_down = Specie('Mn', 0, properties={'spin': -1}) + FeMn1 = Composition({Fe_spin_up: 0.5, Mn_spin_up: 0.5}) + FeMn2 = Composition({Fe_spin_down: 0.5, Mn_spin_down: 0.5}) + a = Structure( lattice=[[4, 0, 0], [0, 4, 0], [0, 0, 4]], species=[FeMn1, FeMn2], coords=[[0, 0, 0], [0.5, 0.5, 0.5]] ) @@ -2243,9 +2245,9 @@ def test_partial_occ_and_spin(self): StructureData(pymatgen=a) # same, with vacancies - Fe1 = pymatgen.Composition({Fe_spin_up: 0.5}) - Fe2 = pymatgen.Composition({Fe_spin_down: 0.5}) - a = pymatgen.Structure( + Fe1 = Composition({Fe_spin_up: 0.5}) + Fe2 = Composition({Fe_spin_down: 0.5}) + a = Structure( lattice=[[4, 0, 0], [0, 4, 0], [0, 0, 4]], species=[Fe1, Fe2], coords=[[0, 0, 0], [0.5, 0.5, 0.5]] ) @@ -2257,12 +2259,13 @@ def test_partial_occ_and_spin(self): def test_multiple_kinds_partial_occupancies(): """Tests that a structure with multiple sites with the same element but different partial occupancies, get their own unique kind name.""" - import pymatgen + from pymatgen.core.composition import Composition + from pymatgen.core.structure import Structure - Mg1 = pymatgen.Composition({'Mg': 0.50}) - Mg2 = pymatgen.Composition({'Mg': 0.25}) + Mg1 = Composition({'Mg': 0.50}) + Mg2 = Composition({'Mg': 0.25}) - a = pymatgen.Structure( + a = Structure( lattice=[[4, 0, 0], [0, 4, 0], [0, 0, 4]], species=[Mg1, Mg2], coords=[[0, 0, 0], [0.5, 0.5, 0.5]] ) @@ -2275,12 +2278,13 @@ def test_multiple_kinds_alloy(): Tests that a structure with multiple sites with the same alloy symbols but different weights, get their own unique kind name """ - import pymatgen + from pymatgen.core.composition import Composition + from pymatgen.core.structure import Structure - alloy_one = pymatgen.Composition({'Mg': 0.25, 'Al': 0.75}) - alloy_two = pymatgen.Composition({'Mg': 0.45, 'Al': 0.55}) + alloy_one = Composition({'Mg': 0.25, 'Al': 0.75}) + alloy_two = Composition({'Mg': 0.45, 'Al': 0.55}) - a = pymatgen.Structure( + a = Structure( lattice=[[4, 0, 0], [0, 4, 0], [0, 0, 4]], species=[alloy_one, alloy_two], coords=[[0, 0, 0], [0.5, 0.5, 0.5]] From 4b7febe8264da5c49ec0125c31f7ca8dd0713b2c Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Mar 2021 13:52:39 +0100 Subject: [PATCH 103/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20`get=5Fpymatgen?= =?UTF-8?q?=5Fversion`=20(#4796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In version 2022.0.3 it was moved --- aiida/orm/nodes/data/structure.py | 8 ++++++-- tests/test_dataclasses.py | 18 ++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/aiida/orm/nodes/data/structure.py b/aiida/orm/nodes/data/structure.py index b2848369cb..f6187c4485 100644 --- a/aiida/orm/nodes/data/structure.py +++ b/aiida/orm/nodes/data/structure.py @@ -115,8 +115,12 @@ def get_pymatgen_version(): """ if not has_pymatgen(): return None - import pymatgen - return pymatgen.__version__ + try: + from pymatgen import __version__ + except ImportError: + # this was changed in version 2022.0.3 + from pymatgen.core import __version__ + return __version__ def has_spglib(): diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 9a5fefef5b..cc1b9933dc 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -20,6 +20,9 @@ from aiida.common.utils import Capturing from aiida.orm import load_node from aiida.orm import CifData, StructureData, KpointsData, BandsData, ArrayData, TrajectoryData, Dict +from aiida.orm.nodes.data.structure import ( + ase_refine_cell, get_formula, get_pymatgen_version, has_ase, has_pymatgen, has_spglib +) from aiida.orm.nodes.data.structure import Kind, Site @@ -51,11 +54,15 @@ def simplify(string): return '\n'.join(s.strip() for s in string.split()) +@pytest.mark.skipif(not has_pymatgen(), reason='pymatgen not installed') +def test_get_pymatgen_version(): + assert isinstance(get_pymatgen_version(), str) + + class TestCifData(AiidaTestCase): """Tests for CifData class.""" from distutils.version import StrictVersion from aiida.orm.nodes.data.cif import has_pycifrw - from aiida.orm.nodes.data.structure import has_ase, has_pymatgen, has_spglib, get_pymatgen_version valid_sample_cif_str = ''' data_test @@ -1038,7 +1045,6 @@ class TestStructureData(AiidaTestCase): Tests the creation of StructureData objects (cell and pbc). """ # pylint: disable=too-many-public-methods - from aiida.orm.nodes.data.structure import has_ase, has_spglib from aiida.orm.nodes.data.cif import has_pycifrw def test_cell_ok_and_atoms(self): @@ -1512,7 +1518,6 @@ def test_kind_8(self): Test the ase_refine_cell() function """ # pylint: disable=too-many-statements - from aiida.orm.nodes.data.structure import ase_refine_cell import ase import math import numpy @@ -1593,8 +1598,6 @@ def test_get_formula(self): """ Tests the generation of formula """ - from aiida.orm.nodes.data.structure import get_formula - self.assertEqual(get_formula(['Ba', 'Ti'] + ['O'] * 3), 'BaO3Ti') self.assertEqual(get_formula(['Ba', 'Ti', 'C'] + ['O'] * 3, separator=' '), 'C Ba O3 Ti') self.assertEqual(get_formula(['H'] * 6 + ['C'] * 6), 'C6H6') @@ -1622,8 +1625,6 @@ def test_get_formula_unknown(self): """ Tests the generation of formula, including unknown entry. """ - from aiida.orm.nodes.data.structure import get_formula - self.assertEqual(get_formula(['Ba', 'Ti'] + ['X'] * 3), 'BaTiX3') self.assertEqual(get_formula(['Ba', 'Ti', 'C'] + ['X'] * 3, separator=' '), 'C Ba Ti X3') self.assertEqual(get_formula(['X'] * 6 + ['C'] * 6), 'C6X6') @@ -1916,7 +1917,6 @@ def test_clone(self): class TestStructureDataFromAse(AiidaTestCase): """Tests the creation of Sites from/to a ASE object.""" - from aiida.orm.nodes.data.structure import has_ase @unittest.skipIf(not has_ase(), 'Unable to import ase') def test_ase(self): @@ -2103,7 +2103,6 @@ class TestStructureDataFromPymatgen(AiidaTestCase): Tests the creation of StructureData from a pymatgen Structure and Molecule objects. """ - from aiida.orm.nodes.data.structure import has_pymatgen, get_pymatgen_version @unittest.skipIf(not has_pymatgen(), 'Unable to import pymatgen') def test_1(self): @@ -2296,7 +2295,6 @@ def test_multiple_kinds_alloy(): class TestPymatgenFromStructureData(AiidaTestCase): """Tests the creation of pymatgen Structure and Molecule objects from StructureData.""" - from aiida.orm.nodes.data.structure import has_ase, has_pymatgen, get_pymatgen_version @unittest.skipIf(not has_pymatgen(), 'Unable to import pymatgen') def test_1(self): From 2acffe70836d47a4e6925d4f90baba596b770095 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Mar 2021 14:34:39 +0100 Subject: [PATCH 104/114] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20add=20type=20?= =?UTF-8?q?checking=20for=20aiida/orm/nodes/process=20(#4772)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds type definitions to all code in `aiida/orm/nodes/process`, and enables mypy type checking of the files. Additionally, to fix mypy failures, two changes to the code were made: 1. Change `CalcJobNode.get_description` to return a string 2. In `aiida/engine/processes/calcjobs/tasks.py`, change `node.computer.get_authinfo(node.user)` to `node.get_authinfo()`, to use `CalcJobNode.get_authinfo` which checks if the computer is set. --- .pre-commit-config.yaml | 4 +- aiida/engine/processes/calcjobs/tasks.py | 12 +- aiida/orm/nodes/node.py | 275 ++++++++++++------ aiida/orm/nodes/process/__init__.py | 2 +- .../nodes/process/calculation/calcfunction.py | 6 +- .../orm/nodes/process/calculation/calcjob.py | 114 ++++---- .../nodes/process/calculation/calculation.py | 6 +- aiida/orm/nodes/process/process.py | 78 ++--- aiida/orm/nodes/process/workflow/workchain.py | 7 +- aiida/orm/nodes/process/workflow/workflow.py | 10 +- .../nodes/process/workflow/workfunction.py | 6 +- docs/source/nitpick-exceptions | 26 +- tests/test_calculation_node.py | 5 +- 13 files changed, 346 insertions(+), 205 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e8baeb6d0..a4204ac27f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,9 +43,11 @@ repos: files: >- (?x)^( aiida/common/progress_reporter.py| - aiida/manage/manager.py| aiida/engine/.*py| + aiida/manage/manager.py| aiida/manage/database/delete/nodes.py| + aiida/orm/nodes/node.py| + aiida/orm/nodes/process/.*py| aiida/tools/graph/graph_traversers.py| aiida/tools/groups/paths.py| aiida/tools/importexport/archive/.*py| diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 721b5317f9..e1a9e7d2b5 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -71,7 +71,7 @@ async def task_upload_job(process: 'CalcJob', transport_queue: TransportQueue, c initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) - authinfo = node.computer.get_authinfo(node.user) + authinfo = node.get_authinfo() async def do_upload(): with transport_queue.request_transport(authinfo) as request: @@ -130,7 +130,7 @@ async def task_submit_job(node: CalcJobNode, transport_queue: TransportQueue, ca initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) - authinfo = node.computer.get_authinfo(node.user) + authinfo = node.get_authinfo() async def do_submit(): with transport_queue.request_transport(authinfo) as request: @@ -177,7 +177,7 @@ async def task_update_job(node: CalcJobNode, job_manager, cancellable: Interrupt initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) - authinfo = node.computer.get_authinfo(node.user) + authinfo = node.get_authinfo() job_id = node.get_job_id() async def do_update(): @@ -240,7 +240,7 @@ async def task_retrieve_job( initial_interval = get_config_option(RETRY_INTERVAL_OPTION) max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) - authinfo = node.computer.get_authinfo(node.user) + authinfo = node.get_authinfo() async def do_retrieve(): with transport_queue.request_transport(authinfo) as request: @@ -249,7 +249,7 @@ async def do_retrieve(): # Perform the job accounting and set it on the node if successful. If the scheduler does not implement this # still set the attribute but set it to `None`. This way we can distinguish calculation jobs for which the # accounting was called but could not be set. - scheduler = node.computer.get_scheduler() + scheduler = node.computer.get_scheduler() # type: ignore[union-attr] scheduler.set_transport(transport) try: @@ -300,7 +300,7 @@ async def task_kill_job(node: CalcJobNode, transport_queue: TransportQueue, canc logger.warning(f'CalcJob<{node.pk}> killed, it was in the {node.get_state()} state') return True - authinfo = node.computer.get_authinfo(node.user) + authinfo = node.get_authinfo() async def do_kill(): with transport_queue.request_transport(authinfo) as request: diff --git a/aiida/orm/nodes/node.py b/aiida/orm/nodes/node.py index 90350aa6a4..a30a1d1135 100644 --- a/aiida/orm/nodes/node.py +++ b/aiida/orm/nodes/node.py @@ -9,11 +9,14 @@ ########################################################################### # pylint: disable=too-many-lines,too-many-arguments """Package for node ORM classes.""" +import datetime import importlib +from logging import Logger import warnings import traceback +from typing import Any, Dict, IO, Iterator, List, Optional, Sequence, Tuple, Type, Union +from typing import TYPE_CHECKING from uuid import UUID -from typing import List, Optional from aiida.common import exceptions from aiida.common.escaping import sql_string_match @@ -34,20 +37,25 @@ from ..querybuilder import QueryBuilder from ..users import User +if TYPE_CHECKING: + from aiida.repository import File + from ..implementation import Backend + from ..implementation.nodes import BackendNode + __all__ = ('Node',) -_NO_DEFAULT = tuple() +_NO_DEFAULT = tuple() # type: ignore[var-annotated] class WarnWhenNotEntered: """Temporary wrapper to warn when `Node.open` is called outside of a context manager.""" - def __init__(self, fileobj, name): - self._fileobj = fileobj + def __init__(self, fileobj: Union[IO[str], IO[bytes]], name: str) -> None: + self._fileobj: Union[IO[str], IO[bytes]] = fileobj self._name = name self._was_entered = False - def _warn_if_not_entered(self, method): + def _warn_if_not_entered(self, method) -> None: """Fire a warning if the object wrapper has not yet been entered.""" if not self._was_entered: msg = f'\nThe method `{method}` was called on the return value of `{self._name}.open()`' + \ @@ -64,34 +72,34 @@ def _warn_if_not_entered(self, method): warnings.warn(msg, AiidaDeprecationWarning) # pylint: disable=no-member - def __enter__(self): + def __enter__(self) -> Union[IO[str], IO[bytes]]: self._was_entered = True return self._fileobj.__enter__() - def __exit__(self, *args): + def __exit__(self, *args: Any) -> None: self._fileobj.__exit__(*args) - def __getattr__(self, key): + def __getattr__(self, key: str): if key == '_fileobj': return self._fileobj return getattr(self._fileobj, key) - def __del__(self): + def __del__(self) -> None: self._warn_if_not_entered('del') - def __iter__(self): + def __iter__(self) -> Iterator[Union[str, bytes]]: return self._fileobj.__iter__() - def __next__(self): + def __next__(self) -> Union[str, bytes]: return self._fileobj.__next__() - def read(self, *args, **kwargs): + def read(self, *args: Any, **kwargs: Any) -> Union[str, bytes]: self._warn_if_not_entered('read') return self._fileobj.read(*args, **kwargs) - def close(self, *args, **kwargs): + def close(self, *args: Any, **kwargs: Any) -> None: self._warn_if_not_entered('close') - return self._fileobj.close(*args, **kwargs) + return self._fileobj.close(*args, **kwargs) # type: ignore[call-arg] class Node(Entity, EntityAttributesMixin, EntityExtrasMixin, metaclass=AbstractNodeMeta): @@ -115,7 +123,7 @@ class Node(Entity, EntityAttributesMixin, EntityExtrasMixin, metaclass=AbstractN class Collection(EntityCollection): """The collection of nodes.""" - def delete(self, node_id): + def delete(self, node_id: int) -> None: """Delete a `Node` from the collection with the given id :param node_id: the node id @@ -136,14 +144,14 @@ def delete(self, node_id): repository.erase(force=True) # This will be set by the metaclass call - _logger = None + _logger: Optional[Logger] = None # A tuple of attribute names that can be updated even after node is stored # Requires Sealable mixin, but needs empty tuple for base class - _updatable_attributes = tuple() + _updatable_attributes: Tuple[str, ...] = tuple() # A tuple of attribute names that will be ignored when creating the hash. - _hash_ignored_attributes = tuple() + _hash_ignored_attributes: Tuple[str, ...] = tuple() # Flag that determines whether the class can be cached. _cachable = False @@ -156,15 +164,21 @@ def delete(self, node_id): _unstorable_message = 'only Data, WorkflowNode, CalculationNode or their subclasses can be stored' # These are to be initialized in the `initialization` method - _incoming_cache = None - _repository = None + _incoming_cache: Optional[List[LinkTriple]] = None + _repository: Optional[Repository] = None @classmethod - def from_backend_entity(cls, backend_entity): + def from_backend_entity(cls, backend_entity: 'BackendNode') -> 'Node': entity = super().from_backend_entity(backend_entity) return entity - def __init__(self, backend=None, user=None, computer=None, **kwargs): + def __init__( + self, + backend: Optional['Backend'] = None, + user: Optional[User] = None, + computer: Optional[Computer] = None, + **kwargs: Any + ) -> None: backend = backend or get_manager().get_backend() if computer and not computer.is_stored: @@ -181,20 +195,24 @@ def __init__(self, backend=None, user=None, computer=None, **kwargs): ) super().__init__(backend_entity) - def __eq__(self, other): + @property + def backend_entity(self) -> 'BackendNode': + return super().backend_entity + + def __eq__(self, other: Any) -> bool: """Fallback equality comparison by uuid (can be overwritten by specific types)""" if isinstance(other, Node) and self.uuid == other.uuid: return True return super().__eq__(other) - def __hash__(self): + def __hash__(self) -> int: """Python-Hash: Implementation that is compatible with __eq__""" return UUID(self.uuid).int - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__}: {str(self)}>' - def __str__(self): + def __str__(self) -> str: if not self.is_stored: return f'uuid: {self.uuid} (unstored)' @@ -208,7 +226,7 @@ def __deepcopy__(self, memo): """Deep copying a Node is not supported in general, but only for the Data sub class.""" raise exceptions.InvalidOperation('deep copying a base Node is not supported') - def initialize(self): + def initialize(self) -> None: """ Initialize internal variables for the backend node @@ -222,7 +240,7 @@ def initialize(self): # Calls the initialisation from the RepositoryMixin self._repository = Repository(uuid=self.uuid, is_stored=self.is_stored, base_path=self._repository_base_path) - def _validate(self): + def _validate(self) -> bool: """Check if the attributes and files retrieved from the database are valid. Must be able to work even before storing: therefore, use the `get_attr` and similar methods that automatically @@ -234,7 +252,7 @@ def _validate(self): # pylint: disable=no-self-use return True - def validate_storability(self): + def validate_storability(self) -> None: """Verify that the current node is allowed to be stored. :raises `aiida.common.exceptions.StoringNotAllowed`: if the node does not match all requirements for storing @@ -249,13 +267,13 @@ def validate_storability(self): raise exceptions.StoringNotAllowed(msg) @classproperty - def class_node_type(cls): + def class_node_type(cls) -> str: """Returns the node type of this node (sub) class.""" # pylint: disable=no-self-argument,no-member return cls._plugin_type_string @property - def logger(self): + def logger(self) -> Optional[Logger]: """Return the logger configured for this Node. :return: Logger object @@ -263,16 +281,16 @@ def logger(self): return self._logger @property - def uuid(self): + def uuid(self) -> str: """Return the node UUID. :return: the string representation of the UUID - :rtype: str + """ return self.backend_entity.uuid @property - def node_type(self): + def node_type(self) -> str: """Return the node type. :return: the node type @@ -280,7 +298,7 @@ def node_type(self): return self.backend_entity.node_type @property - def process_type(self): + def process_type(self) -> Optional[str]: """Return the node process type. :return: the process type @@ -288,7 +306,7 @@ def process_type(self): return self.backend_entity.process_type @process_type.setter - def process_type(self, value): + def process_type(self, value: str) -> None: """Set the node process type. :param value: the new value to set @@ -296,7 +314,7 @@ def process_type(self, value): self.backend_entity.process_type = value @property - def label(self): + def label(self) -> str: """Return the node label. :return: the label @@ -304,7 +322,7 @@ def label(self): return self.backend_entity.label @label.setter - def label(self, value): + def label(self, value: str) -> None: """Set the label. :param value: the new value to set @@ -312,7 +330,7 @@ def label(self, value): self.backend_entity.label = value @property - def description(self): + def description(self) -> str: """Return the node description. :return: the description @@ -320,7 +338,7 @@ def description(self): return self.backend_entity.description @description.setter - def description(self, value): + def description(self, value: str) -> None: """Set the description. :param value: the new value to set @@ -328,7 +346,7 @@ def description(self, value): self.backend_entity.description = value @property - def computer(self): + def computer(self) -> Optional[Computer]: """Return the computer of this node. :return: the computer or None @@ -340,7 +358,7 @@ def computer(self): return None @computer.setter - def computer(self, computer): + def computer(self, computer: Optional[Computer]) -> None: """Set the computer of this node. :param computer: a `Computer` @@ -356,7 +374,7 @@ def computer(self, computer): self.backend_entity.computer = computer @property - def user(self): + def user(self) -> User: """Return the user of this node. :return: the user @@ -365,7 +383,7 @@ def user(self): return User.from_backend_entity(self.backend_entity.user) @user.setter - def user(self, user): + def user(self, user: User) -> None: """Set the user of this node. :param user: a `User` @@ -377,7 +395,7 @@ def user(self, user): self.backend_entity.user = user.backend_entity @property - def ctime(self): + def ctime(self) -> datetime.datetime: """Return the node ctime. :return: the ctime @@ -385,14 +403,14 @@ def ctime(self): return self.backend_entity.ctime @property - def mtime(self): + def mtime(self) -> datetime.datetime: """Return the node mtime. :return: the mtime """ return self.backend_entity.mtime - def list_objects(self, path=None, key=None): + def list_objects(self, path: Optional[str] = None, key: Optional[str] = None) -> List['File']: """Return a list of the objects contained in this repository, optionally in the given sub directory. .. deprecated:: 1.4.0 @@ -403,6 +421,8 @@ def list_objects(self, path=None, key=None): :return: a list of `File` named tuples representing the objects present in directory with the given path :raises FileNotFoundError: if the `path` does not exist in the repository of this node """ + assert self._repository is not None, 'repository not initialised' + if key is not None: if path is not None: raise ValueError('cannot specify both `path` and `key`.') @@ -414,7 +434,7 @@ def list_objects(self, path=None, key=None): return self._repository.list_objects(path) - def list_object_names(self, path=None, key=None): + def list_object_names(self, path: Optional[str] = None, key: Optional[str] = None) -> List[str]: """Return a list of the object names contained in this repository, optionally in the given sub directory. .. deprecated:: 1.4.0 @@ -422,8 +442,10 @@ def list_object_names(self, path=None, key=None): :param path: the relative path of the object within the repository. :param key: fully qualified identifier for the object within the repository - :return: a list of `File` named tuples representing the objects present in directory with the given path + """ + assert self._repository is not None, 'repository not initialised' + if key is not None: if path is not None: raise ValueError('cannot specify both `path` and `key`.') @@ -435,7 +457,7 @@ def list_object_names(self, path=None, key=None): return self._repository.list_object_names(path) - def open(self, path=None, mode='r', key=None): + def open(self, path: Optional[str] = None, mode: str = 'r', key: Optional[str] = None) -> WarnWhenNotEntered: """Open a file handle to the object with the given path. .. deprecated:: 1.4.0 @@ -448,6 +470,8 @@ def open(self, path=None, mode='r', key=None): :param key: fully qualified identifier for the object within the repository :param mode: the mode under which to open the handle """ + assert self._repository is not None, 'repository not initialised' + if key is not None: if path is not None: raise ValueError('cannot specify both `path` and `key`.') @@ -465,7 +489,7 @@ def open(self, path=None, mode='r', key=None): return WarnWhenNotEntered(self._repository.open(path, mode), repr(self)) - def get_object(self, path=None, key=None): + def get_object(self, path: Optional[str] = None, key: Optional[str] = None) -> 'File': """Return the object with the given path. .. deprecated:: 1.4.0 @@ -475,6 +499,8 @@ def get_object(self, path=None, key=None): :param key: fully qualified identifier for the object within the repository :return: a `File` named tuple """ + assert self._repository is not None, 'repository not initialised' + if key is not None: if path is not None: raise ValueError('cannot specify both `path` and `key`.') @@ -489,7 +515,10 @@ def get_object(self, path=None, key=None): return self._repository.get_object(path) - def get_object_content(self, path=None, mode='r', key=None): + def get_object_content(self, + path: Optional[str] = None, + mode: str = 'r', + key: Optional[str] = None) -> Union[str, bytes]: """Return the content of a object with the given path. .. deprecated:: 1.4.0 @@ -498,6 +527,8 @@ def get_object_content(self, path=None, mode='r', key=None): :param path: the relative path of the object within the repository. :param key: fully qualified identifier for the object within the repository """ + assert self._repository is not None, 'repository not initialised' + if key is not None: if path is not None: raise ValueError('cannot specify both `path` and `key`.') @@ -515,7 +546,14 @@ def get_object_content(self, path=None, mode='r', key=None): return self._repository.get_object_content(path, mode) - def put_object_from_tree(self, filepath, path=None, contents_only=True, force=False, key=None): + def put_object_from_tree( + self, + filepath: str, + path: Optional[str] = None, + contents_only: bool = True, + force: bool = False, + key: Optional[str] = None + ) -> None: """Store a new object under `path` with the contents of the directory located at `filepath` on this file system. .. warning:: If the repository belongs to a stored node, a `ModificationNotAllowed` exception will be raised. @@ -540,6 +578,8 @@ def put_object_from_tree(self, filepath, path=None, contents_only=True, force=Fa :param force: boolean, if True, will skip the mutability check :raises aiida.common.ModificationNotAllowed: if repository is immutable and `force=False` """ + assert self._repository is not None, 'repository not initialised' + if force: warnings.warn('the `force` keyword is deprecated and will be removed in `v2.0.0`.', AiidaDeprecationWarning) # pylint: disable=no-member @@ -559,7 +599,15 @@ def put_object_from_tree(self, filepath, path=None, contents_only=True, force=Fa self._repository.put_object_from_tree(filepath, path, contents_only, force) - def put_object_from_file(self, filepath, path=None, mode=None, encoding=None, force=False, key=None): + def put_object_from_file( + self, + filepath: str, + path: Optional[str] = None, + mode: Optional[str] = None, + encoding: Optional[str] = None, + force: bool = False, + key: Optional[str] = None + ) -> None: """Store a new object under `path` with contents of the file located at `filepath` on this file system. .. warning:: If the repository belongs to a stored node, a `ModificationNotAllowed` exception will be raised. @@ -584,6 +632,8 @@ def put_object_from_file(self, filepath, path=None, mode=None, encoding=None, fo :param force: boolean, if True, will skip the mutability check :raises aiida.common.ModificationNotAllowed: if repository is immutable and `force=False` """ + assert self._repository is not None, 'repository not initialised' + # Note that the defaults of `mode` and `encoding` had to be change to `None` from `w` and `utf-8` resptively, in # order to detect when they were being passed such that the deprecation warning can be emitted. The defaults did # not make sense and so ignoring them is justified, since the side-effect of this function, a file being copied, @@ -613,7 +663,15 @@ def put_object_from_file(self, filepath, path=None, mode=None, encoding=None, fo self._repository.put_object_from_file(filepath, path, mode, encoding, force) - def put_object_from_filelike(self, handle, path=None, mode='w', encoding='utf8', force=False, key=None): + def put_object_from_filelike( + self, + handle: IO[Any], + path: Optional[str] = None, + mode: str = 'w', + encoding: str = 'utf8', + force: bool = False, + key: Optional[str] = None + ) -> None: """Store a new object under `path` with contents of filelike object `handle`. .. warning:: If the repository belongs to a stored node, a `ModificationNotAllowed` exception will be raised. @@ -633,6 +691,8 @@ def put_object_from_filelike(self, handle, path=None, mode='w', encoding='utf8', :param force: boolean, if True, will skip the mutability check :raises aiida.common.ModificationNotAllowed: if repository is immutable and `force=False` """ + assert self._repository is not None, 'repository not initialised' + if force: warnings.warn('the `force` keyword is deprecated and will be removed in `v2.0.0`.', AiidaDeprecationWarning) # pylint: disable=no-member @@ -650,7 +710,7 @@ def put_object_from_filelike(self, handle, path=None, mode='w', encoding='utf8', self._repository.put_object_from_filelike(handle, path, mode, encoding, force) - def delete_object(self, path=None, force=False, key=None): + def delete_object(self, path: Optional[str] = None, force: bool = False, key: Optional[str] = None) -> None: """Delete the object from the repository. .. warning:: If the repository belongs to a stored node, a `ModificationNotAllowed` exception will be raised. @@ -666,6 +726,8 @@ def delete_object(self, path=None, force=False, key=None): :param force: boolean, if True, will skip the mutability check :raises aiida.common.ModificationNotAllowed: if repository is immutable and `force=False` """ + assert self._repository is not None, 'repository not initialised' + if force: warnings.warn('the `force` keyword is deprecated and will be removed in `v2.0.0`.', AiidaDeprecationWarning) # pylint: disable=no-member @@ -728,7 +790,7 @@ def remove_comment(self, identifier: int) -> None: # pylint: disable=no-self-us """ Comment.objects.delete(identifier) - def add_incoming(self, source, link_type, link_label): + def add_incoming(self, source: 'Node', link_type: LinkType, link_label: str) -> None: """Add a link of the given type from a given node to ourself. :param source: the node from which the link is coming @@ -745,7 +807,7 @@ def add_incoming(self, source, link_type, link_label): else: self._add_incoming_cache(source, link_type, link_label) - def validate_incoming(self, source, link_type, link_label): + def validate_incoming(self, source: 'Node', link_type: LinkType, link_label: str) -> None: """Validate adding a link of the given type from a given node to ourself. This function will first validate the types of the inputs, followed by the node and link types and validate @@ -772,7 +834,7 @@ def validate_incoming(self, source, link_type, link_label): if builder.count() > 0: raise ValueError('the link you are attempting to create would generate a cycle in the graph') - def validate_outgoing(self, target, link_type, link_label): # pylint: disable=unused-argument,no-self-use + def validate_outgoing(self, target: 'Node', link_type: LinkType, link_label: str) -> None: # pylint: disable=unused-argument,no-self-use """Validate adding a link of the given type from ourself to a given node. The validity of the triple (source, link, target) should be validated in the `validate_incoming` call. @@ -788,7 +850,7 @@ def validate_outgoing(self, target, link_type, link_label): # pylint: disable=u type_check(link_type, LinkType, f'link_type should be a LinkType enum but got: {type(link_type)}') type_check(target, Node, f'target should be a `Node` instance but got: {type(target)}') - def _add_incoming_cache(self, source, link_type, link_label): + def _add_incoming_cache(self, source: 'Node', link_type: LinkType, link_label: str) -> None: """Add an incoming link to the cache. .. note: the proposed link is not validated in this function, so this should not be called directly @@ -799,6 +861,8 @@ def _add_incoming_cache(self, source, link_type, link_label): :param link_label: the link label :raise aiida.common.UniquenessError: if the given link triple already exists in the cache """ + assert self._incoming_cache is not None, 'incoming_cache not initialised' + link_triple = LinkTriple(source, link_type, link_label) if link_triple in self._incoming_cache: @@ -807,8 +871,13 @@ def _add_incoming_cache(self, source, link_type, link_label): self._incoming_cache.append(link_triple) def get_stored_link_triples( - self, node_class=None, link_type=(), link_label_filter=None, link_direction='incoming', only_uuid=False - ): + self, + node_class: Type['Node'] = None, + link_type: Union[LinkType, Sequence[LinkType]] = (), + link_label_filter: Optional[str] = None, + link_direction: str = 'incoming', + only_uuid: bool = False + ) -> List[LinkTriple]: """Return the list of stored link triples directly incoming to or outgoing of this node. Note this will only return link triples that are stored in the database. Anything in the cache is ignored. @@ -816,7 +885,7 @@ def get_stored_link_triples( :param node_class: If specified, should be a class, and it filters only elements of that (subclass of) type :param link_type: Only get inputs of this link type, if empty tuple then returns all inputs of all link types. :param link_label_filter: filters the incoming nodes by its link label. This should be a regex statement as - one would pass directly to a QuerBuilder filter statement with the 'like' operation. + one would pass directly to a QueryBuilder filter statement with the 'like' operation. :param link_direction: `incoming` or `outgoing` to get the incoming or outgoing links, respectively. :param only_uuid: project only the node UUID instead of the instance onto the `NodeTriple.node` entries """ @@ -827,8 +896,8 @@ def get_stored_link_triples( raise TypeError(f'link_type should be a LinkType or tuple of LinkType: got {link_type}') node_class = node_class or Node - node_filters = {'id': {'==': self.id}} - edge_filters = {} + node_filters: Dict[str, Any] = {'id': {'==': self.id}} + edge_filters: Dict[str, Any] = {} if link_type: edge_filters['type'] = {'in': [t.value for t in link_type]} @@ -859,7 +928,13 @@ def get_stored_link_triples( return [LinkTriple(entry[0], LinkType(entry[1]), entry[2]) for entry in builder.all()] - def get_incoming(self, node_class=None, link_type=(), link_label_filter=None, only_uuid=False): + def get_incoming( + self, + node_class: Type['Node'] = None, + link_type: Union[LinkType, Sequence[LinkType]] = (), + link_label_filter: Optional[str] = None, + only_uuid: bool = False + ) -> LinkManager: """Return a list of link triples that are (directly) incoming into this node. :param node_class: If specified, should be a class or tuple of classes, and it filters only @@ -870,6 +945,8 @@ def get_incoming(self, node_class=None, link_type=(), link_label_filter=None, on Here wildcards (% and _) can be passed in link label filter as we are using "like" in QB. :param only_uuid: project only the node UUID instead of the instance onto the `NodeTriple.node` entries """ + assert self._incoming_cache is not None, 'incoming_cache not initialised' + if not isinstance(link_type, tuple): link_type = (link_type,) @@ -900,7 +977,13 @@ def get_incoming(self, node_class=None, link_type=(), link_label_filter=None, on return LinkManager(link_triples) - def get_outgoing(self, node_class=None, link_type=(), link_label_filter=None, only_uuid=False): + def get_outgoing( + self, + node_class: Type['Node'] = None, + link_type: Union[LinkType, Sequence[LinkType]] = (), + link_label_filter: Optional[str] = None, + only_uuid: bool = False + ) -> LinkManager: """Return a list of link triples that are (directly) outgoing of this node. :param node_class: If specified, should be a class or tuple of classes, and it filters only @@ -914,20 +997,23 @@ def get_outgoing(self, node_class=None, link_type=(), link_label_filter=None, on link_triples = self.get_stored_link_triples(node_class, link_type, link_label_filter, 'outgoing', only_uuid) return LinkManager(link_triples) - def has_cached_links(self): + def has_cached_links(self) -> bool: """Feturn whether there are unstored incoming links in the cache. :return: boolean, True when there are links in the incoming cache, False otherwise """ + assert self._incoming_cache is not None, 'incoming_cache not initialised' return bool(self._incoming_cache) - def store_all(self, with_transaction=True, use_cache=None): + def store_all(self, with_transaction: bool = True, use_cache=None) -> 'Node': """Store the node, together with all input links. Unstored nodes from cached incoming linkswill also be stored. :parameter with_transaction: if False, do not use a transaction because the caller will already have opened one. """ + assert self._incoming_cache is not None, 'incoming_cache not initialised' + if use_cache is not None: warnings.warn( # pylint: disable=no-member 'the `use_cache` argument is deprecated and will be removed in `v2.0.0`', AiidaDeprecationWarning @@ -946,7 +1032,7 @@ def store_all(self, with_transaction=True, use_cache=None): return self.store(with_transaction) - def store(self, with_transaction=True, use_cache=None): # pylint: disable=arguments-differ + def store(self, with_transaction: bool = True, use_cache=None) -> 'Node': # pylint: disable=arguments-differ """Store the node in the database while saving its attributes and repository directory. After being called attributes cannot be changed anymore! Instead, extras can be changed only AFTER calling @@ -995,12 +1081,14 @@ def store(self, with_transaction=True, use_cache=None): # pylint: disable=argum return self - def _store(self, with_transaction=True, clean=True): + def _store(self, with_transaction: bool = True, clean: bool = True) -> 'Node': """Store the node in the database while saving its attributes and repository directory. :param with_transaction: if False, do not use a transaction because the caller will already have opened one. :param clean: boolean, if True, will clean the attributes and extras before attempting to store """ + assert self._repository is not None, 'repository not initialised' + # First store the repository folder such that if this fails, there won't be an incomplete node in the database. # On the flipside, in the case that storing the node does fail, the repository will now have an orphaned node # directory which will have to be cleaned manually sometime. @@ -1019,19 +1107,24 @@ def _store(self, with_transaction=True, clean=True): return self - def verify_are_parents_stored(self): + def verify_are_parents_stored(self) -> None: """Verify that all `parent` nodes are already stored. :raise aiida.common.ModificationNotAllowed: if one of the source nodes of incoming links is not stored. """ + assert self._incoming_cache is not None, 'incoming_cache not initialised' + for link_triple in self._incoming_cache: if not link_triple.node.is_stored: raise exceptions.ModificationNotAllowed( f'Cannot store because source node of link triple {link_triple} is not stored' ) - def _store_from_cache(self, cache_node, with_transaction): + def _store_from_cache(self, cache_node: 'Node', with_transaction: bool) -> None: """Store this node from an existing cache node.""" + assert self._repository is not None, 'repository not initialised' + assert cache_node._repository is not None, 'cache repository not initialised' # pylint: disable=protected-access + from aiida.orm.utils.mixins import Sealable assert self.node_type == cache_node.node_type @@ -1057,14 +1150,14 @@ def _store_from_cache(self, cache_node, with_transaction): self._add_outputs_from_cache(cache_node) self.set_extra('_aiida_cached_from', cache_node.uuid) - def _add_outputs_from_cache(self, cache_node): + def _add_outputs_from_cache(self, cache_node: 'Node') -> None: """Replicate the output links and nodes from the cached node onto this node.""" for entry in cache_node.get_outgoing(link_type=LinkType.CREATE): new_node = entry.node.clone() new_node.add_incoming(self, link_type=LinkType.CREATE, link_label=entry.link_label) new_node.store() - def get_hash(self, ignore_errors=True, **kwargs): + def get_hash(self, ignore_errors: bool = True, **kwargs: Any) -> Optional[str]: """Return the hash for this node based on its attributes. :param ignore_errors: return ``None`` on ``aiida.common.exceptions.HashingError`` (logging the exception) @@ -1074,7 +1167,7 @@ def get_hash(self, ignore_errors=True, **kwargs): return self._get_hash(ignore_errors=ignore_errors, **kwargs) - def _get_hash(self, ignore_errors=True, **kwargs): + def _get_hash(self, ignore_errors: bool = True, **kwargs: Any) -> Optional[str]: """ Return the hash for this node based on its attributes. @@ -1087,13 +1180,16 @@ def _get_hash(self, ignore_errors=True, **kwargs): except exceptions.HashingError: if not ignore_errors: raise - self.logger.exception('Node hashing failed') + if self.logger: + self.logger.exception('Node hashing failed') + return None - def _get_objects_to_hash(self): + def _get_objects_to_hash(self) -> List[Any]: """Return a list of objects which should be included in the hash.""" + assert self._repository is not None, 'repository not initialised' top_level_module = self.__module__.split('.', 1)[0] try: - version = importlib.import_module(top_level_module).__version__ + version = importlib.import_module(top_level_module).__version__ # type: ignore[attr-defined] except (ImportError, AttributeError) as exc: raise exceptions.HashingError("The node's package version could not be determined") from exc objects = [ @@ -1108,15 +1204,15 @@ def _get_objects_to_hash(self): ] return objects - def rehash(self): + def rehash(self) -> None: """Regenerate the stored hash of the Node.""" self.set_extra(_HASH_EXTRA_KEY, self.get_hash()) - def clear_hash(self): + def clear_hash(self) -> None: """Sets the stored hash of the Node to None.""" self.set_extra(_HASH_EXTRA_KEY, None) - def get_cache_source(self): + def get_cache_source(self) -> Optional[str]: """Return the UUID of the node that was used in creating this node from the cache, or None if it was not cached. :return: source node UUID or None @@ -1124,14 +1220,14 @@ def get_cache_source(self): return self.get_extra('_aiida_cached_from', None) @property - def is_created_from_cache(self): + def is_created_from_cache(self) -> bool: """Return whether this node was created from a cached node. :return: boolean, True if the node was created by cloning a cached node, False otherwise """ return self.get_cache_source() is not None - def _get_same_node(self): + def _get_same_node(self) -> Optional['Node']: """Returns a stored node from which the current Node can be cached or None if it does not exist If a node is returned it is a valid cache, meaning its `_aiida_hash` extra matches `self.get_hash()`. @@ -1148,7 +1244,7 @@ def _get_same_node(self): except StopIteration: return None - def get_all_same_nodes(self): + def get_all_same_nodes(self) -> List['Node']: """Return a list of stored nodes which match the type and hash of the current node. All returned nodes are valid caches, meaning their `_aiida_hash` extra matches `self.get_hash()`. @@ -1158,7 +1254,7 @@ def get_all_same_nodes(self): """ return list(self._iter_all_same_nodes()) - def _iter_all_same_nodes(self, allow_before_store=False): + def _iter_all_same_nodes(self, allow_before_store=False) -> Iterator['Node']: """ Returns an iterator of all same nodes. @@ -1179,22 +1275,21 @@ def _iter_all_same_nodes(self, allow_before_store=False): return (node for node in nodes_identical if node.is_valid_cache) @property - def is_valid_cache(self): + def is_valid_cache(self) -> bool: """Hook to exclude certain `Node` instances from being considered a valid cache.""" # pylint: disable=no-self-use return True - def get_description(self): + def get_description(self) -> str: """Return a string with a description of the node. :return: a description string - :rtype: str """ # pylint: disable=no-self-use return '' @staticmethod - def get_schema(): + def get_schema() -> Dict[str, Any]: """ Every node property contains: - display_name: display name of the property diff --git a/aiida/orm/nodes/process/__init__.py b/aiida/orm/nodes/process/__init__.py index 15e3dd6f03..4a84f892b0 100644 --- a/aiida/orm/nodes/process/__init__.py +++ b/aiida/orm/nodes/process/__init__.py @@ -14,4 +14,4 @@ from .process import * from .workflow import * -__all__ = (calculation.__all__ + process.__all__ + workflow.__all__) +__all__ = (calculation.__all__ + process.__all__ + workflow.__all__) # type: ignore[name-defined] diff --git a/aiida/orm/nodes/process/calculation/calcfunction.py b/aiida/orm/nodes/process/calculation/calcfunction.py index 9749283d5a..bc3bbf64c2 100644 --- a/aiida/orm/nodes/process/calculation/calcfunction.py +++ b/aiida/orm/nodes/process/calculation/calcfunction.py @@ -8,19 +8,23 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module with `Node` sub class for calculation function processes.""" +from typing import TYPE_CHECKING from aiida.common.links import LinkType from aiida.orm.utils.mixins import FunctionCalculationMixin from .calculation import CalculationNode +if TYPE_CHECKING: + from aiida.orm import Node + __all__ = ('CalcFunctionNode',) class CalcFunctionNode(FunctionCalculationMixin, CalculationNode): """ORM class for all nodes representing the execution of a calcfunction.""" - def validate_outgoing(self, target, link_type, link_label): + def validate_outgoing(self, target: 'Node', link_type: LinkType, link_label: str) -> None: """ Validate adding a link of the given type from ourself to a given node. diff --git a/aiida/orm/nodes/process/calculation/calcjob.py b/aiida/orm/nodes/process/calculation/calcjob.py index 3e5776d4d2..ccfa5d921a 100644 --- a/aiida/orm/nodes/process/calculation/calcjob.py +++ b/aiida/orm/nodes/process/calculation/calcjob.py @@ -8,17 +8,30 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module with `Node` sub class for calculation job processes.""" - +import datetime +from typing import Any, AnyStr, Dict, List, Optional, Sequence, Tuple, Type, Union +from typing import TYPE_CHECKING import warnings from aiida.common import exceptions from aiida.common.datastructures import CalcJobState from aiida.common.lang import classproperty from aiida.common.links import LinkType +from aiida.common.folders import Folder from aiida.common.warnings import AiidaDeprecationWarning from .calculation import CalculationNode +if TYPE_CHECKING: + from aiida.engine.processes.builder import ProcessBuilder + from aiida.orm import FolderData + from aiida.orm.authinfos import AuthInfo + from aiida.orm.utils.calcjob import CalcJobResultManager + from aiida.parsers import Parser + from aiida.schedulers.datastructures import JobInfo, JobState + from aiida.tools.calculations import CalculationTools + from aiida.transports import Transport + __all__ = ('CalcJobNode',) @@ -45,7 +58,7 @@ class CalcJobNode(CalculationNode): _tools = None @property - def tools(self): + def tools(self) -> 'CalculationTools': """Return the calculation tools that are registered for the process type associated with this calculation. If the entry point name stored in the `process_type` of the CalcJobNode has an accompanying entry point in the @@ -76,7 +89,7 @@ def tools(self): return self._tools @classproperty - def _updatable_attributes(cls): # pylint: disable=no-self-argument + def _updatable_attributes(cls) -> Tuple[str, ...]: # pylint: disable=no-self-argument return super()._updatable_attributes + ( cls.CALC_JOB_STATE_KEY, cls.REMOTE_WORKDIR_KEY, @@ -91,7 +104,7 @@ def _updatable_attributes(cls): # pylint: disable=no-self-argument ) @classproperty - def _hash_ignored_attributes(cls): # pylint: disable=no-self-argument + def _hash_ignored_attributes(cls) -> Tuple[str, ...]: # pylint: disable=no-self-argument return super()._hash_ignored_attributes + ( 'queue_name', 'account', @@ -101,7 +114,7 @@ def _hash_ignored_attributes(cls): # pylint: disable=no-self-argument 'max_memory_kb', ) - def _get_objects_to_hash(self): + def _get_objects_to_hash(self) -> List[Any]: """Return a list of objects which should be included in the hash. This method is purposefully overridden from the base `Node` class, because we do not want to include the @@ -112,7 +125,7 @@ def _get_objects_to_hash(self): """ from importlib import import_module objects = [ - import_module(self.__module__.split('.', 1)[0]).__version__, + import_module(self.__module__.split('.', 1)[0]).__version__, # type: ignore[attr-defined] { key: val for key, val in self.attributes_items() @@ -127,7 +140,7 @@ def _get_objects_to_hash(self): ] return objects - def get_builder_restart(self): + def get_builder_restart(self) -> 'ProcessBuilder': """Return a `ProcessBuilder` that is ready to relaunch the same `CalcJob` that created this node. The process class will be set based on the `process_type` of this node and the inputs of the builder will be @@ -137,15 +150,14 @@ def get_builder_restart(self): In addition to prepopulating the input nodes, which is implemented by the base `ProcessNode` class, here we also add the `options` that were passed in the `metadata` input of the `CalcJob` process. - :return: `~aiida.engine.processes.builder.ProcessBuilder` instance """ builder = super().get_builder_restart() - builder.metadata.options = self.get_options() + builder.metadata.options = self.get_options() # type: ignore[attr-defined] return builder @property - def _raw_input_folder(self): + def _raw_input_folder(self) -> Folder: """ Get the input folder object. @@ -154,13 +166,15 @@ def _raw_input_folder(self): """ from aiida.common.exceptions import NotExistent + assert self._repository is not None, 'repository not initialised' + return_folder = self._repository._get_base_folder() # pylint: disable=protected-access if return_folder.exists(): return return_folder raise NotExistent('the `_raw_input_folder` has not yet been created') - def get_option(self, name): + def get_option(self, name: str) -> Optional[Any]: """ Retun the value of an option that was set for this CalcJobNode @@ -170,7 +184,7 @@ def get_option(self, name): """ return self.get_attribute(name, None) - def set_option(self, name, value): + def set_option(self, name: str, value: Any) -> None: """ Set an option to the given value @@ -181,21 +195,21 @@ def set_option(self, name, value): """ self.set_attribute(name, value) - def get_options(self): + def get_options(self) -> Dict[str, Any]: """ Return the dictionary of options set for this CalcJobNode :return: dictionary of the options and their values """ options = {} - for name in self.process_class.spec_options.keys(): + for name in self.process_class.spec_options.keys(): # type: ignore[attr-defined] value = self.get_option(name) if value is not None: options[name] = value return options - def set_options(self, options): + def set_options(self, options: Dict[str, Any]) -> None: """ Set the options for this CalcJobNode @@ -204,7 +218,7 @@ def set_options(self, options): for name, value in options.items(): self.set_option(name, value) - def get_state(self): + def get_state(self) -> Optional[CalcJobState]: """Return the calculation job active sub state. The calculation job state serves to give more granular state information to `CalcJobs`, in addition to the @@ -223,10 +237,9 @@ def get_state(self): return state - def set_state(self, state): + def set_state(self, state: CalcJobState) -> None: """Set the calculation active job state. - :param state: a string with the state from ``aiida.common.datastructures.CalcJobState``. :raise: ValueError if state is invalid """ if not isinstance(state, CalcJobState): @@ -234,21 +247,21 @@ def set_state(self, state): self.set_attribute(self.CALC_JOB_STATE_KEY, state.value) - def delete_state(self): + def delete_state(self) -> None: """Delete the calculation job state attribute if it exists.""" try: self.delete_attribute(self.CALC_JOB_STATE_KEY) except AttributeError: pass - def set_remote_workdir(self, remote_workdir): + def set_remote_workdir(self, remote_workdir: str) -> None: """Set the absolute path to the working directory on the remote computer where the calculation is run. :param remote_workdir: absolute filepath to the remote working directory """ self.set_attribute(self.REMOTE_WORKDIR_KEY, remote_workdir) - def get_remote_workdir(self): + def get_remote_workdir(self) -> Optional[str]: """Return the path to the remote (on cluster) scratch folder of the calculation. :return: a string with the remote path @@ -256,10 +269,10 @@ def get_remote_workdir(self): return self.get_attribute(self.REMOTE_WORKDIR_KEY, None) @staticmethod - def _validate_retrieval_directive(directives): + def _validate_retrieval_directive(directives: Sequence[Union[str, Tuple[str, str, str]]]) -> None: """Validate a list or tuple of file retrieval directives. - :param directives: a list or tuple of file retrieveal directives + :param directives: a list or tuple of file retrieval directives :raise ValueError: if the format of the directives is invalid """ if not isinstance(directives, (tuple, list)): @@ -284,7 +297,7 @@ def _validate_retrieval_directive(directives): if not isinstance(directive[2], int): raise ValueError('invalid directive, three element has to be an integer representing the depth') - def set_retrieve_list(self, retrieve_list): + def set_retrieve_list(self, retrieve_list: Sequence[Union[str, Tuple[str, str, str]]]) -> None: """Set the retrieve list. This list of directives will instruct the daemon what files to retrieve after the calculation has completed. @@ -295,14 +308,14 @@ def set_retrieve_list(self, retrieve_list): self._validate_retrieval_directive(retrieve_list) self.set_attribute(self.RETRIEVE_LIST_KEY, retrieve_list) - def get_retrieve_list(self): + def get_retrieve_list(self) -> Optional[Sequence[Union[str, Tuple[str, str, str]]]]: """Return the list of files/directories to be retrieved on the cluster after the calculation has completed. :return: a list of file directives """ return self.get_attribute(self.RETRIEVE_LIST_KEY, None) - def set_retrieve_temporary_list(self, retrieve_temporary_list): + def set_retrieve_temporary_list(self, retrieve_temporary_list: Sequence[Union[str, Tuple[str, str, str]]]) -> None: """Set the retrieve temporary list. The retrieve temporary list stores files that are retrieved after completion and made available during parsing @@ -313,7 +326,7 @@ def set_retrieve_temporary_list(self, retrieve_temporary_list): self._validate_retrieval_directive(retrieve_temporary_list) self.set_attribute(self.RETRIEVE_TEMPORARY_LIST_KEY, retrieve_temporary_list) - def get_retrieve_temporary_list(self): + def get_retrieve_temporary_list(self) -> Optional[Sequence[Union[str, Tuple[str, str, str]]]]: """Return list of files to be retrieved from the cluster which will be available during parsing. :return: a list of file directives @@ -360,7 +373,7 @@ def get_retrieve_singlefile_list(self): """ return self.get_attribute(self.RETRIEVE_SINGLE_FILE_LIST_KEY, None) - def set_job_id(self, job_id): + def set_job_id(self, job_id: Union[int, str]) -> None: """Set the job id that was assigned to the calculation by the scheduler. .. note:: the id will always be stored as a string @@ -369,14 +382,14 @@ def set_job_id(self, job_id): """ return self.set_attribute(self.SCHEDULER_JOB_ID_KEY, str(job_id)) - def get_job_id(self): + def get_job_id(self) -> Optional[str]: """Return job id that was assigned to the calculation by the scheduler. :return: the string representation of the scheduler job id """ return self.get_attribute(self.SCHEDULER_JOB_ID_KEY, None) - def set_scheduler_state(self, state): + def set_scheduler_state(self, state: 'JobState') -> None: """Set the scheduler state. :param state: an instance of `JobState` @@ -390,7 +403,7 @@ def set_scheduler_state(self, state): self.set_attribute(self.SCHEDULER_STATE_KEY, state.value) self.set_attribute(self.SCHEDULER_LAST_CHECK_TIME_KEY, timezone.datetime_to_isoformat(timezone.now())) - def get_scheduler_state(self): + def get_scheduler_state(self) -> Optional['JobState']: """Return the status of the calculation according to the cluster scheduler. :return: a JobState enum instance. @@ -404,7 +417,7 @@ def get_scheduler_state(self): return JobState(state) - def get_scheduler_lastchecktime(self): + def get_scheduler_lastchecktime(self) -> Optional[datetime.datetime]: """Return the time of the last update of the scheduler state by the daemon or None if it was never set. :return: a datetime object or None @@ -417,14 +430,14 @@ def get_scheduler_lastchecktime(self): return value - def set_detailed_job_info(self, detailed_job_info): + def set_detailed_job_info(self, detailed_job_info: Optional[dict]) -> None: """Set the detailed job info dictionary. :param detailed_job_info: a dictionary with metadata with the accounting of a completed job """ self.set_attribute(self.SCHEDULER_DETAILED_JOB_INFO_KEY, detailed_job_info) - def get_detailed_job_info(self): + def get_detailed_job_info(self) -> Optional[dict]: """Return the detailed job info dictionary. The scheduler is polled for the detailed job info after the job is completed and ready to be retrieved. @@ -433,14 +446,14 @@ def get_detailed_job_info(self): """ return self.get_attribute(self.SCHEDULER_DETAILED_JOB_INFO_KEY, None) - def set_last_job_info(self, last_job_info): + def set_last_job_info(self, last_job_info: 'JobInfo') -> None: """Set the last job info. :param last_job_info: a `JobInfo` object """ self.set_attribute(self.SCHEDULER_LAST_JOB_INFO_KEY, last_job_info.get_dict()) - def get_last_job_info(self): + def get_last_job_info(self) -> Optional['JobInfo']: """Return the last information asked to the scheduler about the status of the job. The last job info is updated on every poll of the scheduler, except for the final poll when the job drops from @@ -462,28 +475,26 @@ def get_last_job_info(self): return job_info - def get_authinfo(self): + def get_authinfo(self) -> 'AuthInfo': """Return the `AuthInfo` that is configured for the `Computer` set for this node. :return: `AuthInfo` """ - from aiida.orm.authinfos import AuthInfo - computer = self.computer if computer is None: raise exceptions.NotExistent('No computer has been set for this calculation') - return AuthInfo.from_backend_entity(self.backend.authinfos.get(computer=computer, user=self.user)) + return computer.get_authinfo(self.user) - def get_transport(self): + def get_transport(self) -> 'Transport': """Return the transport for this calculation. :return: `Transport` configured with the `AuthInfo` associated to the computer of this node """ return self.get_authinfo().get_transport() - def get_parser_class(self): + def get_parser_class(self) -> Optional[Type['Parser']]: """Return the output parser object for this calculation or None if no parser is set. :return: a `Parser` class. @@ -499,11 +510,11 @@ def get_parser_class(self): return None @property - def link_label_retrieved(self): + def link_label_retrieved(self) -> str: """Return the link label used for the retrieved FolderData node.""" return 'retrieved' - def get_retrieved_node(self): + def get_retrieved_node(self) -> Optional['FolderData']: """Return the retrieved data folder. :return: the retrieved FolderData node or None if not found @@ -515,7 +526,7 @@ def get_retrieved_node(self): return None @property - def res(self): + def res(self) -> 'CalcJobResultManager': """ To be used to get direct access to the parsed parameters. @@ -528,7 +539,7 @@ def res(self): from aiida.orm.utils.calcjob import CalcJobResultManager return CalcJobResultManager(self) - def get_scheduler_stdout(self): + def get_scheduler_stdout(self) -> Optional[AnyStr]: """Return the scheduler stderr output if the calculation has finished and been retrieved, None otherwise. :return: scheduler stderr output or None @@ -546,7 +557,7 @@ def get_scheduler_stdout(self): return stdout - def get_scheduler_stderr(self): + def get_scheduler_stderr(self) -> Optional[AnyStr]: """Return the scheduler stdout output if the calculation has finished and been retrieved, None otherwise. :return: scheduler stdout output or None @@ -564,6 +575,9 @@ def get_scheduler_stderr(self): return stderr - def get_description(self): - """Return a string with a description of the node based on its properties.""" - return self.get_state() + def get_description(self) -> str: + """Return a description of the node based on its properties.""" + state = self.get_state() + if not state: + return '' + return state.value diff --git a/aiida/orm/nodes/process/calculation/calculation.py b/aiida/orm/nodes/process/calculation/calculation.py index 51a1c7d4d9..4dd8b9bf23 100644 --- a/aiida/orm/nodes/process/calculation/calculation.py +++ b/aiida/orm/nodes/process/calculation/calculation.py @@ -25,25 +25,23 @@ class CalculationNode(ProcessNode): _unstorable_message = 'storing for this node has been disabled' @property - def inputs(self): + def inputs(self) -> NodeLinksManager: """Return an instance of `NodeLinksManager` to manage incoming INPUT_CALC links The returned Manager allows you to easily explore the nodes connected to this node via an incoming INPUT_CALC link. The incoming nodes are reachable by their link labels which are attributes of the manager. - :return: `NodeLinksManager` """ return NodeLinksManager(node=self, link_type=LinkType.INPUT_CALC, incoming=True) @property - def outputs(self): + def outputs(self) -> NodeLinksManager: """Return an instance of `NodeLinksManager` to manage outgoing CREATE links The returned Manager allows you to easily explore the nodes connected to this node via an outgoing CREATE link. The outgoing nodes are reachable by their link labels which are attributes of the manager. - :return: `NodeLinksManager` """ return NodeLinksManager(node=self, link_type=LinkType.CREATE, incoming=False) diff --git a/aiida/orm/nodes/process/process.py b/aiida/orm/nodes/process/process.py index e78b6a4b8d..63409b4857 100644 --- a/aiida/orm/nodes/process/process.py +++ b/aiida/orm/nodes/process/process.py @@ -10,8 +10,10 @@ """Module with `Node` sub class for processes.""" import enum +from typing import Any, Dict, List, Optional, Tuple, Type, Union +from typing import TYPE_CHECKING -from plumpy import ProcessState +from plumpy.process_states import ProcessState from aiida.common.links import LinkType from aiida.common.lang import classproperty @@ -19,6 +21,10 @@ from ..node import Node +if TYPE_CHECKING: + from aiida.engine.processes import Process + from aiida.engine.processes.builder import ProcessBuilder + __all__ = ('ProcessNode',) @@ -48,7 +54,7 @@ class ProcessNode(Sealable, Node): _unstorable_message = 'only Data, WorkflowNode, CalculationNode or their subclasses can be stored' - def __str__(self): + def __str__(self) -> str: base = super().__str__() if self.process_type: return f'{base} ({self.process_type})' @@ -56,7 +62,7 @@ def __str__(self): return f'{base}' @classproperty - def _updatable_attributes(cls): + def _updatable_attributes(cls) -> Tuple[str, ...]: # pylint: disable=no-self-argument return super()._updatable_attributes + ( cls.PROCESS_PAUSED_KEY, @@ -79,7 +85,7 @@ def logger(self): from aiida.orm.utils.log import create_logger_adapter return create_logger_adapter(self._logger, self) - def get_builder_restart(self): + def get_builder_restart(self) -> 'ProcessBuilder': """Return a `ProcessBuilder` that is ready to relaunch the process that created this node. The process class will be set based on the `process_type` of this node and the inputs of the builder will be @@ -94,7 +100,7 @@ def get_builder_restart(self): return builder @property - def process_class(self): + def process_class(self) -> Type['Process']: """Return the process class that was used to create this node. :return: `Process` class @@ -128,7 +134,7 @@ def process_class(self): return process_class - def set_process_type(self, process_type_string): + def set_process_type(self, process_type_string: str) -> None: """ Set the process type string. @@ -137,7 +143,7 @@ def set_process_type(self, process_type_string): self.process_type = process_type_string @property - def process_label(self): + def process_label(self) -> Optional[str]: """ Return the process label @@ -145,7 +151,7 @@ def process_label(self): """ return self.get_attribute(self.PROCESS_LABEL_KEY, None) - def set_process_label(self, label): + def set_process_label(self, label: str) -> None: """ Set the process label @@ -154,7 +160,7 @@ def set_process_label(self, label): self.set_attribute(self.PROCESS_LABEL_KEY, label) @property - def process_state(self): + def process_state(self) -> Optional[ProcessState]: """ Return the process state @@ -167,7 +173,7 @@ def process_state(self): return ProcessState(state) - def set_process_state(self, state): + def set_process_state(self, state: Union[str, ProcessState]): """ Set the process state @@ -178,7 +184,7 @@ def set_process_state(self, state): return self.set_attribute(self.PROCESS_STATE_KEY, state) @property - def process_status(self): + def process_status(self) -> Optional[str]: """ Return the process status @@ -188,7 +194,7 @@ def process_status(self): """ return self.get_attribute(self.PROCESS_STATUS_KEY, None) - def set_process_status(self, status): + def set_process_status(self, status: Optional[str]) -> None: """ Set the process status @@ -210,7 +216,7 @@ def set_process_status(self, status): return self.set_attribute(self.PROCESS_STATUS_KEY, status) @property - def is_terminated(self): + def is_terminated(self) -> bool: """ Return whether the process has terminated @@ -222,7 +228,7 @@ def is_terminated(self): return self.is_excepted or self.is_finished or self.is_killed @property - def is_excepted(self): + def is_excepted(self) -> bool: """ Return whether the process has excepted @@ -234,7 +240,7 @@ def is_excepted(self): return self.process_state == ProcessState.EXCEPTED @property - def is_killed(self): + def is_killed(self) -> bool: """ Return whether the process was killed @@ -246,7 +252,7 @@ def is_killed(self): return self.process_state == ProcessState.KILLED @property - def is_finished(self): + def is_finished(self) -> bool: """ Return whether the process has finished @@ -259,7 +265,7 @@ def is_finished(self): return self.process_state == ProcessState.FINISHED @property - def is_finished_ok(self): + def is_finished_ok(self) -> bool: """ Return whether the process has finished successfully @@ -271,7 +277,7 @@ def is_finished_ok(self): return self.is_finished and self.exit_status == 0 @property - def is_failed(self): + def is_failed(self) -> bool: """ Return whether the process has failed @@ -283,7 +289,7 @@ def is_failed(self): return self.is_finished and self.exit_status != 0 @property - def exit_status(self): + def exit_status(self) -> Optional[int]: """ Return the exit status of the process @@ -291,7 +297,7 @@ def exit_status(self): """ return self.get_attribute(self.EXIT_STATUS_KEY, None) - def set_exit_status(self, status): + def set_exit_status(self, status: Union[None, enum.Enum, int]) -> None: """ Set the exit status of the process @@ -309,7 +315,7 @@ def set_exit_status(self, status): return self.set_attribute(self.EXIT_STATUS_KEY, status) @property - def exit_message(self): + def exit_message(self) -> Optional[str]: """ Return the exit message of the process @@ -317,7 +323,7 @@ def exit_message(self): """ return self.get_attribute(self.EXIT_MESSAGE_KEY, None) - def set_exit_message(self, message): + def set_exit_message(self, message: Optional[str]) -> None: """ Set the exit message of the process, if None nothing will be done @@ -332,7 +338,7 @@ def set_exit_message(self, message): return self.set_attribute(self.EXIT_MESSAGE_KEY, message) @property - def exception(self): + def exception(self) -> Optional[str]: """ Return the exception of the process or None if the process is not excepted. @@ -345,7 +351,7 @@ def exception(self): return None - def set_exception(self, exception): + def set_exception(self, exception: str) -> None: """ Set the exception of the process @@ -357,7 +363,7 @@ def set_exception(self, exception): return self.set_attribute(self.EXCEPTION_KEY, exception) @property - def checkpoint(self): + def checkpoint(self) -> Optional[Dict[str, Any]]: """ Return the checkpoint bundle set for the process @@ -365,7 +371,7 @@ def checkpoint(self): """ return self.get_attribute(self.CHECKPOINT_KEY, None) - def set_checkpoint(self, checkpoint): + def set_checkpoint(self, checkpoint: Dict[str, Any]) -> None: """ Set the checkpoint bundle set for the process @@ -373,7 +379,7 @@ def set_checkpoint(self, checkpoint): """ return self.set_attribute(self.CHECKPOINT_KEY, checkpoint) - def delete_checkpoint(self): + def delete_checkpoint(self) -> None: """ Delete the checkpoint bundle set for the process """ @@ -383,7 +389,7 @@ def delete_checkpoint(self): pass @property - def paused(self): + def paused(self) -> bool: """ Return whether the process is paused @@ -391,7 +397,7 @@ def paused(self): """ return self.get_attribute(self.PROCESS_PAUSED_KEY, False) - def pause(self): + def pause(self) -> None: """ Mark the process as paused by setting the corresponding attribute. @@ -400,7 +406,7 @@ def pause(self): """ return self.set_attribute(self.PROCESS_PAUSED_KEY, True) - def unpause(self): + def unpause(self) -> None: """ Mark the process as unpaused by removing the corresponding attribute. @@ -413,7 +419,7 @@ def unpause(self): pass @property - def called(self): + def called(self) -> List['ProcessNode']: """ Return a list of nodes that the process called @@ -422,7 +428,7 @@ def called(self): return self.get_outgoing(link_type=(LinkType.CALL_CALC, LinkType.CALL_WORK)).all_nodes() @property - def called_descendants(self): + def called_descendants(self) -> List['ProcessNode']: """ Return a list of all nodes that have been called downstream of this process @@ -437,7 +443,7 @@ def called_descendants(self): return descendants @property - def caller(self): + def caller(self) -> Optional['ProcessNode']: """ Return the process node that called this process node, or None if it does not have a caller @@ -450,7 +456,7 @@ def caller(self): else: return caller - def validate_incoming(self, source, link_type, link_label): + def validate_incoming(self, source: Node, link_type: LinkType, link_label: str) -> None: """Validate adding a link of the given type from a given node to ourself. Adding an input link to a `ProcessNode` once it is stored is illegal because this should be taken care of @@ -468,7 +474,7 @@ def validate_incoming(self, source, link_type, link_label): raise ValueError('attempted to add an input link after the process node was already stored.') @property - def is_valid_cache(self): + def is_valid_cache(self) -> bool: """ Return whether the node is valid for caching @@ -490,7 +496,7 @@ def is_valid_cache(self): return is_valid_cache_func(self) - def _get_objects_to_hash(self): + def _get_objects_to_hash(self) -> List[Any]: """ Return a list of objects which should be included in the hash. """ diff --git a/aiida/orm/nodes/process/workflow/workchain.py b/aiida/orm/nodes/process/workflow/workchain.py index 1c4e7a01be..07f0f8a0b3 100644 --- a/aiida/orm/nodes/process/workflow/workchain.py +++ b/aiida/orm/nodes/process/workflow/workchain.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module with `Node` sub class for workchain processes.""" +from typing import Optional, Tuple from aiida.common.lang import classproperty @@ -22,12 +23,12 @@ class WorkChainNode(WorkflowNode): STEPPER_STATE_INFO_KEY = 'stepper_state_info' @classproperty - def _updatable_attributes(cls): + def _updatable_attributes(cls) -> Tuple[str, ...]: # pylint: disable=no-self-argument return super()._updatable_attributes + (cls.STEPPER_STATE_INFO_KEY,) @property - def stepper_state_info(self): + def stepper_state_info(self) -> Optional[str]: """ Return the stepper state info @@ -35,7 +36,7 @@ def stepper_state_info(self): """ return self.get_attribute(self.STEPPER_STATE_INFO_KEY, None) - def set_stepper_state_info(self, stepper_state_info): + def set_stepper_state_info(self, stepper_state_info: str) -> None: """ Set the stepper state info diff --git a/aiida/orm/nodes/process/workflow/workflow.py b/aiida/orm/nodes/process/workflow/workflow.py index 1ccd20141a..8a48d1ba06 100644 --- a/aiida/orm/nodes/process/workflow/workflow.py +++ b/aiida/orm/nodes/process/workflow/workflow.py @@ -8,12 +8,16 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module with `Node` sub class for workflow processes.""" +from typing import TYPE_CHECKING from aiida.common.links import LinkType from aiida.orm.utils.managers import NodeLinksManager from ..process import ProcessNode +if TYPE_CHECKING: + from aiida.orm import Node + __all__ = ('WorkflowNode',) @@ -25,7 +29,7 @@ class WorkflowNode(ProcessNode): _unstorable_message = 'storing for this node has been disabled' @property - def inputs(self): + def inputs(self) -> NodeLinksManager: """Return an instance of `NodeLinksManager` to manage incoming INPUT_WORK links The returned Manager allows you to easily explore the nodes connected to this node @@ -37,7 +41,7 @@ def inputs(self): return NodeLinksManager(node=self, link_type=LinkType.INPUT_WORK, incoming=True) @property - def outputs(self): + def outputs(self) -> NodeLinksManager: """Return an instance of `NodeLinksManager` to manage outgoing RETURN links The returned Manager allows you to easily explore the nodes connected to this node @@ -48,7 +52,7 @@ def outputs(self): """ return NodeLinksManager(node=self, link_type=LinkType.RETURN, incoming=False) - def validate_outgoing(self, target, link_type, link_label): + def validate_outgoing(self, target: 'Node', link_type: LinkType, link_label: str) -> None: """Validate adding a link of the given type from ourself to a given node. A workflow cannot 'create' Data, so if we receive an outgoing link to an unstored Data node, that means diff --git a/aiida/orm/nodes/process/workflow/workfunction.py b/aiida/orm/nodes/process/workflow/workfunction.py index 11de0f144d..c97dc1095b 100644 --- a/aiida/orm/nodes/process/workflow/workfunction.py +++ b/aiida/orm/nodes/process/workflow/workfunction.py @@ -8,19 +8,23 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module with `Node` sub class for workflow function processes.""" +from typing import TYPE_CHECKING from aiida.common.links import LinkType from aiida.orm.utils.mixins import FunctionCalculationMixin from .workflow import WorkflowNode +if TYPE_CHECKING: + from aiida.orm import Node + __all__ = ('WorkFunctionNode',) class WorkFunctionNode(FunctionCalculationMixin, WorkflowNode): """ORM class for all nodes representing the execution of a workfunction.""" - def validate_outgoing(self, target, link_type, link_label): + def validate_outgoing(self, target: 'Node', link_type: LinkType, link_label: str) -> None: """ Validate adding a link of the given type from ourself to a given node. diff --git a/docs/source/nitpick-exceptions b/docs/source/nitpick-exceptions index 845391ba44..06635a95c4 100644 --- a/docs/source/nitpick-exceptions +++ b/docs/source/nitpick-exceptions @@ -32,26 +32,36 @@ py:class aiida.engine.runners.ResultAndNode py:class aiida.engine.runners.ResultAndPk py:class aiida.engine.processes.workchains.workchain.WorkChainSpec py:class aiida.manage.manager.Manager +py:class aiida.orm.nodes.node.WarnWhenNotEntered py:class aiida.orm.utils.links.LinkQuadruple py:class aiida.tools.importexport.dbexport.ExportReport py:class aiida.tools.importexport.dbexport.ArchiveData py:class aiida.tools.groups.paths.WalkNodeResult -py:class Node -py:class ProcessSpec +py:class Backend +py:class BackendEntity +py:class BackendNode +py:class AuthInfo +py:class CalcJob py:class CalcJobNode +py:class Data py:class ExitCode +py:class File +py:class FolderData +py:class JobInfo +py:class JobState +py:class Node +py:class Parser +py:class PersistenceError py:class Process -py:class AuthInfo +py:class ProcessBuilder py:class ProcessNode +py:class ProcessSpec +py:class Port py:class PortNamespace py:class Runner +py:class Transport py:class TransportQueue -py:class PersistenceError -py:class Port -py:class Data -py:class JobInfo -py:class CalcJob py:class WorkChainSpec py:class kiwipy.communications.Communicator diff --git a/tests/test_calculation_node.py b/tests/test_calculation_node.py index 7fd83e89e6..0e80d1eef5 100644 --- a/tests/test_calculation_node.py +++ b/tests/test_calculation_node.py @@ -11,6 +11,7 @@ from aiida.backends.testbase import AiidaTestCase from aiida.common.exceptions import ModificationNotAllowed +from aiida.common.datastructures import CalcJobState from aiida.orm import CalculationNode, CalcJobNode @@ -117,7 +118,9 @@ def test_process_node_updatable_attribute(self): node.delete_attribute(CalculationNode.PROCESS_STATE_KEY) def test_get_description(self): - self.assertEqual(self.calcjob.get_description(), self.calcjob.get_state()) + self.assertEqual(self.calcjob.get_description(), '') + self.calcjob.set_state(CalcJobState.PARSING) + self.assertEqual(self.calcjob.get_description(), CalcJobState.PARSING.value) def test_get_authinfo(self): """Test that we can get the AuthInfo object from the calculation instance.""" From a01b3ffbcb8773ea405da241f597a40b0bd7b51c Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Mar 2021 14:52:30 +0100 Subject: [PATCH 105/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20`WorkChain.resolv?= =?UTF-8?q?e=5Fawaitable`=20(#4795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An alteration to a recent fix (#4773); `Process.has_terminated` is a method, not a property. --- aiida/engine/processes/workchains/workchain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiida/engine/processes/workchains/workchain.py b/aiida/engine/processes/workchains/workchain.py index f853dd1daf..4978e3594f 100644 --- a/aiida/engine/processes/workchains/workchain.py +++ b/aiida/engine/processes/workchains/workchain.py @@ -167,7 +167,7 @@ def resolve_awaitable(self, awaitable: Awaitable, value: Any) -> None: awaitable.resolved = True - if not self.has_terminated: + if not self.has_terminated(): # the process may be terminated, for example, if the process was killed or excepted # then we should not try to update it self._update_process_status() From b3f7080e5e11e9e323b81b7b9cc2d25d6386b498 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 9 Mar 2021 23:52:50 +0100 Subject: [PATCH 106/114] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20`Task.cancel`=20s?= =?UTF-8?q?hould=20not=20set=20state=20as=20EXCEPTED=20(#4792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, stopping the daemon in python 3.7 excepts all processes. This is due to the code in `shutdown_runner`, which cancels all asyncio tasks running on the loop, including process continue and transport tasks. Cancelling a task raises an `asyncio.CancellErrror`. In python 3.8+ this exception only inherits from `BaseException`, and so is not caught by any `except Exception` "checkpoints" in plumpy/aiida-core. In python <= 3.7 however, the exception is equal to `concurrent.futures.CancelledError`, and so it was caught by one of: `Process.step`, `Running.execute` or `ProcessLauncher.handle_continue_exception` and the process was set to an excepted state. Ideally in the long-term, we will alter `shutdown_runner`, to not use such a "brute-force" mechanism. But in the short-term term this commit directly fixes the issue, by re-raising the `asyncio.CancelledError` exception. --- aiida/engine/processes/calcjobs/tasks.py | 6 ++++- aiida/engine/transports.py | 5 ++++ aiida/manage/external/rmq.py | 5 ++++ environment.yml | 4 +-- requirements/requirements-py-3.7.txt | 4 +-- requirements/requirements-py-3.8.txt | 4 +-- requirements/requirements-py-3.9.txt | 4 +-- setup.json | 4 +-- tests/engine/test_daemon.py | 33 +++++++++++++++++++++--- 9 files changed, 55 insertions(+), 14 deletions(-) diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index e1a9e7d2b5..8e57fb5db5 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Transport tasks for calculation jobs.""" +import asyncio import functools import logging import tempfile @@ -406,7 +407,10 @@ async def execute(self) -> plumpy.process_states.State: # type: ignore[override else: logger.warning(f'killed CalcJob<{node.pk}> but async future was None') raise - except (plumpy.process_states.Interruption, plumpy.futures.CancelledError): + except (plumpy.futures.CancelledError, asyncio.CancelledError): + node.set_process_status(f'Transport task {command} was cancelled') + raise + except plumpy.process_states.Interruption: node.set_process_status(f'Transport task {command} was interrupted') raise else: diff --git a/aiida/engine/transports.py b/aiida/engine/transports.py index b722140834..d301235e27 100644 --- a/aiida/engine/transports.py +++ b/aiida/engine/transports.py @@ -108,6 +108,11 @@ def do_open(): try: transport_request.count += 1 yield transport_request.future + except asyncio.CancelledError: # pylint: disable=try-except-raise + # note this is only required in python<=3.7, + # where asyncio.CancelledError inherits from Exception + _LOGGER.debug('Transport task cancelled') + raise except Exception: _LOGGER.error('Exception whilst using transport:\n%s', traceback.format_exc()) raise diff --git a/aiida/manage/external/rmq.py b/aiida/manage/external/rmq.py index f2069603ab..c7cccfd149 100644 --- a/aiida/manage/external/rmq.py +++ b/aiida/manage/external/rmq.py @@ -9,6 +9,7 @@ ########################################################################### # pylint: disable=cyclic-import """Components to communicate tasks to RabbitMQ.""" +import asyncio from collections.abc import Mapping import logging import traceback @@ -209,6 +210,10 @@ async def _continue(self, communicator, pid, nowait, tag=None): message = 'the class of the process could not be imported.' self.handle_continue_exception(node, exception, message) raise + except asyncio.CancelledError: # pylint: disable=try-except-raise + # note this is only required in python<=3.7, + # where asyncio.CancelledError inherits from Exception + raise except Exception as exception: message = 'failed to recreate the process instance in order to continue it.' self.handle_continue_exception(node, exception, message) diff --git a/environment.yml b/environment.yml index 09b03959f8..1f5d72c388 100644 --- a/environment.yml +++ b/environment.yml @@ -21,11 +21,11 @@ dependencies: - ipython~=7.20 - jinja2~=2.10 - jsonschema~=3.0 -- kiwipy[rmq]~=0.7.3 +- kiwipy[rmq]~=0.7.4 - numpy~=1.17 - pamqp~=2.3 - paramiko>=2.7.2,~=2.7 -- plumpy~=0.18.6 +- plumpy~=0.19.0 - pgsu~=0.1.0 - psutil~=5.6 - psycopg2>=2.8.3,~=2.8 diff --git a/requirements/requirements-py-3.7.txt b/requirements/requirements-py-3.7.txt index 49c846ca68..b1decfa255 100644 --- a/requirements/requirements-py-3.7.txt +++ b/requirements/requirements-py-3.7.txt @@ -57,7 +57,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.3 +kiwipy==0.7.4 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -88,7 +88,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.6 +plumpy==0.19.0 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/requirements/requirements-py-3.8.txt b/requirements/requirements-py-3.8.txt index 8ab1f0cd09..4d2326794d 100644 --- a/requirements/requirements-py-3.8.txt +++ b/requirements/requirements-py-3.8.txt @@ -56,7 +56,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.3 +kiwipy==0.7.4 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -87,7 +87,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.6 +plumpy==0.19.0 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/requirements/requirements-py-3.9.txt b/requirements/requirements-py-3.9.txt index f050094ca4..5bcba80782 100644 --- a/requirements/requirements-py-3.9.txt +++ b/requirements/requirements-py-3.9.txt @@ -56,7 +56,7 @@ jupyter-console==6.2.0 jupyter-core==4.7.1 jupyterlab-pygments==0.1.2 jupyterlab-widgets==1.0.0 -kiwipy==0.7.3 +kiwipy==0.7.4 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==1.1.1 @@ -87,7 +87,7 @@ pickleshare==0.7.5 Pillow==8.1.0 plotly==4.14.3 pluggy==0.13.1 -plumpy==0.18.6 +plumpy==0.19.0 prometheus-client==0.9.0 prompt-toolkit==3.0.14 psutil==5.8.0 diff --git a/setup.json b/setup.json index 9dac042c00..1c62482f47 100644 --- a/setup.json +++ b/setup.json @@ -35,11 +35,11 @@ "ipython~=7.20", "jinja2~=2.10", "jsonschema~=3.0", - "kiwipy[rmq]~=0.7.3", + "kiwipy[rmq]~=0.7.4", "numpy~=1.17", "pamqp~=2.3", "paramiko~=2.7,>=2.7.2", - "plumpy~=0.18.6", + "plumpy~=0.19.0", "pgsu~=0.1.0", "psutil~=5.6", "psycopg2-binary~=2.8,>=2.8.3", diff --git a/tests/engine/test_daemon.py b/tests/engine/test_daemon.py index fd9c64ff7b..53f6b4a20b 100644 --- a/tests/engine/test_daemon.py +++ b/tests/engine/test_daemon.py @@ -8,8 +8,35 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Test daemon module.""" -from aiida.backends.testbase import AiidaTestCase +import asyncio +from plumpy.process_states import ProcessState +import pytest -class TestDaemon(AiidaTestCase): - """Testing the daemon.""" +from aiida.manage.manager import get_manager +from tests.utils import processes as test_processes + + +async def reach_waiting_state(process): + while process.state != ProcessState.WAITING: + await asyncio.sleep(0.1) + + +@pytest.mark.usefixtures('clear_database_before_test') +def test_cancel_process_task(): + """This test is designed to replicate how processes are cancelled in the current `shutdown_runner` callback. + + The `CancelledError` should bubble up to the caller, and not be caught and transition the process to excepted. + """ + runner = get_manager().get_runner() + # create the process and start it running + process = runner.instantiate_process(test_processes.WaitProcess) + task = runner.loop.create_task(process.step_until_terminated()) + # wait for the process to reach a WAITING state + runner.loop.run_until_complete(asyncio.wait_for(reach_waiting_state(process), 5.0)) + # cancel the task and wait for the cancellation + task.cancel() + with pytest.raises(asyncio.CancelledError): + runner.loop.run_until_complete(asyncio.wait_for(task, 5.0)) + # the node should still record a waiting state, not excepted + assert process.node.process_state == ProcessState.WAITING From 241f251bd00ee3e2506a7d2a5f82dbd765d717ef Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 10 Mar 2021 15:00:37 +0100 Subject: [PATCH 107/114] Docs: fix the citation links on the index page (#4800) The links were still using markdown syntax instead of restructured text. --- docs/source/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index f58610009c..1a21108347 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -110,11 +110,11 @@ How to cite If you use AiiDA for your research, please cite the following work: -.. highlights:: **AiiDA >= 1.0:** Sebastiaan. P. Huber, Spyros Zoupanos, Martin Uhrin, Leopold Talirz, Leonid Kahle, Rico Häuselmann, Dominik Gresch, Tiziano Müller, Aliaksandr V. Yakutovich, Casper W. Andersen, Francisco F. Ramirez, Carl S. Adorf, Fernando Gargiulo, Snehal Kumbhar, Elsa Passaro, Conrad Johnston, Andrius Merkys, Andrea Cepellotti, Nicolas Mounet, Nicola Marzari, Boris Kozinsky, and Giovanni Pizzi, *AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and data provenance*, Scientific Data **7**, 300 (2020); DOI: [10.1038/s41597-020-00638-4](https://doi.org/10.1038/s41597-020-00638-4) +.. highlights:: **AiiDA >= 1.0:** Sebastiaan. P. Huber, Spyros Zoupanos, Martin Uhrin, Leopold Talirz, Leonid Kahle, Rico Häuselmann, Dominik Gresch, Tiziano Müller, Aliaksandr V. Yakutovich, Casper W. Andersen, Francisco F. Ramirez, Carl S. Adorf, Fernando Gargiulo, Snehal Kumbhar, Elsa Passaro, Conrad Johnston, Andrius Merkys, Andrea Cepellotti, Nicolas Mounet, Nicola Marzari, Boris Kozinsky, and Giovanni Pizzi, *AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and data provenance*, Scientific Data **7**, 300 (2020); DOI: `10.1038/s41597-020-00638-4 `_ -.. highlights:: **AiiDA >= 1.0:** Martin Uhrin, Sebastiaan. P. Huber, Jusong Yu, Nicola Marzari, and Giovanni Pizzi, *Workflows in AiiDA: Engineering a high-throughput, event-based engine for robust and modular computational workflows*, Computational Materials Science **187**, 110086 (2021); DOI: [10.1016/j.commatsci.2020.110086](https://doi.org/10.1016/j.commatsci.2020.110086) +.. highlights:: **AiiDA >= 1.0:** Martin Uhrin, Sebastiaan. P. Huber, Jusong Yu, Nicola Marzari, and Giovanni Pizzi, *Workflows in AiiDA: Engineering a high-throughput, event-based engine for robust and modular computational workflows*, Computational Materials Science **187**, 110086 (2021); DOI: `10.1016/j.commatsci.2020.110086 `_ -.. highlights:: **AiiDA < 1.0:** Giovanni Pizzi, Andrea Cepellotti, Riccardo Sabatini, Nicola Marzari, and Boris Kozinsky, *AiiDA: automated interactive infrastructure and database for computational science*, Computational Materials Science **111**, 218-230 (2016); DOI: [10.1016/j.commatsci.2015.09.013](https://doi.org/10.1016/j.commatsci.2015.09.013) +.. highlights:: **AiiDA < 1.0:** Giovanni Pizzi, Andrea Cepellotti, Riccardo Sabatini, Nicola Marzari, and Boris Kozinsky, *AiiDA: automated interactive infrastructure and database for computational science*, Computational Materials Science **111**, 218-230 (2016); DOI: `10.1016/j.commatsci.2015.09.013 `_ **************** From 9359b079bc49cec6d3f1e871ec856b71928c0696 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 10 Mar 2021 15:30:51 +0100 Subject: [PATCH 108/114] `CalcJob`: add the option to stash files after job completion (#4424) A new namespace `stash` is added to the `metadata.options` input namespace of the `CalcJob` process. This option namespace allows a user to specify certain files that are created by the calculation job to be stashed somewhere on the remote. This can be useful if those files need to be stored for a longer time than the scratch space where the job was run is typically not cleaned for, but need to be kept on the remote machine and not retrieved. Examples are files that are necessary to restart a calculation but are too big to be retrieved and stored permanently in the local file repository. The files that are to be stashed are specified through their relative filepaths within the working directory in the `stash.source_list` option. For now, the only supported option is to have AiiDA's engine copy the files to another location on the same filesystem as the working directory of the calculation job. The base path is defined through the `stash.target_base` option. In the future, other methods may be implemented, such as placing all files in a (compressed) tarball or even stash files on tape. Which mode is to be used is communicated through the enum `aiida.common.datastructures.StashMode` which for now therefore only has the `COPY` value. If the `stash` option namespace is defined for a calculation job, the daemon will perform the stashing operations before the files are retrieved. This also means that the stashing also happens before the parsing of the output files (which occurs after the retrieving step) which means that the files will be stashed independent of the final exit status that the parser will assign to the calculation job. This may cause files to be stashed of calculations that will later be considered to have failed. However, the stashed files can always be deleted manually by the user afterwards if needed. Finally, the stashed files are represented by an output node that is attached to the calculation node through the label `remote_stash`. Just like the `remote_folder` node, this represents a location or files on a remote machine and so is merely a "symbolic link" of sorts. AiiDA does not actually own the files and the contents may disappear at some point. To be able to distinguish the stashed folder from the remote folder, a new data plugin is used, the `RemoteStashFolderData`. The base class is `RemoteStashData` which is not instantiable, but will merely serve as a base class for future subclasses, one for each `StashMode` value. The reason is that the way files need to be accessed depend on the way they were stashed and so it is good to have separate classes for this. It was considered to give `RemoteFolderData` and `RemoteData` the same base class (changing the type of the `remote_folder` to a new subclass `RemoteFolderData`) but this would introduce breaking changes and so this was relegated to a potential future major release. --- .github/system_tests/test_daemon.py | 23 +++- aiida/common/datastructures.py | 9 +- aiida/engine/daemon/execmanager.py | 59 ++++++++++ aiida/engine/processes/calcjobs/calcjob.py | 67 +++++++++--- aiida/engine/processes/calcjobs/tasks.py | 101 ++++++++++++++---- aiida/orm/nodes/data/__init__.py | 7 +- aiida/orm/nodes/data/remote/__init__.py | 6 ++ .../nodes/data/{remote.py => remote/base.py} | 16 +-- aiida/orm/nodes/data/remote/stash/__init__.py | 6 ++ aiida/orm/nodes/data/remote/stash/base.py | 52 +++++++++ aiida/orm/nodes/data/remote/stash/folder.py | 66 ++++++++++++ setup.json | 4 +- tests/engine/processes/test_builder.py | 12 +-- tests/engine/test_calc_job.py | 41 ++++++- tests/orm/data/test_remote_stash.py | 68 ++++++++++++ 15 files changed, 481 insertions(+), 56 deletions(-) create mode 100644 aiida/orm/nodes/data/remote/__init__.py rename aiida/orm/nodes/data/{remote.py => remote/base.py} (95%) create mode 100644 aiida/orm/nodes/data/remote/stash/__init__.py create mode 100644 aiida/orm/nodes/data/remote/stash/base.py create mode 100644 aiida/orm/nodes/data/remote/stash/folder.py create mode 100644 tests/orm/data/test_remote_stash.py diff --git a/.github/system_tests/test_daemon.py b/.github/system_tests/test_daemon.py index 631d7a1784..e91112095f 100644 --- a/.github/system_tests/test_daemon.py +++ b/.github/system_tests/test_daemon.py @@ -9,11 +9,14 @@ ########################################################################### # pylint: disable=no-name-in-module """Tests to run with a running daemon.""" +import os +import shutil import subprocess import sys +import tempfile import time -from aiida.common import exceptions +from aiida.common import exceptions, StashMode from aiida.engine import run, submit from aiida.engine.daemon.client import get_daemon_client from aiida.engine.persistence import ObjectLoader @@ -415,6 +418,24 @@ def launch_all(): print('Running the `MultiplyAddWorkChain`') run_multiply_add_workchain() + # Testing the stashing functionality + process, inputs, expected_result = create_calculation_process(code=code_doubler, inputval=1) + with tempfile.TemporaryDirectory() as tmpdir: + + # Delete the temporary directory to test that the stashing functionality will create it if necessary + shutil.rmtree(tmpdir, ignore_errors=True) + + source_list = ['output.txt', 'triple_value.tmp'] + inputs['metadata']['options']['stash'] = {'target_base': tmpdir, 'source_list': source_list} + _, node = run.get_node(process, **inputs) + assert node.is_finished_ok + assert 'remote_stash' in node.outputs + remote_stash = node.outputs.remote_stash + assert remote_stash.stash_mode == StashMode.COPY + assert remote_stash.target_basepath.startswith(tmpdir) + assert sorted(remote_stash.source_list) == sorted(source_list) + assert sorted(p for p in os.listdir(remote_stash.target_basepath)) == sorted(source_list) + # Submitting the calcfunction through the launchers print('Submitting calcfunction to the daemon') proc, expected_result = launch_calcfunction(inputval=1) diff --git a/aiida/common/datastructures.py b/aiida/common/datastructures.py index 166c7d88d9..271cdaec48 100644 --- a/aiida/common/datastructures.py +++ b/aiida/common/datastructures.py @@ -12,7 +12,13 @@ from .extendeddicts import DefaultFieldsAttributeDict -__all__ = ('CalcJobState', 'CalcInfo', 'CodeInfo', 'CodeRunMode') +__all__ = ('StashMode', 'CalcJobState', 'CalcInfo', 'CodeInfo', 'CodeRunMode') + + +class StashMode(Enum): + """Mode to use when stashing files from the working directory of a completed calculation job for safekeeping.""" + + COPY = 'copy' class CalcJobState(Enum): @@ -21,6 +27,7 @@ class CalcJobState(Enum): UPLOADING = 'uploading' SUBMITTING = 'submitting' WITHSCHEDULER = 'withscheduler' + STASHING = 'stashing' RETRIEVING = 'retrieving' PARSING = 'parsing' diff --git a/aiida/engine/daemon/execmanager.py b/aiida/engine/daemon/execmanager.py index fa086e4d94..02dc638a99 100644 --- a/aiida/engine/daemon/execmanager.py +++ b/aiida/engine/daemon/execmanager.py @@ -332,6 +332,65 @@ def submit_calculation(calculation: CalcJobNode, transport: Transport) -> str: return job_id +def stash_calculation(calculation: CalcJobNode, transport: Transport) -> None: + """Stash files from the working directory of a completed calculation to a permanent remote folder. + + After a calculation has been completed, optionally stash files from the work directory to a storage location on the + same remote machine. This is useful if one wants to keep certain files from a completed calculation to be removed + from the scratch directory, because they are necessary for restarts, but that are too heavy to retrieve. + Instructions of which files to copy where are retrieved from the `stash.source_list` option. + + :param calculation: the calculation job node. + :param transport: an already opened transport. + """ + from aiida.common.datastructures import StashMode + from aiida.orm import RemoteStashFolderData + + logger_extra = get_dblogger_extra(calculation) + + stash_options = calculation.get_option('stash') + stash_mode = stash_options.get('mode', StashMode.COPY.value) + source_list = stash_options.get('source_list', []) + + if not source_list: + return + + if stash_mode != StashMode.COPY.value: + EXEC_LOGGER.warning(f'stashing mode {stash_mode} is not implemented yet.') + return + + cls = RemoteStashFolderData + + EXEC_LOGGER.debug(f'stashing files for calculation<{calculation.pk}>: {source_list}', extra=logger_extra) + + uuid = calculation.uuid + target_basepath = os.path.join(stash_options['target_base'], uuid[:2], uuid[2:4], uuid[4:]) + + for source_filename in source_list: + + source_filepath = os.path.join(calculation.get_remote_workdir(), source_filename) + target_filepath = os.path.join(target_basepath, source_filename) + + # If the source file is in a (nested) directory, create those directories first in the target directory + target_dirname = os.path.dirname(target_filepath) + transport.makedirs(target_dirname, ignore_existing=True) + + try: + transport.copy(source_filepath, target_filepath) + except (IOError, ValueError) as exception: + EXEC_LOGGER.warning(f'failed to stash {source_filepath} to {target_filepath}: {exception}') + else: + EXEC_LOGGER.debug(f'stashed {source_filepath} to {target_filepath}') + + remote_stash = cls( + computer=calculation.computer, + target_basepath=target_basepath, + stash_mode=StashMode(stash_mode), + source_list=source_list, + ).store() + remote_stash.add_incoming(calculation, link_type=LinkType.CREATE, link_label='remote_stash') + + def retrieve_calculation(calculation: CalcJobNode, transport: Transport, retrieved_temporary_folder: str) -> None: """Retrieve all the files of a completed job calculation using the given transport. diff --git a/aiida/engine/processes/calcjobs/calcjob.py b/aiida/engine/processes/calcjobs/calcjob.py index b0bd6bf174..f13a65a965 100644 --- a/aiida/engine/processes/calcjobs/calcjob.py +++ b/aiida/engine/processes/calcjobs/calcjob.py @@ -97,6 +97,33 @@ def validate_calc_job(inputs: Any, ctx: PortNamespace) -> Optional[str]: # pyli return None +def validate_stash_options(stash_options: Any, _: Any) -> Optional[str]: + """Validate the ``stash`` options.""" + from aiida.common.datastructures import StashMode + + target_base = stash_options.get('target_base', None) + source_list = stash_options.get('source_list', None) + stash_mode = stash_options.get('mode', StashMode.COPY.value) + + if not isinstance(target_base, str) or not os.path.isabs(target_base): + return f'`metadata.options.stash.target_base` should be an absolute filepath, got: {target_base}' + + if ( + not isinstance(source_list, (list, tuple)) or + any(not isinstance(src, str) or os.path.isabs(src) for src in source_list) + ): + port = 'metadata.options.stash.source_list' + return f'`{port}` should be a list or tuple of relative filepaths, got: {source_list}' + + try: + StashMode(stash_mode) + except ValueError: + port = 'metadata.options.stash.mode' + return f'`{port}` should be a member of aiida.common.datastructures.StashMode, got: {stash_mode}' + + return None + + def validate_parser(parser_name: Any, _: Any) -> Optional[str]: """Validate the parser. @@ -104,11 +131,10 @@ def validate_parser(parser_name: Any, _: Any) -> Optional[str]: """ from aiida.plugins import ParserFactory - if parser_name is not plumpy.ports.UNSPECIFIED: - try: - ParserFactory(parser_name) - except exceptions.EntryPointError as exception: - return f'invalid parser specified: {exception}' + try: + ParserFactory(parser_name) + except exceptions.EntryPointError as exception: + return f'invalid parser specified: {exception}' return None @@ -118,9 +144,6 @@ def validate_additional_retrieve_list(additional_retrieve_list: Any, _: Any) -> :return: string with error message in case the input is invalid. """ - if additional_retrieve_list is plumpy.ports.UNSPECIFIED: - return None - if any(not isinstance(value, str) or os.path.isabs(value) for value in additional_retrieve_list): return f'`additional_retrieve_list` should only contain relative filepaths but got: {additional_retrieve_list}' @@ -216,9 +239,21 @@ def define(cls, spec: CalcJobProcessSpec) -> None: # type: ignore[override] spec.input('metadata.options.additional_retrieve_list', required=False, valid_type=(list, tuple), validator=validate_additional_retrieve_list, help='List of relative file paths that should be retrieved in addition to what the plugin specifies.') + spec.input_namespace('metadata.options.stash', required=False, populate_defaults=False, + validator=validate_stash_options, + help='Optional directives to stash files after the calculation job has completed.') + spec.input('metadata.options.stash.target_base', valid_type=str, required=False, + help='The base location to where the files should be stashd. For example, for the `copy` stash mode, this ' + 'should be an absolute filepath on the remote computer.') + spec.input('metadata.options.stash.source_list', valid_type=(tuple, list), required=False, + help='Sequence of relative filepaths representing files in the remote directory that should be stashed.') + spec.input('metadata.options.stash.stash_mode', valid_type=str, required=False, + help='Mode with which to perform the stashing, should be value of `aiida.common.datastructures.StashMode.') spec.output('remote_folder', valid_type=orm.RemoteData, help='Input files necessary to run the process will be stored in this folder node.') + spec.output('remote_stash', valid_type=orm.RemoteStashData, required=False, + help='Contents of the `stash.source_list` option are stored in this remote folder after job completion.') spec.output(cls.link_label_retrieved, valid_type=orm.FolderData, pass_to_parser=True, help='Files that are retrieved by the daemon will be stored in this node. By default the stdout and stderr ' 'of the scheduler will be added, but one can add more by specifying them in `CalcInfo.retrieve_list`.') @@ -653,29 +688,29 @@ def presubmit(self, folder: Folder) -> CalcInfo: local_copy_list = calc_info.local_copy_list try: validate_list_of_string_tuples(local_copy_list, tuple_length=3) - except ValidationError as exc: + except ValidationError as exception: raise PluginInternalError( - f'[presubmission of calc {this_pk}] local_copy_list format problem: {exc}' - ) from exc + f'[presubmission of calc {this_pk}] local_copy_list format problem: {exception}' + ) from exception remote_copy_list = calc_info.remote_copy_list try: validate_list_of_string_tuples(remote_copy_list, tuple_length=3) - except ValidationError as exc: + except ValidationError as exception: raise PluginInternalError( - f'[presubmission of calc {this_pk}] remote_copy_list format problem: {exc}' - ) from exc + f'[presubmission of calc {this_pk}] remote_copy_list format problem: {exception}' + ) from exception for (remote_computer_uuid, _, dest_rel_path) in remote_copy_list: try: Computer.objects.get(uuid=remote_computer_uuid) # pylint: disable=unused-variable - except exceptions.NotExistent as exc: + except exceptions.NotExistent as exception: raise PluginInternalError( '[presubmission of calc {}] ' 'The remote copy requires a computer with UUID={}' 'but no such computer was found in the ' 'database'.format(this_pk, remote_computer_uuid) - ) from exc + ) from exception if os.path.isabs(dest_rel_path): raise PluginInternalError( '[presubmission of calc {}] ' diff --git a/aiida/engine/processes/calcjobs/tasks.py b/aiida/engine/processes/calcjobs/tasks.py index 8e57fb5db5..95fb4b0f8e 100644 --- a/aiida/engine/processes/calcjobs/tasks.py +++ b/aiida/engine/processes/calcjobs/tasks.py @@ -37,6 +37,7 @@ SUBMIT_COMMAND = 'submit' UPDATE_COMMAND = 'update' RETRIEVE_COMMAND = 'retrieve' +STASH_COMMAND = 'stash' KILL_COMMAND = 'kill' RETRY_INTERVAL_OPTION = 'transport.task_retry_initial_interval' @@ -100,9 +101,9 @@ async def do_upload(): raise except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): raise - except Exception: + except Exception as exception: logger.warning(f'uploading CalcJob<{node.pk}> failed') - raise TransportTaskException(f'upload_calculation failed {max_attempts} times consecutively') + raise TransportTaskException(f'upload_calculation failed {max_attempts} times consecutively') from exception else: logger.info(f'uploading CalcJob<{node.pk}> successful') node.set_state(CalcJobState.SUBMITTING) @@ -146,9 +147,9 @@ async def do_submit(): ) except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): # pylint: disable=try-except-raise raise - except Exception: + except Exception as exception: logger.warning(f'submitting CalcJob<{node.pk}> failed') - raise TransportTaskException(f'submit_calculation failed {max_attempts} times consecutively') + raise TransportTaskException(f'submit_calculation failed {max_attempts} times consecutively') from exception else: logger.info(f'submitting CalcJob<{node.pk}> successful') node.set_state(CalcJobState.WITHSCHEDULER) @@ -171,8 +172,10 @@ async def task_update_job(node: CalcJobNode, job_manager, cancellable: Interrupt :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` :return: True if the tasks was successfully completed, False otherwise """ - if node.get_state() == CalcJobState.RETRIEVING: - logger.warning(f'CalcJob<{node.pk}> already marked as RETRIEVING, skipping task_update_job') + state = node.get_state() + + if state in [CalcJobState.RETRIEVING, CalcJobState.STASHING]: + logger.warning(f'CalcJob<{node.pk}> already marked as `{state}`, skipping task_update_job') return True initial_interval = get_config_option(RETRY_INTERVAL_OPTION) @@ -205,13 +208,13 @@ async def do_update(): ) except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): # pylint: disable=try-except-raise raise - except Exception: + except Exception as exception: logger.warning(f'updating CalcJob<{node.pk}> failed') - raise TransportTaskException(f'update_calculation failed {max_attempts} times consecutively') + raise TransportTaskException(f'update_calculation failed {max_attempts} times consecutively') from exception else: logger.info(f'updating CalcJob<{node.pk}> successful') if job_done: - node.set_state(CalcJobState.RETRIEVING) + node.set_state(CalcJobState.STASHING) return job_done @@ -271,15 +274,65 @@ async def do_retrieve(): ) except (plumpy.futures.CancelledError, plumpy.process_states.Interruption): # pylint: disable=try-except-raise raise - except Exception: + except Exception as exception: logger.warning(f'retrieving CalcJob<{node.pk}> failed') - raise TransportTaskException(f'retrieve_calculation failed {max_attempts} times consecutively') + raise TransportTaskException(f'retrieve_calculation failed {max_attempts} times consecutively') from exception else: node.set_state(CalcJobState.PARSING) logger.info(f'retrieving CalcJob<{node.pk}> successful') return result +async def task_stash_job(node: CalcJobNode, transport_queue: TransportQueue, cancellable: InterruptableFuture): + """Transport task that will optionally stash files of a completed job calculation on the remote. + + The task will first request a transport from the queue. Once the transport is yielded, the relevant execmanager + function is called, wrapped in the exponential_backoff_retry coroutine, which, in case of a caught exception, will + retry after an interval that increases exponentially with the number of retries, for a maximum number of retries. + If all retries fail, the task will raise a TransportTaskException + + :param node: the node that represents the job calculation + :param transport_queue: the TransportQueue from which to request a Transport + :param cancellable: the cancelled flag that will be queried to determine whether the task was cancelled + :type cancellable: :class:`aiida.engine.utils.InterruptableFuture` + :raises: Return if the tasks was successfully completed + :raises: TransportTaskException if after the maximum number of retries the transport task still excepted + """ + if node.get_state() == CalcJobState.RETRIEVING: + logger.warning(f'calculation<{node.pk}> already marked as RETRIEVING, skipping task_stash_job') + return + + initial_interval = get_config_option(RETRY_INTERVAL_OPTION) + max_attempts = get_config_option(MAX_ATTEMPTS_OPTION) + + authinfo = node.get_authinfo() + + async def do_stash(): + with transport_queue.request_transport(authinfo) as request: + transport = await cancellable.with_interrupt(request) + + logger.info(f'stashing calculation<{node.pk}>') + return execmanager.stash_calculation(node, transport) + + try: + await exponential_backoff_retry( + do_stash, + initial_interval, + max_attempts, + logger=node.logger, + ignore_exceptions=plumpy.process_states.Interruption + ) + except plumpy.process_states.Interruption: + raise + except Exception as exception: + logger.warning(f'stashing calculation<{node.pk}> failed') + raise TransportTaskException(f'stash_calculation failed {max_attempts} times consecutively') from exception + else: + node.set_state(CalcJobState.RETRIEVING) + logger.info(f'stashing calculation<{node.pk}> successful') + return + + async def task_kill_job(node: CalcJobNode, transport_queue: TransportQueue, cancellable: InterruptableFuture): """Transport task that will attempt to kill a job calculation. @@ -313,9 +366,9 @@ async def do_kill(): result = await exponential_backoff_retry(do_kill, initial_interval, max_attempts, logger=node.logger) except plumpy.process_states.Interruption: raise - except Exception: + except Exception as exception: logger.warning(f'killing CalcJob<{node.pk}> failed') - raise TransportTaskException(f'kill_calculation failed {max_attempts} times consecutively') + raise TransportTaskException(f'kill_calculation failed {max_attempts} times consecutively') from exception else: logger.info(f'killing CalcJob<{node.pk}> successful') node.set_scheduler_state(JobState.DONE) @@ -353,11 +406,11 @@ def load_instance_state(self, saved_state, load_context): async def execute(self) -> plumpy.process_states.State: # type: ignore[override] # pylint: disable=invalid-overridden-method """Override the execute coroutine of the base `Waiting` state.""" - # pylint: disable=too-many-branches, too-many-statements + # pylint: disable=too-many-branches,too-many-statements node = self.process.node transport_queue = self.process.runner.transport - command = self.data result: plumpy.process_states.State = self + command = self.data process_status = f'Waiting for transport task: {command}' @@ -376,7 +429,7 @@ async def execute(self) -> plumpy.process_states.State: # type: ignore[override await self._launch_task(task_submit_job, node, transport_queue) result = self.update() - elif self.data == UPDATE_COMMAND: + elif command == UPDATE_COMMAND: job_done = False while not job_done: @@ -386,11 +439,18 @@ async def execute(self) -> plumpy.process_states.State: # type: ignore[override node.set_process_status(process_status) job_done = await self._launch_task(task_update_job, node, self.process.runner.job_manager) + if node.get_option('stash') is not None: + result = self.stash() + else: + result = self.retrieve() + + elif command == STASH_COMMAND: + node.set_process_status(process_status) + await self._launch_task(task_stash_job, node, transport_queue) result = self.retrieve() - elif self.data == RETRIEVE_COMMAND: + elif command == RETRIEVE_COMMAND: node.set_process_status(process_status) - # Create a temporary folder that has to be deleted by JobProcess.retrieved after successful parsing temp_folder = tempfile.mkdtemp() await self._launch_task(task_retrieve_job, node, transport_queue, temp_folder) result = self.parse(temp_folder) @@ -453,6 +513,11 @@ def retrieve(self) -> 'Waiting': ProcessState.WAITING, None, msg=msg, data=RETRIEVE_COMMAND ) # type: ignore[return-value] + def stash(self): + """Return the `Waiting` state that will `stash` the `CalcJob`.""" + msg = 'Waiting to stash' + return self.create_state(ProcessState.WAITING, None, msg=msg, data=STASH_COMMAND) + def parse(self, retrieved_temporary_folder: str) -> plumpy.process_states.Running: """Return the `Running` state that will parse the `CalcJob`. diff --git a/aiida/orm/nodes/data/__init__.py b/aiida/orm/nodes/data/__init__.py index 9734c08d2d..8ed0d10aa4 100644 --- a/aiida/orm/nodes/data/__init__.py +++ b/aiida/orm/nodes/data/__init__.py @@ -8,7 +8,6 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Module with `Node` sub classes for data structures.""" - from .array import ArrayData, BandsData, KpointsData, ProjectionData, TrajectoryData, XyData from .base import BaseType, to_aiida_type from .bool import Bool @@ -22,7 +21,7 @@ from .list import List from .numeric import NumericType from .orbital import OrbitalData -from .remote import RemoteData +from .remote import RemoteData, RemoteStashData, RemoteStashFolderData from .singlefile import SinglefileData from .str import Str from .structure import StructureData @@ -30,6 +29,6 @@ __all__ = ( 'Data', 'BaseType', 'ArrayData', 'BandsData', 'KpointsData', 'ProjectionData', 'TrajectoryData', 'XyData', 'Bool', - 'CifData', 'Code', 'Float', 'FolderData', 'Int', 'List', 'OrbitalData', 'Dict', 'RemoteData', 'SinglefileData', - 'Str', 'StructureData', 'UpfData', 'NumericType', 'to_aiida_type' + 'CifData', 'Code', 'Float', 'FolderData', 'Int', 'List', 'OrbitalData', 'Dict', 'RemoteData', 'RemoteStashData', + 'RemoteStashFolderData', 'SinglefileData', 'Str', 'StructureData', 'UpfData', 'NumericType', 'to_aiida_type' ) diff --git a/aiida/orm/nodes/data/remote/__init__.py b/aiida/orm/nodes/data/remote/__init__.py new file mode 100644 index 0000000000..2f88d7edbc --- /dev/null +++ b/aiida/orm/nodes/data/remote/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""Module with data plugins that represent remote resources and so effectively are symbolic links.""" +from .base import RemoteData +from .stash import RemoteStashData, RemoteStashFolderData + +__all__ = ('RemoteData', 'RemoteStashData', 'RemoteStashFolderData') diff --git a/aiida/orm/nodes/data/remote.py b/aiida/orm/nodes/data/remote/base.py similarity index 95% rename from aiida/orm/nodes/data/remote.py rename to aiida/orm/nodes/data/remote/base.py index ba8b8e52e0..b293e2e6b9 100644 --- a/aiida/orm/nodes/data/remote.py +++ b/aiida/orm/nodes/data/remote/base.py @@ -11,7 +11,7 @@ import os from aiida.orm import AuthInfo -from .data import Data +from ..data import Data __all__ = ('RemoteData',) @@ -79,7 +79,7 @@ def getfile(self, relpath, destpath): full_path, self.computer.label # pylint: disable=no-member ) - ) + ) from exception raise def listdir(self, relpath='.'): @@ -102,7 +102,7 @@ def listdir(self, relpath='.'): format(full_path, self.computer.label) # pylint: disable=no-member ) exc.errno = exception.errno - raise exc + raise exc from exception else: raise @@ -115,7 +115,7 @@ def listdir(self, relpath='.'): format(full_path, self.computer.label) # pylint: disable=no-member ) exc.errno = exception.errno - raise exc + raise exc from exception else: raise @@ -139,7 +139,7 @@ def listdir_withattributes(self, path='.'): format(full_path, self.computer.label) # pylint: disable=no-member ) exc.errno = exception.errno - raise exc + raise exc from exception else: raise @@ -152,7 +152,7 @@ def listdir_withattributes(self, path='.'): format(full_path, self.computer.label) # pylint: disable=no-member ) exc.errno = exception.errno - raise exc + raise exc from exception else: raise @@ -176,8 +176,8 @@ def _validate(self): try: self.get_remote_path() - except AttributeError: - raise ValidationError("attribute 'remote_path' not set.") + except AttributeError as exception: + raise ValidationError("attribute 'remote_path' not set.") from exception computer = self.computer if computer is None: diff --git a/aiida/orm/nodes/data/remote/stash/__init__.py b/aiida/orm/nodes/data/remote/stash/__init__.py new file mode 100644 index 0000000000..f744240cfc --- /dev/null +++ b/aiida/orm/nodes/data/remote/stash/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""Module with data plugins that represent files of completed calculations jobs that have been stashed.""" +from .base import RemoteStashData +from .folder import RemoteStashFolderData + +__all__ = ('RemoteStashData', 'RemoteStashFolderData') diff --git a/aiida/orm/nodes/data/remote/stash/base.py b/aiida/orm/nodes/data/remote/stash/base.py new file mode 100644 index 0000000000..f904643bab --- /dev/null +++ b/aiida/orm/nodes/data/remote/stash/base.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""Data plugin that models an archived folder on a remote computer.""" +from aiida.common.datastructures import StashMode +from aiida.common.lang import type_check +from ...data import Data + +__all__ = ('RemoteStashData',) + + +class RemoteStashData(Data): + """Data plugin that models an archived folder on a remote computer. + + A stashed folder is essentially an instance of ``RemoteData`` that has been archived. Archiving in this context can + simply mean copying the content of the folder to another location on the same or another filesystem as long as it is + on the same machine. In addition, the folder may have been compressed into a single file for efficiency or even + written to tape. The ``stash_mode`` attribute will distinguish how the folder was stashed which will allow the + implementation to also `unstash` it and transform it back into a ``RemoteData`` such that it can be used as an input + for new ``CalcJobs``. + + This class is a non-storable base class that merely registers the ``stash_mode`` attribute. Only its subclasses, + that actually implement a certain stash mode, can be instantiated and therefore stored. The reason for this design + is that because the behavior of the class can change significantly based on the mode employed to stash the files and + implementing all these variants in the same class will lead to an unintuitive interface where certain properties or + methods of the class will only be available or function properly based on the ``stash_mode``. + """ + + _storable = False + + def __init__(self, stash_mode: StashMode, **kwargs): + """Construct a new instance + + :param stash_mode: the stashing mode with which the data was stashed on the remote. + """ + super().__init__(**kwargs) + self.stash_mode = stash_mode + + @property + def stash_mode(self) -> StashMode: + """Return the mode with which the data was stashed on the remote. + + :return: the stash mode. + """ + return StashMode(self.get_attribute('stash_mode')) + + @stash_mode.setter + def stash_mode(self, value: StashMode): + """Set the mode with which the data was stashed on the remote. + + :param value: the stash mode. + """ + type_check(value, StashMode) + self.set_attribute('stash_mode', value.value) diff --git a/aiida/orm/nodes/data/remote/stash/folder.py b/aiida/orm/nodes/data/remote/stash/folder.py new file mode 100644 index 0000000000..7d7c00b2fc --- /dev/null +++ b/aiida/orm/nodes/data/remote/stash/folder.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""Data plugin that models a stashed folder on a remote computer.""" +import typing + +from aiida.common.datastructures import StashMode +from aiida.common.lang import type_check +from .base import RemoteStashData + +__all__ = ('RemoteStashFolderData',) + + +class RemoteStashFolderData(RemoteStashData): + """Data plugin that models a folder with files of a completed calculation job that has been stashed through a copy. + + This data plugin can and should be used to stash files if and only if the stash mode is `StashMode.COPY`. + """ + + _storable = True + + def __init__(self, stash_mode: StashMode, target_basepath: str, source_list: typing.List, **kwargs): + """Construct a new instance + + :param stash_mode: the stashing mode with which the data was stashed on the remote. + :param target_basepath: the target basepath. + :param source_list: the list of source files. + """ + super().__init__(stash_mode, **kwargs) + self.target_basepath = target_basepath + self.source_list = source_list + + if stash_mode != StashMode.COPY: + raise ValueError('`RemoteStashFolderData` can only be used with `stash_mode == StashMode.COPY`.') + + @property + def target_basepath(self) -> str: + """Return the target basepath. + + :return: the target basepath. + """ + return self.get_attribute('target_basepath') + + @target_basepath.setter + def target_basepath(self, value: str): + """Set the target basepath. + + :param value: the target basepath. + """ + type_check(value, str) + self.set_attribute('target_basepath', value) + + @property + def source_list(self) -> typing.Union[typing.List, typing.Tuple]: + """Return the list of source files that were stashed. + + :return: the list of source files. + """ + return self.get_attribute('source_list') + + @source_list.setter + def source_list(self, value: typing.Union[typing.List, typing.Tuple]): + """Set the list of source files that were stashed. + + :param value: the list of source files. + """ + type_check(value, (list, tuple)) + self.set_attribute('source_list', value) diff --git a/setup.json b/setup.json index 1c62482f47..d7cac88646 100644 --- a/setup.json +++ b/setup.json @@ -163,7 +163,9 @@ "list = aiida.orm.nodes.data.list:List", "numeric = aiida.orm.nodes.data.numeric:NumericType", "orbital = aiida.orm.nodes.data.orbital:OrbitalData", - "remote = aiida.orm.nodes.data.remote:RemoteData", + "remote = aiida.orm.nodes.data.remote.base:RemoteData", + "remote.stash = aiida.orm.nodes.data.remote.stash.base:RemoteStashData", + "remote.stash.folder = aiida.orm.nodes.data.remote.stash.folder:RemoteStashFolderData", "singlefile = aiida.orm.nodes.data.singlefile:SinglefileData", "str = aiida.orm.nodes.data.str:Str", "structure = aiida.orm.nodes.data.structure:StructureData", diff --git a/tests/engine/processes/test_builder.py b/tests/engine/processes/test_builder.py index aa7ad19b0b..239bc4984d 100644 --- a/tests/engine/processes/test_builder.py +++ b/tests/engine/processes/test_builder.py @@ -28,29 +28,29 @@ def test_access_methods(): builder = ProcessBuilder(ArithmeticAddCalculation) builder['x'] = node_numb - assert dict(builder) == {'metadata': {'options': {}}, 'x': node_numb} + assert dict(builder) == {'metadata': {'options': {'stash': {}}}, 'x': node_numb} del builder['x'] - assert dict(builder) == {'metadata': {'options': {}}} + assert dict(builder) == {'metadata': {'options': {'stash': {}}}} with pytest.raises(ValueError): builder['x'] = node_dict builder['x'] = node_numb - assert dict(builder) == {'metadata': {'options': {}}, 'x': node_numb} + assert dict(builder) == {'metadata': {'options': {'stash': {}}}, 'x': node_numb} # AS ATTRIBUTES del builder builder = ProcessBuilder(ArithmeticAddCalculation) builder.x = node_numb - assert dict(builder) == {'metadata': {'options': {}}, 'x': node_numb} + assert dict(builder) == {'metadata': {'options': {'stash': {}}}, 'x': node_numb} del builder.x - assert dict(builder) == {'metadata': {'options': {}}} + assert dict(builder) == {'metadata': {'options': {'stash': {}}}} with pytest.raises(ValueError): builder.x = node_dict builder.x = node_numb - assert dict(builder) == {'metadata': {'options': {}}, 'x': node_numb} + assert dict(builder) == {'metadata': {'options': {'stash': {}}}, 'x': node_numb} diff --git a/tests/engine/test_calc_job.py b/tests/engine/test_calc_job.py index 6b67541b80..75a611d8b9 100644 --- a/tests/engine/test_calc_job.py +++ b/tests/engine/test_calc_job.py @@ -19,9 +19,10 @@ from aiida import orm from aiida.backends.testbase import AiidaTestCase -from aiida.common import exceptions, LinkType, CalcJobState +from aiida.common import exceptions, LinkType, CalcJobState, StashMode from aiida.engine import launch, CalcJob, Process, ExitCode from aiida.engine.processes.ports import PortNamespace +from aiida.engine.processes.calcjobs.calcjob import validate_stash_options from aiida.plugins import CalculationFactory ArithmeticAddCalculation = CalculationFactory('arithmetic.add') # pylint: disable=invalid-name @@ -616,3 +617,41 @@ def test_additional_retrieve_list(generate_process, fixture_sandbox): with pytest.raises(ValueError, match=r'`additional_retrieve_list` should only contain relative filepaths.*'): process = generate_process({'metadata': {'options': {'additional_retrieve_list': ['/abs/path']}}}) + + +@pytest.mark.usefixtures('clear_database_before_test') +@pytest.mark.parametrize(('stash_options', 'expected'), ( + ({ + 'target_base': None + }, '`metadata.options.stash.target_base` should be'), + ({ + 'target_base': 'relative/path' + }, '`metadata.options.stash.target_base` should be'), + ({ + 'target_base': '/path' + }, '`metadata.options.stash.source_list` should be'), + ({ + 'target_base': '/path', + 'source_list': ['/abspath'] + }, '`metadata.options.stash.source_list` should be'), + ({ + 'target_base': '/path', + 'source_list': ['rel/path'], + 'mode': 'test' + }, '`metadata.options.stash.mode` should be'), + ({ + 'target_base': '/path', + 'source_list': ['rel/path'] + }, None), + ({ + 'target_base': '/path', + 'source_list': ['rel/path'], + 'mode': StashMode.COPY.value + }, None), +)) +def test_validate_stash_options(stash_options, expected): + """Test the ``validate_stash_options`` function.""" + if expected is None: + assert validate_stash_options(stash_options, None) is expected + else: + assert expected in validate_stash_options(stash_options, None) diff --git a/tests/orm/data/test_remote_stash.py b/tests/orm/data/test_remote_stash.py new file mode 100644 index 0000000000..45318ca1b3 --- /dev/null +++ b/tests/orm/data/test_remote_stash.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Tests for the :mod:`aiida.orm.nodes.data.remote.stash` module.""" +import pytest + +from aiida.common.datastructures import StashMode +from aiida.common.exceptions import StoringNotAllowed +from aiida.orm import RemoteStashData, RemoteStashFolderData + + +@pytest.mark.usefixtures('clear_database_before_test') +def test_base_class(): + """Verify that base class cannot be stored.""" + node = RemoteStashData(stash_mode=StashMode.COPY) + + with pytest.raises(StoringNotAllowed): + node.store() + + +@pytest.mark.usefixtures('clear_database_before_test') +@pytest.mark.parametrize('store', (False, True)) +def test_constructor(store): + """Test the constructor and storing functionality.""" + stash_mode = StashMode.COPY + target_basepath = '/absolute/path' + source_list = ['relative/folder', 'relative/file'] + + data = RemoteStashFolderData(stash_mode, target_basepath, source_list) + + assert data.stash_mode == stash_mode + assert data.target_basepath == target_basepath + assert data.source_list == source_list + + if store: + data.store() + assert data.is_stored + assert data.stash_mode == stash_mode + assert data.target_basepath == target_basepath + assert data.source_list == source_list + + +@pytest.mark.usefixtures('clear_database_before_test') +@pytest.mark.parametrize( + 'argument, value', ( + ('stash_mode', 'copy'), + ('target_basepath', ['list']), + ('source_list', 'relative/path'), + ('source_list', ('/absolute/path')), + ) +) +def test_constructor_invalid(argument, value): + """Test the constructor for invalid argument types.""" + kwargs = { + 'stash_mode': StashMode.COPY, + 'target_basepath': '/absolute/path', + 'source_list': ('relative/folder', 'relative/file'), + } + + with pytest.raises(TypeError): + kwargs[argument] = value + RemoteStashFolderData(**kwargs) From d7625227dfa3a4c406b1f476c0c1e71a76408a3a Mon Sep 17 00:00:00 2001 From: ramirezfranciscof Date: Wed, 10 Mar 2021 15:56:43 +0100 Subject: [PATCH 109/114] `verdi process play`: only query for active processes with `--all` flag (#4671) The query used to target all process nodes with the `paused` attribute, so even those in a terminal state. Here an additional filter is added to only query for nodes in an active process state, because terminal nodes should not be affected. This should speed up the query in principle. --- aiida/cmdline/commands/cmd_process.py | 3 +- tests/cmdline/commands/test_process.py | 170 +++++++++++-------------- tests/conftest.py | 30 +++++ 3 files changed, 105 insertions(+), 98 deletions(-) diff --git a/aiida/cmdline/commands/cmd_process.py b/aiida/cmdline/commands/cmd_process.py index 8eb0e72cb2..ba70c81b37 100644 --- a/aiida/cmdline/commands/cmd_process.py +++ b/aiida/cmdline/commands/cmd_process.py @@ -247,7 +247,8 @@ def process_play(processes, all_entries, timeout, wait): raise click.BadOptionUsage('all', 'cannot specify individual processes and the `--all` flag at the same time.') if not processes and all_entries: - builder = QueryBuilder().append(ProcessNode, filters={'attributes.paused': True}) + filters = CalculationQueryBuilder().get_filters(process_state=('created', 'waiting', 'running'), paused=True) + builder = QueryBuilder().append(ProcessNode, filters=filters) processes = builder.all(flat=True) futures = {} diff --git a/tests/cmdline/commands/test_process.py b/tests/cmdline/commands/test_process.py index 39d8a7aabb..bea5ed8069 100644 --- a/tests/cmdline/commands/test_process.py +++ b/tests/cmdline/commands/test_process.py @@ -8,8 +8,6 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for `verdi process`.""" -import subprocess -import sys import time import asyncio from concurrent.futures import Future @@ -23,7 +21,6 @@ from aiida.cmdline.commands import cmd_process from aiida.common.links import LinkType from aiida.common.log import LOG_LEVEL_REPORT -from aiida.manage.manager import get_manager from aiida.orm import CalcJobNode, WorkflowNode, WorkFunctionNode, WorkChainNode from tests.utils import processes as test_processes @@ -33,100 +30,6 @@ def get_result_lines(result): return [e for e in result.output.split('\n') if e] -class TestVerdiProcessDaemon(AiidaTestCase): - """Tests for `verdi process` that require a running daemon.""" - - TEST_TIMEOUT = 5. - - def setUp(self): - super().setUp() - from aiida.cmdline.utils.common import get_env_with_venv_bin - from aiida.engine.daemon.client import DaemonClient - from aiida.manage.configuration import get_config - - # Add the current python path to the environment that will be used for the daemon sub process. This is necessary - # to guarantee the daemon can also import all the classes that are defined in this `tests` module. - env = get_env_with_venv_bin() - env['PYTHONPATH'] = ':'.join(sys.path) - - profile = get_config().current_profile - self.daemon_client = DaemonClient(profile) - self.daemon = subprocess.Popen( - self.daemon_client.cmd_string.split(), stderr=sys.stderr, stdout=sys.stdout, env=env - ) - self.runner = get_manager().create_runner(rmq_submit=True) - self.cli_runner = CliRunner() - - def tearDown(self): - import os - import signal - - os.kill(self.daemon.pid, signal.SIGTERM) - super().tearDown() - - @pytest.mark.skip(reason='fails to complete randomly (see issue #4731)') - @pytest.mark.requires_rmq - def test_pause_play_kill(self): - """ - Test the pause/play/kill commands - """ - # pylint: disable=no-member - from aiida.orm import load_node - - calc = self.runner.submit(test_processes.WaitProcess) - start_time = time.time() - while calc.process_state is not plumpy.ProcessState.WAITING: - if time.time() - start_time >= self.TEST_TIMEOUT: - self.fail('Timed out waiting for process to enter waiting state') - - # Make sure that calling any command on a non-existing process id will not except but print an error - # To simulate a process without a corresponding task, we simply create a node and store it. This node will not - # have an associated task at RabbitMQ, but it will be a valid `ProcessNode` so it will pass the initial - # filtering of the `verdi process` commands - orphaned_node = WorkFunctionNode().store() - non_existing_process_id = str(orphaned_node.pk) - for command in [cmd_process.process_pause, cmd_process.process_play, cmd_process.process_kill]: - result = self.cli_runner.invoke(command, [non_existing_process_id]) - self.assertClickResultNoException(result) - self.assertIn('Error:', result.output) - - self.assertFalse(calc.paused) - result = self.cli_runner.invoke(cmd_process.process_pause, [str(calc.pk)]) - self.assertIsNone(result.exception, result.output) - - # We need to make sure that the process is picked up by the daemon and put in the Waiting state before we start - # running the CLI commands, so we add a broadcast subscriber for the state change, which when hit will set the - # future to True. This will be our signal that we can start testing - waiting_future = Future() - filters = kiwipy.BroadcastFilter( - lambda *args, **kwargs: waiting_future.set_result(True), sender=calc.pk, subject='state_changed.*.waiting' - ) - self.runner.communicator.add_broadcast_subscriber(filters) - - # The process may already have been picked up by the daemon and put in the waiting state, before the subscriber - # got the chance to attach itself, making it have missed the broadcast. That's why check if the state is already - # waiting, and if not, we run the loop of the runner to start waiting for the broadcast message. To make sure - # that we have the latest state of the node as it is in the database, we force refresh it by reloading it. - calc = load_node(calc.pk) - if calc.process_state != plumpy.ProcessState.WAITING: - self.runner.loop.run_until_complete(asyncio.wait_for(waiting_future, timeout=5.0)) - - # Here we now that the process is with the daemon runner and in the waiting state so we can starting running - # the `verdi process` commands that we want to test - result = self.cli_runner.invoke(cmd_process.process_pause, ['--wait', str(calc.pk)]) - self.assertIsNone(result.exception, result.output) - self.assertTrue(calc.paused) - - result = self.cli_runner.invoke(cmd_process.process_play, ['--wait', str(calc.pk)]) - self.assertIsNone(result.exception, result.output) - self.assertFalse(calc.paused) - - result = self.cli_runner.invoke(cmd_process.process_kill, ['--wait', str(calc.pk)]) - self.assertIsNone(result.exception, result.output) - self.assertTrue(calc.is_terminated) - self.assertTrue(calc.is_killed) - - class TestVerdiProcess(AiidaTestCase): """Tests for `verdi process`.""" @@ -490,3 +393,76 @@ def test_multiple_processes(self): self.assertIn('No callers found', get_result_lines(result)[0]) self.assertIn(str(self.node_root.pk), get_result_lines(result)[1]) self.assertIn(str(self.node_root.pk), get_result_lines(result)[2]) + + +@pytest.mark.skip(reason='fails to complete randomly (see issue #4731)') +@pytest.mark.requires_rmq +@pytest.mark.usefixtures('with_daemon', 'clear_database_before_test') +@pytest.mark.parametrize('cmd_try_all', (True, False)) +def test_pause_play_kill(cmd_try_all, run_cli_command): + """ + Test the pause/play/kill commands + """ + # pylint: disable=no-member, too-many-locals + from aiida.cmdline.commands.cmd_process import process_pause, process_play, process_kill + from aiida.manage.manager import get_manager + from aiida.engine import ProcessState + from aiida.orm import load_node + + runner = get_manager().create_runner(rmq_submit=True) + calc = runner.submit(test_processes.WaitProcess) + + test_daemon_timeout = 5. + start_time = time.time() + while calc.process_state is not plumpy.ProcessState.WAITING: + if time.time() - start_time >= test_daemon_timeout: + raise RuntimeError('Timed out waiting for process to enter waiting state') + + # Make sure that calling any command on a non-existing process id will not except but print an error + # To simulate a process without a corresponding task, we simply create a node and store it. This node will not + # have an associated task at RabbitMQ, but it will be a valid `ProcessNode` with and active state, so it will + # pass the initial filtering of the `verdi process` commands + orphaned_node = WorkFunctionNode() + orphaned_node.set_process_state(ProcessState.RUNNING) + orphaned_node.store() + non_existing_process_id = str(orphaned_node.pk) + for command in [process_pause, process_play, process_kill]: + result = run_cli_command(command, [non_existing_process_id]) + assert 'Error:' in result.output + + assert not calc.paused + result = run_cli_command(process_pause, [str(calc.pk)]) + + # We need to make sure that the process is picked up by the daemon and put in the Waiting state before we start + # running the CLI commands, so we add a broadcast subscriber for the state change, which when hit will set the + # future to True. This will be our signal that we can start testing + waiting_future = Future() + filters = kiwipy.BroadcastFilter( + lambda *args, **kwargs: waiting_future.set_result(True), sender=calc.pk, subject='state_changed.*.waiting' + ) + runner.communicator.add_broadcast_subscriber(filters) + + # The process may already have been picked up by the daemon and put in the waiting state, before the subscriber + # got the chance to attach itself, making it have missed the broadcast. That's why check if the state is already + # waiting, and if not, we run the loop of the runner to start waiting for the broadcast message. To make sure + # that we have the latest state of the node as it is in the database, we force refresh it by reloading it. + calc = load_node(calc.pk) + if calc.process_state != plumpy.ProcessState.WAITING: + runner.loop.run_until_complete(asyncio.wait_for(waiting_future, timeout=5.0)) + + # Here we now that the process is with the daemon runner and in the waiting state so we can starting running + # the `verdi process` commands that we want to test + result = run_cli_command(process_pause, ['--wait', str(calc.pk)]) + assert calc.paused + + if cmd_try_all: + cmd_option = '--all' + else: + cmd_option = str(calc.pk) + + result = run_cli_command(process_play, ['--wait', cmd_option]) + assert not calc.paused + + result = run_cli_command(process_kill, ['--wait', str(calc.pk)]) + assert calc.is_terminated + assert calc.is_killed diff --git a/tests/conftest.py b/tests/conftest.py index 0b0e379c11..4a0c5862fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -334,3 +334,33 @@ def override_logging(): config.unset_option('logging.aiida_loglevel') config.unset_option('logging.db_loglevel') configure_logging(with_orm=True) + + +@pytest.fixture +def with_daemon(): + """Starts the daemon process and then makes sure to kill it once the test is done.""" + import sys + import signal + import subprocess + + from aiida.engine.daemon.client import DaemonClient + from aiida.cmdline.utils.common import get_env_with_venv_bin + + # Add the current python path to the environment that will be used for the daemon sub process. + # This is necessary to guarantee the daemon can also import all the classes that are defined + # in this `tests` module. + env = get_env_with_venv_bin() + env['PYTHONPATH'] = ':'.join(sys.path) + + profile = get_config().current_profile + daemon = subprocess.Popen( + DaemonClient(profile).cmd_string.split(), + stderr=sys.stderr, + stdout=sys.stdout, + env=env, + ) + + yield + + # Note this will always be executed after the yield no matter what happened in the test that used this fixture. + os.kill(daemon.pid, signal.SIGTERM) From 623d0d8b8b3f11e4f25a0d7d60061dccdba7150a Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Thu, 11 Mar 2021 21:09:55 +0100 Subject: [PATCH 110/114] Dependencies: update pymatgen version specification (#4805) Addresses #4797 --- setup.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.json b/setup.json index d7cac88646..39284aab0a 100644 --- a/setup.json +++ b/setup.json @@ -83,7 +83,7 @@ "atomic_tools": [ "PyCifRW~=4.4", "ase~=3.18", - "pymatgen>=2019.7.2", + "pymatgen>=2019.7.2,<=2022.02.03,!=2019.9.7", "pymysql~=0.9.3", "seekpath~=1.9,>=1.9.3", "spglib~=1.14" From 767f57a02b1ba40421ab9b17a67b2532aff556c6 Mon Sep 17 00:00:00 2001 From: Carl Simon Adorf Date: Tue, 16 Mar 2021 09:55:02 +0100 Subject: [PATCH 111/114] Dependencies: Pin sqlalchemy to minor release (#4809) Version 1.4 currently breaks `verdi setup` and indeed, according to https://www.sqlalchemy.org/download.html, minor releases of SqlAlchemy may have breaking changes. --- environment.yml | 2 +- setup.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index 1f5d72c388..095809e39e 100644 --- a/environment.yml +++ b/environment.yml @@ -35,7 +35,7 @@ dependencies: - reentry~=1.3 - simplejson~=3.16 - sqlalchemy-utils~=0.36.0 -- sqlalchemy>=1.3.10,~=1.3 +- sqlalchemy~=1.3.10 - tabulate~=0.8.5 - tqdm~=4.45 - tzlocal~=2.0 diff --git a/setup.json b/setup.json index 39284aab0a..c8c6b91ea7 100644 --- a/setup.json +++ b/setup.json @@ -49,7 +49,7 @@ "reentry~=1.3", "simplejson~=3.16", "sqlalchemy-utils~=0.36.0", - "sqlalchemy~=1.3,>=1.3.10", + "sqlalchemy~=1.3.10", "tabulate~=0.8.5", "tqdm~=4.45", "tzlocal~=2.0", From 075beb29a6f42921aec8d22d67fa84dc5b8b9f81 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 16 Mar 2021 12:10:55 +0100 Subject: [PATCH 112/114] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Add=20documentat?= =?UTF-8?q?ion=20on=20stashing=20(#4812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some additional minor changes * Add link for `TransferCalcjob` feedback * Add `versionadded` to `TransferCalcjob` docs --- docs/source/howto/data.rst | 5 ++- docs/source/topics/calculations/usage.rst | 48 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/source/howto/data.rst b/docs/source/howto/data.rst index 3c0a34719c..f0bfdd18eb 100644 --- a/docs/source/howto/data.rst +++ b/docs/source/howto/data.rst @@ -818,16 +818,19 @@ This command will delete both the file repository and the database. It is not possible to restore a deleted profile unless it was previously backed up! +.. _how-to:data:transfer: Transferring data ================= +.. versionadded:: 1.6.0 + .. danger:: This feature is still in beta version and its API might change in the near future. It is therefore not recommended that you rely on it for your public/production workflows. - Moreover, feedback on its implementation is much appreciated. + Moreover, feedback on its implementation is much appreciated (at https://github.com/aiidateam/aiida-core/issues/4811). When a calculation job is launched, AiiDA will create a :py:class:`~aiida.orm.nodes.data.remote.RemoteData` node that is attached as an output node to the calculation node with the label ``remote_folder``. The input files generated by the ``CalcJob`` plugin are copied to this remote folder and, since the job is executed there as well, the code will produce its output files in that same remote folder also. diff --git a/docs/source/topics/calculations/usage.rst b/docs/source/topics/calculations/usage.rst index 2104f15f9a..de7c77b1d7 100644 --- a/docs/source/topics/calculations/usage.rst +++ b/docs/source/topics/calculations/usage.rst @@ -493,6 +493,54 @@ The parser implementation can then parse these files and store the relevant info After the parser terminates, the engine will take care to automatically clean up the sandbox folder with the temporarily retrieved files. The contract of the 'retrieve temporary list' is essentially that the files will be available during parsing and will be destroyed immediately afterwards. +.. _topics:calculations:usage:calcjobs:stashing: + +Stashing files on the remote +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.6.0 + +The ``stash`` option namespace allows a user to specify certain files that are created by the calculation job to be stashed somewhere on the remote. +This can be useful if those files need to be stored for a longer time than the scratch space where the job was run is typically not cleaned for, but need to be kept on the remote machine and not retrieved. +Examples are files that are necessary to restart a calculation but are too big to be retrieved and stored permanently in the local file repository. + +The files that are to be stashed are specified through their relative filepaths within the working directory in the ``stash.source_list`` option. +Using the ``COPY`` mode, the target path defines another location (on the same filesystem as the calculation) to copy these files to, and is set through the ``stash.target_base`` option, for example: + +.. code-block:: python + + from aiida.common.datastructures import StashMode + + inputs = { + 'code': ...., + ... + 'metadata': { + 'options': { + 'stash': { + 'source_list': ['aiida.out', 'output.txt'], + 'target_base': '/storage/project/stash_folder', + 'stash_mode': StashMode.COPY.value, + } + } + } + } + +.. note:: + + In the future, other methods for stashing may be implemented, such as placing all files in a (compressed) tarball or even stash files on tape. + +.. important:: + + If the ``stash`` option namespace is defined for a calculation job, the daemon will perform the stashing operations before the files are retrieved. + This means that the stashing happens before the parsing of the output files (which occurs after the retrieving step), such that that the files will be stashed independent of the final exit status that the parser will assign to the calculation job. + This may cause files to be stashed for calculations that will later be considered to have failed. + +The stashed files are represented by an output node that is attached to the calculation node through the label ``remote_stash``, as a ``RemoteStashFolderData`` node. +Just like the ``remote_folder`` node, this represents a location or files on a remote machine and so is equivalent to a "symbolic link". + +.. important:: + + AiiDA does not actually own the files in the remote stash, and so the contents may disappear at some point. .. _topics:calculations:usage:calcjobs:options: From abbceedd3064512a01d5c4098533658d8fd0c21f Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 16 Mar 2021 15:39:58 +0100 Subject: [PATCH 113/114] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20Add=20PyPI?= =?UTF-8?q?=20release=20workflow=20(#4807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is workflow is intended to reduce the potential for manual errors and faulty releases. When you create the release, and hence git tag, this workflow is triggered; checks the tag created matches the aiida package version, runs pre-commit and (some) pytests and, if they all pass, deploys to PyPI. --- .github/workflows/check_release_tag.py | 16 ++++ .github/workflows/post-release.yml | 49 ++++++++++ .github/workflows/release.yml | 125 ++++++++++++++++++------- 3 files changed, 158 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/check_release_tag.py create mode 100644 .github/workflows/post-release.yml diff --git a/.github/workflows/check_release_tag.py b/.github/workflows/check_release_tag.py new file mode 100644 index 0000000000..2501a1c957 --- /dev/null +++ b/.github/workflows/check_release_tag.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Check that the GitHub release tag matches the package version.""" +import argparse +import json + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('GITHUB_REF', help='The GITHUB_REF environmental variable') + parser.add_argument('SETUP_PATH', help='Path to the setup.json') + args = parser.parse_args() + assert args.GITHUB_REF.startswith('refs/tags/v'), f'GITHUB_REF should start with "refs/tags/v": {args.GITHUB_REF}' + tag_version = args.GITHUB_REF[11:] + with open(args.SETUP_PATH) as handle: + data = json.load(handle) + pypi_version = data['version'] + assert tag_version == pypi_version, f'The tag version {tag_version} != {pypi_version} specified in `setup.json`' diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml new file mode 100644 index 0000000000..6f965dba00 --- /dev/null +++ b/.github/workflows/post-release.yml @@ -0,0 +1,49 @@ +name: post-release + +on: + release: + types: [published, edited] + +jobs: + + upload-transifex: + # Every time when a new version is released, + # upload the latest pot files to transifex services for team transilation. + # https://www.transifex.com/aiidateam/aiida-core/dashboard/ + + name: Upload pot files to transifex + runs-on: ubuntu-latest + timeout-minutes: 30 + + # Build doc to pot files and register them to `.tx/config` file + # Installation steps are modeled after the docs job in `ci.yml` + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.7 + uses: actions/setup-python@v2 + with: + python-version: 3.7 + + - name: Install python dependencies + run: | + pip install transifex-client sphinx-intl + pip install -e .[docs,tests] + + - name: Build pot files + env: + READTHEDOCS: 'True' + RUN_APIDOC: 'False' + run: + sphinx-build -b gettext docs/source locale + + - name: Setting transifex configuration and upload pot files + env: + PROJECT_NAME: aiida-core + USER: ${{ secrets.TRANSIFEX_USER }} + PASSWD: ${{ secrets.TRANSIFEX_PASSWORD }} + run: | + tx init --no-interactive + sphinx-intl update-txconfig-resources --pot-dir locale --transifex-project-name ${PROJECT_NAME} + echo $'[https://www.transifex.com]\nhostname = https://www.transifex.com\nusername = '"${USER}"$'\npassword = '"${PASSWD}"$'\n' > ~/.transifexrc + tx push -s diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d55fcfa698..b130b687ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,49 +1,110 @@ name: release +# Automate deployment to PyPI when creating a release tag vX.Y.Z +# will only be published to PyPI if the git tag matches the release version +# and the pre-commit and tests pass + on: - release: - types: [published, edited] + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" jobs: - upload-transifex: - # Every time when a new version is released, - # upload the latest pot files to transifex services for team transilation. - # https://www.transifex.com/aiidateam/aiida-core/dashboard/ + check-release-tag: - name: Upload pot files to transifex runs-on: ubuntu-latest - timeout-minutes: 30 - # Build doc to pot files and register them to `.tx/config` file - # Installation steps are modeled after the docs job in `ci.yml` steps: - uses: actions/checkout@v2 - - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.8 + - run: python .github/workflows/check_release_tag.py $GITHUB_REF setup.json + + pre-commit: + needs: [check-release-tag] + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 - name: Install python dependencies + run: pip install -e .[all] + - name: Run pre-commit + run: pre-commit run --all-files || ( git status --short ; git diff ; exit 1 ) + + tests: + + needs: [check-release-tag] + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgres:10 + env: + POSTGRES_DB: test_django + POSTGRES_PASSWORD: '' + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + rabbitmq: + image: rabbitmq:latest + ports: + - 5672:5672 + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install system dependencies + run: | + sudo apt update + sudo apt install postgresql-10 graphviz + - name: Install aiida-core run: | - pip install transifex-client sphinx-intl - pip install -e .[docs,tests] - - - name: Build pot files - env: - READTHEDOCS: 'True' - RUN_APIDOC: 'False' - run: - sphinx-build -b gettext docs/source locale - - - name: Setting transifex configuration and upload pot files - env: - PROJECT_NAME: aiida-core - USER: ${{ secrets.TRANSIFEX_USER }} - PASSWD: ${{ secrets.TRANSIFEX_PASSWORD }} + pip install --upgrade pip setuptools + pip install -r requirements/requirements-py-3.8.txt + pip install --no-deps -e . + reentry scan + - name: Run sub-set of test suite + run: pytest -sv -k 'requires_rmq' + + publish: + + name: Publish to PyPI + + needs: [check-release-tag, pre-commit, tests] + + runs-on: ubuntu-latest + + steps: + - name: Checkout source + uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Build package run: | - tx init --no-interactive - sphinx-intl update-txconfig-resources --pot-dir locale --transifex-project-name ${PROJECT_NAME} - echo $'[https://www.transifex.com]\nhostname = https://www.transifex.com\nusername = '"${USER}"$'\npassword = '"${PASSWD}"$'\n' > ~/.transifexrc - tx push -s + pip install wheel + python setup.py sdist bdist_wheel + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.PYPI_KEY }} From 260bb7a9ac96336d727e950ac434288850e446e0 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 18 Mar 2021 14:25:53 +0100 Subject: [PATCH 114/114] =?UTF-8?q?=F0=9F=9A=80=20RELEASE:=20v1.6.0=20(#48?= =?UTF-8?q?16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ramirezfranciscof --- CHANGELOG.md | 112 ++++++++++++++++++++++++++++++++++++++++++++-- aiida/__init__.py | 2 +- setup.json | 2 +- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e79a0e1134..d5ed12d88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,112 @@ # Changelog -## v1.5.2 +## v1.6.0 - 2021-03-15 + +[full changelog](https://github.com/aiidateam/aiida-core/compare/v1.5.2...v1.6.0) | [GitHub contributors page for this release](https://github.com/aiidateam/aiida-core/graphs/contributors?from=2020-12-07&to=2021-03-15&type=c) + +As well as introducing a number of improvements and new features listed below, this release marks the "under-the-hood" migration from the `tornado` package to the Python built-in module `asyncio`, for handling asynchronous processing within the AiiDA engine. +This removes a number of blocking dependency version clashes with other tools, in particular with the newest Jupyter shell and notebook environments. +The migration does not present any backward incompatible changes to AiiDA's public API. +A substantial effort has been made to test and debug the new implementation, and ensure it performs at least equivalent to the previous code (or improves it!), but please let us know if you uncover any additional issues. + +This release also drops support for Python 3.6 (testing is carried out against `3.7`, `3.8` and `3.9`). + +NOTE: `v1.6` is tentatively intended to be the final minor `v1.x` release before `v2.x`, that will include a new file repository implementation and remove all deprecated code. + +### New calculation features ✨ + +The `additional_retrieve_list` metadata option has been added to `CalcJob` ([#4437](https://github.com/aiidateam/aiida-core/pull/4437)). +This new option allows one to specify additional files to be retrieved on a per-instance basis, in addition to the files that are already defined by the plugin to be retrieved. + +A **new namespace `stash`** has bee added to the `metadata.options` input namespace of the `CalcJob` process ([#4424](https://github.com/aiidateam/aiida-core/pull/4424)). +This option namespace allows a user to specify certain files that are created by the calculation job to be stashed somewhere on the remote. +This can be useful if those files need to be stored for a longer time than the scratch space (where the job was run) is available for, but need to be kept on the remote machine and not retrieved. +Examples are files that are necessary to restart a calculation but are too big to be retrieved and stored permanently in the local file repository. + +See [Stashing files on the remote](https://aiida.readthedocs.io/projects/aiida-core/en/v1.6.0/topics/calculations/usage.html#stashing-files-on-the-remote) for more details. + +The **new `TransferCalcjob` plugin** ([#4194](https://github.com/aiidateam/aiida-core/pull/4194)) allows the user to copy files between a remote machine and the local machine running AiiDA. +More specifically, it can do any of the following: + +- Take any number of files from any number of `RemoteData` folders in a remote machine and copy them in the local repository of a single newly created `FolderData` node. +- Take any number of files from any number of `FolderData` nodes in the local machine and copy them in a single newly created `RemoteData` folder in a given remote machine. + +See the [Transferring data](https://aiida.readthedocs.io/projects/aiida-core/en/v1.6.0/howto/data.html#transferring-data) how-to for more details. + +### Profile configuration improvements 👌 + +The way the global/profile configuration is accessed has undergone a number of distinct changes ([#4712](https://github.com/aiidateam/aiida-core/pull/4712)): + +- When loaded, the `config.json` (found in the `.aiida` folder) is now validated against a [JSON Schema](https://json-schema.org/) that can be found in [`aiida/manage/configuration/schema`](https://github.com/aiidateam/aiida-core/tree/develop/aiida/manage/configuration/schema). +- The schema includes a number of new global/profile options, including: `transport.task_retry_initial_interval`, `transport.task_maximum_attempts`, `rmq.task_timeout` and `logging.aiopika_loglevel` ([#4583](https://github.com/aiidateam/aiida-core/pull/4583)). +- The `cache_config.yml` has now also been **deprecated** and merged into the `config.json`, as part of the profile options. + This merge will be handled automatically, upon first load of the `config.json` using the new AiiDA version. + +In-line with these changes, the `verdi config` command has been refactored into separate commands, including `verdi config list`, `verdi config set`, `verdi config unset` and `verdi config caching`. + +See the [Configuring profile options](https://aiida.readthedocs.io/projects/aiida-core/en/v1.6.0/howto/installation.html#configuring-profile-options) and [Configuring caching](https://aiida.readthedocs.io/projects/aiida-core/en/v1.6.0/howto/run_codes.html#how-to-save-compute-time-with-caching) how-tos for more details. + +### Command-line additions and improvements 👌 + +In addition to `verdi config`, numerous other new commands and options have been added to `verdi`: + +- **Deprecated** `verdi export` and `verdi import` commands (replaced by new `verdi archive`) ([#4710](https://github.com/aiidateam/aiida-core/pull/4710)) +- Added `verdi group delete --delete-nodes`, to also delete the nodes in a group during its removal ([#4578](https://github.com/aiidateam/aiida-core/pull/4578)). +- Improved `verdi group remove-nodes` command to warn when requested nodes are not in the specified group ([#4728](https://github.com/aiidateam/aiida-core/pull/4728)). +- Added `exception` to the projection mapping of `verdi process list`, for example to use in debugging as: `verdi process list -S excepted -P ctime pk exception` ([#4786](https://github.com/aiidateam/aiida-core/pull/4786)). +- Added `verdi database summary` ([#4737](https://github.com/aiidateam/aiida-core/pull/4737)): + This prints a summary of the count of each entity and (optionally) the list of unique identifiers for some entities. +- Improved `verdi process play` performance, by only querying for active processes with the `--all` flag ([#4671](https://github.com/aiidateam/aiida-core/pull/4671)) +- Added the `verdi database version` command ([#4613](https://github.com/aiidateam/aiida-core/pull/4613)): + This shows the schema generation and version of the database of the given profile, useful mostly for developers when debugging. +- Improved `verdi node delete` performance ([#4575](https://github.com/aiidateam/aiida-core/pull/4575)): + The logic has been re-written to greatly reduce the time to delete large amounts of nodes. +- Fixed `verdi quicksetup --non-interactive`, to ensure it does not include any user prompts ([#4573](https://github.com/aiidateam/aiida-core/pull/4573)) +- Fixed `verdi --version` when used in editable mode ([#4576](https://github.com/aiidateam/aiida-core/pull/4576)) + +### API additions and improvements 👌 + +The base `Node` class now evaluates equality based on the node's UUID ([#4753](https://github.com/aiidateam/aiida-core/pull/4753)). +For example, loading the same node twice will always resolve as equivalent: `load_node(1) == load_node(1)`. +Note that existing, class specific, equality relationships will still override the base class behaviour, for example: `Int(99) == Int(99)`, even if the nodes have different UUIDs. +This behaviour for subclasses is still under discussion at: + +When hashing nodes for use with the caching features, `-0.` is now converted to `0.`, to reduce issues with differing hashes before/after node storage ([#4648](https://github.com/aiidateam/aiida-core/pull/4648)). +Known failure modes for hashing are now also raised with the `HashingError` exception ([#4778](https://github.com/aiidateam/aiida-core/pull/4778)). + +Both `aiida.tools.delete_nodes` ([#4578](https://github.com/aiidateam/aiida-core/pull/4578)) and `aiida.orm.to_aiida_type` ([#4672](https://github.com/aiidateam/aiida-core/pull/4672)) have been exposed for use in the public API. + +A `pathlib.Path` instance can now be used for the `file` argument of `SinglefileData` ([#3614](https://github.com/aiidateam/aiida-core/pull/3614)) + +Type annotations have been added to all inputs/outputs of functions and methods in `aiida.engine` ([#4669](https://github.com/aiidateam/aiida-core/pull/4669)) and `aiida/orm/nodes/processes` ([#4772](https://github.com/aiidateam/aiida-core/pull/4772)). +As outlined in [PEP 484](https://www.python.org/dev/peps/pep-0484/), this improves static code analysis and, for example, allows for better auto-completion and type checking in many code editors. + +### New REST API Query endpoint ✨ + +The `/querybuilder` endpoint is the first POST method available for AiiDA's RESTful API ([#4337](https://github.com/aiidateam/aiida-core/pull/4337)) + +The POST endpoint returns what the QueryBuilder would return, when providing it with a proper `queryhelp` dictionary ([see the documentation here](https://aiida.readthedocs.io/projects/aiida-core/en/latest/topics/database.html#the-queryhelp)). +Furthermore, it returns the entities/results in the "standard" REST API format - with the exception of `link_type` and `link_label` keys for links (these particular keys are still present as `type` and `label`, respectively). + +For security, POST methods can be toggled on/off with the `verdi restapi --posting/--no-posting` options (it is on by default). +Although note that this option is not yet strictly public, since its naming may be changed in the future! + +See [AiiDA REST API documentation](https://aiida.readthedocs.io/projects/aiida-core/en/latest/reference/rest_api.html) for more details. + +### Additional Changes + +- Fixed the direct scheduler which, in combination with `SshTransport`, was hanging on submit command ([#4735](https://github.com/aiidateam/aiida-core/pull/4735)). + In the ssh transport, to emulate 'chdir', the current directory is now kept in memory, and every command prepended with `cd FOLDER_NAME && ACTUALCOMMAND`. + +- In `aiida.tools.ipython.ipython_magics`, `load_ipython_extension` has been **deprecated** in favour of `register_ipython_extension` ([#4548](https://github.com/aiidateam/aiida-core/pull/4548)). + +- Refactored `.ci/` folder to make tests more portable and easier to understand ([#4565](https://github.com/aiidateam/aiida-core/pull/4565)) + The `ci/` folder had become cluttered, containing configuration and scripts for both the GitHub Actions and Jenkins CI. + This change moved the GH actions specific scripts to `.github/system_tests`, and refactored the Jenkins setup/tests to use [molecule](molecule.readthedocs.io) in the `.molecule/` folder. + +- For aiida-core development, the pytest `requires_rmq` marker and `config_with_profile` fixture have been added ([#4739](https://github.com/aiidateam/aiida-core/pull/4739) and [#4764](https://github.com/aiidateam/aiida-core/pull/4764)) + +## v1.5.2 - 2020-12-07 Note: release `v1.5.1` was skipped due to a problem with the uploaded files to PyPI. @@ -16,7 +122,7 @@ Note: release `v1.5.1` was skipped due to a problem with the uploaded files to P - CI: manually install `numpy` to prevent incompatible releases [[#4615]](https://github.com/aiidateam/aiida-core/pull/4615) -## v1.5.0 +## v1.5.0 - 2020-11-13 In this minor version release, support for Python 3.9 is added [[#4301]](https://github.com/aiidateam/aiida-core/pull/4301), while support for Python 3.5 is dropped [[#4386]](https://github.com/aiidateam/aiida-core/pull/4386). This version is compatible with all current Python versions that are not end-of-life: @@ -59,7 +165,7 @@ This version is compatible with all current Python versions that are not end-of- ### Dependencies - Update requirement `pytest~=6.0` and use `pyproject.toml` [[#4410]](https://github.com/aiidateam/aiida-core/pull/4410) -### Archive (import/export) refactor: +### Archive (import/export) refactor - The refactoring goal was to pave the way for the implementation of a new archive format in v2.0.0 ([ aiidateamAEP005](https://github.com/aiidateam/AEP/pull/21)) - Three abstract+concrete interface classes are defined; writer, reader, migrator, which are **independent of theinternal structure of the archive**. These classes are used within the export/import code. - The code in `aiida/tools/importexport` has been largely re-written, in particular adding `aiida/toolsimportexport/archive`, which contains this code for interfacing with an archive, and **does not require connectionto an AiiDA profile**. diff --git a/aiida/__init__.py b/aiida/__init__.py index 9545fc6b21..e357c2907b 100644 --- a/aiida/__init__.py +++ b/aiida/__init__.py @@ -31,7 +31,7 @@ 'For further information please visit http://www.aiida.net/. All rights reserved.' ) __license__ = 'MIT license, see LICENSE.txt file.' -__version__ = '1.5.2' +__version__ = '1.6.0' __authors__ = 'The AiiDA team.' __paper__ = ( 'S. P. Huber et al., "AiiDA 1.0, a scalable computational infrastructure for automated reproducible workflows and ' diff --git a/setup.json b/setup.json index c8c6b91ea7..b70e9ce465 100644 --- a/setup.json +++ b/setup.json @@ -1,6 +1,6 @@ { "name": "aiida-core", - "version": "1.5.2", + "version": "1.6.0", "url": "http://www.aiida.net/", "license": "MIT License", "author": "The AiiDA team",