Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazy entry points in verdi #6153

Merged
merged 34 commits into from
Oct 22, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ebf9fc0
WIP: Lazy entry points in verdi
danielhollas Oct 14, 2023
3b5dde9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 17, 2023
8559c37
Try running tests
danielhollas Oct 17, 2023
47aa3ba
Fix EntryPoint imports
danielhollas Oct 17, 2023
aebdb2a
Revert breaking changes to see if tests pass
danielhollas Oct 17, 2023
82cefcd
I guess this does not work?
danielhollas Oct 18, 2023
a0dd26f
One more
danielhollas Oct 18, 2023
15b498f
Break
danielhollas Oct 18, 2023
2d6b8b0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 18, 2023
9e01860
Remove click from aiida.orm
danielhollas Oct 18, 2023
a66ba9c
initialize to empty lists
danielhollas Oct 18, 2023
2f8697a
Lazy import tabulate, ~6ms
danielhollas Oct 18, 2023
ee62868
Lazy import inspect, ~7ms
danielhollas Oct 18, 2023
5c4488d
Lazy imports of stdlib, ~15ms
danielhollas Oct 18, 2023
fd15352
One more temporary fix for tests
danielhollas Oct 18, 2023
57e1f26
Lazy PluginParamType
danielhollas Oct 18, 2023
4174c80
lazy IdentifierParamType
danielhollas Oct 18, 2023
615a0aa
Update tests
danielhollas Oct 18, 2023
f036805
Merge branch 'main' into verdi/lazy-entry-points
danielhollas Oct 18, 2023
82b950a
Update aiida/cmdline/utils/echo.py
danielhollas Oct 20, 2023
ceb3ec9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 20, 2023
8d88334
Minor refactoring and typing fix in configuration.py
danielhollas Oct 20, 2023
7a2cc44
review cleanup
danielhollas Oct 20, 2023
2b82927
Update docstring
danielhollas Oct 20, 2023
d4b2491
Optimization and typing in params/types/plugin.py
danielhollas Oct 20, 2023
3e945c0
Enable typing for params/types/plugin.py|identifier.py and aiida/mana…
danielhollas Oct 20, 2023
5d398c6
Move _get_valid_groups
danielhollas Oct 20, 2023
347f1b0
ugh, stupid tui docs
danielhollas Oct 20, 2023
a3ca765
fix test_environment_variable_not_set
danielhollas Oct 20, 2023
4b0d8aa
Merge branch 'main' into verdi/lazy-entry-points
danielhollas Oct 20, 2023
76b480e
review + enable typing for cmdline/groups/dynamic.py
danielhollas Oct 21, 2023
bec3662
Improve typing
danielhollas Oct 21, 2023
d356545
Merge branch 'main' into verdi/lazy-entry-points
danielhollas Oct 21, 2023
6930e79
Fix docs
sphuber Oct 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aiida/cmdline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
'echo_info',
'echo_report',
'echo_success',
'echo_tabulate',
'echo_warning',
'format_call_graph',
'is_verbose',
Expand Down
7 changes: 3 additions & 4 deletions aiida/cmdline/commands/cmd_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@
from functools import partial

import click
import tabulate

from aiida.cmdline.commands.cmd_verdi import verdi
from aiida.cmdline.groups.dynamic import DynamicEntryPointCommandGroup
from aiida.cmdline.params import arguments, options, types
from aiida.cmdline.params.options.commands import code as options_code
from aiida.cmdline.utils import echo
from aiida.cmdline.utils import echo, echo_tabulate
from aiida.cmdline.utils.decorators import deprecated_command, with_dbenv
from aiida.common import exceptions

Expand Down Expand Up @@ -232,7 +231,7 @@ def show(code):
if is_verbose():
table.append(['Calculations', len(code.base.links.get_outgoing().all())])

echo.echo(tabulate.tabulate(table))
echo_tabulate(table)


@verdi_code.command()
Expand Down Expand Up @@ -419,7 +418,7 @@ def code_list(computer, default_calc_job_plugin, all_entries, all_users, raw, sh
row.append('@'.join(str(result[entity][projection]) for entity, projection in VALID_PROJECTIONS[key]))
table.append(row)

echo.echo(tabulate.tabulate(table, headers=headers, tablefmt=table_format))
echo_tabulate(table, headers=headers, tablefmt=table_format)

if not raw:
echo.echo_report('\nUse `verdi code show IDENTIFIER` to see details for a code', prefix=False)
7 changes: 3 additions & 4 deletions aiida/cmdline/commands/cmd_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@
from math import isclose

import click
import tabulate

from aiida.cmdline.commands.cmd_verdi import VerdiCommandGroup, verdi
from aiida.cmdline.params import arguments, options
from aiida.cmdline.params.options.commands import computer as options_computer
from aiida.cmdline.utils import echo
from aiida.cmdline.utils import echo, echo_tabulate
from aiida.cmdline.utils.decorators import with_dbenv
from aiida.common.exceptions import EntryPointError, ValidationError
from aiida.plugins.entry_point import get_entry_point_names
Expand Down Expand Up @@ -461,7 +460,7 @@ def computer_show(computer):
['Prepend text', computer.get_prepend_text()],
['Append text', computer.get_append_text()],
]
echo.echo(tabulate.tabulate(table))
echo_tabulate(table)


@verdi_computer.command('relabel')
Expand Down Expand Up @@ -697,4 +696,4 @@ def computer_config_show(computer, user, defaults, as_option_string):
table.append((f'* {name}', config[name]))
else:
table.append((f'* {name}', '-'))
echo.echo(tabulate.tabulate(table, tablefmt='plain'))
echo_tabulate(table, tablefmt='plain')
17 changes: 9 additions & 8 deletions aiida/cmdline/commands/cmd_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@
###########################################################################
"""`verdi node` command."""
import pathlib
import shutil

import click
import tabulate

from aiida.cmdline.commands.cmd_verdi import verdi
from aiida.cmdline.params import arguments, options
from aiida.cmdline.params.types.plugin import PluginParamType
from aiida.cmdline.utils import decorators, echo, multi_line_input
from aiida.cmdline.utils import decorators, echo, echo_tabulate, multi_line_input
from aiida.cmdline.utils.decorators import with_dbenv
from aiida.common import exceptions, timezone
from aiida.common.links import GraphTraversalRules
Expand All @@ -43,6 +41,7 @@ def repo_cat(node, relative_path):
For ``SinglefileData`` nodes, the `RELATIVE_PATH` does not have to be specified as it is determined automatically.
"""
import errno
import shutil
import sys

from aiida.orm import SinglefileData
Expand Down Expand Up @@ -92,6 +91,8 @@ def repo_dump(node, output_directory):
The output directory should not exist. If it does, the command
will abort.
"""
import shutil

from aiida.repository import FileType

output_directory = pathlib.Path(output_directory)
Expand Down Expand Up @@ -146,9 +147,9 @@ def node_label(nodes, label, raw, force):
table.append([node.pk, node.label])

if raw:
echo.echo(tabulate.tabulate(table, tablefmt='plain'))
echo_tabulate(table, tablefmt='plain')
else:
echo.echo(tabulate.tabulate(table, headers=['ID', 'Label']))
echo_tabulate(table, headers=['ID', 'Label'])

else:
if not force:
Expand Down Expand Up @@ -180,9 +181,9 @@ def node_description(nodes, description, force, raw):
table.append([node.pk, node.description])

if raw:
echo.echo(tabulate.tabulate(table, tablefmt='plain'))
echo_tabulate(table, tablefmt='plain')
else:
echo.echo(tabulate.tabulate(table, headers=['ID', 'Description']))
echo_tabulate(table, headers=['ID', 'Description'])

else:
if not force:
Expand Down Expand Up @@ -229,7 +230,7 @@ def node_show(nodes, print_groups):
table = [(gr['groups']['id'], gr['groups']['label'], gr['groups']['type_string']) for gr in res]
table.sort()

echo.echo(tabulate.tabulate(table, headers=['PK', 'Label', 'Group type']))
echo_tabulate(table, headers=['PK', 'Label', 'Group type'])


def echo_node_dict(nodes, keys, fmt, identifier, raw, use_attrs=True):
Expand Down
3 changes: 2 additions & 1 deletion aiida/cmdline/commands/cmd_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
# For further information please visit http://www.aiida.net #
###########################################################################
"""Command for `verdi plugins`."""
import inspect

import click

Expand All @@ -27,6 +26,8 @@ def verdi_plugin():
@click.argument('entry_point', type=click.STRING, required=False)
def plugin_list(entry_point_group, entry_point):
"""Display a list of all available plugins."""
import inspect

from aiida.cmdline.utils.common import print_process_info
from aiida.common import EntryPointError
from aiida.engine import Process
Expand Down
5 changes: 2 additions & 3 deletions aiida/cmdline/commands/cmd_rabbitmq.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@
import typing as t

import click
import tabulate
import wrapt

from aiida.cmdline.commands.cmd_devel import verdi_devel
from aiida.cmdline.params import arguments, options
from aiida.cmdline.utils import decorators, echo
from aiida.cmdline.utils import decorators, echo, echo_tabulate

if t.TYPE_CHECKING:
import kiwipy.rmq
Expand Down Expand Up @@ -192,7 +191,7 @@ def cmd_queues_list(client, project, raw, filter_name):

headers = [name.capitalize() for name in project] if not raw else []
tablefmt = None if not raw else 'plain'
echo.echo(tabulate.tabulate(output, headers=headers, tablefmt=tablefmt))
echo_tabulate(output, headers=headers, tablefmt=tablefmt)


@cmd_queues.command('create')
Expand Down
5 changes: 4 additions & 1 deletion aiida/cmdline/params/options/commands/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"""Reusable command line interface options for the setup commands."""
import functools
import getpass
import hashlib

import click

Expand Down Expand Up @@ -94,6 +93,8 @@ def get_quicksetup_database_name(ctx, param, value): # pylint: disable=unused-a
:param ctx: click context which should contain the contextual parameters
:return: the database name
"""
import hashlib

if value is not None:
return value

Expand All @@ -114,6 +115,8 @@ def get_quicksetup_username(ctx, param, value): # pylint: disable=unused-argume
:param ctx: click context which should contain the contextual parameters
:return: the username
"""
import hashlib

if value is not None:
return value

Expand Down
34 changes: 20 additions & 14 deletions aiida/cmdline/params/types/identifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Module for custom click param type identifier
"""
from abc import ABC, abstractmethod
from functools import cached_property

import click

Expand Down Expand Up @@ -46,24 +47,29 @@ def __init__(self, sub_classes=None):
will be mapped upon. These classes have to be strict sub classes of the base orm class defined
by the orm class loader
"""
from aiida.common import exceptions
if sub_classes is not None and not isinstance(sub_classes, tuple):
raise TypeError('sub_classes should be a tuple of entry point strings')

self._sub_classes = None
self._entry_points = []

if sub_classes is not None:
self._entry_point_strings = sub_classes

if not isinstance(sub_classes, tuple):
raise TypeError('sub_classes should be a tuple of entry point strings')
@cached_property
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how cached_property is implemented, but wouldn't it be easier to implement the "caching" ourselves here. E.g.:

    def __init__(self, sub_classes=None):
        if sub_classes is not None and not isinstance(sub_classes, tuple):
            raise TypeError('sub_classes should be a tuple of entry point strings')

        self._sub_classes = None
        self._entry_point_strings = sub_classes
        self._entry_points = None

    def entry_points(self):
        """Return list of allowed entry points."""
        from aiida.common import exceptions

        if self._entry_points is not None:
            return self._entry_points

        if self._entry_point_strings is None:
            return []

        entry_points = []

        for entry_point_string in self._entry_point_strings:
            try:
                entry_point = get_entry_point_from_string(entry_point_string)
            except (ValueError, exceptions.EntryPointError) as exception:
                raise ValueError(f'{entry_point_string} is not a valid entry point string: {exception}')
            else:
                entry_points.append(entry_point)
        
        self._entry_points = entry_points

        return self._entry_points

i.e. just make entry_points a public property and have _entry_points be None initially and populate it the first time entry_points is called.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we have a different definition of easier 😄 To me the decorator seems clearer, it's one less level of indirection, and allowed me to not change the code elsewhere (i.e. using the entry_points instead of _entry_points). But I don't have a strong feeling if you think I should change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I shouldn't have said easier. I was mostly wondering how cached_property implements this under the hood. Will it be as efficient as the "manual" version? if there is no (noticable) difference, then I agree that using the cached_property is easier to read and fine to keep that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll do a microbenchmark and report back. I would expect this to make much difference because I don't think this property should be accessed too many times.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, don't spend too much time on it. It is not that important, we can keep the cached_property approach

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I may do it later and we can revisit this later, but don't want to block this PR on this.

def _entry_points(self):
"""Allowed entry points, loaded on demand"""
from aiida.common import exceptions

for entry_point_string in sub_classes:
if self._entry_point_strings is None:
return []

try:
entry_point = get_entry_point_from_string(entry_point_string)
except (ValueError, exceptions.EntryPointError) as exception:
raise ValueError(f'{entry_point_string} is not a valid entry point string: {exception}')
else:
self._entry_points.append(entry_point)
entry_points = []
for entry_point_string in self._entry_point_strings:
try:
entry_point = get_entry_point_from_string(entry_point_string)
except (ValueError, exceptions.EntryPointError) as exception:
raise ValueError(f'{entry_point_string} is not a valid entry point string: {exception}')
else:
entry_points.append(entry_point)
return entry_points

@property
@abstractmethod
Expand Down Expand Up @@ -99,7 +105,7 @@ def convert(self, value, param, ctx):
if not issubclass(loader, OrmEntityLoader):
raise RuntimeError('the orm class loader should be a subclass of OrmEntityLoader')

# If entry points where in the constructor, we load their corresponding classes, validate that they are valid
# If entry points were in the constructor, we load their corresponding classes, validate that they are valid
# sub classes of the orm class loader and then pass it as the sub_class parameter to the load_entity call.
# We store the loaded entry points in an instance variable, such that the loading only has to be done once.
if self._entry_points and self._sub_classes is None:
Expand Down
7 changes: 4 additions & 3 deletions aiida/cmdline/params/types/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
###########################################################################
"""Click parameter types for paths."""
import os
from socket import timeout

import click

Expand Down Expand Up @@ -86,13 +85,14 @@ def convert(self, value, param, ctx):

def checks_url(self, url, param, ctx):
"""Check whether URL is reachable within timeout."""
import socket
import urllib.error
import urllib.request

try:
with urllib.request.urlopen(url, timeout=self.timeout_seconds):
pass
except (urllib.error.URLError, urllib.error.HTTPError, timeout):
except (urllib.error.URLError, urllib.error.HTTPError, socket.timeout):
self.fail(f'{self.name} "{url}" could not be reached within {self.timeout_seconds} s.\n', param, ctx)

return url
Expand Down Expand Up @@ -124,10 +124,11 @@ def convert(self, value, param, ctx):

def get_url(self, url, param, ctx):
"""Retrieve file from URL."""
import socket
import urllib.error
import urllib.request

try:
return urllib.request.urlopen(url, timeout=self.timeout_seconds) # pylint: disable=consider-using-with
except (urllib.error.URLError, urllib.error.HTTPError, timeout):
except (urllib.error.URLError, urllib.error.HTTPError, socket.timeout):
self.fail(f'{self.name} "{url}" could not be reached within {self.timeout_seconds} s.\n', param, ctx)
66 changes: 35 additions & 31 deletions aiida/cmdline/params/types/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import functools

import click
from importlib_metadata import EntryPoint

from aiida.common import exceptions
from aiida.plugins import factories
Expand Down Expand Up @@ -68,49 +67,52 @@ def __init__(self, group=None, load=False, *args, **kwargs):
is not specified use the tuple of all recognized entry point groups.
"""
# pylint: disable=keyword-arg-before-vararg
self.load = load
self._input_group = group

super().__init__(*args, **kwargs)

def _get_valid_groups(self):
"""Get allowed groups for this instance"""

group = self._input_group
valid_entry_point_groups = get_entry_point_groups()

if group is None:
self._groups = tuple(valid_entry_point_groups)
return tuple(valid_entry_point_groups)

if isinstance(group, str):
unvalidated_groups = (group,)
elif isinstance(group, tuple):
unvalidated_groups = group
else:
if isinstance(group, str):
invalidated_groups = tuple([group])
elif isinstance(group, tuple):
invalidated_groups = group
else:
raise ValueError('invalid type for group')
raise ValueError('invalid type for group')

groups = []
groups = []

for grp in invalidated_groups:
for grp in unvalidated_groups:

if not grp.startswith(ENTRY_POINT_GROUP_PREFIX):
grp = ENTRY_POINT_GROUP_PREFIX + grp
if not grp.startswith(ENTRY_POINT_GROUP_PREFIX):
grp = ENTRY_POINT_GROUP_PREFIX + grp

if grp not in valid_entry_point_groups:
raise ValueError(f'entry point group {grp} is not recognized')
if grp not in valid_entry_point_groups:
raise ValueError(f'entry point group {grp} is not recognized')

groups.append(grp)
groups.append(grp)

self._groups = tuple(groups)
return tuple(groups)

self._init_entry_points()
self.load = load
@functools.cached_property
def groups(self) -> tuple:
return self._get_valid_groups()

super().__init__(*args, **kwargs)

def _init_entry_points(self):
"""
Populate entry point information that will be used later on. This should only be called
once in the constructor after setting self.groups because the groups should not be changed
after instantiation
"""
self._entry_points = [(group, entry_point) for group in self.groups for entry_point in get_entry_points(group)]
self._entry_point_names = [entry_point.name for group in self.groups for entry_point in get_entry_points(group)]
@functools.cached_property
def _entry_points(self) -> list[tuple]:
return [(group, entry_point) for group in self.groups for entry_point in get_entry_points(group)]

@property
def groups(self):
return self._groups
@functools.cached_property
def _entry_point_names(self) -> list[str]:
return [entry_point.name for group in self.groups for entry_point in get_entry_points(group)]

@property
def has_potential_ambiguity(self):
Expand Down Expand Up @@ -236,6 +238,8 @@ def convert(self, value, param, ctx):
Convert the string value to an entry point instance, if the value can be successfully parsed
into an actual entry point. Will raise click.BadParameter if validation fails.
"""
from importlib_metadata import EntryPoint

# If the value is already of the expected return type, simply return it. This behavior is new in `click==8.0`:
# https://click.palletsprojects.com/en/8.0.x/parameters/#implementing-custom-types
if isinstance(value, EntryPoint):
Expand Down
Loading