From ec6d7ea6293ac1d9dd84cbb7f1fda5f58155769f Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 17 Oct 2019 00:19:51 +0100 Subject: [PATCH] `GroupPath`: a utility to work with virtual `Group` hierarchies Groups can be used to store nodes in AiiDA, but do not have any builtin hierarchy themselves. However, often it may be useful to think of groups as folders on a filesystem and the nodes within them as the files. Building this functionality directly on the database would require significant changes, but a virtual hierarchy based on the group labels can be readily provided. This is what the new utility class `GroupPath` facilitates. It allows group labels to be interpreted as the hierarchy of groups. Example: consider one has groups with the following labels group/sub/a group/sub/b group/other/c One could see this as the group `group` containing the sub groups `sub` and `other`, with `sub` containing `a` and `b` itself. The `GroupPath` class allows one to exploit this hierarchical naming:: path = GroupPath('group') path.sub.a.get_group() # will return group with label `group/sub/a` It can also be used to create groups that do not yet exist: path = GroupPath() path.some.group.get_or_create_group() This will create a `Group` with the label `some/group`. The `GroupPath` class implements many other useful methods to make the traversing and manipulating of groups a lot easier. --- .ci/workchains.py | 1 + .pylintrc | 2 +- aiida/cmdline/commands/cmd_group.py | 69 +++++ aiida/tools/groups/__init__.py | 11 + aiida/tools/groups/paths.py | 352 ++++++++++++++++++++++++ docs/source/verdi/verdi_user_guide.rst | 1 + tests/cmdline/commands/test_group_ls.py | 127 +++++++++ tests/tools/groups/__init__.py | 9 + tests/tools/groups/test_paths.py | 161 +++++++++++ 9 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 aiida/tools/groups/__init__.py create mode 100644 aiida/tools/groups/paths.py create mode 100644 tests/cmdline/commands/test_group_ls.py create mode 100644 tests/tools/groups/__init__.py create mode 100644 tests/tools/groups/test_paths.py diff --git a/.ci/workchains.py b/.ci/workchains.py index f5ab3872d7..4ae521540f 100644 --- a/.ci/workchains.py +++ b/.ci/workchains.py @@ -7,6 +7,7 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### +# pylint: disable=invalid-name from aiida.common import AttributeDict from aiida.engine import calcfunction, workfunction, WorkChain, ToContext, append_, while_, ExitCode from aiida.engine import BaseRestartWorkChain, process_handler, ProcessHandlerReport diff --git a/.pylintrc b/.pylintrc index 9e3adfb075..8fc7a6a3c3 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=bad-continuation,locally-disabled,useless-suppression,django-not-available,bad-option-value,logging-format-interpolation,no-else-raise,import-outside-toplevel +disable=bad-continuation,locally-disabled,useless-suppression,django-not-available,bad-option-value,logging-format-interpolation,no-else-raise,import-outside-toplevel,cyclic-import # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/aiida/cmdline/commands/cmd_group.py b/aiida/cmdline/commands/cmd_group.py index d74e416bd5..16978379ae 100644 --- a/aiida/cmdline/commands/cmd_group.py +++ b/aiida/cmdline/commands/cmd_group.py @@ -361,3 +361,72 @@ def group_copy(source_group, destination_group): # Copy nodes dest_group.add_nodes(list(source_group.nodes)) echo.echo_success('Nodes copied from group<{}> to group<{}>'.format(source_group.label, dest_group.label)) + + +@verdi_group.group('path') +def verdi_group_path(): + """Inspect groups of nodes, with delimited label paths.""" + + +@verdi_group_path.command('ls') +@click.argument('path', type=click.STRING, required=False) +@click.option('-R', '--recursive', is_flag=True, default=False, help='Recursively list sub-paths encountered') +@click.option('-l', '--long', 'as_table', is_flag=True, default=False, help='List as a table, with sub-group count') +@click.option( + '-d', '--with-description', 'with_description', is_flag=True, default=False, help='Show also the group description' +) +@click.option( + '--no-virtual', + 'no_virtual', + is_flag=True, + default=False, + help='Only show paths that fully correspond to an existing group' +) +@click.option( + '-t', + '--type', + 'group_type', + type=types.LazyChoice(valid_group_type_strings), + default=user_defined_group, + help='Show groups of a specific type, instead of user-defined groups. Start with semicolumn if you want to ' + 'specify aiida-internal type' +) +@click.option('--no-warn', is_flag=True, default=False, help='Do not issue a warning if any paths are invalid.') +@with_dbenv() +def group_path_ls(path, recursive, as_table, no_virtual, group_type, with_description, no_warn): + # pylint: disable=too-many-arguments + """Show a list of existing group paths.""" + from aiida.tools.groups.paths import GroupPath, InvalidPath + + try: + path = GroupPath(path or '', type_string=group_type, warn_invalid_child=not no_warn) + except InvalidPath as err: + echo.echo_critical(str(err)) + + if recursive: + children = path.walk() + else: + children = path.children + + if as_table or with_description: + from tabulate import tabulate + headers = ['Path', 'Sub-Groups'] + if with_description: + headers.append('Description') + rows = [] + for child in sorted(children): + if no_virtual and child.is_virtual: + continue + row = [ + child.path if child.is_virtual else click.style(child.path, bold=True), + len([c for c in child.walk() if not c.is_virtual]) + ] + if with_description: + row.append('-' if child.is_virtual else child.get_group().description) + rows.append(row) + echo.echo(tabulate(rows, headers=headers)) + else: + for child in sorted(children): + if no_virtual and child.is_virtual: + continue + echo.echo(child.path, bold=not child.is_virtual) diff --git a/aiida/tools/groups/__init__.py b/aiida/tools/groups/__init__.py new file mode 100644 index 0000000000..7d429eeab7 --- /dev/null +++ b/aiida/tools/groups/__init__.py @@ -0,0 +1,11 @@ +# 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=wildcard-import,undefined-variable +"""Provides tools for interacting with AiiDA Groups.""" +from .paths import * + +__all__ = paths.__all__ diff --git a/aiida/tools/groups/paths.py b/aiida/tools/groups/paths.py new file mode 100644 index 0000000000..9d20ea9c55 --- /dev/null +++ b/aiida/tools/groups/paths.py @@ -0,0 +1,352 @@ +# -*- 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 # +########################################################################### +"""Provides functionality for managing large numbers of AiiDA Groups, via label delimitation.""" +from collections import namedtuple +from functools import total_ordering +import re +from typing import Any, Iterable, List, Optional # pylint: disable=unused-import +import warnings + +from aiida import orm +from aiida.common.exceptions import NotExistent + +__all__ = ('GroupPath', 'InvalidPath') + +REGEX_ATTR = re.compile('^[a-zA-Z][\\_a-zA-Z0-9]*$') + + +class InvalidPath(Exception): + """An exception to indicate that a path is not valid.""" + + +class GroupNotFoundError(Exception): + """An exception raised when a path does not have an associated group.""" + + def __init__(self, grouppath): + msg = 'No such group: {}'.format(grouppath.path) + super().__init__(msg) + + +class GroupNotUniqueError(Exception): + """An exception raised when a path has multiple associated groups.""" + + def __init__(self, grouppath): + msg = 'The path is not unique: {}'.format(grouppath.path) + super().__init__(msg) + + +class NoGroupsInPathError(Exception): + """An exception raised when a path has multiple associated groups.""" + + def __init__(self, grouppath): + msg = 'The path does not contain any descendant groups: {}'.format(grouppath.path) + super().__init__(msg) + + +WalkNodeResult = namedtuple('WalkNodeResult', ['group_path', 'node']) + + +@total_ordering +class GroupPath: + """A class to provide label delimited access to groups. + + See tests for usage examples. + """ + + def __init__(self, path='', type_string=orm.GroupTypeString.USER.value, warn_invalid_child=True): + # type: (str, Optional[str], Optional[GroupPath]) + """Instantiate the class. + + :param path: The initial path of the group. + :param type_string: Used to query for and instantiate a ``Group`` with. + :param warn_invalid_child: Issue a warning, when iterating children, if a child path is invalid. + + """ + self._delimiter = '/' + if not isinstance(type_string, str): + raise TypeError('type_string must a str: {}'.format(type_string)) + self._type_string = type_string + self._path_string = self._validate_path(path) + self._path_list = self._path_string.split(self._delimiter) if path else [] + self._warn_invalid_child = warn_invalid_child + + def _validate_path(self, path): + """Validate the supplied path.""" + if path == self._delimiter: + return '' + if self._delimiter * 2 in path: + raise InvalidPath("The path may not contain a duplicate delimiter '{}': {}".format(self._delimiter, path)) + if (path.startswith(self._delimiter) or path.endswith(self._delimiter)): + raise InvalidPath("The path may not start/end with the delimiter '{}': {}".format(self._delimiter, path)) + return path + + def __repr__(self): + # type: () -> str + """Represent the instantiated class.""" + return "{}('{}', type='{}')".format(self.__class__.__name__, self.path, self.type_string) + + def __eq__(self, other): + # type: (Any) -> bool + """Compare equality of path and type string to another ``GroupPath`` object.""" + if not isinstance(other, GroupPath): + return NotImplemented + return (self.path, self.type_string) == (other.path, other.type_string) + + def __lt__(self, other): + # type: (Any) -> bool + """Compare less-than operator of path and type string to another ``GroupPath`` object.""" + if not isinstance(other, GroupPath): + return NotImplemented + return (self.path, self.type_string) < (other.path, other.type_string) + + @property + def path(self): + # type: () -> str + """Return the path string.""" + return self._path_string + + @property + def path_list(self): + # type: () -> List[str] + """Return a list of the path components.""" + return self._path_list[:] + + @property + def key(self): + # type: () -> str + """Return the final component of the the path.""" + if self._path_list: + return self._path_list[-1] + return None + + @property + def delimiter(self): + # type: () -> str + """Return the delimiter used to split path into components.""" + return self._delimiter + + @property + def type_string(self): + # type: () -> str + """Return the type_string used to query for and instantiate a ``Group`` with.""" + return self._type_string + + @property + def parent(self): + # type: () -> Optional[GroupPath] + """Return the parent path.""" + if self.path_list: + return GroupPath( + self.delimiter.join(self.path_list[:-1]), + type_string=self.type_string, + warn_invalid_child=self._warn_invalid_child + ) + return None + + def __truediv__(self, path): + # type: (str) -> GroupPath + """Return a child ``GroupPath``, with a new path formed by appending ``path`` to the current path.""" + if not isinstance(path, str): + raise TypeError('path is not a string: {}'.format(path)) + path = self._validate_path(path) + child = GroupPath( + path=self.path + self.delimiter + path if self.path else path, + type_string=self.type_string, + warn_invalid_child=self._warn_invalid_child + ) + return child + + def __getitem__(self, path): + # type: (str) -> GroupPath + """Return a child ``GroupPath``, with a new path formed by appending ``path`` to the current path.""" + return self.__truediv__(path) + + def get_group(self): + # type: () -> Optional[orm.Group] + """Return the concrete group associated with this path.""" + try: + return orm.Group.objects.get(label=self.path, type_string=self.type_string) + except NotExistent: + return None + + @property + def group_ids(self): + # type: () -> List[int] + """Return all the UUID associated with this GroupPath. + + :returns: and empty list, if no group associated with this label, + or can be multiple if type_string was None + + This is an efficient method for checking existence, + which does not require the (slow) loading of the ORM entity. + """ + query = orm.QueryBuilder() + filters = {'label': self.path} + if self.type_string is not None: + filters['type_string'] = self.type_string + query.append(orm.Group, filters=filters, project='id') + return [r[0] for r in query.all()] + + @property + def is_virtual(self): + # type: () -> bool + """Return whether there is one or more concrete groups associated with this path.""" + return len(self.group_ids) == 0 + + def get_or_create_group(self): + # type: () -> (orm.Group, bool) + """Return the concrete group associated with this path or, create it, if it does not already exist.""" + if self.type_string is not None: + return orm.Group.objects.get_or_create(label=self.path, type_string=self.type_string) + return orm.Group.objects.get_or_create(label=self.path) + + def delete_group(self): + """Delete the concrete group associated with this path. + + :raises: GroupNotFoundError, GroupNotUniqueError + """ + ids = self.group_ids + if not ids: + raise GroupNotFoundError(self) + if len(ids) > 1: + raise GroupNotUniqueError(self) + orm.Group.objects.delete(ids[0]) + + @property + def children(self): + # type: () -> Iterable[GroupPath] + """Iterate through all (direct) children of this path.""" + query = orm.QueryBuilder() + filters = {} + if self.path: + filters['label'] = {'like': self.path + self.delimiter + '%'} + if self.type_string is not None: + filters['type_string'] = self.type_string + query.append(orm.Group, filters=filters, project='label') + if query.count() == 0 and self.is_virtual: + raise NoGroupsInPathError(self) + + yielded = [] + for (label,) in query.iterall(): + path = label.split(self._delimiter) + if len(path) <= len(self._path_list): + continue + path_string = self._delimiter.join(path[:len(self._path_list) + 1]) + if (path_string not in yielded and path[:len(self._path_list)] == self._path_list): + yielded.append(path_string) + try: + yield GroupPath( + path=path_string, type_string=self.type_string, warn_invalid_child=self._warn_invalid_child + ) + except InvalidPath: + if self._warn_invalid_child: + warnings.warn('invalid path encountered: {}'.format(path_string)) # pylint: disable=no-member + + def __iter__(self): + # type: () -> Iterable[GroupPath] + """Iterate through all (direct) children of this path.""" + return self.children + + def __len__(self): + # type: () -> int + """Return the number of children for this path.""" + return sum(1 for _ in self.children) + + def __contains__(self, key): + # type: (str) -> bool + """Return whether a child exists for this key.""" + for child in self.children: + if child.path_list[-1] == key: + return True + return False + + def walk(self, return_virtual=True): + # type: () -> Iterable[GroupPath] + """Recursively iterate through all children of this path.""" + for child in self: + if return_virtual or not child.is_virtual: + yield child + for sub_child in child.walk(return_virtual=return_virtual): + if return_virtual or not sub_child.is_virtual: + yield sub_child + + def walk_nodes(self, filters=None, node_class=None, query_batch=None): + # type: () -> Iterable[WalkNodeResult] + """Recursively iterate through all nodes of this path and its children. + + :param filters: filters to apply to the node query + :param node_class: return only nodes of a certain class (or list of classes) + :param int batch_size: The size of the batches to ask the backend to batch results in subcollections. + You can optimize the speed of the query by tuning this parameter. + Be aware though that is only safe if no commit will take place during this transaction. + """ + query = orm.QueryBuilder() + group_filters = {} + if self.path: + group_filters['label'] = {'or': [{'==': self.path}, {'like': self.path + self.delimiter + '%'}]} + if self.type_string is not None: + group_filters['type_string'] = self.type_string + query.append(orm.Group, filters=group_filters, project='label', tag='group') + query.append( + orm.Node if node_class is None else node_class, + with_group='group', + filters=filters, + project=['*'], + ) + for (label, node) in query.iterall(query_batch) if query_batch else query.all(): + yield WalkNodeResult(GroupPath(label, type_string=self.type_string), node) + + @property + def browse(self): + """Return a ``GroupAttr`` instance, for attribute access to children.""" + return GroupAttr(self) + + +class GroupAttr: + """A class to provide attribute access to a ``GroupPath`` children. + + The only public attributes on this class are dynamically created from the ``GroupPath`` child keys. + NOTE: any child keys that do not conform to an acceptable (public) attribute string will be ignored. + The ``GroupPath`` can be retrieved *via* a function call, e.g.:: + + group_path = GroupPath() + group_attr = GroupAttr(group_path) + group_attr.a.b.c() == GroupPath("a/b/c") + + """ + + def __init__(self, group_path): + # type: (GroupPath) + """Instantiate the ``GroupPath``, and a mapping of its children.""" + self._group_path = group_path + + def __repr__(self): + # type: () -> str + """Represent the instantiated class.""" + return "{}('{}', type='{}')".format( + self.__class__.__name__, self._group_path.path, self._group_path.type_string + ) + + def __call__(self): + # type: () -> GroupPath + """Return the ``GroupPath``.""" + return self._group_path + + def __dir__(self): + """Return a list of available attributes.""" + return [c.path_list[-1] for c in self._group_path.children if REGEX_ATTR.match(c.path_list[-1])] + + def __getattr__(self, attr): + # type: (str) -> GroupAttr + """Return the requested attribute name.""" + for child in self._group_path.children: + if attr == child.path_list[-1]: + return GroupAttr(child) + raise AttributeError(attr) diff --git a/docs/source/verdi/verdi_user_guide.rst b/docs/source/verdi/verdi_user_guide.rst index 8265083e6d..1fc8bfabe8 100644 --- a/docs/source/verdi/verdi_user_guide.rst +++ b/docs/source/verdi/verdi_user_guide.rst @@ -436,6 +436,7 @@ Below is a list with all available subcommands. delete Delete a group. description Change the description of a group. list Show a list of existing groups. + path Inspect groups of nodes, with delimited label paths. relabel Change the label of a group. remove-nodes Remove nodes from a group. show Show information for a given group. diff --git a/tests/cmdline/commands/test_group_ls.py b/tests/cmdline/commands/test_group_ls.py new file mode 100644 index 0000000000..7cc01079a4 --- /dev/null +++ b/tests/cmdline/commands/test_group_ls.py @@ -0,0 +1,127 @@ +# -*- 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 GroupPath command line interface""" +# pylint: disable=redefined-outer-name,unused-argument +from textwrap import dedent + +from click.testing import CliRunner +import pytest + +from aiida import orm +from aiida.cmdline.commands.cmd_group import group_path_ls + + +@pytest.fixture +def setup_groups(clear_database_before_test): + """Setup some groups for testing.""" + for label in ['a', 'a/b', 'a/c/d', 'a/c/e/g', 'a/f']: + group, _ = orm.Group.objects.get_or_create(label, type_string=orm.GroupTypeString.USER.value) + group.description = 'A description of {}'.format(label) + orm.Group.objects.get_or_create('a/x', type_string=orm.GroupTypeString.UPFGROUP_TYPE.value) + yield + + +def test_with_no_opts(setup_groups): + """Test ``verdi group path ls``""" + + cli_runner = CliRunner() + + result = cli_runner.invoke(group_path_ls) + assert result.exit_code == 0, result.exception + assert result.output == 'a\n' + + result = cli_runner.invoke(group_path_ls, ['a']) + assert result.exit_code == 0, result.exception + assert result.output == 'a/b\na/c\na/f\n' + + result = cli_runner.invoke(group_path_ls, ['a/c']) + assert result.exit_code == 0, result.exception + assert result.output == 'a/c/d\na/c/e\n' + + +def test_recursive(setup_groups): + """Test ``verdi group path ls --recursive``""" + + cli_runner = CliRunner() + + for tag in ['-R', '--recursive']: + result = cli_runner.invoke(group_path_ls, [tag]) + assert result.exit_code == 0, result.exception + assert result.output == 'a\na/b\na/c\na/c/d\na/c/e\na/c/e/g\na/f\n' + + result = cli_runner.invoke(group_path_ls, [tag, 'a/c']) + assert result.exit_code == 0, result.exception + assert result.output == 'a/c/d\na/c/e\na/c/e/g\n' + + +@pytest.mark.parametrize('tag', ['-l', '--long']) +def test_long(setup_groups, tag): + """Test ``verdi group path ls --long``""" + + cli_runner = CliRunner() + + result = cli_runner.invoke(group_path_ls, [tag]) + assert result.exit_code == 0, result.exception + assert result.output == dedent( + """\ + Path Sub-Groups + ------ ------------ + a 4 + """ + ) + + result = cli_runner.invoke(group_path_ls, [tag, '-d', 'a']) + assert result.exit_code == 0, result.exception + assert result.output == dedent( + """\ + Path Sub-Groups Description + ------ ------------ -------------------- + a/b 0 A description of a/b + a/c 2 - + a/f 0 A description of a/f + """ + ) + + result = cli_runner.invoke(group_path_ls, [tag, '-R']) + assert result.exit_code == 0, result.exception + assert result.output == dedent( + """\ + Path Sub-Groups + ------- ------------ + a 4 + a/b 0 + a/c 2 + a/c/d 0 + a/c/e 1 + a/c/e/g 0 + a/f 0 + """ + ) + + +@pytest.mark.parametrize('tag', ['--no-virtual']) +def test_groups_only(setup_groups, tag): + """Test ``verdi group path ls --no-virtual``""" + + cli_runner = CliRunner() + + result = cli_runner.invoke(group_path_ls, [tag, '-l', '-R', '--with-description']) + assert result.exit_code == 0, result.exception + assert result.output == dedent( + """\ + Path Sub-Groups Description + ------- ------------ ------------------------ + a 4 A description of a + a/b 0 A description of a/b + a/c/d 0 A description of a/c/d + a/c/e/g 0 A description of a/c/e/g + a/f 0 A description of a/f + """ + ) diff --git a/tests/tools/groups/__init__.py b/tests/tools/groups/__init__.py new file mode 100644 index 0000000000..2776a55f97 --- /dev/null +++ b/tests/tools/groups/__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/tests/tools/groups/test_paths.py b/tests/tools/groups/test_paths.py new file mode 100644 index 0000000000..a6f1cdb757 --- /dev/null +++ b/tests/tools/groups/test_paths.py @@ -0,0 +1,161 @@ +# -*- 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 GroupPath""" +# pylint: disable=redefined-outer-name,unused-argument +import pytest + +from aiida import orm +from aiida.tools.groups.paths import (GroupAttr, GroupPath, InvalidPath, GroupNotFoundError, NoGroupsInPathError) + + +@pytest.fixture +def setup_groups(clear_database_before_test): + """Setup some groups for testing.""" + for label in ['a', 'a/b', 'a/c/d', 'a/c/e/g', 'a/f']: + group, _ = orm.Group.objects.get_or_create(label, type_string=orm.GroupTypeString.USER.value) + group.description = 'A description of {}'.format(label) + yield + + +@pytest.mark.parametrize('path', ('/a', 'a/', '/a/', 'a//b')) +def test_invalid_paths(setup_groups, path): + """Invalid paths should raise an ``InvalidPath`` exception.""" + with pytest.raises(InvalidPath): + GroupPath(path=path) + + +def test_root_path(setup_groups): + """Test the root path properties""" + group_path = GroupPath() + assert group_path.path == '' + assert group_path.delimiter == '/' + assert group_path.parent is None + assert group_path.is_virtual + assert group_path.get_group() is None + + +def test_path_concatenation(setup_groups): + """Test methods to build a new path.""" + group_path = GroupPath() + assert (group_path / 'a').path == 'a' + assert (group_path / 'a' / 'b').path == 'a/b' + assert (group_path / 'a/b').path == 'a/b' + assert group_path['a/b'].path == 'a/b' + assert GroupPath('a/b/c') == GroupPath('a/b') / 'c' + + +def test_path_existence(setup_groups): + """Test existence of child "folders".""" + group_path = GroupPath() + assert 'a' in group_path + assert 'x' not in group_path + + +def test_group_retrieval(setup_groups): + """Test retrieval of the actual group from a path. + + The ``group`` attribute will return None + if no group is associated with the path + """ + group_path = GroupPath() + assert group_path['x'].is_virtual + assert not group_path['a'].is_virtual + assert group_path.get_group() is None + assert isinstance(group_path['a'].get_group(), orm.Group) + + +def test_group_creation(setup_groups): + """Test creation of new groups.""" + group_path = GroupPath() + group, created = group_path['a'].get_or_create_group() + assert isinstance(group, orm.Group) + assert created is False + group, created = group_path['x'].get_or_create_group() + assert isinstance(group, orm.Group) + assert created is True + + +def test_group_deletion(setup_groups): + """Test deletion of existing groups.""" + group_path = GroupPath() + assert not group_path['a'].is_virtual + group_path['a'].delete_group() + assert group_path['a'].is_virtual + with pytest.raises(GroupNotFoundError): + group_path['a'].delete_group() + + +def test_path_iteration(setup_groups): + """Test iteration of groups.""" + group_path = GroupPath() + assert len(group_path) == 1 + assert [(c.path, c.is_virtual) for c in group_path.children] == [('a', False)] + child = next(group_path.children) + assert child.parent == group_path + assert len(child) == 3 + assert [(c.path, c.is_virtual) for c in sorted(child)] == [('a/b', False), ('a/c', True), ('a/f', False)] + + +def test_path_with_no_groups(setup_groups): + """Test ``NoGroupsInPathError`` is raised if the path contains descendant groups.""" + group_path = GroupPath() + with pytest.raises(NoGroupsInPathError): + list(group_path['x']) + + +def test_walk(setup_groups): + """Test the ``GroupPath.walk()`` function.""" + group_path = GroupPath() + assert [c.path for c in sorted(group_path.walk())] == ['a', 'a/b', 'a/c', 'a/c/d', 'a/c/e', 'a/c/e/g', 'a/f'] + + +def test_walk_with_invalid_path(clear_database_before_test): + for label in ['a', 'a/b', 'a/c/d', 'a/c/e/g', 'a/f', 'bad//group', 'bad/other']: + orm.Group.objects.get_or_create(label, type_string=orm.GroupTypeString.USER.value) + group_path = GroupPath() + assert [c.path for c in sorted(group_path.walk()) + ] == ['a', 'a/b', 'a/c', 'a/c/d', 'a/c/e', 'a/c/e/g', 'a/f', 'bad', 'bad/other'] + + +def test_walk_nodes(clear_database): + """Test the ``GroupPath.walk_nodes()`` function.""" + group, _ = orm.Group.objects.get_or_create('a', type_string=orm.GroupTypeString.USER.value) + node = orm.Data() + node.set_attribute_many({'i': 1, 'j': 2}) + node.store() + group.add_nodes(node) + group_path = GroupPath() + assert [(r.group_path.path, r.node.attributes) for r in group_path.walk_nodes()] == [('a', {'i': 1, 'j': 2})] + + +def test_type_string(clear_database_before_test): + """Test that only the type_string instantiated in ``GroupPath`` is returned.""" + for label in ['a', 'a/b', 'a/c/d', 'a/c/e/g']: + orm.Group.objects.get_or_create(label, type_string=orm.GroupTypeString.USER.value) + for label in ['a/c/e', 'a/f']: + orm.Group.objects.get_or_create(label, type_string=orm.GroupTypeString.UPFGROUP_TYPE.value) + group_path = GroupPath() + assert sorted([c.path for c in group_path.walk()]) == ['a', 'a/b', 'a/c', 'a/c/d', 'a/c/e', 'a/c/e/g'] + group_path = GroupPath(type_string=orm.GroupTypeString.UPFGROUP_TYPE.value) + assert sorted([c.path for c in group_path.walk()]) == ['a', 'a/c', 'a/c/e', 'a/f'] + assert GroupPath('a/b/c') != GroupPath('a/b/c', type_string=orm.GroupTypeString.UPFGROUP_TYPE.value) + + +def test_attr(clear_database_before_test): + """Test ``GroupAttr``.""" + for label in ['a', 'a/b', 'a/c/d', 'a/c/e/g', 'a/f', 'bad space', 'bad@char', '_badstart']: + orm.Group.objects.get_or_create(label) + group_path = GroupPath() + assert isinstance(group_path.browse.a.c.d, GroupAttr) + assert isinstance(group_path.browse.a.c.d(), GroupPath) + assert group_path.browse.a.c.d().path == 'a/c/d' + assert not set(group_path.browse.__dir__()).intersection(['bad space', 'bad@char', '_badstart']) + with pytest.raises(AttributeError): + group_path.browse.a.c.x # pylint: disable=pointless-statement