From 0ebee9691ad7577337c0746940014d282f4514ce Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 15:39:15 -0500 Subject: [PATCH 01/22] Update azure-pipelines.yml --- azure-pipelines.yml | 57 ++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index b1d5cc3..795ec7a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,3 +1,5 @@ +name: $(Date:yyyyMMdd).$(Rev:rr) + pool: vmImage: ubuntu-latest @@ -43,36 +45,37 @@ stages: - task: TwineAuthenticate@1 inputs: artifactFeed: 'ITAC-API/pygrouper' - - task: PythonScript@0 - inputs: - scriptSource: inline - script: | - with open('pygrouper/__init__.py', 'r+') as f: - content = f.read() + - ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: + - task: PythonScript@0 + inputs: + scriptSource: inline + script: | + with open('pygrouper/__init__.py', 'r+') as f: + content = f.read() - for line in content.splitlines(): - if line.startswith('__version__'): - delim = '"' if '"' in line else "'" - version = line.split(delim)[1] - break - else: - raise RuntimeError("Unable to find version string.") + for line in content.splitlines(): + if line.startswith('__version__'): + delim = '"' if '"' in line else "'" + version = line.split(delim)[1] + break + else: + raise RuntimeError("Unable to find version string.") - print(f"found {version}") - build_number = "$(Build.BuildNumber)".replace(".", "") - new_version = f"{version}b{build_number}" - print(f"new version: {new_version}") + print(f"found {version}") + build_number = "$(Build.BuildNumber)".replace(".", "") + new_version = f"{version}b{build_number}" + print(f"new version: {new_version}") - content = content.replace(version, new_version) - f.seek(0) - f.write(content) - f.truncate() - displayName: Set prerelease Version + content = content.replace(version, new_version) + f.seek(0) + f.write(content) + f.truncate() + displayName: Set prerelease Version - script: | pip install build twine python -m build - cat $(PYPIRC_PATH) - # twine upload -r mkdocs-material-umn --config-file $(PYPIRC_PATH) dist/* --verbose - pwd - ls -l - - publish: $(Pipeline.Workspace)/s/dist + # cat $(PYPIRC_PATH) + twine upload -r mkdocs-material-umn --config-file $(PYPIRC_PATH) dist/* --verbose + # pwd + # ls -l + # - publish: $(Pipeline.Workspace)/s/dist From b9a57686b0f014b66bf18b3e3e8602f16f94f860 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 15:42:23 -0500 Subject: [PATCH 02/22] Update azure-pipelines.yml --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 795ec7a..8c303ad 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -75,7 +75,7 @@ stages: pip install build twine python -m build # cat $(PYPIRC_PATH) - twine upload -r mkdocs-material-umn --config-file $(PYPIRC_PATH) dist/* --verbose + twine upload -r pygrouper --config-file $(PYPIRC_PATH) dist/* --verbose # pwd # ls -l # - publish: $(Pipeline.Workspace)/s/dist From ce1667c5760db27d99c3bff599ff494932f24fcd Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 16:11:45 -0500 Subject: [PATCH 03/22] troubleshoot failure --- azure-pipelines.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8c303ad..6ff6785 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -74,7 +74,7 @@ stages: - script: | pip install build twine python -m build - # cat $(PYPIRC_PATH) + cat $(PYPIRC_PATH) twine upload -r pygrouper --config-file $(PYPIRC_PATH) dist/* --verbose # pwd # ls -l diff --git a/pyproject.toml b/pyproject.toml index e098bc2..668a572 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] From a40358b2f6ee5437e5c07120253f04f3870c74fd Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 16:41:43 -0500 Subject: [PATCH 04/22] rename from pygrouper to python_grouper --- {pygrouper => grouper_python}/__init__.py | 0 {pygrouper => grouper_python}/group.py | 0 {pygrouper => grouper_python}/membership.py | 0 {pygrouper => grouper_python}/objects/__init__.py | 0 {pygrouper => grouper_python}/objects/client.py | 0 {pygrouper => grouper_python}/objects/exceptions.py | 0 {pygrouper => grouper_python}/objects/group.py | 0 {pygrouper => grouper_python}/objects/membership.py | 0 {pygrouper => grouper_python}/objects/person.py | 0 {pygrouper => grouper_python}/objects/py.typed | 0 {pygrouper => grouper_python}/objects/stem.py | 0 {pygrouper => grouper_python}/objects/subject.py | 0 {pygrouper => grouper_python}/privilege.py | 0 {pygrouper => grouper_python}/py.typed | 0 {pygrouper => grouper_python}/stem.py | 0 {pygrouper => grouper_python}/subject.py | 0 {pygrouper => grouper_python}/util.py | 0 pyproject.toml | 8 ++++---- tests/test_client.py | 2 +- 19 files changed, 5 insertions(+), 5 deletions(-) rename {pygrouper => grouper_python}/__init__.py (100%) rename {pygrouper => grouper_python}/group.py (100%) rename {pygrouper => grouper_python}/membership.py (100%) rename {pygrouper => grouper_python}/objects/__init__.py (100%) rename {pygrouper => grouper_python}/objects/client.py (100%) rename {pygrouper => grouper_python}/objects/exceptions.py (100%) rename {pygrouper => grouper_python}/objects/group.py (100%) rename {pygrouper => grouper_python}/objects/membership.py (100%) rename {pygrouper => grouper_python}/objects/person.py (100%) rename {pygrouper => grouper_python}/objects/py.typed (100%) rename {pygrouper => grouper_python}/objects/stem.py (100%) rename {pygrouper => grouper_python}/objects/subject.py (100%) rename {pygrouper => grouper_python}/privilege.py (100%) rename {pygrouper => grouper_python}/py.typed (100%) rename {pygrouper => grouper_python}/stem.py (100%) rename {pygrouper => grouper_python}/subject.py (100%) rename {pygrouper => grouper_python}/util.py (100%) diff --git a/pygrouper/__init__.py b/grouper_python/__init__.py similarity index 100% rename from pygrouper/__init__.py rename to grouper_python/__init__.py diff --git a/pygrouper/group.py b/grouper_python/group.py similarity index 100% rename from pygrouper/group.py rename to grouper_python/group.py diff --git a/pygrouper/membership.py b/grouper_python/membership.py similarity index 100% rename from pygrouper/membership.py rename to grouper_python/membership.py diff --git a/pygrouper/objects/__init__.py b/grouper_python/objects/__init__.py similarity index 100% rename from pygrouper/objects/__init__.py rename to grouper_python/objects/__init__.py diff --git a/pygrouper/objects/client.py b/grouper_python/objects/client.py similarity index 100% rename from pygrouper/objects/client.py rename to grouper_python/objects/client.py diff --git a/pygrouper/objects/exceptions.py b/grouper_python/objects/exceptions.py similarity index 100% rename from pygrouper/objects/exceptions.py rename to grouper_python/objects/exceptions.py diff --git a/pygrouper/objects/group.py b/grouper_python/objects/group.py similarity index 100% rename from pygrouper/objects/group.py rename to grouper_python/objects/group.py diff --git a/pygrouper/objects/membership.py b/grouper_python/objects/membership.py similarity index 100% rename from pygrouper/objects/membership.py rename to grouper_python/objects/membership.py diff --git a/pygrouper/objects/person.py b/grouper_python/objects/person.py similarity index 100% rename from pygrouper/objects/person.py rename to grouper_python/objects/person.py diff --git a/pygrouper/objects/py.typed b/grouper_python/objects/py.typed similarity index 100% rename from pygrouper/objects/py.typed rename to grouper_python/objects/py.typed diff --git a/pygrouper/objects/stem.py b/grouper_python/objects/stem.py similarity index 100% rename from pygrouper/objects/stem.py rename to grouper_python/objects/stem.py diff --git a/pygrouper/objects/subject.py b/grouper_python/objects/subject.py similarity index 100% rename from pygrouper/objects/subject.py rename to grouper_python/objects/subject.py diff --git a/pygrouper/privilege.py b/grouper_python/privilege.py similarity index 100% rename from pygrouper/privilege.py rename to grouper_python/privilege.py diff --git a/pygrouper/py.typed b/grouper_python/py.typed similarity index 100% rename from pygrouper/py.typed rename to grouper_python/py.typed diff --git a/pygrouper/stem.py b/grouper_python/stem.py similarity index 100% rename from pygrouper/stem.py rename to grouper_python/stem.py diff --git a/pygrouper/subject.py b/grouper_python/subject.py similarity index 100% rename from pygrouper/subject.py rename to grouper_python/subject.py diff --git a/pygrouper/util.py b/grouper_python/util.py similarity index 100% rename from pygrouper/util.py rename to grouper_python/util.py diff --git a/pyproject.toml b/pyproject.toml index 668a572..bd52490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,22 +3,22 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] -name = "pygrouper" +name = "grouper_python" requires-python = ">=3.11" dynamic = ["dependencies", "optional-dependencies", "version"] [tool.setuptools] -packages = ["pygrouper", "pygrouper.objects"] +packages = ["grouper_python", "grouper_python.objects"] [tool.setuptools.dynamic] dependencies = {file = "requirements.txt"} optional-dependencies.dev = {file = "requirements-dev.txt"} optional-dependencies.script = {file = "requirements-script.txt"} -version = {attr = "pygrouper.__version__"} +version = {attr = "grouper_python.__version__"} [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--cov pygrouper --cov-branch --cov-report=term-missing" +addopts = "--cov grouper_python --cov-branch --cov-report=term-missing" [tool.pylama] linters = "pycodestyle,pyflakes" diff --git a/tests/test_client.py b/tests/test_client.py index c6640bf..671d025 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -from pygrouper import Client +from grouper_python import Client def test_import(): From bb4f91404b629138666698d0155427459a3ed2d2 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 16:46:43 -0500 Subject: [PATCH 05/22] rename package in azure-pipelines --- azure-pipelines.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 6ff6785..b33c56c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -18,11 +18,11 @@ stages: displayName: Run Tests continueOnError: true - script: | - pytest --pylama -k 'pylama' --junit-xml=junit/test-pylama-results.xml -o addopts= tests/ pygrouper/ + pytest --pylama -k 'pylama' --junit-xml=junit/test-pylama-results.xml -o addopts= tests/ grouper_python/ displayName: Run Linter Tests continueOnError: true - script: | - mypy -p pygrouper --junit-xml junit/test-mypy-results.xml + mypy -p grouper_python --junit-xml junit/test-mypy-results.xml displayName: Run MyPy Tests continueOnError: true - task: PublishTestResults@2 @@ -50,7 +50,7 @@ stages: inputs: scriptSource: inline script: | - with open('pygrouper/__init__.py', 'r+') as f: + with open('grouper_python/__init__.py', 'r+') as f: content = f.read() for line in content.splitlines(): @@ -75,7 +75,7 @@ stages: pip install build twine python -m build cat $(PYPIRC_PATH) - twine upload -r pygrouper --config-file $(PYPIRC_PATH) dist/* --verbose + twine upload -r grouper_python --config-file $(PYPIRC_PATH) dist/* --verbose # pwd # ls -l # - publish: $(Pipeline.Workspace)/s/dist From ea41aeb5447fb41b5687ee5bd12bf3b390402ccf Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 16:51:13 -0500 Subject: [PATCH 06/22] Update azure-pipelines.yml --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index b33c56c..4553aa3 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -75,7 +75,7 @@ stages: pip install build twine python -m build cat $(PYPIRC_PATH) - twine upload -r grouper_python --config-file $(PYPIRC_PATH) dist/* --verbose + twine upload -r pygrouper --config-file $(PYPIRC_PATH) dist/* --verbose # pwd # ls -l # - publish: $(Pipeline.Workspace)/s/dist From c771c07954a3c166d4791a1f9c2d65aee2d280ba Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 24 Apr 2023 16:59:47 -0500 Subject: [PATCH 07/22] include py.typed in package --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bd52490..469ae5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ dynamic = ["dependencies", "optional-dependencies", "version"] [tool.setuptools] packages = ["grouper_python", "grouper_python.objects"] +[tool.setuptools.package-data] +"grouper_python" = ["py.typed"] + [tool.setuptools.dynamic] dependencies = {file = "requirements.txt"} optional-dependencies.dev = {file = "requirements-dev.txt"} From 0a9b29f7108bf74e5ef7308da77bd5b36d8d456d Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 08:22:18 -0500 Subject: [PATCH 08/22] add act_as_subject where missing --- grouper_python/objects/client.py | 4 ++-- grouper_python/objects/stem.py | 3 ++- grouper_python/stem.py | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index d8ac5e8..f6337b3 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -60,8 +60,8 @@ def get_groups( group_name=group_name, client=self, stem=stem, act_as_subject=act_as_subject ) - def get_stem(self, stem_name: str) -> "Stem": - return get_stem_by_name(stem_name, self) + def get_stem(self, stem_name: str, act_as_subject: Subject | None = None) -> Stem: + return get_stem_by_name(stem_name, self, act_as_subject=act_as_subject) def get_subject( self, diff --git a/grouper_python/objects/stem.py b/grouper_python/objects/stem.py index a06b329..594ba75 100644 --- a/grouper_python/objects/stem.py +++ b/grouper_python/objects/stem.py @@ -101,6 +101,7 @@ def create_child_group( display_extension: str, description: str = "", detail: dict[str, Any] | None = None, + act_as_subject: Subject | None = None, ) -> Group: from .group import CreateGroup @@ -110,7 +111,7 @@ def create_child_group( description=description, detail=detail, ) - return (create_groups([create], self.client))[0] + return (create_groups([create], self.client, act_as_subject))[0] def get_child_stems( self, diff --git a/grouper_python/stem.py b/grouper_python/stem.py index 0be08b8..8902fc0 100644 --- a/grouper_python/stem.py +++ b/grouper_python/stem.py @@ -7,7 +7,9 @@ from .objects.subject import Subject -def get_stem_by_name(stem_name: str, client: Client) -> Stem: +def get_stem_by_name( + stem_name: str, client: Client, act_as_subject: Subject | None = None +) -> Stem: from .objects.stem import Stem body = { @@ -17,7 +19,7 @@ def get_stem_by_name(stem_name: str, client: Client) -> Stem: # "includeGroupDetail": "T", } } - r = client._call_grouper("/stems", body) + r = client._call_grouper("/stems", body, act_as_subject=act_as_subject) return Stem.from_results(client, r["WsFindStemsResults"]["stemResults"][0]) From 16037a73dc091f2a24f1b0ed028466ec53b51187 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 08:32:34 -0500 Subject: [PATCH 09/22] Add close() to client --- grouper_python/objects/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index f6337b3..059fe28 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -41,6 +41,9 @@ def __exit__( ) -> None: self.httpx_client.close() + def close(self) -> None: + self.httpx_client.close() + def get_group( self, group_name: str, From dbc4ef3bcf27b68a5428f6a00b840783bfe79244 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 09:11:17 -0500 Subject: [PATCH 10/22] add tests --- grouper_python/group.py | 2 +- grouper_python/membership.py | 2 +- grouper_python/objects/client.py | 2 +- grouper_python/objects/group.py | 2 +- grouper_python/objects/person.py | 2 +- grouper_python/objects/stem.py | 2 +- grouper_python/objects/subject.py | 2 +- grouper_python/privilege.py | 2 +- grouper_python/stem.py | 2 +- grouper_python/subject.py | 2 +- requirements-dev.txt | 3 +++ tests/__init__.py | 0 tests/conftest.py | 11 ++++++++ tests/data.py | 22 ++++++++++++++++ tests/test_client.py | 42 +++++++++++++++++++++++++++++-- 15 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/data.py diff --git a/grouper_python/group.py b/grouper_python/group.py index a1782e8..3ec6905 100644 --- a/grouper_python/group.py +++ b/grouper_python/group.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .objects.group import CreateGroup, Group from .objects.client import Client from .objects.subject import Subject diff --git a/grouper_python/membership.py b/grouper_python/membership.py index 240cf92..f795688 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .objects.group import Group from .objects.client import Client from .objects.membership import Membership, HasMember diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index 059fe28..1e922dd 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .group import Group from .stem import Stem from .subject import Subject diff --git a/grouper_python/objects/group.py b/grouper_python/objects/group.py index b70a2a1..4311cbb 100644 --- a/grouper_python/objects/group.py +++ b/grouper_python/objects/group.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .membership import Membership, HasMember from .client import Client from .subject import Subject diff --git a/grouper_python/objects/person.py b/grouper_python/objects/person.py index deb978c..3cf9e95 100644 --- a/grouper_python/objects/person.py +++ b/grouper_python/objects/person.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .client import Client from .subject import Subject diff --git a/grouper_python/objects/stem.py b/grouper_python/objects/stem.py index 594ba75..3456fc6 100644 --- a/grouper_python/objects/stem.py +++ b/grouper_python/objects/stem.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .subject import Subject from .group import Group diff --git a/grouper_python/objects/subject.py b/grouper_python/objects/subject.py index a87aa25..395dc96 100644 --- a/grouper_python/objects/subject.py +++ b/grouper_python/objects/subject.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .group import Group from .client import Client diff --git a/grouper_python/privilege.py b/grouper_python/privilege.py index 0c83293..7ba0bdc 100644 --- a/grouper_python/privilege.py +++ b/grouper_python/privilege.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .objects.client import Client from .objects.subject import Subject diff --git a/grouper_python/stem.py b/grouper_python/stem.py index 8902fc0..2e58fa0 100644 --- a/grouper_python/stem.py +++ b/grouper_python/stem.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .objects.stem import Stem, CreateStem from .objects.client import Client from .objects.subject import Subject diff --git a/grouper_python/subject.py b/grouper_python/subject.py index 3f83916..7565d85 100644 --- a/grouper_python/subject.py +++ b/grouper_python/subject.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from .objects.group import Group from .objects.client import Client from .objects.subject import Subject diff --git a/requirements-dev.txt b/requirements-dev.txt index ad4b06c..cc0165a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,3 +14,6 @@ mypy # files are being formatted (currently manually) with black black + +# For mocking httpx calls +respx diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..30c415b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +from grouper_python import Client +import pytest +from .data import URI_BASE + + +@pytest.fixture() +def grouper_client(): + with Client( + URI_BASE, "username", "password" + ) as client: + yield client diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 0000000..e897943 --- /dev/null +++ b/tests/data.py @@ -0,0 +1,22 @@ +URI_BASE = "https://grouper/grouper-ws/servicesRest/v2_4_000" + +grouper_group_result1 = { + "extension": "GROUP1", + "displayName": "Test Stem:Test1 Display Name", + "description": "Group 1 Test description", + "uuid": "1ab0482715c74f51bc32822a70bf8f77", + "enabled": "T", + "displayExtension": "Test1 Display Name", + "name": "test:GROUP1", + "typeOfGroup": "group", + "idIndex": "12345", +} + +find_groups_result_valid_one_group = { + "WsFindGroupsResults": { + "resultMetadata": { + "success": "T", + }, + "groupResults": [grouper_group_result1], + } +} diff --git a/tests/test_client.py b/tests/test_client.py index 671d025..3480dd7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,43 @@ -from grouper_python import Client +from __future__ import annotations +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from grouper_python import Client +from . import data +import respx +from httpx import Response + + +def test_import_and_init(): + from grouper_python import Client -def test_import(): Client("url", "username", "password") + + +def test_context_manager(): + from grouper_python import Client + + with Client("url", "username", "password") as client: + print(client) + + assert client.httpx_client.is_closed is True + + +def test_close(): + from grouper_python import Client + + client = Client("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): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group) + ) + group = grouper_client.get_group("test:GROUP1") + + assert group.name == group.universal_identifier == "test:GROUP1" + assert group.id == group.uuid == "1ab0482715c74f51bc32822a70bf8f77" From b57321708074e461517a76b69031626bbc758f4e Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 09:44:16 -0500 Subject: [PATCH 11/22] client tests --- grouper_python/objects/client.py | 2 +- grouper_python/objects/person.py | 2 +- tests/conftest.py | 4 +-- tests/data.py | 56 ++++++++++++++++++++++++++++++-- tests/test_client.py | 32 ++++++++++++++++++ 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index 1e922dd..3493e1e 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -58,7 +58,7 @@ def get_groups( group_name: str, stem: str | None = None, act_as_subject: Subject | None = None, - ) -> list["Group"]: + ) -> list[Group]: return find_group_by_name( group_name=group_name, client=self, stem=stem, act_as_subject=act_as_subject ) diff --git a/grouper_python/objects/person.py b/grouper_python/objects/person.py index 3cf9e95..8c8f9a1 100644 --- a/grouper_python/objects/person.py +++ b/grouper_python/objects/person.py @@ -24,7 +24,7 @@ def from_results( } return cls( id=person_body["id"], - description=person_body.get("description", ""), + description=attrs.get("description", ""), universal_identifier=attrs.get(client.universal_identifier_attr, ""), sourceId=person_body["sourceId"], name=person_body["name"], diff --git a/tests/conftest.py b/tests/conftest.py index 30c415b..d1c43f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,5 @@ @pytest.fixture() def grouper_client(): - with Client( - URI_BASE, "username", "password" - ) as client: + with Client(URI_BASE, "username", "password") as client: yield client diff --git a/tests/data.py b/tests/data.py index e897943..df270f3 100644 --- a/tests/data.py +++ b/tests/data.py @@ -12,11 +12,61 @@ "idIndex": "12345", } +grouper_group_result2 = { + "extension": "GROUP2", + "displayName": "Test Stem:Test2 Display Name", + "description": "Group 2 Test description", + "uuid": "61db7e3435864838b039a7fce155d49c", + "enabled": "T", + "displayExtension": "Test1 Display Name", + "name": "test:GROUP1", + "typeOfGroup": "group", + "idIndex": "12345", +} + find_groups_result_valid_one_group = { "WsFindGroupsResults": { - "resultMetadata": { - "success": "T", - }, + "resultMetadata": {"success": "T"}, "groupResults": [grouper_group_result1], } } + +find_groups_result_valid_two_groups = { + "WsFindGroupsResults": { + "resultMetadata": {"success": "T"}, + "groupResults": [grouper_group_result1, grouper_group_result2], + } +} + +get_subject_result_valid = { + "WsGetSubjectsResults": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": ["name", "description"], + "wsSubjects": [ + { + "sourceId": "ldap", + "success": "T", + "attributeValues": ["User 3 Name", "username3"], + "name": "User 3 Name", + "id": "12345abcd", + } + ], + } +} + +find_stem_result_valid = { + "WsFindStemsResults": { + "resultMetadata": {"success": "T"}, + "stemResults": [ + { + "displayExtension": "Child Stem", + "extension": "child", + "displayName": "Test Stem:Child Stem", + "name": "test:child", + "description": "a child stem", + "idIndex": "452945", + "uuid": "e2c91c056fb746cca551d6887c722215", + } + ], + } +} diff --git a/tests/test_client.py b/tests/test_client.py index 3480dd7..af383dd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,3 +41,35 @@ def test_get_group(grouper_client: Client): assert group.name == group.universal_identifier == "test:GROUP1" assert group.id == group.uuid == "1ab0482715c74f51bc32822a70bf8f77" + + +@respx.mock +def test_get_groups(grouper_client: Client): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_two_groups) + ) + groups = grouper_client.get_groups("GROUP") + + assert len(groups) == 2 + + +@respx.mock +def test_get_stem(grouper_client: Client): + respx.post(url=data.URI_BASE + "/stems").mock( + return_value=Response(200, json=data.find_stem_result_valid) + ) + stem = grouper_client.get_stem("test:child") + + assert stem.id == stem.uuid == "e2c91c056fb746cca551d6887c722215" + assert stem.name == "test:child" + + +@respx.mock +def test_get_subject(grouper_client: Client): + respx.post(url=data.URI_BASE + "/subjects").mock( + return_value=Response(200, json=data.get_subject_result_valid) + ) + subject = grouper_client.get_subject("username3") + + assert subject.id == "12345abcd" + assert subject.description == subject.universal_identifier == "username3" From 5ace3638f3948805b95442be143e1b4b5c31a791 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 12:34:19 -0500 Subject: [PATCH 12/22] test group and stem --- grouper_python/__init__.py | 6 +- grouper_python/membership.py | 43 +++--- tests/conftest.py | 24 +++- tests/data.py | 260 +++++++++++++++++++++++++++++++++-- tests/test_client.py | 18 ++- tests/test_group.py | 99 +++++++++++++ tests/test_stem.py | 89 ++++++++++++ 7 files changed, 496 insertions(+), 43 deletions(-) create mode 100644 tests/test_group.py create mode 100644 tests/test_stem.py diff --git a/grouper_python/__init__.py b/grouper_python/__init__.py index ee85f70..f844c95 100644 --- a/grouper_python/__init__.py +++ b/grouper_python/__init__.py @@ -1,5 +1,9 @@ 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 __version__ = "0.1.0" -__all__ = ["Client"] +__all__ = ["Client", "Group", "Stem", "Subject", "Person"] diff --git a/grouper_python/membership.py b/grouper_python/membership.py index f795688..67ff5f2 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -119,24 +119,20 @@ def has_members( ) -> dict[str, HasMember]: from .objects.membership import HasMember - if (subject_identifiers and subject_ids) or ( - not subject_identifiers and not subject_ids - ): + if not subject_identifiers and not subject_ids: raise ValueError( "Exactly one of subject_identifiers or subject_ids must be specified" ) - subject_lookups = [] - if subject_identifiers: - subject_lookups = [ - {"subjectIdentifier": ident} for ident in subject_identifiers - ] - ident_key = "identifierLookup" - elif subject_ids: - subject_lookups = [{"subjectId": ident} for ident in subject_ids] - ident_key = "id" + # subject_lookups = [] + # if subject_identifiers: + subject_identifier_lookups = [ + {"subjectIdentifier": ident} for ident in subject_identifiers + ] + # if subject_ids: + subject_id_lookups = [{"subjectId": ident} for ident in subject_ids] body = { "WsRestHasMemberRequest": { - "subjectLookups": subject_lookups, + "subjectLookups": subject_identifier_lookups + subject_id_lookups, "memberFilter": member_filter, } } @@ -162,14 +158,21 @@ def has_members( ident = result["wsSubject"]["id"] else: raise GrouperSuccessException(r) - if result["resultMetadata"]["resultCode"] == "IS_NOT_MEMBER": - is_member = HasMember.IS_NOT_MEMBER - ident = result["wsSubject"][ident_key] - elif result["resultMetadata"]["resultCode"] == "IS_MEMBER": - is_member = HasMember.IS_MEMBER - ident = result["wsSubject"][ident_key] else: - raise GrouperSuccessException(r) + if "identifierLookup" in result["wsSubject"]: + ident_key = "identifierLookup" + elif "id" in result["wsSubject"]: + ident_key = "id" + else: + raise GrouperSuccessException(r) + if result["resultMetadata"]["resultCode"] == "IS_NOT_MEMBER": + is_member = HasMember.IS_NOT_MEMBER + ident = result["wsSubject"][ident_key] + elif result["resultMetadata"]["resultCode"] == "IS_MEMBER": + is_member = HasMember.IS_MEMBER + ident = result["wsSubject"][ident_key] + else: + raise GrouperSuccessException(r) r_dict[ident] = is_member return r_dict diff --git a/tests/conftest.py b/tests/conftest.py index d1c43f5..1d20f63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,25 @@ -from grouper_python import Client +from collections.abc import Iterable + +from grouper_python import Client, Group, Stem import pytest -from .data import URI_BASE +from . import data @pytest.fixture() -def grouper_client(): - with Client(URI_BASE, "username", "password") as client: +def grouper_client() -> Iterable[Client]: + with Client(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) + 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) + yield stem diff --git a/tests/data.py b/tests/data.py index df270f3..fd69d56 100644 --- a/tests/data.py +++ b/tests/data.py @@ -18,19 +18,45 @@ "description": "Group 2 Test description", "uuid": "61db7e3435864838b039a7fce155d49c", "enabled": "T", - "displayExtension": "Test1 Display Name", - "name": "test:GROUP1", + "displayExtension": "Test2 Display Name", + "name": "test:GROUP2", "typeOfGroup": "group", "idIndex": "12345", } -find_groups_result_valid_one_group = { +grouper_group_result3 = { + "extension": "GROUP3", + "displayName": "Test Stem:Child Stem:Test3 Display Name", + "description": "Group 3 Test description", + "uuid": "61db7e3435864838b039a7fce155d49c", + "enabled": "T", + "displayExtension": "Test3 Display Name", + "name": "test:child:GROUP3", + "typeOfGroup": "group", + "idIndex": "12345", +} + +find_groups_result_valid_one_group_1 = { "WsFindGroupsResults": { "resultMetadata": {"success": "T"}, "groupResults": [grouper_group_result1], } } +find_groups_result_valid_one_group_2 = { + "WsFindGroupsResults": { + "resultMetadata": {"success": "T"}, + "groupResults": [grouper_group_result2], + } +} + +find_groups_result_valid_one_group_3 = { + "WsFindGroupsResults": { + "resultMetadata": {"success": "T"}, + "groupResults": [grouper_group_result3], + } +} + find_groups_result_valid_two_groups = { "WsFindGroupsResults": { "resultMetadata": {"success": "T"}, @@ -46,7 +72,7 @@ { "sourceId": "ldap", "success": "T", - "attributeValues": ["User 3 Name", "username3"], + "attributeValues": ["User 3 Name", "user3333"], "name": "User 3 Name", "id": "12345abcd", } @@ -54,19 +80,227 @@ } } -find_stem_result_valid = { +grouper_stem_1 = { + "displayExtension": "Child Stem", + "extension": "child", + "displayName": "Test Stem:Child Stem", + "name": "test:child", + "description": "a child stem", + "idIndex": "452945", + "uuid": "e2c91c056fb746cca551d6887c722215", +} + +grouper_stem_2 = { + "displayExtension": "Second Child Stem", + "extension": "second", + "displayName": "Test Stem:Child Stem:Second Child Stem", + "name": "test:child:second", + "description": "a second child stem", + "idIndex": "452945", + "uuid": "359ecba27d704e58841e26fcbb3bfca8", +} + +find_stem_result_valid_1 = { "WsFindStemsResults": { "resultMetadata": {"success": "T"}, - "stemResults": [ + "stemResults": [grouper_stem_1], + } +} + +find_stem_result_valid_2 = { + "WsFindStemsResults": { + "resultMetadata": {"success": "T"}, + "stemResults": [grouper_stem_2], + } +} + +ws_membership1 = { + "membershipType": "immediate", + "groupId": "1ab0482715c74f51bc32822a70bf8f77", + "subjectId": "61db7e3435864838b039a7fce155d49c", + "subjectSourceId": "g:gsa", +} +ws_membership2 = { + "membershipType": "effective", + "groupId": "1ab0482715c74f51bc32822a70bf8f77", + "subjectId": "abcdefgh1", + "subjectSourceId": "ldap", +} +ws_membership3 = { + "membershipType": "immediate", + "groupId": "1ab0482715c74f51bc32822a70bf8f77", + "subjectId": "abcdefgh2", + "subjectSourceId": "ldap", +} +ws_membership4 = { + "membershipType": "effective", + "groupId": "1ab0482715c74f51bc32822a70bf8f77", + "subjectId": "abcdefgh3", + "subjectSourceId": "ldap", +} +ws_membership5 = { + "membershipType": "immediate", + "groupId": "1ab0482715c74f51bc32822a70bf8f77", + "subjectId": "abcdefgh3", + "subjectSourceId": "umnldap", +} +ws_subject1 = { + "sourceId": "g:gsa", + "attributeValues": ["Group 2 Test description", "test:GROUP2"], + "name": "test:GROUP2", + "id": "61db7e3435864838b039a7fce155d49c", +} +ws_subject2 = { + "sourceId": "ldap", + "attributeValues": ["user1111", "User 1 Name"], + "name": "User 1 Name", + "id": "abcdefgh1", +} +ws_subject3 = { + "sourceId": "ldap", + "attributeValues": ["user2222", "User 2 Name"], + "name": "User 2 Name", + "id": "abcdefgh2", +} +ws_subject4 = { + "sourceId": "ldap", + "attributeValues": ["user3333", "User 3 Name"], + "name": "User 3 Name", + "id": "abcdefgh3", +} + +get_subject_result_valid = { + "WsGetSubjectsResults": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": ["description", "name"], + "wsSubjects": [ws_subject4 | {"success": "T"}], + } +} + +get_members_result_valid_one_group = { + "WsGetMembersResults": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": ["description", "name"], + "results": [ { - "displayExtension": "Child Stem", - "extension": "child", - "displayName": "Test Stem:Child Stem", - "name": "test:child", - "description": "a child stem", - "idIndex": "452945", - "uuid": "e2c91c056fb746cca551d6887c722215", + "resultMetadata": {"success": "T"}, + "wsGroup": grouper_group_result1, + "wsSubjects": [ws_subject1, ws_subject2], } ], } } + +get_membership_result_valid_one_group = { + "WsGetMembershipsResults": { + "resultMetadata": {"success": "T"}, + "wsMemberships": [ + ws_membership1, + ws_membership2, + ws_membership3, + ws_membership4, + ws_membership5, + ], + "subjectAttributeNames": ["description", "name"], + "wsGroups": [grouper_group_result1], + "wsSubjects": [ws_subject1, ws_subject2, ws_subject3, ws_subject4], + } +} + +create_priv_group_request = { + "WsRestAssignGrouperPrivilegesLiteRequest": { + "allowed": "T", + "privilegeName": "update", + "subjectIdentifier": "user3333", + "groupName": "test:GROUP1", + "privilegeType": "access", + } +} + +delete_priv_group_request = { + "WsRestAssignGrouperPrivilegesLiteRequest": { + "allowed": "F", + "privilegeName": "update", + "subjectIdentifier": "user3333", + "groupName": "test:GROUP1", + "privilegeType": "access", + } +} + +assign_priv_result_valid = { + "WsAssignGrouperPrivilegesLiteResult": {"resultMetadata": {"success": "T"}} +} + +add_member_result_valid = { + "WsAddMemberResults": { + "resultMetadata": {"success": "T"}, + "wsGroupAssigned": grouper_group_result1, + } +} + +remove_member_result_valid = { + "WsDeleteMemberResults": { + "resultMetadata": {"success": "T"}, + "wsGroup": grouper_group_result1, + } +} + +has_member_result1 = { + "WsHasMemberResults": { + "resultMetadata": {"success": "T"}, + "results": [ + { + "resultMetadata": {"success": "T", "resultCode": "IS_MEMBER"}, + "wsSubject": {"identifierLookup": "user3333"}, + } + ], + } +} + +delete_groups_result_success = { + "WsGroupDeleteResults": { + "resultMetadata": {"success": "T"}, + "results": [{"resultMetadata": {"resultCode": "SUCCESS"}}], + } +} + + +create_priv_stem_request = { + "WsRestAssignGrouperPrivilegesLiteRequest": { + "allowed": "T", + "privilegeName": "stemAttrRead", + "subjectIdentifier": "user3333", + "stemName": "test:child", + "privilegeType": "naming", + } +} + +delete_priv_stem_request = { + "WsRestAssignGrouperPrivilegesLiteRequest": { + "allowed": "F", + "privilegeName": "stemAttrRead", + "subjectIdentifier": "user3333", + "stemName": "test:child", + "privilegeType": "naming", + } +} + +create_stems_result_success_one_stem = { + "WsStemSaveResults": { + "resultMetadata": {"success": "T"}, + "results": [{"wsStem": grouper_stem_2}], + } +} + +group_save_result_success_one_group = { + "WsGroupSaveResults": { + "resultMetadata": {"success": "T"}, + "results": [ + {"resultMetadata": {"success": "T"}, "wsGroup": grouper_group_result3} + ], + } +} + +delete_stem_result_success = { + "WsStemDeleteResults": {"resultMetadata": {"success": "T"}} +} diff --git a/tests/test_client.py b/tests/test_client.py index af383dd..a5c0572 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ if TYPE_CHECKING: from grouper_python import Client +from grouper_python import Group, Stem, Subject, Person from . import data import respx from httpx import Response @@ -35,10 +36,11 @@ def test_close(): @respx.mock def test_get_group(grouper_client: Client): respx.post(url=data.URI_BASE + "/groups").mock( - return_value=Response(200, json=data.find_groups_result_valid_one_group) + return_value=Response(200, json=data.find_groups_result_valid_one_group_1) ) group = grouper_client.get_group("test:GROUP1") + assert type(group) is Group assert group.name == group.universal_identifier == "test:GROUP1" assert group.id == group.uuid == "1ab0482715c74f51bc32822a70bf8f77" @@ -51,15 +53,19 @@ def test_get_groups(grouper_client: Client): groups = grouper_client.get_groups("GROUP") assert len(groups) == 2 + assert type(groups[0]) is Group + assert type(groups[1]) is Group + @respx.mock def test_get_stem(grouper_client: Client): respx.post(url=data.URI_BASE + "/stems").mock( - return_value=Response(200, json=data.find_stem_result_valid) + return_value=Response(200, json=data.find_stem_result_valid_1) ) stem = grouper_client.get_stem("test:child") + assert type(stem) is Stem assert stem.id == stem.uuid == "e2c91c056fb746cca551d6887c722215" assert stem.name == "test:child" @@ -69,7 +75,9 @@ def test_get_subject(grouper_client: Client): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_valid) ) - subject = grouper_client.get_subject("username3") + subject = grouper_client.get_subject("user3333") - assert subject.id == "12345abcd" - assert subject.description == subject.universal_identifier == "username3" + assert type(subject) is Person + assert isinstance(subject, Subject) is True + assert subject.id == "abcdefgh3" + assert subject.description == subject.universal_identifier == "user3333" diff --git a/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..c59b034 --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,99 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from grouper_python import Group +from grouper_python.objects.membership import HasMember +from . import data +import respx +from httpx import Response + + +@respx.mock +def test_get_members(grouper_group: Group): + respx.post(url=data.URI_BASE + "/memberships").mock( + return_value=Response(200, json=data.get_membership_result_valid_one_group) + ) + respx.post(url=data.URI_BASE + "/groups").mock( + side_effect=[ + Response(200, json=data.get_members_result_valid_one_group), + Response(200, json=data.find_groups_result_valid_one_group_2), + ] + ) + members = grouper_group.get_members() + + assert len(members) == 2 + + +@respx.mock +def test_get_memberships(grouper_group: Group): + respx.post(url=data.URI_BASE + "/memberships").mock( + return_value=Response(200, json=data.get_membership_result_valid_one_group) + ) + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_2) + ) + memberships = grouper_group.get_memberships() + + assert len(memberships) == 5 + + +@respx.mock +def test_create_privilege(grouper_group: Group): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.create_priv_group_request, + ).mock(Response(200, json=data.assign_priv_result_valid)) + + grouper_group.create_privilege("user3333", "update") + + +@respx.mock +def test_delete_privilege(grouper_group: Group): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.delete_priv_group_request, + ).mock(return_value=Response(200, json=data.assign_priv_result_valid)) + + grouper_group.delete_privilege("user3333", "update") + + +@respx.mock +def test_add_members(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.add_member_result_valid) + ) + + grouper_group.add_members(["abcdefgh1"]) + + +@respx.mock +def test_delete_members(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.remove_member_result_valid) + ) + + grouper_group.delete_members(["abcdefgh1"]) + + +@respx.mock +def test_has_members(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups/test:GROUP1/members").mock( + return_value=Response(200, json=data.has_member_result1) + ) + + has_members = grouper_group.has_members(["user3333"]) + + assert has_members["user3333"] == HasMember.IS_MEMBER + assert has_members["user3333"].value == 1 + + +@respx.mock +def test_delete(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.delete_groups_result_success) + ) + + grouper_group.delete() diff --git a/tests/test_stem.py b/tests/test_stem.py new file mode 100644 index 0000000..0943c1c --- /dev/null +++ b/tests/test_stem.py @@ -0,0 +1,89 @@ +from __future__ import annotations +# from typing import TYPE_CHECKING + +# if TYPE_CHECKING: +from grouper_python import Stem, Group +from . import data +import respx +from httpx import Response + + +@respx.mock +def test_create_privilege(grouper_stem: Stem): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.create_priv_stem_request, + ).mock(Response(200, json=data.assign_priv_result_valid)) + + grouper_stem.create_privilege("user3333", "stemAttrRead") + + +@respx.mock +def test_delete_privilege(grouper_stem: Stem): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.delete_priv_stem_request, + ).mock(return_value=Response(200, json=data.assign_priv_result_valid)) + + grouper_stem.delete_privilege("user3333", "stemAttrRead") + + +@respx.mock +def test_create_child_stem(grouper_stem: Stem): + respx.post(url=data.URI_BASE + "/stems").mock( + return_value=Response(200, json=data.create_stems_result_success_one_stem) + ) + + new_stem = grouper_stem.create_child_stem( + "second", "Second Child Stem", "a second child stem" + ) + + assert type(new_stem) is Stem + + +@respx.mock +def test_create_child_group(grouper_stem: Stem): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.group_save_result_success_one_group) + ) + + new_group = grouper_stem.create_child_group( + "GROUP3", "Test3 Display Name", "Group 3 Test description" + ) + + assert type(new_group) is Group + + +@respx.mock +def test_get_child_stems(grouper_stem: Stem): + respx.post(url=data.URI_BASE + "/stems").mock( + return_value=Response(200, json=data.find_stem_result_valid_2) + ) + + stems = grouper_stem.get_child_stems(True) + + assert len(stems) == 1 + assert type(stems[0]) is Stem + + +@respx.mock +def test_get_child_groups(grouper_stem: Stem): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_3) + ) + + groups = grouper_stem.get_child_groups(True) + + assert len(groups) == 1 + assert type(groups[0]) is Group + + +@respx.mock +def test_delete(grouper_stem: Stem): + respx.post(url=data.URI_BASE + "/stems").mock( + return_value=Response(200, json=data.delete_stem_result_success) + ) + + grouper_stem.delete() From 9aee9cb580e0d6da49ca1a6298143508837c4c52 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 13:45:30 -0500 Subject: [PATCH 13/22] test subject and person --- grouper_python/subject.py | 1 + tests/conftest.py | 24 +++++++++++- tests/data.py | 79 +++++++++++++++++++++++++++++---------- tests/test_client.py | 13 ++++++- tests/test_group.py | 2 +- tests/test_person.py | 73 ++++++++++++++++++++++++++++++++++++ tests/test_stem.py | 5 ++- tests/test_subject.py | 10 +++++ 8 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 tests/test_person.py create mode 100644 tests/test_subject.py diff --git a/grouper_python/subject.py b/grouper_python/subject.py index 7565d85..3397069 100644 --- a/grouper_python/subject.py +++ b/grouper_python/subject.py @@ -57,6 +57,7 @@ def get_subject_by_identifier( act_as_subject: Subject | None = None, ) -> Subject: from .objects.person import Person + from .objects.subject import Subject attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) body = { diff --git a/tests/conftest.py b/tests/conftest.py index 1d20f63..baac30e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ from collections.abc import Iterable -from grouper_python import Client, Group, Stem +from grouper_python import Client, Group, Stem, Person, Subject import pytest from . import data @@ -23,3 +23,25 @@ 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) yield stem + + +@pytest.fixture() +def grouper_subject() -> Iterable[Subject]: + with Client(data.URI_BASE, "username", "password") as client: + subject = Subject.from_results( + client=client, + subject_body=data.ws_subject4, + subject_attr_names=["description", "name"], + ) + yield subject + + +@pytest.fixture() +def grouper_person() -> Iterable[Person]: + with Client(data.URI_BASE, "username", "password") as client: + person = Person.from_results( + client=client, + person_body=data.ws_subject4, + subject_attr_names=["description", "name"], + ) + yield person diff --git a/tests/data.py b/tests/data.py index fd69d56..e910ffa 100644 --- a/tests/data.py +++ b/tests/data.py @@ -64,22 +64,6 @@ } } -get_subject_result_valid = { - "WsGetSubjectsResults": { - "resultMetadata": {"success": "T"}, - "subjectAttributeNames": ["name", "description"], - "wsSubjects": [ - { - "sourceId": "ldap", - "success": "T", - "attributeValues": ["User 3 Name", "user3333"], - "name": "User 3 Name", - "id": "12345abcd", - } - ], - } -} - grouper_stem_1 = { "displayExtension": "Child Stem", "extension": "child", @@ -142,7 +126,7 @@ "membershipType": "immediate", "groupId": "1ab0482715c74f51bc32822a70bf8f77", "subjectId": "abcdefgh3", - "subjectSourceId": "umnldap", + "subjectSourceId": "ldap", } ws_subject1 = { "sourceId": "g:gsa", @@ -169,7 +153,7 @@ "id": "abcdefgh3", } -get_subject_result_valid = { +get_subject_result_valid_person = { "WsGetSubjectsResults": { "resultMetadata": {"success": "T"}, "subjectAttributeNames": ["description", "name"], @@ -177,6 +161,14 @@ } } +get_subject_result_valid_group = { + "WsGetSubjectsResults": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": ["description", "name"], + "wsSubjects": [ws_subject1 | {"success": "T"}], + } +} + get_members_result_valid_one_group = { "WsGetMembersResults": { "resultMetadata": {"success": "T"}, @@ -207,6 +199,19 @@ } } +get_groups_for_subject_result_valid = { + "WsGetMembershipsResults": { + "resultMetadata": {"success": "T"}, + "wsGroups": [grouper_group_result1], + } +} + +get_groups_for_subject_no_memberships = { + "WsGetMembershipsResults": { + "resultMetadata": {"success": "T"}, + } +} + create_priv_group_request = { "WsRestAssignGrouperPrivilegesLiteRequest": { "allowed": "T", @@ -245,7 +250,7 @@ } } -has_member_result1 = { +has_member_result_identifier = { "WsHasMemberResults": { "resultMetadata": {"success": "T"}, "results": [ @@ -257,6 +262,42 @@ } } +has_member_result_id = { + "WsHasMemberResults": { + "resultMetadata": {"success": "T"}, + "results": [ + { + "resultMetadata": {"success": "T", "resultCode": "IS_MEMBER"}, + "wsSubject": {"id": "abcdefgh3"}, + } + ], + } +} + +has_member_result_not_member = { + "WsHasMemberResults": { + "resultMetadata": {"success": "T"}, + "results": [ + { + "resultMetadata": {"success": "T", "resultCode": "IS_NOT_MEMBER"}, + "wsSubject": {"id": "abcdefgh3"}, + } + ], + } +} + +has_member_result_subject_not_found = { + "WsHasMemberResults": { + "resultMetadata": {"success": "T"}, + "results": [ + { + "resultMetadata": {"success": "T", "resultCode2": "SUBJECT_NOT_FOUND"}, + "wsSubject": {"id": "abcdefgh3"}, + } + ], + } +} + delete_groups_result_success = { "WsGroupDeleteResults": { "resultMetadata": {"success": "T"}, diff --git a/tests/test_client.py b/tests/test_client.py index a5c0572..05b083a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -57,7 +57,6 @@ def test_get_groups(grouper_client: Client): assert type(groups[1]) is Group - @respx.mock def test_get_stem(grouper_client: Client): respx.post(url=data.URI_BASE + "/stems").mock( @@ -73,7 +72,7 @@ def test_get_stem(grouper_client: Client): @respx.mock def test_get_subject(grouper_client: Client): respx.post(url=data.URI_BASE + "/subjects").mock( - return_value=Response(200, json=data.get_subject_result_valid) + return_value=Response(200, json=data.get_subject_result_valid_person) ) subject = grouper_client.get_subject("user3333") @@ -81,3 +80,13 @@ def test_get_subject(grouper_client: Client): assert isinstance(subject, Subject) is True assert subject.id == "abcdefgh3" assert subject.description == subject.universal_identifier == "user3333" + + +@respx.mock +def test_get_subject_is_group_not_resolve(grouper_client: Client): + respx.post(url=data.URI_BASE + "/subjects").mock( + return_value=Response(200, json=data.get_subject_result_valid_group) + ) + subject = grouper_client.get_subject("test:GROUP2", False) + + assert type(subject) is Subject diff --git a/tests/test_group.py b/tests/test_group.py index c59b034..6c71205 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -81,7 +81,7 @@ def test_delete_members(grouper_group: Group): @respx.mock def test_has_members(grouper_group: Group): respx.post(url=data.URI_BASE + "/groups/test:GROUP1/members").mock( - return_value=Response(200, json=data.has_member_result1) + return_value=Response(200, json=data.has_member_result_identifier) ) has_members = grouper_group.has_members(["user3333"]) diff --git a/tests/test_person.py b/tests/test_person.py new file mode 100644 index 0000000..c191296 --- /dev/null +++ b/tests/test_person.py @@ -0,0 +1,73 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from grouper_python import Person +from grouper_python import Group +from . import data +import pytest +import respx +from httpx import Response + + +@respx.mock +def test_get_groups(grouper_person: Person): + respx.post(url=data.URI_BASE + "/memberships").mock( + return_value=Response(200, json=data.get_groups_for_subject_result_valid) + ) + + groups = grouper_person.get_groups() + assert len(groups) == 1 + assert type(groups[0]) is Group + + groups = grouper_person.get_groups(stem="test") + assert len(groups) == 1 + assert type(groups[0]) is Group + + groups = grouper_person.get_groups(stem="test", substems=False) + assert len(groups) == 1 + assert type(groups[0]) is Group + + +@respx.mock +def test_get_groups_no_membership(grouper_person: Person): + respx.post(url=data.URI_BASE + "/memberships").mock( + return_value=Response(200, json=data.get_groups_for_subject_no_memberships) + ) + + groups = grouper_person.get_groups() + assert len(groups) == 0 + + +@respx.mock +def test_is_member_true(grouper_person: Person): + respx.post(url=data.URI_BASE + "/groups/test:GROUP1/members").mock( + return_value=Response(200, json=data.has_member_result_id) + ) + + has_member = grouper_person.is_member("test:GROUP1") + + assert has_member is True + + +@respx.mock +def test_is_member_false(grouper_person: Person): + respx.post(url=data.URI_BASE + "/groups/test:GROUP2/members").mock( + return_value=Response(200, json=data.has_member_result_not_member) + ) + + has_member = grouper_person.is_member("test:GROUP2") + + assert has_member is False + + +@respx.mock +def test_is_member_not_found(grouper_person: Person): + # This is unlikely to happen, unless someone has manually + # constructed their Subject rather than building it from a Grouper Result + respx.post(url=data.URI_BASE + "/groups/test:GROUP2/members").mock( + return_value=Response(200, json=data.has_member_result_subject_not_found) + ) + + with pytest.raises(ValueError): + grouper_person.is_member("test:GROUP2") diff --git a/tests/test_stem.py b/tests/test_stem.py index 0943c1c..81d4daf 100644 --- a/tests/test_stem.py +++ b/tests/test_stem.py @@ -62,8 +62,11 @@ def test_get_child_stems(grouper_stem: Stem): return_value=Response(200, json=data.find_stem_result_valid_2) ) - stems = grouper_stem.get_child_stems(True) + stems = grouper_stem.get_child_stems(recursive=True) + assert len(stems) == 1 + assert type(stems[0]) is Stem + stems = grouper_stem.get_child_stems(recursive=False) assert len(stems) == 1 assert type(stems[0]) is Stem diff --git a/tests/test_subject.py b/tests/test_subject.py new file mode 100644 index 0000000..9737cb0 --- /dev/null +++ b/tests/test_subject.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from grouper_python import Subject + + +def test_subject_equality(grouper_subject: Subject): + compare = grouper_subject == "a thing" + assert compare is False From a5ab91a2fb3450bfe8025532dabdd4689b448aa1 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Tue, 25 Apr 2023 16:24:07 -0500 Subject: [PATCH 14/22] More test coverage --- grouper_python/group.py | 42 +++++++++++++--- grouper_python/membership.py | 22 ++++---- grouper_python/objects/exceptions.py | 19 ++++--- grouper_python/privilege.py | 6 ++- grouper_python/stem.py | 6 +-- grouper_python/subject.py | 8 +-- tests/data.py | 49 ++++++++++++++++-- tests/test_client.py | 75 ++++++++++++++++++++++++++-- tests/test_group.py | 30 +++++++++++ tests/test_stem.py | 27 ++++++++-- 10 files changed, 234 insertions(+), 50 deletions(-) diff --git a/grouper_python/group.py b/grouper_python/group.py index 3ec6905..3ef2bad 100644 --- a/grouper_python/group.py +++ b/grouper_python/group.py @@ -9,6 +9,7 @@ GrouperGroupNotFoundException, GrouperSuccessException, GrouperStemNotFoundException, + GrouperPermissionDenied, ) @@ -41,8 +42,9 @@ def find_group_by_name( if r_metadata["resultCode"] == "INVALID_QUERY" and r_metadata[ "resultMessage" ].startswith("Cant find stem"): - raise GrouperStemNotFoundException(str(stem)) + raise GrouperStemNotFoundException(str(stem), r) else: # pragma: no cover + # Some other issue, so pass the failure through raise if "groupResults" in r["WsFindGroupsResults"]: return [ @@ -101,14 +103,38 @@ def delete_groups( "wsGroupLookups": group_lookup, } } - r = client._call_grouper( - "/groups", - body, - act_as_subject=act_as_subject, - ) + try: + r = client._call_grouper( + "/groups", + body, + act_as_subject=act_as_subject, + ) + except GrouperSuccessException as err: + r = err.grouper_result + r_metadata = r["WsGroupDeleteResults"]["resultMetadata"] + if r_metadata["resultCode"] == "PROBLEM_DELETING_GROUPS": + raise GrouperPermissionDenied(r) + else: # pragma: no cover + # Some other issue, so pass the failure through + raise for result in r["WsGroupDeleteResults"]["results"]: - if result["resultMetadata"]["resultCode"] != "SUCCESS": + meta = result["resultMetadata"] + if meta["resultCode"] == "SUCCESS_GROUP_NOT_FOUND": + try: + result_message = meta["resultMessage"] + split_message = result_message.split(",") + group_name = split_message[1].split("=")[1] + except Exception: # pragma: no cover + # The try above feels fragile, so if it fails, + # throw a SuccessException + raise GrouperSuccessException(r) + raise GrouperGroupNotFoundException(group_name, r) + elif meta["resultCode"] != "SUCCESS": # pragma: no cover + # Whatever the error here, we don't understand it + # well enough to process it into something more specific raise GrouperSuccessException(r) + else: + pass def get_groups_by_parent( @@ -160,5 +186,5 @@ 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) + raise GrouperGroupNotFoundException(group_name, r) return Group.from_results(client, r["WsFindGroupsResults"]["groupResults"][0]) diff --git a/grouper_python/membership.py b/grouper_python/membership.py index 67ff5f2..b5f6fa9 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -55,8 +55,8 @@ def get_memberships_for_groups( group_name = split_message[2].split("=")[1] except Exception: raise - raise GrouperGroupNotFoundException(group_name) - else: # pragma: no cover + raise GrouperGroupNotFoundException(group_name, r) + else: raise if "wsGroups" not in r["WsGetMembershipsResults"].keys(): # if "wsGroups" is not in the result but it was succesful, @@ -121,7 +121,7 @@ def has_members( if not subject_identifiers and not subject_ids: raise ValueError( - "Exactly one of subject_identifiers or subject_ids must be specified" + "At least one of subject_identifiers or subject_ids must be specified" ) # subject_lookups = [] # if subject_identifiers: @@ -145,8 +145,8 @@ def has_members( except GrouperSuccessException as err: r = err.grouper_result if r["WsHasMemberResults"]["resultMetadata"]["resultCode"] == "GROUP_NOT_FOUND": - raise GrouperGroupNotFoundException(group_name) - else: # pragma: no cover + raise GrouperGroupNotFoundException(group_name, r) + else: raise results = r["WsHasMemberResults"]["results"] r_dict = {} @@ -207,15 +207,15 @@ def add_members_to_group( except GrouperSuccessException as err: r = err.grouper_result if r["WsAddMemberResults"]["resultMetadata"]["resultCode"] == "GROUP_NOT_FOUND": - raise GrouperGroupNotFoundException(group_name) + raise GrouperGroupNotFoundException(group_name, r) elif ( r["WsAddMemberResults"]["resultMetadata"]["resultCode"] == "PROBLEM_WITH_ASSIGNMENT" and r["WsAddMemberResults"]["results"][0]["resultMetadata"]["resultCode"] == "INSUFFICIENT_PRIVILEGES" ): - raise GrouperPermissionDenied() - else: # pragma: no cover + raise GrouperPermissionDenied(r) + else: raise return Group.from_results(client, r["WsAddMemberResults"]["wsGroupAssigned"]) @@ -254,15 +254,15 @@ def delete_members_from_group( r["WsDeleteMemberResults"]["resultMetadata"]["resultCode"] == "GROUP_NOT_FOUND" ): - raise GrouperGroupNotFoundException(group_name) + raise GrouperGroupNotFoundException(group_name, r) elif ( r["WsDeleteMemberResults"]["resultMetadata"]["resultCode"] == "PROBLEM_DELETING_MEMBERS" and r["WsDeleteMemberResults"]["results"][0]["resultMetadata"]["resultCode"] == "INSUFFICIENT_PRIVILEGES" ): - raise GrouperPermissionDenied() - else: # pragma: no cover + raise GrouperPermissionDenied(r) + else: raise return Group.from_results(client, r["WsDeleteMemberResults"]["wsGroup"]) diff --git a/grouper_python/objects/exceptions.py b/grouper_python/objects/exceptions.py index 97c5d10..25c25ee 100644 --- a/grouper_python/objects/exceptions.py +++ b/grouper_python/objects/exceptions.py @@ -25,40 +25,43 @@ class GrouperAuthException(GrouperException): class GrouperPermissionDenied(GrouperException): """Permission denied in grouper.""" - pass + def __init__(self, grouper_result: dict[str, Any]) -> None: + self.grouper_result = grouper_result + super().__init__("Permission denied") class GrouperEntityNotFoundException(GrouperException): """The Grouper Entity was not found.""" - def __init__(self, entity_identifier: str) -> None: + def __init__(self, entity_identifier: str, grouper_result: dict[str, Any]) -> None: """Initialize Exception with entity name.""" 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""" - def __init__(self, subject_identifier: str) -> None: + def __init__(self, subject_identifier: str, grouper_result: dict[str, Any]) -> None: """Initialize Exception with subject identifier.""" self.subject_identifier = subject_identifier - super().__init__(subject_identifier) + super().__init__(subject_identifier, grouper_result) class GrouperGroupNotFoundException(GrouperEntityNotFoundException): """The Grouper Group was not found.""" - def __init__(self, group_name: str) -> None: + def __init__(self, group_name: str, grouper_result: dict[str, Any]) -> None: """Initialize Exception with group name.""" self.group_name = group_name - super().__init__(group_name) + super().__init__(group_name, grouper_result) class GrouperStemNotFoundException(GrouperEntityNotFoundException): """The Grouper Stem was not found.""" - def __init__(self, stem_name: str) -> None: + def __init__(self, stem_name: str, grouper_result: dict[str, Any]) -> None: """Initialize Exception with stem name.""" self.stem_name = stem_name - super().__init__(stem_name) + super().__init__(stem_name, grouper_result) diff --git a/grouper_python/privilege.py b/grouper_python/privilege.py index 7ba0bdc..ee39309 100644 --- a/grouper_python/privilege.py +++ b/grouper_python/privilege.py @@ -28,8 +28,10 @@ def assign_privilege( elif target_type == "group": body["WsRestAssignGrouperPrivilegesLiteRequest"]["groupName"] = target body["WsRestAssignGrouperPrivilegesLiteRequest"]["privilegeType"] = "access" - else: # pragma: no cover - pass + else: + raise ValueError( + f"Target type must be either 'stem' or 'group', but got {target_type}." + ) client._call_grouper( "/grouperPrivileges", body, diff --git a/grouper_python/stem.py b/grouper_python/stem.py index 2e58fa0..a0e124f 100644 --- a/grouper_python/stem.py +++ b/grouper_python/stem.py @@ -70,11 +70,7 @@ def create_stems( } for stem in creates ] - body = { - "WsRestStemSaveRequest": { - "wsStemToSaves": stems_to_save, - } - } + 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"]) diff --git a/grouper_python/subject.py b/grouper_python/subject.py index 3397069..50a9ef1 100644 --- a/grouper_python/subject.py +++ b/grouper_python/subject.py @@ -21,11 +21,7 @@ def get_groups_for_subject( body: dict[str, Any] = { "WsRestGetMembershipsRequest": { "fieldName": "members", - "wsSubjectLookups": [ - { - "subjectId": subject_id, - } - ], + "wsSubjectLookups": [{"subjectId": subject_id}], "includeGroupDetail": "T", } } @@ -70,7 +66,7 @@ def get_subject_by_identifier( r = client._call_grouper("/subjects", body, act_as_subject=act_as_subject) subject = r["WsGetSubjectsResults"]["wsSubjects"][0] if subject["success"] == "F": - raise GrouperSubjectNotFoundException(subject_identifier) + raise GrouperSubjectNotFoundException(subject_identifier, r) if subject["sourceId"] == "g:gsa": if resolve_group: # from .group import get_group_by_name diff --git a/tests/data.py b/tests/data.py index e910ffa..9dbc4f2 100644 --- a/tests/data.py +++ b/tests/data.py @@ -64,6 +64,20 @@ } } +find_groups_result_stem_not_found = { + "WsFindGroupsResults": { + "resultMetadata": { + "success": "F", + "resultCode": "INVALID_QUERY", + "resultMessage": "Cant find stem: 'invalid',", + } + } +} + +find_groups_result_valid_no_groups = { + "WsFindGroupsResults": {"resultMetadata": {"success": "T"}} +} + grouper_stem_1 = { "displayExtension": "Child Stem", "extension": "child", @@ -169,6 +183,13 @@ } } +get_subject_result_subject_not_found = { + "WsGetSubjectsResults": { + "resultMetadata": {"success": "T"}, + "wsSubjects": [{"success": "F"}], + } +} + get_members_result_valid_one_group = { "WsGetMembersResults": { "resultMetadata": {"success": "T"}, @@ -207,9 +228,7 @@ } get_groups_for_subject_no_memberships = { - "WsGetMembershipsResults": { - "resultMetadata": {"success": "T"}, - } + "WsGetMembershipsResults": {"resultMetadata": {"success": "T"}} } create_priv_group_request = { @@ -305,6 +324,30 @@ } } +delete_groups_permission_denied = { + "WsGroupDeleteResults": { + "resultMetadata": {"success": "F", "resultCode": "PROBLEM_DELETING_GROUPS"}, + "results": [{"resultMetadata": {"resultCode": "INSUFFICIENT_PRIVILEGES"}}], + } +} + +delete_groups_group_not_found = { + "WsGroupDeleteResults": { + "resultMetadata": {"success": "T"}, + "results": [ + { + "resultMetadata": { + "resultCode": "SUCCESS_GROUP_NOT_FOUND", + "resultMessage": ( + "Cant find group: 'WsGroupLookup[pitGroups=[]," + "groupName=test:GROUP1," + ), + } + } + ], + } +} + create_priv_stem_request = { "WsRestAssignGrouperPrivilegesLiteRequest": { diff --git a/tests/test_client.py b/tests/test_client.py index 05b083a..00e008c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,8 +5,14 @@ from grouper_python import Client from grouper_python import Group, Stem, Subject, Person from . import data +import pytest import respx from httpx import Response +from grouper_python.objects.exceptions import ( + GrouperSubjectNotFoundException, + GrouperStemNotFoundException, + GrouperGroupNotFoundException, +) def test_import_and_init(): @@ -41,8 +47,18 @@ def test_get_group(grouper_client: Client): group = grouper_client.get_group("test:GROUP1") assert type(group) is Group - assert group.name == group.universal_identifier == "test:GROUP1" - assert group.id == group.uuid == "1ab0482715c74f51bc32822a70bf8f77" + + +@respx.mock +def test_get_group_not_found(grouper_client: Client): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_no_groups) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_client.get_group("test:NOT") + + assert excinfo.value.group_name == "test:NOT" @respx.mock @@ -50,13 +66,40 @@ def test_get_groups(grouper_client: Client): respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_two_groups) ) + groups = grouper_client.get_groups("GROUP") + assert len(groups) == 2 + assert type(groups[0]) is Group + assert type(groups[1]) is Group + groups = grouper_client.get_groups("GROUP", stem="test") assert len(groups) == 2 assert type(groups[0]) is Group assert type(groups[1]) is Group +@respx.mock +def test_get_groups_invalid_stem(grouper_client: Client): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_stem_not_found) + ) + + with pytest.raises(GrouperStemNotFoundException) as excinfo: + grouper_client.get_groups("GROUP", stem="invalid") + + assert excinfo.value.stem_name == "invalid" + + +@respx.mock +def test_get_groups_no_groups(grouper_client: Client): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_no_groups) + ) + + groups = grouper_client.get_groups("NOT") + assert len(groups) == 0 + + @respx.mock def test_get_stem(grouper_client: Client): respx.post(url=data.URI_BASE + "/stems").mock( @@ -82,11 +125,37 @@ def test_get_subject(grouper_client: Client): assert subject.description == subject.universal_identifier == "user3333" +@respx.mock +def test_get_subject_is_group(grouper_client: Client): + respx.post(url=data.URI_BASE + "/subjects").mock( + return_value=Response(200, json=data.get_subject_result_valid_group) + ) + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_1) + ) + + subject = grouper_client.get_subject("test:GROUP1") + + assert type(subject) is Group + + @respx.mock def test_get_subject_is_group_not_resolve(grouper_client: Client): respx.post(url=data.URI_BASE + "/subjects").mock( return_value=Response(200, json=data.get_subject_result_valid_group) ) - subject = grouper_client.get_subject("test:GROUP2", False) + subject = grouper_client.get_subject("test:GROUP2", resolve_group=False) assert type(subject) is Subject + + +@respx.mock +def test_get_subject_not_found(grouper_client: Client): + respx.post(url=data.URI_BASE + "/subjects").mock( + return_value=Response(200, json=data.get_subject_result_subject_not_found) + ) + + with pytest.raises(GrouperSubjectNotFoundException) as excinfo: + grouper_client.get_subject("notauser") + + assert excinfo.value.subject_identifier == "notauser" diff --git a/tests/test_group.py b/tests/test_group.py index 6c71205..1d5a148 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -4,7 +4,12 @@ if TYPE_CHECKING: from grouper_python import Group from grouper_python.objects.membership import HasMember +from grouper_python.objects.exceptions import ( + GrouperPermissionDenied, + GrouperGroupNotFoundException, +) from . import data +import pytest import respx from httpx import Response @@ -97,3 +102,28 @@ def test_delete(grouper_group: Group): ) grouper_group.delete() + + +@respx.mock +def test_delete_permission_denied(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.delete_groups_permission_denied) + ) + + with pytest.raises(GrouperPermissionDenied): + grouper_group.delete() + + +@respx.mock +def test_delete_group_not_found(grouper_group: Group): + # This is unlikely to happen, unless the Group was + # manually construted. However this does test the code + # in the underlying method for when a group isn't found + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.delete_groups_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_group.delete() + + assert excinfo.value.group_name == "test:GROUP1" diff --git a/tests/test_stem.py b/tests/test_stem.py index 81d4daf..6ec9cbb 100644 --- a/tests/test_stem.py +++ b/tests/test_stem.py @@ -1,7 +1,4 @@ from __future__ import annotations -# from typing import TYPE_CHECKING - -# if TYPE_CHECKING: from grouper_python import Stem, Group from . import data import respx @@ -49,6 +46,19 @@ def test_create_child_group(grouper_stem: Stem): return_value=Response(200, json=data.group_save_result_success_one_group) ) + new_group = grouper_stem.create_child_group( + "GROUP3", "Test3 Display Name", "Group 3 Test description", {"typeNames": []} + ) + + assert type(new_group) is Group + + +@respx.mock +def test_create_child_group_with_detail(grouper_stem: Stem): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.group_save_result_success_one_group) + ) + new_group = grouper_stem.create_child_group( "GROUP3", "Test3 Display Name", "Group 3 Test description" ) @@ -77,11 +87,20 @@ def test_get_child_groups(grouper_stem: Stem): return_value=Response(200, json=data.find_groups_result_valid_one_group_3) ) - groups = grouper_stem.get_child_groups(True) + groups = grouper_stem.get_child_groups(recursive=True) + assert len(groups) == 1 + assert type(groups[0]) is Group + groups = grouper_stem.get_child_groups(recursive=False) assert len(groups) == 1 assert type(groups[0]) is Group + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_no_groups) + ) + groups = grouper_stem.get_child_groups(recursive=True) + assert len(groups) == 0 + @respx.mock def test_delete(grouper_stem: Stem): From 4fe181a3cf6b720e477aefe8cab177fb87a52913 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Wed, 26 Apr 2023 10:45:51 -0500 Subject: [PATCH 15/22] full code coverage --- grouper_python/membership.py | 99 +++++++++++---- grouper_python/objects/group.py | 2 +- grouper_python/privilege.py | 2 +- grouper_python/util.py | 2 +- tests/data.py | 100 +++++++++++++++- tests/test_group.py | 165 +++++++++++++++++++++++-- tests/test_util.py | 205 ++++++++++++++++++++++++++++++++ 7 files changed, 540 insertions(+), 35 deletions(-) create mode 100644 tests/test_util.py diff --git a/grouper_python/membership.py b/grouper_python/membership.py index b5f6fa9..8f57acb 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -53,11 +53,14 @@ def get_memberships_for_groups( result_message = meta["resultMessage"] split_message = result_message.split(",") group_name = split_message[2].split("=")[1] - except Exception: - raise + except Exception: # pragma: no cover + # The try above feels fragile, so if it fails, + # throw the original SuccessException + raise err raise GrouperGroupNotFoundException(group_name, r) - else: - raise + else: # pragma: no cover + # We don't know what exactly has happened here + raise err if "wsGroups" not in r["WsGetMembershipsResults"].keys(): # if "wsGroups" is not in the result but it was succesful, # that means the group(s) exist but have no memberships @@ -98,7 +101,9 @@ def get_memberships_for_groups( membership_type = MembershipType.DIRECT elif ws_membership["membershipType"] == "effective": membership_type = MembershipType.INDIRECT - else: + else: # pragma: no cover + # Unknown membershipType, we don't know what's going on, + # so raise a SuccessException raise GrouperSuccessException(r) membership = Membership( @@ -146,8 +151,10 @@ def has_members( r = err.grouper_result if r["WsHasMemberResults"]["resultMetadata"]["resultCode"] == "GROUP_NOT_FOUND": raise GrouperGroupNotFoundException(group_name, r) - else: - raise + else: # pragma: no cover + # We're not sure what exactly has happened here, + # So raise the original SuccessException + raise err results = r["WsHasMemberResults"]["results"] r_dict = {} for result in results: @@ -156,14 +163,18 @@ def has_members( if result["resultMetadata"]["resultCode2"] == "SUBJECT_NOT_FOUND": is_member = HasMember.SUBJECT_NOT_FOUND ident = result["wsSubject"]["id"] - else: + else: # pragma: no cover + # We're not sure what exactly has happened here, + # So raise a SuccessException raise GrouperSuccessException(r) else: if "identifierLookup" in result["wsSubject"]: ident_key = "identifierLookup" elif "id" in result["wsSubject"]: ident_key = "id" - else: + else: # pragma: no cover + # We're not sure what exactly has happened here, + # So raise a SuccessException raise GrouperSuccessException(r) if result["resultMetadata"]["resultCode"] == "IS_NOT_MEMBER": is_member = HasMember.IS_NOT_MEMBER @@ -171,7 +182,9 @@ def has_members( elif result["resultMetadata"]["resultCode"] == "IS_MEMBER": is_member = HasMember.IS_MEMBER ident = result["wsSubject"][ident_key] - else: + else: # pragma: no cover + # We're not sure what exactly has happened here, + # So raise a SuccessException raise GrouperSuccessException(r) r_dict[ident] = is_member return r_dict @@ -215,8 +228,10 @@ def add_members_to_group( == "INSUFFICIENT_PRIVILEGES" ): raise GrouperPermissionDenied(r) - else: - raise + else: # pragma: no cover + # We're not sure what exactly has happened here, + # So raise the original SuccessException + raise err return Group.from_results(client, r["WsAddMemberResults"]["wsGroupAssigned"]) @@ -262,7 +277,9 @@ def delete_members_from_group( == "INSUFFICIENT_PRIVILEGES" ): raise GrouperPermissionDenied(r) - else: + 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"]) @@ -288,11 +305,51 @@ def get_members_for_groups( "includeSubjectDetail": "T", } } - r = client._call_grouper( - "/groups", - body, - act_as_subject=act_as_subject, - ) + try: + r = client._call_grouper( + "/groups", + body, + act_as_subject=act_as_subject, + ) + except GrouperSuccessException as err: + r = err.grouper_result + for result in r["WsGetMembersResults"]["results"]: + meta = result["resultMetadata"] + if meta["success"] == "F": + if meta["resultCode"] == "GROUP_NOT_FOUND": + try: + result_message = meta["resultMessage"] + split_message = result_message.split(",") + group_name = split_message[2].split("=")[1] + except Exception: # pragma: no cover + # The try above feels fragile, so if it fails, + # throw the original SuccessException + raise err + raise GrouperGroupNotFoundException(group_name, r) + else: # pragma: no cover + # We're not sure what exactly has happened here, + # So raise the original SuccessException + raise err + else: + pass + # If we've gotten here, we don't know what's going on, + # So raise the original SuccessException + raise err # pragma: no cover + + # meta = r["WsGetMembersResults"]["resultMetadata"] + # if meta["resultCode"] == "GROUP_NOT_FOUND": + # try: + # result_message = meta["resultMessage"] + # split_message = result_message.split(",") + # group_name = split_message[2].split("=")[1] + # except Exception: # pragma: no cover + # # The try above feels fragile, so if it fails, + # # throw the original SuccessException + # raise err + # raise GrouperGroupNotFoundException(group_name, r) + # else: # pragma: no cover + # # We don't know what exactly has happened here + # raise err r_dict: dict[Group, list[Subject]] = {} r_attributes = r["WsGetMembersResults"]["subjectAttributeNames"] for result in r["WsGetMembersResults"]["results"]: @@ -322,6 +379,8 @@ def get_members_for_groups( else: pass r_dict[key] = members - else: - pass + else: # pragma: no cover + # we don't know what's going on, + # so raise a SuccessException + raise GrouperSuccessException(r) return r_dict diff --git a/grouper_python/objects/group.py b/grouper_python/objects/group.py index 4311cbb..7a06f6f 100644 --- a/grouper_python/objects/group.py +++ b/grouper_python/objects/group.py @@ -83,7 +83,7 @@ def get_memberships( resolve_groups=resolve_groups, act_as_subject=act_as_subject, ) - return memberships[self] + return memberships[self] if memberships else [] def create_privilege( self, diff --git a/grouper_python/privilege.py b/grouper_python/privilege.py index ee39309..840319d 100644 --- a/grouper_python/privilege.py +++ b/grouper_python/privilege.py @@ -30,7 +30,7 @@ def assign_privilege( body["WsRestAssignGrouperPrivilegesLiteRequest"]["privilegeType"] = "access" else: raise ValueError( - f"Target type must be either 'stem' or 'group', but got {target_type}." + f"Target type must be either 'stem' or 'group', but got '{target_type}'." ) client._call_grouper( "/grouperPrivileges", diff --git a/grouper_python/util.py b/grouper_python/util.py index f1fb8de..b713bc3 100644 --- a/grouper_python/util.py +++ b/grouper_python/util.py @@ -40,7 +40,7 @@ def call_grouper( result = client.request(method=method, url=path, json=body) print(result.status_code) if result.status_code == 401: - raise GrouperAuthException(result.content) + raise GrouperAuthException(result.text) data: dict[str, Any] = result.json() result_type = list(data.keys())[0] if data[result_type]["resultMetadata"]["success"] != "T": diff --git a/tests/data.py b/tests/data.py index 9dbc4f2..ed3f1e6 100644 --- a/tests/data.py +++ b/tests/data.py @@ -204,6 +204,55 @@ } } +get_members_result_empty = { + "WsGetMembersResults": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": ["name", "description"], + "results": [ + {"resultMetadata": {"success": "T"}, "wsGroup": grouper_group_result1} + ], + } +} + +get_members_result_group_not_found = { + "WsGetMembersResults": { + "resultMetadata": {"success": "F"}, + "results": [ + { + "resultMetadata": { + "success": "F", + "resultCode": "GROUP_NOT_FOUND", + "resultMessage": ( + "Invalid group for 'wsGroupLookup', " + "WsGroupLookup[pitGroups=[]," + "groupName=test:NOT," + ), + } + } + ], + } +} + +get_members_result_multiple_groups_second_group_not_found = { + "WsGetMembersResults": { + "resultMetadata": {"success": "F"}, + "results": [ + {"resultMetadata": {"success": "T"}}, + { + "resultMetadata": { + "success": "F", + "resultCode": "GROUP_NOT_FOUND", + "resultMessage": ( + "Invalid group for 'wsGroupLookup', " + "WsGroupLookup[pitGroups=[]," + "groupName=test:NOT," + ), + } + }, + ], + } +} + get_membership_result_valid_one_group = { "WsGetMembershipsResults": { "resultMetadata": {"success": "T"}, @@ -220,6 +269,24 @@ } } +get_membership_result_group_not_found = { + "WsGetMembershipsResults": { + "resultMetadata": { + "success": "F", + "resultCode": "GROUP_NOT_FOUND", + "resultMessage": ( + "Invalid group for 'group', " + "WsGroupLookup[pitGroups=[]," + "groupName=test:NOT," + ), + } + } +} + +get_membership_result_valid_no_memberships = { + "WsGetMembershipsResults": {"resultMetadata": {"success": "T"}} +} + get_groups_for_subject_result_valid = { "WsGetMembershipsResults": { "resultMetadata": {"success": "T"}, @@ -262,6 +329,19 @@ } } +add_member_result_group_not_found = { + "WsAddMemberResults": { + "resultMetadata": {"success": "F", "resultCode": "GROUP_NOT_FOUND"} + } +} + +add_member_result_permission_denied = { + "WsAddMemberResults": { + "resultMetadata": {"success": "F", "resultCode": "PROBLEM_WITH_ASSIGNMENT"}, + "results": [{"resultMetadata": {"resultCode": "INSUFFICIENT_PRIVILEGES"}}], + } +} + remove_member_result_valid = { "WsDeleteMemberResults": { "resultMetadata": {"success": "T"}, @@ -281,6 +361,19 @@ } } +remove_member_result_group_not_found = { + "WsDeleteMemberResults": { + "resultMetadata": {"success": "F", "resultCode": "GROUP_NOT_FOUND"} + } +} + +remove_member_result_permission_denied = { + "WsDeleteMemberResults": { + "resultMetadata": {"success": "F", "resultCode": "PROBLEM_DELETING_MEMBERS"}, + "results": [{"resultMetadata": {"resultCode": "INSUFFICIENT_PRIVILEGES"}}], + } +} + has_member_result_id = { "WsHasMemberResults": { "resultMetadata": {"success": "T"}, @@ -317,6 +410,12 @@ } } +has_member_result_group_not_found = { + "WsHasMemberResults": { + "resultMetadata": {"success": "F", "resultCode": "GROUP_NOT_FOUND"} + } +} + delete_groups_result_success = { "WsGroupDeleteResults": { "resultMetadata": {"success": "T"}, @@ -348,7 +447,6 @@ } } - create_priv_stem_request = { "WsRestAssignGrouperPrivilegesLiteRequest": { "allowed": "T", diff --git a/tests/test_group.py b/tests/test_group.py index 1d5a148..2cf98cc 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from grouper_python import Group from grouper_python.objects.membership import HasMember from grouper_python.objects.exceptions import ( GrouperPermissionDenied, GrouperGroupNotFoundException, ) +from grouper_python import Person, Group, Subject from . import data import pytest import respx @@ -16,31 +13,116 @@ @respx.mock def test_get_members(grouper_group: Group): - respx.post(url=data.URI_BASE + "/memberships").mock( - return_value=Response(200, json=data.get_membership_result_valid_one_group) - ) - respx.post(url=data.URI_BASE + "/groups").mock( + group_call = respx.post(url=data.URI_BASE + "/groups").mock( side_effect=[ Response(200, json=data.get_members_result_valid_one_group), Response(200, json=data.find_groups_result_valid_one_group_2), ] ) members = grouper_group.get_members() + assert len(members) == 2 + assert group_call.call_count == 2 + group_call = respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.get_members_result_valid_one_group) + ) + members = grouper_group.get_members(resolve_groups=False) assert len(members) == 2 + # Call count is cumulative for the test, so it was called twice before, + # and should be called once more, for a total of 3 + assert group_call.call_count == 3 + + +@respx.mock +def test_get_members_valid_no_members(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.get_members_result_empty) + ) + + members = grouper_group.get_members(resolve_groups=False) + assert len(members) == 0 + + +@respx.mock +def test_get_members_group_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.get_members_result_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_group.get_members() + + assert excinfo.value.group_name == "test:NOT" + + # members = grouper_group.get_members(resolve_groups=False) + # assert len(members) == 0 @respx.mock def test_get_memberships(grouper_group: Group): - respx.post(url=data.URI_BASE + "/memberships").mock( + membership_call = respx.post(url=data.URI_BASE + "/memberships").mock( return_value=Response(200, json=data.get_membership_result_valid_one_group) ) - respx.post(url=data.URI_BASE + "/groups").mock( + group_call = respx.post(url=data.URI_BASE + "/groups").mock( return_value=Response(200, json=data.find_groups_result_valid_one_group_2) ) memberships = grouper_group.get_memberships() - assert len(memberships) == 5 + membership_types = {"Person": 0, "Group": 0} + for m in memberships: + print(type(m.member)) + if type(m.member) is Person: + membership_types["Person"] = membership_types["Person"] + 1 + elif type(m.member) is Group: + membership_types["Group"] = membership_types["Group"] + 1 + # When resolving groups (the default) this result should have 4 Persons and 1 Group + assert membership_types["Person"] == 4 + assert membership_types["Group"] == 1 + + memberships = grouper_group.get_memberships(resolve_groups=False) + assert len(memberships) == 5 + membership_types = {"Person": 0, "Subject": 0} + for m in memberships: + print(type(m.member)) + if type(m.member) is Person: + membership_types["Person"] = membership_types["Person"] + 1 + elif type(m.member) is Subject: + membership_types["Subject"] = membership_types["Subject"] + 1 + # When not resolving groups, this result should have 4 Persons and 1 Subject + assert membership_types["Person"] == 4 + assert membership_types["Subject"] == 1 + + # The Membership endpoint should be called twice, once each for each + # get_memberships() call + assert membership_call.call_count == 2 + # The group call endpoint should only be called once, for the first + # call to get_memberships() where groups are resolved + # In the second call, groups are not resolved, so the group is returned + # as a subject instead, and the groups endpoint isn't called. + assert group_call.call_count == 1 + + +@respx.mock +def test_get_memberships_group_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/memberships").mock( + return_value=Response(200, json=data.get_membership_result_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_group.get_memberships() + + assert excinfo.value.group_name == "test:NOT" + + +@respx.mock +def test_get_memberships_valid_no_memberships(grouper_group: Group): + respx.post(url=data.URI_BASE + "/memberships").mock( + return_value=Response(200, json=data.get_membership_result_valid_no_memberships) + ) + + memberships = grouper_group.get_memberships() + + assert len(memberships) == 0 @respx.mock @@ -74,6 +156,28 @@ def test_add_members(grouper_group: Group): grouper_group.add_members(["abcdefgh1"]) +@respx.mock +def test_add_members_group_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.add_member_result_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_group.add_members(["user3333"]) + + assert excinfo.value.group_name == "test:GROUP1" + + +@respx.mock +def test_add_members_permission_denied(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.add_member_result_permission_denied) + ) + + with pytest.raises(GrouperPermissionDenied): + grouper_group.add_members(["user3333"]) + + @respx.mock def test_delete_members(grouper_group: Group): respx.post(url=data.URI_BASE + "/groups").mock( @@ -83,6 +187,28 @@ def test_delete_members(grouper_group: Group): grouper_group.delete_members(["abcdefgh1"]) +@respx.mock +def test_delete_members_group_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.remove_member_result_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_group.delete_members(["user3333"]) + + assert excinfo.value.group_name == "test:GROUP1" + + +@respx.mock +def test_delete_members_permission_denied(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.remove_member_result_permission_denied) + ) + + with pytest.raises(GrouperPermissionDenied): + grouper_group.delete_members(["user3333"]) + + @respx.mock def test_has_members(grouper_group: Group): respx.post(url=data.URI_BASE + "/groups/test:GROUP1/members").mock( @@ -95,6 +221,23 @@ def test_has_members(grouper_group: Group): assert has_members["user3333"].value == 1 +@respx.mock +def test_has_members_group_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/groups/test:GROUP1/members").mock( + return_value=Response(200, json=data.has_member_result_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_group.has_members(["user3333"]) + + assert excinfo.value.group_name == "test:GROUP1" + + # has_members = grouper_group.has_members(["user3333"]) + + # assert has_members["user3333"] == HasMember.IS_MEMBER + # assert has_members["user3333"].value == 1 + + @respx.mock def test_delete(grouper_group: Group): respx.post(url=data.URI_BASE + "/groups").mock( diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..62c7778 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,205 @@ +"""In addition to testing call_grouper in util, this will test some code paths in +other functions that are only reachable when called directly, rather than through +an existing object.""" +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from grouper_python import Client, Person +import respx +from httpx import Response +from . import data +import pytest +from grouper_python.util import call_grouper +from grouper_python.privilege import assign_privilege +from grouper_python.membership import has_members, get_members_for_groups +from grouper_python.objects.exceptions import ( + GrouperAuthException, + GrouperGroupNotFoundException, +) + + +def test_both_act_as_id_and_identifier(grouper_client: Client): + with pytest.raises(ValueError) as excinfo: + call_grouper( + grouper_client.httpx_client, + "/path", + {}, + act_as_subject_id="abcd1234", + act_as_subject_identifier="user1234", + ) + assert ( + excinfo.value.args[0] + == "Only one of act_as_subject_id or act_as_subject_identifier should be" + " specified" + ) + + +@respx.mock +def test_act_as_subject_lite(grouper_client: Client, grouper_person: Person): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_1) + ) + grouper_client.get_group("test:GROUP1", act_as_subject=grouper_person) + + +@respx.mock +def test_act_as_subject_notlite(grouper_client: Client, grouper_person: Person): + respx.post(url=data.URI_BASE + "/subjects").mock( + return_value=Response(200, json=data.get_subject_result_valid_person) + ) + grouper_client.get_subject("user3333", act_as_subject=grouper_person) + + +@respx.mock +def test_act_as_id_lite(grouper_client: Client): + orig_body = { + "WsRestFindGroupsLiteRequest": { + "groupName": "test:GROUP1", + "queryFilterType": "FIND_BY_GROUP_NAME_EXACT", + "includeGroupDetail": "T", + } + } + body_with_auth = { + "WsRestFindGroupsLiteRequest": { + "groupName": "test:GROUP1", + "queryFilterType": "FIND_BY_GROUP_NAME_EXACT", + "includeGroupDetail": "T", + "actAsSubjectId": "abcd1234", + } + } + respx.route(method="POST", url=data.URI_BASE + "/groups", json=body_with_auth).mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_1) + ) + call_grouper( + grouper_client.httpx_client, + path="/groups", + body=orig_body, + act_as_subject_id="abcd1234", + ) + + +@respx.mock +def test_act_as_identifier_lite(grouper_client: Client): + orig_body = { + "WsRestFindGroupsLiteRequest": { + "groupName": "test:GROUP1", + "queryFilterType": "FIND_BY_GROUP_NAME_EXACT", + "includeGroupDetail": "T", + } + } + body_with_auth = { + "WsRestFindGroupsLiteRequest": { + "groupName": "test:GROUP1", + "queryFilterType": "FIND_BY_GROUP_NAME_EXACT", + "includeGroupDetail": "T", + "actAsSubjectIdentifier": "user1234", + } + } + respx.route(method="POST", url=data.URI_BASE + "/groups", json=body_with_auth).mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_1) + ) + call_grouper( + grouper_client.httpx_client, + path="/groups", + body=orig_body, + act_as_subject_identifier="user1234", + ) + + +@respx.mock +def test_act_as_id_notlite(grouper_client: Client): + orig_body = { + "WsRestGetSubjectsRequest": { + "wsSubjectLookups": [{"subjectIdentifier": "user1234"}], + "includeSubjectDetail": "T", + } + } + body_with_auth = { + "WsRestGetSubjectsRequest": { + "wsSubjectLookups": [{"subjectIdentifier": "user1234"}], + "includeSubjectDetail": "T", + "actAsSubjectLookup": {"subjectId": "abcd1234"}, + } + } + respx.route( + method="POST", url=data.URI_BASE + "/subjects", json=body_with_auth + ).mock(return_value=Response(200, json=data.get_subject_result_valid_person)) + call_grouper( + grouper_client.httpx_client, + path="subjects", + body=orig_body, + act_as_subject_id="abcd1234", + ) + + +@respx.mock +def test_act_as_identifier_notlite(grouper_client: Client): + orig_body = { + "WsRestGetSubjectsRequest": { + "wsSubjectLookups": [{"subjectIdentifier": "user1234"}], + "includeSubjectDetail": "T", + } + } + body_with_auth = { + "WsRestGetSubjectsRequest": { + "wsSubjectLookups": [{"subjectIdentifier": "user1234"}], + "includeSubjectDetail": "T", + "actAsSubjectLookup": {"subjectIdentifier": "user1234"}, + } + } + respx.route( + method="POST", url=data.URI_BASE + "/subjects", json=body_with_auth + ).mock(return_value=Response(200, json=data.get_subject_result_valid_person)) + call_grouper( + grouper_client.httpx_client, + path="subjects", + body=orig_body, + act_as_subject_identifier="user1234", + ) + + +@respx.mock +def test_grouper_auth_exception(grouper_client: Client): + """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") + ) + + with pytest.raises(GrouperAuthException): + call_grouper(grouper_client.httpx_client, body={}, path="/groups") + + +@respx.mock +def test_assign_privilege_unknown_target_type(grouper_client: Client): + with pytest.raises(ValueError) as excinfo: + assign_privilege( + "target:name", "type", "update", "user1234", "T", grouper_client + ) + assert ( + excinfo.value.args[0] + == "Target type must be either 'stem' or 'group', but got 'type'." + ) + + +@respx.mock +def test_has_members_no_subject(grouper_client: Client): + with pytest.raises(ValueError) as excinfo: + has_members("target:name", grouper_client) + assert ( + excinfo.value.args[0] + == "At least one of subject_identifiers or subject_ids must be specified" + ) + + +@respx.mock +def test_get_members_second_group_not_found(grouper_client: Client): + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response( + 200, json=data.get_members_result_multiple_groups_second_group_not_found + ) + ) + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + get_members_for_groups(["test:GROUP1", "test:NOT"], grouper_client) + + assert excinfo.value.group_name == "test:NOT" From 0c5965a05ccc8ad0074e48c15a89f56eb570001d Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Wed, 26 Apr 2023 11:40:12 -0500 Subject: [PATCH 16/22] remove cruft --- grouper_python/membership.py | 18 ------------------ grouper_python/objects/py.typed | 0 2 files changed, 18 deletions(-) delete mode 100644 grouper_python/objects/py.typed diff --git a/grouper_python/membership.py b/grouper_python/membership.py index 8f57acb..7b63d8a 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -128,12 +128,9 @@ def has_members( raise ValueError( "At least one of subject_identifiers or subject_ids must be specified" ) - # subject_lookups = [] - # if subject_identifiers: subject_identifier_lookups = [ {"subjectIdentifier": ident} for ident in subject_identifiers ] - # if subject_ids: subject_id_lookups = [{"subjectId": ident} for ident in subject_ids] body = { "WsRestHasMemberRequest": { @@ -335,21 +332,6 @@ def get_members_for_groups( # If we've gotten here, we don't know what's going on, # So raise the original SuccessException raise err # pragma: no cover - - # meta = r["WsGetMembersResults"]["resultMetadata"] - # if meta["resultCode"] == "GROUP_NOT_FOUND": - # try: - # result_message = meta["resultMessage"] - # split_message = result_message.split(",") - # group_name = split_message[2].split("=")[1] - # except Exception: # pragma: no cover - # # The try above feels fragile, so if it fails, - # # throw the original SuccessException - # raise err - # raise GrouperGroupNotFoundException(group_name, r) - # else: # pragma: no cover - # # We don't know what exactly has happened here - # raise err r_dict: dict[Group, list[Subject]] = {} r_attributes = r["WsGetMembersResults"]["subjectAttributeNames"] for result in r["WsGetMembersResults"]["results"]: diff --git a/grouper_python/objects/py.typed b/grouper_python/objects/py.typed deleted file mode 100644 index e69de29..0000000 From c44a05b18cab275353a3f417ab1d8d68008f783d Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Thu, 27 Apr 2023 13:59:48 -0500 Subject: [PATCH 17/22] add get_privileges also do some type checking on test files --- azure-pipelines.yml | 2 +- grouper_python/group.py | 1 - grouper_python/membership.py | 1 - grouper_python/objects/group.py | 29 ++++-- grouper_python/objects/person.py | 2 - grouper_python/objects/privilege.py | 68 ++++++++++++++ grouper_python/objects/stem.py | 26 +++++- grouper_python/objects/subject.py | 31 ++++++- grouper_python/privilege.py | 79 ++++++++++++++++ grouper_python/util.py | 1 - pyproject.toml | 6 ++ tests/data.py | 131 +++++++++++++++++++++++++-- tests/test_group.py | 20 ++++- tests/test_privilege.py | 134 ++++++++++++++++++++++++++++ tests/test_stem.py | 17 +++- tests/test_subject.py | 16 ++++ 16 files changed, 536 insertions(+), 28 deletions(-) create mode 100644 grouper_python/objects/privilege.py create mode 100644 tests/test_privilege.py diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4553aa3..76d7f98 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -22,7 +22,7 @@ stages: displayName: Run Linter Tests continueOnError: true - script: | - mypy -p grouper_python --junit-xml junit/test-mypy-results.xml + mypy --junit-xml junit/test-mypy-results.xml displayName: Run MyPy Tests continueOnError: true - task: PublishTestResults@2 diff --git a/grouper_python/group.py b/grouper_python/group.py index 3ef2bad..3e95a94 100644 --- a/grouper_python/group.py +++ b/grouper_python/group.py @@ -160,7 +160,6 @@ def get_groups_by_parent( body, act_as_subject=act_as_subject, ) - print(r) if "groupResults" in r["WsFindGroupsResults"]: return [ Group.from_results(client, grp) diff --git a/grouper_python/membership.py b/grouper_python/membership.py index 7b63d8a..c181ed6 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -253,7 +253,6 @@ def delete_members_from_group( "includeGroupDetail": "T", } } - print(body) try: r = client._call_grouper( "/groups", diff --git a/grouper_python/objects/group.py b/grouper_python/objects/group.py index 7a06f6f..40b67c5 100644 --- a/grouper_python/objects/group.py +++ b/grouper_python/objects/group.py @@ -4,6 +4,7 @@ if TYPE_CHECKING: # pragma: no cover from .membership import Membership, HasMember from .client import Client + from .privilege import Privilege from .subject import Subject from pydantic import BaseModel from ..membership import ( @@ -13,7 +14,7 @@ delete_members_from_group, has_members, ) -from ..privilege import assign_privilege +from ..privilege import assign_privilege, get_privileges from ..group import delete_groups @@ -23,7 +24,6 @@ class Group(Subject): uuid: str enabled: str displayExtension: str - name: str typeOfGroup: str idIndex: str detail: dict[str, Any] | None @@ -39,12 +39,13 @@ def from_results( 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"], - name=group_body["name"], typeOfGroup=group_body["typeOfGroup"], idIndex=group_body["idIndex"], detail=group_body.get("detail"), @@ -85,7 +86,7 @@ def get_memberships( ) return memberships[self] if memberships else [] - def create_privilege( + def create_privilege_on_this( self, entity_identifier: str, privilege_name: str, @@ -101,7 +102,7 @@ def create_privilege( act_as_subject=act_as_subject, ) - def delete_privilege( + def delete_privilege_on_this( self, entity_identifier: str, privilege_name: str, @@ -117,6 +118,24 @@ def delete_privilege( act_as_subject=act_as_subject, ) + def get_privilege_on_this( + self, + subject_id: str | None = None, + subject_identifier: str | None = None, + privilege_name: str | None = None, + attributes: list[str] = [], + act_as_subject: Subject | None = None, + ) -> list[Privilege]: + return get_privileges( + client=self.client, + subject_id=subject_id, + subject_identifier=subject_identifier, + group_name=self.name, + privilege_name=privilege_name, + attributes=attributes, + act_as_subject=act_as_subject, + ) + def add_members( self, subject_identifiers: list[str] = [], diff --git a/grouper_python/objects/person.py b/grouper_python/objects/person.py index 8c8f9a1..eeb4e43 100644 --- a/grouper_python/objects/person.py +++ b/grouper_python/objects/person.py @@ -7,8 +7,6 @@ class Person(Subject): - sourceId: str - name: str attributes: dict[str, str] @classmethod diff --git a/grouper_python/objects/privilege.py b/grouper_python/objects/privilege.py new file mode 100644 index 0000000..4b570be --- /dev/null +++ b/grouper_python/objects/privilege.py @@ -0,0 +1,68 @@ +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 .subject import Subject +from .group import Group +from .stem import Stem + + +class Privilege(BaseModel): + 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 + + class Config: + smart_union = True + + @classmethod + def from_results( + cls: type[Privilege], + client: Client, + privilege_body: dict[str, Any], + subject_attr_names: list[str] = [], + ) -> Privilege: + stem = ( + Stem.from_results(client, privilege_body["wsStem"]) + if "wsStem" in privilege_body + else None + ) + group = ( + Group.from_results(client, privilege_body["wsGroup"]) + if "wsGroup" in privilege_body + else None + ) + target: Stem | Group + if stem: + target = stem + elif group: + target = 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"], + ) diff --git a/grouper_python/objects/stem.py b/grouper_python/objects/stem.py index 3456fc6..fb386c6 100644 --- a/grouper_python/objects/stem.py +++ b/grouper_python/objects/stem.py @@ -4,9 +4,10 @@ if TYPE_CHECKING: # pragma: no cover from .subject import Subject from .group import Group + from .privilege import Privilege from pydantic import BaseModel -from ..privilege import assign_privilege +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 @@ -25,6 +26,7 @@ class Stem(BaseModel): class Config: arbitrary_types_allowed = True + fields = {"client": {"exclude": True}} @classmethod def from_results( @@ -45,7 +47,7 @@ def from_results( client=client, ) - def create_privilege( + def create_privilege_on_this( self, entity_identifier: str, privilege_name: str, @@ -61,7 +63,7 @@ def create_privilege( act_as_subject=act_as_subject, ) - def delete_privilege( + def delete_privilege_on_this( self, entity_identifier: str, privilege_name: str, @@ -77,6 +79,24 @@ def delete_privilege( act_as_subject=act_as_subject, ) + def get_privilege_on_this( + self, + subject_id: str | None = None, + subject_identifier: str | None = None, + privilege_name: str | None = None, + attributes: list[str] = [], + act_as_subject: Subject | None = None, + ) -> list[Privilege]: + return get_privileges( + client=self.client, + subject_id=subject_id, + subject_identifier=subject_identifier, + stem_name=self.name, + privilege_name=privilege_name, + attributes=attributes, + act_as_subject=act_as_subject, + ) + def create_child_stem( self, extension: str, diff --git a/grouper_python/objects/subject.py b/grouper_python/objects/subject.py index 395dc96..d75d104 100644 --- a/grouper_python/objects/subject.py +++ b/grouper_python/objects/subject.py @@ -3,22 +3,25 @@ if TYPE_CHECKING: # pragma: no cover from .group import Group - + from .privilege import Privilege from .client import Client from pydantic import BaseModel - from ..subject import get_groups_for_subject from ..membership import has_members +from ..privilege import get_privileges 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( @@ -37,8 +40,10 @@ def from_results( universal_identifier_attr = client.universal_identifier_attr return cls( id=subject_body["id"], - description=subject_body.get("description", ""), + description=attrs.get("description", ""), universal_identifier=attrs.get(universal_identifier_attr), + sourceId=subject_body["sourceId"], + name=subject_body["name"], client=client, ) @@ -85,3 +90,23 @@ def is_member( return False else: raise ValueError + + def get_privileges_for_this( + self, + group_name: str | None = None, + stem_name: str | None = None, + privilege_name: str | None = None, + privilege_type: str | None = None, + attributes: list[str] = [], + act_as_subject: Subject | None = None, + ) -> list[Privilege]: + return get_privileges( + client=self.client, + subject_id=self.id, + group_name=group_name, + stem_name=stem_name, + privilege_name=privilege_name, + privilege_type=privilege_type, + attributes=attributes, + act_as_subject=act_as_subject, + ) diff --git a/grouper_python/privilege.py b/grouper_python/privilege.py index 840319d..ba57857 100644 --- a/grouper_python/privilege.py +++ b/grouper_python/privilege.py @@ -4,6 +4,13 @@ if TYPE_CHECKING: # pragma: no cover from .objects.client import Client from .objects.subject import Subject + from .objects.privilege import Privilege +from .objects.exceptions import ( + GrouperSuccessException, + GrouperSubjectNotFoundException, + GrouperGroupNotFoundException, + GrouperStemNotFoundException, +) def assign_privilege( @@ -37,3 +44,75 @@ def assign_privilege( body, act_as_subject=act_as_subject, ) + + +def get_privileges( + client: Client, + subject_id: str | None = None, + subject_identifier: str | None = None, + group_name: str | None = None, + stem_name: str | None = None, + privilege_name: str | None = None, + privilege_type: str | None = None, + attributes: list[str] = [], + act_as_subject: Subject | None = None, +) -> list[Privilege]: + from .objects.privilege import Privilege + + if subject_id and subject_identifier: + raise ValueError("Only specify one of subject_id or subject_identifier.") + if group_name and stem_name: + raise ValueError("Only specify one of group_name or stem_name.") + if not subject_id and not subject_identifier and not group_name and not stem_name: + raise ValueError( + "Must specify a valid target to retrieve privileges for." + " Specify either a subject, a stem, a group," + " a subject and stem, or a subject and group." + ) + request = { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": ",".join(attributes), + } + if subject_id: + request["subjectId"] = subject_id + elif subject_identifier: + request["subjectIdentifier"] = subject_identifier + if group_name: + request["groupName"] = group_name + elif stem_name: + request["stemName"] = stem_name + if privilege_name: + request["privilegeName"] = privilege_name + if privilege_type: + request["privilegeType"] = privilege_type + body = {"WsRestGetGrouperPrivilegesLiteRequest": request} + try: + r = client._call_grouper( + "/grouperPrivileges", + body, + act_as_subject=act_as_subject, + ) + result = r["WsGetGrouperPrivilegesLiteResult"] + return [ + Privilege.from_results(client, priv, result["subjectAttributeNames"]) + for priv in result["privilegeResults"] + ] + except GrouperSuccessException as err: + r = err.grouper_result + r_code = r["WsGetGrouperPrivilegesLiteResult"]["resultMetadata"]["resultCode"] + if r_code == "SUBJECT_NOT_FOUND": + raise GrouperSubjectNotFoundException( + subject_identifier=str(subject_identifier) + if subject_identifier + else str(subject_id), + grouper_result=r, + ) + elif r_code == "GROUP_NOT_FOUND": + raise GrouperGroupNotFoundException(str(group_name), r) + elif r_code == "STEM_NOT_FOUND": + raise GrouperStemNotFoundException(str(stem_name), r) + else: # pragma: no cover + # We don't know what went wrong, + # so raise the original SuccessException + raise err diff --git a/grouper_python/util.py b/grouper_python/util.py index b713bc3..51fe370 100644 --- a/grouper_python/util.py +++ b/grouper_python/util.py @@ -38,7 +38,6 @@ def call_grouper( } result = client.request(method=method, url=path, json=body) - print(result.status_code) if result.status_code == 401: raise GrouperAuthException(result.text) data: dict[str, Any] = result.json() diff --git a/pyproject.toml b/pyproject.toml index 469ae5a..d8c7a4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ accept_no_yields_doc = false # linters = "pycodestyle,pyflakes,pydocstyle,pylint" [tool.mypy] +files="tests,grouper_python" # --strict (as of 0.990) warn_unused_configs = true disallow_any_generics = true @@ -70,3 +71,8 @@ strict_equality = true strict_concatenate = true # --strict end plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = "tests.*" +allow_untyped_defs = true +allow_incomplete_defs = true diff --git a/tests/data.py b/tests/data.py index ed3f1e6..fe69cbd 100644 --- a/tests/data.py +++ b/tests/data.py @@ -1,5 +1,7 @@ URI_BASE = "https://grouper/grouper-ws/servicesRest/v2_4_000" +subject_attribute_names = ["description", "name"] + grouper_group_result1 = { "extension": "GROUP1", "displayName": "Test Stem:Test1 Display Name", @@ -170,7 +172,7 @@ get_subject_result_valid_person = { "WsGetSubjectsResults": { "resultMetadata": {"success": "T"}, - "subjectAttributeNames": ["description", "name"], + "subjectAttributeNames": subject_attribute_names, "wsSubjects": [ws_subject4 | {"success": "T"}], } } @@ -178,7 +180,7 @@ get_subject_result_valid_group = { "WsGetSubjectsResults": { "resultMetadata": {"success": "T"}, - "subjectAttributeNames": ["description", "name"], + "subjectAttributeNames": subject_attribute_names, "wsSubjects": [ws_subject1 | {"success": "T"}], } } @@ -193,7 +195,7 @@ get_members_result_valid_one_group = { "WsGetMembersResults": { "resultMetadata": {"success": "T"}, - "subjectAttributeNames": ["description", "name"], + "subjectAttributeNames": subject_attribute_names, "results": [ { "resultMetadata": {"success": "T"}, @@ -207,7 +209,7 @@ get_members_result_empty = { "WsGetMembersResults": { "resultMetadata": {"success": "T"}, - "subjectAttributeNames": ["name", "description"], + "subjectAttributeNames": subject_attribute_names, "results": [ {"resultMetadata": {"success": "T"}, "wsGroup": grouper_group_result1} ], @@ -263,7 +265,7 @@ ws_membership4, ws_membership5, ], - "subjectAttributeNames": ["description", "name"], + "subjectAttributeNames": subject_attribute_names, "wsGroups": [grouper_group_result1], "wsSubjects": [ws_subject1, ws_subject2, ws_subject3, ws_subject4], } @@ -486,3 +488,122 @@ delete_stem_result_success = { "WsStemDeleteResults": {"resultMetadata": {"success": "T"}} } + +priv_result_stem = { + "revokable": "T", + "ownerSubject": ws_subject2, + "allowed": "T", + "wsStem": grouper_stem_1, + "wsSubject": ws_subject4, + "privilegeType": "naming", + "privilegeName": "stemAdmin", +} + +priv_result_group = { + "revokable": "T", + "ownerSubject": ws_subject2, + "allowed": "T", + "wsGroup": grouper_group_result1, + "wsSubject": ws_subject4, + "privilegeType": "access", + "privilegeName": "admin", +} + +get_priv_for_stem_request = { + "WsRestGetGrouperPrivilegesLiteRequest": { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": "", + "stemName": "test:child", + } +} + +get_priv_for_stem_result = { + "WsGetGrouperPrivilegesLiteResult": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": subject_attribute_names, + "privilegeResults": [priv_result_stem], + } +} + +get_priv_for_group_request = { + "WsRestGetGrouperPrivilegesLiteRequest": { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": "", + "groupName": "test:GROUP1", + } +} + +get_priv_for_group_with_subject_identifier_request = { + "WsRestGetGrouperPrivilegesLiteRequest": { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": "", + "groupName": "test:GROUP1", + "subjectIdentifier": "user3333" + } +} + +get_priv_for_group_with_privilege_name_request = { + "WsRestGetGrouperPrivilegesLiteRequest": { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": "", + "groupName": "test:GROUP1", + "privilegeName": "admin", + } +} + +get_priv_for_group_with_privilege_type_request = { + "WsRestGetGrouperPrivilegesLiteRequest": { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": "", + "subjectId": "abcdefgh3", + "privilegeType": "access", + } +} + +get_priv_for_group_result = { + "WsGetGrouperPrivilegesLiteResult": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": subject_attribute_names, + "privilegeResults": [priv_result_group], + } +} + +get_priv_for_subject_request = { + "WsRestGetGrouperPrivilegesLiteRequest": { + "includeSubjectDetail": "T", + "includeGroupDetail": "T", + "subjectAttributeNames": "", + "subjectId": "abcdefgh3", + } +} + +get_priv_for_subject_result = { + "WsGetGrouperPrivilegesLiteResult": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": subject_attribute_names, + "privilegeResults": [priv_result_stem, priv_result_group], + } +} + +get_priv_result_subject_not_found = { + "WsGetGrouperPrivilegesLiteResult": { + "resultMetadata": {"success": "F", "resultCode": "SUBJECT_NOT_FOUND"} + } +} + +get_priv_result_group_not_found = { + "WsGetGrouperPrivilegesLiteResult": { + "resultMetadata": {"success": "F", "resultCode": "GROUP_NOT_FOUND"} + } +} + +get_priv_result_stem_not_found = { + "WsGetGrouperPrivilegesLiteResult": { + "resultMetadata": {"success": "F", "resultCode": "STEM_NOT_FOUND"} + } +} diff --git a/tests/test_group.py b/tests/test_group.py index 2cf98cc..88037b9 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,3 +1,4 @@ +# mypy: allow_untyped_defs from __future__ import annotations from grouper_python.objects.membership import HasMember from grouper_python.objects.exceptions import ( @@ -70,7 +71,6 @@ def test_get_memberships(grouper_group: Group): assert len(memberships) == 5 membership_types = {"Person": 0, "Group": 0} for m in memberships: - print(type(m.member)) if type(m.member) is Person: membership_types["Person"] = membership_types["Person"] + 1 elif type(m.member) is Group: @@ -83,7 +83,6 @@ def test_get_memberships(grouper_group: Group): assert len(memberships) == 5 membership_types = {"Person": 0, "Subject": 0} for m in memberships: - print(type(m.member)) if type(m.member) is Person: membership_types["Person"] = membership_types["Person"] + 1 elif type(m.member) is Subject: @@ -133,7 +132,7 @@ def test_create_privilege(grouper_group: Group): json=data.create_priv_group_request, ).mock(Response(200, json=data.assign_priv_result_valid)) - grouper_group.create_privilege("user3333", "update") + grouper_group.create_privilege_on_this("user3333", "update") @respx.mock @@ -144,7 +143,20 @@ def test_delete_privilege(grouper_group: Group): json=data.delete_priv_group_request, ).mock(return_value=Response(200, json=data.assign_priv_result_valid)) - grouper_group.delete_privilege("user3333", "update") + grouper_group.delete_privilege_on_this("user3333", "update") + + +@respx.mock +def test_get_privilege(grouper_group: Group): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.get_priv_for_group_request, + ).mock(return_value=Response(200, json=data.get_priv_for_group_result)) + + privs = grouper_group.get_privilege_on_this() + + assert len(privs) == 1 @respx.mock diff --git a/tests/test_privilege.py b/tests/test_privilege.py new file mode 100644 index 0000000..26ec6c7 --- /dev/null +++ b/tests/test_privilege.py @@ -0,0 +1,134 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from grouper_python import Subject, Group, Client +import respx +from httpx import Response +from . import data +import pytest +from grouper_python.privilege import get_privileges +from grouper_python.objects.exceptions import ( + GrouperSubjectNotFoundException, + GrouperGroupNotFoundException, + GrouperStemNotFoundException, +) + + +def test_get_privilege_both_group_and_stem(grouper_subject: Subject): + with pytest.raises(ValueError) as excinfo: + grouper_subject.get_privileges_for_this( + group_name="test:GROUP1", stem_name="test:child" + ) + + assert excinfo.value.args[0] == "Only specify one of group_name or stem_name." + + +def test_get_privilege_both_subject_id_and_identifier(grouper_group: Group): + with pytest.raises(ValueError) as excinfo: + grouper_group.get_privilege_on_this( + subject_id="abcd1234", subject_identifier="user1234" + ) + + assert ( + excinfo.value.args[0] == "Only specify one of subject_id or subject_identifier." + ) + + +@respx.mock +def test_get_privilege_subject_identifier(grouper_group: Group): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.get_priv_for_group_with_subject_identifier_request, + ).mock(return_value=Response(200, json=data.get_priv_for_group_result)) + + privs = grouper_group.get_privilege_on_this(subject_identifier="user3333") + + assert len(privs) == 1 + + +@respx.mock +def test_get_privilege_with_privilege_name(grouper_group: Group): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.get_priv_for_group_with_privilege_name_request, + ).mock(return_value=Response(200, json=data.get_priv_for_group_result)) + + privs = grouper_group.get_privilege_on_this(privilege_name="admin") + + assert len(privs) == 1 + + +@respx.mock +def test_get_privilege_with_privilege_type(grouper_subject: Subject): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + 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") + + assert len(privs) == 1 + + +def test_get_privileges_no_target(grouper_client: Client): + + with pytest.raises(ValueError) as excinfo: + get_privileges(grouper_client) + + assert excinfo.value.args[0] == ( + "Must specify a valid target to retrieve privileges for." + " Specify either a subject, a stem, a group," + " a subject and stem, or a subject and group." + ) + + +@respx.mock +def test_get_privilege_subject_identifier_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/grouperPrivileges",).mock( + return_value=Response(404, json=data.get_priv_result_subject_not_found) + ) + + with pytest.raises(GrouperSubjectNotFoundException) as excinfo: + grouper_group.get_privilege_on_this(subject_identifier="user3333") + + assert excinfo.value.subject_identifier == "user3333" + + +@respx.mock +def test_get_privilege_subject_id_not_found(grouper_group: Group): + respx.post(url=data.URI_BASE + "/grouperPrivileges",).mock( + return_value=Response(404, json=data.get_priv_result_subject_not_found) + ) + + with pytest.raises(GrouperSubjectNotFoundException) as excinfo: + grouper_group.get_privilege_on_this(subject_id="abcd1234") + + assert excinfo.value.subject_identifier == "abcd1234" + + +@respx.mock +def test_get_privilege_group_not_found(grouper_subject: Subject): + respx.post(url=data.URI_BASE + "/grouperPrivileges",).mock( + return_value=Response(404, json=data.get_priv_result_group_not_found) + ) + + with pytest.raises(GrouperGroupNotFoundException) as excinfo: + grouper_subject.get_privileges_for_this(group_name="test:GROUP1") + + assert excinfo.value.group_name == "test:GROUP1" + + +@respx.mock +def test_get_privilege_stem_not_found(grouper_subject: Subject): + respx.post(url=data.URI_BASE + "/grouperPrivileges",).mock( + return_value=Response(404, json=data.get_priv_result_stem_not_found) + ) + + with pytest.raises(GrouperStemNotFoundException) as excinfo: + grouper_subject.get_privileges_for_this(stem_name="invalid") + + assert excinfo.value.stem_name == "invalid" diff --git a/tests/test_stem.py b/tests/test_stem.py index 6ec9cbb..717b5fb 100644 --- a/tests/test_stem.py +++ b/tests/test_stem.py @@ -13,7 +13,7 @@ def test_create_privilege(grouper_stem: Stem): json=data.create_priv_stem_request, ).mock(Response(200, json=data.assign_priv_result_valid)) - grouper_stem.create_privilege("user3333", "stemAttrRead") + grouper_stem.create_privilege_on_this("user3333", "stemAttrRead") @respx.mock @@ -24,7 +24,20 @@ def test_delete_privilege(grouper_stem: Stem): json=data.delete_priv_stem_request, ).mock(return_value=Response(200, json=data.assign_priv_result_valid)) - grouper_stem.delete_privilege("user3333", "stemAttrRead") + grouper_stem.delete_privilege_on_this("user3333", "stemAttrRead") + + +@respx.mock +def test_get_privilege(grouper_stem: Stem): + respx.route( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + json=data.get_priv_for_stem_request, + ).mock(return_value=Response(200, json=data.get_priv_for_stem_result)) + + privs = grouper_stem.get_privilege_on_this() + + assert len(privs) == 1 @respx.mock diff --git a/tests/test_subject.py b/tests/test_subject.py index 9737cb0..994e941 100644 --- a/tests/test_subject.py +++ b/tests/test_subject.py @@ -3,8 +3,24 @@ if TYPE_CHECKING: from grouper_python 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( + method="POST", + url=data.URI_BASE + "/grouperPrivileges", + 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() + + assert len(privs) == 2 From c36c9fd11f9cd0fbda42b03931ece71c4fdc87c9 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Fri, 28 Apr 2023 12:41:23 -0500 Subject: [PATCH 18/22] add find_subjects --- grouper_python/objects/client.py | 17 +++++++++++- grouper_python/subject.py | 47 ++++++++++++++++++++++++++++++-- tests/data.py | 12 ++++++++ tests/test_client.py | 30 ++++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index 3493e1e..acd25b8 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -10,7 +10,7 @@ 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 +from ..subject import get_subject_by_identifier, find_subject class Client: @@ -81,6 +81,21 @@ def get_subject( act_as_subject=act_as_subject, ) + def find_subject( + self, + search_string: str, + resolve_group: bool = True, + attributes: list[str] = [], + act_as_subject: Subject | None = None, + ) -> list[Subject]: + return find_subject( + search_string=search_string, + client=self, + resolve_group=resolve_group, + attributes=attributes, + act_as_subject=act_as_subject, + ) + def _call_grouper( self, path: str, diff --git a/grouper_python/subject.py b/grouper_python/subject.py index 50a9ef1..2ffddfe 100644 --- a/grouper_python/subject.py +++ b/grouper_python/subject.py @@ -69,8 +69,6 @@ def get_subject_by_identifier( raise GrouperSubjectNotFoundException(subject_identifier, r) if subject["sourceId"] == "g:gsa": if resolve_group: - # from .group import get_group_by_name - return get_group_by_name(subject["name"], client) else: return Subject.from_results( @@ -84,3 +82,48 @@ def get_subject_by_identifier( person_body=subject, subject_attr_names=r["WsGetSubjectsResults"]["subjectAttributeNames"], ) + + +def find_subject( + search_string: str, + client: Client, + resolve_group: bool = True, + attributes: list[str] = [], + act_as_subject: Subject | None = None, +) -> list[Subject]: + from .objects.person import Person + from .objects.subject import Subject + + attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) + body = { + "WsRestGetSubjectsRequest": { + "searchString": search_string, + "includeSubjectDetail": "T", + "subjectAttributeNames": [*attribute_set], + } + } + r = client._call_grouper("/subjects", body, act_as_subject=act_as_subject) + r_list: list[Subject] = [] + if "wsSubjects" in r["WsGetSubjectsResults"]: + subject_attr_names = r["WsGetSubjectsResults"]["subjectAttributeNames"] + for subject in r["WsGetSubjectsResults"]["wsSubjects"]: + if subject["sourceId"] == "g:gsa": + if resolve_group: + r_list.append(get_group_by_name(subject["name"], client)) + else: + r_list.append( + Subject.from_results( + client=client, + subject_body=subject, + subject_attr_names=subject_attr_names, + ) + ) + else: + r_list.append( + Person.from_results( + client=client, + person_body=subject, + subject_attr_names=subject_attr_names, + ) + ) + return r_list diff --git a/tests/data.py b/tests/data.py index fe69cbd..1f2689b 100644 --- a/tests/data.py +++ b/tests/data.py @@ -192,6 +192,18 @@ } } +get_subject_result_valid_search_multiple_subjects = { + "WsGetSubjectsResults": { + "resultMetadata": {"success": "T"}, + "subjectAttributeNames": subject_attribute_names, + "wsSubjects": [ws_subject1, ws_subject2, ws_subject3], + } +} + +get_subject_result_valid_search_no_results = { + "WsGetSubjectsResults": {"resultMetadata": {"success": "T"}} +} + get_members_result_valid_one_group = { "WsGetMembersResults": { "resultMetadata": {"success": "T"}, diff --git a/tests/test_client.py b/tests/test_client.py index 00e008c..87b435d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -159,3 +159,33 @@ def test_get_subject_not_found(grouper_client: Client): grouper_client.get_subject("notauser") assert excinfo.value.subject_identifier == "notauser" + + +@respx.mock +def test_find_subjects(grouper_client: Client): + respx.post(url=data.URI_BASE + "/subjects").mock( + return_value=Response( + 200, json=data.get_subject_result_valid_search_multiple_subjects + ) + ) + respx.post(url=data.URI_BASE + "/groups").mock( + return_value=Response(200, json=data.find_groups_result_valid_one_group_1) + ) + + subjects = grouper_client.find_subject("user") + assert len(subjects) == 3 + + subjects = grouper_client.find_subject("user", resolve_group=False) + assert len(subjects) == 3 + + +@respx.mock +def test_find_subjects_no_result(grouper_client: Client): + 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") + assert len(subjects) == 0 From 92b6dd9cb4686b02d0c695247baca927c9ebe277 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Fri, 28 Apr 2023 13:45:44 -0500 Subject: [PATCH 19/22] created shared function for resolving subject reduces duplicated code --- grouper_python/membership.py | 62 ++++++++++--------------------- grouper_python/objects/client.py | 4 +- grouper_python/subject.py | 64 ++++++++++---------------------- grouper_python/util.py | 33 +++++++++++++++- tests/test_client.py | 2 +- 5 files changed, 74 insertions(+), 91 deletions(-) diff --git a/grouper_python/membership.py b/grouper_python/membership.py index c181ed6..7919a83 100644 --- a/grouper_python/membership.py +++ b/grouper_python/membership.py @@ -11,7 +11,7 @@ GrouperSuccessException, GrouperPermissionDenied, ) -from .group import get_group_by_name +from .util import resolve_subject def get_memberships_for_groups( @@ -24,8 +24,6 @@ def get_memberships_for_groups( ) -> dict[Group, list[Membership]]: from .objects.membership import Membership, MembershipType, MemberType from .objects.group import Group - from .objects.person import Person - from .objects.subject import Subject attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) @@ -75,27 +73,19 @@ def get_memberships_for_groups( groups = { ws_group["uuid"]: Group.from_results(client, ws_group) for ws_group in ws_groups } - r_attributes = r["WsGetMembershipsResults"].get("subjectAttributeNames", []) + subject_attr_names = r["WsGetMembershipsResults"].get("subjectAttributeNames", []) r_dict: dict[Group, list[Membership]] = {group: [] for group in groups.values()} for ws_membership in ws_memberships: - subject: Subject + subject = resolve_subject( + subject_body=subjects[ws_membership["subjectId"]], + client=client, + subject_attr_names=subject_attr_names, + resolve_group=resolve_groups, + ) if ws_membership["subjectSourceId"] == "g:gsa": member_type = MemberType.GROUP - if resolve_groups: - subject = get_group_by_name( - subjects[ws_membership["subjectId"]]["name"], client - ) - else: - subject = Subject.from_results( - client, subjects[ws_membership["subjectId"]], r_attributes - ) else: member_type = MemberType.PERSON - subject = Person.from_results( - client, - subjects[ws_membership["subjectId"]], - r_attributes, - ) if ws_membership["membershipType"] == "immediate": membership_type = MembershipType.DIRECT @@ -288,9 +278,7 @@ def get_members_for_groups( resolve_groups: bool = True, act_as_subject: Subject | None = None, ) -> dict[Group, list[Subject]]: - from .objects.person import Person from .objects.group import Group - from .objects.subject import Subject group_lookup = [{"groupName": group} for group in group_names] body = { @@ -332,33 +320,23 @@ def get_members_for_groups( # So raise the original SuccessException raise err # pragma: no cover r_dict: dict[Group, list[Subject]] = {} - r_attributes = r["WsGetMembersResults"]["subjectAttributeNames"] + subject_attr_names = r["WsGetMembersResults"]["subjectAttributeNames"] for result in r["WsGetMembersResults"]["results"]: - members: list[Subject] = [] + # members: list[Subject] = [] key = Group.from_results(client, result["wsGroup"]) if result["resultMetadata"]["success"] == "T": if "wsSubjects" in result: - for subject in result["wsSubjects"]: - if subject["sourceId"] == "g:gsa": - if resolve_groups: - group = get_group_by_name(subject["name"], client) - members.append(group) - else: - subject = Subject.from_results( - client=client, - subject_body=subject, - subject_attr_names=r_attributes, - ) - members.append(subject) - else: - person = Person.from_results( - client=client, - person_body=subject, - subject_attr_names=r_attributes, - ) - members.append(person) + members = [ + resolve_subject( + subject_body=subject, + client=client, + subject_attr_names=subject_attr_names, + resolve_group=resolve_groups, + ) + for subject in result["wsSubjects"] + ] else: - pass + members = [] r_dict[key] = members else: # pragma: no cover # we don't know what's going on, diff --git a/grouper_python/objects/client.py b/grouper_python/objects/client.py index acd25b8..42ed6c2 100644 --- a/grouper_python/objects/client.py +++ b/grouper_python/objects/client.py @@ -84,14 +84,14 @@ def get_subject( def find_subject( self, search_string: str, - resolve_group: bool = True, + resolve_groups: bool = True, attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Subject]: return find_subject( search_string=search_string, client=self, - resolve_group=resolve_group, + resolve_groups=resolve_groups, attributes=attributes, act_as_subject=act_as_subject, ) diff --git a/grouper_python/subject.py b/grouper_python/subject.py index 2ffddfe..781a0dd 100644 --- a/grouper_python/subject.py +++ b/grouper_python/subject.py @@ -6,7 +6,7 @@ from .objects.client import Client from .objects.subject import Subject from .objects.exceptions import GrouperSubjectNotFoundException -from .group import get_group_by_name +from .util import resolve_subject def get_groups_for_subject( @@ -52,9 +52,6 @@ def get_subject_by_identifier( attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> Subject: - from .objects.person import Person - from .objects.subject import Subject - attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) body = { "WsRestGetSubjectsRequest": { @@ -67,33 +64,21 @@ def get_subject_by_identifier( subject = r["WsGetSubjectsResults"]["wsSubjects"][0] if subject["success"] == "F": raise GrouperSubjectNotFoundException(subject_identifier, r) - if subject["sourceId"] == "g:gsa": - if resolve_group: - return get_group_by_name(subject["name"], client) - else: - return Subject.from_results( - client=client, - subject_body=subject, - subject_attr_names=r["WsGetSubjectsResults"]["subjectAttributeNames"], - ) - else: - return Person.from_results( - client=client, - person_body=subject, - subject_attr_names=r["WsGetSubjectsResults"]["subjectAttributeNames"], - ) + return resolve_subject( + subject_body=subject, + client=client, + subject_attr_names=r["WsGetSubjectsResults"]["subjectAttributeNames"], + resolve_group=resolve_group, + ) def find_subject( search_string: str, client: Client, - resolve_group: bool = True, + resolve_groups: bool = True, attributes: list[str] = [], act_as_subject: Subject | None = None, ) -> list[Subject]: - from .objects.person import Person - from .objects.subject import Subject - attribute_set = set(attributes + [client.universal_identifier_attr, "name"]) body = { "WsRestGetSubjectsRequest": { @@ -103,27 +88,16 @@ def find_subject( } } r = client._call_grouper("/subjects", body, act_as_subject=act_as_subject) - r_list: list[Subject] = [] if "wsSubjects" in r["WsGetSubjectsResults"]: subject_attr_names = r["WsGetSubjectsResults"]["subjectAttributeNames"] - for subject in r["WsGetSubjectsResults"]["wsSubjects"]: - if subject["sourceId"] == "g:gsa": - if resolve_group: - r_list.append(get_group_by_name(subject["name"], client)) - else: - r_list.append( - Subject.from_results( - client=client, - subject_body=subject, - subject_attr_names=subject_attr_names, - ) - ) - else: - r_list.append( - Person.from_results( - client=client, - person_body=subject, - subject_attr_names=subject_attr_names, - ) - ) - return r_list + return [ + resolve_subject( + subject_body=subject, + client=client, + subject_attr_names=subject_attr_names, + resolve_group=resolve_groups, + ) + for subject in r["WsGetSubjectsResults"]["wsSubjects"] + ] + else: + return [] diff --git a/grouper_python/util.py b/grouper_python/util.py index 51fe370..ff86bde 100644 --- a/grouper_python/util.py +++ b/grouper_python/util.py @@ -1,8 +1,13 @@ from __future__ import annotations -from typing import Any +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from .objects.client import Client + from .objects.subject import Subject import httpx from copy import deepcopy from .objects.exceptions import GrouperAuthException, GrouperSuccessException +from .group import get_group_by_name def call_grouper( @@ -45,3 +50,29 @@ def call_grouper( if data[result_type]["resultMetadata"]["success"] != "T": raise GrouperSuccessException(data) return data + + +def resolve_subject( + subject_body: dict[str, Any], + client: Client, + subject_attr_names: list[str], + resolve_group: bool, +) -> Subject: + from .objects.person import Person + from .objects.subject import Subject + + if subject_body["sourceId"] == "g:gsa": + if resolve_group: + return get_group_by_name(subject_body["name"], client) + else: + return Subject.from_results( + client=client, + subject_body=subject_body, + subject_attr_names=subject_attr_names, + ) + else: + return Person.from_results( + client=client, + person_body=subject_body, + subject_attr_names=subject_attr_names, + ) diff --git a/tests/test_client.py b/tests/test_client.py index 87b435d..f405a93 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -175,7 +175,7 @@ def test_find_subjects(grouper_client: Client): subjects = grouper_client.find_subject("user") assert len(subjects) == 3 - subjects = grouper_client.find_subject("user", resolve_group=False) + subjects = grouper_client.find_subject("user", resolve_groups=False) assert len(subjects) == 3 From ace4d95079af116471ca63d2cfe77a001f8b34c7 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 1 May 2023 10:23:15 -0500 Subject: [PATCH 20/22] remove script stuff --- README.md | 24 ------------------------ requirements-script.txt | 1 - 2 files changed, 25 deletions(-) delete mode 100644 requirements-script.txt diff --git a/README.md b/README.md index 9a68584..6bb1a0b 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,6 @@ To install grouper library only: pip install . ``` -To install additional requirements for running the included scripts: - -``` sh -pip install .[script] -``` - To install for development: ``` sh @@ -22,21 +16,3 @@ pip install --editable .[dev] This will install so the `grouper` module is based off the source code, and also installs neccessary linters and testing requirements. -Currently there are no "tests" written for this code, -but at the very least it passes `pylama` and `mypy`. - -## Script usage - -To use the included scripts (`docs_base.py` and `docs_site.py`) -you will need to setup a `.env` file. -Copy `.env.template` to `.env`. -Change the values for GROUPER_USER, GROUPER_PWD, and if neccessary, GROUPER_BASE_URL. -If there are double quotes (`"`) in your password, surround it with single quotes (`'`) -instead of the double quotes in the sample file. - -### Specifying sites - -Specify the sites to create groups for using a text file (by default, `sites.txt`) -in the root of this repository. -Specify the URLs, one per line. -This file will be read by `docs_site.py`. diff --git a/requirements-script.txt b/requirements-script.txt deleted file mode 100644 index 566cccb..0000000 --- a/requirements-script.txt +++ /dev/null @@ -1 +0,0 @@ -python-dotenv From 94f6077a45e7d0c19465948b9963ae48b3219851 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 1 May 2023 11:08:02 -0500 Subject: [PATCH 21/22] Create LICENSE [skip ci] --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From f2218458d1bcecd63a73e4bd12a9465ae28b7568 Mon Sep 17 00:00:00 2001 From: Peter Bajurny Date: Mon, 1 May 2023 11:36:32 -0500 Subject: [PATCH 22/22] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6bb1a0b..b7e80d7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # grouper-python +This is a module to interact with the [Grouper Web Services API](https://spaces.at.internet2.edu/display/Grouper/Grouper+Web+Services). + ## Installation To install grouper library only: