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

CLI: Various improvements concerning user details #6173

Merged
merged 5 commits into from
Nov 12, 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
3 changes: 1 addition & 2 deletions aiida/cmdline/commands/cmd_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,7 @@ def setup(
)
if created:
user.store()
profile.default_user_email = user.email
config.update_profile(profile)
config.set_default_user_email(profile, user.email)

# store the updated configuration
config.store()
Expand Down
105 changes: 28 additions & 77 deletions aiida/cmdline/commands/cmd_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,14 @@
# For further information please visit http://www.aiida.net #
###########################################################################
"""`verdi user` command."""

from functools import partial

import click

from aiida.cmdline.commands.cmd_verdi import verdi
from aiida.cmdline.params import arguments, options, types
from aiida.cmdline.params.options.commands import setup as options_setup
from aiida.cmdline.utils import decorators, echo


def set_default_user(profile, user):
"""Set the user as the default user for the given profile.

:param profile: the profile
:param user: the user
"""
from aiida.manage.configuration import get_config
config = get_config()
profile.default_user_email = user.email
config.update_profile(profile)
config.store()


def get_user_attribute_default(attribute, ctx):
"""Return the default value for the given attribute of the user passed in the context.

:param attribute: attribute for which to get the current value
:param ctx: click context which should contain the selected user
:return: user attribute default value if set, or None
"""
default = getattr(ctx.params['user'], attribute)

# None or empty string means there is no default
if not default:
return None

return default


@verdi.group('user')
def verdi_user():
"""Inspect and manage users."""
Expand All @@ -58,15 +27,22 @@ def user_list():
"""Show a list of all users."""
from aiida.orm import User

default_user = User.collection.get_default()
table = []

for user in sorted(User.collection.all(), key=lambda user: user.email):
row = ['*' if user.is_default else '', user.email, user.first_name, user.last_name, user.institution]
if user.is_default:
table.append(list(map(echo.highlight_string, row)))
else:
table.append(row)

if default_user is None:
echo.echo_warning('no default user has been configured')
echo.echo_tabulate(table, headers=['', 'Email', 'First name', 'Last name', 'Institution'])
echo.echo('')

attributes = ['email', 'first_name', 'last_name']
sort = lambda user: user.email
highlight = lambda x: x.email == default_user.email if default_user else None
echo.echo_formatted_list(User.collection.all(), attributes, sort=sort, highlight=highlight)
if User.collection.get_default() is None:
echo.echo_warning('No default user has been configured')
Copy link
Member

Choose a reason for hiding this comment

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

Curious if this ever can happen. One needs to set up a user to set up a profile, and that would immediately set this user as the default. If you haven't set up a profile, you get:

❯ verdi user list
Critical: no default profile defined: None
{'CONFIG_VERSION': {'CURRENT': 9, 'OLDEST_COMPATIBLE': 9}, 'profiles': {}, 'options': {'autofill.user.email': 'idontwantto@tell.you', 'autofill.user.first_name': 'John', 'autofill.user.last_name': 'Doe', 'autofill.user.institution': 'Unknown'}}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes this is possible. If you delete the user, or you simply change the user's email, or even if the config is corrupted and the default_user_email key is removed. Lot's of possibilities :D

Copy link
Member

Choose a reason for hiding this comment

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

Haha, I guess you're right

❯ verdi user list
    Email      First name    Last name    Institution
--  ---------  ------------  -----------  -------------
    mwhahahha  John          Doe          Unknown

Warning: No default user has been configured

But these paths are probably not really desirable, since once you don't have a default user, you can't even store a simple Int node.

Side tangent: I checked out the docstring of the store method, and it thoroughly confused me.

Utter confusion
In [4]: Int(1).store?
Docstring:
Lightweight persistence for python variables.

Example::

  In [1]: l = ['hello',10,'world']
  In [2]: %store l
  Stored 'l' (list)
  In [3]: exit

  (IPython session is closed and started again...)

  ville@badger:~$ ipython
  In [1]: l
  NameError: name 'l' is not defined
  In [2]: %store -r
  In [3]: l
  Out[3]: ['hello', 10, 'world']

Usage:

* ``%store``          - Show list of all variables and their current
                        values
* ``%store spam bar`` - Store the *current* value of the variables spam
                        and bar to disk
* ``%store -d spam``  - Remove the variable and its value from storage
* ``%store -z``       - Remove all variables from storage
* ``%store -r``       - Refresh all variables, aliases and directory history
                        from store (overwrite current vals)
* ``%store -r spam bar`` - Refresh specified variables and aliases from store
                           (delete current val)
* ``%store foo >a.txt``  - Store value of foo to new file a.txt
* ``%store foo >>a.txt`` - Append value of foo to file a.txt

It should be noted that if you change the value of a variable, you
need to %store it again if you want to persist the new value.

