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/.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 `_` (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/README.md b/README.md index 07b9446..dd46a3c 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,23 @@ 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 -$ 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( @@ -62,7 +67,8 @@ Type "help", "copyright", "credits" or "license" for more information. ... 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() @@ -117,8 +123,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+) @@ -135,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+) @@ -311,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. @@ -329,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` @@ -346,29 +352,32 @@ SSH_KEY_OPTIONS = ['ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', ## Usage -Set up a virtual environment (`virtualenv` is used in these examples) +### Install + +Install from PyPI: ```console -virtualenv -p /usr/local/bin/python3 venv -source venv/bin/activate +pip install fabric-comanage-api ``` -### Install supporting packages +### Development setup -Install from PyPi +This project uses [uv](https://docs.astral.sh/uv/) for dependency management. To set up a development environment: ```console -pip install fabric-comanage-api +uv venv --python 3.12 +uv pip install -e ".[dev]" ``` -**OR** - -Install for Local Development +### Lint and test ```console -pip install -r requirements.txt +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/) @@ -457,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 39118f1..a492eb7 100644 --- a/comanage_api/__init__.py +++ b/comanage_api/__init__.py @@ -1,28 +1,40 @@ -import requests_mock -from requests import Session +import json +import logging -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 requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +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" - - -class ComanageApi(object): +__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 +# handler to the 'comanage_api' logger. +logging.getLogger(__name__).addHandler(logging.NullHandler()) + + +class ComanageApi( + CoOrgIdentityLinksMixin, + CoPeopleMixin, + CoPersonRolesMixin, + COUsMixin, + EmailAddressesMixin, + IdentifiersMixin, + NamesMixin, + OrgIdentitiesMixin, + SshKeysMixin, +): """ fabric-comanage-api: @@ -44,10 +56,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) @@ -64,6 +80,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', @@ -80,209 +98,67 @@ 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 + # 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) - # 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) + # HTTP helpers + def _get(self, path: str, params: dict = None) -> dict: + 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: + 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: + 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: + 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, + 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)}) diff --git a/comanage_api/_coorgidentitylinks.py b/comanage_api/_coorgidentitylinks.py index 80d7b7a..358dfe9 100644 --- a/comanage_api/_coorgidentitylinks.py +++ b/comanage_api/_coorgidentitylinks.py @@ -2,216 +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. """ -import json - - -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: - :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() - - -def coorg_identity_links_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -def coorg_identity_links_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -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 - """ - 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() - - - -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 - """ - 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() - - -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 - """ - 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() +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 fe6705b..821c97e 100644 --- a/comanage_api/_copeople.py +++ b/comanage_api/_copeople.py @@ -2,350 +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. """ -import json - - -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: - :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() - - -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: - :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() - - -def copeople_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -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: - :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() - - -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 - """ - url = self._CO_API_URL + '/co_people.json' - params = {'coid': self._CO_API_ORG_ID} - if given: - params.update({'given': given}) - if family: - params.update({'family': family}) - if mail: - params.update({'mail': mail}) - resp = self._s.get( - url=url, - params=params - ) - if resp.status_code == 200: +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 + + +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: - 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() - - -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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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: + 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: - 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() - - -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 - """ - 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 _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 30fb15a..fe3025c 100644 --- a/comanage_api/_copersonroles.py +++ b/comanage_api/_copersonroles.py @@ -2,491 +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. """ -import json - - -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 - """ - post_body = { - 'RequestType': 'CoPersonRoles', - '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) - } - ] - } - if status: - if status not in self.STATUS_OPTIONS: - raise TypeError("Invalid Fields 'status'") - post_body['CoPersonRoles'][0]['Status'] = str(status) - else: - post_body['CoPersonRoles'][0]['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) - else: - resp.raise_for_status() - - -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 - """ - 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() - - -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 - """ - coperson_role = coperson_roles_view_one(self, coperson_role_id) - post_body = { - 'RequestType': 'CoPersonRoles', - 'Version': '1.0', - 'CoPersonRoles': [ - { - 'Version': '1.0', - 'Person': - { - 'Type': 'CO' - }, - '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) - else: - post_body['CoPersonRoles'][0]['Status'] = coperson_role.get('CoPersonRoles')[0].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) - 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() - -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 - """ - 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() - - -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>", - {...} - } +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 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() - - -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>", - {...} - } - }, - {...} - ] - }: - - 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 - """ - 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() - - -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 - """ - 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() + '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 5b9fa7d..5a3816c 100644 --- a/comanage_api/_cous.py +++ b/comanage_api/_cous.py @@ -2,353 +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. """ -import json - - -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 - """ - post_body = { - 'RequestType': 'Cous', - 'Version': '1.0', - 'Cous': - [ - { - 'Version': '1.0', - '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() - - -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 - """ - 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() - - -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 - """ - cou = cous_view_one(self, cou_id) - post_body = { - 'RequestType': 'Cous', - 'Version': '1.0', - 'Cous': - [ - { - 'Version': '1.0', - 'CoId': self._CO_API_ORG_ID - } - ] - } - 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) - 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() - - -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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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() + 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 0e4788f..eaf0286 100644 --- a/comanage_api/_emailaddresses.py +++ b/comanage_api/_emailaddresses.py @@ -2,242 +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. """ -import json - - -def email_addresses_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - 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() - - -def email_addresses_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -def email_addresses_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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() +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 9b15692..f2bcfec 100644 --- a/comanage_api/_identifiers.py +++ b/comanage_api/_identifiers.py @@ -2,259 +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. """ -import json - - -def identifiers_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - 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() - - -def identifiers_assign(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -def identifiers_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -def identifiers_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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() +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 7c40d97..83a33b7 100644 --- a/comanage_api/_names.py +++ b/comanage_api/_names.py @@ -2,245 +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. """ -import json - - -def names_add(self) -> dict: - """ - ### NOT IMPLEMENTED ### - 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() - - -def names_delete(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -def names_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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() +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 746efa5..5550721 100644 --- a/comanage_api/_orgidentities.py +++ b/comanage_api/_orgidentities.py @@ -2,279 +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. """ -import json - - -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: - :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() - - -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: - :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() - - -def org_identities_edit(self) -> bool: - """ - ### NOT IMPLEMENTED ### - 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() - - -def org_identities_view_all(self) -> dict: - """ - Retrieve all existing EmailAddresses. - - :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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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() - - -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 - """ - 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() +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 e4bf671..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,380 +18,168 @@ - Authenticators that are locked cannot be managed by the API. """ -import json - - -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 TypeError("Invalid Fields 'key_type'") - post_body = json.dumps({ - 'RequestType': 'SshKeys', - 'Version': '1.0', - 'SshKeys': - [ +# SSH Key plugin base path +_SSH_KEY_PATH = 'ssh_key_authenticator/ssh_keys' + + +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'") + 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), + '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: - """ - 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 - """ - 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() - - -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 - """ - sshkey = ssh_keys_view_one(self, ssh_key_id=ssh_key_id) - post_body = { - 'RequestType': 'SshKeys', - 'Version': '1.0', - 'SshKeys': - [ - { - 'Version': '1.0', - 'Person': - { - 'Type': 'CO' - } - } - ] - } - 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 - else: - resp.raise_for_status() - - -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 - """ - 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() - - -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 = self._CO_API_URL + '/ssh_key_authenticator/ssh_keys.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) - if resp.status_code == 204: - return no_ssh_keys - else: - resp.raise_for_status() - - -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": - { - "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 = 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: + }) + + 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 39c6d61..2d55453 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 from the project root: `uv run python examples/<name>.py` ## Table of Contents @@ -19,7 +21,7 @@ Examples demonstrating basic usage for each wrapped endpoint. Some of the values ## <a name="config"></a>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( ## <a name="coorgidentitylink"></a>CoOrgIdentityLink API -Example: `co_org_identity_links_example.py` +Example: `coorg_identity_links_example.py` ```console -$ python examples/coorg_identity_links_example.py +$ uv run python examples/coorg_identity_links_example.py ### 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: <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": "<Id>", + "CoPersonId": "<CoPersonId>", + "OrgIdentityId": "<OrgIdentityId>", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "<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": "<Id>", + "CoPersonId": "<CoPersonId>", + "OrgIdentityId": "<OrgIdentityId>", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "<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" - } - ] -} +$ uv run python examples/copeople_example.py +### discover CO Person ID +Using CO Person ID: <Id> +### discover identifier for CO Person +Using identifier: <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": "<Id>", + "CoId": "<CoId>", "Status": "Active", - "Created": "2021-03-17 16:03:02", - "Modified": "2021-03-17 16:04:23", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "5", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "<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": "<Id>", + "Mail": "<email@example.com>", + "Type": "official", + "Verified": true, + "Person": { + "Type": "CO", + "Id": "<Id>" + }, + "Created": "<CreateTime>", + "Modified": "<ModTime>", + "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/241998" + "ActorIdentifier": "<ActorIdentifier>" } ] } +<email@example.com> +``` + +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 ``` ## <a name="copersonrole"></a>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 +$ uv run python examples/coperson_roles_example.py +### discover CO Person ID and COU ID +Using CO Person ID: <Id> +Using COU ID: <Id> ### coperson_roles_add { "ResponseType": "NewObject", "Version": "1.0", "ObjectType": "CoPersonRole", - "Id": "1727" + "Id": "<Id>" } ### coperson_roles_view_one { @@ -276,20 +209,20 @@ $ python examples/coperson_roles_example.py "CoPersonRoles": [ { "Version": "1.0", - "Id": "1727", + "Id": "<Id>", "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, - "CouId": "39", + "CouId": "<CouId>", "Affiliation": "student", - "O": "Impact", + "O": "<CO_API_ORG_NAME>", "Status": "PendingApproval", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:44", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "co_3.development" + "ActorIdentifier": "<ActorIdentifier>" } ] } @@ -302,64 +235,37 @@ True "CoPersonRoles": [ { "Version": "1.0", - "Id": "1727", + "Id": "<Id>", "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, - "CouId": "39", + "CouId": "<CouId>", "Affiliation": "member", - "O": "Impact", + "O": "<CO_API_ORG_NAME>", "Status": "Active", - "Created": "2021-09-30 01:22:44", - "Modified": "2021-09-30 01:22:45", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "1", "Deleted": false, - "ActorIdentifier": "co_3.development" + "ActorIdentifier": "<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 +$ uv run python examples/cous_example.py ### cous_add { "ResponseType": "NewObject", "Version": "1.0", "ObjectType": "Cou", - "Id": "105" + "Id": "<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": "<Id>", + "CoId": "<CoId>", + "Name": "<name>", + "Description": "<description>", + "Lft": "<Lft>", + "Rght": "<Rght>", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/242181" + "ActorIdentifier": "<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": "<Id>", + "CoId": "<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": "<Lft>", + "Rght": "<Rght>", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "1", "Deleted": false, - "ActorIdentifier": "co_3.development" + "ActorIdentifier": "<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 +$ uv run python examples/email_addresses_example.py +### discover CO Person ID +Using CO Person ID: <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": "<Id>", + "Mail": "<email@example.com>", "Type": "official", "Verified": true, "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, - "SourceEmailAddressId": "809", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "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": "<ActorIdentifier>" } ] } @@ -637,20 +420,19 @@ $ python examples/email_addresses_example.py "EmailAddresses": [ { "Version": "1.0", - "Id": "810", - "Mail": "mjstealey@gmail.com", + "Id": "<Id>", + "Mail": "<email@example.com>", "Type": "official", "Verified": true, "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, - "SourceEmailAddressId": "809", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "<ActorIdentifier>" } ] } @@ -661,22 +443,17 @@ $ python examples/email_addresses_example.py Example: `identifiers_example.py` ```console -$ python examples/identifiers_example.py +$ uv run python examples/identifiers_example.py +### discover CO Person ID +Using CO Person ID: <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": "<Id>", + "Identifier": "<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": "<Id>" }, - "SourceIdentifierId": "1551", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "<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": "<Id>", + "Identifier": "<Identifier>", "Type": "oidcsub", "Status": "Active", "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, - "SourceIdentifierId": "1550", - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:37", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "<ActorIdentifier>" } ] } @@ -766,19 +508,15 @@ $ python examples/identifiers_example.py Example: `names_example.py` ```console -$ python examples/names_example.py +$ uv run python examples/names_example.py +### discover CO Person ID +Using CO Person ID: <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": "<Id>", + "Given": "<Given>", + "Family": "<Family>", "Type": "official", - "Language": "", "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, "PrimaryName": true, - "Created": "2021-09-15 12:34:47", - "Modified": "2021-09-15 12:34:47", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "<ActorIdentifier>" } ] } @@ -848,20 +548,20 @@ $ python examples/names_example.py "Names": [ { "Version": "1.0", - "Id": "923", - "Given": "mj", - "Family": "stealey", + "Id": "<Id>", + "Given": "<Given>", + "Family": "<Family>", "Type": "official", "Person": { "Type": "CO", - "Id": "1603" + "Id": "<Id>" }, - "PrimaryName": false, - "Created": "2021-09-15 12:34:37", - "Modified": "2021-09-15 12:34:47", - "Revision": "1", + "PrimaryName": true, + "Created": "<CreateTime>", + "Modified": "<ModTime>", + "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverA/users/226066" + "ActorIdentifier": "<ActorIdentifier>" } ] } @@ -872,19 +572,13 @@ $ python examples/names_example.py Example: `org_identities_example.py` ```console -$ python examples/org_identities_example.py +$ uv run python examples/org_identities_example.py ### 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": "<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": "<Organization>", + "CoId": "<CoId>", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/2604273" + "ActorIdentifier": "<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": "<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": "<Organization>", + "CoId": "<CoId>", + "Created": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "http://cilogon.org/serverT/users/37233" + "ActorIdentifier": "<ActorIdentifier>" } ] } @@ -1000,17 +632,20 @@ $ python examples/org_identities_example.py ## <a name="sshkey"></a>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 +$ uv run python examples/ssh_keys_example.py +### discover CO Person ID +Using CO Person ID: <Id> ### ssh_keys_add { "ResponseType": "NewObject", "Version": "1.0", "ObjectType": "SshKey", - "Id": "38" + "Id": "<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": "<Id>", "Person": { "Type": "CO", - "Id": "163" + "Id": "<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": "<CreateTime>", + "Modified": "<ModTime>", "Revision": "0", "Deleted": false, - "ActorIdentifier": "co_6.impact-development", - "SshKeyAuthenticatorId": "7" + "ActorIdentifier": "<ActorIdentifier>", + "SshKeyAuthenticatorId": "<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..6353934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,56 @@ [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", + "ruff", +] + +[project.urls] +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.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__" } + +[tool.setuptools.packages.find] +include = ["comanage_api*"] +exclude = ["examples*", "tests*"] 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 b7e37b4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,22 +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 - requests-mock -python_requires = >=3.6 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..80c5492 --- /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 __VERSION__, ComanageApi + +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.2.0' + + +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..0a164b6 --- /dev/null +++ b/tests/test_copeople.py @@ -0,0 +1,120 @@ +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) + 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) + 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 = { + '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..afe4ad4 --- /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) + 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..4234744 --- /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) + 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) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..acef835 --- /dev/null +++ b/uv.lock @@ -0,0 +1,458 @@ +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" }, + { name = "ruff" }, +] + +[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'" }, + { name = "ruff", 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 = "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" +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" }, +]