From 4a797be9016cb85eaebbf04c591aab963b47f937 Mon Sep 17 00:00:00 2001 From: "Michael J. Stealey" Date: Tue, 21 Apr 2026 19:19:39 -0400 Subject: [PATCH 1/5] bug fixes, critical issues, reduce code duplication --- README.md | 27 +- comanage_api/__init__.py | 41 +- comanage_api/_coorgidentitylinks.py | 72 +- comanage_api/_copeople.py | 127 +--- comanage_api/_copersonroles.py | 157 ++-- comanage_api/_cous.py | 127 +--- comanage_api/_emailaddresses.py | 71 +- comanage_api/_identifiers.py | 82 +-- comanage_api/_names.py | 71 +- comanage_api/_orgidentities.py | 82 +-- comanage_api/_sshkeys.py | 156 ++-- examples/README.md | 880 ++++++----------------- examples/coorg_identity_links_example.py | 46 +- examples/copeople_example.py | 54 +- examples/coperson_roles_example.py | 40 +- examples/email_addresses_example.py | 40 +- examples/identifiers_example.py | 45 +- examples/names_example.py | 40 +- examples/org_identities_example.py | 43 +- examples/ssh_keys_example.py | 28 +- pyproject.toml | 46 +- setup.cfg | 1 - uv.lock | 431 +++++++++++ 23 files changed, 1100 insertions(+), 1607 deletions(-) create mode 100644 uv.lock diff --git a/README.md b/README.md index 07b9446..acfb788 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,6 @@ api = ComanageApi( Get some data! (example using `cous_view_per_co()` which retrieves all COUs attached to a given CO) ```python -$ python -Python 3.9.6 (v3.9.6:db3ff76da1, Jun 28 2021, 11:49:53) -[Clang 6.0 (clang-600.0.57)] on darwin -Type "help", "copyright", "credits" or "license" for more information. ->>> >>> from comanage_api import ComanageApi >>> >>> api = ComanageApi( @@ -117,8 +112,8 @@ Return types based on implementation status of wrapped API endpoints - `-> dict`: Data is returned as a Python [Dictionary](https://docs.python.org/3/c-api/dict.html) object - `-> bool`: Success/Failure is returned as Python [Boolean](https://docs.python.org/3/c-api/bool.html) object - Not Implemented (`### NOT IMPLEMENTED ###`): - - `-> dict`: raise exception (`HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local`) - - `-> bool`: raise exception (`HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local`) + - `-> dict`: raises `NotImplementedError` + - `-> bool`: raises `NotImplementedError` ### [CoOrgIdentityLink API](https://spaces.at.internet2.edu/display/COmanage/CoOrgIdentityLink+API) (COmanage v4.0.0+) @@ -346,27 +341,21 @@ SSH_KEY_OPTIONS = ['ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', ## Usage -Set up a virtual environment (`virtualenv` is used in these examples) - -```console -virtualenv -p /usr/local/bin/python3 venv -source venv/bin/activate -``` - -### Install supporting packages +### Install -Install from PyPi +Install from PyPI: ```console pip install fabric-comanage-api ``` -**OR** +### Development setup -Install for Local Development +This project uses [uv](https://docs.astral.sh/uv/) for dependency management. To set up a development environment: ```console -pip install -r requirements.txt +uv venv --python 3.12 +uv pip install -e ".[dev]" ``` ### Configure your environment diff --git a/comanage_api/__init__.py b/comanage_api/__init__.py index 39118f1..155d167 100644 --- a/comanage_api/__init__.py +++ b/comanage_api/__init__.py @@ -1,4 +1,5 @@ -import requests_mock +import json + from requests import Session from ._coorgidentitylinks import coorg_identity_links_add, coorg_identity_links_delete, coorg_identity_links_edit, \ @@ -80,18 +81,42 @@ def __init__(self, co_api_url: str, co_api_user: str, co_api_pass: str, co_api_o # SSH Key Type options self.SSH_KEY_OPTIONS = ['ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'ssh-rsa', 'ssh-rsa1'] - # create mock response session - self._mock_session = Session() - self._adapter = requests_mock.Adapter() - self._mock_session.mount('mock://', self._adapter) - # add mock adapters - self._MOCK_501_URL = 'mock://not_implemented_501.local' - self._adapter.register_uri('GET', self._MOCK_501_URL, reason='Not Implemented', status_code=501) # create comanage_api session self._s = Session() self._s.headers = {'Content-Type': 'application/json'} self._s.auth = (self._CO_API_USER, self._CO_API_PASS) + # HTTP helpers + def _get(self, path: str, params: dict = None) -> dict: + resp = self._s.get(f"{self._CO_API_URL}/{path}", params=params) + resp.raise_for_status() + return resp.json() + + def _post(self, path: str, data: dict) -> dict: + resp = self._s.post(f"{self._CO_API_URL}/{path}", data=json.dumps(data)) + resp.raise_for_status() + return resp.json() + + def _put(self, path: str, data: dict) -> bool: + resp = self._s.put(f"{self._CO_API_URL}/{path}", data=json.dumps(data)) + resp.raise_for_status() + return True + + def _delete(self, path: str, params: dict = None) -> bool: + resp = self._s.delete(f"{self._CO_API_URL}/{path}", params=params) + resp.raise_for_status() + return True + + def _get_by_entity(self, path: str, entity_type: str, entity_id: int, + valid_options: list, field_name: str) -> dict: + if not entity_type: + entity_type = 'copersonid' + else: + entity_type = str(entity_type).lower() + if entity_type not in valid_options: + raise ValueError(f"Invalid Fields '{field_name}'") + return self._get(path, params={entity_type: str(entity_id)}) + # CoOrgIdentityLink API def coorg_identity_links_add(self): return coorg_identity_links_add(self) diff --git a/comanage_api/_coorgidentitylinks.py b/comanage_api/_coorgidentitylinks.py index 80d7b7a..1aa3727 100644 --- a/comanage_api/_coorgidentitylinks.py +++ b/comanage_api/_coorgidentitylinks.py @@ -24,8 +24,6 @@ Retrieve an existing CO Identity Link. """ -import json - def coorg_identity_links_add(self) -> dict: """ @@ -35,17 +33,8 @@ def coorg_identity_links_add(self) -> dict: Note that invitations are a separate operation. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("coorg_identity_links_add() is not implemented") def coorg_identity_links_delete(self) -> bool: @@ -54,17 +43,8 @@ def coorg_identity_links_delete(self) -> bool: Remove a CO Org Identity Link. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("coorg_identity_links_delete() is not implemented") def coorg_identity_links_edit(self) -> bool: @@ -73,17 +53,8 @@ def coorg_identity_links_edit(self) -> bool: Edit an existing CO Identity Link. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("coorg_identity_links_edit() is not implemented") def coorg_identity_links_view_all(self) -> dict: @@ -115,15 +86,7 @@ def coorg_identity_links_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_org_identity_links.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() - + return self._get('co_org_identity_links.json') def coorg_identity_links_view_by_identity(self, identity_type: str, identity_id: int) -> dict: @@ -159,22 +122,8 @@ def coorg_identity_links_view_by_identity(self, identity_type: str, identity_id: 404 Org Identity Unknown orgidentityid not found 500 Other Error Unknown error """ - if not identity_type: - identity_type = 'copersonid' - else: - identity_type = str(identity_type).lower() - if identity_type not in self.PERSON_OPTIONS: - raise TypeError("Invalid Fields 'identity_type'") - url = self._CO_API_URL + '/co_org_identity_links.json' - params = {str(identity_type): str(identity_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get_by_entity('co_org_identity_links.json', identity_type, identity_id, + self.PERSON_OPTIONS, 'identity_type') def coorg_identity_links_view_one(self, coorg_identity_link_id: int) -> dict: @@ -207,11 +156,4 @@ def coorg_identity_links_view_one(self, coorg_identity_link_id: int) -> dict: 404 CoOrgIdentityLink Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_org_identity_links/' + str(coorg_identity_link_id) + '.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'co_org_identity_links/{coorg_identity_link_id}.json') diff --git a/comanage_api/_copeople.py b/comanage_api/_copeople.py index fe6705b..2a63272 100644 --- a/comanage_api/_copeople.py +++ b/comanage_api/_copeople.py @@ -36,8 +36,6 @@ Retrieve an existing CO Person. """ -import json - def copeople_add(self) -> dict: """ @@ -46,17 +44,8 @@ def copeople_add(self) -> dict: Note that linking to an OrgIdentity and invitations are separate operations. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("copeople_add() is not implemented") def copeople_delete(self) -> bool: @@ -67,17 +56,8 @@ def copeople_delete(self) -> bool: before the OrgIdentity record can be removed. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("copeople_delete() is not implemented") def copeople_edit(self) -> bool: @@ -86,17 +66,8 @@ def copeople_edit(self) -> bool: Edit an existing CO Person. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("copeople_edit() is not implemented") def copeople_find(self) -> dict: @@ -106,17 +77,14 @@ def copeople_find(self) -> dict: When too many records are found, a message may be returned rather than specific records. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("copeople_find() is not implemented") + + +def _deduplicate_copeople(resp_dict: dict) -> dict: + distinct_copeople = list({v['Id']: v for v in resp_dict.get('CoPeople')}.values()) + resp_dict['CoPeople'] = distinct_copeople + return resp_dict def copeople_match(self, given: str = None, family: str = None, mail: str = None, distinct_by_id: bool = True) -> dict: @@ -155,28 +123,17 @@ def copeople_match(self, given: str = None, family: str = None, mail: str = None -- unknown CO will return an empty set 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_people.json' params = {'coid': self._CO_API_ORG_ID} if given: - params.update({'given': given}) + params['given'] = given if family: - params.update({'family': family}) + params['family'] = family if mail: - params.update({'mail': mail}) - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - if distinct_by_id: - resp_dict = json.loads(resp.text) - distinct_copeople = list({v['Id']: v for v in resp_dict.get('CoPeople')}.values()) - resp_dict['CoPeople'] = distinct_copeople - return resp_dict - else: - return json.loads(resp.text) - else: - resp.raise_for_status() + params['mail'] = mail + resp_dict = self._get('co_people.json', params=params) + if distinct_by_id: + return _deduplicate_copeople(resp_dict) + return resp_dict def copeople_view_all(self) -> dict: @@ -207,14 +164,7 @@ def copeople_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_people.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('co_people.json') def copeople_view_per_co(self) -> dict: @@ -247,16 +197,7 @@ def copeople_view_per_co(self) -> dict: -- unknown CO will return an empty set 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_people.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('co_people.json', params={'coid': self._CO_API_ORG_ID}) def copeople_view_per_identifier(self, identifier: str, distinct_by_id: bool = True) -> dict: @@ -291,22 +232,11 @@ def copeople_view_per_identifier(self, identifier: str, distinct_by_id: bool = T 404 CO Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_people.json' params = {'coid': self._CO_API_ORG_ID, 'search.identifier': identifier} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - if distinct_by_id: - resp_dict = json.loads(resp.text) - distinct_copeople = list({v['Id']: v for v in resp_dict.get('CoPeople')}.values()) - resp_dict['CoPeople'] = distinct_copeople - return resp_dict - else: - return json.loads(resp.text) - else: - resp.raise_for_status() + resp_dict = self._get('co_people.json', params=params) + if distinct_by_id: + return _deduplicate_copeople(resp_dict) + return resp_dict def copeople_view_one(self, coperson_id: int) -> dict: @@ -339,13 +269,4 @@ def copeople_view_one(self, coperson_id: int) -> dict: 404 copeople Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_people/' + str(coperson_id) + '.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'co_people/{coperson_id}.json', params={'coid': self._CO_API_ORG_ID}) diff --git a/comanage_api/_copersonroles.py b/comanage_api/_copersonroles.py index 30fb15a..550b5f1 100644 --- a/comanage_api/_copersonroles.py +++ b/comanage_api/_copersonroles.py @@ -22,8 +22,6 @@ Retrieve an existing CO Person Role. """ -import json - def coperson_roles_add(self, coperson_id: int, cou_id: int, status: str = None, affiliation: str = None) -> dict: """ @@ -85,45 +83,33 @@ def coperson_roles_add(self, coperson_id: int, cou_id: int, status: str = None, 403 COU Does Not Exist The specified COU does not exist 500 Other Error Unknown error """ - post_body = { - 'RequestType': 'CoPersonRoles', + role = { 'Version': '1.0', - 'CoPersonRoles': [ - { - 'Version': '1.0', - 'Person': - { - 'Type': 'CO', - 'Id': str(coperson_id) - }, - 'CouId': str(cou_id), - 'O': str(self._CO_API_ORG_NAME) - } - ] + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) + }, + 'CouId': str(cou_id), + 'O': str(self._CO_API_ORG_NAME) } if status: if status not in self.STATUS_OPTIONS: - raise TypeError("Invalid Fields 'status'") - post_body['CoPersonRoles'][0]['Status'] = str(status) + raise ValueError("Invalid Fields 'status'") + role['Status'] = str(status) else: - post_body['CoPersonRoles'][0]['Status'] = 'Active' + role['Status'] = 'Active' if affiliation: affiliation = str(affiliation).lower() if affiliation not in self.AFFILIATION_OPTIONS: - raise TypeError("Invalid Fields 'affiliation'") - post_body['CoPersonRoles'][0]['Affiliation'] = str(affiliation) - else: - post_body['CoPersonRoles'][0]['Affiliation'] = 'member' - post_body = json.dumps(post_body) - url = self._CO_API_URL + '/co_person_roles.json' - resp = self._s.post( - url=url, - data=post_body - ) - if resp.status_code == 201: - return json.loads(resp.text) + raise ValueError("Invalid Fields 'affiliation'") + role['Affiliation'] = str(affiliation) else: - resp.raise_for_status() + role['Affiliation'] = 'member' + return self._post('co_person_roles.json', { + 'RequestType': 'CoPersonRoles', + 'Version': '1.0', + 'CoPersonRoles': [role] + }) def coperson_roles_delete(self, coperson_role_id: int) -> bool: @@ -142,14 +128,7 @@ def coperson_roles_delete(self, coperson_role_id: int) -> bool: 404 CoPersonRole Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_person_roles/' + str(coperson_role_id) + '.json' - resp = self._s.delete( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + return self._delete(f'co_person_roles/{coperson_role_id}.json') def coperson_roles_edit(self, coperson_role_id: int, coperson_id: int = None, cou_id: int = None, status: str = None, @@ -209,53 +188,35 @@ def coperson_roles_edit(self, coperson_role_id: int, coperson_id: int = None, co 404 CoPersonRole Unknown id not found 500 Other Error Unknown error """ - coperson_role = coperson_roles_view_one(self, coperson_role_id) - post_body = { - 'RequestType': 'CoPersonRoles', + existing = coperson_roles_view_one(self, coperson_role_id) + existing_role = existing.get('CoPersonRoles')[0] + role = { 'Version': '1.0', - 'CoPersonRoles': [ - { - 'Version': '1.0', - 'Person': - { - 'Type': 'CO' - }, - 'O': str(self._CO_API_ORG_NAME) - } - ] + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) if coperson_id else str(existing_role.get('Person').get('Id')) + }, + 'CouId': str(cou_id) if cou_id else str(existing_role.get('CouId')), + 'O': str(self._CO_API_ORG_NAME) } - if coperson_id: - post_body['CoPersonRoles'][0]['Person']['Id'] = str(coperson_id) - else: - post_body['CoPersonRoles'][0]['Person']['Id'] = str( - coperson_role.get('CoPersonRoles')[0].get('Person').get('Id')) - if cou_id: - post_body['CoPersonRoles'][0]['CouId'] = str(cou_id) - else: - post_body['CoPersonRoles'][0]['CouId'] = str(coperson_role.get('CoPersonRoles')[0].get('CouId')) if status: if status not in self.STATUS_OPTIONS: - raise TypeError("Invalid Fields 'status'") - post_body['CoPersonRoles'][0]['Status'] = str(status) + raise ValueError("Invalid Fields 'status'") + role['Status'] = str(status) else: - post_body['CoPersonRoles'][0]['Status'] = coperson_role.get('CoPersonRoles')[0].get('Status') + role['Status'] = existing_role.get('Status') if affiliation: affiliation = str(affiliation).lower() if affiliation not in self.AFFILIATION_OPTIONS: - raise TypeError("Invalid Fields 'affiliation'") - post_body['CoPersonRoles'][0]['Affiliation'] = str(affiliation) + raise ValueError("Invalid Fields 'affiliation'") + role['Affiliation'] = str(affiliation) else: - post_body['CoPersonRoles'][0]['Affiliation'] = coperson_role.get('CoPersonRoles')[0].get('Affiliation') - post_body = json.dumps(post_body) - url = self._CO_API_URL + '/co_person_roles/' + str(coperson_role_id) + '.json' - resp = self._s.put( - url=url, - data=post_body - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + role['Affiliation'] = existing_role.get('Affiliation') + return self._put(f'co_person_roles/{coperson_role_id}.json', { + 'RequestType': 'CoPersonRoles', + 'Version': '1.0', + 'CoPersonRoles': [role] + }) def coperson_roles_view_all(self) -> dict: @@ -304,14 +265,7 @@ def coperson_roles_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_person_roles.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('co_person_roles.json') def coperson_roles_view_per_coperson(self, coperson_id: int) -> dict: @@ -362,16 +316,7 @@ def coperson_roles_view_per_coperson(self, coperson_id: int) -> dict: 404 CO Person Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_person_roles.json' - params = {'copersonid': int(coperson_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('co_person_roles.json', params={'copersonid': int(coperson_id)}) def coperson_roles_view_per_cou(self, cou_id: int) -> dict: @@ -422,16 +367,7 @@ def coperson_roles_view_per_cou(self, cou_id: int) -> dict: 404 COU Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_person_roles.json' - params = {'couid': int(cou_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('co_person_roles.json', params={'couid': int(cou_id)}) def coperson_roles_view_one(self, coperson_role_id: int) -> dict: @@ -482,11 +418,4 @@ def coperson_roles_view_one(self, coperson_role_id: int) -> dict: 404 CoPersonRole Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/co_person_roles/' + str(coperson_role_id) + '.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'co_person_roles/{coperson_role_id}.json') diff --git a/comanage_api/_cous.py b/comanage_api/_cous.py index 5b9fa7d..1ac6b84 100644 --- a/comanage_api/_cous.py +++ b/comanage_api/_cous.py @@ -19,8 +19,6 @@ Retrieve an existing Cou. """ -import json - def cous_add(self, name: str, description: str, parent_id: int = None) -> dict: """ @@ -69,31 +67,19 @@ def cous_add(self, name: str, description: str, parent_id: int = None) -> dict: 403 Wrong CO Parent/Child COU not member of same CO 500 Other Error Unknown error """ - post_body = { - 'RequestType': 'Cous', + cou = { 'Version': '1.0', - 'Cous': - [ - { - 'Version': '1.0', - 'CoId': self._CO_API_ORG_ID, - 'Name': str(name), - 'Description': str(description) - } - ] + 'CoId': self._CO_API_ORG_ID, + 'Name': str(name), + 'Description': str(description) } if parent_id: - post_body['Cous'][0]['ParentId'] = str(parent_id) - post_body = json.dumps(post_body) - url = self._CO_API_URL + '/cous.json' - resp = self._s.post( - url=url, - data=post_body - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + cou['ParentId'] = str(parent_id) + return self._post('cous.json', { + 'RequestType': 'Cous', + 'Version': '1.0', + 'Cous': [cou] + }) def cous_delete(self, cou_id: int) -> bool: @@ -114,16 +100,7 @@ def cous_delete(self, cou_id: int) -> bool: 404 Identifier Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/cous/' + str(cou_id) + '.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.delete( - url=url, - params=params - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + return self._delete(f'cous/{cou_id}.json', params={'coid': self._CO_API_ORG_ID}) def cous_edit(self, cou_id: int, name: str = None, description: str = None, parent_id: int = None) -> bool: @@ -173,43 +150,26 @@ def cous_edit(self, cou_id: int, name: str = None, description: str = None, pare 404 Identifier Unknown id not found 500 Other Error Unknown error """ - cou = cous_view_one(self, cou_id) - post_body = { - 'RequestType': 'Cous', + existing = cous_view_one(self, cou_id) + existing_cou = existing.get('Cous')[0] + cou = { 'Version': '1.0', - 'Cous': - [ - { - 'Version': '1.0', - 'CoId': self._CO_API_ORG_ID - } - ] + 'CoId': self._CO_API_ORG_ID, + 'Name': str(name) if name else existing_cou.get('Name'), + 'Description': str(description) if description else existing_cou.get('Description') } - if name: - post_body['Cous'][0]['Name'] = str(name) - else: - post_body['Cous'][0]['Name'] = cou.get('Cous')[0].get('Name') - if description: - post_body['Cous'][0]['Description'] = str(description) + if parent_id is not None and parent_id != 0: + cou['ParentId'] = str(parent_id) + elif parent_id == 0: + cou['ParentId'] = '' else: - post_body['Cous'][0]['Description'] = cou.get('Cous')[0].get('Description') - if parent_id: - post_body['Cous'][0]['ParentId'] = str(parent_id) - else: - if cou.get('Cous')[0].get('ParentId'): - post_body['Cous'][0]['ParentId'] = str(cou.get('Cous')[0].get('ParentId')) - if str(parent_id) == '0': - post_body['Cous'][0]['ParentId'] = '' - post_body = json.dumps(post_body) - url = self._CO_API_URL + '/cous/' + str(cou_id) + '.json' - resp = self._s.put( - url=url, - data=post_body - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + if existing_cou.get('ParentId'): + cou['ParentId'] = str(existing_cou.get('ParentId')) + return self._put(f'cous/{cou_id}.json', { + 'RequestType': 'Cous', + 'Version': '1.0', + 'Cous': [cou] + }) def cous_view_all(self) -> dict: @@ -248,14 +208,7 @@ def cous_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/cous.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('cous.json') def cous_view_per_co(self) -> dict: @@ -295,16 +248,7 @@ def cous_view_per_co(self) -> dict: 404 CO Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/cous.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('cous.json', params={'coid': self._CO_API_ORG_ID}) def cous_view_one(self, cou_id: int) -> dict: @@ -342,13 +286,4 @@ def cous_view_one(self, cou_id: int) -> dict: 404 COU Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/cous/' + str(cou_id) + '.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'cous/{cou_id}.json', params={'coid': self._CO_API_ORG_ID}) diff --git a/comanage_api/_emailaddresses.py b/comanage_api/_emailaddresses.py index 0e4788f..c648a6f 100644 --- a/comanage_api/_emailaddresses.py +++ b/comanage_api/_emailaddresses.py @@ -22,8 +22,6 @@ Retrieve an existing EmailAddress. """ -import json - def email_addresses_add(self) -> dict: """ @@ -31,17 +29,8 @@ def email_addresses_add(self) -> dict: Add a new EmailAddress. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("email_addresses_add() is not implemented") def email_addresses_delete(self) -> bool: @@ -50,17 +39,8 @@ def email_addresses_delete(self) -> bool: Remove an EmailAddress. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("email_addresses_delete() is not implemented") def email_addresses_edit(self) -> bool: @@ -69,17 +49,8 @@ def email_addresses_edit(self) -> bool: Edit an existing EmailAddress. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("email_addresses_edit() is not implemented") def email_addresses_view_all(self) -> dict: @@ -118,14 +89,7 @@ def email_addresses_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/email_addresses.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('email_addresses.json') def email_addresses_view_per_person(self, person_type: str, person_id: int) -> dict: @@ -178,22 +142,8 @@ def email_addresses_view_per_person(self, person_type: str, person_id: int) -> d 404 Org Identity Unknown id not found for Org Identity 500 Other Error Unknown error """ - if not person_type: - person_type = 'copersonid' - else: - person_type = str(person_type).lower() - if person_type not in self.EMAILADDRESS_OPTIONS: - raise TypeError("Invalid Fields 'person_type'") - url = self._CO_API_URL + '/email_addresses.json' - params = {str(person_type): str(person_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get_by_entity('email_addresses.json', person_type, person_id, + self.EMAILADDRESS_OPTIONS, 'person_type') def email_addresses_view_one(self, email_address_id: int) -> dict: @@ -233,11 +183,4 @@ def email_addresses_view_one(self, email_address_id: int) -> dict: 404 EmailAddress Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/email_addresses/' + str(email_address_id) + '.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'email_addresses/{email_address_id}.json') diff --git a/comanage_api/_identifiers.py b/comanage_api/_identifiers.py index 9b15692..ccbd7b4 100644 --- a/comanage_api/_identifiers.py +++ b/comanage_api/_identifiers.py @@ -25,8 +25,6 @@ Retrieve an existing Identifier. """ -import json - def identifiers_add(self) -> dict: """ @@ -34,17 +32,8 @@ def identifiers_add(self) -> dict: Add a new Identifier. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("identifiers_add() is not implemented") def identifiers_assign(self) -> bool: @@ -53,17 +42,8 @@ def identifiers_assign(self) -> bool: Assign Identifiers for a CO Person. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("identifiers_assign() is not implemented") def identifiers_delete(self) -> bool: @@ -72,17 +52,8 @@ def identifiers_delete(self) -> bool: Remove an Identifier. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("identifiers_delete() is not implemented") def identifiers_edit(self) -> bool: @@ -91,17 +62,8 @@ def identifiers_edit(self) -> bool: Edit an existing Identifier. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("identifiers_edit() is not implemented") def identifiers_view_all(self) -> dict: @@ -137,14 +99,7 @@ def identifiers_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/identifiers.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('identifiers.json') def identifiers_view_per_entity(self, entity_type: str, entity_id: int) -> dict: @@ -197,22 +152,8 @@ def identifiers_view_per_entity(self, entity_type: str, entity_id: int) -> dict: 404 Org Identity Unknown id not found for Org Identity 500 Other Error Unknown error """ - if not entity_type: - entity_type = 'copersonid' - else: - entity_type = str(entity_type).lower() - if entity_type not in self.ENTITY_OPTIONS: - raise TypeError("Invalid Fields 'entity_type'") - url = self._CO_API_URL + '/identifiers.json' - params = {str(entity_type): str(entity_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get_by_entity('identifiers.json', entity_type, entity_id, + self.ENTITY_OPTIONS, 'entity_type') def identifiers_view_one(self, identifier_id: int) -> dict: @@ -250,11 +191,4 @@ def identifiers_view_one(self, identifier_id: int) -> dict: 404 Identifier Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/identifiers/' + str(identifier_id) + '.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'identifiers/{identifier_id}.json') diff --git a/comanage_api/_names.py b/comanage_api/_names.py index 7c40d97..815d102 100644 --- a/comanage_api/_names.py +++ b/comanage_api/_names.py @@ -22,8 +22,6 @@ Retrieve Names attached to a CO Person or Org Identity. """ -import json - def names_add(self) -> dict: """ @@ -31,17 +29,8 @@ def names_add(self) -> dict: Add a new Name. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("names_add() is not implemented") def names_delete(self) -> bool: @@ -50,17 +39,8 @@ def names_delete(self) -> bool: Remove a Name. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("names_delete() is not implemented") def names_edit(self) -> bool: @@ -69,17 +49,8 @@ def names_edit(self) -> bool: Edit an existing Name. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("names_edit() is not implemented") def names_view_all(self) -> dict: @@ -122,14 +93,7 @@ def names_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/names.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('names.json') def names_view_per_person(self, person_type: str, person_id: int) -> dict: @@ -176,22 +140,8 @@ def names_view_per_person(self, person_type: str, person_id: int) -> dict: 404 Org Identity Unknown id not found for Org Identity 500 Other Error Unknown error """ - if not person_type: - person_type = 'copersonid' - else: - person_type = str(person_type).lower() - if person_type not in self.PERSON_OPTIONS: - raise TypeError("Invalid Fields 'person_type'") - url = self._CO_API_URL + '/names.json' - params = {str(person_type): str(person_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get_by_entity('names.json', person_type, person_id, + self.PERSON_OPTIONS, 'person_type') def names_view_one(self, name_id: int) -> dict: @@ -236,11 +186,4 @@ def names_view_one(self, name_id: int) -> dict: 404 Name Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/names/' + str(name_id) + '.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'names/{name_id}.json') diff --git a/comanage_api/_orgidentities.py b/comanage_api/_orgidentities.py index 746efa5..5966ecc 100644 --- a/comanage_api/_orgidentities.py +++ b/comanage_api/_orgidentities.py @@ -27,8 +27,6 @@ Retrieve an existing Organizational Identity. """ -import json - def org_identities_add(self) -> dict: """ @@ -36,17 +34,8 @@ def org_identities_add(self) -> dict: Add a new Organizational Identity. A person must have an OrgIdentity before they can be added to a CO. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() + raise NotImplementedError("org_identities_add() is not implemented") def org_identities_delete(self) -> bool: @@ -57,17 +46,8 @@ def org_identities_delete(self) -> bool: This method will also delete related data, such as Addresses, EmailAddresses, and TelephoneNumbers. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("org_identities_delete() is not implemented") def org_identities_edit(self) -> bool: @@ -76,22 +56,13 @@ def org_identities_edit(self) -> bool: Edit an existing Organizational Identity. :param self: - :return - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local: """ - url = self._MOCK_501_URL - resp = self._mock_session.get( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + raise NotImplementedError("org_identities_edit() is not implemented") def org_identities_view_all(self) -> dict: """ - Retrieve all existing EmailAddresses. + Retrieve all existing Organizational Identities. :param self: :return @@ -124,14 +95,7 @@ def org_identities_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/org_identities.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('org_identities.json') def org_identities_view_per_co(self) -> dict: @@ -170,16 +134,7 @@ def org_identities_view_per_co(self) -> dict: 404 CO Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/org_identities.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('org_identities.json', params={'coid': self._CO_API_ORG_ID}) def org_identities_view_per_identifier(self, identifier_id: int) -> dict: @@ -220,16 +175,10 @@ def org_identities_view_per_identifier(self, identifier_id: int) -> dict: 404 CO Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/org_identities.json' - params = {'coid': self._CO_API_ORG_ID, 'search.identifier': int(identifier_id)} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get('org_identities.json', params={ + 'coid': self._CO_API_ORG_ID, + 'search.identifier': int(identifier_id) + }) def org_identities_view_one(self, org_identity_id: int) -> dict: @@ -268,13 +217,4 @@ def org_identities_view_one(self, org_identity_id: int) -> dict: 404 OrgIdentity Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/org_identities/' + str(org_identity_id) + '.json' - params = {'coid': self._CO_API_ORG_ID} - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'org_identities/{org_identity_id}.json', params={'coid': self._CO_API_ORG_ID}) diff --git a/comanage_api/_sshkeys.py b/comanage_api/_sshkeys.py index e4bf671..14952a6 100644 --- a/comanage_api/_sshkeys.py +++ b/comanage_api/_sshkeys.py @@ -37,6 +37,9 @@ import json +# SSH Key plugin base path +_SSH_KEY_PATH = 'ssh_key_authenticator/ssh_keys' + def ssh_keys_add(self, coperson_id: int, ssh_key: str, key_type: str, comment: str = None, ssh_key_authenticator_id: int = None) -> dict: @@ -110,35 +113,24 @@ def ssh_keys_add(self, coperson_id: int, ssh_key: str, key_type: str, comment: s ssh_key_authenticator_id = self._CO_SSH_KEY_AUTHENTICATOR_ID key_type = str(key_type).lower() if key_type not in self.SSH_KEY_OPTIONS: - raise TypeError("Invalid Fields 'key_type'") - post_body = json.dumps({ + raise ValueError("Invalid Fields 'key_type'") + return self._post(f'{_SSH_KEY_PATH}.json', { 'RequestType': 'SshKeys', 'Version': '1.0', - 'SshKeys': - [ - { - 'Version': '1.0', - 'Person': - { - 'Type': 'CO', - 'Id': str(coperson_id) - }, - 'Comment': str(comment), - 'Type': str(key_type), - 'Skey': str(ssh_key), - 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) - } - ] + 'SshKeys': [ + { + 'Version': '1.0', + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) + }, + 'Comment': str(comment) if comment else '', + 'Type': str(key_type), + 'Skey': str(ssh_key), + 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) + } + ] }) - url = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys.json' - resp = self._s.post( - url=url, - data=post_body - ) - if resp.status_code == 201: - return json.loads(resp.text) - else: - resp.raise_for_status() def ssh_keys_delete(self, ssh_key_id: int) -> bool: @@ -157,14 +149,7 @@ def ssh_keys_delete(self, ssh_key_id: int) -> bool: 404 SshKey Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys/' + str(ssh_key_id) + '.json' - resp = self._s.delete( - url=url - ) - if resp.status_code == 200: - return True - else: - resp.raise_for_status() + return self._delete(f'{_SSH_KEY_PATH}/{ssh_key_id}.json') def ssh_keys_edit(self, ssh_key_id: int, coperson_id: int = None, ssh_key: str = None, key_type: str = None, @@ -213,54 +198,31 @@ def ssh_keys_edit(self, ssh_key_id: int, coperson_id: int = None, ssh_key: str = 404 SshKey Unknown id not found 500 Other Error Unknown error """ - sshkey = ssh_keys_view_one(self, ssh_key_id=ssh_key_id) - post_body = { - 'RequestType': 'SshKeys', + existing = ssh_keys_view_one(self, ssh_key_id=ssh_key_id) + existing_key = existing.get('SshKeys')[0] + sshkey_record = { 'Version': '1.0', - 'SshKeys': - [ - { - 'Version': '1.0', - 'Person': - { - 'Type': 'CO' - } - } - ] + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) if coperson_id else str(existing_key.get('Person').get('Id')) + }, + 'Skey': str(ssh_key) if ssh_key else existing_key.get('Skey'), + 'Comment': str(comment) if comment else existing_key.get('Comment'), + 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) if ssh_key_authenticator_id + else str(existing_key.get('SshKeyAuthenticatorId')) } - if coperson_id: - post_body['SshKeys'][0]['Person']['Id'] = str(coperson_id) - else: - post_body['SshKeys'][0]['Person']['Id'] = str(sshkey.get('SshKeys')[0].get('Person').get('Id')) - if ssh_key: - post_body['SshKeys'][0]['Skey'] = str(ssh_key) - else: - post_body['SshKeys'][0]['Skey'] = sshkey.get('SshKeys')[0].get('Skey') if key_type: key_type = str(key_type).lower() if key_type not in self.SSH_KEY_OPTIONS: - raise TypeError("Invalid Fields 'key_type'") - post_body['SshKeys'][0]['Type'] = str(key_type) - else: - post_body['SshKeys'][0]['Type'] = sshkey.get('SshKeys')[0].get('Type') - if comment: - post_body['SshKeys'][0]['Comment'] = str(comment) - else: - post_body['SshKeys'][0]['Comment'] = sshkey.get('SshKeys')[0].get('Comment') - if ssh_key_authenticator_id: - post_body['SshKeys'][0]['SshKeyAuthenticatorId'] = str(ssh_key_authenticator_id) - else: - post_body['SshKeys'][0]['SshKeyAuthenticatorId'] = str(sshkey.get('SshKeys')[0].get('SshKeyAuthenticatorId')) - post_body = json.dumps(post_body) - url = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys/' + str(ssh_key_id) + '.json' - resp = self._s.put( - url=url, - data=post_body - ) - if resp.status_code == 200: - return True + raise ValueError("Invalid Fields 'key_type'") + sshkey_record['Type'] = str(key_type) else: - resp.raise_for_status() + sshkey_record['Type'] = existing_key.get('Type') + return self._put(f'{_SSH_KEY_PATH}/{ssh_key_id}.json', { + 'RequestType': 'SshKeys', + 'Version': '1.0', + 'SshKeys': [sshkey_record] + }) def ssh_keys_view_all(self) -> dict: @@ -299,14 +261,7 @@ def ssh_keys_view_all(self) -> dict: 401 Unauthorized Authentication required 500 Other Error Unknown error """ - url = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'{_SSH_KEY_PATH}.json') def ssh_keys_view_per_coperson(self, coperson_id: int) -> dict: @@ -347,23 +302,17 @@ def ssh_keys_view_per_coperson(self, coperson_id: int) -> dict: 404 SSH Key Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys.json' + url = f"{self._CO_API_URL}/{_SSH_KEY_PATH}.json" params = {'copersonid': str(coperson_id)} - resp = self._s.get( - url=url, - params=params - ) - no_ssh_keys = { - 'RequestType': 'SshKeys', - 'Version': '1.0', - 'SshKeys': [] - } - if resp.status_code == 200: - return json.loads(resp.text) + resp = self._s.get(url=url, params=params) if resp.status_code == 204: - return no_ssh_keys - else: - resp.raise_for_status() + return { + 'RequestType': 'SshKeys', + 'Version': '1.0', + 'SshKeys': [] + } + resp.raise_for_status() + return resp.json() def ssh_keys_view_one(self, ssh_key_id: int) -> dict: @@ -404,11 +353,4 @@ def ssh_keys_view_one(self, ssh_key_id: int) -> dict: 404 SSH Key Unknown id not found 500 Other Error Unknown error """ - url = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys/' + str(ssh_key_id) + '.json' - resp = self._s.get( - url=url - ) - if resp.status_code == 200: - return json.loads(resp.text) - else: - resp.raise_for_status() + return self._get(f'{_SSH_KEY_PATH}/{ssh_key_id}.json') diff --git a/examples/README.md b/examples/README.md index 39c6d61..c7ee548 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,10 @@ # Examples -Examples demonstrating basic usage for each wrapped endpoint. Some of the values used herein are specific to the ImPACT project registry and thus would not work for other registries, but the concept would be the same for any registry. +Examples demonstrating basic usage for each wrapped endpoint. Examples dynamically discover valid IDs from the configured CO at runtime, so they work across different registries without modification. - Example code tested against COmanage v4.0.0 +- Examples use the alpha tier configuration from `.env` (registry-test.cilogon.org) +- Run examples as modules from the project root: `python -m examples.` ## Table of Contents @@ -19,7 +21,7 @@ Examples demonstrating basic usage for each wrapped endpoint. Some of the values ## Configuration -All example presented herein use the same base configuration as defined by the `examples/__init__.py` file +All examples presented herein use the same base configuration as defined by the `examples/__init__.py` file ```python # examples/__init__.py @@ -63,22 +65,18 @@ api = ComanageApi( ## CoOrgIdentityLink API -Example: `co_org_identity_links_example.py` +Example: `coorg_identity_links_example.py` ```console -$ python examples/coorg_identity_links_example.py +$ python -m examples.coorg_identity_links_example ### coorg_identity_links_add -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - coorg_identity_links_add() is not implemented ### coorg_identity_links_delete -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - coorg_identity_links_delete() is not implemented ### coorg_identity_links_edit -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### coorg_identity_links_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry-test.cilogon.org/registry/co_org_identity_links.json +[NOT IMPLEMENTED] NotImplementedError - coorg_identity_links_edit() is not implemented +### discover CO Person ID +Using CO Person ID: ### coorg_identity_links_view_by_identity { "ResponseType": "CoOrgIdentityLinks", @@ -86,14 +84,14 @@ $ python examples/coorg_identity_links_example.py "CoOrgIdentityLinks": [ { "Version": "1.0", - "Id": "44", - "CoPersonId": "163", - "OrgIdentityId": "190", - "Created": "2018-01-24 19:08:47", - "Modified": "2018-01-24 19:08:47", + "Id": "", + "CoPersonId": "", + "OrgIdentityId": "", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "" } ] } @@ -104,14 +102,14 @@ $ python examples/coorg_identity_links_example.py "CoOrgIdentityLinks": [ { "Version": "1.0", - "Id": "44", - "CoPersonId": "163", - "OrgIdentityId": "190", - "Created": "2018-01-24 19:08:47", - "Modified": "2018-01-24 19:08:47", + "Id": "", + "CoPersonId": "", + "OrgIdentityId": "", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "" } ] } @@ -121,103 +119,14 @@ $ python examples/coorg_identity_links_example.py Example: `copeople_example.py` +**NOTE**: This example exits early after `email_addresses_view_per_person`. The unimplemented methods (`copeople_add`, `copeople_delete`, `copeople_edit`, `copeople_find`) and remaining view methods are after `exit(0)` and only run if that line is removed. + ```console -$ python examples/copeople_example.py -### copeople_add -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### copeople_delete -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### copeople_edit -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### copeople_find -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### copeople_match -{ - "ResponseType": "CoPeople", - "Version": "1.0", - "CoPeople": [ - { - "Version": "1.0", - "Id": "1135", - "CoId": "3", - "Status": "Active", - "Created": "2021-03-17 16:03:02", - "Modified": "2021-03-17 16:04:23", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" - } - ] -} -### copeople_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry.cilogon.org/registry/co_people.json -### copeople_view_per_co -{ - "ResponseType": "CoPeople", - "Version": "1.0", - "CoPeople": [ - { - "Version": "1.0", - "Id": "29", - "CoId": "3", - "Status": "Active", - "Created": "2018-11-05 20:48:55", - "Modified": "2018-11-06 03:23:00", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/241998" - }, - { - "Version": "1.0", - "Id": "1135", - "CoId": "3", - "Status": "Active", - "Created": "2021-03-17 16:03:02", - "Modified": "2021-03-17 16:04:23", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" - }, - { - "Version": "1.0", - "Id": "1558", - "CoId": "3", - "Status": "Active", - "Created": "2021-09-10 18:32:32", - "Modified": "2021-09-10 18:33:46", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/227641" - }, - { - "Version": "1.0", - "Id": "1573", - "CoId": "3", - "Status": "Active", - "Created": "2021-09-14 11:06:03", - "Modified": "2021-09-14 11:06:57", - "Revision": "7", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/2604273" - }, - { - "Version": "1.0", - "Id": "1603", - "CoId": "3", - "Status": "Active", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:36:02", - "Revision": "7", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - } - ] -} +$ python -m examples.copeople_example +### discover CO Person ID +Using CO Person ID: +### discover identifier for CO Person +Using identifier: ### copeople_view_per_identifier { "ResponseType": "CoPeople", @@ -225,49 +134,73 @@ $ python examples/copeople_example.py "CoPeople": [ { "Version": "1.0", - "Id": "1135", - "CoId": "3", + "Id": "", + "CoId": "", "Status": "Active", - "Created": "2021-03-17 16:03:02", - "Modified": "2021-03-17 16:04:23", + "Created": "", + "Modified": "", "Revision": "5", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "" } ] } -### copeople_view_one +### email_addresses_view_per_person { - "ResponseType": "CoPeople", + "ResponseType": "EmailAddresses", "Version": "1.0", - "CoPeople": [ + "EmailAddresses": [ { "Version": "1.0", - "Id": "29", - "CoId": "3", - "Status": "Active", - "Created": "2018-11-05 20:48:55", - "Modified": "2018-11-06 03:23:00", - "Revision": "5", + "Id": "", + "Mail": "", + "Type": "official", + "Verified": true, + "Person": { + "Type": "CO", + "Id": "" + }, + "Created": "", + "Modified": "", + "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/241998" + "ActorIdentifier": "" } ] } + +``` + +When run with `exit(0)` removed, the unimplemented methods produce: + +```console +### copeople_add +[NOT IMPLEMENTED] NotImplementedError - copeople_add() is not implemented +### copeople_delete +[NOT IMPLEMENTED] NotImplementedError - copeople_delete() is not implemented +### copeople_edit +[NOT IMPLEMENTED] NotImplementedError - copeople_edit() is not implemented +### copeople_find +[NOT IMPLEMENTED] NotImplementedError - copeople_find() is not implemented ``` ## CoPersonRole API Example: `coperson_roles_example.py` +This example dynamically discovers a valid CO Person ID and COU ID, then performs a full CRUD cycle: add a role, view it, edit it, list roles, and delete it. + ```console -$ python examples/coperson_roles_example.py +$ python -m examples.coperson_roles_example +### discover CO Person ID and COU ID +Using CO Person ID: +Using COU ID: ### coperson_roles_add { "ResponseType": "NewObject", "Version": "1.0", "ObjectType": "CoPersonRole", - "Id": "1727" + "Id": "" } ### coperson_roles_view_one { @@ -276,20 +209,20 @@ $ python examples/coperson_roles_example.py "CoPersonRoles": [ { "Version": "1.0", - "Id": "1727", + "Id": "", "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, - "CouId": "39", + "CouId": "", "Affiliation": "student", - "O": "Impact", + "O": "", "Status": "PendingApproval", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:44", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "co_3.development" + "ActorIdentifier": "" } ] } @@ -302,64 +235,37 @@ True "CoPersonRoles": [ { "Version": "1.0", - "Id": "1727", + "Id": "", "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, - "CouId": "39", + "CouId": "", "Affiliation": "member", - "O": "Impact", + "O": "", "Status": "Active", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:45", + "Created": "", + "Modified": "", "Revision": "1", "Deleted": false, - "ActorIdentifier": "co_3.development" + "ActorIdentifier": "" } ] } ### coperson_roles_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry.cilogon.org/registry/co_person_roles.json +{ + "ResponseType": "CoPersonRoles", + "Version": "1.0", + "CoPersonRoles": [ + ... + ] +} ### coperson_roles_view_per_coperson { "ResponseType": "CoPersonRoles", "Version": "1.0", "CoPersonRoles": [ - { - "Version": "1.0", - "Id": "1648", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "CouId": "39", - "Affiliation": "member", - "Status": "Active", - "Created": "2021-09-15 12:34:47", - "Modified": "2021-09-15 12:36:02", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - }, - { - "Version": "1.0", - "Id": "1727", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "CouId": "39", - "Affiliation": "member", - "O": "Impact", - "Status": "Active", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:45", - "Revision": "1", - "Deleted": false, - "ActorIdentifier": "co_3.development" - } + ... ] } ### coperson_roles_view_per_cou @@ -367,71 +273,7 @@ True "ResponseType": "CoPersonRoles", "Version": "1.0", "CoPersonRoles": [ - { - "Version": "1.0", - "Id": "1622", - "Person": { - "Type": "CO", - "Id": "1558" - }, - "CouId": "39", - "Affiliation": "member", - "Status": "Active", - "Created": "2021-09-10 18:32:32", - "Modified": "2021-09-10 18:33:46", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/227641" - }, - { - "Version": "1.0", - "Id": "1628", - "Person": { - "Type": "CO", - "Id": "1573" - }, - "CouId": "39", - "Affiliation": "member", - "Status": "Active", - "Created": "2021-09-14 11:06:09", - "Modified": "2021-09-14 11:06:57", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/2604273" - }, - { - "Version": "1.0", - "Id": "1648", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "CouId": "39", - "Affiliation": "member", - "Status": "Active", - "Created": "2021-09-15 12:34:47", - "Modified": "2021-09-15 12:36:02", - "Revision": "5", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - }, - { - "Version": "1.0", - "Id": "1727", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "CouId": "39", - "Affiliation": "member", - "O": "Impact", - "Status": "Active", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:45", - "Revision": "1", - "Deleted": false, - "ActorIdentifier": "co_3.development" - } + ... ] } ### coperson_roles_delete @@ -442,21 +284,9 @@ True "Version": "1.0", "CoPersonRoles": [ { - "Version": "1.0", - "Id": "1727", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "CouId": "39", - "Affiliation": "member", - "O": "Impact", - "Status": "Active", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:46", - "Revision": "1", + ... "Deleted": true, - "ActorIdentifier": "co_3.development" + ... } ] } @@ -466,14 +296,16 @@ True Example: `cous_example.py` +This example performs a full CRUD cycle: add a COU, view all, edit it, view one, delete it, and attempt to view the deleted COU. + ```console -$ python examples/cous_example.py +$ python -m examples.cous_example ### cous_add { "ResponseType": "NewObject", "Version": "1.0", "ObjectType": "Cou", - "Id": "105" + "Id": "" } ### cous_view_all { @@ -482,46 +314,27 @@ $ python examples/cous_example.py "Cous": [ { "Version": "1.0", - "Id": "38", - "CoId": "3", - "Name": "enrollment-approval", - "Description": "Enrollment Approval Personnel - can approve or deny new registry members", - "Lft": "66", - "Rght": "67", - "Created": "2021-09-10 14:33:11", - "Modified": "2021-09-10 14:33:11", + "Id": "", + "CoId": "", + "Name": "", + "Description": "", + "Lft": "", + "Rght": "", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "" }, - { - "Version": "1.0", - "Id": "39", - "CoId": "3", - "Name": "impact-users", - "Description": "ImPACT Users - Registering with the ImPACT site will add new user's to this group", - "Lft": "68", - "Rght": "69", - "Created": "2021-09-10 14:44:09", - "Modified": "2021-09-10 14:44:09", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" - }, - { - "Version": "1.0", - "Id": "105", - "CoId": "3", - "Name": "cou test", - "Description": "cou test description", - "Lft": "96", - "Rght": "97", - "Created": "2021-10-01 20:45:33", - "Modified": "2021-10-01 20:45:33", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "co_3.development" - } + ... + ] +} +### cous_view_per_co +{ + "ResponseType": "Cous", + "Version": "1.0", + "Cous": [ + ... ] } ### cous_edit @@ -533,17 +346,17 @@ True "Cous": [ { "Version": "1.0", - "Id": "105", - "CoId": "3", + "Id": "", + "CoId": "", "Name": "cou test - edited", "Description": "cou test description - edited", - "Lft": "96", - "Rght": "97", - "Created": "2021-10-01 20:45:33", - "Modified": "2021-10-01 20:45:34", + "Lft": "", + "Rght": "", + "Created": "", + "Modified": "", "Revision": "1", "Deleted": false, - "ActorIdentifier": "co_3.development" + "ActorIdentifier": "" } ] } @@ -555,18 +368,9 @@ True "Version": "1.0", "Cous": [ { - "Version": "1.0", - "Id": "105", - "CoId": "3", - "Name": "cou test - edited", - "Description": "cou test description - edited", - "Lft": "96", - "Rght": "97", - "Created": "2021-10-01 20:45:33", - "Modified": "2021-10-01 20:45:34", - "Revision": "1", + ... "Deleted": true, - "ActorIdentifier": "co_3.development" + ... } ] } @@ -577,19 +381,15 @@ True Example: `email_addresses_example.py` ```console -$ python examples/email_addresses_example.py +$ python -m examples.email_addresses_example +### discover CO Person ID +Using CO Person ID: ### email_addresses_add -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - email_addresses_add() is not implemented ### email_addresses_delete -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - email_addresses_delete() is not implemented ### email_addresses_edit -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### email_addresses_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry.cilogon.org/registry/email_addresses.json +[NOT IMPLEMENTED] NotImplementedError - email_addresses_edit() is not implemented ### email_addresses_view_per_person { "ResponseType": "EmailAddresses", @@ -597,36 +397,19 @@ $ python examples/email_addresses_example.py "EmailAddresses": [ { "Version": "1.0", - "Id": "810", - "Mail": "mjstealey@gmail.com", + "Id": "", + "Mail": "", "Type": "official", "Verified": true, "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, - "SourceEmailAddressId": "809", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - }, - { - "Version": "1.0", - "Id": "811", - "Mail": "mjstealey@gmail.com", - "Type": "official", - "Verified": true, - "Person": { - "Type": "CO", - "Id": "1603" - }, - "Created": "2021-09-15 12:34:47", - "Modified": "2021-09-15 12:35:22", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "" } ] } @@ -637,20 +420,19 @@ $ python examples/email_addresses_example.py "EmailAddresses": [ { "Version": "1.0", - "Id": "810", - "Mail": "mjstealey@gmail.com", + "Id": "", + "Mail": "", "Type": "official", "Verified": true, "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, - "SourceEmailAddressId": "809", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "" } ] } @@ -661,22 +443,17 @@ $ python examples/email_addresses_example.py Example: `identifiers_example.py` ```console -$ python examples/identifiers_example.py +$ python -m examples.identifiers_example +### discover CO Person ID +Using CO Person ID: ### identifiers_add -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - identifiers_add() is not implemented ### identifiers_assign -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - identifiers_assign() is not implemented ### identifiers_delete -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - identifiers_delete() is not implemented ### identifiers_edit -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### identifiers_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry.cilogon.org/registry/identifiers.json +[NOT IMPLEMENTED] NotImplementedError - identifiers_edit() is not implemented ### identifiers_view_per_entity { "ResponseType": "Identifiers", @@ -684,55 +461,21 @@ $ python examples/identifiers_example.py "Identifiers": [ { "Version": "1.0", - "Id": "1552", - "Identifier": "http://cilogon.org/serverA/users/226066", + "Id": "", + "Identifier": "", "Type": "oidcsub", "Status": "Active", "Person": { "Type": "CO", - "Id": "1603" - }, - "SourceIdentifierId": "1550", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - }, - { - "Version": "1.0", - "Id": "1553", - "Identifier": "http://cilogon.org/serverA/users/226066", - "Type": "sorid", - "Status": "Active", - "Person": { - "Type": "CO", - "Id": "1603" + "Id": "" }, - "SourceIdentifierId": "1551", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "" }, - { - "Version": "1.0", - "Id": "1554", - "Identifier": "ImPACT1000006", - "Type": "impactid", - "Login": false, - "Status": "Active", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "Created": "2021-09-15 12:36:01", - "Modified": "2021-09-15 12:36:01", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" - } + ... ] } ### identifiers_view_one @@ -742,20 +485,19 @@ $ python examples/identifiers_example.py "Identifiers": [ { "Version": "1.0", - "Id": "1552", - "Identifier": "http://cilogon.org/serverA/users/226066", + "Id": "", + "Identifier": "", "Type": "oidcsub", "Status": "Active", "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, - "SourceIdentifierId": "1550", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "" } ] } @@ -766,19 +508,15 @@ $ python examples/identifiers_example.py Example: `names_example.py` ```console -$ python examples/names_example.py +$ python -m examples.names_example +### discover CO Person ID +Using CO Person ID: ### names_add -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - names_add() is not implemented ### names_delete -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - names_delete() is not implemented ### names_edit -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### names_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry.cilogon.org/registry/names.json +[NOT IMPLEMENTED] NotImplementedError - names_edit() is not implemented ### names_view_per_person { "ResponseType": "Names", @@ -786,58 +524,20 @@ $ python examples/names_example.py "Names": [ { "Version": "1.0", - "Id": "923", - "Given": "mj", - "Family": "stealey", - "Type": "official", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "PrimaryName": false, - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:47", - "Revision": "1", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - }, - { - "Version": "1.0", - "Id": "924", - "Given": "mj", - "Family": "stealey", - "Type": "official", - "Person": { - "Type": "CO", - "Id": "1603" - }, - "SourceNameId": "922", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - }, - { - "Version": "1.0", - "Id": "926", - "Honorific": "", - "Given": "mj", - "Middle": "", - "Family": "stealey", - "Suffix": "", + "Id": "", + "Given": "", + "Family": "", "Type": "official", - "Language": "", "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, "PrimaryName": true, - "Created": "2021-09-15 12:34:47", - "Modified": "2021-09-15 12:34:47", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "" } ] } @@ -848,20 +548,20 @@ $ python examples/names_example.py "Names": [ { "Version": "1.0", - "Id": "923", - "Given": "mj", - "Family": "stealey", + "Id": "", + "Given": "", + "Family": "", "Type": "official", "Person": { "Type": "CO", - "Id": "1603" + "Id": "" }, - "PrimaryName": false, - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:47", - "Revision": "1", + "PrimaryName": true, + "Created": "", + "Modified": "", + "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "" } ] } @@ -872,19 +572,13 @@ $ python examples/names_example.py Example: `org_identities_example.py` ```console -$ python examples/org_identities_example.py +$ python -m examples.org_identities_example ### org_identities_add -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - org_identities_add() is not implemented ### org_identities_delete -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local +[NOT IMPLEMENTED] NotImplementedError - org_identities_delete() is not implemented ### org_identities_edit -[ERROR] Exception caught ---> HTTPError - 501 Server Error: Not Implemented for url: mock://not_implemented_501.local -### org_identities_view_all -[ERROR] Exception caught ---> HTTPError - 401 Client Error: Unauthorized for url: https://registry.cilogon.org/registry/org_identities.json +[NOT IMPLEMENTED] NotImplementedError - org_identities_edit() is not implemented ### org_identities_view_per_co { "ResponseType": "OrgIdentities", @@ -892,89 +586,27 @@ $ python examples/org_identities_example.py "OrgIdentities": [ { "Version": "1.0", - "Id": "12", - "Status": "SY", - "Affiliation": "member", - "O": "National Center for Supercomputing Applications", - "CoId": "3", - "Created": "2018-11-05 20:40:19", - "Modified": "2018-11-05 20:40:19", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/37233" - }, - { - "Version": "1.0", - "Id": "13", - "Status": "SY", - "Affiliation": "member", - "O": "University of North Carolina at Chapel Hill", - "CoId": "3", - "Created": "2018-11-05 20:48:47", - "Modified": "2018-11-05 20:48:47", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/241998" - }, - { - "Version": "1.0", - "Id": "321", - "Status": "SY", - "Affiliation": "member", - "O": "University of North Carolina at Chapel Hill", - "CoId": "3", - "Created": "2021-03-17 16:02:48", - "Modified": "2021-03-17 16:02:48", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" - }, - { - "Version": "1.0", - "Id": "418", - "Status": "SY", - "Affiliation": "member", - "O": "University of North Carolina at Chapel Hill", - "CoId": "3", - "Created": "2021-09-10 18:32:22", - "Modified": "2021-09-10 18:32:22", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/227641" - }, - { - "Version": "1.0", - "Id": "430", + "Id": "", "Status": "SY", "Affiliation": "member", - "O": "University of Wisconsin-Madison", - "CoId": "3", - "Created": "2021-09-14 11:06:03", - "Modified": "2021-09-14 11:06:03", + "O": "", + "CoId": "", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/2604273" + "ActorIdentifier": "" }, - { - "Version": "1.0", - "Id": "435", - "Status": "SY", - "Affiliation": "member", - "O": "Google", - "CoId": "3", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" - } + ... ] } ### org_identities_view_per_identifier { "ResponseType": "OrgIdentities", "Version": "1.0", - "OrgIdentities": [] + "OrgIdentities": [ + ... + ] } ### org_identities_view_one { @@ -983,16 +615,16 @@ $ python examples/org_identities_example.py "OrgIdentities": [ { "Version": "1.0", - "Id": "12", + "Id": "", "Status": "SY", "Affiliation": "member", - "O": "National Center for Supercomputing Applications", - "CoId": "3", - "Created": "2018-11-05 20:40:19", - "Modified": "2018-11-05 20:40:19", + "O": "", + "CoId": "", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/37233" + "ActorIdentifier": "" } ] } @@ -1000,17 +632,20 @@ $ python examples/org_identities_example.py ## SshKey API +Example: `ssh_keys_example.py` -Example: `ssh_keys_example.py ` +This example dynamically discovers a CO Person ID, then performs a full CRUD cycle: add an SSH key, view all, view per person, view one, edit it, and delete it. ```console -$ python examples/ssh_keys_example.py +$ python -m examples.ssh_keys_example +### discover CO Person ID +Using CO Person ID: ### ssh_keys_add { "ResponseType": "NewObject", "Version": "1.0", "ObjectType": "SshKey", - "Id": "38" + "Id": "" } ### ssh_keys_view_all [ERROR] Exception caught @@ -1022,37 +657,20 @@ $ python examples/ssh_keys_example.py "SshKeys": [ { "Version": "1.0", - "Id": "36", - "Person": { - "Type": "CO", - "Id": "163" - }, - "Comment": "SshKey API test", - "Type": "ssh-rsa", - "Skey": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqlE3to9rJzLb5pUldEEeFi9gYlrIQ7WGFVvx4azWY95+nN8DkOukaK6IMnXP8t0icCWKN4ib6Q5Avea99HD8LQtsmxQjDIgwB/McX3cjXzwB6y8InEBB213bD6koHnsf/fELTTFt6MkJdNUbqOGFvHSUnN6BPUGQ42jXqPw6wVXzOR5nUX9bLc4uPS8moMVXWWK+lG7odGPXHju8AP/6gdjuRaFJnYE3OYoLNbEDnn6cneTtnz5AuQW0KBocc56MyOelNSzxoz/XcNvZH/Hp7wPAJNZhmN6/futZBjG0AzIBHs/J9JXszxq4FO3M4oqg0G+UgFQccXXi1afkJxu7z", - "Created": "2021-10-18 15:04:22", - "Modified": "2021-10-18 15:04:22", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "co_6.impact-development", - "SshKeyAuthenticatorId": "7" - }, - { - "Version": "1.0", - "Id": "38", + "Id": "", "Person": { "Type": "CO", - "Id": "163" + "Id": "" }, "Comment": "SshKey API test", "Type": "ssh-rsa", - "Skey": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqlE3to9rJzLb5pUldEEeFi9gYlrIQ7WGFVvx4azWY95+nN8DkOukaK6IMnXP8t0icCWKN4ib6Q5Avea99HD8LQtsmxQjDIgwB/McX3cjXzwB6y8InEBB213bD6koHnsf/fELTTFt6MkJdNUbqOGFvHSUnN6BPUGQ42jXqPw6wVXzOR5nUX9bLc4uPS8moMVXWWK+lG7odGPXHju8AP/6gdjuRaFJnYE3OYoLNbEDnn6cneTtnz5AuQW0KBocc56MyOelNSzxoz/XcNvZH/Hp7wPAJNZhmN6/futZBjG0AzIBHs/J9JXszxq4FO3M4oqg0G+UgFQccXXi1afkJxu7z", - "Created": "2021-10-18 15:07:07", - "Modified": "2021-10-18 15:07:07", + "Skey": "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + "Created": "", + "Modified": "", "Revision": "0", "Deleted": false, - "ActorIdentifier": "co_6.impact-development", - "SshKeyAuthenticatorId": "7" + "ActorIdentifier": "", + "SshKeyAuthenticatorId": "" } ] } @@ -1062,21 +680,9 @@ $ python examples/ssh_keys_example.py "Version": "1.0", "SshKeys": [ { - "Version": "1.0", - "Id": "36", - "Person": { - "Type": "CO", - "Id": "163" - }, + ... "Comment": "SshKey API test", - "Type": "ssh-rsa", - "Skey": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqlE3to9rJzLb5pUldEEeFi9gYlrIQ7WGFVvx4azWY95+nN8DkOukaK6IMnXP8t0icCWKN4ib6Q5Avea99HD8LQtsmxQjDIgwB/McX3cjXzwB6y8InEBB213bD6koHnsf/fELTTFt6MkJdNUbqOGFvHSUnN6BPUGQ42jXqPw6wVXzOR5nUX9bLc4uPS8moMVXWWK+lG7odGPXHju8AP/6gdjuRaFJnYE3OYoLNbEDnn6cneTtnz5AuQW0KBocc56MyOelNSzxoz/XcNvZH/Hp7wPAJNZhmN6/futZBjG0AzIBHs/J9JXszxq4FO3M4oqg0G+UgFQccXXi1afkJxu7z", - "Created": "2021-10-18 15:04:22", - "Modified": "2021-10-18 15:04:22", - "Revision": "0", - "Deleted": false, - "ActorIdentifier": "co_6.impact-development", - "SshKeyAuthenticatorId": "7" + ... } ] } @@ -1088,48 +694,18 @@ True "Version": "1.0", "SshKeys": [ { - "Version": "1.0", - "Id": "36", - "Person": { - "Type": "CO", - "Id": "163" - }, + ... "Comment": "NEW COMMENT", - "Type": "ssh-rsa", - "Skey": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqlE3to9rJzLb5pUldEEeFi9gYlrIQ7WGFVvx4azWY95+nN8DkOukaK6IMnXP8t0icCWKN4ib6Q5Avea99HD8LQtsmxQjDIgwB/McX3cjXzwB6y8InEBB213bD6koHnsf/fELTTFt6MkJdNUbqOGFvHSUnN6BPUGQ42jXqPw6wVXzOR5nUX9bLc4uPS8moMVXWWK+lG7odGPXHju8AP/6gdjuRaFJnYE3OYoLNbEDnn6cneTtnz5AuQW0KBocc56MyOelNSzxoz/XcNvZH/Hp7wPAJNZhmN6/futZBjG0AzIBHs/J9JXszxq4FO3M4oqg0G+UgFQccXXi1afkJxu7z", - "Created": "2021-10-18 15:04:22", - "Modified": "2021-10-18 15:07:08", "Revision": "1", - "Deleted": false, - "ActorIdentifier": "co_6.impact-development", - "SshKeyAuthenticatorId": "7" + ... } ] } ### ssh_keys_delete True ### ssh_keys_view_one (previously deleted ssh key) -{ - "ResponseType": "SshKeys", - "Version": "1.0", - "SshKeys": [ - { - "Version": "1.0", - "Id": "35", - "Person": { - "Type": "CO", - "Id": "163" - }, - "Comment": "NEW COMMENT", - "Type": "ssh-rsa", - "Skey": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqlE3to9rJzLb5pUldEEeFi9gYlrIQ7WGFVvx4azWY95+nN8DkOukaK6IMnXP8t0icCWKN4ib6Q5Avea99HD8LQtsmxQjDIgwB/McX3cjXzwB6y8InEBB213bD6koHnsf/fELTTFt6MkJdNUbqOGFvHSUnN6BPUGQ42jXqPw6wVXzOR5nUX9bLc4uPS8moMVXWWK+lG7odGPXHju8AP/6gdjuRaFJnYE3OYoLNbEDnn6cneTtnz5AuQW0KBocc56MyOelNSzxoz/XcNvZH/Hp7wPAJNZhmN6/futZBjG0AzIBHs/J9JXszxq4FO3M4oqg0G+UgFQccXXi1afkJxu7z", - "Created": "2021-10-18 15:00:08", - "Modified": "2021-10-18 15:04:24", - "Revision": "1", - "Deleted": true, - "ActorIdentifier": "co_6.impact-development", - "SshKeyAuthenticatorId": "7" - } - ] -} +[ERROR] Exception caught +--> HTTPError - 404 Client Error: Not Found for url: ... ``` + +**NOTE**: `ssh_keys_view_all` returns 401 because the endpoint requires specific authorization beyond the API user credentials. Use `ssh_keys_view_per_coperson` to retrieve keys for a specific person. diff --git a/examples/coorg_identity_links_example.py b/examples/coorg_identity_links_example.py index 52ff2bb..3ea5ce5 100644 --- a/examples/coorg_identity_links_example.py +++ b/examples/coorg_identity_links_example.py @@ -9,58 +9,52 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -IDENTITY_TYPE = 'orgidentityid' -IDENTITY_ID = 190 - -# coorg_identity_links_add, coorg_identity_links_delete, coorg_identity_links_edit, \ -# coorg_identity_links_view_all, coorg_identity_links_view_by_identity, coorg_identity_links_view_one - # coorg_identity_links_add() -> dict print('### coorg_identity_links_add') try: new_coorg_identity_link = api.coorg_identity_links_add() print(json.dumps(new_coorg_identity_link, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # coorg_identity_links_delete() -> bool print('### coorg_identity_links_delete') try: delete_coorg_identity_link = api.coorg_identity_links_delete() print(json.dumps(delete_coorg_identity_link, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # coorg_identity_links_edit() -> bool print('### coorg_identity_links_edit') try: edit_coorg_identity_link = api.coorg_identity_links_edit() print(json.dumps(edit_coorg_identity_link, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) -# coorg_identity_links_view_all() -> dict -print('### coorg_identity_links_view_all') +# dynamically discover a valid CO Person ID for view_by_identity +print('### discover CO Person ID') try: - all_coorg_identity_links = api.coorg_identity_links_view_all() - print(json.dumps(all_coorg_identity_links, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') + per_co_copeople = api.copeople_view_per_co() + CO_PERSON_ID = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(CO_PERSON_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') print('--> ', type(err).__name__, '-', err) + CO_PERSON_ID = None # coorg_identity_links_view_by_identity(self, identity_type: str, identity_id: int) -> dict print('### coorg_identity_links_view_by_identity') try: + IDENTITY_ID = CO_PERSON_ID + IDENTITY_TYPE = 'copersonid' per_identity_coorg_identity_links = api.coorg_identity_links_view_by_identity( identity_type=IDENTITY_TYPE, identity_id=IDENTITY_ID ) print(json.dumps(per_identity_coorg_identity_links, indent=4)) -except (TypeError, HTTPError) as err: +except (NameError, KeyError, IndexError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -69,8 +63,8 @@ try: # get first CoOrgIdentityLinks['Id'] from per_identity_coorg_identity_links response coorg_identity_link_id = int(per_identity_coorg_identity_links['CoOrgIdentityLinks'][0]['Id']) - one_email_address = api.coorg_identity_links_view_one(coorg_identity_link_id=coorg_identity_link_id) - print(json.dumps(one_email_address, indent=4)) -except (NameError, KeyError, IndexError, TypeError, HTTPError) as err: + one_coorg_identity_link = api.coorg_identity_links_view_one(coorg_identity_link_id=coorg_identity_link_id) + print(json.dumps(one_coorg_identity_link, indent=4)) +except (NameError, KeyError, IndexError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/examples/copeople_example.py b/examples/copeople_example.py index e9cc485..6a82178 100644 --- a/examples/copeople_example.py +++ b/examples/copeople_example.py @@ -9,22 +9,42 @@ ) from examples import * +# dynamically discover a valid CO Person ID and identifier from the CO +print('### discover CO Person ID') +try: + per_co_copeople = api.copeople_view_per_co() + co_person_id = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(co_person_id)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') + print('--> ', type(err).__name__, '-', err) + co_person_id = None + +# get identifier for the discovered CO Person +print('### discover identifier for CO Person') +try: + person_identifiers = api.identifiers_view_per_entity( + entity_type='copersonid', + entity_id=co_person_id + ) + identifier = person_identifiers['Identifiers'][0]['Identifier'] + print('Using identifier: ' + str(identifier)) +except (NameError, KeyError, IndexError, ValueError, HTTPError) as err: + print('[ERROR] Could not discover an identifier') + print('--> ', type(err).__name__, '-', err) + identifier = None + # copeople_view_per_identifier(identifier: str, distinct_by_id: bool = True) -> dict print('### copeople_view_per_identifier') try: - identifier = 'http://cilogon.org/serverE/users/109379' - distinct_by_id = True identifier_copeople = api.copeople_view_per_identifier( identifier=identifier, distinct_by_id=True ) print(json.dumps(identifier_copeople, indent=4)) - co_person_id = identifier_copeople.get('CoPeople')[0].get('Id') - print(co_person_id) -except HTTPError as err: +except (HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) - co_person_id = 0 # email_addresses_view_per_person(person_type: str, person_id: int) -> dict: print('### email_addresses_view_per_person') @@ -36,7 +56,7 @@ print(json.dumps(per_person_email_addresses, indent=4)) email_address = per_person_email_addresses.get('EmailAddresses')[0].get('Mail') print(email_address) -except (TypeError, HTTPError) as err: +except (KeyError, IndexError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) email_address = None @@ -50,36 +70,32 @@ try: new_copeople = api.copeople_add() print(json.dumps(new_copeople, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # copeople_delete() -> bool print('### copeople_delete') try: delete_copeople = api.copeople_delete() print(json.dumps(delete_copeople, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # copeople_edit() -> bool print('### copeople_edit') try: edit_copeople = api.copeople_edit() print(json.dumps(edit_copeople, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # copeople_find() -> dict print('### copeople_find') try: find_copeople = api.copeople_find() print(json.dumps(find_copeople, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # copeople_match(given: str = None, family: str = None, mail: str = None, distinct_by_id: bool = True) -> dict print('### copeople_match') diff --git a/examples/coperson_roles_example.py b/examples/coperson_roles_example.py index f8bb144..f752b83 100644 --- a/examples/coperson_roles_example.py +++ b/examples/coperson_roles_example.py @@ -1,4 +1,4 @@ -# examples/copersone_roles_example.py +# examples/coperson_roles_example.py # CoPersonRoles API examples import os @@ -9,9 +9,25 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -CO_PERSON_ID = 163 -COU_ID = 28 +# dynamically discover a valid CO Person ID and COU ID from the CO +print('### discover CO Person ID and COU ID') +try: + per_co_copeople = api.copeople_view_per_co() + CO_PERSON_ID = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(CO_PERSON_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') + print('--> ', type(err).__name__, '-', err) + CO_PERSON_ID = None + +try: + cous_per_co = api.cous_view_per_co() + COU_ID = int(cous_per_co['Cous'][0]['Id']) + print('Using COU ID: ' + str(COU_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a COU ID') + print('--> ', type(err).__name__, '-', err) + COU_ID = None # # coperson_roles_add(coperson_id: int, cou_id: int, status: str = None, affiliation: str = None) -> dict print('### coperson_roles_add') @@ -27,7 +43,7 @@ affiliation=affiliation ) print(json.dumps(roles_add, indent=4)) -except HTTPError as err: +except (ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -37,7 +53,7 @@ coperson_role_id = roles_add.get('Id') roles_view_one = api.coperson_roles_view_one(coperson_role_id=coperson_role_id) print(json.dumps(roles_view_one, indent=4)) -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -55,7 +71,7 @@ affiliation=affiliation ) print(roles_edit) -except HTTPError as err: +except (NameError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -64,7 +80,7 @@ try: roles_view_one = api.coperson_roles_view_one(coperson_role_id=coperson_role_id) print(json.dumps(roles_view_one, indent=4)) -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -82,7 +98,7 @@ try: roles_view_per_coperson = api.coperson_roles_view_per_coperson(coperson_id=coperson_id) print(json.dumps(roles_view_per_coperson, indent=4)) -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -91,7 +107,7 @@ try: roles_view_per_cou = api.coperson_roles_view_per_cou(cou_id=cou_id) print(json.dumps(roles_view_per_cou, indent=4)) -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -100,7 +116,7 @@ try: roles_delete = api.coperson_roles_delete(coperson_role_id=coperson_role_id) print(roles_delete) -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -109,6 +125,6 @@ try: roles_view_one = api.coperson_roles_view_one(coperson_role_id=coperson_role_id) print(json.dumps(roles_view_one, indent=4)) -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/examples/email_addresses_example.py b/examples/email_addresses_example.py index c200805..9c9d925 100644 --- a/examples/email_addresses_example.py +++ b/examples/email_addresses_example.py @@ -9,44 +9,40 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -CO_PERSON_ID = 163 +# dynamically discover a valid CO Person ID from the CO +print('### discover CO Person ID') +try: + per_co_copeople = api.copeople_view_per_co() + CO_PERSON_ID = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(CO_PERSON_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') + print('--> ', type(err).__name__, '-', err) + CO_PERSON_ID = None # email_addresses_add() -> dict print('### email_addresses_add') try: new_email_address = api.email_addresses_add() print(json.dumps(new_email_address, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # email_addresses_delete() -> bool print('### email_addresses_delete') try: delete_email_address = api.email_addresses_delete() print(json.dumps(delete_email_address, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # email_addresses_edit() -> bool print('### email_addresses_edit') try: edit_email_address = api.email_addresses_edit() print(json.dumps(edit_email_address, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) - -# email_addresses_view_all() -> dict -print('### email_addresses_view_all') -try: - all_email_addresses = api.email_addresses_view_all() - print(json.dumps(all_email_addresses, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # email_addresses_view_per_person(person_type: str, person_id: int) -> dict: print('### email_addresses_view_per_person') @@ -56,7 +52,7 @@ person_id=CO_PERSON_ID ) print(json.dumps(per_person_email_addresses, indent=4)) -except (TypeError, HTTPError) as err: +except (ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -67,6 +63,6 @@ email_address_id = int(per_person_email_addresses['EmailAddresses'][0]['Id']) one_email_address = api.email_addresses_view_one(email_address_id=email_address_id) print(json.dumps(one_email_address, indent=4)) -except (NameError, KeyError, IndexError, TypeError, HTTPError) as err: +except (NameError, KeyError, IndexError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/examples/identifiers_example.py b/examples/identifiers_example.py index 978521e..502d415 100644 --- a/examples/identifiers_example.py +++ b/examples/identifiers_example.py @@ -9,53 +9,48 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -CO_PERSON_ID = 163 +# dynamically discover a valid CO Person ID from the CO +print('### discover CO Person ID') +try: + per_co_copeople = api.copeople_view_per_co() + CO_PERSON_ID = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(CO_PERSON_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') + print('--> ', type(err).__name__, '-', err) + CO_PERSON_ID = None # identifiers_add() -> dict print('### identifiers_add') try: new_identifier = api.identifiers_add() print(json.dumps(new_identifier, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # identifiers_assign() -> bool print('### identifiers_assign') try: assign_identifier = api.identifiers_assign() print(json.dumps(assign_identifier, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # identifiers_delete() -> bool print('### identifiers_delete') try: delete_identifier = api.identifiers_delete() print(json.dumps(delete_identifier, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # identifiers_edit() -> bool print('### identifiers_edit') try: edit_identifier = api.identifiers_edit() print(json.dumps(edit_identifier, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) - -# identifiers_view_all() -> dict -print('### identifiers_view_all') -try: - all_identifiers = api.identifiers_view_all() - print(json.dumps(all_identifiers, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # identifiers_view_per_entity(entity_type: str, entity_id: int) -> dict: print('### identifiers_view_per_entity') @@ -65,7 +60,7 @@ entity_id=CO_PERSON_ID ) print(json.dumps(entity_identifiers, indent=4)) -except (TypeError, HTTPError) as err: +except (ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -76,6 +71,6 @@ identifier_id = int(entity_identifiers['Identifiers'][0]['Id']) one_identifier = api.identifiers_view_one(identifier_id=identifier_id) print(json.dumps(one_identifier, indent=4)) -except (NameError, KeyError, IndexError, TypeError, HTTPError) as err: +except (NameError, KeyError, IndexError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/examples/names_example.py b/examples/names_example.py index d63cabf..6bfe7fe 100644 --- a/examples/names_example.py +++ b/examples/names_example.py @@ -9,44 +9,40 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -CO_PERSON_ID = 163 +# dynamically discover a valid CO Person ID from the CO +print('### discover CO Person ID') +try: + per_co_copeople = api.copeople_view_per_co() + CO_PERSON_ID = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(CO_PERSON_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') + print('--> ', type(err).__name__, '-', err) + CO_PERSON_ID = None # names_add() -> dict print('### names_add') try: new_name = api.names_add() print(json.dumps(new_name, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # names_delete() -> bool print('### names_delete') try: delete_name = api.names_delete() print(json.dumps(delete_name, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # names_edit() -> bool print('### names_edit') try: edit_name = api.names_edit() print(json.dumps(edit_name, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) - -# names_view_all() -> dict -print('### names_view_all') -try: - all_names = api.names_view_all() - print(json.dumps(all_names, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # names_view_per_person(person_type: str, person_id: int) -> dict print('### names_view_per_person') @@ -56,7 +52,7 @@ person_id=CO_PERSON_ID ) print(json.dumps(person_names, indent=4)) -except (TypeError, HTTPError) as err: +except (ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -66,6 +62,6 @@ name_id = int(person_names['Names'][0]['Id']) one_name = api.names_view_one(name_id=name_id) print(json.dumps(one_name, indent=4)) -except (NameError, KeyError, IndexError, TypeError, HTTPError) as err: +except (NameError, KeyError, IndexError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/examples/org_identities_example.py b/examples/org_identities_example.py index 110c36d..2466ac4 100644 --- a/examples/org_identities_example.py +++ b/examples/org_identities_example.py @@ -9,44 +9,29 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -ORG_IDENDIFIER_ID = 1234 - # org_identities_add() -> dict print('### org_identities_add') try: new_org_identity = api.org_identities_add() print(json.dumps(new_org_identity, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # org_identities_delete() -> bool print('### org_identities_delete') try: delete_org_identity = api.org_identities_delete() print(json.dumps(delete_org_identity, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # org_identities_edit() -> bool print('### org_identities_edit') try: edit_org_identity = api.org_identities_edit() print(json.dumps(edit_org_identity, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) - -# org_identities_view_all() -> dict -print('### org_identities_view_all') -try: - all_org_identities = api.org_identities_view_all() - print(json.dumps(all_org_identities, indent=4)) -except HTTPError as err: - print('[ERROR] Exception caught') - print('--> ', type(err).__name__, '-', err) +except NotImplementedError as err: + print('[NOT IMPLEMENTED] ', type(err).__name__, '-', err) # org_identities_view_per_co() -> dict print('### org_identities_view_per_co') @@ -57,24 +42,26 @@ print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) -# org_identities_view_per_identitifer(identifier_id: int) -> dict: +# dynamically discover identifier_id from per_co_org_identities print('### org_identities_view_per_identifier') try: + # use the first OrgIdentity ID as the identifier_id + ORG_IDENTIFIER_ID = int(per_co_org_identities['OrgIdentities'][0]['Id']) per_identifier_org_identities = api.org_identities_view_per_identifier( - identifier_id=ORG_IDENDIFIER_ID + identifier_id=ORG_IDENTIFIER_ID ) print(json.dumps(per_identifier_org_identities, indent=4)) -except (TypeError, HTTPError) as err: +except (NameError, KeyError, IndexError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) # org_identities_view_one(org_identity_id: int) -> dict print('### org_identities_view_one') try: - # get first OrgIdentities['Id'] from per_identifier_org_identities response + # get first OrgIdentities['Id'] from per_co_org_identities response org_identity_id = int(per_co_org_identities['OrgIdentities'][0]['Id']) - one_email_address = api.org_identities_view_one(org_identity_id=org_identity_id) - print(json.dumps(one_email_address, indent=4)) -except (NameError, KeyError, IndexError, TypeError, HTTPError) as err: + one_org_identity = api.org_identities_view_one(org_identity_id=org_identity_id) + print(json.dumps(one_org_identity, indent=4)) +except (NameError, KeyError, IndexError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/examples/ssh_keys_example.py b/examples/ssh_keys_example.py index beeb7fb..b22d9de 100644 --- a/examples/ssh_keys_example.py +++ b/examples/ssh_keys_example.py @@ -9,9 +9,17 @@ ) from examples import * -# must be set ahead of time and be valid within the CO -CO_PERSON_ID = 163 -PREV_DELETED_KEY_ID = 35 +# dynamically discover a valid CO Person ID from the CO +print('### discover CO Person ID') +try: + per_co_copeople = api.copeople_view_per_co() + CO_PERSON_ID = int(per_co_copeople['CoPeople'][0]['Id']) + print('Using CO Person ID: ' + str(CO_PERSON_ID)) +except (KeyError, IndexError, HTTPError) as err: + print('[ERROR] Could not discover a CO Person ID') + print('--> ', type(err).__name__, '-', err) + CO_PERSON_ID = None + EX_SSH_KEY = 'AAAAB3NzaC1yc2EAAAADAQABAAABAQCqlE3to9rJzLb5pUldEEeFi9gYlrIQ7WGFVvx4azWY95+nN8DkOukaK6' \ 'IMnXP8t0icCWKN4ib6Q5Avea99HD8LQtsmxQjDIgwB/McX3cjXzwB6y8InEBB213bD6koHnsf/fELTTFt6MkJd' \ 'NUbqOGFvHSUnN6BPUGQ42jXqPw6wVXzOR5nUX9bLc4uPS8moMVXWWK+lG7odGPXHju8AP/6gdjuRaFJnYE3OYo' \ @@ -35,7 +43,7 @@ comment=comment ) print(json.dumps(new_key, indent=4)) -except HTTPError as err: +except (ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -70,7 +78,7 @@ else: print('No SSH Keys Found...') ssh_key_id = -1 -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -88,7 +96,7 @@ print(edit_key) else: print('No SSH Keys Found...') -except HTTPError as err: +except (NameError, ValueError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -100,7 +108,7 @@ print(json.dumps(one_key, indent=4)) else: print('No SSH Keys Found...') -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) @@ -116,20 +124,18 @@ print(delete_key) else: print('No SSH Keys Found...') -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) # ssh_keys_view_one(ssh_key_id: int) -> dict print('### ssh_keys_view_one (previously deleted ssh key)') try: - # use known previously deleted key (demo purposes only) - ssh_key_id = PREV_DELETED_KEY_ID if ssh_key_id != -1: one_key = api.ssh_keys_view_one(ssh_key_id=ssh_key_id) print(json.dumps(one_key, indent=4)) else: print('No SSH Keys Found...') -except HTTPError as err: +except (NameError, HTTPError) as err: print('[ERROR] Exception caught') print('--> ', type(err).__name__, '-', err) diff --git a/pyproject.toml b/pyproject.toml index 8d91941..ef68430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,44 @@ [build-system] -requires = [ - "setuptools", - "wheel" -] +requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.build_meta" + +[project] +name = "fabric-comanage-api" +description = "Fabric COmanage API" +dynamic = ["version"] +readme = "README.md" +license = "MIT" +requires-python = ">=3.9" +authors = [ + { name = "Michael J. Stealey", email = "michael.j.stealey@gmail.com" }, +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", +] +dependencies = [ + "requests>=2.25.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "requests-mock", + "python-dotenv", +] + +[project.urls] +Homepage = "https://github.com/fabric-testbed/python-comanage-api" +Repository = "https://github.com/fabric-testbed/python-comanage-api" + +[tool.setuptools.dynamic] +version = { attr = "comanage_api.__VERSION__" } + +[tool.setuptools.packages.find] +include = ["comanage_api*"] +exclude = ["examples*", "tests*"] diff --git a/setup.cfg b/setup.cfg index b7e37b4..d14ab65 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,5 +18,4 @@ classifiers = packages = find: install_requires = requests - requests-mock python_requires = >=3.6 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e66dce8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,431 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fabric-comanage-api" +source = { editable = "." } +dependencies = [ + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'" }, + { name = "python-dotenv", marker = "extra == 'dev'" }, + { name = "requests", specifier = ">=2.25.0" }, + { name = "requests-mock", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[package]] +name = "idna" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-mock" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] From 2e85437e6b72c8b656bd0ae6b8aa77474950d39e Mon Sep 17 00:00:00 2001 From: "Michael J. Stealey" Date: Tue, 21 Apr 2026 19:54:45 -0400 Subject: [PATCH 2/5] error handling, robustness, and testing --- README.md | 15 +++- comanage_api/__init__.py | 55 ++++++++++-- comanage_api/_sshkeys.py | 7 +- pyproject.toml | 3 + tests/conftest.py | 35 ++++++++ tests/test_api.py | 135 +++++++++++++++++++++++++++++ tests/test_coorgidentitylinks.py | 75 ++++++++++++++++ tests/test_copeople.py | 121 ++++++++++++++++++++++++++ tests/test_copersonroles.py | 124 ++++++++++++++++++++++++++ tests/test_cous.py | 144 +++++++++++++++++++++++++++++++ tests/test_emailaddresses.py | 77 +++++++++++++++++ tests/test_identifiers.py | 82 ++++++++++++++++++ tests/test_names.py | 73 ++++++++++++++++ tests/test_orgidentities.py | 69 +++++++++++++++ tests/test_sshkeys.py | 142 ++++++++++++++++++++++++++++++ 15 files changed, 1148 insertions(+), 9 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_coorgidentitylinks.py create mode 100644 tests/test_copeople.py create mode 100644 tests/test_copersonroles.py create mode 100644 tests/test_cous.py create mode 100644 tests/test_emailaddresses.py create mode 100644 tests/test_identifiers.py create mode 100644 tests/test_names.py create mode 100644 tests/test_orgidentities.py create mode 100644 tests/test_sshkeys.py diff --git a/README.md b/README.md index acfb788..852251c 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,20 @@ api = ComanageApi( co_api_pass=COMANAGE_API_PASS, co_api_org_id=COMANAGE_API_CO_ID, co_api_org_name=COMANAGE_API_CO_NAME, - co_ssh_key_authenticator_id=COMANAGE_API_SSH_KEY_AUTHENTICATOR_ID + co_ssh_key_authenticator_id=COMANAGE_API_SSH_KEY_AUTHENTICATOR_ID, + timeout=30 # optional, HTTP request timeout in seconds (default: 30) ) ``` +**Built-in robustness:** All HTTP requests include a configurable timeout (default 30s) and automatic retry with exponential backoff on transient failures (429, 500, 502, 503, 504). + +**Logging:** The library uses Python's standard `logging` module under the `comanage_api` logger. No output is produced by default. To enable: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + Get some data! (example using `cous_view_per_co()` which retrieves all COUs attached to a given CO) ```python @@ -57,7 +67,8 @@ Get some data! (example using `cous_view_per_co()` which retrieves all COUs atta ... co_api_pass='xxxx-xxxx-xxxx-xxxx', ... co_api_org_id='123', ... co_api_org_name='RegistryName', -... co_ssh_key_authenticator_id='123' +... co_ssh_key_authenticator_id='123', +... timeout=30 ... ) >>> >>> cous = api.cous_view_per_co() diff --git a/comanage_api/__init__.py b/comanage_api/__init__.py index 155d167..ba5bba8 100644 --- a/comanage_api/__init__.py +++ b/comanage_api/__init__.py @@ -1,6 +1,9 @@ import json +import logging from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry from ._coorgidentitylinks import coorg_identity_links_add, coorg_identity_links_delete, coorg_identity_links_edit, \ coorg_identity_links_view_all, coorg_identity_links_view_by_identity, coorg_identity_links_view_one @@ -22,6 +25,11 @@ # fabric-comanage-api version __VERSION__ = "0.1.5" +# Library logging: NullHandler prevents "last resort" output for callers +# who don't configure logging. Callers who want logs should add their own +# handler to the 'comanage_api' logger. +logging.getLogger(__name__).addHandler(logging.NullHandler()) + class ComanageApi(object): """ @@ -45,10 +53,14 @@ class ComanageApi(object): COmanage Org Name (required) co_ssh_key_authenticator_id: int = None SSH Authenticator Plugin ID (optional) + timeout: int = 30 + HTTP request timeout in seconds (optional, default 30) """ + _log = logging.getLogger(__name__) + def __init__(self, co_api_url: str, co_api_user: str, co_api_pass: str, co_api_org_id: int, - co_api_org_name: str, co_ssh_key_authenticator_id: int = None): + co_api_org_name: str, co_ssh_key_authenticator_id: int = None, timeout: int = 30): # COmanage API user and pass self._CO_API_USER = str(co_api_user) self._CO_API_PASS = str(co_api_pass) @@ -65,6 +77,8 @@ def __init__(self, co_api_url: str, co_api_user: str, co_api_pass: str, co_api_o self._CO_SSH_KEY_AUTHENTICATOR_ID = int(co_ssh_key_authenticator_id) else: self._CO_SSH_KEY_AUTHENTICATOR_ID = 0 + # HTTP request timeout + self._timeout = timeout # Status Type options self.STATUS_OPTIONS = ['Active', 'Approved', 'Confirmed', 'Declined', 'Deleted', 'Denied', 'Duplicate', 'Expired', @@ -81,30 +95,59 @@ def __init__(self, co_api_url: str, co_api_user: str, co_api_pass: str, co_api_o # SSH Key Type options self.SSH_KEY_OPTIONS = ['ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'ssh-rsa', 'ssh-rsa1'] - # create comanage_api session + # create comanage_api session with retry logic + retry = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=['GET', 'POST', 'PUT', 'DELETE'], + ) + adapter = HTTPAdapter(max_retries=retry) self._s = Session() + self._s.mount('https://', adapter) + self._s.mount('http://', adapter) self._s.headers = {'Content-Type': 'application/json'} self._s.auth = (self._CO_API_USER, self._CO_API_PASS) # HTTP helpers def _get(self, path: str, params: dict = None) -> dict: - resp = self._s.get(f"{self._CO_API_URL}/{path}", params=params) + url = f"{self._CO_API_URL}/{path}" + self._log.debug('GET %s params=%s', url, params) + resp = self._s.get(url, params=params, timeout=self._timeout) + if not resp.ok: + self._log.warning('GET %s returned %s', url, resp.status_code) resp.raise_for_status() + self._log.info('GET %s OK', url) return resp.json() def _post(self, path: str, data: dict) -> dict: - resp = self._s.post(f"{self._CO_API_URL}/{path}", data=json.dumps(data)) + url = f"{self._CO_API_URL}/{path}" + self._log.debug('POST %s', url) + resp = self._s.post(url, data=json.dumps(data), timeout=self._timeout) + if not resp.ok: + self._log.warning('POST %s returned %s', url, resp.status_code) resp.raise_for_status() + self._log.info('POST %s OK (%s)', url, resp.status_code) return resp.json() def _put(self, path: str, data: dict) -> bool: - resp = self._s.put(f"{self._CO_API_URL}/{path}", data=json.dumps(data)) + url = f"{self._CO_API_URL}/{path}" + self._log.debug('PUT %s', url) + resp = self._s.put(url, data=json.dumps(data), timeout=self._timeout) + if not resp.ok: + self._log.warning('PUT %s returned %s', url, resp.status_code) resp.raise_for_status() + self._log.info('PUT %s OK', url) return True def _delete(self, path: str, params: dict = None) -> bool: - resp = self._s.delete(f"{self._CO_API_URL}/{path}", params=params) + url = f"{self._CO_API_URL}/{path}" + self._log.debug('DELETE %s params=%s', url, params) + resp = self._s.delete(url, params=params, timeout=self._timeout) + if not resp.ok: + self._log.warning('DELETE %s returned %s', url, resp.status_code) resp.raise_for_status() + self._log.info('DELETE %s OK', url) return True def _get_by_entity(self, path: str, entity_type: str, entity_id: int, diff --git a/comanage_api/_sshkeys.py b/comanage_api/_sshkeys.py index 14952a6..1a77034 100644 --- a/comanage_api/_sshkeys.py +++ b/comanage_api/_sshkeys.py @@ -304,14 +304,19 @@ def ssh_keys_view_per_coperson(self, coperson_id: int) -> dict: """ url = f"{self._CO_API_URL}/{_SSH_KEY_PATH}.json" params = {'copersonid': str(coperson_id)} - resp = self._s.get(url=url, params=params) + self._log.debug('GET %s params=%s', url, params) + resp = self._s.get(url=url, params=params, timeout=self._timeout) if resp.status_code == 204: + self._log.info('GET %s returned 204 (no SSH keys)', url) return { 'RequestType': 'SshKeys', 'Version': '1.0', 'SshKeys': [] } + if not resp.ok: + self._log.warning('GET %s returned %s', url, resp.status_code) resp.raise_for_status() + self._log.info('GET %s OK', url) return resp.json() diff --git a/pyproject.toml b/pyproject.toml index ef68430..48c9983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dev = [ Homepage = "https://github.com/fabric-testbed/python-comanage-api" Repository = "https://github.com/fabric-testbed/python-comanage-api" +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.setuptools.dynamic] version = { attr = "comanage_api.__VERSION__" } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e643131 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +import pytest +import requests_mock as rm + +from comanage_api import ComanageApi + +API_URL = 'https://registry.example.org/registry' +API_USER = 'co_123.test-user' +API_PASS = 'test-pass' +CO_ID = 123 +CO_NAME = 'TestCO' +SSH_KEY_AUTH_ID = 7 + + +@pytest.fixture +def mock_adapter(): + with rm.Mocker() as m: + yield m + + +@pytest.fixture +def api(mock_adapter): + return ComanageApi( + co_api_url=API_URL, + co_api_user=API_USER, + co_api_pass=API_PASS, + co_api_org_id=CO_ID, + co_api_org_name=CO_NAME, + co_ssh_key_authenticator_id=SSH_KEY_AUTH_ID, + timeout=5, + ) + + +@pytest.fixture +def url(): + return API_URL diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..51ebb9c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,135 @@ +import pytest +from requests.adapters import HTTPAdapter +from requests.exceptions import HTTPError + +from comanage_api import ComanageApi, __VERSION__ + +API_URL = 'https://registry.example.org/registry' + + +class TestConstructor: + def test_defaults(self, api, mock_adapter): + assert api._CO_API_ORG_ID == 123 + assert api._CO_API_ORG_NAME == 'TestCO' + assert api._timeout == 5 + assert api._CO_SSH_KEY_AUTHENTICATOR_ID == 7 + + def test_trailing_slash_stripped(self, mock_adapter): + api = ComanageApi( + co_api_url='https://example.com/registry/', + co_api_user='u', co_api_pass='p', + co_api_org_id=1, co_api_org_name='T', + ) + assert api._CO_API_URL == 'https://example.com/registry' + + def test_default_timeout(self, mock_adapter): + api = ComanageApi( + co_api_url='https://example.com', + co_api_user='u', co_api_pass='p', + co_api_org_id=1, co_api_org_name='T', + ) + assert api._timeout == 30 + + def test_custom_timeout(self, mock_adapter): + api = ComanageApi( + co_api_url='https://example.com', + co_api_user='u', co_api_pass='p', + co_api_org_id=1, co_api_org_name='T', + timeout=60, + ) + assert api._timeout == 60 + + def test_no_ssh_key_authenticator(self, mock_adapter): + api = ComanageApi( + co_api_url='https://example.com', + co_api_user='u', co_api_pass='p', + co_api_org_id=1, co_api_org_name='T', + ) + assert api._CO_SSH_KEY_AUTHENTICATOR_ID == 0 + + def test_retry_adapter_mounted(self, api, mock_adapter): + adapter = api._s.get_adapter('https://example.com') + assert isinstance(adapter, HTTPAdapter) + + def test_version(self): + assert __VERSION__ == '0.1.5' + + +class TestOptionSets: + def test_status_options(self, api, mock_adapter): + assert 'Active' in api.STATUS_OPTIONS + assert 'Suspended' in api.STATUS_OPTIONS + assert len(api.STATUS_OPTIONS) == 14 + + def test_affiliation_options(self, api, mock_adapter): + assert 'member' in api.AFFILIATION_OPTIONS + assert 'faculty' in api.AFFILIATION_OPTIONS + + def test_entity_options(self, api, mock_adapter): + assert 'copersonid' in api.ENTITY_OPTIONS + assert 'cogroupid' in api.ENTITY_OPTIONS + + def test_person_options(self, api, mock_adapter): + assert 'copersonid' in api.PERSON_OPTIONS + assert 'orgidentityid' in api.PERSON_OPTIONS + assert len(api.PERSON_OPTIONS) == 2 + + def test_ssh_key_options(self, api, mock_adapter): + assert 'ssh-rsa' in api.SSH_KEY_OPTIONS + assert 'ssh-ed25519' in api.SSH_KEY_OPTIONS + + +class TestHttpHelpers: + def test_get(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/test.json', json={'ok': True}) + result = api._get('test.json') + assert result == {'ok': True} + + def test_get_with_params(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/test.json', json={'ok': True}) + api._get('test.json', params={'key': 'val'}) + assert 'key' in mock_adapter.last_request.qs + + def test_get_error(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/test.json', status_code=500) + with pytest.raises(HTTPError): + api._get('test.json') + + def test_post(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/test.json', json={'Id': '1'}, status_code=201) + result = api._post('test.json', {'data': 'value'}) + assert result['Id'] == '1' + + def test_put(self, api, mock_adapter): + mock_adapter.put(f'{API_URL}/test.json', status_code=200) + assert api._put('test.json', {'data': 'value'}) is True + + def test_delete(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/test.json', status_code=200) + assert api._delete('test.json') is True + + def test_delete_with_params(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/test.json', status_code=200) + api._delete('test.json', params={'coid': 123}) + assert 'coid' in mock_adapter.last_request.qs + + +class TestGetByEntity: + def test_valid_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/test.json', json={'ok': True}) + api._get_by_entity('test.json', 'copersonid', 100, api.PERSON_OPTIONS, 'test_field') + assert 'copersonid' in mock_adapter.last_request.qs + + def test_invalid_type(self, api, mock_adapter): + with pytest.raises(ValueError, match="test_field"): + api._get_by_entity('test.json', 'badtype', 100, api.PERSON_OPTIONS, 'test_field') + + def test_none_defaults_to_copersonid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/test.json', json={'ok': True}) + api._get_by_entity('test.json', None, 100, api.PERSON_OPTIONS, 'test_field') + assert 'copersonid' in mock_adapter.last_request.qs + + def test_case_insensitive(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/test.json', json={'ok': True}) + api._get_by_entity('test.json', 'OrgIdentityId', 50, api.PERSON_OPTIONS, 'test_field') + assert 'orgidentityid' in mock_adapter.last_request.qs diff --git a/tests/test_coorgidentitylinks.py b/tests/test_coorgidentitylinks.py new file mode 100644 index 0000000..d1e91a7 --- /dev/null +++ b/tests/test_coorgidentitylinks.py @@ -0,0 +1,75 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' + + +SAMPLE_LINKS = { + 'ResponseType': 'CoOrgIdentityLinks', + 'Version': '1.0', + 'CoOrgIdentityLinks': [{ + 'Version': '1.0', + 'Id': 800, + 'CoPersonId': 100, + 'OrgIdentityId': 600, + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + }], +} + + +class TestCoorgIdentityLinksNotImplemented: + def test_add(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.coorg_identity_links_add() + + def test_delete(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.coorg_identity_links_delete() + + def test_edit(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.coorg_identity_links_edit() + + +class TestCoorgIdentityLinksView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links.json', json=SAMPLE_LINKS) + result = api.coorg_identity_links_view_all() + assert result['CoOrgIdentityLinks'][0]['Id'] == 800 + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links/800.json', json=SAMPLE_LINKS) + result = api.coorg_identity_links_view_one(coorg_identity_link_id=800) + assert result['CoOrgIdentityLinks'][0]['CoPersonId'] == 100 + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links/999.json', status_code=404) + with pytest.raises(HTTPError): + api.coorg_identity_links_view_one(coorg_identity_link_id=999) + + +class TestCoorgIdentityLinksViewByIdentity: + def test_valid_copersonid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links.json', json=SAMPLE_LINKS) + api.coorg_identity_links_view_by_identity(identity_type='copersonid', identity_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_valid_orgidentityid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links.json', json=SAMPLE_LINKS) + api.coorg_identity_links_view_by_identity(identity_type='orgidentityid', identity_id=600) + assert 'orgidentityid' in mock_adapter.last_request.qs + + def test_invalid_identity_type(self, api, mock_adapter): + with pytest.raises(ValueError, match="identity_type"): + api.coorg_identity_links_view_by_identity(identity_type='badtype', identity_id=100) + + def test_default_identity_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links.json', json=SAMPLE_LINKS) + api.coorg_identity_links_view_by_identity(identity_type=None, identity_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_case_insensitive(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_org_identity_links.json', json=SAMPLE_LINKS) + api.coorg_identity_links_view_by_identity(identity_type='OrgIdentityId', identity_id=600) + assert 'orgidentityid' in mock_adapter.last_request.qs diff --git a/tests/test_copeople.py b/tests/test_copeople.py new file mode 100644 index 0000000..8d2f3cd --- /dev/null +++ b/tests/test_copeople.py @@ -0,0 +1,121 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' +CO_ID = 123 + + +SAMPLE_COPEOPLE = { + 'ResponseType': 'CoPeople', + 'Version': '1.0', + 'CoPeople': [ + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + {'Version': '1.0', 'Id': 101, 'CoId': CO_ID, 'Status': 'Active'}, + ], +} + + +class TestCopeopleNotImplemented: + def test_add(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.copeople_add() + + def test_delete(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.copeople_delete() + + def test_edit(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.copeople_edit() + + def test_find(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.copeople_find() + + +class TestCopeopleMatch: + def test_match_by_given(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) + result = api.copeople_match(given='John') + assert 'given' in mock_adapter.last_request.qs + assert len(result['CoPeople']) == 2 + + def test_match_distinct_by_id(self, api, mock_adapter): + dupes = { + 'ResponseType': 'CoPeople', + 'Version': '1.0', + 'CoPeople': [ + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + ], + } + mock_adapter.get(f'{API_URL}/co_people.json', json=dupes) + result = api.copeople_match(given='John', distinct_by_id=True) + assert len(result['CoPeople']) == 1 + + def test_match_no_distinct(self, api, mock_adapter): + dupes = { + 'ResponseType': 'CoPeople', + 'Version': '1.0', + 'CoPeople': [ + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + ], + } + mock_adapter.get(f'{API_URL}/co_people.json', json=dupes) + result = api.copeople_match(given='John', distinct_by_id=False) + assert len(result['CoPeople']) == 2 + + def test_match_multiple_params(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) + api.copeople_match(given='John', family='Doe', mail='j@example.com') + qs = mock_adapter.last_request.qs + assert 'given' in qs + assert 'family' in qs + assert 'mail' in qs + + +class TestCopeopleView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) + result = api.copeople_view_all() + assert len(result['CoPeople']) == 2 + + def test_view_per_co(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) + result = api.copeople_view_per_co() + assert 'coid' in mock_adapter.last_request.qs + + def test_view_per_identifier(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) + result = api.copeople_view_per_identifier(identifier='user@example.com') + qs = mock_adapter.last_request.qs + assert 'search.identifier' in qs + + def test_view_per_identifier_distinct(self, api, mock_adapter): + dupes = { + 'ResponseType': 'CoPeople', + 'Version': '1.0', + 'CoPeople': [ + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + {'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}, + ], + } + mock_adapter.get(f'{API_URL}/co_people.json', json=dupes) + result = api.copeople_view_per_identifier(identifier='x', distinct_by_id=True) + assert len(result['CoPeople']) == 1 + + def test_view_one(self, api, mock_adapter): + single = { + 'ResponseType': 'CoPeople', + 'Version': '1.0', + 'CoPeople': [{'Version': '1.0', 'Id': 100, 'CoId': CO_ID, 'Status': 'Active'}], + } + mock_adapter.get(f'{API_URL}/co_people/100.json', json=single) + result = api.copeople_view_one(coperson_id=100) + assert result['CoPeople'][0]['Id'] == 100 + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_people/999.json', status_code=404) + with pytest.raises(HTTPError): + api.copeople_view_one(coperson_id=999) diff --git a/tests/test_copersonroles.py b/tests/test_copersonroles.py new file mode 100644 index 0000000..63f27c1 --- /dev/null +++ b/tests/test_copersonroles.py @@ -0,0 +1,124 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' + + +SAMPLE_ROLE = { + 'ResponseType': 'CoPersonRoles', + 'Version': '1.0', + 'CoPersonRoles': [{ + 'Version': '1.0', + 'Id': 200, + 'Person': {'Type': 'CO', 'Id': 100}, + 'CouId': 42, + 'Affiliation': 'member', + 'O': 'TestCO', + 'Status': 'Active', + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + 'Revision': 0, + 'Deleted': False, + 'ActorIdentifier': 'admin', + }], +} + +NEW_ROLE = { + 'ResponseType': 'NewObject', + 'Version': '1.0', + 'ObjectType': 'CoPersonRole', + 'Id': '200', +} + + +class TestCopersonRolesAdd: + def test_success_defaults(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/co_person_roles.json', json=NEW_ROLE, status_code=201) + result = api.coperson_roles_add(coperson_id=100, cou_id=42) + assert result['Id'] == '200' + body = mock_adapter.last_request.json() + assert body['CoPersonRoles'][0]['Status'] == 'Active' + assert body['CoPersonRoles'][0]['Affiliation'] == 'member' + + def test_custom_status_and_affiliation(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/co_person_roles.json', json=NEW_ROLE, status_code=201) + api.coperson_roles_add(coperson_id=100, cou_id=42, status='Pending', affiliation='faculty') + body = mock_adapter.last_request.json() + assert body['CoPersonRoles'][0]['Status'] == 'Pending' + assert body['CoPersonRoles'][0]['Affiliation'] == 'faculty' + + def test_invalid_status(self, api, mock_adapter): + with pytest.raises(ValueError, match="status"): + api.coperson_roles_add(coperson_id=100, cou_id=42, status='BadStatus') + + def test_invalid_affiliation(self, api, mock_adapter): + with pytest.raises(ValueError, match="affiliation"): + api.coperson_roles_add(coperson_id=100, cou_id=42, affiliation='CEO') + + def test_affiliation_case_insensitive(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/co_person_roles.json', json=NEW_ROLE, status_code=201) + api.coperson_roles_add(coperson_id=100, cou_id=42, affiliation='Faculty') + body = mock_adapter.last_request.json() + assert body['CoPersonRoles'][0]['Affiliation'] == 'faculty' + + +class TestCopersonRolesDelete: + def test_success(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/co_person_roles/200.json', status_code=200) + assert api.coperson_roles_delete(coperson_role_id=200) is True + + def test_not_found(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/co_person_roles/999.json', status_code=404) + with pytest.raises(HTTPError): + api.coperson_roles_delete(coperson_role_id=999) + + +class TestCopersonRolesEdit: + def test_edit_status(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles/200.json', json=SAMPLE_ROLE) + mock_adapter.put(f'{API_URL}/co_person_roles/200.json', status_code=200) + assert api.coperson_roles_edit(coperson_role_id=200, status='Suspended') is True + body = mock_adapter.last_request.json() + assert body['CoPersonRoles'][0]['Status'] == 'Suspended' + assert body['CoPersonRoles'][0]['Person']['Id'] == '100' + + def test_edit_keeps_existing(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles/200.json', json=SAMPLE_ROLE) + mock_adapter.put(f'{API_URL}/co_person_roles/200.json', status_code=200) + api.coperson_roles_edit(coperson_role_id=200) + body = mock_adapter.last_request.json() + assert body['CoPersonRoles'][0]['Status'] == 'Active' + assert body['CoPersonRoles'][0]['Affiliation'] == 'member' + assert body['CoPersonRoles'][0]['CouId'] == '42' + + def test_edit_invalid_status(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles/200.json', json=SAMPLE_ROLE) + with pytest.raises(ValueError, match="status"): + api.coperson_roles_edit(coperson_role_id=200, status='Nope') + + def test_edit_invalid_affiliation(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles/200.json', json=SAMPLE_ROLE) + with pytest.raises(ValueError, match="affiliation"): + api.coperson_roles_edit(coperson_role_id=200, affiliation='king') + + +class TestCopersonRolesView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles.json', json=SAMPLE_ROLE) + result = api.coperson_roles_view_all() + assert result['CoPersonRoles'][0]['Id'] == 200 + + def test_view_per_coperson(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles.json', json=SAMPLE_ROLE) + api.coperson_roles_view_per_coperson(coperson_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_view_per_cou(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles.json', json=SAMPLE_ROLE) + api.coperson_roles_view_per_cou(cou_id=42) + assert 'couid' in mock_adapter.last_request.qs + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/co_person_roles/200.json', json=SAMPLE_ROLE) + result = api.coperson_roles_view_one(coperson_role_id=200) + assert result['CoPersonRoles'][0]['Id'] == 200 diff --git a/tests/test_cous.py b/tests/test_cous.py new file mode 100644 index 0000000..14f1f3f --- /dev/null +++ b/tests/test_cous.py @@ -0,0 +1,144 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' +CO_ID = 123 + + +SAMPLE_COU = { + 'ResponseType': 'Cous', + 'Version': '1.0', + 'Cous': [{ + 'Version': '1.0', + 'Id': 42, + 'CoId': CO_ID, + 'Name': 'test-cou', + 'Description': 'A test COU', + 'Lft': 1, + 'Rght': 2, + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + 'Revision': 0, + 'Deleted': False, + 'ActorIdentifier': 'admin', + }], +} + +NEW_COU = { + 'ResponseType': 'NewObject', + 'Version': '1.0', + 'ObjectType': 'Cou', + 'Id': '42', +} + + +class TestCousAdd: + def test_success(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/cous.json', json=NEW_COU, status_code=201) + result = api.cous_add(name='test-cou', description='A test COU') + assert result['Id'] == '42' + + def test_with_parent_id(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/cous.json', json=NEW_COU, status_code=201) + api.cous_add(name='child', description='child cou', parent_id=10) + body = mock_adapter.last_request.json() + assert body['Cous'][0]['ParentId'] == '10' + + def test_without_parent_id(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/cous.json', json=NEW_COU, status_code=201) + api.cous_add(name='root', description='root cou') + body = mock_adapter.last_request.json() + assert 'ParentId' not in body['Cous'][0] + + def test_http_error(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/cous.json', status_code=400) + with pytest.raises(HTTPError): + api.cous_add(name='bad', description='bad') + + +class TestCousDelete: + def test_success(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/cous/42.json', status_code=200) + assert api.cous_delete(cou_id=42) is True + + def test_not_found(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/cous/999.json', status_code=404) + with pytest.raises(HTTPError): + api.cous_delete(cou_id=999) + + +class TestCousEdit: + def test_edit_name(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous/42.json', json=SAMPLE_COU) + mock_adapter.put(f'{API_URL}/cous/42.json', status_code=200) + assert api.cous_edit(cou_id=42, name='new-name') is True + body = mock_adapter.last_request.json() + assert body['Cous'][0]['Name'] == 'new-name' + assert body['Cous'][0]['Description'] == 'A test COU' + + def test_edit_keeps_existing_when_none(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous/42.json', json=SAMPLE_COU) + mock_adapter.put(f'{API_URL}/cous/42.json', status_code=200) + api.cous_edit(cou_id=42) + body = mock_adapter.last_request.json() + assert body['Cous'][0]['Name'] == 'test-cou' + assert body['Cous'][0]['Description'] == 'A test COU' + + def test_parent_id_set(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous/42.json', json=SAMPLE_COU) + mock_adapter.put(f'{API_URL}/cous/42.json', status_code=200) + api.cous_edit(cou_id=42, parent_id=10) + body = mock_adapter.last_request.json() + assert body['Cous'][0]['ParentId'] == '10' + + def test_parent_id_clear(self, api, mock_adapter): + cou_with_parent = { + 'ResponseType': 'Cous', + 'Version': '1.0', + 'Cous': [{**SAMPLE_COU['Cous'][0], 'ParentId': '5'}], + } + mock_adapter.get(f'{API_URL}/cous/42.json', json=cou_with_parent) + mock_adapter.put(f'{API_URL}/cous/42.json', status_code=200) + api.cous_edit(cou_id=42, parent_id=0) + body = mock_adapter.last_request.json() + assert body['Cous'][0]['ParentId'] == '' + + def test_parent_id_keep_existing(self, api, mock_adapter): + cou_with_parent = { + 'ResponseType': 'Cous', + 'Version': '1.0', + 'Cous': [{**SAMPLE_COU['Cous'][0], 'ParentId': '5'}], + } + mock_adapter.get(f'{API_URL}/cous/42.json', json=cou_with_parent) + mock_adapter.put(f'{API_URL}/cous/42.json', status_code=200) + api.cous_edit(cou_id=42, name='updated') + body = mock_adapter.last_request.json() + assert body['Cous'][0]['ParentId'] == '5' + + +class TestCousView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous.json', json=SAMPLE_COU) + result = api.cous_view_all() + assert result['Cous'][0]['Name'] == 'test-cou' + + def test_view_per_co(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous.json', json=SAMPLE_COU) + result = api.cous_view_per_co() + assert 'coid' in mock_adapter.last_request.qs + assert result['Cous'][0]['Id'] == 42 + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous/42.json', json=SAMPLE_COU) + result = api.cous_view_one(cou_id=42) + assert result['Cous'][0]['Id'] == 42 + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous/999.json', status_code=404) + with pytest.raises(HTTPError): + api.cous_view_one(cou_id=999) + + def test_view_all_server_error(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/cous.json', status_code=500) + with pytest.raises(HTTPError): + api.cous_view_all() diff --git a/tests/test_emailaddresses.py b/tests/test_emailaddresses.py new file mode 100644 index 0000000..c2bc8f4 --- /dev/null +++ b/tests/test_emailaddresses.py @@ -0,0 +1,77 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' + + +SAMPLE_EMAILS = { + 'ResponseType': 'EmailAddresses', + 'Version': '1.0', + 'EmailAddresses': [{ + 'Version': '1.0', + 'Id': 300, + 'Mail': 'user@example.com', + 'Type': 'official', + 'Verified': True, + 'Person': {'Type': 'CO', 'Id': 100}, + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + }], +} + + +class TestEmailAddressesNotImplemented: + def test_add(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.email_addresses_add() + + def test_delete(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.email_addresses_delete() + + def test_edit(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.email_addresses_edit() + + +class TestEmailAddressesView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses.json', json=SAMPLE_EMAILS) + result = api.email_addresses_view_all() + assert result['EmailAddresses'][0]['Mail'] == 'user@example.com' + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses/300.json', json=SAMPLE_EMAILS) + result = api.email_addresses_view_one(email_address_id=300) + assert result['EmailAddresses'][0]['Id'] == 300 + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses/999.json', status_code=404) + with pytest.raises(HTTPError): + api.email_addresses_view_one(email_address_id=999) + + +class TestEmailAddressesViewPerPerson: + def test_valid_copersonid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses.json', json=SAMPLE_EMAILS) + result = api.email_addresses_view_per_person(person_type='copersonid', person_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_valid_orgidentityid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses.json', json=SAMPLE_EMAILS) + api.email_addresses_view_per_person(person_type='orgidentityid', person_id=50) + assert 'orgidentityid' in mock_adapter.last_request.qs + + def test_case_insensitive(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses.json', json=SAMPLE_EMAILS) + api.email_addresses_view_per_person(person_type='CopersonId', person_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_invalid_person_type(self, api, mock_adapter): + with pytest.raises(ValueError, match="person_type"): + api.email_addresses_view_per_person(person_type='badtype', person_id=100) + + def test_default_person_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/email_addresses.json', json=SAMPLE_EMAILS) + api.email_addresses_view_per_person(person_type=None, person_id=100) + assert 'copersonid' in mock_adapter.last_request.qs diff --git a/tests/test_identifiers.py b/tests/test_identifiers.py new file mode 100644 index 0000000..21d1755 --- /dev/null +++ b/tests/test_identifiers.py @@ -0,0 +1,82 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' + + +SAMPLE_IDENTIFIERS = { + 'ResponseType': 'Identifiers', + 'Version': '1.0', + 'Identifiers': [{ + 'Version': '1.0', + 'Id': 400, + 'Type': 'oidcsub', + 'Identifier': 'http://cilogon.org/serverA/users/12345', + 'Login': False, + 'Person': {'Type': 'CO', 'Id': 100}, + 'Status': 'Active', + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + }], +} + + +class TestIdentifiersNotImplemented: + def test_add(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.identifiers_add() + + def test_assign(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.identifiers_assign() + + def test_delete(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.identifiers_delete() + + def test_edit(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.identifiers_edit() + + +class TestIdentifiersView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers.json', json=SAMPLE_IDENTIFIERS) + result = api.identifiers_view_all() + assert result['Identifiers'][0]['Id'] == 400 + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers/400.json', json=SAMPLE_IDENTIFIERS) + result = api.identifiers_view_one(identifier_id=400) + assert result['Identifiers'][0]['Identifier'] == 'http://cilogon.org/serverA/users/12345' + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers/999.json', status_code=404) + with pytest.raises(HTTPError): + api.identifiers_view_one(identifier_id=999) + + +class TestIdentifiersViewPerEntity: + def test_valid_copersonid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers.json', json=SAMPLE_IDENTIFIERS) + api.identifiers_view_per_entity(entity_type='copersonid', entity_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_valid_cogroupid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers.json', json=SAMPLE_IDENTIFIERS) + api.identifiers_view_per_entity(entity_type='cogroupid', entity_id=50) + assert 'cogroupid' in mock_adapter.last_request.qs + + def test_invalid_entity_type(self, api, mock_adapter): + with pytest.raises(ValueError, match="entity_type"): + api.identifiers_view_per_entity(entity_type='badtype', entity_id=100) + + def test_default_entity_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers.json', json=SAMPLE_IDENTIFIERS) + api.identifiers_view_per_entity(entity_type=None, entity_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_case_insensitive(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/identifiers.json', json=SAMPLE_IDENTIFIERS) + api.identifiers_view_per_entity(entity_type='CoPersonId', entity_id=100) + assert 'copersonid' in mock_adapter.last_request.qs diff --git a/tests/test_names.py b/tests/test_names.py new file mode 100644 index 0000000..72304f5 --- /dev/null +++ b/tests/test_names.py @@ -0,0 +1,73 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' + + +SAMPLE_NAMES = { + 'ResponseType': 'Names', + 'Version': '1.0', + 'Names': [{ + 'Version': '1.0', + 'Id': 500, + 'Given': 'John', + 'Family': 'Doe', + 'Type': 'official', + 'Person': {'Type': 'CO', 'Id': 100}, + 'PrimaryName': True, + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + }], +} + + +class TestNamesNotImplemented: + def test_add(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.names_add() + + def test_delete(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.names_delete() + + def test_edit(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.names_edit() + + +class TestNamesView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/names.json', json=SAMPLE_NAMES) + result = api.names_view_all() + assert result['Names'][0]['Given'] == 'John' + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/names/500.json', json=SAMPLE_NAMES) + result = api.names_view_one(name_id=500) + assert result['Names'][0]['Family'] == 'Doe' + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/names/999.json', status_code=404) + with pytest.raises(HTTPError): + api.names_view_one(name_id=999) + + +class TestNamesViewPerPerson: + def test_valid_copersonid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/names.json', json=SAMPLE_NAMES) + api.names_view_per_person(person_type='copersonid', person_id=100) + assert 'copersonid' in mock_adapter.last_request.qs + + def test_valid_orgidentityid(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/names.json', json=SAMPLE_NAMES) + api.names_view_per_person(person_type='orgidentityid', person_id=50) + assert 'orgidentityid' in mock_adapter.last_request.qs + + def test_invalid_person_type(self, api, mock_adapter): + with pytest.raises(ValueError, match="person_type"): + api.names_view_per_person(person_type='badtype', person_id=100) + + def test_default_person_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/names.json', json=SAMPLE_NAMES) + api.names_view_per_person(person_type=None, person_id=100) + assert 'copersonid' in mock_adapter.last_request.qs diff --git a/tests/test_orgidentities.py b/tests/test_orgidentities.py new file mode 100644 index 0000000..02377e9 --- /dev/null +++ b/tests/test_orgidentities.py @@ -0,0 +1,69 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' +CO_ID = 123 + + +SAMPLE_ORG_IDS = { + 'ResponseType': 'OrgIdentities', + 'Version': '1.0', + 'OrgIdentities': [{ + 'Version': '1.0', + 'Id': 600, + 'Affiliation': 'member', + 'O': 'Example Org', + 'CoId': CO_ID, + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + }], +} + + +class TestOrgIdentitiesNotImplemented: + def test_add(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.org_identities_add() + + def test_delete(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.org_identities_delete() + + def test_edit(self, api, mock_adapter): + with pytest.raises(NotImplementedError): + api.org_identities_edit() + + +class TestOrgIdentitiesView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/org_identities.json', json=SAMPLE_ORG_IDS) + result = api.org_identities_view_all() + assert result['OrgIdentities'][0]['Id'] == 600 + + def test_view_per_co(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/org_identities.json', json=SAMPLE_ORG_IDS) + result = api.org_identities_view_per_co() + assert 'coid' in mock_adapter.last_request.qs + + def test_view_per_identifier(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/org_identities.json', json=SAMPLE_ORG_IDS) + api.org_identities_view_per_identifier(identifier_id=12345) + qs = mock_adapter.last_request.qs + assert 'coid' in qs + assert 'search.identifier' in qs + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/org_identities/600.json', json=SAMPLE_ORG_IDS) + result = api.org_identities_view_one(org_identity_id=600) + assert result['OrgIdentities'][0]['Id'] == 600 + assert 'coid' in mock_adapter.last_request.qs + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/org_identities/999.json', status_code=404) + with pytest.raises(HTTPError): + api.org_identities_view_one(org_identity_id=999) + + def test_view_all_server_error(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/org_identities.json', status_code=500) + with pytest.raises(HTTPError): + api.org_identities_view_all() diff --git a/tests/test_sshkeys.py b/tests/test_sshkeys.py new file mode 100644 index 0000000..9c05a43 --- /dev/null +++ b/tests/test_sshkeys.py @@ -0,0 +1,142 @@ +import pytest +from requests.exceptions import HTTPError + +API_URL = 'https://registry.example.org/registry' +SSH_KEY_AUTH_ID = 7 + + +SSH_PATH = 'ssh_key_authenticator/ssh_keys' + +SAMPLE_SSHKEY = { + 'ResponseType': 'SshKeys', + 'Version': '1.0', + 'SshKeys': [{ + 'Version': '1.0', + 'Id': 700, + 'Person': {'Type': 'CO', 'Id': 100}, + 'Comment': 'test key', + 'Type': 'ssh-rsa', + 'Skey': 'AAAAB3NzaC1yc2EAAAADAQABAAABAQC...', + 'SshKeyAuthenticatorId': SSH_KEY_AUTH_ID, + 'Created': '2024-01-01 00:00:00', + 'Modified': '2024-01-01 00:00:00', + 'Revision': 0, + 'Deleted': False, + 'ActorIdentifier': 'admin', + }], +} + +NEW_SSHKEY = { + 'ResponseType': 'NewObject', + 'Version': '1.0', + 'ObjectType': 'SshKey', + 'Id': '700', +} + + +class TestSshKeysAdd: + def test_success(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/{SSH_PATH}.json', json=NEW_SSHKEY, status_code=201) + result = api.ssh_keys_add(coperson_id=100, ssh_key='AAAA...', key_type='ssh-rsa', comment='my key') + assert result['Id'] == '700' + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['Comment'] == 'my key' + assert body['SshKeys'][0]['SshKeyAuthenticatorId'] == str(SSH_KEY_AUTH_ID) + + def test_no_comment(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/{SSH_PATH}.json', json=NEW_SSHKEY, status_code=201) + api.ssh_keys_add(coperson_id=100, ssh_key='AAAA...', key_type='ssh-rsa') + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['Comment'] == '' + + def test_custom_authenticator_id(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/{SSH_PATH}.json', json=NEW_SSHKEY, status_code=201) + api.ssh_keys_add(coperson_id=100, ssh_key='AAAA...', key_type='ssh-rsa', + ssh_key_authenticator_id=99) + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['SshKeyAuthenticatorId'] == '99' + + def test_invalid_key_type(self, api, mock_adapter): + with pytest.raises(ValueError, match="key_type"): + api.ssh_keys_add(coperson_id=100, ssh_key='AAAA...', key_type='bad-type') + + def test_key_type_case_insensitive(self, api, mock_adapter): + mock_adapter.post(f'{API_URL}/{SSH_PATH}.json', json=NEW_SSHKEY, status_code=201) + api.ssh_keys_add(coperson_id=100, ssh_key='AAAA...', key_type='SSH-RSA') + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['Type'] == 'ssh-rsa' + + +class TestSshKeysDelete: + def test_success(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/{SSH_PATH}/700.json', status_code=200) + assert api.ssh_keys_delete(ssh_key_id=700) is True + + def test_not_found(self, api, mock_adapter): + mock_adapter.delete(f'{API_URL}/{SSH_PATH}/999.json', status_code=404) + with pytest.raises(HTTPError): + api.ssh_keys_delete(ssh_key_id=999) + + +class TestSshKeysEdit: + def test_edit_comment(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}/700.json', json=SAMPLE_SSHKEY) + mock_adapter.put(f'{API_URL}/{SSH_PATH}/700.json', status_code=200) + assert api.ssh_keys_edit(ssh_key_id=700, comment='new comment') is True + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['Comment'] == 'new comment' + assert body['SshKeys'][0]['Person']['Id'] == '100' + + def test_edit_keeps_existing(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}/700.json', json=SAMPLE_SSHKEY) + mock_adapter.put(f'{API_URL}/{SSH_PATH}/700.json', status_code=200) + api.ssh_keys_edit(ssh_key_id=700) + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['Type'] == 'ssh-rsa' + assert body['SshKeys'][0]['Skey'] == 'AAAAB3NzaC1yc2EAAAADAQABAAABAQC...' + assert body['SshKeys'][0]['Comment'] == 'test key' + + def test_edit_key_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}/700.json', json=SAMPLE_SSHKEY) + mock_adapter.put(f'{API_URL}/{SSH_PATH}/700.json', status_code=200) + api.ssh_keys_edit(ssh_key_id=700, key_type='ssh-ed25519') + body = mock_adapter.last_request.json() + assert body['SshKeys'][0]['Type'] == 'ssh-ed25519' + + def test_edit_invalid_key_type(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}/700.json', json=SAMPLE_SSHKEY) + with pytest.raises(ValueError, match="key_type"): + api.ssh_keys_edit(ssh_key_id=700, key_type='bad-type') + + +class TestSshKeysView: + def test_view_all(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}.json', json=SAMPLE_SSHKEY) + result = api.ssh_keys_view_all() + assert result['SshKeys'][0]['Id'] == 700 + + def test_view_per_coperson(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}.json', json=SAMPLE_SSHKEY) + result = api.ssh_keys_view_per_coperson(coperson_id=100) + assert result['SshKeys'][0]['Id'] == 700 + assert 'copersonid' in mock_adapter.last_request.qs + + def test_view_per_coperson_204_empty(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}.json', status_code=204) + result = api.ssh_keys_view_per_coperson(coperson_id=100) + assert result['SshKeys'] == [] + + def test_view_per_coperson_error(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}.json', status_code=401) + with pytest.raises(HTTPError): + api.ssh_keys_view_per_coperson(coperson_id=100) + + def test_view_one(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}/700.json', json=SAMPLE_SSHKEY) + result = api.ssh_keys_view_one(ssh_key_id=700) + assert result['SshKeys'][0]['Id'] == 700 + + def test_view_one_not_found(self, api, mock_adapter): + mock_adapter.get(f'{API_URL}/{SSH_PATH}/999.json', status_code=404) + with pytest.raises(HTTPError): + api.ssh_keys_view_one(ssh_key_id=999) From 6c7fd705e9452c6bb708f928c18cc5806d30ea05 Mon Sep 17 00:00:00 2001 From: "Michael J. Stealey" Date: Tue, 21 Apr 2026 20:13:37 -0400 Subject: [PATCH 3/5] packaging, github actions --- .github/workflows/ci.yml | 29 +++++++++++++ README.md | 9 ++++ comanage_api/__init__.py | 80 ++++++++++++++++++++++++++++-------- comanage_api/_sshkeys.py | 1 - pyproject.toml | 9 ++++ tests/test_api.py | 2 +- tests/test_copeople.py | 7 ++-- tests/test_emailaddresses.py | 2 +- tests/test_orgidentities.py | 2 +- uv.lock | 27 ++++++++++++ 10 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..16992d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv venv --python 3.12 + - run: uv pip install -e ".[dev]" + - run: uv run ruff check . + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv venv --python ${{ matrix.python-version }} + - run: uv pip install -e ".[dev]" + - run: uv run pytest -v diff --git a/README.md b/README.md index 852251c..5a20a39 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,15 @@ uv venv --python 3.12 uv pip install -e ".[dev]" ``` +### Lint and test + +```console +uv run ruff check . # lint +uv run pytest -v # test +``` + +CI runs both on every push to `main`/`develop` and on PRs, across Python 3.9–3.12. + ### Configure your environment Create a `.env` file from the included template if you don't want to put the API credentials in your code. Example code makes use of [python-dotenv](https://pypi.org/project/python-dotenv/) diff --git a/comanage_api/__init__.py b/comanage_api/__init__.py index ba5bba8..838e64d 100644 --- a/comanage_api/__init__.py +++ b/comanage_api/__init__.py @@ -5,22 +5,70 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from ._coorgidentitylinks import coorg_identity_links_add, coorg_identity_links_delete, coorg_identity_links_edit, \ - coorg_identity_links_view_all, coorg_identity_links_view_by_identity, coorg_identity_links_view_one -from ._copeople import copeople_add, copeople_delete, copeople_edit, copeople_find, copeople_match, \ - copeople_view_all, copeople_view_per_co, copeople_view_per_identifier, copeople_view_one -from ._copersonroles import coperson_roles_add, coperson_roles_delete, coperson_roles_edit, coperson_roles_view_all, \ - coperson_roles_view_per_coperson, coperson_roles_view_per_cou, coperson_roles_view_one -from ._cous import cous_add, cous_delete, cous_edit, cous_view_all, cous_view_per_co, cous_view_one -from ._emailaddresses import email_addresses_add, email_addresses_delete, email_addresses_edit, \ - email_addresses_view_all, email_addresses_view_per_person, email_addresses_view_one -from ._identifiers import identifiers_add, identifiers_assign, identifiers_delete, identifiers_edit, \ - identifiers_view_all, identifiers_view_per_entity, identifiers_view_one -from ._names import names_add, names_delete, names_edit, names_view_all, names_view_per_person, names_view_one -from ._orgidentities import org_identities_add, org_identities_delete, org_identities_edit, org_identities_view_all, \ - org_identities_view_per_co, org_identities_view_per_identifier, org_identities_view_one -from ._sshkeys import ssh_keys_add, ssh_keys_delete, ssh_keys_edit, ssh_keys_view_all, ssh_keys_view_per_coperson, \ - ssh_keys_view_one +from ._coorgidentitylinks import ( + coorg_identity_links_add, + coorg_identity_links_delete, + coorg_identity_links_edit, + coorg_identity_links_view_all, + coorg_identity_links_view_by_identity, + coorg_identity_links_view_one, +) +from ._copeople import ( + copeople_add, + copeople_delete, + copeople_edit, + copeople_find, + copeople_match, + copeople_view_all, + copeople_view_one, + copeople_view_per_co, + copeople_view_per_identifier, +) +from ._copersonroles import ( + coperson_roles_add, + coperson_roles_delete, + coperson_roles_edit, + coperson_roles_view_all, + coperson_roles_view_one, + coperson_roles_view_per_coperson, + coperson_roles_view_per_cou, +) +from ._cous import cous_add, cous_delete, cous_edit, cous_view_all, cous_view_one, cous_view_per_co +from ._emailaddresses import ( + email_addresses_add, + email_addresses_delete, + email_addresses_edit, + email_addresses_view_all, + email_addresses_view_one, + email_addresses_view_per_person, +) +from ._identifiers import ( + identifiers_add, + identifiers_assign, + identifiers_delete, + identifiers_edit, + identifiers_view_all, + identifiers_view_one, + identifiers_view_per_entity, +) +from ._names import names_add, names_delete, names_edit, names_view_all, names_view_one, names_view_per_person +from ._orgidentities import ( + org_identities_add, + org_identities_delete, + org_identities_edit, + org_identities_view_all, + org_identities_view_one, + org_identities_view_per_co, + org_identities_view_per_identifier, +) +from ._sshkeys import ( + ssh_keys_add, + ssh_keys_delete, + ssh_keys_edit, + ssh_keys_view_all, + ssh_keys_view_one, + ssh_keys_view_per_coperson, +) # fabric-comanage-api version __VERSION__ = "0.1.5" diff --git a/comanage_api/_sshkeys.py b/comanage_api/_sshkeys.py index 1a77034..82742ec 100644 --- a/comanage_api/_sshkeys.py +++ b/comanage_api/_sshkeys.py @@ -35,7 +35,6 @@ - Authenticators that are locked cannot be managed by the API. """ -import json # SSH Key plugin base path _SSH_KEY_PATH = 'ssh_key_authenticator/ssh_keys' diff --git a/pyproject.toml b/pyproject.toml index 48c9983..6353934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dev = [ "pytest", "requests-mock", "python-dotenv", + "ruff", ] [project.urls] @@ -39,6 +40,14 @@ Repository = "https://github.com/fabric-testbed/python-comanage-api" [tool.pytest.ini_options] testpaths = ["tests"] +[tool.ruff] +line-length = 120 +target-version = "py39" +extend-exclude = ["examples"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + [tool.setuptools.dynamic] version = { attr = "comanage_api.__VERSION__" } diff --git a/tests/test_api.py b/tests/test_api.py index 51ebb9c..02aba73 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,7 +2,7 @@ from requests.adapters import HTTPAdapter from requests.exceptions import HTTPError -from comanage_api import ComanageApi, __VERSION__ +from comanage_api import __VERSION__, ComanageApi API_URL = 'https://registry.example.org/registry' diff --git a/tests/test_copeople.py b/tests/test_copeople.py index 8d2f3cd..0a164b6 100644 --- a/tests/test_copeople.py +++ b/tests/test_copeople.py @@ -83,14 +83,13 @@ def test_view_all(self, api, mock_adapter): def test_view_per_co(self, api, mock_adapter): mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) - result = api.copeople_view_per_co() + api.copeople_view_per_co() assert 'coid' in mock_adapter.last_request.qs def test_view_per_identifier(self, api, mock_adapter): mock_adapter.get(f'{API_URL}/co_people.json', json=SAMPLE_COPEOPLE) - result = api.copeople_view_per_identifier(identifier='user@example.com') - qs = mock_adapter.last_request.qs - assert 'search.identifier' in qs + api.copeople_view_per_identifier(identifier='user@example.com') + assert 'search.identifier' in mock_adapter.last_request.qs def test_view_per_identifier_distinct(self, api, mock_adapter): dupes = { diff --git a/tests/test_emailaddresses.py b/tests/test_emailaddresses.py index c2bc8f4..afe4ad4 100644 --- a/tests/test_emailaddresses.py +++ b/tests/test_emailaddresses.py @@ -54,7 +54,7 @@ def test_view_one_not_found(self, api, mock_adapter): class TestEmailAddressesViewPerPerson: def test_valid_copersonid(self, api, mock_adapter): mock_adapter.get(f'{API_URL}/email_addresses.json', json=SAMPLE_EMAILS) - result = api.email_addresses_view_per_person(person_type='copersonid', person_id=100) + api.email_addresses_view_per_person(person_type='copersonid', person_id=100) assert 'copersonid' in mock_adapter.last_request.qs def test_valid_orgidentityid(self, api, mock_adapter): diff --git a/tests/test_orgidentities.py b/tests/test_orgidentities.py index 02377e9..4234744 100644 --- a/tests/test_orgidentities.py +++ b/tests/test_orgidentities.py @@ -42,7 +42,7 @@ def test_view_all(self, api, mock_adapter): def test_view_per_co(self, api, mock_adapter): mock_adapter.get(f'{API_URL}/org_identities.json', json=SAMPLE_ORG_IDS) - result = api.org_identities_view_per_co() + api.org_identities_view_per_co() assert 'coid' in mock_adapter.last_request.qs def test_view_per_identifier(self, api, mock_adapter): diff --git a/uv.lock b/uv.lock index e66dce8..acef835 100644 --- a/uv.lock +++ b/uv.lock @@ -172,6 +172,7 @@ dev = [ { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "requests-mock" }, + { name = "ruff" }, ] [package.metadata] @@ -180,6 +181,7 @@ requires-dist = [ { name = "python-dotenv", marker = "extra == 'dev'" }, { name = "requests", specifier = ">=2.25.0" }, { name = "requests-mock", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, ] provides-extras = ["dev"] @@ -358,6 +360,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, ] +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + [[package]] name = "tomli" version = "2.4.1" From 6527265c37b8b34dd1a41e78cb3be81b080b0b90 Mon Sep 17 00:00:00 2001 From: "Michael J. Stealey" Date: Tue, 21 Apr 2026 21:27:46 -0400 Subject: [PATCH 4/5] refactor as methods via mixins --- README.md | 12 +- comanage_api/__init__.py | 280 +------------- comanage_api/_coorgidentitylinks.py | 273 ++++++------- comanage_api/_copeople.py | 383 ++++++------------ comanage_api/_copersonroles.py | 581 ++++++++-------------------- comanage_api/_cous.py | 408 ++++++------------- comanage_api/_emailaddresses.py | 236 +++-------- comanage_api/_identifiers.py | 248 +++--------- comanage_api/_names.py | 239 +++--------- comanage_api/_orgidentities.py | 286 ++++---------- comanage_api/_sshkeys.py | 483 ++++++++--------------- examples/README.md | 20 +- 12 files changed, 980 insertions(+), 2469 deletions(-) diff --git a/README.md b/README.md index 5a20a39..dd46a3c 100644 --- a/README.md +++ b/README.md @@ -141,15 +141,15 @@ Return types based on implementation status of wrapped API endpoints - Edit an existing CO Identity Link. - `coorg_identity_links_view_all() -> dict` - Retrieve all existing CO Identity Links. -- `coorg_identity_links_view_by_identity(identifier_id: int) -> dict` +- `coorg_identity_links_view_by_identity(identity_type: str, identity_id: int) -> dict` - Retrieve all existing CO Identity Links for a CO Person or an Org Identity. -- `coorg_identity_links_view_one(org_identity_id: int) -> dict` +- `coorg_identity_links_view_one(coorg_identity_link_id: int) -> dict` - Retrieve an existing CO Identity Link. **NOTE**: when provided, valid values for `identity_type` as follows: ```python -IDENTITY_OPTIONS = ['copersonid', 'orgidentityid'] +PERSON_OPTIONS = ['copersonid', 'orgidentityid'] ``` ### [CoPerson API](https://spaces.at.internet2.edu/display/COmanage/CoPerson+API) (COmanage v3.3.0+) @@ -317,7 +317,7 @@ PERSON_OPTIONS = ['copersonid', 'orgidentityid'] - Edit an existing Organizational Identity. - `org_identities_view_all() -> dict` - Retrieve all existing Organizational Identities. -- `org_identities_view_per_co(person_type: str, person_id: int) -> dict` +- `org_identities_view_per_co() -> dict` - Retrieve all existing Organizational Identities for the specified CO. - `org_identities_view_per_identifier(identifier_id: int) -> dict` - Retrieve all existing Organizational Identities attached to the specified identifier. @@ -335,7 +335,7 @@ PERSON_OPTIONS = ['copersonid', 'orgidentityid'] - `ssh_keys_delete(ssh_key_id: int) -> bool` - Remove an SSH Key. - `ssh_keys_edit(ssh_key_id: int, coperson_id: int = None, ssh_key: str = None, key_type: str = None, comment: str = None, ssh_key_authenticator_id: int = None) -> bool` - - Edit an exiting SSH Key. + - Edit an existing SSH Key. - `ssh_keys_view_all() -> dict` - Retrieve all existing SSH Keys. - `ssh_keys_view_per_coperson(coperson_id: int) -> dict` @@ -466,6 +466,6 @@ Pressing the "Edit" option will display the fields for the Authenticator along w - Identifier API: [https://spaces.at.internet2.edu/display/COmanage/Identifier+API](https://spaces.at.internet2.edu/display/COmanage/Identifier+API) - Name API: [https://spaces.at.internet2.edu/display/COmanage/Name+API](https://spaces.at.internet2.edu/display/COmanage/Name+API) - OrgIdentity API: [https://spaces.at.internet2.edu/display/COmanage/OrgIdentity+API](https://spaces.at.internet2.edu/display/COmanage/OrgIdentity+API) -- SsHKey API: [https://spaces.at.internet2.edu/display/COmanage/SshKey+API](https://spaces.at.internet2.edu/display/COmanage/SshKey+API) +- SshKey API: [https://spaces.at.internet2.edu/display/COmanage/SshKey+API](https://spaces.at.internet2.edu/display/COmanage/SshKey+API) - SSH Key Authenticator Plugin: [https://spaces.at.internet2.edu/display/COmanage/SSH+Key+Authenticator+Plugin](https://spaces.at.internet2.edu/display/COmanage/SSH+Key+Authenticator+Plugin) - PyPi: [https://pypi.org](https://pypi.org) diff --git a/comanage_api/__init__.py b/comanage_api/__init__.py index 838e64d..301f8a2 100644 --- a/comanage_api/__init__.py +++ b/comanage_api/__init__.py @@ -5,70 +5,15 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from ._coorgidentitylinks import ( - coorg_identity_links_add, - coorg_identity_links_delete, - coorg_identity_links_edit, - coorg_identity_links_view_all, - coorg_identity_links_view_by_identity, - coorg_identity_links_view_one, -) -from ._copeople import ( - copeople_add, - copeople_delete, - copeople_edit, - copeople_find, - copeople_match, - copeople_view_all, - copeople_view_one, - copeople_view_per_co, - copeople_view_per_identifier, -) -from ._copersonroles import ( - coperson_roles_add, - coperson_roles_delete, - coperson_roles_edit, - coperson_roles_view_all, - coperson_roles_view_one, - coperson_roles_view_per_coperson, - coperson_roles_view_per_cou, -) -from ._cous import cous_add, cous_delete, cous_edit, cous_view_all, cous_view_one, cous_view_per_co -from ._emailaddresses import ( - email_addresses_add, - email_addresses_delete, - email_addresses_edit, - email_addresses_view_all, - email_addresses_view_one, - email_addresses_view_per_person, -) -from ._identifiers import ( - identifiers_add, - identifiers_assign, - identifiers_delete, - identifiers_edit, - identifiers_view_all, - identifiers_view_one, - identifiers_view_per_entity, -) -from ._names import names_add, names_delete, names_edit, names_view_all, names_view_one, names_view_per_person -from ._orgidentities import ( - org_identities_add, - org_identities_delete, - org_identities_edit, - org_identities_view_all, - org_identities_view_one, - org_identities_view_per_co, - org_identities_view_per_identifier, -) -from ._sshkeys import ( - ssh_keys_add, - ssh_keys_delete, - ssh_keys_edit, - ssh_keys_view_all, - ssh_keys_view_one, - ssh_keys_view_per_coperson, -) +from ._coorgidentitylinks import CoOrgIdentityLinksMixin +from ._copeople import CoPeopleMixin +from ._copersonroles import CoPersonRolesMixin +from ._cous import COUsMixin +from ._emailaddresses import EmailAddressesMixin +from ._identifiers import IdentifiersMixin +from ._names import NamesMixin +from ._orgidentities import OrgIdentitiesMixin +from ._sshkeys import SshKeysMixin # fabric-comanage-api version __VERSION__ = "0.1.5" @@ -79,7 +24,17 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -class ComanageApi(object): +class ComanageApi( + CoOrgIdentityLinksMixin, + CoPeopleMixin, + CoPersonRolesMixin, + COUsMixin, + EmailAddressesMixin, + IdentifiersMixin, + NamesMixin, + OrgIdentitiesMixin, + SshKeysMixin, +): """ fabric-comanage-api: @@ -207,198 +162,3 @@ def _get_by_entity(self, path: str, entity_type: str, entity_id: int, if entity_type not in valid_options: raise ValueError(f"Invalid Fields '{field_name}'") return self._get(path, params={entity_type: str(entity_id)}) - - # CoOrgIdentityLink API - def coorg_identity_links_add(self): - return coorg_identity_links_add(self) - - def coorg_identity_links_delete(self): - return coorg_identity_links_delete(self) - - def coorg_identity_links_edit(self): - return coorg_identity_links_edit(self) - - def coorg_identity_links_view_all(self): - return coorg_identity_links_view_all(self) - - def coorg_identity_links_view_by_identity(self, identity_type: str, identity_id: int): - return coorg_identity_links_view_by_identity(self, identity_type=identity_type, identity_id=identity_id) - - def coorg_identity_links_view_one(self, coorg_identity_link_id: int): - return coorg_identity_links_view_one(self, coorg_identity_link_id=coorg_identity_link_id) - - # COperson API - def copeople_add(self): - return copeople_add(self) - - def copeople_delete(self): - return copeople_delete(self) - - def copeople_edit(self): - return copeople_edit(self) - - def copeople_find(self): - return copeople_find(self) - - def copeople_match(self, given: str = None, family: str = None, mail: str = None, distinct_by_id: bool = True): - return copeople_match(self, given=given, family=family, mail=mail, distinct_by_id=distinct_by_id) - - def copeople_view_all(self): - return copeople_view_all(self) - - def copeople_view_per_co(self): - return copeople_view_per_co(self) - - def copeople_view_per_identifier(self, identifier: str, distinct_by_id: bool = True): - return copeople_view_per_identifier(self, identifier=identifier, distinct_by_id=distinct_by_id) - - def copeople_view_one(self, coperson_id: int): - return copeople_view_one(self, coperson_id=coperson_id) - - # COPersonRoles API - def coperson_roles_add(self, coperson_id: int, cou_id: int, status: str = None, affiliation: str = None): - return coperson_roles_add(self, coperson_id=coperson_id, cou_id=cou_id, status=status, affiliation=affiliation) - - def coperson_roles_delete(self, coperson_role_id: int): - return coperson_roles_delete(self, coperson_role_id=coperson_role_id) - - def coperson_roles_edit(self, coperson_role_id: int, coperson_id: int = None, cou_id: int = None, - status: str = None, affiliation: str = None): - return coperson_roles_edit(self, coperson_role_id=coperson_role_id, coperson_id=coperson_id, cou_id=cou_id, - status=status, affiliation=affiliation) - - def coperson_roles_view_all(self): - return coperson_roles_view_all(self) - - def coperson_roles_view_per_coperson(self, coperson_id: int): - return coperson_roles_view_per_coperson(self, coperson_id=coperson_id) - - def coperson_roles_view_per_cou(self, cou_id: int): - return coperson_roles_view_per_cou(self, cou_id=cou_id) - - def coperson_roles_view_one(self, coperson_role_id: int): - return coperson_roles_view_one(self, coperson_role_id=coperson_role_id) - - # COU API - def cous_add(self, name: str, description: str, parent_id: int = None): - return cous_add(self, name=name, description=description, parent_id=parent_id) - - def cous_delete(self, cou_id: int): - return cous_delete(self, cou_id=cou_id) - - def cous_edit(self, cou_id: int, name: str = None, description: str = None, parent_id: int = None): - return cous_edit(self, cou_id=cou_id, name=name, description=description, parent_id=parent_id) - - def cous_view_all(self): - return cous_view_all(self) - - def cous_view_per_co(self): - return cous_view_per_co(self) - - def cous_view_one(self, cou_id: int): - return cous_view_one(self, cou_id=cou_id) - - # EmailAddress API - def email_addresses_add(self): - return email_addresses_add(self) - - def email_addresses_delete(self): - return email_addresses_delete(self) - - def email_addresses_edit(self): - return email_addresses_edit(self) - - def email_addresses_view_all(self): - return email_addresses_view_all(self) - - def email_addresses_view_per_person(self, person_type: str, person_id: int): - return email_addresses_view_per_person(self, person_type=person_type, person_id=person_id) - - def email_addresses_view_one(self, email_address_id: int): - return email_addresses_view_one(self, email_address_id=email_address_id) - - # Indentifier API - def identifiers_add(self): - return identifiers_add(self) - - def identifiers_assign(self): - return identifiers_assign(self) - - def identifiers_delete(self): - return identifiers_delete(self) - - def identifiers_edit(self): - return identifiers_edit(self) - - def identifiers_view_all(self): - return identifiers_view_all(self) - - def identifiers_view_per_entity(self, entity_type: str, entity_id: int): - return identifiers_view_per_entity(self, entity_type=entity_type, entity_id=entity_id) - - def identifiers_view_one(self, identifier_id: int): - return identifiers_view_one(self, identifier_id=identifier_id) - - # Name API - def names_add(self): - return names_add(self) - - def names_delete(self): - return names_delete(self) - - def names_edit(self): - return names_edit(self) - - def names_view_all(self): - return names_view_all(self) - - def names_view_per_person(self, person_type: str, person_id: int): - return names_view_per_person(self, person_type=person_type, person_id=person_id) - - def names_view_one(self, name_id: int): - return names_view_one(self, name_id=name_id) - - # OrgIdentity API - def org_identities_add(self): - return org_identities_add(self) - - def org_identities_delete(self): - return org_identities_delete(self) - - def org_identities_edit(self): - return org_identities_edit(self) - - def org_identities_view_all(self): - return org_identities_view_all(self) - - def org_identities_view_per_co(self): - return org_identities_view_per_co(self) - - def org_identities_view_per_identifier(self, identifier_id: int): - return org_identities_view_per_identifier(self, identifier_id=identifier_id) - - def org_identities_view_one(self, org_identity_id: int): - return org_identities_view_one(self, org_identity_id=org_identity_id) - - # SshKey API - def ssh_keys_add(self, coperson_id: int, ssh_key: str, key_type: str, comment: str = None, - ssh_key_authenticator_id: int = None): - return ssh_keys_add(self, coperson_id=coperson_id, ssh_key=ssh_key, key_type=key_type, comment=comment, - ssh_key_authenticator_id=ssh_key_authenticator_id) - - def ssh_keys_delete(self, ssh_key_id: int): - return ssh_keys_delete(self, ssh_key_id=ssh_key_id) - - def ssh_keys_edit(self, ssh_key_id: int, coperson_id: int = None, ssh_key: str = None, key_type: str = None, - comment: str = None, ssh_key_authenticator_id: int = None): - return ssh_keys_edit(self, ssh_key_id=ssh_key_id, coperson_id=coperson_id, ssh_key=ssh_key, key_type=key_type, - comment=comment, ssh_key_authenticator_id=ssh_key_authenticator_id) - - def ssh_keys_view_all(self): - return ssh_keys_view_all(self) - - def ssh_keys_view_per_coperson(self, coperson_id: int): - return ssh_keys_view_per_coperson(self, coperson_id=coperson_id) - - def ssh_keys_view_one(self, ssh_key_id: int): - return ssh_keys_view_one(self, ssh_key_id=ssh_key_id) diff --git a/comanage_api/_coorgidentitylinks.py b/comanage_api/_coorgidentitylinks.py index 1aa3727..358dfe9 100644 --- a/comanage_api/_coorgidentitylinks.py +++ b/comanage_api/_coorgidentitylinks.py @@ -2,158 +2,127 @@ """ CoOrgIdentityLink API - https://spaces.at.internet2.edu/display/COmanage/CoOrgIdentityLink+API - -Methods -------- -coorg_identity_links_add() -> dict - ### NOT IMPLEMENTED ### - Add a new CO Org Identity Link. - A person must have an Org Identity and a CO Person record before they can be linked. - Note that invitations are a separate operation. -coorg_identity_links_delete() -> bool - ### NOT IMPLEMENTED ### - Remove a CO Org Identity Link. -coorg_identity_links_edit() -> bool - ### NOT IMPLEMENTED ### - Edit an existing CO Identity Link. -coorg_identity_links_view_all() -> dict - Retrieve all existing CO Identity Links. -coorg_identity_links_view_by_identity(identifier_id: int) -> dict - Retrieve all existing CO Identity Links for a CO Person or an Org Identity. -coorg_identity_links_view_one(org_identity_id: int) -> dict - Retrieve an existing CO Identity Link. """ -def coorg_identity_links_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Add a new CO Org Identity Link. - A person must have an Org Identity and a CO Person record before they can be linked. - Note that invitations are a separate operation. - - :param self: - """ - raise NotImplementedError("coorg_identity_links_add() is not implemented") - - -def coorg_identity_links_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Remove a CO Org Identity Link. - - :param self: - """ - raise NotImplementedError("coorg_identity_links_delete() is not implemented") - - -def coorg_identity_links_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Edit an existing CO Identity Link. - - :param self: - """ - raise NotImplementedError("coorg_identity_links_edit() is not implemented") - - -def coorg_identity_links_view_all(self) -> dict: - """ - Retrieve all existing CO Identity Links. - - :param self: - :return - { - "ResponseType":"CoOrgIdentityLinks", - "Version":"1.0", - "CoOrgIdentityLinks": - [ - { - "Version":"1.0", - "Id":"", - "CoPersonId":"", - "OrgIdentityId":"", - "Created":"", - "Modified":"" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoOrgIdentityLink Response CoOrgIdentityLinks returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('co_org_identity_links.json') - - -def coorg_identity_links_view_by_identity(self, identity_type: str, identity_id: int) -> dict: - """ - Retrieve all existing CO Identity Links for a CO Person or an Org Identity. - - :param self: - :param identity_type: - :param identity_id: - :return - { - "ResponseType":"CoOrgIdentityLinks", - "Version":"1.0", - "CoOrgIdentityLinks": - [ - { - "Version":"1.0", - "Id":"", - "CoPersonId":"", - "OrgIdentityId":"", - "Created":"", - "Modified":"" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoOrgIdentityLink Response CoOrgIdentityLinks returned - 401 Unauthorized Authentication required - 404 CO Person Unknown copersonid not found - 404 Org Identity Unknown orgidentityid not found - 500 Other Error Unknown error - """ - return self._get_by_entity('co_org_identity_links.json', identity_type, identity_id, - self.PERSON_OPTIONS, 'identity_type') - - -def coorg_identity_links_view_one(self, coorg_identity_link_id: int) -> dict: - """ - Retrieve an existing CO Identity Link. - - :param self: - :param coorg_identity_link_id: - :return - { - "ResponseType":"CoOrgIdentityLinks", - "Version":"1.0", - "CoOrgIdentityLinks": - [ - { - "Version":"1.0", - "Id":"", - "CoPersonId":"", - "OrgIdentityId":"", - "Created":"", - "Modified":"" - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoOrgIdentityLink Response CoOrgIdentityLinks returned - 401 Unauthorized Authentication required - 404 CoOrgIdentityLink Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'co_org_identity_links/{coorg_identity_link_id}.json') +class CoOrgIdentityLinksMixin: + """Mixin providing CoOrgIdentityLink API methods.""" + + def coorg_identity_links_add(self) -> dict: + """ + ### NOT IMPLEMENTED ### + Add a new CO Org Identity Link. + A person must have an Org Identity and a CO Person record before they can be linked. + Note that invitations are a separate operation. + """ + raise NotImplementedError("coorg_identity_links_add() is not implemented") + + def coorg_identity_links_delete(self) -> bool: + """ + ### NOT IMPLEMENTED ### + Remove a CO Org Identity Link. + """ + raise NotImplementedError("coorg_identity_links_delete() is not implemented") + + def coorg_identity_links_edit(self) -> bool: + """ + ### NOT IMPLEMENTED ### + Edit an existing CO Identity Link. + """ + raise NotImplementedError("coorg_identity_links_edit() is not implemented") + + def coorg_identity_links_view_all(self) -> dict: + """ + Retrieve all existing CO Identity Links. + + :return + { + "ResponseType":"CoOrgIdentityLinks", + "Version":"1.0", + "CoOrgIdentityLinks": + [ + { + "Version":"1.0", + "Id":"", + "CoPersonId":"", + "OrgIdentityId":"", + "Created":"", + "Modified":"" + }, + {...} + ] + }: + + Response Format + HTTP Status Response Body Description + 200 OK CoOrgIdentityLink Response CoOrgIdentityLinks returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('co_org_identity_links.json') + + def coorg_identity_links_view_by_identity(self, identity_type: str, identity_id: int) -> dict: + """ + Retrieve all existing CO Identity Links for a CO Person or an Org Identity. + + :param identity_type: + :param identity_id: + :return + { + "ResponseType":"CoOrgIdentityLinks", + "Version":"1.0", + "CoOrgIdentityLinks": + [ + { + "Version":"1.0", + "Id":"", + "CoPersonId":"", + "OrgIdentityId":"", + "Created":"", + "Modified":"" + }, + {...} + ] + }: + + Response Format + HTTP Status Response Body Description + 200 OK CoOrgIdentityLink Response CoOrgIdentityLinks returned + 401 Unauthorized Authentication required + 404 CO Person Unknown copersonid not found + 404 Org Identity Unknown orgidentityid not found + 500 Other Error Unknown error + """ + return self._get_by_entity('co_org_identity_links.json', identity_type, identity_id, + self.PERSON_OPTIONS, 'identity_type') + + def coorg_identity_links_view_one(self, coorg_identity_link_id: int) -> dict: + """ + Retrieve an existing CO Identity Link. + + :param coorg_identity_link_id: + :return + { + "ResponseType":"CoOrgIdentityLinks", + "Version":"1.0", + "CoOrgIdentityLinks": + [ + { + "Version":"1.0", + "Id":"", + "CoPersonId":"", + "OrgIdentityId":"", + "Created":"", + "Modified":"" + } + ] + }: + + Response Format + HTTP Status Response Body Description + 200 OK CoOrgIdentityLink Response CoOrgIdentityLinks returned + 401 Unauthorized Authentication required + 404 CoOrgIdentityLink Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'co_org_identity_links/{coorg_identity_link_id}.json') diff --git a/comanage_api/_copeople.py b/comanage_api/_copeople.py index 2a63272..821c97e 100644 --- a/comanage_api/_copeople.py +++ b/comanage_api/_copeople.py @@ -2,271 +2,136 @@ """ CoPerson API - https://spaces.at.internet2.edu/display/COmanage/CoPerson+API - -Methods -------- -copeople_add() -> dict - ### NOT IMPLEMENTED ### - Add a new CO Person. A person must have an OrgIdentity before they can be added to a CO. - Note that linking to an OrgIdentity and invitations are separate operations. -copeople_delete() -> bool - ### NOT IMPLEMENTED ### - Remove a CO Person. This method will also delete related data, such as CoPersonRoles, EmailAddresses, - and Identifiers. A person must be removed from any COs (CoPerson records must be deleted) - before the OrgIdentity record can be removed. -copeople_edit() -> bool - ### NOT IMPLEMENTED ### - Edit an existing CO Person. -copeople_find() -> dict - ### NOT IMPLEMENTED ### - Search for existing CO Person records. - When too many records are found, a message may be returned rather than specific records. -copeople_match(given: str = None, family: str = None, mail: str = None, distinct_by_id: bool = True) -> dict - Attempt to match existing CO Person records. - Note that matching is not performed on search criteria of less than 3 characters, - or for email addresses that are not syntactically valid. -copeople_view_all() -> dict - Retrieve all existing CO People. -copeople_view_per_co() -> dict - Retrieve all existing CO People for the specified CO. -copeople_view_per_identifier(identifier: str, distinct_by_id: bool = True) -> dict - Retrieve all existing CO People attached to the specified identifier. - Note the specified identifier must be attached to a CO Person, not an Org Identity. -copeople_view_one(coperson_id: int) -> dict - Retrieve an existing CO Person. """ -def copeople_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Add a new CO Person. A person must have an OrgIdentity before they can be added to a CO. - Note that linking to an OrgIdentity and invitations are separate operations. - - :param self: - """ - raise NotImplementedError("copeople_add() is not implemented") - - -def copeople_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Remove a CO Person. This method will also delete related data, such as CoPersonRoles, EmailAddresses, - and Identifiers. A person must be removed from any COs (CoPerson records must be deleted) - before the OrgIdentity record can be removed. - - :param self: - """ - raise NotImplementedError("copeople_delete() is not implemented") - - -def copeople_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Edit an existing CO Person. - - :param self: - """ - raise NotImplementedError("copeople_edit() is not implemented") - - -def copeople_find(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Search for existing CO Person records. - When too many records are found, a message may be returned rather than specific records. - - :param self: - """ - raise NotImplementedError("copeople_find() is not implemented") - - def _deduplicate_copeople(resp_dict: dict) -> dict: distinct_copeople = list({v['Id']: v for v in resp_dict.get('CoPeople')}.values()) resp_dict['CoPeople'] = distinct_copeople return resp_dict -def copeople_match(self, given: str = None, family: str = None, mail: str = None, distinct_by_id: bool = True) -> dict: - """ - Attempt to match existing CO Person records. - Note that matching is not performed on search criteria of less than 3 characters, - or for email addresses that are not syntactically valid. - - :param self: - :param given: - :param family: - :param mail: - :param distinct_by_id: - :return - { - "RequestType":"CoPeople", - "Version":"1.0", - "CoPeople": - [ - { - "Version":"1.0", - "CoId":"", - "Timezone":"", - "DateOfBirth":"", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Locked"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended") - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPerson Response CoPerson returned (zero or more matches may be returned) - 401 Unauthorized Authentication required - 404 CO Unknown id not found - Not currently implemented - -- unknown CO will return an empty set - 500 Other Error Unknown error - """ - params = {'coid': self._CO_API_ORG_ID} - if given: - params['given'] = given - if family: - params['family'] = family - if mail: - params['mail'] = mail - resp_dict = self._get('co_people.json', params=params) - if distinct_by_id: - return _deduplicate_copeople(resp_dict) - return resp_dict - - -def copeople_view_all(self) -> dict: - """ - Retrieve all existing CO People. - - :param self: - :return - { - "RequestType":"CoPeople", - "Version":"1.0", - "CoPeople": - [ - { - "Version":"1.0", - "CoId":"", - "Timezone":"", - "DateOfBirth":"", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Locked"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended") - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPerson Response CoPerson returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('co_people.json') - - -def copeople_view_per_co(self) -> dict: - """ - Retrieve all existing CO People for the specified CO. - - :param self: - :return - { - "RequestType":"CoPeople", - "Version":"1.0", - "CoPeople": - [ - { - "Version":"1.0", - "CoId":"", - "Timezone":"", - "DateOfBirth":"", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Locked"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended") - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPerson Response CoPerson returned (zero or more matches may be returned) - 401 Unauthorized Authentication required - 404 CO Unknown id not found - Not currently implemented - -- unknown CO will return an empty set - 500 Other Error Unknown error - """ - return self._get('co_people.json', params={'coid': self._CO_API_ORG_ID}) - - -def copeople_view_per_identifier(self, identifier: str, distinct_by_id: bool = True) -> dict: - """ - Retrieve all existing CO People attached to the specified identifier. - Note the specified identifier must be attached to a CO Person, not an Org Identity. - - :param self: - :param identifier: - :param distinct_by_id: - :return - { - "RequestType":"CoPeople", - "Version":"1.0", - "CoPeople": - [ - { - "Version":"1.0", - "CoId":"", - "Timezone":"", - "DateOfBirth":"", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Locked"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended") - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPerson Response CoPerson returned - 401 Unauthorized Authentication required - 404 CO Unknown id not found - 500 Other Error Unknown error - """ - params = {'coid': self._CO_API_ORG_ID, 'search.identifier': identifier} - resp_dict = self._get('co_people.json', params=params) - if distinct_by_id: - return _deduplicate_copeople(resp_dict) - return resp_dict - - -def copeople_view_one(self, coperson_id: int) -> dict: - """ - Retrieve an existing CO Person. - - :param self: - :param coperson_id: - :return - { - "RequestType":"CoPeople", - "Version":"1.0", - "CoPeople": - [ - { - "Version":"1.0", - "CoId":"", - "Timezone":"", - "DateOfBirth":"", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Locked"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended") - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPerson Response CoPerson returned - 401 Unauthorized Authentication required - 404 copeople Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'co_people/{coperson_id}.json', params={'coid': self._CO_API_ORG_ID}) +class CoPeopleMixin: + """Mixin providing CoPerson API methods.""" + + def copeople_add(self) -> dict: + """ + ### NOT IMPLEMENTED ### + Add a new CO Person. A person must have an OrgIdentity before they can be added to a CO. + Note that linking to an OrgIdentity and invitations are separate operations. + """ + raise NotImplementedError("copeople_add() is not implemented") + + def copeople_delete(self) -> bool: + """ + ### NOT IMPLEMENTED ### + Remove a CO Person. This method will also delete related data, such as CoPersonRoles, EmailAddresses, + and Identifiers. A person must be removed from any COs (CoPerson records must be deleted) + before the OrgIdentity record can be removed. + """ + raise NotImplementedError("copeople_delete() is not implemented") + + def copeople_edit(self) -> bool: + """ + ### NOT IMPLEMENTED ### + Edit an existing CO Person. + """ + raise NotImplementedError("copeople_edit() is not implemented") + + def copeople_find(self) -> dict: + """ + ### NOT IMPLEMENTED ### + Search for existing CO Person records. + When too many records are found, a message may be returned rather than specific records. + """ + raise NotImplementedError("copeople_find() is not implemented") + + def copeople_match(self, given: str = None, family: str = None, mail: str = None, + distinct_by_id: bool = True) -> dict: + """ + Attempt to match existing CO Person records. + Note that matching is not performed on search criteria of less than 3 characters, + or for email addresses that are not syntactically valid. + + :param given: + :param family: + :param mail: + :param distinct_by_id: + + Response Format + HTTP Status Response Body Description + 200 OK CoPerson Response CoPerson returned (zero or more matches may be returned) + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + params = {'coid': self._CO_API_ORG_ID} + if given: + params['given'] = given + if family: + params['family'] = family + if mail: + params['mail'] = mail + resp_dict = self._get('co_people.json', params=params) + if distinct_by_id: + return _deduplicate_copeople(resp_dict) + return resp_dict + + def copeople_view_all(self) -> dict: + """ + Retrieve all existing CO People. + + Response Format + HTTP Status Response Body Description + 200 OK CoPerson Response CoPerson returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('co_people.json') + + def copeople_view_per_co(self) -> dict: + """ + Retrieve all existing CO People for the specified CO. + + Response Format + HTTP Status Response Body Description + 200 OK CoPerson Response CoPerson returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('co_people.json', params={'coid': self._CO_API_ORG_ID}) + + def copeople_view_per_identifier(self, identifier: str, distinct_by_id: bool = True) -> dict: + """ + Retrieve all existing CO People attached to the specified identifier. + Note the specified identifier must be attached to a CO Person, not an Org Identity. + + :param identifier: + :param distinct_by_id: + + Response Format + HTTP Status Response Body Description + 200 OK CoPerson Response CoPerson returned + 401 Unauthorized Authentication required + 404 CO Unknown id not found + 500 Other Error Unknown error + """ + params = {'coid': self._CO_API_ORG_ID, 'search.identifier': identifier} + resp_dict = self._get('co_people.json', params=params) + if distinct_by_id: + return _deduplicate_copeople(resp_dict) + return resp_dict + + def copeople_view_one(self, coperson_id: int) -> dict: + """ + Retrieve an existing CO Person. + + :param coperson_id: + + Response Format + HTTP Status Response Body Description + 200 OK CoPerson Response CoPerson returned + 401 Unauthorized Authentication required + 404 copeople Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'co_people/{coperson_id}.json', params={'coid': self._CO_API_ORG_ID}) diff --git a/comanage_api/_copersonroles.py b/comanage_api/_copersonroles.py index 550b5f1..fe3025c 100644 --- a/comanage_api/_copersonroles.py +++ b/comanage_api/_copersonroles.py @@ -2,420 +2,177 @@ """ CoPersonRole API - https://spaces.at.internet2.edu/display/COmanage/CoPersonRole+API - -Methods -------- -coperson_roles_add(coperson_id: int, cou_id: int, status: str = None, affiliation: str = None) -> dict - Add a new CO Person Role. -coperson_roles_delete(coperson_role_id: int) -> bool - Remove a CO Person Role. -coperson_roles_edit(coperson_role_id: int, coperson_id: int = None, cou_id: int = None, status: str = None, - affiliation: str = None) -> bool - Edit an existing CO Person Role. -coperson_roles_view_all() -> dict - Retrieve all existing CO Person Roles. -coperson_roles_view_per_coperson(coperson_id: int) -> dict - Retrieve all existing CO Person Roles for the specified CO Person. Available since Registry v2.0.0. -coperson_roles_view_per_cou(cou_id: int) -> dict - Retrieve all existing CO Person Roles for the specified COU. -coperson_roles_view_one(coperson_role_id: int) -> dict - Retrieve an existing CO Person Role. """ -def coperson_roles_add(self, coperson_id: int, cou_id: int, status: str = None, affiliation: str = None) -> dict: - """ - Add a new CO Person Role. - - :param self: - :param affiliation: - :param coperson_id: - :param cou_id: - :param status: - - :return - { - "ResponseType":"NewObject", - "Version":"1.0", - "ObjectType":"CoPersonRole", - "Id":"" - }: - - Request Format - { - "RequestType":"CoPersonRoles", - "Version":"1.0", - "CoPersonRoles": - [ - { - "Version":"1.0", - "Person": - { - "Type":"CO", - "Id":"" - }, - "CouId":"", - "Affiliation":"", - "Title":"", - "O":"<O>", - "Ordr":"<Order>", - "Ou":"<Ou>", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended"), - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "ExtendedAttributes": - { - "<Attribute>":"<Value>", - {...} - } - } - ] - } - - Response Format - HTTP Status Response Body Description - 201 Added NewObjectResponse with ObjectType CoPersonRole created - 400 Bad Request CoPersonRole Request not provided in POST body - 400 Invalid Fields ErrorResponse with details in An error in one or more provided fields - InvalidFields element - 401 Unauthorized Authentication required - 403 COU Does Not Exist The specified COU does not exist - 500 Other Error Unknown error - """ - role = { - 'Version': '1.0', - 'Person': { - 'Type': 'CO', - 'Id': str(coperson_id) - }, - 'CouId': str(cou_id), - 'O': str(self._CO_API_ORG_NAME) - } - if status: - if status not in self.STATUS_OPTIONS: - raise ValueError("Invalid Fields 'status'") - role['Status'] = str(status) - else: - role['Status'] = 'Active' - if affiliation: - affiliation = str(affiliation).lower() - if affiliation not in self.AFFILIATION_OPTIONS: - raise ValueError("Invalid Fields 'affiliation'") - role['Affiliation'] = str(affiliation) - else: - role['Affiliation'] = 'member' - return self._post('co_person_roles.json', { - 'RequestType': 'CoPersonRoles', - 'Version': '1.0', - 'CoPersonRoles': [role] - }) - - -def coperson_roles_delete(self, coperson_role_id: int) -> bool: - """ - Remove a CO Person Role. - - :param self: - :param coperson_role_id: - :return: - - Response Format - HTTP Status Response Body Description - 200 Deleted CoPersonRole deleted - 400 Invalid Fields id not provided - 401 Unauthorized Authentication required - 404 CoPersonRole Unknown id not found - 500 Other Error Unknown error - """ - return self._delete(f'co_person_roles/{coperson_role_id}.json') - - -def coperson_roles_edit(self, coperson_role_id: int, coperson_id: int = None, cou_id: int = None, status: str = None, - affiliation: str = None) -> bool: - """ - Edit an existing CO Person Role. - - :param self: - :param coperson_role_id: - :param affiliation: - :param coperson_id: - :param cou_id: - :param status: - - :return: - - Request Format - { - "RequestType":"CoPersonRoles", - "Version":"1.0", - "CoPersonRoles": - [ - { - "Version":"1.0", - "Person": - { - "Type":"CO", - "Id":"<coperson_id>" - }, - "CouId":"<cou_id>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ordr":"<Order>", - "Ou":"<Ou>", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"| - "GracePeriod"|"Invited"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended"), - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "ExtendedAttributes": - { - "<Attribute>":"<Value>", - {...} - } - } - ] - } - - Response Format - HTTP Status Response Body Description - 200 OK CoPersonRole updated - 400 Bad Request CoPersonRole Request not provided in POST body - 400 Invalid Fields ErrorRespons with details in An error in one or more provided fields - InvalidFields element - 401 Unauthorized Authentication required - 403 COU Does Not Exist The specified COU does not exist - 404 CoPersonRole Unknown id not found - 500 Other Error Unknown error - """ - existing = coperson_roles_view_one(self, coperson_role_id) - existing_role = existing.get('CoPersonRoles')[0] - role = { - 'Version': '1.0', - 'Person': { - 'Type': 'CO', - 'Id': str(coperson_id) if coperson_id else str(existing_role.get('Person').get('Id')) - }, - 'CouId': str(cou_id) if cou_id else str(existing_role.get('CouId')), - 'O': str(self._CO_API_ORG_NAME) - } - if status: - if status not in self.STATUS_OPTIONS: - raise ValueError("Invalid Fields 'status'") - role['Status'] = str(status) - else: - role['Status'] = existing_role.get('Status') - if affiliation: - affiliation = str(affiliation).lower() - if affiliation not in self.AFFILIATION_OPTIONS: - raise ValueError("Invalid Fields 'affiliation'") - role['Affiliation'] = str(affiliation) - else: - role['Affiliation'] = existing_role.get('Affiliation') - return self._put(f'co_person_roles/{coperson_role_id}.json', { - 'RequestType': 'CoPersonRoles', - 'Version': '1.0', - 'CoPersonRoles': [role] - }) - - -def coperson_roles_view_all(self) -> dict: - """ - Retrieve all existing CO Person Roles. - - :param self: - :return - { - "ResponseType":"CoPersonRoles", - "Version":"1.0", - "CoPersonRoles": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - "CouId":"<CouId>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ordr":"<Order>", - "Ou":"<Ou>", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"|"GracePeriod"|"Invited"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended"), - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "Created":"<CreateTime>", - "Modified":"<ModTime>", - "ExtendedAttributes": - { - "<Attribute>":"<Value>", - {...} - } - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPersonRole Response CoPersonRoles returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('co_person_roles.json') - - -def coperson_roles_view_per_coperson(self, coperson_id: int) -> dict: - """ - Retrieve all existing CO Person Roles for the specified CO Person. Available since Registry v2.0.0. - - :param self: - :param coperson_id: - :return - { - "ResponseType":"CoPersonRoles", - "Version":"1.0", - "CoPersonRoles": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - "CouId":"<CouId>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ordr":"<Order>", - "Ou":"<Ou>", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"|"GracePeriod"|"Invited"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended"), - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "Created":"<CreateTime>", - "Modified":"<ModTime>", - "ExtendedAttributes": - { - "<Attribute>":"<Value>", - {...} - } - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPersonRole Response CoPersonRoles returned - 401 Unauthorized Authentication required - 404 CO Person Unknown id not found - 500 Other Error Unknown error - """ - return self._get('co_person_roles.json', params={'copersonid': int(coperson_id)}) - - -def coperson_roles_view_per_cou(self, cou_id: int) -> dict: - """ - Retrieve all existing CO Person Roles for the specified COU. - - :param self: - :param cou_id: - :return - { - "ResponseType":"CoPersonRoles", - "Version":"1.0", - "CoPersonRoles": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - "CouId":"<CouId>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ordr":"<Order>", - "Ou":"<Ou>", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"|"GracePeriod"|"Invited"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended"), - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "Created":"<CreateTime>", - "Modified":"<ModTime>", - "ExtendedAttributes": - { - "<Attribute>":"<Value>", - {...} - } +class CoPersonRolesMixin: + """Mixin providing CoPersonRole API methods.""" + + def coperson_roles_add(self, coperson_id: int, cou_id: int, status: str = None, + affiliation: str = None) -> dict: + """ + Add a new CO Person Role. + + :param coperson_id: + :param cou_id: + :param status: + :param affiliation: + + Response Format + HTTP Status Response Body Description + 201 Added NewObjectResponse with ObjectType CoPersonRole created + 400 Bad Request CoPersonRole Request not provided in POST body + 401 Unauthorized Authentication required + 403 COU Does Not Exist The specified COU does not exist + 500 Other Error Unknown error + """ + role = { + 'Version': '1.0', + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPersonRole Response CoPersonRoles returned - 401 Unauthorized Authentication required - 404 COU Unknown id not found - 500 Other Error Unknown error - """ - return self._get('co_person_roles.json', params={'couid': int(cou_id)}) - - -def coperson_roles_view_one(self, coperson_role_id: int) -> dict: - """ - Retrieve an existing CO Person Role. - - :param self: - :param coperson_role_id: - :return - { - "ResponseType":"CoPersonRoles", - "Version":"1.0", - "CoPersonRoles": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - "CouId":"<CouId>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ordr":"<Order>", - "Ou":"<Ou>", - "Status":("Active"|"Approved"|"Confirmed"|"Declined"|"Deleted"|"Denied"|"Duplicate"|"Expired"|"GracePeriod"|"Invited"|"Pending"|"PendingApproval"|"PendingConfirmation"|"Suspended"), - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "Created":"<CreateTime>", - "Modified":"<ModTime>", - "ExtendedAttributes": - { - "<Attribute>":"<Value>", - {...} - } + 'CouId': str(cou_id), + 'O': str(self._CO_API_ORG_NAME) + } + if status: + if status not in self.STATUS_OPTIONS: + raise ValueError("Invalid Fields 'status'") + role['Status'] = str(status) + else: + role['Status'] = 'Active' + if affiliation: + affiliation = str(affiliation).lower() + if affiliation not in self.AFFILIATION_OPTIONS: + raise ValueError("Invalid Fields 'affiliation'") + role['Affiliation'] = str(affiliation) + else: + role['Affiliation'] = 'member' + return self._post('co_person_roles.json', { + 'RequestType': 'CoPersonRoles', + 'Version': '1.0', + 'CoPersonRoles': [role] + }) + + def coperson_roles_delete(self, coperson_role_id: int) -> bool: + """ + Remove a CO Person Role. + + :param coperson_role_id: + + Response Format + HTTP Status Response Body Description + 200 Deleted CoPersonRole deleted + 400 Invalid Fields id not provided + 401 Unauthorized Authentication required + 404 CoPersonRole Unknown id not found + 500 Other Error Unknown error + """ + return self._delete(f'co_person_roles/{coperson_role_id}.json') + + def coperson_roles_edit(self, coperson_role_id: int, coperson_id: int = None, cou_id: int = None, + status: str = None, affiliation: str = None) -> bool: + """ + Edit an existing CO Person Role. + + :param coperson_role_id: + :param coperson_id: + :param cou_id: + :param status: + :param affiliation: + + Response Format + HTTP Status Response Body Description + 200 OK CoPersonRole updated + 400 Bad Request CoPersonRole Request not provided in body + 401 Unauthorized Authentication required + 403 COU Does Not Exist The specified COU does not exist + 404 CoPersonRole Unknown id not found + 500 Other Error Unknown error + """ + existing = self.coperson_roles_view_one(coperson_role_id) + existing_role = existing.get('CoPersonRoles')[0] + role = { + 'Version': '1.0', + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) if coperson_id else str(existing_role.get('Person').get('Id')) }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK CoPersonRole Response CoPersonRoles returned - 401 Unauthorized Authentication required - 404 CoPersonRole Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'co_person_roles/{coperson_role_id}.json') + 'CouId': str(cou_id) if cou_id else str(existing_role.get('CouId')), + 'O': str(self._CO_API_ORG_NAME) + } + if status: + if status not in self.STATUS_OPTIONS: + raise ValueError("Invalid Fields 'status'") + role['Status'] = str(status) + else: + role['Status'] = existing_role.get('Status') + if affiliation: + affiliation = str(affiliation).lower() + if affiliation not in self.AFFILIATION_OPTIONS: + raise ValueError("Invalid Fields 'affiliation'") + role['Affiliation'] = str(affiliation) + else: + role['Affiliation'] = existing_role.get('Affiliation') + return self._put(f'co_person_roles/{coperson_role_id}.json', { + 'RequestType': 'CoPersonRoles', + 'Version': '1.0', + 'CoPersonRoles': [role] + }) + + def coperson_roles_view_all(self) -> dict: + """ + Retrieve all existing CO Person Roles. + + Response Format + HTTP Status Response Body Description + 200 OK CoPersonRole Response CoPersonRoles returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('co_person_roles.json') + + def coperson_roles_view_per_coperson(self, coperson_id: int) -> dict: + """ + Retrieve all existing CO Person Roles for the specified CO Person. Available since Registry v2.0.0. + + :param coperson_id: + + Response Format + HTTP Status Response Body Description + 200 OK CoPersonRole Response CoPersonRoles returned + 401 Unauthorized Authentication required + 404 CO Person Unknown id not found + 500 Other Error Unknown error + """ + return self._get('co_person_roles.json', params={'copersonid': int(coperson_id)}) + + def coperson_roles_view_per_cou(self, cou_id: int) -> dict: + """ + Retrieve all existing CO Person Roles for the specified COU. + + :param cou_id: + + Response Format + HTTP Status Response Body Description + 200 OK CoPersonRole Response CoPersonRoles returned + 401 Unauthorized Authentication required + 404 COU Unknown id not found + 500 Other Error Unknown error + """ + return self._get('co_person_roles.json', params={'couid': int(cou_id)}) + + def coperson_roles_view_one(self, coperson_role_id: int) -> dict: + """ + Retrieve an existing CO Person Role. + + :param coperson_role_id: + + Response Format + HTTP Status Response Body Description + 200 OK CoPersonRole Response CoPersonRoles returned + 401 Unauthorized Authentication required + 404 CoPersonRole Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'co_person_roles/{coperson_role_id}.json') diff --git a/comanage_api/_cous.py b/comanage_api/_cous.py index 1ac6b84..5a3816c 100644 --- a/comanage_api/_cous.py +++ b/comanage_api/_cous.py @@ -2,288 +2,136 @@ """ COU API - https://spaces.at.internet2.edu/display/COmanage/COU+API - -Methods -------- -cous_add(name: str, description: str, parent_id: int = None) -> dict - Add a new Cou. -cous_delete(cou_id: int) -> bool - Remove a Cou. -cous_edit(cou_id: int, name: str = None, description: str = None, parent_id: int = None) -> bool - Edit an existing Cou. -cous_view_all() -> dict - Retrieve all existing Cous. -cous_view_per_co() -> dict - Retrieve Cou attached to a CO. -cous_view_one(cou_id: int) -> dict - Retrieve an existing Cou. """ -def cous_add(self, name: str, description: str, parent_id: int = None) -> dict: - """ - Add a new Cou. - - :param self: - :param name: - :param description: - :param parent_id: - :return - { - "ResponseType":"NewObject", - "Version":"1.0", - "ObjectType":"Cou", - "Id":"<INTEGER>" - }: - - Request Format - { - "RequestType":"Cous", - "Version":"1.0", - "Cous": - [ - { - "Version":"1.0", - "CoId":"<CO_API_ORG_ID>", - "ParentId":"<parent_id>", - "Name":"<name>", - "Description":"<description>", - } - ] +class COUsMixin: + """Mixin providing COU API methods.""" + + def cous_add(self, name: str, description: str, parent_id: int = None) -> dict: + """ + Add a new Cou. + + :param name: + :param description: + :param parent_id: + + Response Format + HTTP Status Response Body Description + 201 Added NewObjectResponse with ObjectType Cou added + 400 Bad Request Cou Request not provided in POST body + 401 Unauthorized Authentication required + 403 CO Does Not Exist The specified CO does not exist + 403 Name In Use A COU already exists with the specified name + 500 Other Error Unknown error + """ + cou = { + 'Version': '1.0', + 'CoId': self._CO_API_ORG_ID, + 'Name': str(name), + 'Description': str(description) } - - Response Format - HTTP Status Response Body Description - 201 Added NewObjectResponse with ObjectType Cou added - 400 Bad Request Cou Request not provided in POST body - 400 Invalid Fields ErrorRespons with details in An error in one or more provided fields - InvalidFields element - 401 Unauthorized Authentication required - 403 CO Does Not Exist The specified CO does not exist - 403 Name In Use A COU already exists with the specified name in - the specified CO - 403 Parent Would Parent COU can not be a descendant of the child - Create Cycle - 403 Wrong CO Parent/Child COU not member of same CO - 500 Other Error Unknown error - """ - cou = { - 'Version': '1.0', - 'CoId': self._CO_API_ORG_ID, - 'Name': str(name), - 'Description': str(description) - } - if parent_id: - cou['ParentId'] = str(parent_id) - return self._post('cous.json', { - 'RequestType': 'Cous', - 'Version': '1.0', - 'Cous': [cou] - }) - - -def cous_delete(self, cou_id: int) -> bool: - """ - Remove a Cou. - - :param self: - :param cou_id: - :return: - - Response Format - HTTP Status Response Body Description - 200 Deleted Cou deleted - 400 Invalid Fields id not provided - 401 Unauthorized Authentication required - 403 CoPersonRole Exists One or more CO Person Roles are members of this COU, - and so the COU cannot be removed - 404 Identifier Unknown id not found - 500 Other Error Unknown error - """ - return self._delete(f'cous/{cou_id}.json', params={'coid': self._CO_API_ORG_ID}) - - -def cous_edit(self, cou_id: int, name: str = None, description: str = None, parent_id: int = None) -> bool: - """ - Edit an existing Cou. - - :param self: - :param cou_id: - :param name: - :param description: - :param parent_id: - :return - { - "status_code": 200, - "reason": "OK" - }: - - Request Format - { - "RequestType":"Cous", - "Version":"1.0", - "Cous": - [ - { - "Version":"1.0", - "CoId":"<CO_API_ORG_ID>", - "ParentId":"<parent_id>", - "Name":"<name>", - "Description":"<description>", - } - ] + if parent_id: + cou['ParentId'] = str(parent_id) + return self._post('cous.json', { + 'RequestType': 'Cous', + 'Version': '1.0', + 'Cous': [cou] + }) + + def cous_delete(self, cou_id: int) -> bool: + """ + Remove a Cou. + + :param cou_id: + + Response Format + HTTP Status Response Body Description + 200 Deleted Cou deleted + 400 Invalid Fields id not provided + 401 Unauthorized Authentication required + 404 Identifier Unknown id not found + 500 Other Error Unknown error + """ + return self._delete(f'cous/{cou_id}.json', params={'coid': self._CO_API_ORG_ID}) + + def cous_edit(self, cou_id: int, name: str = None, description: str = None, + parent_id: int = None) -> bool: + """ + Edit an existing Cou. + + :param cou_id: + :param name: + :param description: + :param parent_id: + + Response Format + HTTP Status Response Body Description + 200 OK Cou updated + 400 Bad Request Cou Request not provided in POST body + 401 Unauthorized Authentication required + 403 CO Does Not Exist The specified CO does not exist + 403 Name In Use A COU already exists with the specified name + 404 Identifier Unknown id not found + 500 Other Error Unknown error + """ + existing = self.cous_view_one(cou_id) + existing_cou = existing.get('Cous')[0] + cou = { + 'Version': '1.0', + 'CoId': self._CO_API_ORG_ID, + 'Name': str(name) if name else existing_cou.get('Name'), + 'Description': str(description) if description else existing_cou.get('Description') } - - Response Format - HTTP Status Response Body Description - 200 OK Cou updated - 400 Bad Request Cou Request not provided in POST body - 400 Invalid Fields ErrorRespons with details in An error in one or more provided fields - InvalidFields element - 401 Unauthorized Authentication required - 403 CO Does Not Exist The specified CO does not exist - 403 Name In Use A COU already exists with the specified name in - the specified CO - 403 Parent Would Parent COU can not be a descendant of the child - Create Cycle - 403 Wrong CO Parent/Child COU not member of same CO - 404 Identifier Unknown id not found - 500 Other Error Unknown error - """ - existing = cous_view_one(self, cou_id) - existing_cou = existing.get('Cous')[0] - cou = { - 'Version': '1.0', - 'CoId': self._CO_API_ORG_ID, - 'Name': str(name) if name else existing_cou.get('Name'), - 'Description': str(description) if description else existing_cou.get('Description') - } - if parent_id is not None and parent_id != 0: - cou['ParentId'] = str(parent_id) - elif parent_id == 0: - cou['ParentId'] = '' - else: - if existing_cou.get('ParentId'): - cou['ParentId'] = str(existing_cou.get('ParentId')) - return self._put(f'cous/{cou_id}.json', { - 'RequestType': 'Cous', - 'Version': '1.0', - 'Cous': [cou] - }) - - -def cous_view_all(self) -> dict: - """ - Retrieve all existing Cous. - - :param self: - :return - { - "ResponseType":"Cous", - "Version":"1.0", - "Cous":[ - { - "Version":"1.0", - "Id":"<INTEGER>", - "CoId":"<CO_API_ORG_ID>", - "Name":"<name>", - "Description":"<description>", - "Lft":"64", - "Rght":"65", - "Created":"2021-09-14 14:53:02", - "Modified":"2021-09-14 14:53:02", - "Revision":"0", - "Deleted":false, - "ActorIdentifier":"<COmanage_ID>" - }, - { - ... - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Cou Response Cou returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('cous.json') - - -def cous_view_per_co(self) -> dict: - """ - Retrieve Cou attached to a CO. - - :param self: - :return - { - "ResponseType":"Cous", - "Version":"1.0", - "Cous":[ - { - "Version":"1.0", - "Id":"<INTEGER>", - "CoId":"<CO_API_ORG_ID>", - "Name":"<name>", - "Description":"<description>", - "Lft":"64", - "Rght":"65", - "Created":"2021-09-14 14:53:02", - "Modified":"2021-09-14 14:53:02", - "Revision":"0", - "Deleted":false, - "ActorIdentifier":"<COmanage_ID>" - }, - { - ... - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Cou Response Cou returned - 401 Unauthorized Authentication required - 404 CO Unknown id not found - 500 Other Error Unknown error - """ - return self._get('cous.json', params={'coid': self._CO_API_ORG_ID}) - - -def cous_view_one(self, cou_id: int) -> dict: - """ - Retrieve an existing Cou. - - :param self: - :param cou_id: - :return - { - "ResponseType":"Cous", - "Version":"1.0", - "Cous":[ - { - "Version":"1.0", - "Id":"<INTEGER>", - "CoId":"<CO_API_ORG_ID>", - "Name":"<name>", - "Description":"<description>", - "Lft":"64", - "Rght":"65", - "Created":"2021-09-14 14:53:02", - "Modified":"2021-09-14 14:53:02", - "Revision":"0", - "Deleted":false, - "ActorIdentifier":"<COmanage_ID>" - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Cou Response Cou returned - 401 Unauthorized Authentication required - 404 COU Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'cous/{cou_id}.json', params={'coid': self._CO_API_ORG_ID}) + if parent_id is not None and parent_id != 0: + cou['ParentId'] = str(parent_id) + elif parent_id == 0: + cou['ParentId'] = '' + else: + if existing_cou.get('ParentId'): + cou['ParentId'] = str(existing_cou.get('ParentId')) + return self._put(f'cous/{cou_id}.json', { + 'RequestType': 'Cous', + 'Version': '1.0', + 'Cous': [cou] + }) + + def cous_view_all(self) -> dict: + """ + Retrieve all existing Cous. + + Response Format + HTTP Status Response Body Description + 200 OK Cou Response Cou returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('cous.json') + + def cous_view_per_co(self) -> dict: + """ + Retrieve Cou attached to a CO. + + Response Format + HTTP Status Response Body Description + 200 OK Cou Response Cou returned + 401 Unauthorized Authentication required + 404 CO Unknown id not found + 500 Other Error Unknown error + """ + return self._get('cous.json', params={'coid': self._CO_API_ORG_ID}) + + def cous_view_one(self, cou_id: int) -> dict: + """ + Retrieve an existing Cou. + + :param cou_id: + + Response Format + HTTP Status Response Body Description + 200 OK Cou Response Cou returned + 401 Unauthorized Authentication required + 404 COU Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'cous/{cou_id}.json', params={'coid': self._CO_API_ORG_ID}) diff --git a/comanage_api/_emailaddresses.py b/comanage_api/_emailaddresses.py index c648a6f..eaf0286 100644 --- a/comanage_api/_emailaddresses.py +++ b/comanage_api/_emailaddresses.py @@ -2,185 +2,63 @@ """ EmailAddress API - https://spaces.at.internet2.edu/display/COmanage/EmailAddress+API - -Methods -------- -email_addresses_add() -> dict - ### NOT IMPLEMENTED ### - Add a new EmailAddress. -email_addresses_delete() -> bool - ### NOT IMPLEMENTED ### - Remove an EmailAddress. -email_addresses_edit() -> bool - ### NOT IMPLEMENTED ### - Edit an existing EmailAddress. -email_addresses_view_all() -> dict - Retrieve all existing EmailAddresses. -email_addresses_view_per_person(person_type: str, person_id: int) -> dict - Retrieve EmailAddresses attached to a CO Department, CO Person, or Org Identity. -email_addresses_view_one(email_address_id: int) -> dict - Retrieve an existing EmailAddress. """ -def email_addresses_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Add a new EmailAddress. - - :param self: - """ - raise NotImplementedError("email_addresses_add() is not implemented") - - -def email_addresses_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Remove an EmailAddress. - - :param self: - """ - raise NotImplementedError("email_addresses_delete() is not implemented") - - -def email_addresses_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Edit an existing EmailAddress. - - :param self: - """ - raise NotImplementedError("email_addresses_edit() is not implemented") - - -def email_addresses_view_all(self) -> dict: - """ - Retrieve all existing EmailAddresses. - - :param self: - :return - { - "ResponseType":"EmailAddresses", - "Version":"1.0", - "EmailAddresses": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Mail":"<Mail>", - "Type":<"Type">, - "Description":"<Description>", - "Verified":true|false, - "Person": - { - "Type":("CO"|"Dept"|"Org"|"Organization"), - "Id":"<ID>" - } - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK EmailAddress Response EmailAddresses returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('email_addresses.json') - - -def email_addresses_view_per_person(self, person_type: str, person_id: int) -> dict: - """ - Retrieve EmailAddresses attached to a CO Department, CO Person, or Org Identity. - - :param self: - :param person_type: - :param person_id: - :return - { - "ResponseType":"EmailAddresses", - "Version":"1.0", - "EmailAddresses": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Mail":"<Mail>", - "Type":<"Type">, - "Description":"<Description>", - "Verified":true|false, - "Person": - { - "Type":("CO"|"Dept"|"Org"|"Organization"), - "Id":"<ID>" - } - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK EmailAddress Response EmailAddress returned - 204 CO Department The requested CO Department was found, - Has No EmailAddress but has no email addresses attached - 204 CO Person The requested CO Person was found, - Has No EmailAddress but has no email addresses attached - 204 Organization The requested Organization was found, - Has No EmailAddress but has no email addresses attached - 204 Org Identity The requested Org Identity was found, - Has No EmailAddress but has no email addresses attached - 401 Unauthorized Authentication required - 404 CO Department Unknown id not found for CO Department - 404 CO Person Unknown id not found for CO Person - 404 Organization Unknown id not found for Organization - 404 Org Identity Unknown id not found for Org Identity - 500 Other Error Unknown error - """ - return self._get_by_entity('email_addresses.json', person_type, person_id, - self.EMAILADDRESS_OPTIONS, 'person_type') - - -def email_addresses_view_one(self, email_address_id: int) -> dict: - """ - Retrieve an existing EmailAddress. - - :param self: - :param emailaddress_id: - :return - { - "ResponseType":"EmailAddresses", - "Version":"1.0", - "EmailAddresses": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Mail":"<Mail>", - "Type":<"Type">, - "Description":"<Description>", - "Verified":true|false, - "Person": - { - "Type":("CO"|"Dept"|"Org"|"Organization"), - "Id":"<ID>" - } - "Created":"<CreateTime>", - "Modified":"<ModTime>" - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK EmailAddress Response EmailAddress returned - 401 Unauthorized Authentication required - 404 EmailAddress Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'email_addresses/{email_address_id}.json') +class EmailAddressesMixin: + """Mixin providing EmailAddress API methods.""" + + def email_addresses_add(self) -> dict: + """### NOT IMPLEMENTED ### Add a new EmailAddress.""" + raise NotImplementedError("email_addresses_add() is not implemented") + + def email_addresses_delete(self) -> bool: + """### NOT IMPLEMENTED ### Remove an EmailAddress.""" + raise NotImplementedError("email_addresses_delete() is not implemented") + + def email_addresses_edit(self) -> bool: + """### NOT IMPLEMENTED ### Edit an existing EmailAddress.""" + raise NotImplementedError("email_addresses_edit() is not implemented") + + def email_addresses_view_all(self) -> dict: + """ + Retrieve all existing EmailAddresses. + + Response Format + HTTP Status Response Body Description + 200 OK EmailAddress Response EmailAddresses returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('email_addresses.json') + + def email_addresses_view_per_person(self, person_type: str, person_id: int) -> dict: + """ + Retrieve EmailAddresses attached to a CO Department, CO Person, or Org Identity. + + :param person_type: + :param person_id: + + Response Format + HTTP Status Response Body Description + 200 OK EmailAddress Response EmailAddress returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get_by_entity('email_addresses.json', person_type, person_id, + self.EMAILADDRESS_OPTIONS, 'person_type') + + def email_addresses_view_one(self, email_address_id: int) -> dict: + """ + Retrieve an existing EmailAddress. + + :param email_address_id: + + Response Format + HTTP Status Response Body Description + 200 OK EmailAddress Response EmailAddress returned + 401 Unauthorized Authentication required + 404 EmailAddress Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'email_addresses/{email_address_id}.json') diff --git a/comanage_api/_identifiers.py b/comanage_api/_identifiers.py index ccbd7b4..f2bcfec 100644 --- a/comanage_api/_identifiers.py +++ b/comanage_api/_identifiers.py @@ -2,193 +2,67 @@ """ Identifier API - https://spaces.at.internet2.edu/display/COmanage/Identifier+API - -Methods -------- -identifiers_add() -> dict - ### NOT IMPLEMENTED ### - Add a new Identifier. -identifiers_assign() -> bool - ### NOT IMPLEMENTED ### - Assign Identifiers for a CO Person. -identifiers_delete() -> bool - ### NOT IMPLEMENTED ### - Remove an Identifier. -identifiers_edit() -> bool - ### NOT IMPLEMENTED ### - Edit an existing Identifier. -identifiers_view_all() -> dict - Retrieve all existing Identifiers. -identifiers_view_per_entity(entity_type: str, entity_id: int) -> dict - Retrieve Identifiers attached to a CO Department, Co Group, CO Person, or Org Identity. -identifiers_view_one(identifier_id: int) -> dict - Retrieve an existing Identifier. """ -def identifiers_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Add a new Identifier. - - :param self: - """ - raise NotImplementedError("identifiers_add() is not implemented") - - -def identifiers_assign(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Assign Identifiers for a CO Person. - - :param self: - """ - raise NotImplementedError("identifiers_assign() is not implemented") - - -def identifiers_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Remove an Identifier. - - :param self: - """ - raise NotImplementedError("identifiers_delete() is not implemented") - - -def identifiers_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Edit an existing Identifier. - - :param self: - """ - raise NotImplementedError("identifiers_edit() is not implemented") - - -def identifiers_view_all(self) -> dict: - """ - Retrieve all existing Identifiers. - - :param self: - :return - { - "ResponseType":"Identifiers", - "Version":"1.0", - "Identifiers": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Type":"<Type>", - "Identifier":"<Identifier>", - "Login":true|false, - "Person":{"Type":("CO"|"Dept"|"Group"|"Org"|"Organization"),"ID":"<ID>"}, - "CoProvisioningTargetId":"<CoProvisioningTargetId>", - "Status":"Active"|"Deleted", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Identifier Response Identifiers returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('identifiers.json') - - -def identifiers_view_per_entity(self, entity_type: str, entity_id: int) -> dict: - """ - Retrieve Identifiers attached to a CO Department, Co Group, CO Person, or Org Identity. - - :param self: - :param entity_type: - :param entity_id: - :return - { - "ResponseType":"Identifiers", - "Version":"1.0", - "Identifiers": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Type":"<Type>", - "Identifier":"<Identifier>", - "Login":true|false, - "Person":{"Type":("CO"|"Dept"|"Group"|"Org"|"Organization"),"ID":"<ID>"}, - "CoProvisioningTargetId":"<CoProvisioningTargetId>", - "Status":"Active"|"Deleted", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Identifier Response Identifier returned - 204 CO Department The requested CO Department was found, - Has No Identifier but has no identifiers attached - 204 CO Group The requested CO Group was found, - Has No Identifier but has no identifiers attached - 204 CO Person The requested CO Person was found, - Has No Identifier but has no identifiers attached - 204 Organization The requested Organization was found, - Has No Identifier but has no identifiers attached - 204 Org Identity The requested Org Identity was found, - Has No Identifier but has no identifiers attached - 401 Unauthorized Authentication required - 404 CO Department Unknown id not found for CO Department - 404 CO Group Unknown id not found for CO Group - 404 CO Person Unknown id not found for CO Person - 404 Organization Unknown id not found for Organization - 404 Org Identity Unknown id not found for Org Identity - 500 Other Error Unknown error - """ - return self._get_by_entity('identifiers.json', entity_type, entity_id, - self.ENTITY_OPTIONS, 'entity_type') - - -def identifiers_view_one(self, identifier_id: int) -> dict: - """ - Retrieve an existing Identifier. - - :param self: - :param identifier_id: - :return - { - "ResponseType":"Identifiers", - "Version":"1.0", - "Identifiers": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Type":"<Type>", - "Identifier":"<Identifier>", - "Login":true|false, - "Person":{"Type":("CO"|"Dept"|"Group"|"Org"|"Organization"),"ID":"<ID>"}, - "CoProvisioningTargetId":"<CoProvisioningTargetId>", - "Status":"Active"|"Deleted", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Identifier Response Identifiers returned - 401 Unauthorized Authentication required - 404 Identifier Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'identifiers/{identifier_id}.json') +class IdentifiersMixin: + """Mixin providing Identifier API methods.""" + + def identifiers_add(self) -> dict: + """### NOT IMPLEMENTED ### Add a new Identifier.""" + raise NotImplementedError("identifiers_add() is not implemented") + + def identifiers_assign(self) -> bool: + """### NOT IMPLEMENTED ### Assign Identifiers for a CO Person.""" + raise NotImplementedError("identifiers_assign() is not implemented") + + def identifiers_delete(self) -> bool: + """### NOT IMPLEMENTED ### Remove an Identifier.""" + raise NotImplementedError("identifiers_delete() is not implemented") + + def identifiers_edit(self) -> bool: + """### NOT IMPLEMENTED ### Edit an existing Identifier.""" + raise NotImplementedError("identifiers_edit() is not implemented") + + def identifiers_view_all(self) -> dict: + """ + Retrieve all existing Identifiers. + + Response Format + HTTP Status Response Body Description + 200 OK Identifier Response Identifiers returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('identifiers.json') + + def identifiers_view_per_entity(self, entity_type: str, entity_id: int) -> dict: + """ + Retrieve Identifiers attached to a CO Department, Co Group, CO Person, or Org Identity. + + :param entity_type: + :param entity_id: + + Response Format + HTTP Status Response Body Description + 200 OK Identifier Response Identifier returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get_by_entity('identifiers.json', entity_type, entity_id, + self.ENTITY_OPTIONS, 'entity_type') + + def identifiers_view_one(self, identifier_id: int) -> dict: + """ + Retrieve an existing Identifier. + + :param identifier_id: + + Response Format + HTTP Status Response Body Description + 200 OK Identifier Response Identifiers returned + 401 Unauthorized Authentication required + 404 Identifier Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'identifiers/{identifier_id}.json') diff --git a/comanage_api/_names.py b/comanage_api/_names.py index 815d102..83a33b7 100644 --- a/comanage_api/_names.py +++ b/comanage_api/_names.py @@ -2,188 +2,63 @@ """ Name API - https://spaces.at.internet2.edu/display/COmanage/Name+API - -Methods -------- -names_add() -> dict - ### NOT IMPLEMENTED ### - Add a new Name. -names_delete() -> bool - ### NOT IMPLEMENTED ### - Remove a Name. -names_edit() -> bool - ### NOT IMPLEMENTED ### - Edit an existing Name. -names_view_all() -> dict - Retrieve all existing Names. -names_view_per_person(person_type: str, person_id: int) -> dict - Retrieve Names attached to a CO Person or Org Identity. -names_view_one(name_id: int) -> dict - Retrieve Names attached to a CO Person or Org Identity. """ -def names_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Add a new Name. - - :param self: - """ - raise NotImplementedError("names_add() is not implemented") - - -def names_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Remove a Name. - - :param self: - """ - raise NotImplementedError("names_delete() is not implemented") - - -def names_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Edit an existing Name. - - :param self: - """ - raise NotImplementedError("names_edit() is not implemented") - - -def names_view_all(self) -> dict: - """ - Retrieve all existing Names. - - :param self: - :return - { - "ResponseType":"Names", - "Version":"1.0", - "Names": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Honorific":"<Honorific>", - "Given":"<Given>", - "Middle":"<Middle>", - "Family":"<Family>", - "Suffix":"<Suffix>", - "Type":"<Type>", - "Language":"<Language>", - "PrimaryName":true|false, - "Person": - { - "Type":("CO"|"Org"), - "Id":"<ID>" - } - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Name Response Name returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('names.json') - - -def names_view_per_person(self, person_type: str, person_id: int) -> dict: - """ - Retrieve Names attached to a CO Person or Org Identity. - - :param self: - :param person_type: - :param person_id: - :return - { - "ResponseType":"Names", - "Version":"1.0", - "Names": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Honorific":"<Honorific>", - "Given":"<Given>", - "Middle":"<Middle>", - "Family":"<Family>", - "Suffix":"<Suffix>", - "Type":"<Type>", - "Language":"<Language>", - "PrimaryName":true|false, - "Person": - { - "Type":("CO"|"Org"), - "Id":"<ID>" - } - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Name Response Name returned - 401 Unauthorized Authentication required - 404 CO Person Unknown id not found for CO Person - 404 Org Identity Unknown id not found for Org Identity - 500 Other Error Unknown error - """ - return self._get_by_entity('names.json', person_type, person_id, - self.PERSON_OPTIONS, 'person_type') - - -def names_view_one(self, name_id: int) -> dict: - """ - Retrieve Names attached to a CO Person or Org Identity. - - :param self: - :param name_id: - :return - { - "ResponseType":"Names", - "Version":"1.0", - "Names": - [ - { - "Version":"1.0", - "Id":"<ID>", - "Honorific":"<Honorific>", - "Given":"<Given>", - "Middle":"<Middle>", - "Family":"<Family>", - "Suffix":"<Suffix>", - "Type":"<Type>", - "Language":"<Language>", - "PrimaryName":true|false, - "Person": - { - "Type":("CO"|"Org"), - "Id":"<ID>" - } - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK Name Response Name returned - 401 Unauthorized Authentication required - 404 Name Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'names/{name_id}.json') +class NamesMixin: + """Mixin providing Name API methods.""" + + def names_add(self) -> dict: + """### NOT IMPLEMENTED ### Add a new Name.""" + raise NotImplementedError("names_add() is not implemented") + + def names_delete(self) -> bool: + """### NOT IMPLEMENTED ### Remove a Name.""" + raise NotImplementedError("names_delete() is not implemented") + + def names_edit(self) -> bool: + """### NOT IMPLEMENTED ### Edit an existing Name.""" + raise NotImplementedError("names_edit() is not implemented") + + def names_view_all(self) -> dict: + """ + Retrieve all existing Names. + + Response Format + HTTP Status Response Body Description + 200 OK Name Response Name returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('names.json') + + def names_view_per_person(self, person_type: str, person_id: int) -> dict: + """ + Retrieve Names attached to a CO Person or Org Identity. + + :param person_type: + :param person_id: + + Response Format + HTTP Status Response Body Description + 200 OK Name Response Name returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get_by_entity('names.json', person_type, person_id, + self.PERSON_OPTIONS, 'person_type') + + def names_view_one(self, name_id: int) -> dict: + """ + Retrieve an existing Name. + + :param name_id: + + Response Format + HTTP Status Response Body Description + 200 OK Name Response Name returned + 401 Unauthorized Authentication required + 404 Name Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'names/{name_id}.json') diff --git a/comanage_api/_orgidentities.py b/comanage_api/_orgidentities.py index 5966ecc..5550721 100644 --- a/comanage_api/_orgidentities.py +++ b/comanage_api/_orgidentities.py @@ -2,219 +2,79 @@ """ OrgIdentity API - https://spaces.at.internet2.edu/display/COmanage/OrgIdentity+API - -Methods -------- -org_identities_add() -> dict - ### NOT IMPLEMENTED ### - Add a new Organizational Identity. A person must have an OrgIdentity before they can be added to a CO. -org_identities_delete() -> bool - ### NOT IMPLEMENTED ### - Remove an Organizational Identity. - The person must be removed from any COs (CoPerson) before the OrgIdentity record can be removed. - This method will also delete related data, such as Addresses, EmailAddresses, and TelephoneNumbers. -org_identities_edit() -> bool - ### NOT IMPLEMENTED ### - Edit an existing Organizational Identity. -org_identities_view_all() -> dict - Retrieve all existing Organizational Identities. -org_identities_view_per_co(person_type: str, person_id: int) -> dict - Retrieve all existing Organizational Identities for the specified CO. -org_identities_view_per_identifier(identifier_id: int) -> dict - Retrieve all existing Organizational Identities attached to the specified identifier. - Note the specified identifier must be attached to an Org Identity, not a CO Person. -org_identities_view_one(org_identity_id: int) -> dict - Retrieve an existing Organizational Identity. """ -def org_identities_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - Add a new Organizational Identity. A person must have an OrgIdentity before they can be added to a CO. - - :param self: - """ - raise NotImplementedError("org_identities_add() is not implemented") - - -def org_identities_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Remove an Organizational Identity. - The person must be removed from any COs (CoPerson) before the OrgIdentity record can be removed. - This method will also delete related data, such as Addresses, EmailAddresses, and TelephoneNumbers. - - :param self: - """ - raise NotImplementedError("org_identities_delete() is not implemented") - - -def org_identities_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - Edit an existing Organizational Identity. - - :param self: - """ - raise NotImplementedError("org_identities_edit() is not implemented") - - -def org_identities_view_all(self) -> dict: - """ - Retrieve all existing Organizational Identities. - - :param self: - :return - { - "ResponseType":"OrgIdentities", - "Version":"1.0", - "OrgIdentities": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ou":"<Ou>", - "CoId":"<CoId>", - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "DateOfBirth":"<DateOfBirth>", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK OrgIdentity Response OrgIdentity returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get('org_identities.json') - - -def org_identities_view_per_co(self) -> dict: - """ - Retrieve all existing Organizational Identities for the specified CO. - - :param self: - :return - { - "ResponseType":"OrgIdentities", - "Version":"1.0", - "OrgIdentities": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ou":"<Ou>", - "CoId":"<CoId>", - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "DateOfBirth":"<DateOfBirth>", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK OrgIdentity Response OrgIdentity returned - 401 Unauthorized Authentication required - 404 CO Unknown id not found - 500 Other Error Unknown error - """ - return self._get('org_identities.json', params={'coid': self._CO_API_ORG_ID}) - - -def org_identities_view_per_identifier(self, identifier_id: int) -> dict: - """ - Retrieve all existing Organizational Identities attached to the specified identifier. - Note the specified identifier must be attached to an Org Identity, not a CO Person. - - :param self: - :param identifier_id: - :return - { - "ResponseType":"OrgIdentities", - "Version":"1.0", - "OrgIdentities": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ou":"<Ou>", - "CoId":"<CoId>", - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "DateOfBirth":"<DateOfBirth>", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK OrgIdentity Response OrgIdentity returned - 401 Unauthorized Authentication required - 404 CO Unknown id not found - 500 Other Error Unknown error - """ - return self._get('org_identities.json', params={ - 'coid': self._CO_API_ORG_ID, - 'search.identifier': int(identifier_id) - }) - - -def org_identities_view_one(self, org_identity_id: int) -> dict: - """ - Retrieve an existing Organizational Identity. - - :param self: - :param org_identity_id: - :return - { - "ResponseType":"OrgIdentities", - "Version":"1.0", - "OrgIdentities": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Affiliation":"<Affiliation>", - "Title":"<Title>", - "O":"<O>", - "Ou":"<Ou>", - "CoId":"<CoId>", - "ValidFrom":"<ValidFrom>", - "ValidThrough":"<ValidThrough>", - "DateOfBirth":"<DateOfBirth>", - "Created":"<CreateTime>", - "Modified":"<ModTime>" - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK OrgIdentity Response OrgIdentity returned - 401 Unauthorized Authentication required - 404 OrgIdentity Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'org_identities/{org_identity_id}.json', params={'coid': self._CO_API_ORG_ID}) +class OrgIdentitiesMixin: + """Mixin providing OrgIdentity API methods.""" + + def org_identities_add(self) -> dict: + """### NOT IMPLEMENTED ### Add a new Organizational Identity.""" + raise NotImplementedError("org_identities_add() is not implemented") + + def org_identities_delete(self) -> bool: + """### NOT IMPLEMENTED ### Remove an Organizational Identity.""" + raise NotImplementedError("org_identities_delete() is not implemented") + + def org_identities_edit(self) -> bool: + """### NOT IMPLEMENTED ### Edit an existing Organizational Identity.""" + raise NotImplementedError("org_identities_edit() is not implemented") + + def org_identities_view_all(self) -> dict: + """ + Retrieve all existing Organizational Identities. + + Response Format + HTTP Status Response Body Description + 200 OK OrgIdentity Response OrgIdentity returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get('org_identities.json') + + def org_identities_view_per_co(self) -> dict: + """ + Retrieve all existing Organizational Identities for the specified CO. + + Response Format + HTTP Status Response Body Description + 200 OK OrgIdentity Response OrgIdentity returned + 401 Unauthorized Authentication required + 404 CO Unknown id not found + 500 Other Error Unknown error + """ + return self._get('org_identities.json', params={'coid': self._CO_API_ORG_ID}) + + def org_identities_view_per_identifier(self, identifier_id: int) -> dict: + """ + Retrieve all existing Organizational Identities attached to the specified identifier. + Note the specified identifier must be attached to an Org Identity, not a CO Person. + + :param identifier_id: + + Response Format + HTTP Status Response Body Description + 200 OK OrgIdentity Response OrgIdentity returned + 401 Unauthorized Authentication required + 404 CO Unknown id not found + 500 Other Error Unknown error + """ + return self._get('org_identities.json', params={ + 'coid': self._CO_API_ORG_ID, + 'search.identifier': int(identifier_id) + }) + + def org_identities_view_one(self, org_identity_id: int) -> dict: + """ + Retrieve an existing Organizational Identity. + + :param org_identity_id: + + Response Format + HTTP Status Response Body Description + 200 OK OrgIdentity Response OrgIdentity returned + 401 Unauthorized Authentication required + 404 OrgIdentity Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'org_identities/{org_identity_id}.json', params={'coid': self._CO_API_ORG_ID}) diff --git a/comanage_api/_sshkeys.py b/comanage_api/_sshkeys.py index 82742ec..5e985bf 100644 --- a/comanage_api/_sshkeys.py +++ b/comanage_api/_sshkeys.py @@ -3,23 +3,6 @@ """ SshKey API - https://spaces.at.internet2.edu/display/COmanage/SshKey+API -Methods -------- -ssh_keys_add(coperson_id: int, ssh_key: str, key_type: str, comment: str = None, - ssh_key_authenticator_id: int = None) -> dict - Add a new SSH Key. -ssh_keys_delete(ssh_key_id: int) -> bool - Remove an SSH Key. -ssh_keys_edit(ssh_key_id: int, coperson_id: int = None, ssh_key: str = None, key_type: str = None, - comment: str = None, ssh_key_authenticator_id: int = None) -> bool - Edit an exiting SSH Key. -ssh_keys_view_all() -> dict - Retrieve all existing SSH Keys. -ssh_keys_view_per_coperson(coperson_id: int) -> dict - Retrieve all existing SSH Keys for the specified CO Person. -ssh_keys_view_one(ssh_key_id: int) -> dict - Retrieve an existing SSH Key. - Notes ----- Experimental @@ -35,326 +18,168 @@ - Authenticators that are locked cannot be managed by the API. """ - # SSH Key plugin base path _SSH_KEY_PATH = 'ssh_key_authenticator/ssh_keys' -def ssh_keys_add(self, coperson_id: int, ssh_key: str, key_type: str, comment: str = None, - ssh_key_authenticator_id: int = None) -> dict: - """ - Add a new SSH Key. - - :param self: - :param coperson_id: - :param ssh_key: - :param key_type: - :param comment: - :param ssh_key_authenticator_id: - :return - { - "ResponseType":"SshKeys", - "Version":"1.0", - "SshKeys": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - , - "Comment":"<Comment>", - "Type":("ssh-dss"|"ecdsa-sha2-nistp256"|"ecdsa-sha2-nistp384"|"ecdsa-sha2-nistp521"| - "ssh-ed25519'"|"ssh-rsa"|"ssh-rsa1"), - "Skey":"<SshKey>", - "SshKeyAuthenticatorId":"<Id>" - }, - {...} - ] - }: - - Request Format - { - "RequestType":"SshKeys", - "Version":"1.0", - "SshKeys": - [ - { - "Version":"1.0", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - "Comment":"<Comment>", - "Type":("ssh-dss"|"ecdsa-sha2-nistp256"|"ecdsa-sha2-nistp384"|"ecdsa-sha2-nistp521"| - "ssh-ed25519'"|"ssh-rsa"|"ssh-rsa1"), - "Skey":"<SshKey>", - "SshKeyAuthenticatorId":"<Id>" - } - ] - }: - - Response Format - HTTP Status Response Body Description - 201 Added NewObjectResponse with SSH Key created - ObjectType of SshKey - 400 Bad Request SSH Key Request not provided in POST body - 400 Invalid Fields ErrorResponse with details in An error in one or more provided fields - InvalidFields element - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - if not ssh_key_authenticator_id: - ssh_key_authenticator_id = self._CO_SSH_KEY_AUTHENTICATOR_ID - key_type = str(key_type).lower() - if key_type not in self.SSH_KEY_OPTIONS: - raise ValueError("Invalid Fields 'key_type'") - return self._post(f'{_SSH_KEY_PATH}.json', { - 'RequestType': 'SshKeys', - 'Version': '1.0', - 'SshKeys': [ - { - 'Version': '1.0', - 'Person': { - 'Type': 'CO', - 'Id': str(coperson_id) - }, - 'Comment': str(comment) if comment else '', - 'Type': str(key_type), - 'Skey': str(ssh_key), - 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) - } - ] - }) - - -def ssh_keys_delete(self, ssh_key_id: int) -> bool: - """ - Remove an SSH Key. - - :param self: - :param ssh_key_id: - :return:: - - Response Format - HTTP Status Response Body Description - 200 OK SSH Key updated - 400 Invalid Fields An error in one or more provided fields - 401 Unauthorized Authentication required - 404 SshKey Unknown id not found - 500 Other Error Unknown error - """ - return self._delete(f'{_SSH_KEY_PATH}/{ssh_key_id}.json') - - -def ssh_keys_edit(self, ssh_key_id: int, coperson_id: int = None, ssh_key: str = None, key_type: str = None, - comment: str = None, ssh_key_authenticator_id: int = None) -> bool: - """ - Edit an exiting SSH Key. - - :param self: - :param ssh_key_id: - :param coperson_id: - :param ssh_key: - :param key_type: - :param comment: - :param ssh_key_authenticator_id: - :return: - - Request Format - { - "RequestType":"SshKeys", - "Version":"1.0", - "SshKeys": - [ - { - "Version":"1.0", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - "Comment":"<Comment>", - "Type":("ssh-dss"|"ecdsa-sha2-nistp256"|"ecdsa-sha2-nistp384"|"ecdsa-sha2-nistp521"| - "ssh-ed25519'"|"ssh-rsa"|"ssh-rsa1"), - "Skey":"<SshKey>", - "SshKeyAuthenticatorId":"<Id>" - } - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK SSH Key updated - 400 Bad Request SSH Key Request not provided in POST body - 400 Invalid Fields ErrorResponse with details in An error in one or more provided fields - InvalidFields element - 401 Unauthorized Authentication required - 404 SshKey Unknown id not found - 500 Other Error Unknown error - """ - existing = ssh_keys_view_one(self, ssh_key_id=ssh_key_id) - existing_key = existing.get('SshKeys')[0] - sshkey_record = { - 'Version': '1.0', - 'Person': { - 'Type': 'CO', - 'Id': str(coperson_id) if coperson_id else str(existing_key.get('Person').get('Id')) - }, - 'Skey': str(ssh_key) if ssh_key else existing_key.get('Skey'), - 'Comment': str(comment) if comment else existing_key.get('Comment'), - 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) if ssh_key_authenticator_id - else str(existing_key.get('SshKeyAuthenticatorId')) - } - if key_type: +class SshKeysMixin: + """Mixin providing SshKey API methods.""" + + def ssh_keys_add(self, coperson_id: int, ssh_key: str, key_type: str, comment: str = None, + ssh_key_authenticator_id: int = None) -> dict: + """ + Add a new SSH Key. + + :param coperson_id: + :param ssh_key: + :param key_type: + :param comment: + :param ssh_key_authenticator_id: + + Response Format + HTTP Status Response Body Description + 201 Added NewObjectResponse with SshKey SSH Key created + 400 Bad Request SSH Key Request not provided in POST body + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + if not ssh_key_authenticator_id: + ssh_key_authenticator_id = self._CO_SSH_KEY_AUTHENTICATOR_ID key_type = str(key_type).lower() if key_type not in self.SSH_KEY_OPTIONS: raise ValueError("Invalid Fields 'key_type'") - sshkey_record['Type'] = str(key_type) - else: - sshkey_record['Type'] = existing_key.get('Type') - return self._put(f'{_SSH_KEY_PATH}/{ssh_key_id}.json', { - 'RequestType': 'SshKeys', - 'Version': '1.0', - 'SshKeys': [sshkey_record] - }) - - -def ssh_keys_view_all(self) -> dict: - """ - Retrieve all existing SSH Keys. - - :param self: - :return - { - "ResponseType":"SshKeys", - "Version":"1.0", - "SshKeys": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - , - "Comment":"<Comment>", - "Type":("ssh-dss"|"ecdsa-sha2-nistp256"|"ecdsa-sha2-nistp384"|"ecdsa-sha2-nistp521"| - "ssh-ed25519"|"ssh-rsa"|"ssh-rsa1"), - "Skey":"<SshKey>", - "SshKeyAuthenticatorId":"<Id>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK SshKey Response SSH Keys returned - 401 Unauthorized Authentication required - 500 Other Error Unknown error - """ - return self._get(f'{_SSH_KEY_PATH}.json') - - -def ssh_keys_view_per_coperson(self, coperson_id: int) -> dict: - """ - Retrieve all existing SSH Keys for the specified CO Person. - - :param self: - :param coperson_id: - :return - { - "ResponseType":"SshKeys", - "Version":"1.0", - "SshKeys": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": - { - "Type":"CO", - "Id":"<ID>" - }, - , - "Comment":"<Comment>", - "Type":("ssh-dss"|"ecdsa-sha2-nistp256"|"ecdsa-sha2-nistp384"|"ecdsa-sha2-nistp521"| - "ssh-ed25519"|"ssh-rsa"|"ssh-rsa1"), - "Skey":"<SshKey>", - "SshKeyAuthenticatorId":"<Id>" - }, - {...} - ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK SshKey Response SSH Keys returned - 401 Unauthorized Authentication required - 404 SSH Key Unknown id not found - 500 Other Error Unknown error - """ - url = f"{self._CO_API_URL}/{_SSH_KEY_PATH}.json" - params = {'copersonid': str(coperson_id)} - self._log.debug('GET %s params=%s', url, params) - resp = self._s.get(url=url, params=params, timeout=self._timeout) - if resp.status_code == 204: - self._log.info('GET %s returned 204 (no SSH keys)', url) - return { + return self._post(f'{_SSH_KEY_PATH}.json', { 'RequestType': 'SshKeys', 'Version': '1.0', - 'SshKeys': [] - } - if not resp.ok: - self._log.warning('GET %s returned %s', url, resp.status_code) - resp.raise_for_status() - self._log.info('GET %s OK', url) - return resp.json() - - -def ssh_keys_view_one(self, ssh_key_id: int) -> dict: - """ - Retrieve an existing SSH Key. - - :param self: - :param ssh_key_id: - :return - { - "ResponseType":"SshKeys", - "Version":"1.0", - "SshKeys": - [ - { - "Version":"1.0", - "Id":"<Id>", - "Person": + 'SshKeys': [ { - "Type":"CO", - "Id":"<ID>" - }, - , - "Comment":"<Comment>", - "Type":("ssh-dss"|"ecdsa-sha2-nistp256"|"ecdsa-sha2-nistp384"|"ecdsa-sha2-nistp521"| - "ssh-ed25519"|"ssh-rsa"|"ssh-rsa1"), - "Skey":"<SshKey>", - "SshKeyAuthenticatorId":"<Id>" - }, - {...} + 'Version': '1.0', + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) + }, + 'Comment': str(comment) if comment else '', + 'Type': str(key_type), + 'Skey': str(ssh_key), + 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) + } ] - }: - - Response Format - HTTP Status Response Body Description - 200 OK SshKey Response SSH Keys returned - 401 Unauthorized Authentication required - 404 SSH Key Unknown id not found - 500 Other Error Unknown error - """ - return self._get(f'{_SSH_KEY_PATH}/{ssh_key_id}.json') + }) + + def ssh_keys_delete(self, ssh_key_id: int) -> bool: + """ + Remove an SSH Key. + + :param ssh_key_id: + + Response Format + HTTP Status Response Body Description + 200 OK SSH Key deleted + 401 Unauthorized Authentication required + 404 SshKey Unknown id not found + 500 Other Error Unknown error + """ + return self._delete(f'{_SSH_KEY_PATH}/{ssh_key_id}.json') + + def ssh_keys_edit(self, ssh_key_id: int, coperson_id: int = None, ssh_key: str = None, key_type: str = None, + comment: str = None, ssh_key_authenticator_id: int = None) -> bool: + """ + Edit an existing SSH Key. + + :param ssh_key_id: + :param coperson_id: + :param ssh_key: + :param key_type: + :param comment: + :param ssh_key_authenticator_id: + + Response Format + HTTP Status Response Body Description + 200 OK SSH Key updated + 400 Bad Request SSH Key Request not provided in POST body + 401 Unauthorized Authentication required + 404 SshKey Unknown id not found + 500 Other Error Unknown error + """ + existing = self.ssh_keys_view_one(ssh_key_id=ssh_key_id) + existing_key = existing.get('SshKeys')[0] + sshkey_record = { + 'Version': '1.0', + 'Person': { + 'Type': 'CO', + 'Id': str(coperson_id) if coperson_id else str(existing_key.get('Person').get('Id')) + }, + 'Skey': str(ssh_key) if ssh_key else existing_key.get('Skey'), + 'Comment': str(comment) if comment else existing_key.get('Comment'), + 'SshKeyAuthenticatorId': str(ssh_key_authenticator_id) if ssh_key_authenticator_id + else str(existing_key.get('SshKeyAuthenticatorId')) + } + if key_type: + key_type = str(key_type).lower() + if key_type not in self.SSH_KEY_OPTIONS: + raise ValueError("Invalid Fields 'key_type'") + sshkey_record['Type'] = str(key_type) + else: + sshkey_record['Type'] = existing_key.get('Type') + return self._put(f'{_SSH_KEY_PATH}/{ssh_key_id}.json', { + 'RequestType': 'SshKeys', + 'Version': '1.0', + 'SshKeys': [sshkey_record] + }) + + def ssh_keys_view_all(self) -> dict: + """ + Retrieve all existing SSH Keys. + + Response Format + HTTP Status Response Body Description + 200 OK SshKey Response SSH Keys returned + 401 Unauthorized Authentication required + 500 Other Error Unknown error + """ + return self._get(f'{_SSH_KEY_PATH}.json') + + def ssh_keys_view_per_coperson(self, coperson_id: int) -> dict: + """ + Retrieve all existing SSH Keys for the specified CO Person. + + :param coperson_id: + + Response Format + HTTP Status Response Body Description + 200 OK SshKey Response SSH Keys returned + 401 Unauthorized Authentication required + 404 SSH Key Unknown id not found + 500 Other Error Unknown error + """ + url = f"{self._CO_API_URL}/{_SSH_KEY_PATH}.json" + params = {'copersonid': str(coperson_id)} + self._log.debug('GET %s params=%s', url, params) + resp = self._s.get(url=url, params=params, timeout=self._timeout) + if resp.status_code == 204: + self._log.info('GET %s returned 204 (no SSH keys)', url) + return { + 'RequestType': 'SshKeys', + 'Version': '1.0', + 'SshKeys': [] + } + if not resp.ok: + self._log.warning('GET %s returned %s', url, resp.status_code) + resp.raise_for_status() + self._log.info('GET %s OK', url) + return resp.json() + + def ssh_keys_view_one(self, ssh_key_id: int) -> dict: + """ + Retrieve an existing SSH Key. + + :param ssh_key_id: + + Response Format + HTTP Status Response Body Description + 200 OK SshKey Response SSH Keys returned + 401 Unauthorized Authentication required + 404 SSH Key Unknown id not found + 500 Other Error Unknown error + """ + return self._get(f'{_SSH_KEY_PATH}/{ssh_key_id}.json') diff --git a/examples/README.md b/examples/README.md index c7ee548..2d55453 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ Examples demonstrating basic usage for each wrapped endpoint. Examples dynamical - Example code tested against COmanage v4.0.0 - Examples use the alpha tier configuration from `.env` (registry-test.cilogon.org) -- Run examples as modules from the project root: `python -m examples.<name>` +- Run examples from the project root: `uv run python examples/<name>.py` ## Table of Contents @@ -68,7 +68,7 @@ api = ComanageApi( Example: `coorg_identity_links_example.py` ```console -$ python -m examples.coorg_identity_links_example +$ uv run python examples/coorg_identity_links_example.py ### coorg_identity_links_add [NOT IMPLEMENTED] NotImplementedError - coorg_identity_links_add() is not implemented ### coorg_identity_links_delete @@ -122,7 +122,7 @@ Example: `copeople_example.py` **NOTE**: This example exits early after `email_addresses_view_per_person`. The unimplemented methods (`copeople_add`, `copeople_delete`, `copeople_edit`, `copeople_find`) and remaining view methods are after `exit(0)` and only run if that line is removed. ```console -$ python -m examples.copeople_example +$ uv run python examples/copeople_example.py ### discover CO Person ID Using CO Person ID: <Id> ### discover identifier for CO Person @@ -191,7 +191,7 @@ Example: `coperson_roles_example.py` This example dynamically discovers a valid CO Person ID and COU ID, then performs a full CRUD cycle: add a role, view it, edit it, list roles, and delete it. ```console -$ python -m examples.coperson_roles_example +$ uv run python examples/coperson_roles_example.py ### discover CO Person ID and COU ID Using CO Person ID: <Id> Using COU ID: <Id> @@ -299,7 +299,7 @@ Example: `cous_example.py` This example performs a full CRUD cycle: add a COU, view all, edit it, view one, delete it, and attempt to view the deleted COU. ```console -$ python -m examples.cous_example +$ uv run python examples/cous_example.py ### cous_add { "ResponseType": "NewObject", @@ -381,7 +381,7 @@ True Example: `email_addresses_example.py` ```console -$ python -m examples.email_addresses_example +$ uv run python examples/email_addresses_example.py ### discover CO Person ID Using CO Person ID: <Id> ### email_addresses_add @@ -443,7 +443,7 @@ Using CO Person ID: <Id> Example: `identifiers_example.py` ```console -$ python -m examples.identifiers_example +$ uv run python examples/identifiers_example.py ### discover CO Person ID Using CO Person ID: <Id> ### identifiers_add @@ -508,7 +508,7 @@ Using CO Person ID: <Id> Example: `names_example.py` ```console -$ python -m examples.names_example +$ uv run python examples/names_example.py ### discover CO Person ID Using CO Person ID: <Id> ### names_add @@ -572,7 +572,7 @@ Using CO Person ID: <Id> Example: `org_identities_example.py` ```console -$ python -m examples.org_identities_example +$ uv run python examples/org_identities_example.py ### org_identities_add [NOT IMPLEMENTED] NotImplementedError - org_identities_add() is not implemented ### org_identities_delete @@ -637,7 +637,7 @@ Example: `ssh_keys_example.py` This example dynamically discovers a CO Person ID, then performs a full CRUD cycle: add an SSH key, view all, view per person, view one, edit it, and delete it. ```console -$ python -m examples.ssh_keys_example +$ uv run python examples/ssh_keys_example.py ### discover CO Person ID Using CO Person ID: <Id> ### ssh_keys_add From fa47ee41282f09447c517526857b3dcd4e713ac1 Mon Sep 17 00:00:00 2001 From: "Michael J. Stealey" <michael.j.stealey@gmail.com> Date: Sat, 25 Apr 2026 19:03:02 -0400 Subject: [PATCH 5/5] release: prepare 0.2.0 - Bump __VERSION__ to 0.2.0 and update version-pinned test - Add CHANGELOG.md documenting Phase 1-6 changes since 0.1.5 - Add .github/workflows/release.yml for PyPI Trusted Publishing on tag push - Remove legacy setup.cfg, requirements.txt, MANIFEST.in (superseded by pyproject.toml) - Add CLAUDE.md project guidance - Ignore development_plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .github/workflows/release.yml | 36 ++++++++++++++++++ .gitignore | 1 + CHANGELOG.md | 69 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 71 +++++++++++++++++++++++++++++++++++ MANIFEST.in | 1 - comanage_api/__init__.py | 2 +- requirements.txt | 3 -- setup.cfg | 21 ----------- tests/test_api.py | 2 +- 9 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md delete mode 100644 MANIFEST.in delete mode 100644 requirements.txt delete mode 100644 setup.cfg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..361bb08 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Publish to PyPI + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv venv --python 3.12 + - run: uv pip install -e ".[dev]" + - run: uv run ruff check . + - run: uv run pytest -v + - run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/fabric-comanage-api + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 788d935..2fb8730 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ cython_debug/ .idea tldr.py userinfo +development_plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..956a67b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to `fabric-comanage-api` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-04-25 + +A substantial refactor focused on robustness, testability, and packaging +modernization. The public API is fully preserved — all existing methods retain +their names, signatures, and return types. + +### Added + +- Configurable HTTP request `timeout` parameter on `ComanageApi` (default: 30s). +- Automatic retry with exponential backoff on transient failures (HTTP 429, 500, + 502, 503, 504) via `urllib3.util.Retry` mounted on an `HTTPAdapter`. Retries + are applied to all HTTP methods. +- Structured logging under the `comanage_api` logger. A `NullHandler` is + registered so callers who don't configure logging see no output; callers who + enable logging get DEBUG (request URL/method/params), INFO (success), and + WARNING (non-2xx before raise) messages. Credentials and request/response + bodies are never logged. +- 138 unit tests across 10 files in `tests/`, using `pytest` + `requests-mock`, + covering all 9 endpoint modules plus the core `ComanageApi` class. Tests + exercise success paths, parameter validation, HTTP error propagation, and + edge cases (deduplication, 204 empty responses, `parent_id=0`). +- GitHub Actions CI workflow (`.github/workflows/ci.yml`) running `ruff` lint + on Python 3.12 and `pytest` across Python 3.9, 3.10, 3.11, and 3.12. +- `ruff` configuration in `pyproject.toml` (line-length 120, rules E/F/I/W). +- Centralized HTTP helpers on `ComanageApi`: `_get`, `_post`, `_put`, `_delete`, + and `_get_by_entity` — every endpoint module now delegates to these. + +### Changed + +- **Refactored to mixin architecture.** Each `_*.py` module now exports a mixin + class (e.g. `CoPeopleMixin`, `COUsMixin`, `SshKeysMixin`) and `ComanageApi` + inherits from all 9. The 60-method passthrough wrapper layer in `__init__.py` + is gone. +- Migrated all packaging metadata to `pyproject.toml` (PEP 621). Removed + `setup.cfg`, `requirements.txt`, and `MANIFEST.in`. +- Bumped minimum supported Python from 3.6 to 3.9. +- Adopted `uv` as the recommended package manager for development. +- Invalid enum values now raise `ValueError` instead of `TypeError` across + `_copersonroles`, `_emailaddresses`, `_identifiers`, `_names`, `_sshkeys`, + and `_coorgidentitylinks`. +- Replaced four copies of the validate-and-GET pattern in `view_per_*` methods + with a single `_get_by_entity` helper. + +### Fixed + +- `cous_edit()` now correctly distinguishes the three `parent_id` cases — + *value provided* (set), *0* (clear parent), *None* (keep existing) — using + explicit `is not None` checks. +- `ssh_keys_add()` no longer stringifies a missing comment as the literal + `"None"`; it now sends an empty string when no comment is supplied. +- `org_identities_view_all()` docstring corrected (previously a copy-paste of + the EmailAddresses docstring). + +### Removed + +- `requests-mock` removed from runtime `install_requires` (it remains in dev + dependencies). The `_MOCK_501_URL` / `_mock_session` machinery in + `__init__.py` is gone; unimplemented endpoints now raise `NotImplementedError`. + +## [0.1.5] + +Prior releases — see git history. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7288e9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**fabric-comanage-api** — a Python 3 client wrapper for the [COmanage REST API v1](https://spaces.at.internet2.edu/display/COmanage/REST+API+v1), published to PyPI as `fabric-comanage-api` (current version 0.2.0). Part of the [FABRIC Testbed](https://github.com/fabric-testbed) project. MIT licensed. + +## Build & Install + +```bash +# Development setup with uv +uv venv --python 3.12 +uv pip install -e ".[dev]" + +# Install from PyPI +pip install fabric-comanage-api +``` + +Build is configured via `pyproject.toml` (PEP 621 metadata, setuptools backend). Version is sourced from `comanage_api.__VERSION__`. Requires Python >= 3.9. + +## Dependencies + +Runtime: `requests>=2.25.0`. Dev: `pytest`, `requests-mock`, `python-dotenv` (installed via `pip install -e ".[dev]"`). + +## Testing + +Unit tests use `pytest` + `requests-mock`. Run with: + +```bash +uv run pytest -v +``` + +138 tests across 10 files in `tests/` cover all endpoint modules: successful responses, parameter validation (`ValueError`), HTTP error propagation, and edge cases (deduplication, 204 empty, `parent_id=0`). + +The `examples/` directory contains per-endpoint scripts that exercise the API against a live COmanage instance. To run them, copy `template.env` to `.env`, fill in credentials, then run individual example scripts (e.g., `uv run python examples/cous_example.py`). + +## Architecture + +`ComanageApi` (in `comanage_api/__init__.py`) is the single public class. It holds connection state (`_CO_API_URL`, `_CO_API_USER`, `_CO_API_PASS`, `_CO_API_ORG_ID`, `_CO_API_ORG_NAME`) and a `requests.Session` for HTTP Basic Auth. + +**HTTP helpers** on `ComanageApi` (`_get`, `_post`, `_put`, `_delete`, `_get_by_entity`) centralize all request/response handling. Each API domain module delegates to these helpers. + +Each API domain lives in its own private module (`_copeople.py`, `_cous.py`, `_sshkeys.py`, etc.). These modules define standalone functions that accept a `ComanageApi` instance as `self`. The `__init__.py` imports these functions and wraps them as methods on `ComanageApi`, creating a facade. + +**API endpoint modules:** +- `_coorgidentitylinks.py` — CoOrgIdentityLink (requires COmanage v4.0.0+) +- `_copeople.py` — CoPerson +- `_copersonroles.py` — CoPersonRole +- `_cous.py` — COU (Collaborative Organizational Unit) +- `_emailaddresses.py` — EmailAddress +- `_identifiers.py` — Identifier +- `_names.py` — Name +- `_orgidentities.py` — OrgIdentity +- `_sshkeys.py` — SshKey (requires COmanage v4.0.0+, experimental) + +**Pattern for each module:** functions named `<resource>_<action>` (e.g., `cous_add`, `cous_view_all`, `cous_delete`) that build paths/params/bodies and delegate to `self._get()`, `self._post()`, `self._put()`, or `self._delete()`. Unimplemented endpoints raise `NotImplementedError`. + +## Configuration + +Environment variables (see `template.env`): +- `COMANAGE_API_USER` / `COMANAGE_API_PASS` — API credentials +- `COMANAGE_API_CO_NAME` / `COMANAGE_API_CO_ID` — target CO +- `COMANAGE_API_URL` — registry base URL +- `COMANAGE_API_SSH_KEY_AUTHENTICATOR_ID` — optional SSH key authenticator plugin ID + +## Conventions + +- Instance-level constants for valid option sets: `STATUS_OPTIONS`, `AFFILIATION_OPTIONS`, `SSH_KEY_OPTIONS`, `ENTITY_OPTIONS`, `PERSON_OPTIONS`, `EMAILADDRESS_OPTIONS`. +- All API methods validate parameters against these option sets before making HTTP calls. +- Commit messages reference GitHub issues with `[#N]` prefix. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 68c6895..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -exclude examples/* diff --git a/comanage_api/__init__.py b/comanage_api/__init__.py index 301f8a2..a492eb7 100644 --- a/comanage_api/__init__.py +++ b/comanage_api/__init__.py @@ -16,7 +16,7 @@ from ._sshkeys import SshKeysMixin # fabric-comanage-api version -__VERSION__ = "0.1.5" +__VERSION__ = "0.2.0" # Library logging: NullHandler prevents "last resort" output for callers # who don't configure logging. Callers who want logs should add their own diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e217873..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -python-dotenv -requests -requests-mock diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d14ab65..0000000 --- a/setup.cfg +++ /dev/null @@ -1,21 +0,0 @@ -[metadata] -name = fabric-comanage-api -description = Fabric COmanage API -version = attr: comanage_api.__VERSION__ -author = Michael J. Stealey -author_email = michael.j.stealey@gmail.com -url = https://github.com/fabric-testbed/python-comanage-api -long_description = file: README.md -long_description_content_type = text/markdown -license = MIT -license_files = LICENSE -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: MIT License - Operating System :: OS Independent - -[options] -packages = find: -install_requires = - requests -python_requires = >=3.6 diff --git a/tests/test_api.py b/tests/test_api.py index 02aba73..80c5492 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -52,7 +52,7 @@ def test_retry_adapter_mounted(self, api, mock_adapter): assert isinstance(adapter, HTTPAdapter) def test_version(self): - assert __VERSION__ == '0.1.5' + assert __VERSION__ == '0.2.0' class TestOptionSets: