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

Add verdi profile setup #6023

Merged
merged 3 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 38 additions & 1 deletion aiida/cmdline/commands/cmd_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,54 @@
import click

from aiida.cmdline.commands.cmd_verdi import verdi
from aiida.cmdline.groups import DynamicEntryPointCommandGroup
from aiida.cmdline.params import arguments, options
from aiida.cmdline.params.options.commands import setup
from aiida.cmdline.utils import defaults, echo
from aiida.common import exceptions
from aiida.manage.configuration import get_config
from aiida.manage.configuration import Profile, create_profile, get_config


@verdi.group('profile')
def verdi_profile():
"""Inspect and manage the configured profiles."""


def command_create_profile(ctx: click.Context, storage_cls, non_interactive: bool, profile: Profile, **kwargs): # pylint: disable=unused-argument
"""Create a new profile, initialise its storage and create a default user.

:param ctx: The context of the CLI command.
:param storage_cls: The storage class obtained through loading the entry point from ``aiida.storage`` group.
:param non_interactive: Whether the command was invoked interactively or not.
:param profile: The profile instance. This is an empty ``Profile`` instance created by the command line argument
which currently only contains the selected profile name for the profile that is to be created.
:param kwargs: Arguments to initialise instance of the selected storage implementation.
"""
try:
profile = create_profile(ctx.obj.config, storage_cls, name=profile.name, **kwargs)
except (ValueError, TypeError, exceptions.EntryPointError, exceptions.StorageMigrationError) as exception:
echo.echo_critical(str(exception))

echo.echo_success(f'Created new profile `{profile.name}`.')


@verdi_profile.group(
'setup',
cls=DynamicEntryPointCommandGroup,
command=command_create_profile,
entry_point_group='aiida.storage',
shared_options=[
setup.SETUP_PROFILE(),
setup.SETUP_USER_EMAIL(),
setup.SETUP_USER_FIRST_NAME(),
setup.SETUP_USER_LAST_NAME(),
setup.SETUP_USER_INSTITUTION(),
]
)
def profile_setup():
"""Set up a new profile."""


@verdi_profile.command('list')
def profile_list():
"""Display a list of all available profiles."""
Expand Down
36 changes: 26 additions & 10 deletions aiida/cmdline/groups/dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,30 @@ def list_options(self, entry_point: str) -> list:

:param entry_point: The entry point.
"""
return [
self.create_option(*item)
for item in self.factory(entry_point).get_cli_options().items() # type: ignore[union-attr]
]
cls = self.factory(entry_point)

if not hasattr(cls, 'Configuration'):
from aiida.common.warnings import warn_deprecation
warn_deprecation(
'Relying on `_get_cli_options` is deprecated. The options should be defined through a '
'`pydantic.BaseModel` that should be assigned to the `Config` class attribute.',
version=3
)
options_spec = self.factory(entry_point).get_cli_options() # type: ignore[union-attr]
else:

options_spec = {}

for key, field_info in cls.Configuration.model_fields.items():
options_spec[key] = {
'required': field_info.is_required(),
'type': field_info.annotation,
'prompt': field_info.title,
'default': field_info.default if field_info.default is not None else None,
'help': field_info.description,
}

return [self.create_option(*item) for item in options_spec.items()]

@staticmethod
def create_option(name, spec: dict) -> t.Callable[[t.Any], t.Any]:
Expand All @@ -136,6 +156,7 @@ def create_option(name, spec: dict) -> t.Callable[[t.Any], t.Any]:
name_dashed = name.replace('_', '-')
option_name = f'--{name_dashed}/--no-{name_dashed}' if is_flag else f'--{name_dashed}'
option_short_name = spec.pop('short_name', None)
option_names = (option_short_name, option_name) if option_short_name else (option_name,)

kwargs = {'cls': spec.pop('cls', InteractiveOption), 'show_default': True, 'is_flag': is_flag, **spec}

Expand All @@ -144,9 +165,4 @@ def create_option(name, spec: dict) -> t.Callable[[t.Any], t.Any]:
if kwargs['cls'] is InteractiveOption and is_flag and default is None:
kwargs['cls'] = functools.partial(InteractiveOption, prompt_fn=lambda ctx: False)

if option_short_name:
option = click.option(option_short_name, option_name, **kwargs)
else:
option = click.option(option_name, **kwargs)

return option
return click.option(*(option_names), **kwargs)
37 changes: 37 additions & 0 deletions aiida/manage/configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,43 @@ def profile_context(profile: Optional[str] = None, allow_switch=False) -> 'Profi
manager.load_profile(current_profile, allow_switch=True)


def create_profile(
config: Config,
storage_cls,
*,
name: str,
email: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
institution: Optional[str] = None,
**kwargs
) -> Profile:
"""Create a new profile, initialise its storage and create a default user.

:param config: The config instance.
:param storage_cls: The storage class obtained through loading the entry point from ``aiida.storage`` group.
:param name: Name of the profile.
:param email: Email for the default user.
:param first_name: First name for the default user.
:param last_name: Last name for the default user.
:param institution: Institution for the default user.
:param kwargs: Arguments to initialise instance of the selected storage implementation.
"""
from aiida.orm import User

storage_config = storage_cls.Configuration(**{k: v for k, v in kwargs.items() if v is not None}).dict()
profile: Profile = config.create_profile(name=name, storage_cls=storage_cls, storage_config=storage_config)

with profile_context(profile.name, allow_switch=True):
user = User(email=email, first_name=first_name, last_name=last_name, institution=institution).store()
profile.default_user_email = user.email

config.update_profile(profile)
config.store()

return profile


def reset_config():
"""Reset the globally loaded config.

Expand Down
78 changes: 74 additions & 4 deletions aiida/manage/configuration/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
from __future__ import annotations

import codecs
import contextlib
import io
import json
import os
from typing import Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type
import uuid

from pydantic import ( # pylint: disable=no-name-in-module
Expand All @@ -30,14 +32,19 @@
field_validator,
)

from aiida.common.exceptions import ConfigurationError
from aiida.common.log import LogLevels
from aiida.common.exceptions import ConfigurationError, EntryPointError, StorageMigrationError
from aiida.common.log import AIIDA_LOGGER, LogLevels

from .options import Option, get_option, get_option_names, parse_option
from .profile import Profile

__all__ = ('Config',)

if TYPE_CHECKING:
from aiida.orm.implementation.storage_backend import StorageBackend

LOGGER = AIIDA_LOGGER.getChild(__file__)


class ConfigVersionSchema(BaseModel, defer_build=True):
"""Schema for the version configuration of an AiiDA instance."""
Expand Down Expand Up @@ -126,7 +133,6 @@ def validate_caching_identifier_pattern(cls, value: List[str]) -> List[str]:
from aiida.manage.caching import _validate_identifier_pattern
for identifier in value:
_validate_identifier_pattern(identifier=identifier)

return value


Expand Down Expand Up @@ -446,6 +452,70 @@ def get_profile(self, name: Optional[str] = None) -> Profile:

return self._profiles[name]

def create_profile(self, name: str, storage_cls: Type['StorageBackend'], storage_config: dict[str, str]) -> Profile:
"""Create a new profile and initialise its storage.

:param name: The profile name.
:param storage_cls: The :class:`aiida.orm.implementation.storage_backend.StorageBackend` implementation to use.
:param storage_config: The configuration necessary to initialise and connect to the storage backend.
:returns: The created profile.
:raises ValueError: If the profile already exists.
:raises TypeError: If the ``storage_cls`` is not a subclass of
:class:`aiida.orm.implementation.storage_backend.StorageBackend`.
:raises EntryPointError: If the ``storage_cls`` does not have an associated entry point.
:raises StorageMigrationError: If the storage cannot be initialised.
"""
from aiida.orm.implementation.storage_backend import StorageBackend
from aiida.plugins.entry_point import get_entry_point_from_class