Note also that the variables will need to be pickleable; most basic
python types can be safely %store'd.

Also aliases can be %store'd across sessions.
To remove an alias from the storage, use the %unalias magic.
File:      ~/.virtualenvs/tmp/lib/python3.11/site-packages/IPython/extensions/storemagic.py

else:
echo.echo_report('The user highlighted and marked with a * is the default user.')


@verdi_user.command('configure')
Expand All @@ -78,57 +54,31 @@ def user_list():
type=types.UserParamType(create=True),
cls=options.interactive.InteractiveOption
)
@click.option(
'--first-name',
prompt='First name',
help='First name of the user.',
type=click.STRING,
contextual_default=partial(get_user_attribute_default, 'first_name'),
cls=options.interactive.InteractiveOption
)
@click.option(
'--last-name',
prompt='Last name',
help='Last name of the user.',
type=click.STRING,
contextual_default=partial(get_user_attribute_default, 'last_name'),
cls=options.interactive.InteractiveOption
)
@click.option(
'--institution',
prompt='Institution',
help='Institution of the user.',
type=click.STRING,
contextual_default=partial(get_user_attribute_default, 'institution'),
cls=options.interactive.InteractiveOption
)
@options_setup.SETUP_USER_FIRST_NAME(contextual_default=lambda ctx: ctx.params['user'].first_name)
@options_setup.SETUP_USER_LAST_NAME(contextual_default=lambda ctx: ctx.params['user'].last_name)
@options_setup.SETUP_USER_INSTITUTION(contextual_default=lambda ctx: ctx.params['user'].institution)
@click.option(
'--set-default',
prompt='Set as default?',
help='Set the user as the default user for the current profile.',
is_flag=True,
cls=options.interactive.InteractiveOption
cls=options.interactive.InteractiveOption,
contextual_default=lambda ctx: ctx.params['user'].is_default
)
@click.pass_context
@decorators.with_dbenv()
def user_configure(ctx, user, first_name, last_name, institution, set_default):
def user_configure(ctx, user, first_name, last_name, institution, set_default): # pylint: disable=too-many-arguments
mbercx marked this conversation as resolved.
Show resolved Hide resolved
"""Configure a new or existing user.

An e-mail address is used as the user name.
mbercx marked this conversation as resolved.
Show resolved Hide resolved
"""
# pylint: disable=too-many-arguments
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if institution is not None:
user.institution = institution

action = 'updated' if user.is_stored else 'created'

user.first_name = first_name
user.last_name = last_name
user.institution = institution
user.store()

echo.echo_success(f'{user.email} successfully {action}')
echo.echo_success(f'Successfully {action} `{user.email}`.')

if set_default:
ctx.invoke(user_set_default, user=user)
Expand All @@ -140,5 +90,6 @@ def user_configure(ctx, user, first_name, last_name, institution, set_default):
@decorators.with_dbenv()
def user_set_default(ctx, user):
"""Set a user as the default user for the profile."""
set_default_user(ctx.obj.profile, user)
echo.echo_success(f'set `{user.email}` as the new default user for profile `{ctx.obj.profile.name}`')
from aiida.manage import get_manager
get_manager().set_default_user_email(ctx.obj.profile, user.email)
echo.echo_success(f'Set `{user.email}` as the default user for profile `{ctx.obj.profile.name}.`')
6 changes: 3 additions & 3 deletions aiida/cmdline/params/options/commands/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,21 +186,21 @@ def get_quicksetup_password(ctx, param, value): # pylint: disable=unused-argume

SETUP_USER_FIRST_NAME = options.USER_FIRST_NAME.clone(
prompt='First name',
default=functools.partial(get_config_option, 'autofill.user.first_name'),
default=lambda: get_config_option('autofill.user.first_name') or 'John',
required=True,
cls=options.interactive.InteractiveOption
)

SETUP_USER_LAST_NAME = options.USER_LAST_NAME.clone(
prompt='Last name',
default=functools.partial(get_config_option, 'autofill.user.last_name'),
default=lambda: get_config_option('autofill.user.last_name') or 'Doe',
required=True,
cls=options.interactive.InteractiveOption
)

SETUP_USER_INSTITUTION = options.USER_INSTITUTION.clone(
prompt='Institution',
default=functools.partial(get_config_option, 'autofill.user.institution'),
default=lambda: get_config_option('autofill.user.institution') or 'Unknown',
required=True,
cls=options.interactive.InteractiveOption
)
Expand Down
12 changes: 12 additions & 0 deletions aiida/cmdline/utils/echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ class ExitCode(enum.IntEnum):
}


def highlight_string(string: str, color: str = 'highlight') -> str:
"""Highlight a string with a certain color.

Uses ``click.style`` to highlight the string.

:param string: The string to highlight.
:param color: The color to use.
:returns: The highlighted string.
"""
return click.style(string, fg=COLORS[color])


def echo(message: Any, fg: Optional[str] = None, bold: bool = False, nl: bool = True, err: bool = False) -> None:
"""Log a message to the cmdline logger.

Expand Down
7 changes: 3 additions & 4 deletions aiida/manage/configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,9 @@ def create_profile(

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()
# We can safely use ``Config.set_default_user_email`` here instead of ``Manager.set_default_user_email`` since
# the storage backend of this new profile is not loaded yet.
config.set_default_user_email(profile, user.email)

return profile

Expand Down
15 changes: 15 additions & 0 deletions aiida/manage/configuration/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,21 @@ def set_default_profile(self, name, overwrite=False):
self._default_profile = name
return self

def set_default_user_email(self, profile: Profile, user_email: str) -> None:
"""Set the default user for the given profile.

.. warning::

This does not update the cached default user on the storage backend associated with the profile. To do so,
use :meth:`aiida.manage.manager.Manager.set_default_user_email` instead.

:param profile: The profile to update.
:param user_email: The email of the user to set as the default user.
"""
profile.default_user_email = user_email
self.update_profile(profile)
self.store()

@property
def options(self):
return self._options
Expand Down
10 changes: 10 additions & 0 deletions aiida/manage/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@ def unload_profile(self) -> None:
self.reset_profile()
self._profile = None

def set_default_user_email(self, profile: 'Profile', user_email: str) -> None:
"""Set the default user for the given profile.

:param profile: The profile to update.
:param user_email: The email of the user to set as the default user.
"""
self.get_config().set_default_user_email(profile, user_email)
if self.profile_storage_loaded:
self.get_profile_storage().reset_default_user()

@property
def profile_storage_loaded(self) -> bool:
"""Return whether a storage backend has been loaded.
Expand Down
8 changes: 8 additions & 0 deletions aiida/orm/implementation/storage_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,15 @@ def _clear(self) -> None:
.. warning:: This is a destructive operation, and should only be used for testing purposes.
"""
from aiida.orm.autogroup import AutogroupManager
self.reset_default_user()
self._autogroup = AutogroupManager(self)

def reset_default_user(self) -> None:
"""Reset the default user.

This should be done when the default user of the storage backend is changed on the corresponding profile because
the old default user is cached on this instance.
"""
self._default_user = None

@property
Expand Down
10 changes: 9 additions & 1 deletion aiida/orm/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from typing import TYPE_CHECKING, Optional, Tuple, Type

from aiida.common import exceptions
from aiida.common.lang import classproperty
from aiida.manage import get_manager

from . import entities
Expand Down Expand Up @@ -84,6 +83,15 @@ def normalize_email(email: str) -> str:
email = '@'.join([email_name, domain_part.lower()])
return email

@property
def is_default(self) -> bool:
"""Return whether the user is the default user.

:returns: Boolean, ``True`` if the user is the default, ``False`` otherwise.
"""
default_user = self.collection.get_default()
mbercx marked this conversation as resolved.
Show resolved Hide resolved
return default_user is not None and self.pk == default_user.pk

@property
def email(self) -> str:
return self._backend_entity.email
Expand Down
3 changes: 1 addition & 2 deletions aiida/transports/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from aiida.cmdline.utils import echo
from aiida.cmdline.utils.decorators import with_dbenv
from aiida.common.exceptions import NotExistent
from aiida.manage import get_manager

TRANSPORT_PARAMS = []

Expand All @@ -40,7 +39,7 @@ def configure_computer_main(computer, user, **kwargs):
user = user or orm.User.collection.get_default()

echo.echo_report(f'Configuring computer {computer.label} for user {user.email}.')
if user.email != get_manager().get_profile().default_user_email:
if not user.is_default:
echo.echo_report('Configuring different user, defaults may not be appropriate.')

computer.configure(user=user, **kwargs)
Expand Down
16 changes: 16 additions & 0 deletions tests/cmdline/commands/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ def test_quicksetup(self, tmp_path):
backend = profile.storage_cls(profile)
assert backend.get_global_variable('repository|uuid') == backend.get_repository().uuid

def test_quicksetup_default_user(self, tmp_path):
"""Test `verdi quicksetup` and ensure that user details (apart from the email) are optional."""
profile_name = 'testing-default-user-details'
user_email = 'some@email.com'

options = [
'--non-interactive', '--profile', profile_name, '--email', user_email, '--db-port',
self.pg_test.dsn['port'], '--db-backend', self.storage_backend_name, '--repository',
str(tmp_path)
]

self.cli_runner(cmd_setup.quicksetup, options, use_subprocess=False)

config = configuration.get_config()
assert profile_name in config.profile_names

def test_quicksetup_from_config_file(self, tmp_path):
"""Test `verdi quicksetup` from configuration file."""
with tempfile.NamedTemporaryFile('w') as handle:
Expand Down
Loading
Loading