diff --git a/README.md b/README.md index b7e80d7..df8ad51 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,36 @@ This is a module to interact with the [Grouper Web Services API](https://spaces.at.internet2.edu/display/Grouper/Grouper+Web+Services). +## Basic Usage + +Operations will start by creating a `GrouperClient` object. + +``` python +from grouper_python import GrouperClient + +grouper_client = GrouperClient(base_url, username, password) +``` + +`GrouperClient` can also be used as a context manager. + +``` python +from grouper_python import GrouperClient + +with GrouperClient(base_url, username, password) as grouper_client: + ... +``` + +The `base_url` should end in something like +`grouper-ws/servicesRest/v2_6_000`. + +With a `GrouperClient` object, you can query for a subject, stem, or group. +You can also "search" for groups or subjects. + +Once you have an object, you can perform various operations against it. +To create a new group or stem for example, you would get the "parent" stem, +and then use `create_child_stem()` or `create_child_group()` to create that +stem or group in that parent. + ## Installation To install grouper library only: diff --git a/grouper_python/__init__.py b/grouper_python/__init__.py index f844c95..db4e9b6 100644 --- a/grouper_python/__init__.py +++ b/grouper_python/__init__.py @@ -1,9 +1,8 @@ -from .objects.client import Client -from .objects.group import Group -from .objects.stem import Stem -from .objects.subject import Subject -from .objects.person import Person +"""grouper_python, a Python package for interacting with Grouper Web Services.""" -__version__ = "0.1.0" +from .objects.client import GrouperClient -__all__ = ["Client", "Group", "Stem", "Subject", "Person"] +Client = GrouperClient + +__version__ = "0.1.1" +__all__ = ["GrouperClient"] diff --git a/grouper_python/group.py b/grouper_python/group.py index 3e95a94..30f6b60 100644 --- a/grouper_python/group.py +++ b/grouper_python/group.py @@ -1,9 +1,18 @@ +"""grouper-python.group - functions to interact with group objects. + +These are "helper" functions that most likely will not be called directly. +Instead, a GrouperClient class should be created, then from there use that +GrouperClient's methods to find and create objects, and use those objects' methods. +These helper functions are used by those objects, but can be called +directly if needed. +""" + from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from .objects.group import CreateGroup, Group - from .objects.client import Client + from .objects.client import GrouperClient from .objects.subject import Subject from .objects.exceptions import ( GrouperGroupNotFoundException, @@ -15,10 +24,25 @@ def find_group_by_name( group_name: str, - client: Client, + client: GrouperClient, stem: str | None = None, act_as_subject: Subject | None = None, ) -> list[Group]: + """Find a group or groups by approximate name. + + :param group_name: The group name to search for + :type group_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param stem: Optional stem to limit the search to, defaults to None + :type stem: str | None, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperStemNotFoundException: The specified stem cannot be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: List of found groups, will be an empty list if no groups are found + :rtype: list[Group] + """ from .objects.group import Group body = { @@ -45,10 +69,10 @@ def find_group_by_name( raise GrouperStemNotFoundException(str(stem), r) else: # pragma: no cover # Some other issue, so pass the failure through - raise + raise err if "groupResults" in r["WsFindGroupsResults"]: return [ - Group.from_results(client, grp) + Group(client, grp) for grp in r["WsFindGroupsResults"]["groupResults"] ] else: @@ -57,9 +81,20 @@ def find_group_by_name( def create_groups( groups: list[CreateGroup], - client: Client, + client: GrouperClient, act_as_subject: Subject | None = None, ) -> list[Group]: + """Create groups. + + :param groups: List of groups to create + :type groups: list[CreateGroup] + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: Group objects representing the created groups + :rtype: list[Group] + """ from .objects.group import Group groups_to_save = [] @@ -87,16 +122,29 @@ def create_groups( act_as_subject=act_as_subject, ) return [ - Group.from_results(client, result["wsGroup"]) + Group(client, result["wsGroup"]) for result in r["WsGroupSaveResults"]["results"] ] def delete_groups( group_names: list[str], - client: Client, + client: GrouperClient, act_as_subject: Subject | None = None, ) -> None: + """Delete the given groups. + + :param group_names: The names of groups to delete + :type group_names: list[str] + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperPermissionDenied: Permission denied to complete the operation + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + """ group_lookup = [{"groupName": group} for group in group_names] body = { "WsRestGroupDeleteRequest": { @@ -139,10 +187,25 @@ def delete_groups( def get_groups_by_parent( parent_name: str, - client: Client, + client: GrouperClient, recursive: bool = False, act_as_subject: Subject | None = None, ) -> list[Group]: + """Get Groups within the given parent stem. + + :param parent_name: The parent stem to look in + :type parent_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param recursive: Whether to look recursively through the entire subtree (True), + or only one level in the given parent (False), defaults to False + :type recursive: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: The list of Groups found + :rtype: list[Group] + """ from .objects.group import Group body = { @@ -162,7 +225,7 @@ def get_groups_by_parent( ) if "groupResults" in r["WsFindGroupsResults"]: return [ - Group.from_results(client, grp) + Group(client, grp) for grp in r["WsFindGroupsResults"]["groupResults"] ] else: @@ -171,9 +234,23 @@ def get_groups_by_parent( def get_group_by_name( group_name: str, - client: Client, + client: GrouperClient, act_as_subject: Subject | None = None, ) -> Group: + """Get a group with the given name. + + :param group_name: The name of the group to get + :type group_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: The group with the given name + :rtype: Group + """ from .objects.group import Group body = { @@ -186,4 +263,4 @@ def get_group_by_name( r = client._call_grouper("/groups", body, act_as_subject=act_as_subject) if "groupResults" not in r["WsFindGroupsResults"]: raise GrouperGroupNotFoundException(group_name, r) - return Group.from_results(client, r["WsFindGroupsResults"]["groupResults"][0]) + return Group(client, r["WsFindGroupsResults"]["groupResults"][0]) diff --git a/grouper_python/membership.py b/grouper_python/membership.py index 7919a83..62be8e2 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -1,9 +1,18 @@ +"""grouper-python.membership - functions to interact with grouper membership. + +These are "helper" functions that most likely will not be called directly. +Instead, a GrouperClient class should be created, then from there use that +GrouperClient's methods to find and create objects, and use those objects' methods. +These helper functions are used by those objects, but can be called +directly if needed. +""" + from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .objects.group import Group - from .objects.client import Client + from .objects.client import GrouperClient from .objects.membership import Membership, HasMember from .objects.subject import Subject from .objects.exceptions import ( @@ -16,12 +25,38 @@ def get_memberships_for_groups( group_names: list[str], - client: Client, + client: GrouperClient, attributes: list[str] = [], member_filter: str = "all", resolve_groups: bool = True, act_as_subject: Subject | None = None, ) -> dict[Group, list[Membership]]: + """Get memberships for the given groups. + + Note that a "membership" includes more detail than a "member". + + :param group_names: Group names to retreive memberships for + :type group_names: list[str] + :param client: The GrouperClient to use + :type client: GrouperClient + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param member_filter: Type of mebership to return (all, immediate, effective), + defaults to "all" + :type member_filter: str, optional + :param resolve_groups: Whether to resolve subjects that are groups into Group + objects, which will require an additional API call per group, defaults to True + :type resolve_groups: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A dictionary with Groups as the keys + and those groups' memberships list as the value + :rtype: dict[Group, list[Membership]] + """ from .objects.membership import Membership, MembershipType, MemberType from .objects.group import Group @@ -71,7 +106,7 @@ def get_memberships_for_groups( ws_subjects = r["WsGetMembershipsResults"].get("wsSubjects", []) subjects = {ws_subject["id"]: ws_subject for ws_subject in ws_subjects} groups = { - ws_group["uuid"]: Group.from_results(client, ws_group) for ws_group in ws_groups + ws_group["uuid"]: Group(client, ws_group) for ws_group in ws_groups } subject_attr_names = r["WsGetMembershipsResults"].get("subjectAttributeNames", []) r_dict: dict[Group, list[Membership]] = {group: [] for group in groups.values()} @@ -106,12 +141,37 @@ def get_memberships_for_groups( def has_members( group_name: str, - client: Client, + client: GrouperClient, subject_identifiers: list[str] = [], subject_ids: list[str] = [], member_filter: str = "all", act_as_subject: Subject | None = None, ) -> dict[str, HasMember]: + """Determine if the given subjects are members of the given group. + + :param group_name: Name of group to check members + :type group_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param subject_identifiers: Subject identifiers to check for membership, + defaults to [] + :type subject_identifiers: list[str], optional + :param subject_ids: Subject ids to check for membership, + defaults to [], defaults to [] + :type subject_ids: list[str], optional + :param member_filter: Type of mebership to return (all, immediate, effective), + defaults to "all" + :type member_filter: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises ValueError: No subjects were specified + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A dict with the key being the subject (either identifier or id) + and the value being a HasMember enum. + :rtype: dict[str, HasMember] + """ from .objects.membership import HasMember if not subject_identifiers and not subject_ids: @@ -179,12 +239,34 @@ def has_members( def add_members_to_group( group_name: str, - client: Client, + client: GrouperClient, subject_identifiers: list[str] = [], subject_ids: list[str] = [], replace_all_existing: str = "F", act_as_subject: Subject | None = None, ) -> Group: + """Add members to a group. + + :param group_name: The group to add members to + :type group_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param subject_identifiers: Subject identifiers of members to add, defaults to [] + :type subject_identifiers: list[str], optional + :param subject_ids: Subject ids of members to add, defaults to [] + :type subject_ids: list[str], optional + :param replace_all_existing: Whether to replace existing membership of group, + "T" will replace, "F" will only add members, defaults to "F" + :type replace_all_existing: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperPermissionDenied: Permission denied to complete the operation + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A Group object representing the group that members were added to + :rtype: Group + """ from .objects.group import Group identifiers_to_add = [{"subjectIdentifier": ident} for ident in subject_identifiers] @@ -219,16 +301,35 @@ def add_members_to_group( # We're not sure what exactly has happened here, # So raise the original SuccessException raise err - return Group.from_results(client, r["WsAddMemberResults"]["wsGroupAssigned"]) + return Group(client, r["WsAddMemberResults"]["wsGroupAssigned"]) def delete_members_from_group( group_name: str, - client: Client, + client: GrouperClient, subject_identifiers: list[str] = [], subject_ids: list[str] = [], act_as_subject: Subject | None = None, ) -> Group: + """Remove members from a group. + + :param group_name: The name of the group to remove members from + :type group_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param subject_identifiers: Subject identifiers of members to remove, defaults to [] + :type subject_identifiers: list[str], optional + :param subject_ids: Subject ids of members to remove, defaults to [] + :type subject_ids: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperPermissionDenied: Permission denied to complete the operation + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A Group object representing the group that members were removed from + :rtype: Group + """ from .objects.group import Group identifiers_to_delete = [ @@ -266,18 +367,42 @@ def delete_members_from_group( else: # pragma: no cover # We're not sure what exactly has happened here, # So raise the original SuccessException - raise - return Group.from_results(client, r["WsDeleteMemberResults"]["wsGroup"]) + raise err + return Group(client, r["WsDeleteMemberResults"]["wsGroup"]) def get_members_for_groups( group_names: list[str], - client: Client, + client: GrouperClient, attributes: list[str] = [], member_filter: str = "all", resolve_groups: bool = True, act_as_subject: Subject | None = None, ) -> dict[Group, list[Subject]]: + """Get members for the given groups. + + :param group_names: Group names to retreive members for + :type group_names: list[str] + :param client: The GrouperClient to use + :type client: GrouperClient + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param member_filter: Type of mebership to return (all, immediate, effective), + defaults to "all" + :type member_filter: str, optional + :param resolve_groups: Whether to resolve subjects that are groups into Group + objects, which will require an additional API call per group, defaults to True + :type resolve_groups: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A dictionary with Groups as the keys + and those groups' member list as the value + :rtype: dict[Group, list[Subject]] + """ from .objects.group import Group group_lookup = [{"groupName": group} for group in group_names] @@ -323,7 +448,7 @@ def get_members_for_groups( subject_attr_names = r["WsGetMembersResults"]["subjectAttributeNames"] for result in r["WsGetMembersResults"]["results"]: # members: list[Subject] = [] - key = Group.from_results(client, result["wsGroup"]) + key = Group(client, result["wsGroup"]) if result["resultMetadata"]["success"] == "T": if "wsSubjects" in result: members = [ diff --git a/grouper_python/objects/__init__.py b/grouper_python/objects/__init__.py index e69de29..3c93669 100644 --- a/grouper_python/objects/__init__.py +++ b/grouper_python/objects/__init__.py @@ -0,0 +1,9 @@ +"""grouper_python.objects, Classes for the grouper_python package.""" + +from .group import Group +from .person import Person +from .stem import Stem +from .subject import Subject +from .privilege import Privilege + +__all__ = ["Group", "Person", "Stem", "Subject", "Privilege"] diff --git a/grouper_python/objects/base.py b/grouper_python/objects/base.py new file mode 100644 index 0000000..99eca5d --- /dev/null +++ b/grouper_python/objects/base.py @@ -0,0 +1,51 @@ +"""grouper_python.objects.base, Base classes that other Grouper objects inherit.""" + +from __future__ import annotations +from dataclasses import dataclass, fields, field +from copy import deepcopy +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from .client import GrouperClient + + +@dataclass(init=False, slots=True) +class GrouperBase: + """The root of all Grouper objects.""" + + client: GrouperClient = field(repr=False) + + def dict(self) -> dict[str, Any]: + """Return a dictionary representation of this object. + + Note that the GrouperClient object will not be included + in the dictionary. + + :return: A dictionary representation of this object, + without the GrouperClient + :rtype: dict[str, Any] + """ + return { + field.name: deepcopy(getattr(self, field.name)) + for field in fields(self) + if field.repr + } + + +@dataclass(init=False, slots=True) +class GrouperEntity(GrouperBase): + """The root of all Grouper entities.""" + + id: str + description: str + name: str + + def __hash__(self) -> int: + """Return a hash of the object's id.""" + return hash(self.id) + + def __eq__(self, other: object) -> bool: + """Compare this object to another.""" + if not isinstance(other, GrouperEntity): + return NotImplemented + return self.id == other.id diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index 42ed6c2..80ae440 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -1,3 +1,5 @@ +"""grouper_python.objects.client - Class definition for GrouperClient.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any @@ -10,10 +12,29 @@ from ..util import call_grouper from ..group import get_group_by_name, find_group_by_name from ..stem import get_stem_by_name -from ..subject import get_subject_by_identifier, find_subject +from ..subject import get_subject_by_identifier, find_subjects + + +class GrouperClient: + """Client object for interacting with the grouper API. + :param grouper_base_url: Base URL for web services in your Grouper instance, + will end in something like grouper-ws/servicesRest/v2_6_000 where v2_6_000 is + the version you are targeting. + :type grouper_base_url: str + :param username: Username for Basic Auth to Grouper WS + :type username: str + :param password: Password for Basic Auth to Grouper WS + :type password: str + :param timeout: Timeout for underlying httpx connections, defaults to 30.0 + :type timeout: float, optional + :param universal_identifier_attr: The subject attribute to treat as a + "universal" identifer for subjects of type "person". + This should be the attribute that holds "usernames" for your instance. + Defaults to "description". + :type universal_identifier_attr: str, optional + """ -class Client: def __init__( self, grouper_base_url: str, @@ -22,6 +43,7 @@ def __init__( timeout: float = 30.0, universal_identifier_attr: str = "description", ) -> None: + """Construct a GrouperClient.""" self.httpx_client = httpx.Client( auth=httpx.BasicAuth(username=username, password=password), base_url=grouper_base_url, @@ -30,7 +52,8 @@ def __init__( ) self.universal_identifier_attr = universal_identifier_attr - def __enter__(self) -> Client: + def __enter__(self) -> GrouperClient: + """Enter the context manager.""" return self def __exit__( @@ -39,16 +62,29 @@ def __exit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: + """Close the underlying httpx Client and exit the context manager.""" self.httpx_client.close() def close(self) -> None: + """Close the GrouperClient object by closing the underlying httpx Client.""" self.httpx_client.close() def get_group( self, group_name: str, act_as_subject: Subject | None = None, - ) -> "Group": + ) -> Group: + """Get a group with the given name. + + :param group_name: The name of the group to get + :type group_name: str + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :return: The group with the given name + :rtype: Group + """ return get_group_by_name( group_name=group_name, client=self, act_as_subject=act_as_subject ) @@ -59,11 +95,35 @@ def get_groups( stem: str | None = None, act_as_subject: Subject | None = None, ) -> list[Group]: + """Get groups by approximate name. + + :param group_name: The group name to search for + :type group_name: str + :param stem: Optional stem to limit the search to, defaults to None + :type stem: str | None, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperStemNotFoundException: The specified stem cannot be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: List of found groups, will be an empty list if no groups are found + :rtype: list[Group] + """ return find_group_by_name( group_name=group_name, client=self, stem=stem, act_as_subject=act_as_subject ) def get_stem(self, stem_name: str, act_as_subject: Subject | None = None) -> Stem: + """Get a stem with the given name. + + :param stem_name: The name of the stem to get + :type stem_name: str + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperStemNotFoundException: A stem with the given name cannot be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: The stem with the given name + :rtype: Stem + """ return get_stem_by_name(stem_name, self, act_as_subject=act_as_subject) def get_subject( @@ -73,6 +133,25 @@ def get_subject( attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> Subject: + """Get the subject with the given identifier. + + :param subject_identifier: Identifier of subject to get + :type subject_identifier: str + :param resolve_group: Whether to resolve subject that is a group into a Group + object, which will require an additional API, defaults to True + :type resolve_group: bool, optional + :param attributes: Additional attributes to return for the Subject, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperSubjectNotFoundException: A subject cannot be found + with the given identifier + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: The subject with the given name + :return: The subject with the given name + :rtype: Subject + """ return get_subject_by_identifier( subject_identifier=subject_identifier, client=self, @@ -81,14 +160,30 @@ def get_subject( act_as_subject=act_as_subject, ) - def find_subject( + def find_subjects( self, search_string: str, resolve_groups: bool = True, attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Subject]: - return find_subject( + """Find subjects with the given search string. + + :param search_string: Free-form string tos earch for + :type search_string: str + :param resolve_groups: Whether to resolve subjects that are groups into Group + objects, which will require an additional API call, defaults to True + :type resolve_groups: bool, optional + :param attributes: Additional attributes to return for the Subject, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: List of found Subjects, will be an empty list if no subjects are found + :rtype: list[Subject] + """ + return find_subjects( search_string=search_string, client=self, resolve_groups=resolve_groups, @@ -103,6 +198,19 @@ def _call_grouper( method: str = "POST", act_as_subject: Subject | None = None, ) -> dict[str, Any]: + """Call the Grouper API. + + :param path: API url suffix to call + :type path: str + :param body: body to be sent with API call + :type body: dict[str, Any] + :param method: HTTP method, defaults to "POST" + :type method: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: the full payload returned from Grouper + :rtype: dict[str, Any] + """ return call_grouper( client=self.httpx_client, path=path, diff --git a/grouper_python/objects/exceptions.py b/grouper_python/objects/exceptions.py index 25c25ee..6e55c0f 100644 --- a/grouper_python/objects/exceptions.py +++ b/grouper_python/objects/exceptions.py @@ -1,3 +1,5 @@ +"""grouper_python.objects.exceptions - Exceptions for the grouper_python package.""" + from typing import Any @@ -26,6 +28,7 @@ class GrouperPermissionDenied(GrouperException): """Permission denied in grouper.""" def __init__(self, grouper_result: dict[str, Any]) -> None: + """Initialize Exception with Grouper result body.""" self.grouper_result = grouper_result super().__init__("Permission denied") @@ -33,18 +36,22 @@ def __init__(self, grouper_result: dict[str, Any]) -> None: class GrouperEntityNotFoundException(GrouperException): """The Grouper Entity was not found.""" - def __init__(self, entity_identifier: str, grouper_result: dict[str, Any]) -> None: - """Initialize Exception with entity name.""" + def __init__( + self, entity_identifier: str, grouper_result: dict[str, Any] = {} + ) -> None: + """Initialize Exception with entity name and Grouper result body.""" self.entity_identifier = entity_identifier self.grouper_result = grouper_result super().__init__(f"{self.entity_identifier} not found") class GrouperSubjectNotFoundException(GrouperEntityNotFoundException): - """The Grouper Subject was not found""" + """The Grouper Subject was not found.""" - def __init__(self, subject_identifier: str, grouper_result: dict[str, Any]) -> None: - """Initialize Exception with subject identifier.""" + def __init__( + self, subject_identifier: str, grouper_result: dict[str, Any] = {} + ) -> None: + """Initialize Exception with subject identifier and Grouper result body.""" self.subject_identifier = subject_identifier super().__init__(subject_identifier, grouper_result) @@ -52,8 +59,8 @@ def __init__(self, subject_identifier: str, grouper_result: dict[str, Any]) -> N class GrouperGroupNotFoundException(GrouperEntityNotFoundException): """The Grouper Group was not found.""" - def __init__(self, group_name: str, grouper_result: dict[str, Any]) -> None: - """Initialize Exception with group name.""" + def __init__(self, group_name: str, grouper_result: dict[str, Any] = {}) -> None: + """Initialize Exception with group name and Grouper result body.""" self.group_name = group_name super().__init__(group_name, grouper_result) @@ -61,7 +68,7 @@ def __init__(self, group_name: str, grouper_result: dict[str, Any]) -> None: class GrouperStemNotFoundException(GrouperEntityNotFoundException): """The Grouper Stem was not found.""" - def __init__(self, stem_name: str, grouper_result: dict[str, Any]) -> None: - """Initialize Exception with stem name.""" + def __init__(self, stem_name: str, grouper_result: dict[str, Any] = {}) -> None: + """Initialize Exception with stem name and Grouper result body.""" self.stem_name = stem_name super().__init__(stem_name, grouper_result) diff --git a/grouper_python/objects/group.py b/grouper_python/objects/group.py index 40b67c5..b208ae4 100644 --- a/grouper_python/objects/group.py +++ b/grouper_python/objects/group.py @@ -1,12 +1,14 @@ +"""grouper_python.objects.subject - Class definition for Group and related objects.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from .membership import Membership, HasMember - from .client import Client + from .client import GrouperClient from .privilege import Privilege from .subject import Subject -from pydantic import BaseModel +from dataclasses import dataclass from ..membership import ( get_members_for_groups, get_memberships_for_groups, @@ -18,7 +20,16 @@ from ..group import delete_groups +@dataclass(slots=True, eq=False) class Group(Subject): + """Group object representing a Grouper group. + + :param client: A GrouperClient object containing connection information + :type client: GrouperClient + :param group_body: Body of the group as returned by the Grouper API + :type group_body: dict[str, Any] + """ + extension: str displayName: str uuid: str @@ -28,29 +39,26 @@ class Group(Subject): idIndex: str detail: dict[str, Any] | None - @classmethod - def from_results( - cls: type[Group], - client: Client, + def __init__( + self, + client: GrouperClient, group_body: dict[str, Any], - subject_attr_names: list[str] = [], - ) -> Group: - return cls( - id=group_body["uuid"], - description=group_body.get("description", ""), - universal_identifier=group_body["name"], - sourceId="g:gsa", - name=group_body["name"], - extension=group_body["extension"], - displayName=group_body["displayName"], - uuid=group_body["uuid"], - enabled=group_body["enabled"], - displayExtension=group_body["displayExtension"], - typeOfGroup=group_body["typeOfGroup"], - idIndex=group_body["idIndex"], - detail=group_body.get("detail"), - client=client, - ) + ) -> None: + """Construct a Group.""" + self.id = group_body["uuid"] + self.description = group_body.get("description", "") + self.universal_identifier = group_body["name"] + self.sourceId = "g:gsa" + self.name = group_body["name"] + self.extension = group_body["extension"] + self.displayName = group_body["displayName"] + self.uuid = group_body["uuid"] + self.enabled = group_body["enabled"] + self.displayExtension = group_body["displayExtension"] + self.typeOfGroup = group_body["typeOfGroup"] + self.idIndex = group_body["idIndex"] + self.detail = group_body.get("detail") + self.client = client def get_members( self, @@ -59,6 +67,22 @@ def get_members( resolve_groups: bool = True, act_as_subject: Subject | None = None, ) -> list[Subject]: + """Get members for this Group. + + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param member_filter: Type of mebership to return (all, immediate, effective), + defaults to "all" + :type member_filter: str, optional + :param resolve_groups: Whether to resolve subjects that are groups into Group + objects, which will require an additional API call per group, defaults to True + :type resolve_groups: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: List of members of this group + :rtype: list[Subject] + """ members = get_members_for_groups( group_names=[self.name], client=self.client, @@ -76,6 +100,24 @@ def get_memberships( resolve_groups: bool = True, act_as_subject: Subject | None = None, ) -> list[Membership]: + """Get memberships for this group. + + Note that a "membership" includes more detail than a "member". + + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param member_filter: Type of mebership to return (all, immediate, effective), + defaults to "all" + :type member_filter: str, optional + :param resolve_groups: Whether to resolve subjects that are groups into Group + objects, which will require an additional API call per group, defaults to True + :type resolve_groups: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: List of memberships for this group + :rtype: list[Membership] + """ memberships = get_memberships_for_groups( group_names=[self.name], client=self.client, @@ -92,6 +134,15 @@ def create_privilege_on_this( privilege_name: str, act_as_subject: Subject | None = None, ) -> None: + """Create a privilege on this Group. + + :param entity_identifier: Identifier of the entity to receive the permission + :type entity_identifier: str + :param privilege_name: Name of the privilege to assign + :type privilege_name: str + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + """ assign_privilege( target=self.name, target_type="group", @@ -108,6 +159,15 @@ def delete_privilege_on_this( privilege_name: str, act_as_subject: Subject | None = None, ) -> None: + """Delete a privilege on this Group. + + :param entity_identifier: Identifier of the entity to remove permission for + :type entity_identifier: str + :param privilege_name: Name of the privilege to delete + :type privilege_name: str + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + """ assign_privilege( target=self.name, target_type="group", @@ -126,6 +186,25 @@ def get_privilege_on_this( attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Privilege]: + """Get privileges on this Group. + + :param subject_id: Subject Id to limit retreived permissions to, + cannot be specified if subject_identifer is specified, defaults to None + :type subject_id: str | None, optional + :param subject_identifier: Subject Identifer to limit retreived permissions to, + cannot be specified if subject_id is specified, defaults to None + :type subject_identifier: str | None, optional + :param privilege_name: Name of privilege to get, defaults to None + :type privilege_name: str | None, optional + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: List of retreived privileges on this Group + satisfying the given constraints + :rtype: list[Privilege] + """ return get_privileges( client=self.client, subject_id=subject_id, @@ -143,6 +222,21 @@ def add_members( replace_all_existing: str = "F", act_as_subject: Subject | None = None, ) -> None: + """Add members to this group. + + :param subject_identifiers: Subject identifiers of members to add, + defaults to [] + :type subject_identifiers: list[str], optional + :param subject_ids: Subject ids of members to add, defaults to [] + :type subject_ids: list[str], optional + :param replace_all_existing: Whether to replace existing membership of group, + "T" will replace, "F" will only add members, defaults to "F" + :type replace_all_existing: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperPermissionDenied: Permission denied to complete the operation + :raises GrouperSuccessException: An otherwise unhandled issue with the result + """ add_members_to_group( group_name=self.name, client=self.client, @@ -158,6 +252,18 @@ def delete_members( subject_ids: list[str] = [], act_as_subject: Subject | None = None, ) -> None: + """Remove members from this group. + + :param subject_identifiers: Subject identifiers of members to remove, + defaults to [] + :type subject_identifiers: list[str], optional + :param subject_ids: Subject ids of members to remove, defaults to [] + :type subject_ids: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperPermissionDenied: Permission denied to complete the operation + :raises GrouperSuccessException: An otherwise unhandled issue with the result + """ delete_members_from_group( group_name=self.name, client=self.client, @@ -173,6 +279,22 @@ def has_members( member_filter: str = "all", act_as_subject: Subject | None = None, ) -> dict[str, HasMember]: + """Determine if the given subjects are members of this group. + + :param subject_identifiers:Subject identifiers to check for membership, + defaults to [] + :type subject_identifiers: list[str], optional + :param subject_ids: Subject ids to check for membership, defaults to [] + :type subject_ids: list[str], optional + :param member_filter: Type of mebership to return (all, immediate, effective), + defaults to "all" + :type member_filter: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: A dict with the key being the subject (either identifier or id) + and the value being a HasMember enum. + :rtype: dict[str, HasMember] + """ return has_members( group_name=self.name, client=self.client, @@ -186,6 +308,13 @@ def delete( self, act_as_subject: Subject | None = None, ) -> None: + """Delete this group in Grouper. + + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperPermissionDenied: Permission denied to complete the operation + :raises GrouperSuccessException: An otherwise unhandled issue with the result + """ delete_groups( group_names=[self.name], client=self.client, @@ -193,7 +322,20 @@ def delete( ) -class CreateGroup(BaseModel): +@dataclass +class CreateGroup: + """Class representing all data needed to create a new Grouper group. + + :param name: Full name (ID path) of the group to create + :type name: str + :param display_extension: Display extension (display name) of the group to create + :type display_extension: str + :param description: Description of the group to create + :type description: str + :param detail: detail of the group to create, defaults to None + :type detail: dict[str, Any] | None, optional + """ + name: str display_extension: str description: str diff --git a/grouper_python/objects/membership.py b/grouper_python/objects/membership.py index 37f39ec..537f4b7 100644 --- a/grouper_python/objects/membership.py +++ b/grouper_python/objects/membership.py @@ -1,27 +1,39 @@ -from __future__ import annotations +"""grouper_python.objects.membership - Objects related to Grouper membership.""" +from __future__ import annotations +from typing import TYPE_CHECKING +if TYPE_CHECKING: # pragma: no cover + from .subject import Subject from enum import Enum, StrEnum, auto -from .subject import Subject -from pydantic import BaseModel +from dataclasses import dataclass class HasMember(Enum): + """Enum of results for has_member.""" + IS_MEMBER = 1 IS_NOT_MEMBER = 2 SUBJECT_NOT_FOUND = 3 class MembershipType(StrEnum): + """Enum of membership types.""" + DIRECT = auto() INDIRECT = auto() class MemberType(StrEnum): + """Enum of member types.""" + PERSON = auto() GROUP = auto() -class Membership(BaseModel): +@dataclass +class Membership: + """Class representing a Grouper membership.""" + member: Subject member_type: MemberType membership_type: MembershipType diff --git a/grouper_python/objects/person.py b/grouper_python/objects/person.py index eeb4e43..7fdfc78 100644 --- a/grouper_python/objects/person.py +++ b/grouper_python/objects/person.py @@ -1,31 +1,66 @@ +"""grouper_python.objects.person - Class definition for Person.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover - from .client import Client + from .client import GrouperClient from .subject import Subject +from dataclasses import dataclass +@dataclass(eq=False, slots=True) class Person(Subject): + """Person object representing a Grouper person. + + :param client: A GrouperClient object containing connection information + :type client: GrouperClient + :param person_body: Body of the person as returned by the Grouper API + :type person_body: dict[str, Any] + :param subject_attr_names: Subject attribute names for the given person body + :type subject_attr_names: list[str] + """ + attributes: dict[str, str] - @classmethod - def from_results( - cls: type[Person], - client: Client, + def __init__( + self, + client: GrouperClient, person_body: dict[str, Any], subject_attr_names: list[str], - ) -> Person: + ) -> None: + """Construct a Person.""" attrs = { subject_attr_names[i]: person_body["attributeValues"][i] for i in range(len(subject_attr_names)) } - return cls( - id=person_body["id"], - description=attrs.get("description", ""), - universal_identifier=attrs.get(client.universal_identifier_attr, ""), - sourceId=person_body["sourceId"], - name=person_body["name"], - attributes=attrs, - client=client, - ) + self.attributes = attrs + self.id = person_body["id"] + self.description = attrs.get("description", "") + self.universal_identifier = attrs.get(client.universal_identifier_attr, "") + self.sourceId = person_body["sourceId"] + self.name = person_body["name"] + self.client = client + # super().__init__(client, person_body, subject_attr_names) + # attributes: dict[str, str] + + # @classmethod + # def from_results( + # cls: type[Person], + # client: GrouperClient, + # person_body: dict[str, Any], + # subject_attr_names: list[str], + # ) -> Person: + # attrs = { + # subject_attr_names[i]: person_body["attributeValues"][i] + # for i in range(len(subject_attr_names)) + # } + # return cls( + # id=person_body["id"], + # description=attrs.get("description", ""), + # universal_identifier=attrs.get(client.universal_identifier_attr, ""), + # sourceId=person_body["sourceId"], + # name=person_body["name"], + # attributes=attrs, + # client=client, + # ) diff --git a/grouper_python/objects/privilege.py b/grouper_python/objects/privilege.py index 4b570be..bfafe85 100644 --- a/grouper_python/objects/privilege.py +++ b/grouper_python/objects/privilege.py @@ -1,68 +1,76 @@ +"""grouper_python.objects.privilege - Class definition for Privilege.""" + from __future__ import annotations from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .client import Client -from pydantic import BaseModel + from .client import GrouperClient from .subject import Subject from .group import Group from .stem import Stem +from .base import GrouperBase +from dataclasses import dataclass + + +@dataclass(slots=True) +class Privilege(GrouperBase): + """Privilege object representing a Grouper privilege. + :param client: A GrouperClient object containing connection information + :type client: GrouperClient + :param privilege_body: Body of the privilege as returned by the Grouper API + :type privilege_body: dict[str, Any] + :param subject_attr_names: Subject attribute names to correspond with + attribute values from the subject_body, defaults to [] + :type subject_attr_names: list[str], optional + :raises ValueError: An unknown/unsupported target for the privilege was returned + by Grouper + """ -class Privilege(BaseModel): + stem: Stem | None + group: Group | None + target: Stem | Group revokable: str owner_subject: Subject allowed: str - stem: Stem | None = None - group: Group | None = None - target: Stem | Group subject: Subject - privilege_type: str privilege_name: str + privilege_type: str - class Config: - smart_union = True - - @classmethod - def from_results( - cls: type[Privilege], - client: Client, + def __init__( + self, + client: GrouperClient, privilege_body: dict[str, Any], subject_attr_names: list[str] = [], - ) -> Privilege: - stem = ( - Stem.from_results(client, privilege_body["wsStem"]) + ) -> None: + """Construct a Privilege.""" + self.stem = ( + Stem(client, privilege_body["wsStem"]) if "wsStem" in privilege_body else None ) - group = ( - Group.from_results(client, privilege_body["wsGroup"]) + self.group = ( + Group(client, privilege_body["wsGroup"]) if "wsGroup" in privilege_body else None ) - target: Stem | Group - if stem: - target = stem - elif group: - target = group + if self.stem: + self.target = self.stem + elif self.group: + self.target = self.group else: # pragma: no cover raise ValueError("Unknown target for privilege", privilege_body) - return cls( - revokable=privilege_body["revokable"], - owner_subject=Subject.from_results( - client=client, - subject_body=privilege_body["ownerSubject"], - subject_attr_names=subject_attr_names, - ), - allowed=privilege_body["allowed"], - stem=stem, - group=group, - target=target, - subject=Subject.from_results( - client=client, - subject_body=privilege_body["wsSubject"], - subject_attr_names=subject_attr_names, - ), - privilege_type=privilege_body["privilegeType"], - privilege_name=privilege_body["privilegeName"], + self.revokable = privilege_body["revokable"] + self.owner_subject = Subject( + client=client, + subject_body=privilege_body["ownerSubject"], + subject_attr_names=subject_attr_names, + ) + self.allowed = privilege_body["allowed"] + self.subject = Subject( + client=client, + subject_body=privilege_body["wsSubject"], + subject_attr_names=subject_attr_names, ) + self.privilege_type = privilege_body["privilegeType"] + self.privilege_name = privilege_body["privilegeName"] diff --git a/grouper_python/objects/stem.py b/grouper_python/objects/stem.py index fb386c6..4004c7c 100644 --- a/grouper_python/objects/stem.py +++ b/grouper_python/objects/stem.py @@ -1,3 +1,5 @@ +"""grouper_python.objects.stem - Class definition for Stem and related objects.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any @@ -6,46 +8,45 @@ from .group import Group from .privilege import Privilege -from pydantic import BaseModel from ..privilege import assign_privilege, get_privileges from ..stem import create_stems, get_stems_by_parent, delete_stems from ..group import create_groups, get_groups_by_parent -from .client import Client +from .client import GrouperClient +from dataclasses import dataclass +from .base import GrouperEntity -class Stem(BaseModel): - client: Client - displayExtension: str +@dataclass(slots=True, eq=False) +class Stem(GrouperEntity): + """Stem object representing a Grouper stem. + + :param client: A GrouperClient object containing connection information + :type client: GrouperClient + :param stem_body: Body of the stem as returned by the Grouper API + :type stem_body: dict[str, Any] + """ + extension: str displayName: str - name: str - description: str - idIndex: str uuid: str - id: str - - class Config: - arbitrary_types_allowed = True - fields = {"client": {"exclude": True}} + displayExtension: str + idIndex: str - @classmethod - def from_results( - cls: type[Stem], - client: Client, + def __init__( + self, + client: GrouperClient, stem_body: dict[str, Any], - subject_attr_names: list[str] = [], - ) -> Stem: - return cls( - id=stem_body["uuid"], - description=stem_body.get("description", ""), - extension=stem_body["extension"], - displayName=stem_body["displayName"], - uuid=stem_body["uuid"], - displayExtension=stem_body["displayExtension"], - name=stem_body["name"], - idIndex=stem_body["idIndex"], - client=client, - ) + ) -> None: + """Construct a Stem.""" + self.id = stem_body["uuid"] + self.description = stem_body.get("description", "") + self.extension = stem_body["extension"] + self.displayName = stem_body["displayName"] + self.uuid = stem_body["uuid"] + self.displayExtension = stem_body["displayExtension"] + self.name = stem_body["name"] + self.idIndex = stem_body["idIndex"] + self.client = client def create_privilege_on_this( self, @@ -53,6 +54,15 @@ def create_privilege_on_this( privilege_name: str, act_as_subject: Subject | None = None, ) -> None: + """Create a privilege on this Stem. + + :param entity_identifier: Identifier of the entity to receive the permission + :type entity_identifier: str + :param privilege_name: Name of the privilege to assign + :type privilege_name: str + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + """ assign_privilege( target=self.name, target_type="stem", @@ -69,6 +79,15 @@ def delete_privilege_on_this( privilege_name: str, act_as_subject: Subject | None = None, ) -> None: + """Delete a privilege on this Stem. + + :param entity_identifier: Identifier of the entity to remove permission for + :type entity_identifier: str + :param privilege_name: Name of the privilege to delete + :type privilege_name: str + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + """ assign_privilege( target=self.name, target_type="stem", @@ -87,6 +106,25 @@ def get_privilege_on_this( attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Privilege]: + """Get privileges on this Stem. + + :param subject_id: Subject Id to limit retreived permissions to, + cannot be specified if subject_identifer is specified, defaults to None + :type subject_id: str | None, optional + :param subject_identifier: Subject Identifer to limit retreived permissions to, + cannot be specified if subject_id is specified, defaults to None + :type subject_identifier: str | None, optional + :param privilege_name: Name of privilege to get, defaults to None + :type privilege_name: str | None, optional + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: List of retreived privileges on this Stem + satisfying the given constraints + :rtype: list[Privilege] + """ return get_privileges( client=self.client, subject_id=subject_id, @@ -104,6 +142,20 @@ def create_child_stem( description: str = "", act_as_subject: Subject | None = None, ) -> Stem: + """Create a child stem in this Stem. + + :param extension: The extension (id) of the stem to create. + :type extension: str + :param display_extension: The display extension (display name) + of the stem to create + :type display_extension: str + :param description: Description of the stem to create, defaults to "" + :type description: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: A Stem object representing the newly created stem + :rtype: Stem + """ create = CreateStem( name=f"{self.name}:{extension}", displayExtension=display_extension, @@ -123,6 +175,22 @@ def create_child_group( detail: dict[str, Any] | None = None, act_as_subject: Subject | None = None, ) -> Group: + """Create a child group in this Stem. + + :param extension: The extension (id) of the group to create. + :type extension: str + :param display_extension: The display extension (display name) + of the group to create + :type display_extension: str + :param description: Description of the group to create, defaults to "" + :type description: str, optional + :param detail: Dict of "details" of the group to create, defaults to None + :type detail: dict[str, Any] | None, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: A Group object representing the newly created group + :rtype: Group + """ from .group import CreateGroup create = CreateGroup( @@ -138,6 +206,16 @@ def get_child_stems( recursive: bool, act_as_subject: Subject | None = None, ) -> list[Stem]: + """Get child stems of this Stem. + + :param recursive: Whether to look recursively through the entire subtree (True), + or only one level in this stem (False) + :type recursive: bool + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: The list of Stems found + :rtype: list[Stem] + """ return get_stems_by_parent( parent_name=self.name, client=self.client, @@ -150,6 +228,16 @@ def get_child_groups( recursive: bool, act_as_subject: Subject | None = None, ) -> list[Group]: + """Get child groups of this Stem. + + :param recursive: Whether to look recursively through the entire subtree (True), + or only one level in this stem (False) + :type recursive: bool + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: The list of Groups found + :rtype: list[Group] + """ return get_groups_by_parent( parent_name=self.name, client=self.client, @@ -161,12 +249,28 @@ def delete( self, act_as_subject: Subject | None = None, ) -> None: + """Delete this Stem. + + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + """ delete_stems( stem_names=[self.name], client=self.client, act_as_subject=act_as_subject ) -class CreateStem(BaseModel): +@dataclass +class CreateStem: + """Class representing all data needed to create a new Grouper stem. + + :param name: Full name (ID path) of the stem to create + :type name: str + :param display_extension: Display extension (display name) of the stem to create + :type display_extension: str + :param description: Description of the stem to create + :type description: str + """ + name: str displayExtension: str description: str diff --git a/grouper_python/objects/subject.py b/grouper_python/objects/subject.py index d75d104..f9ec870 100644 --- a/grouper_python/objects/subject.py +++ b/grouper_python/objects/subject.py @@ -1,35 +1,43 @@ +"""grouper_python.objects.subject - Class definition for Subject.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from .group import Group from .privilege import Privilege -from .client import Client -from pydantic import BaseModel + from .client import GrouperClient from ..subject import get_groups_for_subject from ..membership import has_members from ..privilege import get_privileges +from dataclasses import dataclass +from .base import GrouperEntity +from .exceptions import GrouperSubjectNotFoundException + + +@dataclass(slots=True, eq=False) +class Subject(GrouperEntity): + """Subject object representing a Grouper subject. + :param client: A GrouperClient object containing connection information + :type client: GrouperClient + :param subject_body: Body of the subject as returned by the Grouper API + :type subject_body: dict[str, Any] + :param subject_attr_names: Subject attribute names to correspond with + attribute values from the subject_body + :type subject_attr_names: list[str] + """ -class Subject(BaseModel): - id: str - description: str = "" universal_identifier: str sourceId: str - name: str - client: Client - class Config: - arbitrary_types_allowed = True - fields = {"client": {"exclude": True}} - - @classmethod - def from_results( - cls: type[Subject], - client: Client, + def __init__( + self, + client: GrouperClient, subject_body: dict[str, Any], subject_attr_names: list[str], - ) -> Subject: + ) -> None: + """Construct a Subject.""" attrs = { subject_attr_names[i]: subject_body["attributeValues"][i] for i in range(len(subject_attr_names)) @@ -38,22 +46,12 @@ def from_results( universal_identifier_attr = "name" else: universal_identifier_attr = client.universal_identifier_attr - return cls( - id=subject_body["id"], - description=attrs.get("description", ""), - universal_identifier=attrs.get(universal_identifier_attr), - sourceId=subject_body["sourceId"], - name=subject_body["name"], - client=client, - ) - - def __hash__(self) -> int: - return hash(self.id) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, Subject): - return NotImplemented - return self.id == other.id + self.id = subject_body["id"] + self.description = attrs.get("description", "") + self.universal_identifier = attrs[universal_identifier_attr] + self.sourceId = subject_body["sourceId"] + self.name = subject_body["name"] + self.client = client def get_groups( self, @@ -61,6 +59,19 @@ def get_groups( substems: bool = True, act_as_subject: Subject | None = None, ) -> list[Group]: + """Get groups this subject is a member of. + + :param stem: Optional stem to limit the search to, defaults to None + :type stem: str | None, optional + :param substems: Whether to look recursively through substems + of the given stem (True), or only one level in the given stem (False), + defaults to True + :type substems: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: List of found groups, will be an empty list if no groups are found + :rtype: list[Group] + """ return get_groups_for_subject( self.id, self.client, @@ -75,6 +86,22 @@ def is_member( member_filter: str = "all", act_as_subject: Subject | None = None, ) -> bool: + """Check if this subject is a member of the given group. + + :param group_name: Name of group to check for membership. + :type group_name: str + :param member_filter: Type of mebership to check for + (all, immediate, effective), defaults to "all" + :type member_filter: str, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperSubjectNotFoundException: This subject cannot be found + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: If the user is a member of the group (True) or not (False) + :rtype: bool + """ from .membership import HasMember result = has_members( @@ -89,9 +116,11 @@ def is_member( elif result[self.id] == HasMember.IS_NOT_MEMBER: return False else: - raise ValueError + raise GrouperSubjectNotFoundException( + subject_identifier=self.universal_identifier + ) - def get_privileges_for_this( + def get_privileges_for_this_in_others( self, group_name: str | None = None, stem_name: str | None = None, @@ -100,6 +129,32 @@ def get_privileges_for_this( attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Privilege]: + """Get privileges this subject has in other objects. + + :param group_name: Group name to limit privileges to, + cannot be specified if stem_name is specified, defaults to None + :type group_name: str | None, optional + :param stem_name: Stem name to limit privileges to, + cannot be specified if group_name is specified, defaults to None + :type stem_name: str | None, optional + :param privilege_name: Name of privilege to get, defaults to None + :type privilege_name: str | None, optional + :param privilege_type: Type of privilege to get, defaults to None + :type privilege_type: str | None, optional + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises ValueError: An invalid combination of parameters was given + :raises GrouperGroupNotFoundException: A group with the given name cannot + be found + :raises GrouperStemNotFoundException: A stem with the given name cannot be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A list of retreived privileges for this subject + satisfying any given constraints + :rtype: list[Privilege] + """ return get_privileges( client=self.client, subject_id=self.id, diff --git a/grouper_python/privilege.py b/grouper_python/privilege.py index ba57857..d888988 100644 --- a/grouper_python/privilege.py +++ b/grouper_python/privilege.py @@ -1,8 +1,17 @@ +"""grouper-python.privilege - functions to interact with grouper privileges. + +These are "helper" functions that most likely will not be called directly. +Instead, a GrouperClient class should be created, then from there use that +GrouperClient's methods to find and create objects, and use those objects' methods. +These helper functions are used by those objects, but can be called +directly if needed. +""" + from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .objects.client import Client + from .objects.client import GrouperClient from .objects.subject import Subject from .objects.privilege import Privilege from .objects.exceptions import ( @@ -19,9 +28,28 @@ def assign_privilege( privilege_name: str, entity_identifier: str, allowed: str, - client: Client, + client: GrouperClient, act_as_subject: Subject | None = None, ) -> None: + """Assign (or remove) a permission. + + :param target: Identifier of the target of the permission + :type target: str + :param target_type: Type of target, either "stem" or "group" + :type target_type: str + :param privilege_name: Name of the privilege to assign + :type privilege_name: str + :param entity_identifier: Identifier of the entity to receive the permission + :type entity_identifier: str + :param allowed: "T" to add the permission, "F" to remove it + :type allowed: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises ValueError: An unknown/unsupported target_type is specified + :raises GrouperSuccessException: An otherwise unhandled issue with the result + """ body = { "WsRestAssignGrouperPrivilegesLiteRequest": { "allowed": allowed, @@ -47,7 +75,7 @@ def assign_privilege( def get_privileges( - client: Client, + client: GrouperClient, subject_id: str | None = None, subject_identifier: str | None = None, group_name: str | None = None, @@ -57,6 +85,50 @@ def get_privileges( attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Privilege]: + """Get privileges. + + Supports the following scenarios: + Get all the permissions for a subject + Get all permissions on a given group or stem + Get all permissions for a subject on a given group or stem + + Privileges can additionally be filtered by name or type. + If specifing a group or stem and a privilege type, + the privilege type should align (eg naming for stem, access for group) + or the Grouper API will return an error. + + :param client: The GrouperClient to use + :type client: GrouperClient + :param subject_id: Subject ID of entity to get permissions for, + cannot be specified if subject_identifier is specified, defaults to None + :type subject_id: str | None, optional + :param subject_identifier: Subject Identifier of entity to get permissions for, + cannot be specified if subject_id is specified, defaults to None + :type subject_identifier: str | None, optional + :param group_name: Group name to get privileges for (possibly limited by subject), + cannot be specified if stem_name is specified, defaults to None + :type group_name: str | None, optional + :param stem_name: Stem name to get privileges for (possibly limited by subject), + cannot be specified if group_name is specified, defaults to None + :type stem_name: str | None, optional + :param privilege_name: Name of privilege to get, defaults to None + :type privilege_name: str | None, optional + :param privilege_type: Type of privilege to get, defaults to None + :type privilege_type: str | None, optional + :param attributes: Additional attributes to retrieve for the Subjects, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises ValueError: An invalid combination of parameters was given + :raises GrouperSubjectNotFoundException: A subject cannot be found + with the given identifier or id + :raises GrouperGroupNotFoundException: A group with the given name cannot be found + :raises GrouperStemNotFoundException: A stem with the given name cannot be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: A list of retreived privileges satisfying the given constraints + :rtype: list[Privilege] + """ from .objects.privilege import Privilege if subject_id and subject_identifier: @@ -95,7 +167,7 @@ def get_privileges( ) result = r["WsGetGrouperPrivilegesLiteResult"] return [ - Privilege.from_results(client, priv, result["subjectAttributeNames"]) + Privilege(client, priv, result["subjectAttributeNames"]) for priv in result["privilegeResults"] ] except GrouperSuccessException as err: diff --git a/grouper_python/stem.py b/grouper_python/stem.py index a0e124f..accd75f 100644 --- a/grouper_python/stem.py +++ b/grouper_python/stem.py @@ -1,15 +1,38 @@ +"""grouper-python.stem - functions to interact with stem objects. + +These are "helper" functions that most likely will not be called directly. +Instead, a GrouperClient class should be created, then from there use that +GrouperClient's methods to find and create objects, and use those objects' methods. +These helper functions are used by those objects, but can be called +directly if needed. +""" + from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .objects.stem import Stem, CreateStem - from .objects.client import Client + from .objects.client import GrouperClient from .objects.subject import Subject +from .objects.exceptions import GrouperStemNotFoundException, GrouperSuccessException def get_stem_by_name( - stem_name: str, client: Client, act_as_subject: Subject | None = None + stem_name: str, client: GrouperClient, act_as_subject: Subject | None = None ) -> Stem: + """Get a stem with the given name. + + :param stem_name: The name of the stem to get + :type stem_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperStemNotFoundException: A stem with the given name cannot be found + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: The stem with the given name + :rtype: Stem + """ from .objects.stem import Stem body = { @@ -20,15 +43,36 @@ def get_stem_by_name( } } r = client._call_grouper("/stems", body, act_as_subject=act_as_subject) - return Stem.from_results(client, r["WsFindStemsResults"]["stemResults"][0]) + results = r["WsFindStemsResults"]["stemResults"] + if len(results) == 1: + return Stem(client, r["WsFindStemsResults"]["stemResults"][0]) + if len(results) == 0: + raise GrouperStemNotFoundException(stem_name, r) + else: # pragma: no cover + # Not sure what's going on, so raise an exception + raise GrouperSuccessException(r) def get_stems_by_parent( parent_name: str, - client: Client, + client: GrouperClient, recursive: bool = False, act_as_subject: Subject | None = None, ) -> list[Stem]: + """Get Stems within the given parent stem. + + :param parent_name: The parent stem to lookin + :type parent_name: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param recursive: Whether to look recursively through the entire subtree (True), + or only one level in the given parent (False), defaults to False + :type recursive: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: The list of Stems found + :rtype: list[Stem] + """ from .objects.stem import Stem body = { @@ -47,16 +91,27 @@ def get_stems_by_parent( act_as_subject=act_as_subject, ) return [ - Stem.from_results(client, stem) + Stem(client, stem) for stem in r["WsFindStemsResults"]["stemResults"] ] def create_stems( creates: list[CreateStem], - client: Client, + client: GrouperClient, act_as_subject: Subject | None = None, ) -> list[Stem]: + """Create stems. + + :param creates: list of stems to create + :type creates: list[CreateStem] + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: Stem objects representing the created stems + :rtype: list[Stem] + """ from .objects.stem import Stem stems_to_save = [ @@ -73,16 +128,25 @@ def create_stems( body = {"WsRestStemSaveRequest": {"wsStemToSaves": stems_to_save}} r = client._call_grouper("/stems", body, act_as_subject=act_as_subject) return [ - Stem.from_results(client, result["wsStem"]) + Stem(client, result["wsStem"]) for result in r["WsStemSaveResults"]["results"] ] def delete_stems( stem_names: list[str], - client: Client, + client: GrouperClient, act_as_subject: Subject | None = None, ) -> None: + """Delete the given stems. + + :param stem_names: The names of stems to delete + :type stem_names: list[str] + :param client: The GrouperClient to use + :type client: GrouperClient + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + """ stem_lookups = [{"stemName": stem_name} for stem_name in stem_names] body = {"WsRestStemDeleteRequest": {"wsStemLookups": stem_lookups}} client._call_grouper("/stems", body, act_as_subject=act_as_subject) diff --git a/grouper_python/subject.py b/grouper_python/subject.py index 781a0dd..7d93f85 100644 --- a/grouper_python/subject.py +++ b/grouper_python/subject.py @@ -1,9 +1,18 @@ +"""grouper-python.subject - functions to interact with subject objects. + +These are "helper" functions that most likely will not be called directly. +Instead, a GrouperClient class should be created, then from there use that +GrouperClient's methods to find and create objects, and use those objects' methods. +These helper functions are used by those objects, but can be called +directly if needed. +""" + from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from .objects.group import Group - from .objects.client import Client + from .objects.client import GrouperClient from .objects.subject import Subject from .objects.exceptions import GrouperSubjectNotFoundException from .util import resolve_subject @@ -11,11 +20,28 @@ def get_groups_for_subject( subject_id: str, - client: Client, + client: GrouperClient, stem: str | None = None, substems: bool = True, act_as_subject: Subject | None = None, ) -> list[Group]: + """Get groups the given subject is a member of. + + :param subject_id: Subject id of subject to get groups + :type subject_id: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param stem: Optional stem to limit the search to, defaults to None + :type stem: str | None, optional + :param substems: Whether to look recursively through substems + of the given stem (True), or only one level in the given stem (False), + defaults to True + :type substems: bool, optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :return: List of found groups, will be an empty list if no groups are found + :rtype: list[Group] + """ from .objects.group import Group body: dict[str, Any] = { @@ -38,7 +64,7 @@ def get_groups_for_subject( ) if "wsGroups" in r["WsGetMembershipsResults"]: return [ - Group.from_results(client, grp) + Group(client, grp) for grp in r["WsGetMembershipsResults"]["wsGroups"] ] else: @@ -47,11 +73,32 @@ def get_groups_for_subject( def get_subject_by_identifier( subject_identifier: str, - client: Client, + client: GrouperClient, resolve_group: bool = True, attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> Subject: + """Get the subject with the given identifier. + + :param subject_identifier: Identifier of subject to get + :type subject_identifier: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param resolve_group: Whether to resolve subject that is a group into a Group + object, which will require an additional API for each found group, + defaults to True + :type resolve_group: bool, optional + :param attributes: Additional attributes to return for the Subject, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperSubjectNotFoundException: A subject cannot be found + with the given identifier + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: The subject with the given name + :rtype: Subject + """ attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) body = { "WsRestGetSubjectsRequest": { @@ -72,13 +119,31 @@ def get_subject_by_identifier( ) -def find_subject( +def find_subjects( search_string: str, - client: Client, + client: GrouperClient, resolve_groups: bool = True, attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Subject]: + """Find subjects with the given search string. + + :param search_string: Free-form string tos earch for + :type search_string: str + :param client: The GrouperClient to use + :type client: GrouperClient + :param resolve_groups: Whether to resolve subjects that are groups into Group + objects, which will require an additional API call per group, defaults to True + :type resolve_groups: bool, optional + :param attributes: Additional attributes to return for the Subject, + defaults to [] + :type attributes: list[str], optional + :param act_as_subject: Optional subject to act as, defaults to None + :type act_as_subject: Subject | None, optional + :raises GrouperSuccessException: An otherwise unhandled issue with the result + :return: List of found Subjects, will be an empty list if no subjects are found + :rtype: list[Subject] + """ attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) body = { "WsRestGetSubjectsRequest": { diff --git a/grouper_python/util.py b/grouper_python/util.py index ff86bde..9a11dc3 100644 --- a/grouper_python/util.py +++ b/grouper_python/util.py @@ -1,8 +1,17 @@ +"""grouper-python.util - utility functions for interacting with Grouper. + +These are "helper" functions that most likely will not be called directly. +Instead, a GrouperClient class should be created, then from there use that +GrouperClient's methods to find and create objects, and use those objects' methods. +These helper functions are used by those objects, but can be called +directly if needed. +""" + from __future__ import annotations from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .objects.client import Client + from .objects.client import GrouperClient from .objects.subject import Subject import httpx from copy import deepcopy @@ -18,6 +27,31 @@ def call_grouper( act_as_subject_id: str | None = None, act_as_subject_identifier: str | None = None, ) -> dict[str, Any]: + """Call the Grouper API. + + :param client: httpx Client object to use + :type client: httpx.Client + :param path: API url suffix to call + :type path: str + :param body: body to be sent with API call + :type body: dict[str, Any] + :param method: HTTP method, defaults to "POST" + :type method: str, optional + :param act_as_subject_id: Optional subject id to act as, + cannot be specified if act_as_subject_identifer is specified, + defaults to None + :type act_as_subject_id: str | None, optional + :param act_as_subject_identifier: Optional subject identifier to act as, + cannot be specified if act_as_subject_id is specified + defaults to None + :type act_as_subject_identifier: str | None, optional + :raises ValueError: Both act_as_subject_id and act_as_subject_identifier + were specified. + :raises GrouperAuthException: There is an issue authenticating to the Grouper API + :raises GrouperSuccessException: The result was not "succesful" + :return: the full payload returned from Grouper + :rtype: dict[str, Any] + """ if act_as_subject_id or act_as_subject_identifier: if act_as_subject_id and act_as_subject_identifier: raise ValueError( @@ -54,10 +88,26 @@ def call_grouper( def resolve_subject( subject_body: dict[str, Any], - client: Client, + client: GrouperClient, subject_attr_names: list[str], resolve_group: bool, ) -> Subject: + """Resolve a given subject. + + :param subject_body: The body of the subject to resolve + :type subject_body: dict[str, Any] + :param client: The GrouperClient to use + :type client: GrouperClient + :param subject_attr_names: Subject attribute names for the given subject body + :type subject_attr_names: list[str] + :param resolve_group: Whether to resolve the subject to a Group object if it is + a group. Resolving will require an additional API call. + If True, the group will be resolved and returned as a Group. + If False, the group will be returned as a Subject. + :type resolve_group: bool + :return: The final "resolved" Subject. + :rtype: Subject + """ from .objects.person import Person from .objects.subject import Subject @@ -65,13 +115,13 @@ def resolve_subject( if resolve_group: return get_group_by_name(subject_body["name"], client) else: - return Subject.from_results( + return Subject( client=client, subject_body=subject_body, subject_attr_names=subject_attr_names, ) else: - return Person.from_results( + return Person( client=client, person_body=subject_body, subject_attr_names=subject_attr_names, diff --git a/pyproject.toml b/pyproject.toml index d8c7a4c..6bc8099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,15 +42,15 @@ accept_no_raise_doc = false accept_no_return_doc = false accept_no_yields_doc = false -# [[tool.pylama.files]] -# # Only run these additional linters against the app directory -# # This might need to be more targeted, but this at least shows how -# # filtering can be done to apply different linters to different -# # parts of the code -# # -# # Test functions don't need docstrings, so don't check for them there. -# path = "itac_api/*" -# linters = "pycodestyle,pyflakes,pydocstyle,pylint" +[[tool.pylama.files]] +# Only run these additional linters against the app directory +# This might need to be more targeted, but this at least shows how +# filtering can be done to apply different linters to different +# parts of the code +# +# Test functions don't need docstrings, so don't check for them there. +path = "grouper_python/*" +linters = "pycodestyle,pyflakes,pydocstyle,pylint" [tool.mypy] files="tests,grouper_python" @@ -70,7 +70,6 @@ no_implicit_reexport = true strict_equality = true strict_concatenate = true # --strict end -plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] module = "tests.*" diff --git a/requirements.txt b/requirements.txt index 6b68903..f7621d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ httpx -pydantic diff --git a/tests/conftest.py b/tests/conftest.py index baac30e..a006550 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,34 +1,35 @@ from collections.abc import Iterable -from grouper_python import Client, Group, Stem, Person, Subject +from grouper_python import GrouperClient +from grouper_python.objects import Group, Stem, Person, Subject import pytest from . import data @pytest.fixture() -def grouper_client() -> Iterable[Client]: - with Client(data.URI_BASE, "username", "password") as client: +def grouper_client() -> Iterable[GrouperClient]: + with GrouperClient(data.URI_BASE, "username", "password") as client: yield client @pytest.fixture() def grouper_group() -> Iterable[Group]: - with Client(data.URI_BASE, "username", "password") as client: - group = Group.from_results(client=client, group_body=data.grouper_group_result1) + with GrouperClient(data.URI_BASE, "username", "password") as client: + group = Group(client=client, group_body=data.grouper_group_result1) yield group @pytest.fixture() def grouper_stem() -> Iterable[Stem]: - with Client(data.URI_BASE, "username", "password") as client: - stem = Stem.from_results(client=client, stem_body=data.grouper_stem_1) + with GrouperClient(data.URI_BASE, "username", "password") as client: + stem = Stem(client=client, stem_body=data.grouper_stem_1) yield stem @pytest.fixture() def grouper_subject() -> Iterable[Subject]: - with Client(data.URI_BASE, "username", "password") as client: - subject = Subject.from_results( + with GrouperClient(data.URI_BASE, "username", "password") as client: + subject = Subject( client=client, subject_body=data.ws_subject4, subject_attr_names=["description", "name"], @@ -38,8 +39,8 @@ def grouper_subject() -> Iterable[Subject]: @pytest.fixture() def grouper_person() -> Iterable[Person]: - with Client(data.URI_BASE, "username", "password") as client: - person = Person.from_results( + with GrouperClient(data.URI_BASE, "username", "password") as client: + person = Person( client=client, person_body=data.ws_subject4, subject_attr_names=["description", "name"], diff --git a/tests/data.py b/tests/data.py index 1f2689b..2bc2657 100644 --- a/tests/data.py +++ b/tests/data.py @@ -114,6 +114,13 @@ } } +find_stem_result_valid_empty = { + "WsFindStemsResults": { + "resultMetadata": {"success": "T"}, + "stemResults": [], + } +} + ws_membership1 = { "membershipType": "immediate", "groupId": "1ab0482715c74f51bc32822a70bf8f77", diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..3824ed4 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from grouper_python.objects import Subject + + +def test_object_equality(grouper_subject: Subject): + compare = grouper_subject == "a thing" + assert compare is False + + +def test_dict_output(grouper_subject: Subject): + assert grouper_subject.dict() == { + "name": "User 3 Name", + "id": "abcdefgh3", + "sourceId": "ldap", + "description": "user3333", + "universal_identifier": "user3333", + } diff --git a/tests/test_client.py b/tests/test_client.py index f405a93..38e0800 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from grouper_python import Client -from grouper_python import Group, Stem, Subject, Person + from grouper_python import GrouperClient +from grouper_python.objects import Group, Stem, Subject, Person from . import data import pytest import respx @@ -16,31 +16,31 @@ def test_import_and_init(): - from grouper_python import Client + from grouper_python import GrouperClient - Client("url", "username", "password") + GrouperClient("url", "username", "password") def test_context_manager(): - from grouper_python import Client + from grouper_python import GrouperClient - with Client("url", "username", "password") as client: + with GrouperClient("url", "username", "password") as client: print(client) assert client.httpx_client.is_closed is True def test_close(): - from grouper_python import Client + from grouper_python import GrouperClient - client = Client("url", "username", "password") + client = GrouperClient("url", "username", "password") assert client.httpx_client.is_closed is False client.close() assert client.httpx_client.is_closed is True @respx.mock -def test_get_group(grouper_client: Client): +def test_get_group(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_one_group_1) ) @@ -50,7 +50,7 @@ def test_get_group(grouper_client: Client): @respx.mock -def test_get_group_not_found(grouper_client: Client): +def test_get_group_not_found(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_no_groups) ) @@ -62,7 +62,7 @@ def test_get_group_not_found(grouper_client: Client): @respx.mock -def test_get_groups(grouper_client: Client): +def test_get_groups(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_two_groups) ) @@ -79,7 +79,7 @@ def test_get_groups(grouper_client: Client): @respx.mock -def test_get_groups_invalid_stem(grouper_client: Client): +def test_get_groups_invalid_stem(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_stem_not_found) ) @@ -91,7 +91,7 @@ def test_get_groups_invalid_stem(grouper_client: Client): @respx.mock -def test_get_groups_no_groups(grouper_client: Client): +def test_get_groups_no_groups(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_no_groups) ) @@ -101,7 +101,7 @@ def test_get_groups_no_groups(grouper_client: Client): @respx.mock -def test_get_stem(grouper_client: Client): +def test_get_stem(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/stems").mock( return_value=Response(200, json=data.find_stem_result_valid_1) ) @@ -113,7 +113,19 @@ def test_get_stem(grouper_client: Client): @respx.mock -def test_get_subject(grouper_client: Client): +def test_get_stem_not_found(grouper_client: GrouperClient): + respx.post(url=data.URI_BASE + "/stems").mock( + return_value=Response(200, json=data.find_stem_result_valid_empty) + ) + + with pytest.raises(GrouperStemNotFoundException) as excinfo: + grouper_client.get_stem("invalid") + + assert excinfo.value.stem_name == "invalid" + + +@respx.mock +def test_get_subject(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_valid_person) ) @@ -126,7 +138,7 @@ def test_get_subject(grouper_client: Client): @respx.mock -def test_get_subject_is_group(grouper_client: Client): +def test_get_subject_is_group(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_valid_group) ) @@ -140,7 +152,7 @@ def test_get_subject_is_group(grouper_client: Client): @respx.mock -def test_get_subject_is_group_not_resolve(grouper_client: Client): +def test_get_subject_is_group_not_resolve(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_valid_group) ) @@ -150,7 +162,7 @@ def test_get_subject_is_group_not_resolve(grouper_client: Client): @respx.mock -def test_get_subject_not_found(grouper_client: Client): +def test_get_subject_not_found(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_subject_not_found) ) @@ -162,7 +174,7 @@ def test_get_subject_not_found(grouper_client: Client): @respx.mock -def test_find_subjects(grouper_client: Client): +def test_find_subjects(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response( 200, json=data.get_subject_result_valid_search_multiple_subjects @@ -172,20 +184,20 @@ def test_find_subjects(grouper_client: Client): return_value=Response(200, json=data.find_groups_result_valid_one_group_1) ) - subjects = grouper_client.find_subject("user") + subjects = grouper_client.find_subjects("user") assert len(subjects) == 3 - subjects = grouper_client.find_subject("user", resolve_groups=False) + subjects = grouper_client.find_subjects("user", resolve_groups=False) assert len(subjects) == 3 @respx.mock -def test_find_subjects_no_result(grouper_client: Client): +def test_find_subjects_no_result(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response( 200, json=data.get_subject_result_valid_search_no_results ) ) - subjects = grouper_client.find_subject("user") + subjects = grouper_client.find_subjects("user") assert len(subjects) == 0 diff --git a/tests/test_group.py b/tests/test_group.py index 88037b9..88fd087 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,7 +5,7 @@ GrouperPermissionDenied, GrouperGroupNotFoundException, ) -from grouper_python import Person, Group, Subject +from grouper_python.objects import Person, Group, Subject from . import data import pytest import respx diff --git a/tests/test_person.py b/tests/test_person.py index c191296..78f3148 100644 --- a/tests/test_person.py +++ b/tests/test_person.py @@ -2,12 +2,13 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from grouper_python import Person -from grouper_python import Group + from grouper_python.objects import Person +from grouper_python.objects import Group from . import data import pytest import respx from httpx import Response +from grouper_python.objects.exceptions import GrouperSubjectNotFoundException @respx.mock @@ -69,5 +70,7 @@ def test_is_member_not_found(grouper_person: Person): return_value=Response(200, json=data.has_member_result_subject_not_found) ) - with pytest.raises(ValueError): + with pytest.raises(GrouperSubjectNotFoundException) as excinfo: grouper_person.is_member("test:GROUP2") + + assert excinfo.value.subject_identifier == "user3333" diff --git a/tests/test_privilege.py b/tests/test_privilege.py index 26ec6c7..1cfb93d 100644 --- a/tests/test_privilege.py +++ b/tests/test_privilege.py @@ -2,7 +2,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from grouper_python import Subject, Group, Client + from grouper_python import GrouperClient + from grouper_python.objects import Subject, Group import respx from httpx import Response from . import data @@ -17,7 +18,7 @@ def test_get_privilege_both_group_and_stem(grouper_subject: Subject): with pytest.raises(ValueError) as excinfo: - grouper_subject.get_privileges_for_this( + grouper_subject.get_privileges_for_this_in_others( group_name="test:GROUP1", stem_name="test:child" ) @@ -69,12 +70,12 @@ def test_get_privilege_with_privilege_type(grouper_subject: Subject): json=data.get_priv_for_group_with_privilege_type_request, ).mock(return_value=Response(200, json=data.get_priv_for_group_result)) - privs = grouper_subject.get_privileges_for_this(privilege_type="access") + privs = grouper_subject.get_privileges_for_this_in_others(privilege_type="access") assert len(privs) == 1 -def test_get_privileges_no_target(grouper_client: Client): +def test_get_privileges_no_target(grouper_client: GrouperClient): with pytest.raises(ValueError) as excinfo: get_privileges(grouper_client) @@ -117,7 +118,7 @@ def test_get_privilege_group_not_found(grouper_subject: Subject): ) with pytest.raises(GrouperGroupNotFoundException) as excinfo: - grouper_subject.get_privileges_for_this(group_name="test:GROUP1") + grouper_subject.get_privileges_for_this_in_others(group_name="test:GROUP1") assert excinfo.value.group_name == "test:GROUP1" @@ -129,6 +130,6 @@ def test_get_privilege_stem_not_found(grouper_subject: Subject): ) with pytest.raises(GrouperStemNotFoundException) as excinfo: - grouper_subject.get_privileges_for_this(stem_name="invalid") + grouper_subject.get_privileges_for_this_in_others(stem_name="invalid") assert excinfo.value.stem_name == "invalid" diff --git a/tests/test_stem.py b/tests/test_stem.py index 717b5fb..539857c 100644 --- a/tests/test_stem.py +++ b/tests/test_stem.py @@ -1,5 +1,5 @@ from __future__ import annotations -from grouper_python import Stem, Group +from grouper_python.objects import Stem, Group from . import data import respx from httpx import Response diff --git a/tests/test_subject.py b/tests/test_subject.py index 994e941..72a178c 100644 --- a/tests/test_subject.py +++ b/tests/test_subject.py @@ -2,17 +2,12 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from grouper_python import Subject + from grouper_python.objects import Subject import respx from httpx import Response from . import data -def test_subject_equality(grouper_subject: Subject): - compare = grouper_subject == "a thing" - assert compare is False - - @respx.mock def test_get_privilege(grouper_subject: Subject): respx.route( @@ -21,6 +16,6 @@ def test_get_privilege(grouper_subject: Subject): json=data.get_priv_for_subject_request, ).mock(return_value=Response(200, json=data.get_priv_for_subject_result)) - privs = grouper_subject.get_privileges_for_this() + privs = grouper_subject.get_privileges_for_this_in_others() assert len(privs) == 2 diff --git a/tests/test_util.py b/tests/test_util.py index 62c7778..ca52aaf 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,7 +5,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from grouper_python import Client, Person + from grouper_python import GrouperClient + from grouper_python.objects import Person import respx from httpx import Response from . import data @@ -19,7 +20,7 @@ ) -def test_both_act_as_id_and_identifier(grouper_client: Client): +def test_both_act_as_id_and_identifier(grouper_client: GrouperClient): with pytest.raises(ValueError) as excinfo: call_grouper( grouper_client.httpx_client, @@ -36,7 +37,7 @@ def test_both_act_as_id_and_identifier(grouper_client: Client): @respx.mock -def test_act_as_subject_lite(grouper_client: Client, grouper_person: Person): +def test_act_as_subject_lite(grouper_client: GrouperClient, grouper_person: Person): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_one_group_1) ) @@ -44,7 +45,7 @@ def test_act_as_subject_lite(grouper_client: Client, grouper_person: Person): @respx.mock -def test_act_as_subject_notlite(grouper_client: Client, grouper_person: Person): +def test_act_as_subject_notlite(grouper_client: GrouperClient, grouper_person: Person): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_valid_person) ) @@ -52,7 +53,7 @@ def test_act_as_subject_notlite(grouper_client: Client, grouper_person: Person): @respx.mock -def test_act_as_id_lite(grouper_client: Client): +def test_act_as_id_lite(grouper_client: GrouperClient): orig_body = { "WsRestFindGroupsLiteRequest": { "groupName": "test:GROUP1", @@ -80,7 +81,7 @@ def test_act_as_id_lite(grouper_client: Client): @respx.mock -def test_act_as_identifier_lite(grouper_client: Client): +def test_act_as_identifier_lite(grouper_client: GrouperClient): orig_body = { "WsRestFindGroupsLiteRequest": { "groupName": "test:GROUP1", @@ -108,7 +109,7 @@ def test_act_as_identifier_lite(grouper_client: Client): @respx.mock -def test_act_as_id_notlite(grouper_client: Client): +def test_act_as_id_notlite(grouper_client: GrouperClient): orig_body = { "WsRestGetSubjectsRequest": { "wsSubjectLookups": [{"subjectIdentifier": "user1234"}], @@ -134,7 +135,7 @@ def test_act_as_id_notlite(grouper_client: Client): @respx.mock -def test_act_as_identifier_notlite(grouper_client: Client): +def test_act_as_identifier_notlite(grouper_client: GrouperClient): orig_body = { "WsRestGetSubjectsRequest": { "wsSubjectLookups": [{"subjectIdentifier": "user1234"}], @@ -160,7 +161,7 @@ def test_act_as_identifier_notlite(grouper_client: Client): @respx.mock -def test_grouper_auth_exception(grouper_client: Client): +def test_grouper_auth_exception(grouper_client: GrouperClient): """validate that a 401 (auth issue) from the grouper API is properly caught""" respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(401, text="Unauthorized") @@ -171,7 +172,7 @@ def test_grouper_auth_exception(grouper_client: Client): @respx.mock -def test_assign_privilege_unknown_target_type(grouper_client: Client): +def test_assign_privilege_unknown_target_type(grouper_client: GrouperClient): with pytest.raises(ValueError) as excinfo: assign_privilege( "target:name", "type", "update", "user1234", "T", grouper_client @@ -183,7 +184,7 @@ def test_assign_privilege_unknown_target_type(grouper_client: Client): @respx.mock -def test_has_members_no_subject(grouper_client: Client): +def test_has_members_no_subject(grouper_client: GrouperClient): with pytest.raises(ValueError) as excinfo: has_members("target:name", grouper_client) assert ( @@ -193,7 +194,7 @@ def test_has_members_no_subject(grouper_client: Client): @respx.mock -def test_get_members_second_group_not_found(grouper_client: Client): +def test_get_members_second_group_not_found(grouper_client: GrouperClient): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response( 200, json=data.get_members_result_multiple_groups_second_group_not_found