if name in self.profile_names:
raise ValueError(f'The profile `{name}` already exists.')

if not issubclass(storage_cls, StorageBackend):
raise TypeError(
f'The `storage_cls={storage_cls}` is not subclass of `aiida.orm.implementationStorageBackend`.'
)

_, storage_entry_point = get_entry_point_from_class(storage_cls.__module__, storage_cls.__name__)

if storage_entry_point is None:
raise EntryPointError(f'`{storage_cls}` does not have a registered entry point.')

profile = Profile(
name, {
'storage': {
'backend': storage_entry_point.name,
'config': storage_config,
},
'process_control': {
'backend': 'rabbitmq',
'config': {
'broker_protocol': 'amqp',
'broker_username': 'guest',
'broker_password': 'guest',
'broker_host': '127.0.0.1',
'broker_port': 5672,
'broker_virtual_host': ''
}
},
}
)

LOGGER.report('Initialising the storage backend.')
try:
with contextlib.redirect_stdout(io.StringIO()):
profile.storage_cls.initialise(profile)
except Exception as exception: # pylint: disable=broad-except
raise StorageMigrationError(
f'Storage backend initialisation failed, probably because the configuration is incorrect:\n{exception}'
)
LOGGER.report('Storage initialisation completed.')

self.add_profile(profile)
self.store()

return profile

def add_profile(self, profile):
"""Add a profile to the configuration.

Expand Down
2 changes: 2 additions & 0 deletions aiida/orm/implementation/storage_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# For further information please visit http://www.aiida.net #
###########################################################################
"""Generic backend related objects"""
from __future__ import annotations

import abc
from typing import TYPE_CHECKING, Any, ContextManager, List, Optional, Sequence, TypeVar, Union

Expand Down
3 changes: 2 additions & 1 deletion aiida/repository/backend/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import contextlib
import os
import pathlib
import shutil
import typing as t
import uuid
Expand Down Expand Up @@ -65,7 +66,7 @@ def is_initialised(self) -> bool:
def sandbox(self):
"""Return the sandbox instance of this repository."""
if self._sandbox is None:
self._sandbox = SandboxFolder(filepath=self._filepath)
self._sandbox = SandboxFolder(filepath=pathlib.Path(self._filepath) if self._filepath is not None else None)

return self._sandbox

Expand Down
25 changes: 25 additions & 0 deletions aiida/storage/psql_dos/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import pathlib
from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence, Set, Union

from pydantic import BaseModel, Field
from sqlalchemy import column, insert, update
from sqlalchemy.orm import Session, scoped_session, sessionmaker

Expand Down Expand Up @@ -72,6 +73,30 @@ class PsqlDosBackend(StorageBackend): # pylint: disable=too-many-public-methods
The `django` backend was removed, to consolidate access to this storage.
"""

class Configuration(BaseModel):
"""Model describing required information to configure an instance of the storage."""

database_engine: str = Field(
title='PostgreSQL engine',
description='The engine to use to connect to the database.',
default='postgresql_psycopg2'
)
database_hostname: str = Field(
title='PostgreSQL hostname', description='The hostname of the PostgreSQL server.', default='localhost'
)
database_port: int = Field(
title='PostgreSQL port', description='The port of the PostgreSQL server.', default=5432
)
database_username: str = Field(
title='PostgreSQL username', description='The username with which to connect to the PostgreSQL server.'
)
database_password: str = Field(
title='PostgreSQL password', description='The password with which to connect to the PostgreSQL server.'
)
database_name: Union[str, None] = Field(
title='PostgreSQL database name', description='The name of the database in the PostgreSQL server.'
)

migrator = PsqlDosMigrator

@classmethod
Expand Down
Loading
Loading