pax_global_header00006660000000000000000000000064151322140320014503gustar00rootroot0000000000000052 comment=fb6e7cad3951f2577b516464c8c5983f760d8be3 globus-globus-sdk-python-6a080e4/000077500000000000000000000000001513221403200167165ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/.editorconfig000066400000000000000000000004351513221403200213750ustar00rootroot00000000000000# https://editorconfig.org/ root = true [*] charset = utf-8 indent_style = space indent_size = 4 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false [*.{json,yml,yaml}] indent_size = 2 [Makefile] indent_style = tab globus-globus-sdk-python-6a080e4/.flake8000066400000000000000000000005251513221403200200730ustar00rootroot00000000000000[flake8] exclude = .git,.tox,__pycache__,.eggs,dist,.venv*,docs,build max-line-length = 88 extend-ignore = W503,W504,E203 # in pyi stubs, spacing rules are different (black handles this) per-file-ignores = *.pyi:E302,E305 [flake8:local-plugins] extension = SDK = globus_sdk_flake8:Plugin paths = ./src/globus_sdk/_internal/extensions/ globus-globus-sdk-python-6a080e4/.github/000077500000000000000000000000001513221403200202565ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/.github/CODEOWNERS000066400000000000000000000013071513221403200216520ustar00rootroot00000000000000# # codeowners reference: # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # # for ease of comparison, usernames are kept alphabetized # # default rule * @aaschaer @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen # Flows service **/flows/ @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen docs/services/flows.rst @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen # Timer service **/timer/ @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen docs/services/timer.rst @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen globus-globus-sdk-python-6a080e4/.github/dependabot.yml000066400000000000000000000006131513221403200231060ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" groups: github-actions: patterns: - "*" # Prevent updates of requirements files used only for testing. - package-ecosystem: "pip" directory: "/requirements/" schedule: interval: "monthly" ignore: - dependency-name: "*" globus-globus-sdk-python-6a080e4/.github/workflows/000077500000000000000000000000001513221403200223135ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/.github/workflows/has_changelog.yaml000066400000000000000000000020131513221403200257550ustar00rootroot00000000000000name: changelog on: workflow_call: pull_request: types: - labeled - unlabeled - opened - reopened - synchronize jobs: check_has_news_in_changelog_dir: if: | ! ( contains(github.event.pull_request.labels.*.name, 'no-news-is-good-news') || github.event.pull_request.user.login == 'pre-commit-ci[bot]' || github.event.pull_request.user.login == 'dependabot[bot]' ) runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # do a deep fetch to allow merge-base and diff fetch-depth: 0 - name: check PR adds a news file run: | news_files="$(git diff --name-only "$(git merge-base origin/main "$GITHUB_SHA")" "$GITHUB_SHA" -- changelog.d/*.rst)" if [ -n "$news_files" ]; then echo "Saw new files. changelog.d:" echo "$news_files" else echo "No news files seen" exit 1 fi globus-globus-sdk-python-6a080e4/.github/workflows/publish_to_pypi.yaml000066400000000000000000000011321513221403200264050ustar00rootroot00000000000000name: Publish PyPI Release on: release: types: [published] jobs: publish: runs-on: ubuntu-latest environment: publish-pypi permissions: id-token: write steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" - run: python -m pip install build - run: python -m build . - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 globus-globus-sdk-python-6a080e4/.github/workflows/publish_to_test_pypi.yaml000066400000000000000000000012441513221403200274500ustar00rootroot00000000000000name: Publish Test PyPI Release on: push: tags: ["*"] jobs: publish: runs-on: ubuntu-latest environment: publish-test-pypi permissions: id-token: write steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" - run: python -m pip install build - run: python -m build . - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: repository-url: https://test.pypi.org/legacy/ globus-globus-sdk-python-6a080e4/.github/workflows/test.yaml000066400000000000000000000047521513221403200241660ustar00rootroot00000000000000name: "🧪 Test" on: push: branches: - "main" pull_request: null # build weekly at 4:00 AM UTC schedule: - cron: '0 4 * * 1' jobs: test: name: "${{ matrix.name }}" strategy: fail-fast: false matrix: # The `include` array below will match against these names # and add additional keys and values to the JSON object. name: - "Linux" - "macOS" - "Windows" - "Quality" # The `include` array below will also inherit these values, # which are critical for effective cache-busting. # The nested list syntax ensures that the full array of values is inherited. cache-key-hash-files: - - "requirements/*/*.txt" - "pyproject.toml" - "toxfile.py" include: - name: "Linux" runner: "ubuntu-latest" cpythons: - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" tox-post-environments: - "py3.9-mindeps" - "py3.11-sphinxext" - "coverage_report" - name: "macOS" runner: "macos-latest" cpythons: - "3.11" tox-post-environments: - "py3.11-sphinxext" - "coverage_report" - name: "Windows" runner: "windows-latest" cpythons: - "3.9" - "3.11" tox-post-environments: - "py3.9-mindeps" - "py3.11-sphinxext" - "coverage_report" - name: "Quality" runner: "ubuntu-latest" cpythons: - "3.9" - "3.14" tox-environments: - "check-min-python-is-tested" - "mypy-py3.14" - "mypy-py3.9" - "mypy-test" - "poetry-check" - "pylint" - "test-lazy-imports" - "twine-check" # TODO: revisit caching strategy for '.mypy_cache' # we believe a bad cache can be restored when the package changes, # tricking 'mypy-test' into seeing an old version of the SDK # # cache-paths: # - ".mypy_cache/" uses: "globus/workflows/.github/workflows/tox.yaml@d370cf87e31ead9ba566b6fa07f05f63918a9149" # v1.3 with: config: "${{ toJSON(matrix) }}" globus-globus-sdk-python-6a080e4/.github/workflows/update_pr_references.yaml000066400000000000000000000014771513221403200273740ustar00rootroot00000000000000name: update-pr-references on: push: branches: - main jobs: update_pr_numbers_in_change_fragments: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.x' - name: update any PR numbers in change fragments run: | python ./changelog.d/update-pr-refs.py if [ "$(git status --porcelain)" = "" ]; then echo "no changes" else git add changelog.d/ git \ -c user.name="GitHub Actions" \ -c user.email="actions@github.com" \ commit -m '(actions) update PR references' git push origin fi globus-globus-sdk-python-6a080e4/.gitignore000066400000000000000000000003341513221403200207060ustar00rootroot00000000000000build _build MANIFEST dist *.pyc .tox globus_sdk.egg-info eggs/ .eggs/ .idea # virtualenv .venv/ venv/ ENV/ env/ .env/ # pytest .pytest_cache .coverage .coverage.* # intermediary requirements files /requirements/*.in globus-globus-sdk-python-6a080e4/.pre-commit-config.yaml000066400000000000000000000045641513221403200232100ustar00rootroot00000000000000ci: autoupdate_schedule: "quarterly" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-merge-conflict - id: trailing-whitespace - id: end-of-file-fixer - id: mixed-line-ending args: - "--fix=lf" - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.36.0 hooks: - id: check-github-workflows - id: check-readthedocs - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade args: ["--py39-plus"] - repo: https://github.com/psf/black-pre-commit-mirror rev: 25.12.0 hooks: - id: black name: "Autoformat python files" - repo: https://github.com/adamchainz/blacken-docs rev: 1.20.0 hooks: - id: blacken-docs additional_dependencies: ['black==25.1.0'] - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 name: "Lint python files" additional_dependencies: - 'flake8-bugbear==24.12.12' - 'flake8-comprehensions==3.16.0' - 'flake8-typing-as-t==1.0.0' - repo: https://github.com/PyCQA/isort rev: 7.0.0 hooks: - id: isort name: "Sort python imports" - repo: https://github.com/sirosen/rstbebe rev: 0.2.0 hooks: - id: bad-backticks - repo: https://github.com/sirosen/slyp rev: 0.8.2 hooks: - id: slyp - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell args: ["--ignore-regex", "https://[^\\s]*"] - repo: https://github.com/sirosen/texthooks rev: 0.7.1 hooks: - id: alphabetize-codeowners - repo: https://github.com/rhysd/actionlint rev: v1.7.10 hooks: - id: actionlint additional_dependencies: - github.com/wasilibs/go-shellcheck/cmd/shellcheck@v0.11.1 # custom local hooks - repo: local hooks: - id: forbid-code-block-without-language name: Require code-block directives to specify a language types_or: [python,rst] language: pygrep entry: "\\.\\. +code-block::$" - id: ensure-all-exports-documented name: "Check that all public symbols are documented" entry: ./scripts/ensure_exports_are_documented.py language: python always_run: true pass_filenames: false globus-globus-sdk-python-6a080e4/.readthedocs.yml000066400000000000000000000004601513221403200220040ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/conf.py fail_on_warning: true build: os: "ubuntu-22.04" tools: python: "3.11" jobs: pre_build: - bash ./scripts/rtd-pre-sphinx-build.sh python: install: - method: pip path: . - requirements: "requirements/py3.11/docs.txt" globus-globus-sdk-python-6a080e4/CONTRIBUTING.adoc000066400000000000000000000063121513221403200214570ustar00rootroot00000000000000Contributing to the Globus SDK ============================== First off, thank you so much for taking the time to contribute! :+1: Bugs & Feature Requests ----------------------- Should be reported as https://github.com/globus/globus-sdk-python/issues[GitHub Issues] For a good bug report: - Check if there's a matching issue before opening a new issue - Provide a code sample to reproduce bugs Installing a Development Environment --------------------------------------- To install the development environment (virtualenv and pre-commit hooks) in a new repo, run make install To update an install for an existing development environment, e.g., after updating the dependency list, run make clean && make install Linting & Testing ----------------- Testing the SDK requires https://tox.readthedocs.io/en/latest/[tox]. Run the full test suite with tox And linting with tox -e lint Optional, but recommended, linting setup ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The Globus SDK uses https://pre-commit.com/[`pre-commit`] to automatically run linters and fixers. Install `pre-commit` and then run $ pre-commit install to setup the hooks. The configured linters and fixers can be seen in `.pre-commit-config.yaml`. Writing changelog fragments ~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you are contributing a significant change -- e.g., a new feature -- it's best to include a changelog fragment which documents the change. This will be included in the next SDK release's changelog. Changelog fragments are written using `scriv`. First, make sure you have scriv installed with `toml` support, for example: $ pipx install 'scriv[toml]' Then use `scriv create --edit` to create a changelog fragment. Comments in the template should give you further guidance. Not all changes need a changelog! If it's a minor tweak (e.g., a doc typo), we'll label the PR to skip our changelog requirement. Contributing Documentation -------------------------- Documentation for the SDK is built with https://www.sphinx-doc.org/[Sphinx] and docs are written as https://docutils.sourceforge.io/rst.html[reStructuredText] in the `docs/` directory. If you want to look at local documentation, run `tox -e docs` and the output will be in `docs/_build/dirhtml/`. Code Guidelines --------------- These are recommendations for contributors: - Include tests for any new or changed functionality - Use type annotations liberally - New features should be documented via Sphinx Code Style ~~~~~~~~~~ Style guidance which doesn't get handled by linters and fixers: - If a docstring contains special characters like `\\`, consider using a raw string to ensure it renders correctly - Use the `*` marker for keyword-only arguments for any signatures which take keyword arguments. (For an explanation, see [PEP 3102](https://peps.python.org/pep-3102/).) - Avoid use of `**kwargs` to capture arbitrary keyword arguments. Instead, always define a named dict argument for any open extension points (see `query_params` for prior art in the SDK, or `extra` in `logging` for a case from the stdlib). This makes type checking more effective and avoids a class of backwards compatibility issues when arguments are added. globus-globus-sdk-python-6a080e4/LICENSE000066400000000000000000000261361513221403200177330ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. globus-globus-sdk-python-6a080e4/Makefile000066400000000000000000000015461513221403200203640ustar00rootroot00000000000000SDK_VERSION=$(shell grep '^version' pyproject.toml | head -n 1 | cut -d '"' -f2) # these are just tox invocations wrapped nicely for convenience .PHONY: lint test docs all-checks lint: tox -e lint,mypy,mypy-test,pylint test: tox docs: tox -e docs all-checks: tox -e lint,pylint,mypy,mypy-test,test-lazy-imports,py37,py310,poetry-check,twine-check,docs .PHONY: showvars tag-release prepare-release showvars: @echo "SDK_VERSION=$(SDK_VERSION)" prepare-release: tox -e prepare-release tag-release: git tag -s "$(SDK_VERSION)" -m "v$(SDK_VERSION)" -git push $(shell git rev-parse --abbrev-ref @{push} | cut -d '/' -f1) refs/tags/$(SDK_VERSION) .PHONY: install install: python -m venv .venv --upgrade-deps .venv/bin/pip install -e . --group test .PHONY: clean clean: rm -rf dist build *.egg-info .tox .venv find . -type d -name '__pycache__' -exec rm -r {} + globus-globus-sdk-python-6a080e4/README.rst000066400000000000000000000017031513221403200204060ustar00rootroot00000000000000Globus SDK for Python ===================== This SDK provides a convenient Pythonic interface to `Globus `_ APIs. Basic Usage ----------- Install with ``pip install globus-sdk`` You can then import Globus client classes and other helpers from ``globus_sdk``. For example: .. code-block:: python from globus_sdk import LocalGlobusConnectPersonal # None if Globus Connect Personal is not installed endpoint_id = LocalGlobusConnectPersonal().endpoint_id Testing, Development, and Contributing -------------------------------------- Go to the `CONTRIBUTING `_ guide for detail. Links ----- | Full Documentation: https://globus-sdk-python.readthedocs.io/ | Source Code: https://github.com/globus/globus-sdk-python | API Documentation: https://docs.globus.org/api/ | Release History + Changelog: https://github.com/globus/globus-sdk-python/releases globus-globus-sdk-python-6a080e4/RELEASING.md000066400000000000000000000034061513221403200205540ustar00rootroot00000000000000# RELEASING ## Prereqs - Make sure you have a gpg key setup for use with git. [git-scm.com guide for detail](https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) ## Procedure - Make sure your repo is on `main` and up to date; `git checkout main; git pull` - Read `changelog.d/` and decide if the release is MINOR or PATCH - (optional) Set the version in the `SDK_VERSION` env var, for use in the following steps. `SDK_VERSION=...` - Decide on the new version number and create a branch; `git checkout -b release-$SDK_VERSION` - Update the version in `pyproject.toml` - Update metadata and changelog, then verify changes in `changelog.rst` ``` make prepare-release $EDITOR changelog.rst ``` - Add changed files; `git add changelog.d/ changelog.rst pyproject.toml` - Commit; `git commit -m 'Bump version and changelog for release'` - Push the release branch; `git push -u origin release-$SDK_VERSION` - Open a PR for review; `gh pr create --base main --title "Release v$SDK_VERSION"` - After any changes and approval, merge the PR, checkout `main`, and pull; `git checkout main; git pull` - Tag the release; `make tag-release` _This will run a workflow to publish to test-pypi._ - Create a GitHub release with a copy of the changelog. _This will run a workflow to publish to pypi._ Generate the release body by running ``` ./scripts/changelog2md.py ``` or create the release via the GitHub CLI ``` ./scripts/changelog2md.py | \ gh release create $SDK_VERSION --title "v$SDK_VERSION" --notes-file - ``` - Send an email announcement to the Globus Discuss list with highlighted changes and a link to the GitHub release page. (If the Globus CLI is releasing within a short interval, combine both announcements into a single email notice.) globus-globus-sdk-python-6a080e4/changelog.d/000077500000000000000000000000001513221403200210675ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/changelog.d/README.md000066400000000000000000000002761513221403200223530ustar00rootroot00000000000000# Changelog Fragments Create changelog entries with `scriv` Use `scriv create --edit` to create a changelog fragment Fragments are collected for release as part of `make prepare-release` globus-globus-sdk-python-6a080e4/changelog.d/check-version-is-new.py000077500000000000000000000023021513221403200254010ustar00rootroot00000000000000#!/usr/bin/env python """ Check if the version number in the source is already present as a changelog header. """ import os import re import sys if sys.version_info >= (3, 11): import tomllib else: # Older Python versions import tomli as tomllib PATTERN_FORMAT = "^v{version}\\s+\\({date}\\)$" CHANGELOG_D = os.path.dirname(__file__) REPO_ROOT = os.path.dirname(CHANGELOG_D) def parse_version(): with open(os.path.join(REPO_ROOT, "pyproject.toml"), "rb") as f: pyproject = tomllib.load(f) return pyproject["project"]["version"] def get_header_re(version): version = re.escape(version) return PATTERN_FORMAT.format(version=version, date=r"\d{4}-\d{2}-\d{2}") def changelog_has_version(version): pattern = get_header_re(version) with open(os.path.join(REPO_ROOT, "changelog.rst")) as f: return bool(re.search(pattern, f.read(), flags=re.MULTILINE)) def main(): version = parse_version() if changelog_has_version(version): print(f"version {version} appears in the changelog already!") sys.exit(1) else: print(f"version check OK ({version} is a new version number)") sys.exit(0) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/changelog.d/new_fragment.rst.j2000066400000000000000000000006501513221403200246100ustar00rootroot00000000000000.. .. A new scriv changelog fragment .. .. Uncomment the header that is right (remove the leading dots). .. .. Leave the "(:pr:`...`)" text in your change description. .. GitHub Actions will automatically replace it when the PR is merged. .. {% for cat in config.categories -%} .. {{ cat }} .. {{ config.rst_header_chars[1] * (cat|length) }} .. .. - A bullet item for the {{ cat }} category. (:pr:`NUMBER`) .. {% endfor -%} globus-globus-sdk-python-6a080e4/changelog.d/update-pr-refs.py000077500000000000000000000041701513221403200243040ustar00rootroot00000000000000#!/usr/bin/env python """ Meant to run in the context of a PR GitHub Actions workflow from the repo root """ import argparse import glob import json import os import subprocess import typing as t import urllib.error import urllib.request CHANGELOG_D = os.path.dirname(__file__) def get_pr_number(commit_sha: str) -> t.Optional[str]: req = urllib.request.Request( "https://api.github.com/repos/globus/globus-sdk-python/" f"commits/{commit_sha}/pulls" ) req.add_header("Accept", "application/vnd.github.v3+json") try: response = urllib.request.urlopen(req) except urllib.error.URLError: return None data = json.load(response) if not (isinstance(data, list) and len(data) > 0): return None prdata = data[0] if not (isinstance(prdata, dict) and "number" in prdata): return None return str(prdata["number"]) def update_file(fname: str, pr_num: t.Optional[str]) -> None: with open(fname) as f: content = f.read() if pr_num: content = content.replace(":pr:`NUMBER`", f":pr:`{pr_num}`") else: content = content.replace("(:pr:`NUMBER`)", "") content = "\n".join([line.rstrip() for line in content.splitlines()]) + "\n" with open(fname, "w") as f: f.write(content) def get_commit(fname: str) -> str: return subprocess.run( ["git", "log", "-n", "1", "--pretty=format:%H", "--", fname], capture_output=True, check=True, ).stdout.decode("utf-8") def main(): parser = argparse.ArgumentParser( description="Update PR number in a changelog fragment" ) parser.add_argument("FILENAME", help="file to check and update", nargs="?") args = parser.parse_args() if args.FILENAME: files = [args.FILENAME] else: files = glob.glob(os.path.join(CHANGELOG_D, "*.rst")) for filename in files: print(f"updating {filename}") commit = get_commit(filename) print(f" commit={commit}") pr_num = get_pr_number(commit) print(f" PR={pr_num}") update_file(filename, pr_num) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/changelog.rst000066400000000000000000003340241513221403200214050ustar00rootroot00000000000000.. _changelog: CHANGELOG ######### .. _changelog_version3: See :ref:`versioning` for our versioning policy. The :ref:`upgrading ` doc is a good reference if you are upgrading to a major new version of the SDK. .. scriv-insert-here .. _changelog-4.3.1: v4.3.1 (2026-01-14) =================== Fixed ----- - The type of ``scope_requirements`` in the init signature for ``ClientApp`` has been expanded to ``typing.Mapping`` to match other locations where a ``scope_requirements`` mapping is accepted. (:pr:`1360`) Development ----------- - Update the Streams-related type annotations to accept UUIDs. (:pr:`1353`) .. _changelog-4.3.0: v4.3.0 (2025-12-17) =================== Added ----- - Added support to the ``TransferClient`` for the Streams API (:pr:`1351`) - ``CreateTunnelData`` is a payload builder for tunnel creation documents - ``TransferClient.create_tunnel()`` supports tunnel creation - ``TransferClient.update_tunnel()`` supports updates to a tunnel - ``TransferClient.get_tunnel()`` fetches a tunnel by ID - ``TransferClient.delete_tunnel()`` deletes a tunnel - ``TransferClient.list_tunnels()`` fetches all of the current user's tunnels - ``TransferClient.get_stream_access_point()`` fetches a Stream Access Point by ID .. _changelog-4.2.0: v4.2.0 (2025-12-03) =================== Python Support -------------- - Add support for Python 3.14. (:pr:`1340`) - Remove support for Python 3.8. (:pr:`1341`) Added ----- - ``GlobusApp`` and SDK client classes now support usage as context managers, and feature a new ``close()`` method to close internal resources. ``close()`` is automatically called on exit. (:pr:`1326`) - In support of this, token storages now all feature a ``close()`` method, which does nothing in the default implementation. Previously, only storages with underlying resources to manage featured a ``close()`` method. - ``GlobusApp`` will close any token storage via ``close()`` if the token storage was created by the app on init. Explicitly created storages will not be closed and must be explicitly closed via their ``close()`` method. - Any class inheriting from ``BaseClient`` features ``close()``, which will close any transport object created during client construction. - Transports which are created explicitly will not be closed by their clients, and must be explicitly closed. - Add ``TimersClient.add_app_flow_user_scope`` for ``TimersClient`` instances which are integrated with ``GlobusApp``. This method registers the specific flow ``user`` scope dependency needed for a flow timer. (:pr:`1333`) - Added automatic Globus Auth Requirements Error (GARE) redriving to GlobusApp (:pr:`1339`). - More details available at :ref:`globus_app_gare_integration` - This feature is disabled by default but can be turned on by setting ``auto_retry_gares=True`` in a GlobusAppConfig. Fixed ----- - Fixed a resource leak in which a ``GlobusApp`` would create internal client objects and never close the associated transports. (:pr:`1326`) Development ----------- - Added a new ``make install`` recipe. .. _changelog-4.1.0: v4.1.0 (2025-10-23) =================== Added ----- - Updated ``create_flow`` and ``update_flow`` to accept an authentication policy to assign to a flow. (:pr:`1334`) - Note: SDK support for this feature is being released in advance of service support, which will follow at a later time. .. _changelog-4.0.1: v4.0.1 (2025-10-13) =================== Fixed ----- - Fix the route for ``TransferClient.set_subscription_admin_verified()`` (:pr:`1331`) .. _changelog-4.0.0: v4.0.0 (2025-10-08) =================== *No changes from v4.0.0b2* .. _changelog-4.0.0b2: v4.0.0b2 (2025-09-24) ===================== Added ----- - On Python 3.11+, the SDK will populate the ``__notes__`` of API errors with a message containing the full body of the error response. ``__notes__`` is part of the default presentation of a traceback. (:pr:`1299`) Removed ------- - The following methods and parameters, which were deprecated in globus-sdk v3, have been removed (:pr:`1309`): - The ``skip_activation_check`` parameter for ``TransferData`` and ``DeleteData``. - The ``recursive_symlinks`` parameter for ``TransferData``. - The ``add_symlink_item`` method of ``TransferData``. Changed ------- - Passing non-``Scope`` types to ``Scope.with_dependency`` and ``Scope.with_dependencies`` now raises a ``TypeError``. Previously, this was allowed at runtime but created an invalid ``Scope`` object. (:pr:`1300`) .. _changelog-4.0.0b1: v4.0.0b1 (2025-07-31) ===================== Breaking Changes ---------------- - The ``RequestsTransport`` object has been refactored to separate it from configuration which controls request retries. A new ``RetryConfig`` object is introduced and provided as ``client.retry_config`` on all client types. The interface for controlling these configurations has been updated. (:pr:`1275`) - The ``transport_class`` attribute has been removed from client classes. - Clients now accept ``transport``, an instance of ``RequestsTransport``, and ``retry_config``, an instance of ``RetryConfig``, instead of ``transport_params``. - Users seeking to customize the retry backoff, sleep maximum, and max retries should now use ``retry_config``, as these are no longer controlled through ``transport``. - The capabilities of the ``RequestsTransport.tune()`` context manager have been divided into ``RequestsTransport.tune()`` and ``RetryConfig.tune()``. - The retry configuration is exposed to retry checks as an attribute of the ``RequestCallerInfo``, which is provided on the ``RetryContext``. As a result, checks can examine the configuration. - Interfaces for normalizing scope data have changed. (:pr:`1289`) - The ``scopes_to_str`` function has been replaced with ``ScopeParser.serialize``. - ``ScopeParser.serialize`` will raise an error if the serialized data is empty. A flag, ``reject_empty=False``, can be passed to disable this check. - The ``scopes_to_scope_list`` function has been removed. Removed ------- - Removed the ``filter_role`` parameter to ``FlowsClient.list_flows``. This parameter was deprecated in ``globus-sdk`` version 3. (:pr:`1291`) - Removed ``SearchClient.update_entry``. This method was deprecated in ``globus-sdk`` version 3. (:pr:`1292`) - Removed ``SearchClient.create_entry``. This method was deprecated in ``globus-sdk`` version 3. (:pr:`1293`) - Removed the ``SearchQuery`` type. Users should use ``SearchQueryV1`` instead. ``SearchQuery`` was deprecated in ``globus-sdk`` version 3. (:pr:`1294`) Changed ------- - The legacy token storage adapters are now only available from the ``globus_sdk.token_storage.legacy`` subpackage. Users are encouraged to migrate to the newer tooling available directly from ``globus_sdk.token_storage``. (:pr:`1290`) - Update ``warn_deprecated`` to emit ``RemovedInV5Warning`` and remove ``RemovedInV4Warning`` class (:pr:`1295`) .. _changelog-4.0.0a4: v4.0.0a4 (2025-07-25) ===================== Breaking Changes ---------------- - The ``function_data`` argument to ``ComputeClientV2.register_function`` has been renamed to ``data`` to be consistent with other usages. - ``AuthClient`` no longer accepts ``client_id`` as a parameter and does not provide it as an attribute. This was deprecated in globus-sdk version 3. (:pr:`1271`) Added ----- - Add ``RequestCallerInfo`` data object to ``RequestsTransport.request`` for passing caller context information. (:pr:`1261`) Removed ------- - The ``TimerJob.from_transfer_data`` classmethod, which was deprecated in globus-sdk version 3, has been removed. Users should use the ``TransferTimer`` class to construct timers which submit transfer tasks. (:pr:`1269`) - The ``oauth2_validate_token`` method has been removed from ``NativeAppAuthClient`` and ``ConfidentialAppAuthClient``. This method was deprecated in globus-sdk v3. (:pr:`1270`) - Removed ``AuthClient.oauth2_userinfo``. This method was deprecated in ``globus-sdk`` version 3. (:pr:`1272`) - Removed support for ``ConfidentialAppAuthClient.get_identities``. This usage was deprecated in ``globus-sdk`` version 3. (:pr:`1273`) - Users calling the Get Identities API on behalf of a client identity should instead get tokens for the client and use those tokens to call ``AuthClient.get_identities``. For example, by instantiating an ``AuthClient`` using a ``ClientCredentialsAuthorizer``. - This also means that it is no longer valid to use a ``ConfidentialAppAuthClient`` to initialize an ``IdentityMap``. - ``TransferClient.create_endpoint`` has been removed. This method primarily supported creation of GCSv4 servers and was deprecated in ``globus-sdk`` v3. (:pr:`1276`) - ``GCSClient.connector_id_to_name()`` has been removed. It was deprecated in ``globus-sdk`` version 3. Users should use ``globus_sdk.ConnectorTable`` instead. (:pr:`1277`) - Removed support for Endpoint Activation, a feature which was specific to Globus Connect Server v4. (:pr:`1279`) - Removed the activation methods: ``TransferClient.endpoint_autoactivate``, ``TransferClient.endpoint_activate``, ``TransferClient.endpoint_deactivate``, and ``TransferClient.endpoint_get_activation_requirements`` - Removed the specialized ``ActivationRequirementsResponse`` parsed response type - ``TransferClient.update_endpoint`` would previously check the ``myproxy_server`` and ``oauth_server`` parameters, which were solely used for the purpose of configuring activation. It no longer does so. - Removed the ``ComputeClient`` alias. This name was deprecated in ``globus-sdk`` version 3. Users should use ``ComputeClientV2`` or ``ComputeClientV3`` instead. (:pr:`1282`) - Removed ``GlobusAPIError.raw_text``. This attribute was deprecated in ``globus-sdk`` version 3. Users should use the ``text`` attribute instead. (:pr:`1283`) - Removed ``TransferClient`` methods for modifying "endpoint servers", a feature specific to Globus Connect Server v4. Specifically, ``add_endpoint_server``, ``update_endpoint_server``, and ``delete_endpoint_server``. These methods were deprecated in ``globus-sdk`` version 3. (:pr:`1284`) - Removed the ``ComputeFunctionDocument`` and ``ComputeFunctionMetadata`` classes. These helpers were deprecated in ``globus-sdk`` version 3. - Removed ``TransferClient.operation_symlink``. This method was deprecated in ``globus-sdk`` version 3. (:pr:`1286`) Changed ------- - Renamed the ``globus_sdk._testing`` subpackage to ``globus_sdk.testing``. (:pr:`1251`) - Renamed the ``globus_sdk.tokenstorage`` subpackage to ``globus_sdk.token_storage`` and removed the ``globus_sdk.experimental.tokenstorage`` (:pr:`1252`) - Remove support for normalizing nested iterables of scopes, e.g. ``[["scope1"], "scope2"]`` (:pr:`1259`) .. _changelog-4.0.0a3: v4.0.0a3 (2025-07-10) ===================== Breaking Changes ---------------- - All defaults of ``None`` converted to ``globus_sdk.MISSING`` for all payload types in the Transfer client. (:pr:`1216`) - The ``transfer_client`` parameter to ``TransferData`` and ``DeleteData`` has been removed. See the upgrading doc for transition details. (:pr:`1236`) - In Globus Auth client classes, defaults of ``None`` are converted to ``MISSING`` for optional fields. (:pr:`1236`) Added ----- - ``SpecificFlowClient`` has a new method, ``add_app_transfer_data_access_scope`` which facilitates declaration of scope requirements when starting flows which interact with collections that need ``data_access`` scopes. (:pr:`1166`) Removed ------- - ``globus_sdk.experimental.scope_parser`` has been removed. Use ``globus_sdk.scopes`` instead. (:pr:`1236`) Changed ------- - ``Scope`` objects are now immutable. (:pr:`1208`) - ``Scope.dependencies`` is now a tuple, not a list. - The ``add_dependency`` method has been removed, since mutating a ``Scope`` is no longer possible. - A new evolver method, ``Scope.with_dependency`` has been added. It extends the ``dependencies`` tuple in a new ``Scope`` object. - A batch version of ``Scope.with_dependency`` has been added, ``Scope.with_dependencies``. - An evolver for the ``optional`` field of a ``Scope`` is also now available, named ``Scope.with_optional``. - Scope parsing has been separated from the main ``Scope`` class into a dedicated ``ScopeParser`` which provides parsing methods. (:pr:`1208`) - Use ``globus_sdk.scopes.ScopeParser`` for complex parsing use-cases. The ``ScopeParser.parse`` classmethod parses strings into lists of scope objects. - ``Scope.merge_scopes`` has been moved to ``ScopeParser.merge_scopes``. - ``Scope.parse`` is changed to call ``ScopeParser.parse`` and verify that there is exactly one result, which it returns. This means that ``Scope.parse`` now returns a single ``Scope``, not a ``list[Scope]``. - ``Scope.serialize`` and ``Scope.deserialize`` have been removed as methods. Use ``str(scope_object)`` as a replacement for ``serialize()`` and ``Scope.parse`` as a replacement for ``deserialize()``. - Payload types now inherit from ``dict`` rather than ``UserDict``. The ``PayloadWrapper`` utility class has been replaced with ``Payload``. (:pr:`1222`) - Payload types are more consistent about encoding missing values using ``MISSING``. (:pr:`1222`) - The SDK's ``ScopeBuilder`` types have been replaced with ``StaticScopeCollection`` and ``DynamicScopeCollection`` types. (:pr:`1237`) - Scopes provided as constants by the SDK are now ``Scope`` objects, not strings. They can be converted to strings trivially with ``str(scope)``. - The various scope builder types have been renamed. ``SpecificFlowScopes``, ``GCSEndpointScopes``, and ``GCSCollectionScopes`` replace ``SpecificFlowScopeBuilder``, ``GCSEndpointScopeBuilder``, and ``GCSCollectionScopeBuilder``. - The ``ScopeBuilder`` types have been simplified and improved as the new ``ScopeCollection`` types. (:pr:`1237`) - ``ScopeBuilder`` is replaced with ``StaticScopeCollection`` and ``DynamicScopeCollection``. The ``scopes`` attribute of client classes is now a scope collection. - The attributes of ``ScopeCollection``\s are ``Scope`` objects, not strings. - ``ScopeCollection``\s define ``__iter__``, yielding the provided scopes, but not ``__str__``. .. _changelog-4.0.0a2: v4.0.0a2 (2025-06-05) ===================== Breaking Changes ---------------- - The SDK version is no longer available in ``globus_sdk.version.__version__``. (:pr:`1195`) Packages that want to query the SDK version must use ``importlib.metadata``: .. code-block:: python import importlib.metadata GLOBUS_SDK_VERSION = importlib.metadata.distribution("globus_sdk").version - The legacy ``MutableScope`` type has been removed. (:pr:`1198`) - The ``make_mutable`` method on ``ScopeBuilder`` objects has also been removed as a consequence of this change. - Defaults of ``None`` were converted to ``globus_sdk.MISSING`` for multiple client methods and payload types, covering Compute, Flows, Groups, GCS, and Search. (:pr:`1205`, :pr:`1207`, :pr:`1212`, :pr:`1214`) Removed ------- - ``globus_sdk.experimental.auth_requirements_error`` has been removed. Use ``globus_sdk.gare`` instead. (:pr:`1202`) - ``GlobusAPIError`` no longer provides a setter for ``message``. The ``message`` property is now read-only. (:pr:`1204`) - Deprecated aliases for ``TimersClient``, ``TimersScopes``, and ``TimersAPIError`` have been removed. (:pr:`1206`) .. _changelog-4.0.0a1: v4.0.0a1 (2025-05-20) ===================== Breaking Changes ---------------- - The SDK no longer sets default scopes for direct use of client credentials and auth client login flow methods. Users should either use ``GlobusApp`` objects, which can specify scopes based on the clients in use, or else pass a list of scopes explicitly to ``oauth2_client_credentials_tokens`` or ``oauth2_start_flow``. (:pr:`1186`) - The default ``GlobusAPIError.code`` value is now ``None`` when ``code`` is not supplied in the error body. Previously, the default was ``"Error"``. (:pr:`1190`) - The default ``TimersAPIError.code`` value is now ``None`` when an error which appears to be validation-related has no ``code``. Previously, the default was ``"ValidationError"``. (:pr:`1191`) - SDK client classes no longer define nor prepend a ``base_path`` attribute to paths. Make sure to use the full path now when using client methods. (:pr:`1185`) - Updated MappedCollectionDoc and GuestCollectionDoc with MissingType. (:pr:`1189`) .. _changelog-3.65.0: v3.65.0 (2025-10-02) ==================== Added ----- - Add a ``FlowTimer`` payload class to aid in creating timers that run flows. - Add the ``statuses`` parameter to ``GroupsClient.get_my_groups()``. (:pr:`1317`) - Add ``.change_role()`` to the Globus Groups ``BatchMembershipActions`` helper class. (:pr:`1318`) - Add ``.change_roles()`` to the Globus Groups ``GroupsManager`` class. (:pr:`1318`) Development ----------- - Fix a Poetry deprecation warning in the test suite. (:pr:`1320`) .. _changelog-3.64.0: v3.64.0 (2025-09-24) ==================== Added ----- - Added ``SearchClient.update_index`` as a method for modifying index names and descriptions. (:pr:`1310`) Deprecated ---------- - The following Transfer features have been deprecated: (:pr:`1308`, :pr:`1309`) - The ``add_symlink_item`` method of ``TransferData``. This is not supported by any collections. - The ``recursive_symlinks`` parameter to ``TransferData``. This is not supported by any collections. - The ``skip_activation_check`` parameter to ``TransferData`` and ``DeleteData``. This no longer has any effect when set. .. _changelog-3.63.0: v3.63.0 (2025-09-04) ==================== - Renamed the ``GroupsClient`` method ``set_subscription_admin_verified_id`` to ``set_subscription_admin_verified``. (:pr:`1302`) - ``GroupsClient.set_subscription_admin_verified_id`` still exists but emits a deprecation warning. .. _changelog-3.62.0: v3.62.0 (2025-07-31) ==================== - Added support for setting a group's ``subscription_id`` via ``GroupsClient.set_subscription_admin_verified_id``. (:pr:`1287`) .. _changelog-3.61.0: v3.61.0 (2025-07-23) ==================== Deprecated ---------- - ``TransferClient`` methods which are specific to Globus Connect Server v4 are now deprecated and emit deprecation warnings when used. (:pr:`1274`) - The ``ComputeClient`` alias for ``ComputeClientV2`` is now deprecated. Users of Globus Compute are encouraged to use ``ComputeClientV2`` or ``ComputeClientV3`` instead. (:pr:`1278`) .. _changelog-3.60.0: v3.60.0 (2025-07-09) ==================== Added ----- - Recognize ``dependent_consent_required`` errors from the Auth API as a Globus Auth Requirements Error (GARE) and support converting them to GAREs. (:pr:`1246`) Fixed ----- - Accept authorization parameters containing dependent scopes when ``app.login()`` is called with a GARE's authorization parameters. (:pr:`1247`) .. _changelog-3.59.0: v3.59.0 (2025-07-01) ==================== Added ----- - Added the ``TransferClient.set_subscription_admin_verified()`` method. (:pr:`1227`) - Updated ``ComputeClientV2.get_endpoints`` with a new ``role`` kwarg. (:pr:`1238`) Development ----------- - Convert the CHANGELOG to Markdown-compatible headers. This resolves rendering issues in Dependabot PRs in the CLI, and simplifies compatibility between RST and Markdown. .. _changelog-3.58.0: v3.58.0 (2025-06-16) ==================== Added ----- - Add the ``SpecificFlow.validate_run()`` method. (:pr:`1221`) Fixed ----- - Fix an error which caused the ``restrict_transfers_to_high_assurance`` field to be malformed when set on a collection payload type. (:pr:`1211`) .. _changelog-3.57.0: v3.57.0 (2025-06-04) ==================== Added ----- - Globus Connect Server collection document classes now support attributes up to document version 1.15.0. (:pr:`1197`) Deprecated ---------- - Importing scope parsing tools from ``globus_sdk.experimental`` now emits a deprecation warning. These names were previously deprecated in documentation only. (:pr:`1201`) Documentation ------------- - Remove the badges at the top of the README. (:pr:`1194`) .. _changelog-3.56.1: v3.56.1 (2025-05-20) ==================== Fixed ----- - Fix the type annotation on ``filter_roles`` for ``FlowsClient`` to allow non-list iterables. (:pr:`1183`) .. _changelog-3.56.0: v3.56.0 (2025-05-05) ==================== Added ----- - Transport objects now provide a ``close()`` method for closing resources which belong to them, primarily the underlying session. (:pr:`1171`) - Add ``activity_notification_policy`` to GuestCollectionDocument, associating it with GCS collection document version 1.14.0. (:pr:`1172`) - ``FlowsClient.list_flows`` and ``FlowsClient.list_runs`` now support the ``filter_roles`` parameter to filter results by one or more roles. (:pr:`1174`) - ``AuthLoginClient`` now supports a ``session_message`` when constructing an OAuth2 authorization URL. (:pr:`1179`) - ``LoginFlowManager`` will now use a ``session_message`` present in the supplied ``GlobusAuthorizationParameters`` as part of the login flow. (:pr:`1179`) Changed ------- - When parsing GAREs using ``to_gare`` and ``to_gares``, the root document is now considered a possible location for a GARE when subdocument errors are present on a ``GlobusAPIError`` object. Previously, the root document would only be considered in the absence of subdocument errors. (:pr:`1173`) Deprecated ---------- - ``filter_role`` parameter for ``FlowsClient.list_flows`` is deprecated. Use ``filter_roles`` instead. (:pr:`1174`) .. _changelog-3.55.0: v3.55.0 (2025-04-18) ==================== Added ----- - ``FlowsClient.create_flow`` and ``FlowsClient.update_flow`` now support ``run_managers`` and ``run_monitors``. (:pr:`1164`) - ``SpecificFlowClient.run_flow()`` now supports ``activity_notification_policy`` as an argument, allowing users to select when their run will notify them. A new helper, ``RunActivityNotificationPolicy``, makes construction of valid policies easier. (:pr:`1167`) Changed ------- - The initialization of a client with a ``GlobusApp`` has been improved and is now available under the public ``attach_globus_app`` method on client classes. Attaching an app is only valid for clients which were initialized without an app or authorizer. (:pr:`1137`) - When a ``GlobusApp`` is used with a client, that client's ``app_scopes`` attribute will now always be populated with the scopes that it passed back to the app. (:pr:`1137`) .. _changelog-3.54.0: v3.54.0 (2025-04-02) ==================== Changed ------- - Added the optional ``required_mfa`` field to ``AuthClient.create_policy()`` and ``AuthClient.update_policy()`` request bodies. (:pr:`1159`) .. _changelog-3.53.0: v3.53.0 (2025-03-25) ==================== Added ----- - Index listing in Globus Search is now available via ``SearchClient.index_list``. (:pr:`1155`) Changed ------- - The ``repr`` for ``globus_sdk.gare.GARE`` has been enhanced to be more informative. (:pr:`1156`) Documentation ------------- - New sections on ``Data Transfer`` and ``Session & Consents`` have been added to the User Guide in the docs. Initial docs cover transfer submission, timer creation, deadlines, and reauthentication after session timeouts. (:pr:`1150`, :pr:`1154`, :pr:`1157`) .. _changelog-3.52.0: v3.52.0 (2025-03-19) ==================== Added ----- - The ``transport`` attached to clients now exposes ``headers`` as a readable and writable dict of headers which will be included in every request. Headers provided to the transport's ``request()`` method overwrite these, as before. (:pr:`1140`) Changed ------- - Updates to ``X-Globus-Client-Info`` in ``RequestsTransport.headers`` are now synchronized via a callback mechanism. Direct manipulations of the ``infos`` list will not result in headers being updated -- callers wishing to modify these data should rely only on the ``add()`` and ``clear()`` methods of the ``GlobusClientInfo`` object. (:pr:`1140`) - ``globus_sdk`` logging no longer emits any INFO-level log messages. All INFO messages have been downgraded to DEBUG. (:pr:`1146`) Documentation ------------- - The tutorial documentation has been rewritten. (:pr:`1145`) .. _changelog-3.51.0: v3.51.0 (2025-03-06) ==================== Added ----- - Most client classes now have their ``__doc__`` attribute modified at runtime to provide better ``help()`` and sphinx documentation. (:pr:`1131`) - Introduce ``globus_sdk.IDTokenDecoder``, which implements ``id_token`` decoding. (:pr:`1136`) - For integration with ``GlobusApp``, a new builder protocol is defined, ``IDTokenDecoderProvider``. This defines instantiation within the context of an app. - When ``OAuthTokenResponse.decode_id_token`` is called, it now internally instantiates an ``IDTokenDecoder`` and uses it to perform the decode. - ``IDTokenDecoder`` objects cache OpenID configuration data and JWKs after looking them up. If a decoder is used multiple times, it will reuse the cached data. - Token storage constructs can now contain an ``IDTokenDecoder`` in their ``id_token_decoder`` attribute. The decoder is used preferentially when trying to read the ``sub`` field from an ``id_token`` to store. - ``GlobusAppConfig`` can now contain ``id_token_decoder``, an ``IDTokenDecoder`` or ``IDTokenDecoderProvider``. The default is ``IDTokenDecoder``. - ``GlobusApp`` initialization will now use the config's ``id_token_decoder`` and attach the ``IDTokenDecoder`` to the token storage which is used. - ``ConnectorTable`` has a new classmethod, ``extend`` which allows users to add new connectors to the mapping. ``ConnectorTable.extend()`` returns a new connector table subclass and does not modify the original. (:pr:`1021`) - Add ``ComputeClientV3.register_function()`` method. (:pr:`1142`) Changed ------- - The SDK now defaults JWT leeway to 300 seconds when decoding ``id_token``\s; the previous leeway was 0.5 seconds. Users should find that they are much less prone to validation errors when working in VMs or other scenarios which can cause significant clock drift. (:pr:`1135`) .. _changelog-3.50.0: v3.50.0 (2025-01-14) ==================== Added ----- - Subclasses of ``BaseClient`` may now specify ``base_url`` as class attribute. (:pr:`1125`) Fixed ----- - Fixed an incorrect URL path in ``ComputeClient.get_task_batch``. (:pr:`1117`) - Fix a bug in ``StorageGatewayDocument`` which stored any ``allowed_domains`` argument under an ``"allow_domains"`` key instead of the correct key, ``"allowed_domains"``. (:pr:`1120`) Documentation ------------- - Updated GlobusAppConfig docs to explain how to disable auto-login. (:pr:`1127`) .. _changelog-3.49.0: v3.49.0 (2024-12-04) ==================== Added ----- - Add ``filter_entity_type`` keyword argument on ``TransferClient.endpoint_search()``. (:pr:`1109`) - Added the ``ComputeClientV3.register_endpoint()``, ``ComputeClientV3.update_endpoint()`` ``ComputeClientV3.lock_endpoint()``, and ``ComputeClientV3.get_endpoint_allowlist()`` methods. (:pr:`1113`) - Added the ``ComputeClientV2.get_version()`` and ``ComputeClientV2.get_result_amqp_url()`` methods. (:pr:`1114`) .. _changelog-3.48.0: v3.48.0 (2024-11-21) ==================== Added ----- - Added the ``ComputeClientV2.register_endpoint()``, ``ComputeClientV2.get_endpoint()`` ``ComputeClientV2.get_endpoint_status()``, ``ComputeClientV2.get_endpoints()``, ``ComputeClientV2.delete_endpoint()``, and ``ComputeClientV2.lock_endpoint()`` methods. (:pr:`1110`) Changed ------- - Removed identity ID consistency validation from ``ClientApp``. (:pr:`1111`) Fixed ----- - Fixed a bug that would cause ``ClientApp`` token refreshes to fail. (:pr:`1111`) .. _changelog-3.47.0: v3.47.0 (2024-11-08) ==================== Added ----- - Add ``TimersClient.add_app_transfer_data_access_scope`` for ``TimersClient`` instances which are integrated with ``GlobusApp``. This method registers the nested scope dependency for a ``data_access`` requirement for a transfer timer. (:pr:`1074`) - ``SearchQueryV1`` is a new class for submitting complex queries replacing the legacy ``SearchQuery`` class. A deprecation warning has been added to the ``SearchQuery`` class. (:pr:`1079`) - Created ``ComputeClientV2`` and ``ComputeClientV3`` classes to support Globus Compute API versions 2 and 3, respectively. The canonical ``ComputeClient`` is now a subclass of ``ComputeClientV2``, preserving backward compatibility. (:pr:`1096`) - Added the ``ComputeClientV3.submit()``, ``ComputeClientV2.submit()``, ``ComputeClientV2.get_task()``, ``ComputeClientV2.get_task_batch()``, and ``ComputeClientV2.get_task_group()`` methods. (:pr:`1094`) Changed ------- - Improved error messaging around EOF errors when prompting for code during a command line login flow (:pr:`1093`) Deprecated ---------- - Deprecated the ``ComputeFunctionDocument`` and ``ComputeFunctionMetadata`` classes. This change reflects an early design adjustment to better align with the existing Globus Compute SDK. (:pr:`1092`) Development ----------- - Introduce a ``toxfile.py`` to ensure clean builds during development. (:pr:`1098`) - The lazy importer used for the top-level ``globus_sdk`` module has been rewritten. It produces identical results to the previous system. (:pr:`1100`) .. _changelog-3.46.0: v3.46.0 (2024-10-15) ==================== Python Support -------------- - Support Python 3.13. (:pr:`1058`) Added ----- - Added an initial Globus Compute client class, :class:`globus_sdk.ComputeClient`. (:pr:`1071`) - Application errors are raised as a :class:`globus_sdk.ComputeAPIError`. - A single method, ``ComputeClient.get_function`` is included initially to get information about a registered function. - Compute scopes are defined at ``globus_sdk.scopes.ComputeScopes`` or ``globus_sdk.ComputeClient.scopes``. - Added the ``ComputeClient.register_function()`` and ``ComputeClient.delete_function()`` methods. (:pr:`1085`) - ``ComputeClient.register_function()`` introduces new data model classes: ``ComputeFunctionDocument`` and ``ComputeFunctionMetadata``. - Added the ``TransferClient.set_subscription_id()`` method. (:pr:`1073`) - Added a new error type, ``globus_sdk.ValidationError``, used in certain cases of ``ValueError``\s caused by invalid content. (:pr:`1044`) Removed ------- - Removed the ``skip_error_handling`` optional kwarg from the ``GlobusApp.get_authorizer(...)`` method interface. (:pr:`1060`) Changed ------- - All previously experimental modules have been moved into main module namespaces and are no longer experimental. Aliases will remain in the experimental namespaces with a deprecation warning until SDKv4. - :ref:`gares` have been moved from ``globus_sdk.experimental.auth_requirements_error`` to ``globus_sdk.gare``. (:pr:`1048`) - The primary document type has been renamed from ``GlobusAuthRequirementsError`` to ``GARE``. - The functions provided by this interface have been renamed to use ``gare`` in their naming: ``to_gare``, ``is_gare``, ``has_gares``, and ``to_gares``. - :ref:`globus_apps` have been moved from ``globus_sdk.experimental.globus_app`` to ``globus_sdk`` and ``globus_sdk.globus_app``. (:pr:`1085`) - :ref:`login_flow_managers` have been moved from ``globus_sdk.experimental.login_flow_managers`` to ``globus_sdk.login_flows``. (:pr:`1057`) - :ref:`token_storages` have been moved from ``globus_sdk.experimental.tokenstorage`` to ``globus_sdk.tokenstorage``. (:pr:`1065`) - :ref:`consents` have been moved from ``globus_sdk.experimental.consents`` to ``globus_sdk.scopes.consents``. (:pr:`1047`) - The response classes for OAuth2 token grants now vary by the grant type. For example, a ``refresh_token``-type grant now produces a :class:`globus_sdk.OAuthRefreshTokenResponse`. This allows code handling responses to more easily identify which grant type produced a response. (:pr:`1051`) - The following new classes have been introduced: :class:`globus_sdk.OAuthRefreshTokenResponse`, :class:`globus_sdk.OAuthAuthorizationCodeResponse`, and :class:`globus_sdk.OAuthClientCredentialsResponse`. - The ``RenewingAuthorizer`` class is now a generic over the response type which it handles, and the subtypes of authorizers are specialized for their types of responses. e.g., ``class RefreshTokenAuthorizer(RenewingAuthorizer[OAuthRefreshTokenResponse])``. - The mechanisms of token data validation inside of ``GlobusApp`` are now more modular and extensible. The ``ValidatingTokenStorage`` class does not define built-in validation behaviors, but instead contains a list of validator objects, which can be extended and otherwise altered. (:pr:`1061`) - These changes allow more validation criteria around token data to be handled within the ``ValidatingTokenStorage``. This changes error behaviors to avoid situations in which multiple errors are raised serially by different layers of GlobusApp. - ``LoginFlowManager``\s built with ``GlobusApp`` now generate a more appropriate value for ``prefill_named_grant``, using the current hostname if possible. (:pr:`1075`) - Imports of ``globus_sdk.exc`` now defer importing ``requests`` so as to reduce import-time performance impact the library is not needed. (:pr:`1044`) The following error classes are now lazily loaded even when ``globus_sdk.exc`` is imported: ``GlobusConnectionError``, ``GlobusConnectionTimeoutError``, ``GlobusTimeoutError``, and ``NetworkError``. Fixed ----- - Fixed the typing-time attributes of ``globus_sdk`` so that ``mypy`` and other type checkers won't erroneously suppress errors about missing attributes. (:pr:`1052`) - Fixed the handling of Dependent Token and Refresh Token responses in ``TokenStorage`` and ``ValidatingTokenStorage`` such that ``id_token`` is only parsed when appropriate. (:pr:`1055`) - Fixed a bug where upgrading from access token to refresh token mode in a ``GlobusApp`` could result in multiple login prompts. (:pr:`1060`) .. _changelog-3.45.0: v3.45.0 (2024-09-06) ==================== Added ----- - The scope builder for ``SpecificFlowClient`` is now available for direct access and use via ``globus_sdk.scopes.SpecificFlowScopeBuilder``. Callers can initialize this class with a ``flow_id`` to get a scope builder for a specific flow, e.g., ``SpecificFlowScopeBuilder(flow_id).user``. ``SpecificFlowClient`` now uses this class internally. (:pr:`1030`) - ``TransferClient.add_app_data_access_scope`` now accepts iterables of collection IDs as an alternative to individual collection IDs. (:pr:`1034`) .. rubric:: Experimental - Added ``login(...)``, ``logout(...)``, and ``login_required(...)`` to the experimental ``GlobusApp`` construct. (:pr:`1041`) - ``login(...)`` initiates a login flow if: - the current entity requires a login to satisfy local scope requirements or - ``auth_params``/``force=True`` is passed to the method. - ``logout(...)`` remove and revokes the current entity's app-associated tokens. - ``login_required(...)`` returns a boolean indicating whether the app believes a login is required to satisfy local scope requirements. Removed ------- .. rubric:: Experimental - Made ``run_login_flow`` private in the experimental ``GlobusApp`` construct. Usage sites should be replaced with either ``app.login()`` or ``app.login(force=True)``. (:pr:`1041`) - **Old Usage** .. code-block:: python app = UserApp("my-app", client_id="") app.run_login_flow() - **New Usage** .. code-block:: python app = UserApp("my-app", client_id="") app.login(force=True) Changed ------- - The client for Globus Timers has been renamed to ``TimersClient``. The prior name, ``TimerClient``, has been retained as an alias. (:pr:`1032`) - Similarly, the error and scopes classes have been renamed and aliased: ``TimersAPIError`` replaces ``TimerAPIError`` and ``TimersScopes`` replaces ``TimerScopes``. - Internal module names have been changed to ``timers`` from ``timer`` where possible. - The ``service_name`` attribute is left as ``timer`` for now, as it is integrated into URL and ``_testing`` logic. .. rubric:: Experimental - The experimental ``TokenStorageProvider`` and ``LoginFlowManagerProvider`` protocols have been updated to require keyword-only arguments for their ``for_globus_app`` methods. This protects against potential ordering confusion for their arguments. (:pr:`1028`) - The ``default_scope_requirements`` for ``globus_sdk.FlowsClient`` has been updated to list the Flows ``all`` scope. (:pr:`1029`) - The ``CommandLineLoginFlowManager`` now exposes ``print_authorize_url`` and ``prompt_for_code`` as methods, which replace the ``login_prompt`` and ``code_prompt`` parameters. Users who wish to customize prompting behavior now have a greater degree of control, and can effect this by subclassing the ``CommandLineLoginFlowManager``. (:pr:`1039`) Example usage, which uses the popular ``click`` library to handle the prompts: .. code-block:: python import click from globus_sdk.experimental.login_flow_manager import CommandLineLoginFlowManager class ClickLoginFlowManager(CommandLineLoginFlowManager): def print_authorize_url(self, authorize_url: str) -> None: click.echo(click.style("Login here for a code:", fg="yellow")) click.echo(authorize_url) def prompt_for_code(self) -> str: return click.prompt("Enter the code here:") - ``GlobusApp.token_storage`` is now a public property, allowing users direct access to the ``ValidatingTokenStorage`` used by the app to build authorizers. (:pr:`1040`) - The experimental ``GlobusApp`` construct's scope exploration interface has changed from ``app.get_scope_requirements(resource_server: str) -> tuple[Scope]`` to ``app.scope_requirements``. The new property will return a deep copy of the internal requirements dictionary mapping resource server to a list of Scopes. (:pr:`1042`) Deprecated ---------- - ``TimerScopes`` is now a deprecated name. Use ``TimersScopes`` instead. (:pr:`1032`) Fixed ----- .. rubric:: Experimental - Container types in ``GlobusApp`` function argument annotations are now generally covariant collections like ``Mapping`` rather than invariant types like ``dict``. (:pr:`1035`) Documentation ------------- - The Globus Timers examples have been significantly enhanced and now leverage more modern usage patterns. (:pr:`1032`) .. _changelog-3.44.0: v3.44.0 (2024-08-02) ==================== Added ----- - Added a reference to the new Flows all scope under ``globus_sdk.scopes.FlowsScopes.all``. (:pr:`1016`) .. rubric:: Experimental - Added support for ``ScopeCollectionType`` to GlobusApp's ``__init__`` and ``add_scope_requirements`` methods. (:pr:`1020`) Changed ------- - Updated ``ScopeCollectionType`` to be defined recursively. (:pr:`1020`) - ``TransferClient.add_app_data_access_scope`` now raises an error if it is given an invalid collection ID. (:pr:`1022`) .. rubric:: Experimental - Changed the experimental ``GlobusApp`` class in the following way (:pr:`1017`): - ``app_name`` is no longer required (defaults to "Unnamed Globus App") - Token storage now defaults to including the client id in the path. - Old (unix) : ``~/.globus/app/{app_name}/tokens.json`` - New (unix): ``~/.globus/app/{client_id}/{app_name}/tokens.json`` - Old (win): ``~\AppData\Local\globus\app\{app_name}\tokens.json`` - New (win): ``~\AppData\Local\globus\app\{client_id}\{app_name}\tokens.json`` - ``GlobusAppConfig.token_storage`` now accepts shorthand string references: ``"json"`` to use a ``JSONTokenStorage``, ``"sqlite"`` to use a ``SQLiteTokenStorage`` and ``"memory"`` to use a ``MemoryTokenStorage``. - ``GlobusAppConfig.token_storage`` also now accepts a ``TokenStorageProvider``, a class with a ``for_globus_app(...) -> TokenStorage`` class method. - Renamed the experimental ``FileTokenStorage`` attribute ``.filename`` to ``.filepath``. - Changed the experimental ``GlobusApp`` class in the following ways (:pr:`1018`): - ``LoginFlowManagers`` now insert ``GlobusApp.app_name`` into any native client login flows as the ``prefill_named_grant``. - ``GlobusAppConfig`` now accepts a ``login_redirect_uri`` parameter to specify the redirect URI for a login flow. - Invalid when used with a ``LocalServerLoginFlowManager``. - Defaults to ``"https://auth.globus.org/v2/web/auth-code"`` for native client flows. Raises an error if not set for confidential ones. - ``UserApp`` now allows for the use of confidential client flows with the use of either a ``LocalServerLoginFlowManager`` or a configured ``login_redirect_uri``. - ``GlobusAppConfig.login_flow_manager`` now accepts shorthand string references ``"command-line"`` to use a ``CommandLineLoginFlowManager`` and ``"local-server"`` to use a ``LocalServerLoginFlowManager``. - ``GlobusAppConfig.login_flow_manager`` also now accepts a ``LoginFlowManagerProvider``, a class with a ``for_globus_app(...) -> LoginFlowManager`` class method. Development ----------- - Added a scope normalization function ``globus_sdk.scopes.scopes_to_scope_list`` to translate from ``ScopeCollectionType`` to a list of ``Scope`` objects. (:pr:`1020`) .. _changelog-3.43.0: v3.43.0 (2024-07-25) ==================== Added ----- - The ``TransferClient.task_list`` method now supports ``orderby`` as a parameter. (:pr:`1011`) Changed ------- - The ``SQLiteTokenStorage`` component in ``globus_sdk.experimental`` has been changed in several ways to improve its interface. (:pr:`1004`) - ``:memory:`` is no longer accepted as a database name. Attempts to use it will trigger errors directing users to use ``MemoryTokenStorage`` instead. - Parent directories for a target file are automatically created, and this behavior is inherited from the ``FileTokenStorage`` base class. (This was previously a feature only of the ``JSONTokenStorage``.) - The ``config_storage`` table has been removed from the generated database schema, the schema version number has been incremented to ``2``, and methods and parameters related to manipulation of ``config_storage`` have been removed. Documentation ------------- - Added a new experimental "Updated Examples" section which rewrites and reorders many examples to aid in discovery. (:pr:`1008`) - ``GlobusApp``, ``UserApp`, and ``ClientApp`` class reference docs. (:pr:`1013`) - Added a narrative example titled ``Using a GlobusApp`` detailing the basics of constructing and using a GlobusApp. (:pr:`1013`) - Remove unwritten example updates from toctree. (:pr:`1014`) .. _changelog-3.42.0: v3.42.0 (2024-07-15) ==================== Python Support -------------- - Remove support for Python 3.7. (:pr:`997`) Added ----- - Add ``globus_sdk.ConnectorTable`` which provides information on supported Globus Connect Server connectors. This object maps names to IDs and vice versa. (:pr:`955`) - Support adding query parameters to ``ConfidentialAppAuthClient.oauth2_token_introspect`` via a ``query_params`` argument. (:pr:`984`) - Add ``get_gcs_info`` as a helper method to ``GCSClient`` for getting information from a Globus Connect Server's ``info`` API route. - Add ``endpoint_client_id`` as a property to ``GCSClient``. - Clients will now emit a ``X-Globus-Client-Info`` header which reports the version of the ``globus-sdk`` which was used to send a request. Users may customize this header further by modifying the ``globus_client_info`` object attached to the transport object. (:pr:`990`) .. rubric:: Experimental - Add a new abstract class, ``TokenStorage``, to ``experimental``. ``TokenStorage`` expands the functionality of ``StorageAdapter`` but is not fully backwards compatible. (:pr:`980`) - ``FileTokenStorage``, ``JSONTokenStorage``, ``MemoryTokenStorage`` and ``SQLiteTokenStorage`` are new concrete implementations of ``TokenStorage``. - Add ``ValidatingStorageAdapter`` to ``experimental``, which validates that identity is maintained and scope requirements are met on token storage/retrieval. (:pr:`978`, :pr:`980`) - Add a new abstract class, ``AuthorizerFactory`` to ``experimental``. ``AuthorizerFactory`` provides an interface for getting a ``GlobusAuthorizer`` from a ``ValidatingTokenStorage``. (:pr:`985`) - ``AccessTokenAuthorizerFactory``, ``RefreshTokenAuthorizerFactory``, and ``ClientCredentialsAuthorizerFactory`` are new concrete implementations of ``AuthorizerFactory``. - Add a new abstract class, ``GlobusApp`` to ``experimental``. A ``GlobusApp`` is an abstraction which allows users to define their authorization requirements implicitly and explicitly, attach that state to their various clients, and drive login flows. (:pr:`986`) - ``UserApp`` and ``ClientApp`` are new implementations of ``GlobusApp`` which handle authentications for user-login and client-credentials. - ``GlobusAppConfig`` is an object which can be used to control ``GlobusApp`` behaviors. - Add ``app`` as an optional argument to ``BaseClient`` which will accept a ``GlobusApp`` to handle authentication, token validation, and token storage when using the client. - Add ``default_scope_requirements`` as a property to ``BaseClient`` for subclasses to define scopes to automatically be used with a ``GlobusApp``. The default implementation raises a ``NotImplementedError``. - Add ``add_app_scope`` to ``BaseClient`` as an interface for adding additional scope requirements to its ``app``. - ``AuthClient``, ``FlowsClient``, ``GCSClient``, ``GroupsClient``, ``SearchClient``, ``TimerClient``, and ``TransferClient`` all add ``app`` as an optional argument and define ``default_scope_requirements`` so that they can be used with a ``GlobusApp``. - Add ``add_app_data_access_scope`` to ``TransferClient`` as an interface for adding a dependent data access scope requirements needed for interacting with standard Globus Connect Server mapped collections to its ``app``. - Auto-login (overridable in config) GlobusApp login retry on token validation error. (:pr:`994`) - Added the configuration parameter ``GlobusAppConfig.environment``. (:pr:`1001`) Changed ------- - ``GCSClient`` instances now have a non-None ``resource_server`` property. - ``GlobusAuthorizationParameters`` no longer enforces that at least one field is set. (:pr:`989`) - Improved the validation and checking used inside of ``globus_sdk.tokenstorage.SimpleJSONFileAdapter`` and ``globus_sdk.experimental.tokenstorage.JSONTokenStorage``. (:pr:`997`) Deprecated ---------- - ``GCSClient.connector_id_to_name`` has been deprecated. Use ``ConnectorTable.lookup`` instead. (:pr:`955`) Fixed ----- .. rubric:: Experimental - When a ``JSONTokenStorage`` is used, the containing directory will be automatically be created if it doesn't exist. (:pr:`998`) - ``GlobusApp.add_scope_requirements`` now has the side effect of clearing the authorizer cache for any referenced resource servers. (:pr:`1000`) - ``GlobusAuthorizer.scope_requirements`` was made private and a new method for accessing scope requirements was added at ``GlobusAuthorizer.get_scope_requirements``. (:pr:`1000`) - A ``GlobusApp`` will now auto-create an Auth consent client for dependent scope evaluation against consents as a part of instantiation. (:pr:`1000`) - Fixed a bug where specifying dependent tokens in a new ``GlobusApp`` would cause the app to infinitely prompt for log in. (:pr:`1002`) - Fixed a ``GlobusApp`` bug which would cause LocalServerLoginFlowManager to error on MacOS when versions earlier than Python 3.11. (:pr:`1003`) Documentation ------------- - Document how to manage Globus SDK warnings. (:pr:`988`) .. _changelog-3.41.0: v3.41.0 (2024-04-26) ==================== Added ----- - Added a new AuthClient method ``get_consents`` and supporting local data objects. These allows a client to poll and interact with the current Globus Auth consent state of a particular identity rooted at their client. (:pr:`971`) - Added ``LoginFlowManager`` and ``CommandLineLoginFLowManager`` to experimental (:pr:`972`) - Added ``LocalServerLoginFlowManager`` to experimental (:pr:`977`) - Added support to ``FlowsClient`` for the ``validate_flow`` operation of the Globus Flows service. (:pr:`979`) .. _changelog-3.40.0: v3.40.0 (2024-04-15) ==================== Added ----- - Add ``globus_sdk.tokenstorage.MemoryAdapter`` for the simplest possible in-memory token storage mechanism. (:pr:`964`) - ``ConfidentialAppAuthClient.oauth2_get_dependent_tokens`` now supports the ``scope`` parameter as a string or iterable of strings. (:pr:`965`) - Moved scope parsing out of experimental. The ``Scope`` construct is now importable from the top level ``globus_sdk`` module. (:pr:`966`) - Support updating subscriptions assigned to flows in the Flows service. (:pr:`974`) Development ----------- - Fix concurrency problems in the test suite caused by isort's ``.isorted`` temporary files. (:pr:`973`) .. _changelog-3.39.0: v3.39.0 (2024-03-06) ==================== Added ----- - Added ``TransferClient.operation_stat`` helper method for getting the status of a path on a collection (:pr:`961`) .. _changelog-3.38.0: v3.38.0 (2024-03-01) ==================== Added ----- - ``IterableGCSResponse`` and ``UnpackingGCSResponse`` are now available as top-level exported names. (:pr:`956`) - Add ``GroupsClient.get_group_by_subscription_id`` for resolving subscriptions to groups. This also expands the ``_testing`` data for ``get_group`` to include a subscription group case. (:pr:`957`) - Added ``prompt`` to the recognized *Globus Authorization Requirements Error* ``authorization_parameters`` fields. (:pr:`958`) .. _changelog-3.37.0: v3.37.0 (2024-02-14) ==================== Added ----- - All of the basic HTTP methods of ``BaseClient`` and its derived classes which accept a ``data`` parameter for a request body, e.g. ``TransferClient.post`` or ``GroupsClient.put``, now allow the ``data`` to be passed in the form of already encoded ``bytes``. (:pr:`951`) Fixed ----- - Update ``ensure_datatype`` to work with documents that set ``DATA_TYPE`` to ``MISSING`` instead of omitting it (:pr:`952`) .. _changelog-3.36.0: v3.36.0 (2024-02-12) ==================== Added ----- - Added support for GCS endpoint get & update operations (:pr:`933`) - ``gcs_client.get_endpoint()`` - ``gcs_client.update_endpoint(EndpointDocument(...))`` - ``TransferClient.endpoint_manager_task_list()`` now supports ``filter_endpoint_use`` as a parameter. (:pr:`948`) - ``FlowsClient.create_flow`` now supports ``subscription_id`` as a parameter. (:pr:`949`) .. _changelog-3.35.0: v3.35.0 (2024-01-29) ==================== Added ----- - Added a ``session_required_mfa`` parameter to the ``AuthorizationParameterInfo`` error info object and ``oauth2_get_authorize_url`` method (:pr:`939`) Changed ------- - The argument specification for ``AuthClient.create_policy`` was incorrect. The corrected method will emit deprecation warnings if called with positional arguments, as the corrected version uses keyword-only arguments. (:pr:`936`) Deprecated ---------- - ``TransferClient.operation_symlink`` is now officially deprecated and will emit a ``RemovedInV4Warning`` if used. (:pr:`942`) Fixed ----- - Included documentation in ``AuthorizationParameterInfo`` for ``session_required_policies`` (:pr:`939`) .. _changelog-3.34.0: v3.34.0 (2024-01-02) ==================== Added ----- - Add the ``delete_protected`` field to ``MappedCollectionDocument``. (:pr:`920`) Changed ------- - Minor improvements to handling of paths and URLs. (:pr:`922`) - Request paths which start with the ``base_path`` of a client are now normalized to avoid duplicating the ``base_path``. - When a ``GCSClient`` is initialized with an HTTPS URL, if the URL does not end with the ``/api`` suffix, that suffix will automatically be appended. This allows the ``gcs_manager_url`` field from Globus Transfer to be used verbatim as the address for a ``GCSClient``. Deprecated ---------- - ``NativeAppAuthClient.oauth2_validate_token`` and ``ConfidentialAppAuthClient.oauth2_validate_token`` have been deprecated, as their usage is discouraged by the Auth service. (:pr:`921`) Development ----------- - Migrate from a CHANGELOG symlink to the RST ``.. include`` directive. (:pr:`918`) - Tutorial endpoint references are removed from tests and replaced with bogus values. (:pr:`919`) .. _changelog-3.33.0.post0: v3.33.0.post0 (2023-12-05) ========================== Documentation ------------- - Remove references to the Tutorial Endpoints from documentation. (:pr:`915`) .. _changelog-3.33.0: v3.33.0 (2023-12-04) ==================== Added ----- - Support custom CA certificate bundles. (:pr:`903`) Previously, SSL/TLS verification allowed only a boolean ``True`` or ``False`` value. It is now possible to specify a CA certificate bundle file using the existing ``verify_ssl`` parameter or ``GLOBUS_SDK_VERIFY_SSL`` environment variable. This may be useful for interacting with Globus through certain proxy firewalls. Fixed ----- - Fix the type annotation for ``globus_sdk.IdentityMap`` init, which incorrectly rejected ``ConfidentialAppAuthClient``. (:pr:`912`) .. _changelog-3.32.0: v3.32.0 (2023-11-09) ==================== Added ----- .. note:: These changes pertain to methods of the client objects in the SDK which interact with Globus Auth client registration. To disambiguate, we refer to the Globus Auth entities below as "Globus Auth clients" or specify "in Globus Auth", as appropriate. - Globus Auth clients objects now have methods for interacting with client and project APIs. (:pr:`884`) - ``NativeAppAuthClient.create_native_app_instance`` creates a new native app instance in Globus Auth for a client. - ``ConfidentialAppAuthClient.create_child_client`` creates a child client in Globus Auth for a confidential app. - ``AuthClient.get_project`` looks up a project. - ``AuthClient.get_policy`` looks up a policy document. - ``AuthClient.get_policies`` lists all policies in all projects for which the current user is an admin. - ``AuthClient.create_policy`` creates a new policy. - ``AuthClient.update_policy`` updates an existing policy. - ``AuthClient.delete_policy`` deletes a policy. - ``AuthClient.get_client`` looks up a Globus Auth client by ID or FQDN. - ``AuthClient.get_clients`` lists all Globus Auth clients for which the current user is an admin. - ``AuthClient.create_client`` creates a new client in Globus Auth. - ``AuthClient.update_client`` updates an existing client in Globus Auth. - ``AuthClient.delete_client`` deletes a client in Globus Auth. - ``AuthClient.get_client_credentials`` lists all client credentials for a given Globus Auth client. - ``AuthClient.create_client_credential`` creates a new client credential for a given Globus Auth client. - ``AuthClient.delete_client_credential`` deletes a client credential. - ``AuthClient.get_scope`` looks up a scope. - ``AuthClient.get_scopes`` lists all scopes in all projects for which the current user is an admin. - ``AuthClient.create_scope`` creates a new scope. - ``AuthClient.update_scope`` updates an existing scope. - ``AuthClient.delete_scope`` deletes a scope. - A helper object has been defined for dependent scope manipulation via the scopes APIs, ``globus_sdk.DependentScopeSpec`` (:pr:`884`) Fixed ----- - When serializing ``TransferTimer`` data, do not convert to UTC if the input was a valid datetime with an offset. (:pr:`900`) .. _changelog-3.31.0: v3.31.0 (2023-11-01) ==================== Added ----- - Add support for the new Transfer Timer creation method, in the form of a client method, ``TimerClient.create_timer``, and a payload builder type, ``TransferTimer`` (:pr:`887`) - ``create_timer`` only supports dict data and ``TransferTimer``, not the previous ``TimerJob`` type - Additional helper classes, ``RecurringTimerSchedule`` and ``OneceTimerSchedule``, are provided to help build the ``TransferTimer`` payload - Request encoding in the SDK will now automatically convert any ``uuid.UUID`` objects into strings. Previously this was functionality provided by certain methods, but now it is universal. (:pr:`892`) Deprecated ---------- - Creation of timers to run transfers using ``TimerJob`` is now deprecated (:pr:`887`) .. _changelog-3.30.0: v3.30.0 (2023-10-27) ==================== Added ----- - ``TransferClient.operation_ls`` now supports the ``limit`` and ``offset`` parameters (:pr:`868`) - A new sentinel value, ``globus_sdk.MISSING``, has been introduced. It is used for method calls which need to distinguish missing parameters from an explicit ``None`` used to signify ``null`` (:pr:`885`) - ``globus_sdk.MISSING`` is now supported in payload data for all methods, and will be automatically removed from the payload before sending to the server Changed ------- - ``GroupPolicies`` objects now treat an explicit instantiation with ``high_assurance_timeout=None`` as setting the timeout to ``null`` (:pr:`885`) .. _changelog-3.29.0: v3.29.0 (2023-10-12) ==================== Changed ------- - The inheritance structure used for Globus Auth client classes has changed. (:pr:`849`) - A new class, ``AuthLoginClient``, is the base for ``NativeAppAuthClient`` and ``ConfidentialAppAuthClient``. These classes no longer inherit from ``AuthClient``, and therefore no longer inherit certain methods which would never succeed if called. - ``AuthClient`` is now the only class which provides functionality for accessing Globus Auth APIs. - ``AuthClient`` no longer includes methods for OAuth 2 login flows which would only be valid to call on ``AuthLoginClient`` subclasses. Deprecated ---------- - Several features of Auth client classes are now deprecated. (:pr:`849`) - Setting ``AuthClient.client_id`` or accessing it as an attribute is deprecated and will emit a warning. - ``ConfidentialAppAuthClient.get_identities`` has been preserved as a valid call, but will emit a warning. Users wishing to access this API via client credentials should prefer to get an access token using a client credential callout, and then use that token to call ``AuthClient.get_identities()``. - The ``AuthClient.oauth2_userinfo`` method has been deprecated in favor of ``AuthClient.userinfo``. Callers should prefer the new method name. (:pr:`865`) .. _changelog-3.28.0: v3.28.0 (2023-08-30) ==================== Python Support -------------- - Add support for Python 3.12. (:pr:`808`) Added ----- - Add a ``prompt`` keyword parameter to ``AuthClient.oauth2_get_authorize_url()``. (:pr:`813`) Setting this parameter requires users to authenticate with an identity provider, even if they are already logged in. Doing so can help avoid errors caused by unexpected session required policies, which would otherwise require a second, follow-up login flow. ``prompt`` could previously only be set via the ``query_params`` keyword parameter. It is now more discoverable. - Add ``TimerClient.pause_job`` and ``TimerClient.resume_job`` for pausing and resuming timers. (:pr:`827`) Documentation ------------- - Add an example script which handles creating and running a **flow**. (:pr:`826`) Development ----------- - Added responses to ``_testing`` reflecting an inactive Timers job (:pr:`828`) .. _changelog-3.27.0: v3.27.0 (2023-08-11) ==================== Added ----- - Add a ``FlowsClient.get_run_definition()`` method. (:pr:`799`) Changed ------- - ``FlowsClient.get_run_logs()`` now uses an ``IterableRunLogsResponse``. (:pr:`797`) .. _changelog-3.26.0: v3.26.0 (2023-08-07) ==================== Added ----- - New components are introduced to the experimental subpackage. See the SDK Experimental documentation for more details. - Add tools which manipulate Globus Auth Requirements error data. ``globus_sdk.experimental.auth_requirements_error`` provides a data container class, ``GlobusAuthRequirementsError``, and functions for converting and validating data against this shape. (:pr:`768`) - Introduce an experimental Globus Auth scope parser in ``globus_sdk.experimental.scope_parser`` (:pr:`752`) Changed ------- - The ``scopes`` class attribute of ``SpecificFlowClient`` is now specialized to ensure that type checkers will allow access to ``SpecificFlowClient`` scopes and ``resource_server`` values without ``cast``\ing. The value used is a specialized stub which raises useful errors when class-based access is performed. The ``scopes`` instance attribute is unchanged. (:pr:`793`) .. _changelog-3.25.0: v3.25.0 (2023-07-20) ==================== Added ----- - The ``jwt_params`` argument to ``decode_id_token()`` now allows ``"leeway"`` to be included to pass a ``leeway`` parameter to pyjwt. (:pr:`790`) Fixed ----- - ``decode_id_token()`` defaulted to having no tolerance for clock drift. Slight clock drift could lead to JWT claim validation errors. The new default is 0.5s which should be sufficient for most cases. (:pr:`790`) Documentation ------------- - New scripts in the example gallery demonstrate usage of the Globus Auth Developer APIs to List, Create, Delete, and Update Projects. (:pr:`777`) .. _changelog-3.24.0: v3.24.0 (2023-07-18) ==================== Added ----- - Add ``FlowsClient.list_runs`` as a method for listing all runs for the current user, with support for pagination. (:pr:`782`) - Add ``SearchClient`` methods for managing search index lifecycle: ``create_index``, ``delete_index``, and ``reopen_index`` (:pr:`785`) Changed ------- - The enforcement logic for URLs in ``BaseClient`` instantiation has been improved to only require that ``service_name`` be set if ``base_url`` is not provided. (:pr:`786`) - This change primarily impacts subclasses, which no longer need to set the ``service_name`` class variable if they ensure that the ``base_url`` is always passed with a non-null value. - Direct instantiation of ``BaseClient`` is now possible, although not recommended for most use-cases. .. _changelog-3.23.0: v3.23.0 (2023-07-06) ==================== Added ----- - Add ``AuthClient`` methods to support the Projects APIs for listing, creating, updating, and deleting projects. - ``AuthClient.get_projects`` (:pr:`766`) - ``AuthClient.create_project`` (:pr:`772`) - ``AuthClient.update_project`` (:pr:`774`) - ``AuthClient.delete_project`` (:pr:`776`) - ``globus_sdk._testing`` now exposes a method, ``construct_error`` which makes it simpler to explicitly construct and return a Globus SDK error object for testing. This is used in the SDK's own testsuite and is available for ``_testing`` users. (:pr:`770`) - ``AuthClient.oauth2_get_authorize_url`` now supports the following parameters for session management: ``session_required_identities``, ``session_required_single_domain``, and ``session_required_policies``. Each of these accept list inputs, as returned by ``ErrorInfo.authorization_parameters``. (:pr:`773`) Changed ------- * ``AuthClient``, ``NativeAppAuthClient``, and ``ConfidentialAppAuthClient`` have had their init signatures updated to explicitly list available parameters. (:pr:`764`) * Type annotations for these classes are now more accurate * The ``NativeAppAuthClient`` and ``ConfidentialAppAuthClient`` classes do not accept ``authorizer`` in their init signatures. Previously this was accepted but raised a ``GlobusSDKUsageError``. Attempting to pass an ``authorizer`` will now result in a ``TypeError``. - ``session_required_policies`` parsing in ``AuthorizationParameterInfo`` now supports the policies being returned as a ``list[str]`` in addition to supporting ``str`` (:pr:`769`) Fixed ----- - ``AuthorizationParameterInfo`` is now more type-safe, and will not return parsed data from a response without checking that the data has correct types (:pr:`769`) - Adjust the ``FlowsClient.get_run()`` ``include_flow_description`` parameter so it is submitted only when it has a value. (:pr:`778`) Documentation ------------- - The ``_testing`` documentation has been expanded with a dropdown view of the response contents for each method. In support of this, client method testing docs have been reorganized into a page per service. (:pr:`767`) .. _changelog-3.22.0: v3.22.0 (2023-06-22) ==================== Added ----- * Add support for ``AuthClient.get_identity_providers`` for looking up Identity Providers by domain or ID in Globus Auth (:pr:`757`) * Add a method to the Globus Search client, ``SearchClient.batch_delete_by_subject`` (:pr:`760`) * Add ``AuthScopes.manage_projects`` to scope data. This is also accessible as ``AuthClient.scopes.manage_projects`` (:pr:`761`) Documentation ------------- * Alpha features of globus-sdk are now documented in the "Unstable" doc section (:pr:`753`) .. _changelog-3.21.0: v3.21.0 (2023-06-16) ==================== Added ----- * ``AuthAPIError`` will now parse a unique ``id`` found in the error subdocuments as the ``request_id`` attribute (:pr:`749`) * Add a ``FlowsClient.update_run()`` method. (:pr:`744`) * Add a ``FlowsClient.delete_run()`` method. (:pr:`747`) * Add a ``FlowsClient.cancel_run()`` method. (:pr:`747`) * Add an ``experimental`` subpackage. (:pr:`751`) .. _changelog-3.20.1: v3.20.1 (2023-06-06) ==================== Fixed ----- * Fix ``TransferClient.operation_mkdir`` and ``TransferClient.operation_rename`` to no longer send null ``local_user`` by default (:pr:`741`) .. _changelog-3.20.0: v3.20.0 (2023-06-05) ==================== Added ----- * Implemented ``FlowsClient.get_run(...)`` (:pr:`721`) * Implemented ``FlowsClient.get_run_logs(...)`` (:pr:`722`) * Implemented ``SpecificFlowClient.resume_run(...)`` (:pr:`723`) * ``ConsentRequiredInfo`` now accepts ``required_scope`` (singular) containing a single string as an alternative to ``required_scopes``. However, it will parse both formats into a ``required_scopes`` list. (:pr:`726`) * ``FlowsClient.list_flows`` now supports passing a non-string iterable of strings to ``orderby`` in order to indicate multiple orderings (:pr:`730`) * Support ``pathlib.Path`` objects as filenames for the JSON and sqlite token storage adapters. (:pr:`734`) * Several ``TransferClient`` methods, ``TransferData``, and ``DeleteData`` now support the ``local_user``, ``source_local_user``, and ``destination_local_user`` parameters (:pr:`736`) Changed ------- * Behavior has changed slightly specifically for ``TimerAPIError``. When parsing fails, the ``code`` will be ``Error`` and the ``messages`` will be empty. The ``detail`` field will be treated as the ``errors`` array for these errors when it is present and contains an array of objects. * Error parsing in the SDK has been enhanced to better support JSON:API and related error formats with multiple sub-errors. Several attributes are added or changed. For most SDK users, the changes will be completely transparent or a minor improvement. (:pr:`725`) * Error parsing now attempts to detect the format of the error data and will parse JSON:API data differently from non-JSON:API data. Furthermore, parsing is stricter about the expectations about fields and their types. JSON:API parsing now has its own distinct parsing path, followed only when the JSON:API mimetype is present. * A new attribute is added to API error objects, ``errors``. This is a list of subdocuments parsed from the error data, especially relevant for JSON:API errors and similar formats. See the :ref:`ErrorSubdocument documentation ` for details. * A new attribute is now present on API error objects, ``messages``. This is a list of messages parsed from the error data, for errors with multiple messages. When there is only one message, ``messages`` will only contain one item. * The ``message`` field is now an alias for a joined string of ``messages``. Assigning a string to ``message`` is supported for error subclasses, but is deprecated. * ``message`` will now be ``None`` when no messages can be parsed from the error data. Previously, the default for ``message`` would be an alias for ``text``. * All error types now support ``request_id`` as an attribute, but it will default to ``None`` for errors which do not include a ``request_id``. * An additional field is checked by default for error message data, ``title``. This is useful when errors contain ``title`` but no ``detail`` field. The extraction of messages from errors has been made stricter, especially in the JSON:API case. * The ``code`` field of errors will no longer attempt to parse only the first ``code`` from multiple sub-errors. Instead, ``code`` will first parse a top-level ``code`` field, and then fallback to checking if *all* sub-errors have the same ``code`` value. The result is that certain errors which would populate a non-default ``code`` value no longer will, but the ``code`` will also no longer be misleading when multiple errors with different codes are present in an error object. * The ``code`` field of an error may now be ``None``. This is specifically possible when the error format is detected to be known as JSON:API and there is no ``code`` present in any responses. Fixed ----- * The TransferRequestsTransport will no longer automatically retry errors with a code of EndpointError * Fix pagination on iterable gcs client routes (:pr:`738`, :pr:`739`) * ``GCSClient.get_storage_gateway_list`` * ``GCSClient.get_role_list`` * ``GCSClient.get_collection_list`` * ``GCSClient.get_user_credential_list`` .. _changelog-3.19.0: v3.19.0 (2023-04-14) ==================== Added ----- * Added ``FlowsClient.update_flow(...)`` (:pr:`710`) * Support passing "include" as a transfer ``filter_rule`` method (:pr:`712`) * Make the request-like interface for response objects and errors more uniform. (:pr:`715`) * Both ``GlobusHTTPResponse`` and ``GlobusAPIError`` are updated to ensure that they have the following properties in common: ``http_status``, ``http_reason``, ``headers``, ``content_type``, ``text`` * ``GlobusHTTPResponse`` and ``GlobusAPIError`` have both gained a new property, ``binary_content``, which returns the unencoded response data as bytes Deprecated ---------- * ``GlobusAPIError.raw_text`` is deprecated in favor of ``text`` Fixed ----- * The return type of ``AuthClient.get_identities`` is now correctly annotated as an iterable type, ``globus_sdk.GetIdentitiesResponse`` (:pr:`716`) Documentation ------------- * Documentation for client methods has been improved to more consistently format and display examples and other information (:pr:`714`) .. _changelog-3.18.0: v3.18.0 (2023-03-16) ==================== Added ----- * ``ConfidentialAppAuthClient.oauth2_get_dependent_tokens`` now supports the ``refresh_tokens`` parameter to enable requests for dependent refresh tokens (:pr:`698`) Changed ------- * Behaviors which will change in version 4.0.0 of the ``globus-sdk`` now emit deprecation warnings. * ``TransferData.add_item`` now defaults to omitting ``recursive`` rather than setting its value to ``False``. This change better matches new Transfer API behaviors which treat the absence of the ``recursive`` flag as meaning autodetect, rather than the previous default of ``False``. Setting the recursive flag can still have beneficial behaviors, but should not be necessary for many use-cases (:pr:`696`) Deprecated ---------- * Omitting ``requested_scopes`` or specifying it as ``None`` is now deprecated and will emit a warning. In version 4, users will always be required to specify their scopes when performing login flows. This applies to the following methods: * ``ConfidentialAppAuthClient.oauth2_client_credentials_tokens`` * ``AuthClient.oauth2_start_flow`` * ``SearchClient.update_entry`` and ``SearchClient.create_entry`` are officially deprecated and will emit a warning. These APIs are aliases of ``SearchClient.ingest``, but their existence has caused confusion. Users are encouraged to switch to ``SearchClient.ingest`` instead (:pr:`695`) Fixed ----- * When users input empty ``requested_scopes`` values, these are now rejected with a usage error instead of being translated into the default set of ``requested_scopes`` * Fix the type annotation for ``max_sleep`` on client transports to allow ``float`` values (:pr:`697`) .. _changelog-3.17.0: v3.17.0 (2023-02-27) ==================== Python Support -------------- * Remove support for python3.6 (:pr:`681`) Added ----- * ``MutableScope`` objects can now be used in the ``oauth2_start_flow`` and ``oauth2_client_credentials_tokens`` methods of ``AuthClient`` classes as part of ``requested_scopes`` (:pr:`689`) Changed ------- * Make ``MutableScope.scope_string`` a public instance attribute (was ``_scope_string``) (:pr:`687`) * Globus Groups methods which required enums as arguments now also accept a variety of ``Literal`` strings in their annotations as well. This is coupled with changes to ensure that strings and enums are always serialized correctly in these cases. (:pr:`691`) Fixed ----- * Fix a typo in ``TransferClient.endpoint_manager_task_successful_transfers`` which prevented calls from being made correctly (:pr:`683`) .. _changelog-3.16.0: v3.16.0 (2023-02-07) ==================== Added ----- * Allow UUID values for the ``client_id`` parameter to ``AuthClient`` and its subclasses (:pr:`676`) Changed ------- * Improved GCS Collection datatype detection to support ``collection#1.6.0`` and ``collection#1.7.0`` documents (:pr:`675`) * ``guest_auth_policy_id`` is now supported on ``MappedCollectionDcoument`` * ``user_message`` strings over 64 characters are now supported * The ``session_required_policies`` attribute of ``AuthorizationInfo`` is now parsed as a list of strings when present, and ``None`` when absent. (:pr:`678`) * ``globus_sdk.ArrayResponse`` and ``globus_sdk.IterableResponse`` are now available as names. Previously, these were only importable from ``globus_sdk.response`` (:pr:`680`) Fixed ----- * ``ArrayResponse`` and ``IterableResponse`` have better error behaviors when the API data does not match their expected types (:pr:`680`) Documentation ------------- * Fix the Timer code example (:pr:`672`) * New documentation examples for Transfer Task submission in the presence of ``ConsentRequired`` errors (:pr:`673`) .. _changelog-3.15.1: v3.15.1 (2022-12-13) ==================== Added ----- * AuthorizationParameterInfo now exposes session_required_policies (:pr:`658`) Fixed ----- * Fix a bug where ``TransferClient.endpoint_manager_task_list`` didn't handle the ``last_key`` argument when paginated (:pr:`662`) .. _changelog-3.15.0: v3.15.0 (2022-11-22) ==================== Added ----- * Scope Names can be set explicitly in a ``ScopeBuilder`` (:pr:`641`) * Introduced ``ScopeBuilder.scope_names`` property (:pr:`641`) * Add support for ``interpret_globs`` and ``ignore_missing`` to ``DeleteData`` (:pr:`646`) * A new object, ``globus_sdk.LocalGlobusConnectServer`` can be used to inspect the local installation of Globus Connect Server (:pr:`647`) * The object supports properties for ``endpoint_id`` and ``domain_name`` * This only supports Globus Connect Server version 5 * The filter argument to TransferClient.operation_ls now accepts a list to pass multiple filter params (:pr:`652`) * Improvements to ``MutableScope`` objects (:pr:`654`) * ``MutableScope(...).serialize()`` is added, and ``str(MutableScope(...))`` uses it * ``MutableScope.add_dependency`` now supports ``MutableScope`` objects as inputs * ``ScopeBuilder.make_mutable`` now accepts a keyword argument ``optional``. This allows, for example, ``TransferScopes.make_mutable("all", optional=True)`` Changed ------- * Improve the ``__str__`` implementation for ``OAuthTokenResponse`` (:pr:`640`) * When ``GlobusHTTPResponse`` contains a list, calls to ``get()`` will no longer fail with an ``AttributeError`` but will return the default value (``None`` if unspecified) instead (:pr:`644`) Deprecated ---------- * The ``optional`` argument to ``add_dependency`` is deprecated. ``MutableScope(...).add_dependency(MutableScope("foo", optional=True))`` can be used to add an optional dependency Fixed ----- * Fixed SpecificFlowClient scope string (:pr:`641`) * Fix a bug in the type annotations for transport objects which restricted the size of status code tuples set as classvars (:pr:`651`) .. _changelog-3.14.0: v3.14.0 (2022-11-01) ==================== Python Support -------------- * Python 3.11 is now officially supported (:pr:`628`) Added ----- * Add support for ``FlowsClient.get_flow`` and ``FlowsClient.delete_flow`` (:pr:`631`, :pr:`626`) * Add a ``close()`` method to ``SQLiteAdapter`` which closes the underlying connection (:pr:`628`) .. _changelog-3.13.0: v3.13.0 (2022-10-13) ==================== Added ----- * Add ``connect_params`` to ``SQLiteAdapter``, enabling customization of the sqlite connection (:pr:`613`) * Add ``FlowsClient.create_flow(...)`` (:pr:`614`) * Add ``globus_sdk.SpecificFlowClient`` to manage interactions performed against a specific flow (:pr:`616`) * Add support to ``FlowsClient.list_flows`` for pagination and the ``orderby`` parameter (:pr:`621`, :pr:`622`) Documentation ------------- * Fix rst formatting for a few nested bullet points in existing changelog (:pr:`619`) .. _changelog-3.12.0: v3.12.0 (2022-09-21) ==================== Added ----- * Add Mapped Collection policy helper types for constructing ``policies`` data. (:pr:`607`) The following new types are introduced: * ``CollectionPolicies`` (the base class for these types) * ``POSIXCollectionPolicies`` * ``POSIXStagingCollectionPolicies`` * ``GoogleCloudStorageCollectionPolicies`` Fixed ----- * Fix bug where ``UserCredential`` policies were being converted to a string (:pr:`608`) * Corrected the Flows service ``resource_server`` string to ``flows.globus.org`` (:pr:`612`) .. _changelog-3.11.0: v3.11.0 (2022-08-30) ==================== Added ----- * Implement ``__dir__`` for the lazy importer in ``globus_sdk``. This enables tab completion in the interpreter and other features with rely upon ``dir(globus_sdk)`` (:pr:`603`) * Add an initial Globus Flows client class, ``globus_sdk.FlowsClient`` (:pr:`604`) * ``globus_sdk.FlowsAPIError`` is the error class for this client * ``FlowsClient.list_flows`` is implemented as a method for listing deployed flows, with some of the filtering parameters of this API supported as keyword arguments * The scopes for the Globus Flows API can be accessed via ``globus_sdk.scopes.FlowsScopes`` or ``globus_sdk.FlowsClient.scopes`` Changed ------- * Adjust behaviors of ``TransferData`` and ``TimerJob`` to make ``TimerJob.from_transfer_data`` work and to defer requesting the ``submission_id`` until the task submission call (:pr:`602`) * ``TransferData`` avoids passing ``null`` for several values when they are omitted, ranging from optional parameters to ``add_item`` to ``skip_activation_check`` * ``TransferData`` and ``DeleteData`` now support usage in which the ``transfer_client`` parameters is ``None``. In these cases, if ``submission_id`` is omitted, it will be omitted from the document, allowing the creation of a partial task submsision document with no ``submission_id`` * ``TimerJob.from_transfer_data`` will now raise a ``ValueError`` if the input document contains ``submission_id`` or ``skip_activation_check`` * ``TransferClient.submit_transfer`` and ``TransferClient.submit_delete`` now check to see if the data being sent contains a ``submission_id``. If it does not, ``get_submission_id`` is called automatically and set as the ``submission_id`` on the payload. The new ``submission_id`` is set on the object passed to these methods, meaning that these methods are now side-effecting. The newly recommended usage for ``TransferData`` and ``DeleteData`` is to pass the endpoints as named parameters: .. code-block:: python # -- for TransferData -- # old usage transfer_client = TransferClient() transfer_data = TransferData(transfer_client, ep1, ep2) # new (recommended) usage transfer_data = TransferData(source_endpoint=ep1, destination_endpoint=ep2) # -- for DeleteData -- # old usage transfer_client = TransferClient() delete_data = TransferData(transfer_client, ep) # new (recommended) usage delete_data = DeleteData(endpoint=ep) .. _changelog-3.10.1: v3.10.1 (2022-07-11) ==================== Changed ------- * Use ``setattr`` in the lazy-importer. This makes attribute access after imports faster by several orders of magnitude. (:pr:`591`) Documentation ------------- * Add guest collection example script to docs (:pr:`590`) .. _changelog-3.10.0: v3.10.0 (2022-06-27) ==================== Removed ------- * Remove nonexistent ``monitor_ongoing`` scope from ``TransferScopes`` (:pr:`583`) Added ----- * Add User Credential methods to ``GCSClient`` (:pr:`582`) * ``get_user_credential_list`` * ``get_user_credential`` * ``create_user_credential`` * ``update_user_credential`` * ``delete_user_credential`` * Add ``connector_id_to_name`` helper to ``GCSClient`` to resolve GCS Connector UUIDs to human readable Connector display names (:pr:`582`) .. _changelog-3.9.0: v3.9.0 (2022-06-02) =================== Added ----- * Add helper objects and methods for interacting with Globus Connect Server Storage Gateways (:pr:`554`) * New methods on ``GCSClient``: ``create_storage_gateway``, ``get_storage_gateway``, ``get_storage_gateway_list``, ``update_storage_gateway``, ``delete_storage_gateway`` * New helper classes for constructing storage gateway documents. ``StorageGatewayDocument`` is the main one, but also ``POSIXStoragePolicies`` and ``POSIXStagingStoragePolicies`` are added for declaring the storage gateway ``policies`` field. More policy helpers will be added in future versions. * Add support for more ``StorageGatewayPolicies`` documents. (:pr:`562`) The following types are now available: * ``BlackPearlStoragePolicies`` * ``BoxStoragePolicies`` * ``CephStoragePolicies`` * ``GoogleDriveStoragePolicies`` * ``GoogleCloudStoragePolicies`` * ``OneDriveStoragePolicies`` * ``AzureBlobStoragePolicies`` * ``S3StoragePolicies`` * ``ActiveScaleStoragePolicies`` * ``IrodsStoragePolicies`` * ``HPSSStoragePolicies`` * Add ``https`` scope to ``GCSCollectionScopeBuilder`` (:pr:`563`) * ``ScopeBuilder`` objects now implement ``__str__`` for easy viewing. For example, ``print(globus_sdk.TransferClient.scopes)`` (:pr:`568`) * Several improvements to Transfer helper objects (:pr:`573`) * Add ``TransferData.add_filter_rule`` for adding filter rules (exclude rules) to transfers * Add ``skip_activation_check`` as an argument to ``DeleteData`` and ``TransferData`` * The ``sync_level`` argument to ``TransferData`` is now annotated more accurately to reject bad strings Changed ------- * Update the fields used to extract ``AuthAPIError`` messages (:pr:`566`) * Imports from ``globus_sdk`` are now evaluated lazily via module-level ``__getattr__`` on python 3.7+ (:pr:`571`) * This improves the performance of imports for almost all use-cases, in some cases by over 80% * The method ``globus_sdk._force_eager_imports()`` can be used to force non-lazy imports, for latency sensitive applications which wish to control when the time cost of import evaluation is paid. This method is private and is therefore is not covered under the ``globus-sdk``'s SemVer guarantees, but it is expected to remain stable for the foreseeable future. * Improve handling of array-style API responses (:pr:`575`) * Response objects now define ``__bool__`` as ``bool(data)``. This means that ``bool(response)`` could be ``False`` if the data is ``{}``, ``[]``, ``0``, or other falsey-types. Previously, ``__bool__`` was not defined, meaning it was always ``True`` * ``globus_sdk.response.ArrayResponse`` is a new class which describes responses which are expected to hold a top-level array. It satisfies the sequence protocol, allowing indexing with integers and slices, iteration over the array data, and length checking with ``len(response)`` * ``globus_sdk.GroupsClient.get_my_groups`` returns an ``ArrayResponse``, meaning the response data can now be iterated and otherwise used .. _changelog-3.8.0: v3.8.0 (2022-05-04) =================== Added ----- * Several changes expose more details of HTTP requests (:pr:`551`) * ``GlobusAPIError`` has a new property ``headers`` which provides the case-insensitive mapping of header values from the response * ``GlobusAPIError`` and ``GlobusHTTPResponse`` now include ``http_reason``, a string property containing the "reason" from the response * ``BaseClient.request`` and ``RequestsTransport.request`` now have options for setting boolean options ``allow_redirects`` and ``stream``, controlling how requests are processed * New tools for working with optional and dependent scope strings (:pr:`553`) * A new class is provided for constructing optional and dependent scope strings, ``MutableScope``. Import as in ``from globus_sdk.scopes import MutableScope`` * ``ScopeBuilder`` objects provide a method, ``make_mutable``, which converts from a scope name to a ``MutableScope`` object. See documentation on scopes for usage details .. _changelog-3.7.0: v3.7.0 (2022-04-08) =================== Added ----- * Add a client for the Timer service (:pr:`548`) * Add ``TimerClient`` class, along with ``TimerJob`` for constructing data to pass to the Timer service for job creation, and ``TimerAPIError`` * Modify ``globus_sdk.config`` utilities to provide URLs for Actions and Timer services Fixed ----- * Fix annotations to allow request data to be a string. This is supported at runtime but was missing from annotations. (:pr:`549`) .. _changelog-3.6.0: v3.6.0 (2022-03-18) =================== Added ----- * ``ScopeBuilder`` objects now support ``known_url_scopes``, and known scope arguments to a ``ScopeBuilder`` may now be of type ``str`` in addition to ``list[str]`` (:pr:`536`) * Add the ``RequestsTransport.tune`` contextmanager to the transport layer, allowing the settings on the transport to be set temporarily (:pr:`540`) .. _changelog-3.5.0: v3.5.0 (2022-03-02) =================== Added ----- * ``globus_sdk.IdentityMap`` can now take a cache as an input. This allows multiple ``IdentityMap`` instances to share the same storage cache. Any mutable mapping type is valid, so the cache can be backed by a database or other storage (:pr:`500`) * Add support for ``include`` as a parameter to ``GroupsClient.get_group``. ``include`` can be a string or iterable of strings (:pr:`528`) * Add a new method to tokenstorage, ``SQLiteAdapter.iter_namespaces``, which iterates over all namespaces visible in the token database (:pr:`529`) Changed ------- * Add ``TransferRequestsTransport`` class that does not retry ExternalErrors. This fixes cases in which the ``TransferClient`` incorrectly retried requests (:pr:`522`) * Use the "reason phrase" as a failover for stringified API errors with no body (:pr:`524`) Documentation ------------- * Enhance documentation for all of the parameters on methods of ``GroupsClient`` .. _changelog-3.4.2: v3.4.2 (2022-02-18) =================== Fixed ----- * Fix the pagination behavior for ``TransferClient`` on ``task_skipped_errors`` and ``task_successful_transfers``, and apply the same fix to the endpoint manager variants of these methods. Prior to the fix, paginated calls would return a single page of results and then stop (:pr:`520`) .. _changelog-3.4.1: v3.4.1 (2022-02-11) =================== Fixed ----- * The ``typing_extensions`` requirement in package metadata now sets a lower bound of ``4.0``, to force upgrades of installations to get a new enough version (:pr:`518`) .. _changelog-3.4.0: v3.4.0 (2022-02-11) =================== Added ----- * Support pagination on ``SearchClient.post_search`` (:pr:`507`) * Add support for scroll queries to ``SearchClient``. ``SearchClient.scroll`` and ``SearchClient.paginated.scroll`` are now available as methods, and a new helper class, ``SearchScrollQuery``, can be used to easily construct scrolling queries. (:pr:`507`) * Add methods to ``SearchClient`` for managing index roles. ``create_role``, ``delete_role``, and ``get_role_list`` (:pr:`507`) * Add ``mapped_collection`` and ``filter`` query arguments to ``GCSClient.get_collection_list`` (:pr:`510`) * Add role methods to ``GCSClient`` (:pr:`513`) * ``GCSClient.get_role_list`` lists endpoint or collection roles * ``GCSClient.create_role`` creates a role * ``GCSClient.get_role`` gets a single role * ``GCSClient.delete_role`` deletes a role * The response from ``AuthClient.get_identities`` now supports iteration, returning results from the ``"identities"`` array (:pr:`514`) .. _changelog-3.3.1: v3.3.1 (2022-01-25) =================== Fixed ----- * Packaging bugfix. ``globus-sdk`` is now built with pypa's ``build`` tool, to resolve issues with wheel builds. .. _changelog-3.3.0: v3.3.0 (2022-01-25) =================== Added ----- * Add ``update_group`` method to ``GroupsClient`` (:pr:`506`) * The ``TransferData`` and ``DeleteData`` helper objects now accept the following parameters: ``notify_on_succeeded``, ``notify_on_failed``, and ``notify_on_inactive``. All three are boolean parameters with a default of ``True``. (:pr:`502`) * Add ``Paginator.wrap`` as a method for getting a paginated methods. This interface is more verbose than the existing ``paginated`` methods, but correctly preserves type annotations. It is therefore preferable for users who are using ``mypy`` to do type checking. (:pr:`494`) Changed ------- * ``Paginator`` objects are now generics over a type var for their page type. The page type is bounded by ``GlobusHTTPResponse``, and most type-checker behaviors will remain unchanged (:pr:`495`) Fixed ----- * Several minor bugs have been found and fixed (:pr:`504`) * Exceptions raised in the SDK always use ``raise ... from`` syntax where appropriate. This corrects exception chaining in the local endpoint and several response objects. * The encoding of files opened by the SDK is now always ``UTF-8`` * ``TransferData`` will now reject unsupported ``sync_level`` values with a ``ValueError`` on initialization, rather than erroring at submission time. The ``sync_level`` has also had its type annotation fixed to allow for ``int`` values. * Several instances of undocumented parameters have been discovered, and these are now rectified. Documentation ------------- * Document ``globus_sdk.config.get_service_url`` and ``globus_sdk.config.get_webapp_url`` (:pr:`496`) * Internally, these are updated to be able to default to the ``GLOBUS_SDK_ENVIRONMENT`` setting, so specifying an environment is no longer required .. _changelog-3.2.1: v3.2.1 (2021-12-13) =================== Python Support -------------- * Update to avoid deprecation warnings on python 3.10 (:pr:`499`) .. _changelog-3.2.0: v3.2.0 (2021-12-02) =================== Added ----- * Add ``iter_items`` as a method on ``TransferData`` and ``DeleteData`` (:pr:`488`) * Add the ``resource_server`` property to client classes and objects. For example, ``TransferClient.resource_server`` and ``GroupsClient().resource_server`` are now usable to get the resource server string for the relevant services. ``resource_server`` is documented as part of ``globus_sdk.BaseClient`` and may be ``None``. (:pr:`489`) * The implementation of several properties of ``GlobusHTTPResponse`` has changed (:pr:`497`) * Responses have a new property, ``headers``, a case-insensitive dict of headers from the response * Responses now implement ``http_status`` and ``content_type`` as properties without setters Changed ------- * ClientCredentialsAuthorizer now accepts ``Union[str, Iterable[str]]`` as the type for scopes (:pr:`498`) Fixed ----- * Fix type annotations on client methods with paginated variants (:pr:`491`) .. _changelog-3.1.0: v3.1.0 (2021-10-13) =================== Added ----- * Add ``filter`` as a supported parameter to ``TransferClient.task_list`` (:pr:`484`) * The ``filter`` parameter to ``TransferClient.task_list`` and ``TransferClient.operation_ls`` can now be passed as a ``Dict[str, str | List[str]]``. Documentation on the ``TransferClient`` explains how this will be formatted, and is linked from the param docs for ``filter`` on each method (:pr:`484`) Changed ------- * Adjust package metadata for ``cryptography`` dependency, specifying ``cryptography>=3.3.1`` and no upper bound. This is meant to help mitigate issues in which an older ``cryptography`` version is installed gets used in spite of it being incompatible with ``pyjwt[crypto]>=2.0`` (:pr:`486`) .. _changelog-3.0.3: v3.0.3 (2021-10-11) =================== Fixed ----- * Fix several internal decorators which were destroying type information about decorated functions. Type signatures of many methods are therefore corrected (:pr:`485`) .. _changelog-3.0.2: v3.0.2 (2021-09-29) =================== Changed ------- * Produce more debug logging when SDK logs are enabled (:pr:`480`) Fixed ----- * Update the minimum dependency versions to lower bounds which are verified to work with the testsuite (:pr:`482`) .. _changelog-3.0.1: v3.0.1 (2021-09-15) =================== Added ----- * ``ScopeBuilder`` objects now define the type of ``__getattr__`` for ``mypy`` to know that dynamic attributes are strings (:pr:`472`) Fixed ----- * Fix malformed PEP508 ``python_version`` bound in dev dependencies (:pr:`474`) Development ----------- * Fix remaining ``type: ignore`` usages in globus-sdk (:pr:`473`) .. _changelog-3.0.0: v3.0.0 (2021-09-14) =================== Removed ------- * Remove support for ``bytes`` values for fields consuming UUIDs (:pr:`471`) Added ----- * Add ``filter_is_error`` parameter to advanced task list (:pr:`467`) * Add a ``LocalGlobusConnectPersonal.get_owner_info()`` for looking up local user information from gridmap (:pr:`466`) * Add support for GCS collection create and update. This includes new data helpers, ``MappedCollectionDcoument`` and ``GuestCollectionDocument`` (:pr:`468`) * Add support for specifying ``config_dir`` to ``LocalGlobusConnectPersonal`` (:pr:`470`) .. _changelog-3.0.0b4: v3.0.0b4 (2021-09-01) ===================== Removed ------- * Remove ``BaseClient.qjoin_path`` (:pr:`452`) Added ----- * Add a new ``GCSClient`` class for interacting with GCS Manager APIs (:pr:`447`) * ``GCSClient`` supports ``get_collection`` and ``delete_collection``. ``get_collection`` uses a new ``UnpackingGCSResponse`` response type (:pr:`451`, :pr:`464`) * Add ``delete_destination_extra`` param to ``TransferData`` (:pr:`456`) * ``TransferClient.endpoint_manager_task_list`` now takes filters as named keyword arguments, not only in ``query_params`` (:pr:`460`) Changed ------- * Rename ``GCSScopeBuilder`` to ``GCSCollectionScopeBuilder`` and add ``GCSEndpointScopeBuilder``. The ``GCSClient`` includes helpers for instantiating these scope builders (:pr:`448`) * The ``additional_params`` parameter to ``AuthClient.oauth2_get_authorize_url`` has been renamed to ``query_params`` for consistency with other methods (:pr:`453`) * Enforce keyword-only arguments for most SDK-provided APIs (:pr:`453`) * All type annotations for ``Sequence`` which could be relaxed to ``Iterable`` have been updated (:pr:`465`) Fixed ----- * Minor fix to wheel builds: do not declare wheels as universal (:pr:`444`) * Fix annotations for ``server_id`` on ``TransferClient`` methods (:pr:`455`) * Fix ``visibility`` typo in ``GroupsClient`` (:pr:`463`) Documentation ------------- * Ensure all ``TransferClient`` method parameters are documented (:pr:`449`, :pr:`454`, :pr:`457`, :pr:`458`, :pr:`459`, :pr:`461`, :pr:`462`) .. _changelog-3.0.0b3: v3.0.0b3 (2021-08-13) ===================== Added ----- * Flesh out the ``GroupsClient`` and add helpers for interacting with the Globus Groups service, including enumerated constants, payload builders, and a high-level client for doing non-batch operations called the ``GroupsManager`` (:pr:`435`, :pr:`443`) * globus-sdk now provides much more complete type annotations coverage, allowing type checkers like ``mypy`` to catch a much wider range of usage errors (:pr:`442`) .. _changelog-3.0.0b2: v3.0.0b2 (2021-07-16) ===================== Added ----- * Add scope constants and scope construction helpers. See new documentation on :ref:`scopes and ScopeBuilders ` for details (:pr:`437`, :pr:`440`) * API Errors now have an attached ``info`` object with parsed error data where applicable. See the :ref:`ErrorInfo documentation ` for details (:pr:`441`) Changed ------- * Improve the rendering of API exceptions in stack traces to include the method, URI, and authorization scheme (if recognized) (:pr:`439`) * Payload helper objects (``TransferData``, ``DeleteData``, and ``SearchQuery``) now inherit from a custom object, not ``dict``, but they are still dict-like in behavior (:pr:`438`) .. _changelog-3.0.0b1: v3.0.0b1 (2021-07-02) ===================== Added ----- * Add support for ``TransferClient.get_shared_endpoint_list`` (:pr:`434`) Changed ------- * Passthrough parameters to SDK methods for query params and body params are no longer accepted as extra keyword arguments. Instead, they must be passed explicitly in a ``query_params``, ``body_params``, or ``additional_fields`` dictionary, depending on the context (:pr:`433`) * The interface for retry parameters has been simplified. ``RetryPolicy`` objects have been merged into the transport object, and retry parameters like ``max_retries`` may now be supplied directly as ``transport_params`` (:pr:`430`) .. _changelog-3.0.0a4: v3.0.0a4 (2021-06-28) ===================== Added ----- * Add ``BaseClient`` to the top-level exports of ``globus_sdk``, so it can now be accessed under the name ``globus_sdk.BaseClient`` Fixed ----- * Fix several paginators which were broken in ``3.0.0a3`` (:pr:`431`) Documentation ------------- * Autodocumentation of paginated methods (:pr:`432`) .. _changelog-3.0.0a3: v3.0.0a3 (2021-06-25) ===================== Changed ------- * Pagination has changed significantly. (:pr:`418`) * Methods which support pagination like ``TransferClient.endpoint_search`` no longer return an iterable ``PaginatedResource`` type. Instead, these client methods return ``GlobusHTTPResponse`` objects with a single page of results. * Paginated variants of these methods are available by renaming a call from ``client.`` to ``client.paginated.``. So, for example, a ``TransferClient`` now supports ``client.paginated.endpoint_search()``. The arguments to this function are the same as the original method. * ``client.paginated.`` calls return ``Paginator`` objects, which support two types of iteration: by ``pages()`` and by ``items()``. To replicate the same behavior as SDK v1.x and v2.x ``PaginatedResource`` types, use ``items()``, as in ``client.paginated.endpoint_search("query").items()`` .. _changelog-3.0.0a2: v3.0.0a2 (2021-06-10) ===================== Added ----- * A new subpackage is available for public use, ``globus_sdk.tokenstorage`` (:pr:`405`) * Add client for Globus Groups API, ``globus_sdk.GroupsClient``. Includes a dedicated error class, ``globus_sdk.GroupsAPIError`` Changed ------- * Refactor response classes (:pr:`425`) .. _changelog-3.0.0a1: v3.0.0a1 (2021-06-04) ===================== Removed ------- * Remove ``allowed_authorizer_types`` restriction from ``BaseClient`` (:pr:`407`) * Remove ``auth_client=...`` parameter to ``OAuthTokenResponse.decode_id_token`` (:pr:`400`) Added ----- * ``globus-sdk`` now provides PEP561 typing data (:pr:`420`) * ``OAuthTokenResponse.decode_id_token`` can now be provided a JWK and openid configuration as parameters. ``AuthClient`` implements methods for fetching these data, so that they can be fetched and stored outside of this call. There is no automatic caching of these data. (:pr:`403`) Changed ------- * The interface for ``GlobusAuthorizer`` now defines ``get_authorization_header`` instead of ``set_authorization_header``, and additional keyword arguments are not allowed (:pr:`422`) * New Transport layer handles HTTP details, variable payload encodings, and automatic request retries (:pr:`417`) * Instead of ``json_body=...`` and ``text_body=...``, use ``data=...`` combined with ``encoding="json"``, ``encoding="form"``, or ``encoding="text"`` to format payload data. ``encoding="json"`` is the default when ``data`` is a dict. * By default, requests are retried automatically on potentially transient error codes (e.g. ``http_status=500``) and network errors with exponential backoff * ``globus_sdk.BaseClient`` and its subclasses define ``retry_policy`` and ``transport_class`` class attributes which can be used to customize the retry behavior used * The JWT dependency has been updated to ``pyjwt>=2,<3`` (:pr:`416`) * The config files in ``~/.globus.cfg`` and ``/etc/globus.cfg`` are no longer used. Configuration can now be done via environment variables (:pr:`409`) * ``BaseClient.app_name`` is a property with a custom setter, replacing ``set_app_name`` (:pr:`415`) Documentation ------------- * Update documentation site style and layout (:pr:`423`) .. _changelog_version2: .. _changelog-2.0.1: v2.0.1 (2021-02-02) =================== Python Support -------------- * Remove support for python2 (:pr:`396`, :pr:`397`, :pr:`398`) .. note:: globus-sdk version 2.0.0 was yanked due to a release issue. Version 2.0.1 is the first 2.x version. .. _changelog-1.11.0: v1.11.0 (2021-01-29) ==================== Added ----- * Add support for task skipped errors via ``TransferClient.task_skipped_errors`` and ``TransferClient.endpoint_manager_task_skipped_errors`` (:pr:`393`) Development ----------- * Internal maintenance (:pr:`389`, :pr:`390`, :pr:`391`, :pr:`392`) .. _changelog-1.10.0: v1.10.0 (2020-12-18) ==================== Fixed ----- * Add support for pyinstaller installation of globus-sdk (:pr:`387`) .. _changelog-1.9.1: v1.9.1 (2020-08-27) =================== Fixed ----- * Fix ``GlobusHTTPResponse`` to handle responses with no ``Content-Type`` header (:pr:`375`) .. _changelog-1.9.0: v1.9.0 (2020-03-05) =================== Added ----- * Add ``globus_sdk.IdentityMap``, a mapping-like object for Auth ID lookups (:pr:`367`) * Add ``external_checksum`` and ``checksum_algorithm`` to ``TransferData.add_item()`` named arguments (:pr:`365`) Changed ------- * Don't append trailing slashes when no path is given to a low-level client method like ``get()`` (:pr:`364`) Development ----------- * Minor documentation and build improvements (:pr:`369`, :pr:`362`) .. _changelog-1.8.0: v1.8.0 (2019-07-11) =================== Added ----- * Add a property to paginated results which shows if more results are available (:pr:`346`) Fixed ----- * Fix ``RefreshTokenAuthorizer`` to handle a new ``refresh_token`` being sent back by Auth (:pr:`359`) * Fix typo in endpoint_search log message (:pr:`355`) * Fix Globus Web App activation links in docs (:pr:`356`) Documentation ------------- * Update docs to state that Globus SDK uses semver (:pr:`357`) .. _changelog-1.7.1: v1.7.1 (2019-02-21) =================== Added ----- * Allow arbitrary keyword args to ``TransferData.add_item()`` and ``DeleteData.add_item()``, which passthrough to the item bodies (:pr:`339`) Development ----------- * Minor internal improvements (:pr:`342`, :pr:`343`) .. _changelog-1.7.0: v1.7.0 (2018-12-18) =================== Added ----- * Add ``get_task`` and ``get_task_list`` to ``SearchClient`` (:pr:`335`, :pr:`336`) Development ----------- * Internal maintenance and testing improvements (:pr:`331`, :pr:`334`, :pr:`333`) .. _changelog-1.6.1: v1.6.1 (2018-10-30) =================== Changed ------- * Replace egg distribution format with wheels (:pr:`314`) Development ----------- * Internal maintenance .. _changelog-1.6.0: v1.6.0 (2018-08-29) =================== Python Support -------------- * Officially add support for python 3.7 (:pr:`300`) Removed ------- Added ----- * RenewingAuthorizer and its subclasses now expose the check_expiration_time method (:pr:`309`) * Allow parameters to be passed to customize the request body of ConfidentialAppAuthClient.oauth2_get_dependent_tokens (:pr:`308`) * Add the patch() method to BaseClient and its subclasses, sending an HTTP PATCH request (:pr:`302`) Changed ------- * Use sha256 hashes of tokens (instead of last 5 chars) in debug logging (:pr:`305`) * Make pickling SDK objects safer (but still not officially supported!) (:pr:`284`) * Malformed SDK usage may now raise GlobusSDKUsageError instead of ValueError. GlobusSDKUsageError inherits from ValueError (:pr:`281`) Fixed ----- * Correct handling of environment="production" as an argument to client construction (:pr:`307`) Documentation ------------- * Numerous documentation improvements (:pr:`279`, :pr:`294`, :pr:`296`, :pr:`297`) .. _changelog-1.5.0: v1.5.0 (2018-02-09) =================== Added ----- * Add support for retrieving a local Globus Connect Personal endpoint's UUID (:pr:`276`) Fixed ----- * Fix bug in search client parameter handling (:pr:`274`) .. _changelog-1.4.1: v1.4.1 (2017-12-20) =================== Added ----- * Support connection timeouts. Default timeout of 60 seconds (:pr:`264`) Fixed ----- * Send ``Content-Type: application/json`` on requests with JSON request bodies (:pr:`266`) .. _changelog-1.4.0: v1.4.0 (2017-12-13) =================== Added ----- * Access token response data by way of scope name (:pr:`261`) * Add (beta) SearchClient class (:pr:`259`) Changed ------- * Make ``cryptography`` a strict requirement, globus-sdk[jwt] is no longer necessary (:pr:`257`, :pr:`260`) * Simplify OAuthTokenResponse.decode_id_token to not require the client as an argument (:pr:`255`) .. _changelog-1.3.0: v1.3.0 (2017-11-20) =================== Python Support -------------- * Improve error message when installation onto python2.6 is attempted (:pr:`245`) Changed ------- * Raise errors on client instantiation when ``GLOBUS_SDK_ENVIRONMENT`` appears to be invalid, support ``GLOBUS_SDK_ENVIRONMENT=preview`` (:pr:`247`) .. _changelog-1.2.2: v1.2.2 (2017-11-01) =================== Added ----- * Allow client classes to accept ``base_url`` as an argument to ``_init__()`` (:pr:`241`) Changed ------- * Improve docs on ``TransferClient`` helper classes (:pr:`231`, :pr:`233`) Fixed ----- * Fix packaging to not include testsuite (:pr:`232`) .. _changelog-1.2.1: v1.2.1 (2017-09-29) =================== Changed ------- * Use PyJWT instead of python-jose for JWT support (:pr:`227`) .. _changelog-1.2.0: v1.2.0 (2017-08-18) =================== Added ----- * Add Transfer symlink support (:pr:`218`) Fixed ----- * Better handle UTF-8 inputs (:pr:`208`) * Fix endpoint manager resume (:pr:`224`) Documentation ------------- * Doc Updates & Minor Improvements .. _changelog-1.1.1: v1.1.1 (2017-05-19) =================== Fixed ----- * Use correct paging style when making ``endpoint_manager_task_list`` calls (:pr:`210`) .. _changelog-1.1.0: v1.1.0 (2017-05-01) =================== Python Support -------------- * Add python 3.6 to supported platforms (:pr:`180`) Added ----- * Add endpoint_manager methods to TransferClient (:pr:`191`, :pr:`199`, :pr:`200`, :pr:`201`, :pr:`203`) * Support iterable requested_scopes everywhere (:pr:`185`) Changed ------- * Change "identities_set" to "identity_set" for token introspection (:pr:`163`) * Update dev status classifier to 5, prod (:pr:`178`) Documentation ------------- * Fix docs references to ``oauth2_start_flow_*`` (:pr:`190`) * Remove "Beta" from docs (:pr:`179`) Development ----------- * Numerous improvements to testsuite .. _changelog-1.0.0: v1.0.0 (2017-04-10) =================== Added ----- * Adds ``AuthAPIError`` with more flexible error payload handling (:pr:`175`) .. _changelog-0.7.2: v0.7.2 (2017-04-05) =================== Added ----- * Add ``AuthClient.validate_token`` (:pr:`172`) Fixed ----- * Bugfix for ``on_refresh`` users of ``RefreshTokenAuthorizer`` and ``ClientCredentialsAuthorizer`` (:pr:`173`) .. _changelog-0.7.1: v0.7.1 (2017-04-03) =================== Removed ------- * Remove deprecated ``oauth2_start_flow_*`` methods (:pr:`170`) Added ----- * Add the ``ClientCredentialsAuthorizer`` (:pr:`164`) * Add ``jwt`` extra install target. ``pip install "globus_sdk[jwt]"`` installs ``python-jose`` (:pr:`169`) .. _changelog-0.7.0: v0.7.0 (2017-03-30) =================== Removed ------- * Remove all properties of ``OAuthTokenResponse`` other than ``by_resource_server`` (:pr:`162`) Fixed ----- * Make ``OAuthTokenResponse.decode_id_token()`` respect ``ssl_verify=no`` configuration (:pr:`161`) .. _changelog-0.6.0: v0.6.0 (2017-03-21) =================== Added ----- * Add ``deadline`` support to ``TransferData`` and ``DeleteData`` (:pr:`159`) Changed ------- * Opt out of the Globus Auth behavior where a ``GET`` of an identity username will provision that identity (:pr:`145`) * Wrap some ``requests`` network-related errors in custom exceptions (:pr:`155`) Fixed ----- * Fixup OAuth2 PKCE to be spec-compliant (:pr:`154`) .. _changelog-0.5.1: v0.5.1 (2017-02-25) =================== Added ----- * Add support for the ``prefill_named_grant`` option to the Native App authorization flow (:pr:`143`) Changed ------- * Unicode string improvements (:pr:`129`) * Better handle unexpected error payloads (:pr:`135`) globus-globus-sdk-python-6a080e4/docs/000077500000000000000000000000001513221403200176465ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/.buildinfo000066400000000000000000000003461513221403200216250ustar00rootroot00000000000000# Sphinx build info version 1 # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. config: 09663896a363f5a893d1993939ee5df0 tags: 645f666f9bcd5a90fca523b33c5a78b7 globus-globus-sdk-python-6a080e4/docs/_static/000077500000000000000000000000001513221403200212745ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/_static/css/000077500000000000000000000000001513221403200220645ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/_static/css/globus_sdk_tab_borders.css000066400000000000000000000013221513221403200272760ustar00rootroot00000000000000/* custom CSS to add a border to tab groups * see also: * https://github.com/pradyunsg/furo/discussions/633 */ :root { /* default to '#aaa', which looks okay in both light and dark */ --tabset-border-color: #aaa; } /* set the border color to decrease contrast a little on dark theme * this detection logic is taken from furo, which sets data-theme on the body * element when the user toggles the color scheme */ @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) { --tabset-border-color: #777; } } .sd-tab-set { border-style: solid; border-width: 1px; border-radius: 3px; border-color: var(--tabset-border-color); padding: 1px; } .sd-tab-set .sd-tab-content { padding: 4px; } globus-globus-sdk-python-6a080e4/docs/_static/logo.png000066400000000000000000000175271513221403200227560ustar00rootroot00000000000000‰PNG  IHDR,,y}ŽutEXtSoftwareAdobe ImageReadyqÉe<ùIDATxÚì¿VãJ¶‡‹ž“Ov':î'hs¢›!^`0áÚ&žÀð@|Ìà^Üóƒ;»êhÂã~‚¹Úx«)Ôþ'©J*Iß·––iÛRýùÕÞ»ªvPAýü>þ£—¼ 6ýÿ¿îþ1§”¬ª)J^úz}J®T z>n‘\Ë䊓ë›þ;NDmAI‚yÅI„(Ral³œ<0W{’Ÿ[R#€`AÖ&ב U? Û[¨ˆÝc‚…Hèkr†x‚Õ¡ 곊T¯Á"âu›\SÜF@°Ú'T£äåÌTªŠ©ˆV XÍwûÎÕ¢êwà‘çÉuÅ @°š'V—jQõ:øø X rý&±¨ö®q"\1EVXB%±©k³Z–ï¹J®‚ó€`Õ/T=µ¨Îi [+ë"­EV=b%ÖÔî_.fê&bmA-|è¨X]&/ˆUndýÙ³Š=–g¡z0í\OU52“xI1‚åϱêQíΘ'×)."ຫsu+·Dê"b±–#±’ÀúˆªöÊR-­9EV1¡kJÄjH5W†Ì N)@°ò‹•¸€¸*ˆ Xˆ Z€`!Vˆ@û ±B´ ý´iYÃb^h ,,˺béBؒѰ°ÌE¡ˆUØ<ê¶(€îZXºÝæ‘jlba³:iaY™¡¤I:鲑¹yŒÔ…èŽ`i>+f›É„ÍÒÐÁÒ¸Õ„ªk,éO€Üü¥ab%ýqÏýíðïÿ^üsNQ@›-,ŽàÂ5«Ö•4n¶í‚YCh­…Eãn[w X8ªš@í{æk~×o Ë, I?² öá—†Üg×gå<À¯*R‹][Ýg¹ŽÌ*ãjÈ“=uõ/éŽÐx K×\uQ°d+Ë­ˆUYë#)C­3³:4bÇžžgÛJw¬,h¾`é2†Ó­e bE]ù8ÐÁZöQ¸’ï=ðT—òÛö}N“ïÓ%¡É.áy‡Äj©Buãë Tçº=&´I Ù¶óMË!ME'÷ÓM¡)–XWýޏ§UvNsýtVcY K­bqAOÔ tQ±^Oú:GȬÐÄjdº13XÊÒ¬}žžvèWÜ38ÿN´Ê–ÆÏ*²Š—ê>‹ˆÍ0«nÁz6íßà\H¬T¤ÎÔ’éï°Ü¤SßnêÐYÑ**Xd}•ç“ÙÔ{²›"XU‹•t¢gÄj£SdÖtš\ë¬.; ^D°’÷K<,¤]ÎfXÁjÂhí½C%é0g™ˆ$9À¢’nÔÚ#åS!Ì+X.Kê[\ÆvÜÖ+hÛV¤(“Ç’beÔí{\·&éЗæmv.g—uº(õEAòÊ#X>}evYN ¸)®0»S70ËEÁúj#®KÁrÂI‹Ë{šwA¨®™ò! ÙŽ[àÞú \&*\Cº?‚…;¸Ý¼( ¾¶%¹ÈüÙT«§‚ýˆµ…`•!jqYßp'ž-˜á×°+DX[îàzë*×–µ®F¹H]&µ¶È·†`aa•´®*)sR¿rž”Ã."‚µ¯EájßYˆLs–EÕ±¼Ït…•‹lVË>-k'må‹ìs«zi1œ÷íÑB°vrÔÒ2þRà=UÇòú¸…ˆ‚……%k›Bw±²- ÁZ¹ƒÞaáÕ&(+kUD--ß§ .á†D7ã‚õƒ~KË·IVÍÁq~"‚ÕvÁZLi2 ‚äAG°R>µ°ls§j À5Žè›-PC< Á²CÛ(¿b›fÏ\B,¬Ê™ÂÝLp ¬66€"¹ÄŒf¸†Š¡Û‚Õ:|œÜ Á0êxZ ^‰:þýr )‚ VKG*ÎÅëÀàÂþK,¬¶;~ÅžµFrFTË/A00Z7‰e]í³@X­±4çÛof÷D“XéßÍêäîÂ"XND+¹.×TdV1Á#S,6˜¾g¢Ÿ'¢([½žñšuµ°,€r|NK³\f>0ŠE&‹V%•óRÅë¾k³Ñ@I!Ñl‘©n=aO-;qIÅu¼Í›w­©„tÇ7‡6¸…ýš¾[âbr’÷KÖ†Õ.XÉÈÀ7.ã£ÛoëC²¬ ]ˆ•õÜÖMÚ@û×µZ[­Zߊ`µÍ-Œè3µÕš5~¡wêüÐ7«ØV+Q,,OÕÔxtÛ—>”uXß[ÚHöF¶^$ Š®•O\Å"Ü”ÙõW³šò˜vf´-‚,0M^³Š`ÍMûÒuDú\yˆM½É C¶°äÞäíyÞÕÝ:Í/õ!Ûd|¬Bošh-›º½'ÁŠ[Ø0>,‡:ë[ BuZð¢Ôz•÷Nõ«kôÙtwrDD+nâÈ bXecÀ™™y @Bcîº}ˆK”\ÇÉǬ඄+¹ä!¤uXmk8ý+ŽëŒë«Çr®Âujº7CÚÈcËB¬§6м.G‚šsÉû@¦ñœ¦ØIÝnŸÉ z‰`µgt/ËQÎŽ#®OÜVa(À²ªpˆur‰¥uÑ1Ñš4ia).¡_†ÞS×(¢…[y›HDë&ywL´î¬#\ ­¬ž&uk‚p„è}©©-N;&Z9Ñú Ô;'9;ˬ·pÖÕøÕÑê’{8i¬ah‚ÕÆ ç¨@C¸g x=x!®óÔ=ìJ ^ÚhðVVP‚¥ Ùâ6†¼ aZá½ÅnÕEDÇ-m“ë8 ÝÊ 1Ö¬ë ÁZ™]W–Yí@]宸†Á[Y! Ö= áé$¾ãJ¡ZW‹¶h\qÞÑúŒ`á±²–X?¡Î„…8h¹ª ¾ý¼ãÌ5ÖßÏjìý3Û•ê1_·ÉuÝB+k’ǽ oÒxÒ ®¹ øL»à¬>)«¤.¤¼¢¼V¬ З‚Y&†jõT¹¸ó³ 44ó[)ç2?ʨëÚ=’e :Híuð±ì·9-)É2ñQʺÈà ±L´’ëÐT»Q{jð=HÁÒÛVÑz(à;­ùW0„ͰÁÆ0÷\#'å{,›ª]朲6j»l [E Áò7š5Í5|ÈÙX—ŽFØíHË5ÖÕuÅnÇ: 8z}“-Õ¢:öù *\bqùŽo…XøÁ –ßCo¼E‰ôxó\¢¥#ìØäŸ=LGý‹ ±qSC˜În õ´AÄ>V™Å3ù®KÄ|¹ÏXX¸2íe”W´´¡Š«üQ…k[Á—Ù¨Ãm£¾îÔÁŒ›k<#J"cÉòPGÜMëÔ—hõB²HêQDëÑC=J›Š¬ü ¹­‚õ*Zfµ[¾pÞr¨\գ©?nÕëÊB&‚Ù0n‰Ö³Á jyÃAZGR¡úÔy](ªn}—ç`Óˆœ|ÿAŽÏ‰ôsÊrÜ€`{ú‰Ä!]®_œiRÃ]ßÛ7oGª-ŒÇëR]ØË%âqTþ‹¯/ŪҔ¸Ï&œ£®æˆ•3KëÆ¸¨êíÑž$¤ðbVqЉ¾J~ô Þ%ÔÎ;éP»“J–c˜ä™ou”‹K–aº—ñÌ„w&ߨ€ëò|qØ÷ )ÌÕM޵}I;ëû9ù»C—ÖV°‚e U¿£ ¯¯æ½X]é!¢{™ÛV^Í‘ 7xÕÒ#Þê´²â¤þoŒ›e*ÛúÞ¶±+óvžfú÷·*`wz·V°4.21Ý=äri| -#yY—RºIDzK#¿¡j½pk<®«³ö8Š;)îŸy‹_õµÿÊâÖ©YÍ„G®Üþ`bXê_«™‰Xí_ˆ2WY±ªrÖkðžÁÆ[YÆïÖ¶¡%Œ) ]Ø|eµÏôÿõç ,,µªî:ìþ…BUkŠn´{G\²‘ÇÁR°ëPvoü'ý}Z¿ê 8£v Kg­«îˆâ¦-BàÔÊš›ò‹>wYÀƒÌ`wa|>2>Ô&Xê>šnÍv×­,Ce”µb;>×>jiÚ‘¦QJ-¼ØÕÕ"XÖÂň6Õ).BJ}ÜÊžq¹Üa½k_¾7oiÒ¬©'jŒ,ÃÕò• –%VÚSãÉ3àÜ4lûM+Üï’ïÿºåÿNUŒÒ3³¥ŠÙHïtr¥RÁÚ¶%ZÍŒ¸Uõ8°f;>;]_%³ûJÐ]ïi’S×)w*,Ī#ò¦Ï$nU±k—0#Zvª£¹¾Êàä%?X%ËKc›ùžóïwe¥|…YoU»`õ ¾WÒyoÝŸMu䛪,,ÙI̪}ôvŒÎ,m~ý^ëFæ Œ ï‚¥ë¬"꾕.á`‹X3#ؤÿ¾hX§V¼º„ú€]^gŒ!Ô%ðËœu‹XuËÚ’ì ã:g{}ǰî:^É÷zX@^¡ÿÓÔï˹]&Ú`¡« è»îӲݦ.Ñòæjöîǭ>UäšÕឬy?–Uû+­Ë:Æ‹`i€Ž-7ÅËS ÷zŸ£~û kf˜ ϧÞL|eÆ­ÃÂ:7,agWêHüŸÇÄ·êM]Ç\ÁN^Ï 0þÒÝU-Z¾댶òƒÜ‡gÔpˆìUΣÁÎÌÛIǬ`=|W\·Cƒàu•³‡ÎKëê“‚ï«Jb“/óç¹¾ç¸Ê“Ž¡”pÅzúÍ©k«§–V%}þC@´µV‘X‚ZY¾M­¤<Ö•ÜÛ!ÁõF ×L­-×uWÙò%§çjƒþ“¦ñ{ï¶¡Leiˆ8ë¥:еUÎõYŸÞ2×Öæ°ÑÊ*T6I÷‡5ĈU§-­¥ SÇ}íûÞ] îàfîŠ'5°}jܤ¼½ªb$„FWšeÁ‘fmŒ`±Áy3¯O]#ñ‡äJSyä›4ß¶¤ü¸d XiOÛðËrÃ"~•ËÊ)íŽYgÃý¶f ïøjöÝ@Æ•Uô¹ .¡K…®¥'}·™³j>Ÿ©;æj±ÜT-.\7¨ÓÊwVþê#VÚeÁ’Ù‘/fµFjYð™E´NŠ—æ}™{(ч¥ ¿8jËÇ>N÷î¢`y±fÔòá’<ç‘K«cÝ¡BÑrµPùªH.¸]üÒ¡ºðš\N?waU¼ˆ–ˆ˜äÄê±H/IãaÌ[öÒ'ëy–ˆ8æÉøÙY`屪t‘\e¨Ì3£W_Å+½Œ Ú>&¸ˆÕ7Ëú2ˆxÀU›:òqs]¬ÊÅj‹ˆ¥V@HU‹>H>´¼üƒ+€ZYA Vh³ZbÉ\ E¸¬Ð6Órˆ'‚µÕ¢ ƺ" P+^Rq&XPÅ¢¹¥½ÆÅ6;/Zà:èŠ[H®'€âôB탮ë)„ÒÆ(†Ã|,NQh6.kákÂË©`éöfæš‹‹4çÞ< G±²šéö›€û}“ë6€‚h~¹qqêUìó€ç‚¥7×\ð†Ÿ³­+_VVÖm —W"ÖUßÁGy=§À—`MM½Á÷az¬:Tf]M}o‡ó"XzÓu[Yç´A€½¬«‘q³œáÊ÷½úL/sS³•u†•°S¬¤¸Hm~SÅ!*Þ++«‡•°«~ÉÏXVa]ù¶°R++®³2ôpøÙºŠ êU¥rò*Xúu'Ñ»Ã5Xë º8ƒPNušVußÞS$'#+ßë\ý.Ö5Màý@îȬ4yU9ÝǦÞüHÏMÀºZõ«ÚÇUŸV^‰`©kX÷a¾è²XIp5+X¹çô—ª¾èß‹þßßÿ.~ó×X_Ãä¾%÷B‚?èªX¹Š[ýOÏPé1_ÉCJ¾n±¸Ó㸺$VGb%ý÷´®ç¨ã\ÂcSÎ,‰i=0{²¬|”ôÛã:O£ª\°ôaC- :>“Š:â–œk+á fõ9z•Uº7œc-+YêbIO*VµÇ~j.Б#¿Ú±YMÓÎiêÐp¡ê©PÚ$Vµ V€¢%ˆ`]!\ÐP±ê'/ÆMËEn`P‚e‰ÖµqsÂ]«¡q¯JÛÿiha’ƒ€ [F„ÇÀD+­¸û*÷KpE¨†Ž>RñC|ÖƒÀ ~ æl?À²’‘fªâÅÂSh£Uõš¬ äÁù À è©hE·“ج6tÁe„šúI_…ÊU?‘Axú`|p…HL«) øD´žÌj˼ærë±<£õîßÄqß¼uWMh7WŽKs·JңξêÏҜ߭6’öWý÷@Ë*u_o«ÞM^…JDêÌaˆMÖò4¤¢Bwó6’87øºæï~5?OM)iŒrVÜ Ë ¡j¢UÕ(Á²*î\MaöÿCfw›QÁ·÷¾ŠÔÈq›‹ÿ¢©±×ƒ†U¢ëéÛ®«x1ã^—¶ýÙCÕ¢š6¹|Z©‘q“âV yŽåU»55R¡rݦÓÓ«Z±Wö á=2nŽ)‚·Æý*^f5ãS$^E*µ¦žê²5BÕ Á²ÜDAIXÅ;DÀÒ%íÃ©Ö Uk+#\C,.,°6)á‹#m›}ÏusÛö-d-m(i< ¢Ûx%V{]oÖõUÿjA T "V”mMÍT¨:1yrÐäcj¶ô±y[4·±3iÛê«0}RqªÊ²—¡“këºò :]|bÜ$5ƒbB¶Twr™þ;t1³RhËëo*Jƒ@©/*RqWÑAרŠ+œh\Ë+ æ–›ù-#r?DÏ…E¡YAÒz··9Ù» ê',3"E¼°‹‚µ¡ñ­¸@Vè«H‘ÁÊã ¬¸Ä€ROÄæmÖ+ Ár*b}½>©Q•5–Ž´OÖ¿%kà72oÓæ¸·Í(æ"XµˆY*dƵ¢î@:ÂÆE³º·©€U9k››…aÝ‚{‹id¹·¥âUœdùªâĦr ˆX—CÄŠ»u±ZMòº@œ,¨Ç­ñúÍú¹ËØ `cuÓç´ Â²tRúúkæßMwáRaún<¤² Ât1ÓÙIÛ*;Êü©ÏÕáÙ¦b¥‹OÓUõÆœÌ `“èm%,€6AŠä@ù}üÇȬN¯‘<äÓ?Gd4×{ò—Ô,”áE,Ÿ“k¢¯M&Òç˜P¥€`‚€` X€` X XÀ^üBäã÷ñƒäE®¾õëer-þu÷yCž¡¯÷eþkaV[”k¬åºhy›êé³gëc®õÓó¬¼ü,¹FÉÕÛòwò2K®[[¼t_`ºÅæ¢&AèëýŸhçØö·Kë9ž;êdrMïçªhçM>ã1åä3.Šìµþó>ïžOÝg9Y#T)ý;©ƒûäš&ß±Ä%„mê2yyI®ómÊb˜\ÒA´CËš‰öü §¢\×ú “]b¥ôTDžõ9úîëÇ8#Zç%¬Öt8Î[®ê’^eÜÃ~ƒšW”©—ÜÏßUWÁÚn²§Ü”\ª0®áþm±š•uá,këÂPô¬A .x/—æ}ì­I‰í2»§«!Xe­“^&¾rU²£Ç—ª Wv`¹\ÎS­›™%[Ÿ›N|7{¼Õ‚y0ëRý¢®êk:uUä»NÌûƒT¸?E,+ûÙ“÷l°”ä>îµËÌìyè,¸°Ð YXïg`¹\Ù¸¨úþ¬m.ƒ5Bµð`V+iK/ß9¯bËO÷€`A°‚€KÛ8ÊŒš€…¤u%fþŸ©‹£ñ À {vnFq X¡ZW}ó~Áà-¥€`…ê Ú«£§œ€`ùœaÑ4$:M-+ªíMº”*@9ºo¶Ž^Ô:š›ÕÖÙ®õ0*T²•bdýúÇJÁò!X(_—¼o¡×·ÌïÌæ…cÄ À ¿Pk™ê«Ìm·p`öÛƒöš€­XXU[["PCµ¢¢-«ûøT"_ XN¬—±²–¸|íçÿôùTŸlu<“IEND®B`‚globus-globus-sdk-python-6a080e4/docs/authorization.rst000066400000000000000000000001661513221403200233030ustar00rootroot00000000000000:orphan: The documentation which was found on this page has moved to :ref:`Globus Authorizers `. globus-globus-sdk-python-6a080e4/docs/authorization/000077500000000000000000000000001513221403200225465ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/authorization/gare.rst000066400000000000000000000152331513221403200242220ustar00rootroot00000000000000.. _gares: Globus Auth Requirements Errors (GAREs) ======================================= 'Globus Auth Requirements Error' is a response format that conveys to a client any modifications to a session (i.e., "boosting") that will be required to complete a particular request. The ``globus_sdk.gare`` module provides a number of tools to make it easier to identify and handle these errors when they occur. GARE ---- The ``GARE`` class provides a model for working with Globus Auth Requirements Error responses. Services in the Globus ecosystem may need to communicate authorization requirements to their consumers. For example, a service may need to instruct clients to have the user consent to an additional scope, ``"foo"``. In such a case, ``GARE`` can provide serialization into the well-known Globus Auth Requirements Error format: .. code-block:: python from globus_sdk.gare import GARE error_doc = GARE( code="ConsentRequired", authorization_parameters=GlobusAuthorizationParameters( required_scopes=["foo"], session_message="Missing required 'foo' consent", ), ) # Render a strict dictionary error.to_dict() If non-canonical fields are needed, the ``extra`` argument can be used to supply a dictionary of additional fields to include. Non-canonical fields present in the provided dictionary when calling ``from_dict()`` are stored similarly. You can include these fields in the rendered output dictionary by specifying ``include_extra=True`` when calling ``to_dict()``. .. code-block:: python from globus_sdk.gare import GARE error = GARE( code="ConsentRequired", authorization_parameters=GlobusAuthorizationParameters( required_scopes=["foo"], session_message="Missing required 'foo' consent", ), extra={ "message": "Missing required 'foo' consent", "request_id": "WmMV97A1w", "required_scopes": ["foo"], "resource": "/transfer", }, ) # Render a dictionary with extra fields error.to_dict(include_extra=True) These fields are stored by both the ``GARE`` and ``GlobusAuthenticationParameters`` classes in an ``extra`` attribute. .. note:: Non-canonical fields in a Globus Auth Requirements Error are primarily intended to make it easier for services to provide backward-compatibile error responses to clients that have not adopted the Globus Auth Requirements Error format. Avoid using non-canonical fields for any data that should be generically understood by a consumer of the error response. .. _globus_app_gare_integration: GlobusApp Integration --------------------- In order to standardize a common GARE-handling approach, the SDK's :ref:`GlobusApp ` contains a built-in GARE redrive mechanism (disabled by default). When enabled (setting the config flag ``auto_redrive_gares`` to ``True``), any client created with a GlobusApp will receive an additional request-transport retry handler. This handler will intercept the first occurrence of a 403 response which parses as a GARE and "redrive it", by: 1. Initiating a fresh login request with the defined ``LoginFlowManager`` 2. Caching new tokens for subsequent requests 3. Re-attempting the original request with the new tokens This allows for very simple error-handling, particularly in scripts, wherein the executing user or client is automatically prompted to remediate manually-required steps (e.g., consent, MFA) without any additional code. .. code-block:: python from globus_sdk import UserApp, GlobusAppConfig, TransferClient config = GlobusAppConfig(auto_redrive_gares=True) with UserApp("my-gare-demo", "", config=config) as app: tc = TransferClient(app=app) # If the transfer service were to return a 403 GARE, the script runner would be # prompted to log in with any explicit specifications, e.g., MFA, consents. # Once they complete the login flow (by providing the newly minted auth code), # the original request is attempted once more. task = tc.submit_transfer(your_transfer_data) Parsing Responses ----------------- If you are writing a client to a Globus API, the ``gare`` subpackage provides utilities to detect legacy Globus Auth requirements error response formats and normalize them. To detect if a ``GlobusAPIError``, ``ErrorSubdocument``, or JSON response dictionary represents an error that can be converted to a Globus Auth Requirements Error, you can use, e.g.,: .. code-block:: python from globus_sdk import gare error_dict = { "code": "ConsentRequired", "message": "Missing required foo consent", } # The dict is not a Globus Auth Requirements Error, so `False` is returned. gare.is_auth_requirements_error(error_dict) # The dict is not a Globus Auth Requirements Error and cannot be converted. gare.to_auth_requirements_error(error_dict) # None error_dict = { "code": "ConsentRequired", "message": "Missing required foo consent", "required_scopes": ["urn:globus:auth:scope:transfer.api.globus.org:all[*foo]"], } gare.is_auth_requirements_error(error_dict) # True gare.to_auth_requirements_error(error_dict) # GARE .. note:: If a ``GlobusAPIError`` represents multiple errors that were returned in an array, ``to_auth_requirements_error()`` only returns the first error in that array that can be converted to the Globus Auth Requirements Error response format. In this case (and in general) it's preferable to use ``to_auth_requirements_errors()`` (which also accepts a list of ``GlobusAPIError``\ s, ``ErrorSubdocument``\ s, and JSON response dictionaries): .. code-block:: python gare.to_auth_requirements_error(other_error) # GARE gare.to_auth_requirements_errors([other_error]) # [GARE, ...] Notes ----- ``GARE`` enforces types strictly when parsing a Globus Auth Requirements Error response dictionary, and will raise a :class:`globus_sdk.ValidationError` if a supported field is supplied with a value of the wrong type. ``GARE`` does not attempt to mimic or itself enforce any logic specific to the Globus Auth service with regard to what represents a valid combination of fields (e.g., ``session_required_mfa`` requires either ``session_required_identities`` or ``session_required_single_domain`` in order to be properly handled). Reference --------- .. currentmodule:: globus_sdk.gare .. autoclass:: GARE :members: :inherited-members: .. autoclass:: GlobusAuthorizationParameters :members: :inherited-members: .. autofunction:: to_gare .. autofunction:: to_gares .. autofunction:: is_gare .. autofunction:: has_gares globus-globus-sdk-python-6a080e4/docs/authorization/globus_app/000077500000000000000000000000001513221403200247015ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/authorization/globus_app/apps.rst000066400000000000000000000132251513221403200264010ustar00rootroot00000000000000.. _globus_apps: .. currentmodule:: globus_sdk GlobusApps ========== .. note:: Currently ``GlobusApp`` can only be used in scripts (e.g., notebooks or automation) and applications directly responsible for a user's login flow. Web services and other hosted applications operating as an OAuth2 resource server should see :ref:`globus_authorizers` instead. :class:`GlobusApp` is a high level construct designed to simplify authentication for interactions with :ref:`globus-sdk services `. A ``GlobusApp`` uses an OAuth2 client to obtain and manage OAuth2 tokens required for API interactions. OAuth2 clients must be created external to the SDK by registering an application at the `Globus Developer's Console `_. The following section provides a comparison of the specific types of ``GlobusApps`` to aid in selecting the proper one for your use case. Types of GlobusApps ------------------- There are two flavors of ``GlobusApp`` available in the SDK: * :class:`UserApp`, for interactions in which an end user communicates with Globus services and * :class:`ClientApp`, for interactions in which an OAuth2 client, operating as a "service account", communicates with Globus services. The following table provides a comparison of these two options: .. list-table:: :widths: 50 50 :header-rows: 1 * - **UserApp** - **ClientApp** * - Appropriate for performing actions as a specific end user (e.g., the `Globus CLI `_) - Appropriate for automating actions as a service account * - Created resources (e.g., collections or flows) by default are owned by an end user - Created resources (e.g., collections or flows) by default are owned by the OAuth2 client * - Existing resource access is evaluated based on an end user's permissions - Existing resource access is evaluated based on the OAuth2 client's permissions * - OAuth2 tokens are obtained by putting an end user through a login flow (this occurs in a web browser) - OAuth2 tokens are obtained by programmatically exchanging an OAuth2 client's secret * - Should typically use a "native" OAuth2 client (`Register a thick client `_) May use a "confidential" OAuth2 client (`Register a portal or science gateway `_) - Must use a "confidential" OAuth2 client (`Register a service account `_) .. note:: Not all Globus operations support both app types. Particularly when dealing with sensitive data, services may enforce that a a user be the primary data access actor. In these cases, a ``ClientApp`` will be rejected and a ``UserApp`` must be used instead. Closing Resources via GlobusApps -------------------------------- When used as context managers, ``GlobusApp``\s automatically call their ``close()`` method on exit. Closing an app closes the token storage attached to it, unless it was created explicitly. This covers any token storage created by the app on init, but not those which are created and passed in via the config. For most cases, users are recommended to use the context manager form, and to allow ``GlobusApp`` to both create and close the token storage. For example, .. code-block:: python from globus_sdk import GlobusAppConfig, UserApp # create an app configured to create its own sqlite storage config = GlobusAppConfig(token_storage="sqlite") with UserApp("sample-app", client_id="FILL_IN_HERE", config=config) as app: ... # any clients, usage, etc. # after the context manager, any storage is implicitly closed However, when token storage instances are created by the user, they are not automatically closed, and the user is responsible for closing them. For example, in the following usage, the user must close the token storage: .. code-block:: python from globus_sdk import GlobusAppConfig, UserApp from globus_sdk.token_storage import SQLiteTokenStorage # this token storage is created by the user and will not be closed automatically sql_storage = SQLiteTokenStorage("tokens.sqlite") # create an app configured to use this storage config = GlobusAppConfig(token_storage=sql_storage) with UserApp("sample-app", client_id="FILL_IN_HERE", config=config) as app: do_stuff(app) # a second app uses the same storage, and shares the resource with UserApp("a-different-app", client_id="OTHER_ID", config=config) as app: do_stuff(app) # At this stage, the storage will still be open. # It should be explicitly closed: sql_storage.close() Reference --------- The interfaces of these classes, defined below, intentionally include many "sane defaults" (i.e., storing oauth2 access tokens in a json file). These defaults may be overridden to customize the app's behavior. For more information on what you can customize and how, see :ref:`globus_app_config`. .. autoclass:: GlobusApp() :members: :exclude-members: scope_requirements :member-order: bysource .. In the above class, "scope_requirements" is excluded because it's a ``@property``. Sphinx wants to document it as a method but that's not how it's invoked. Instead documentation is included in the class docstring as an ``ivar``. Implementations ^^^^^^^^^^^^^^^ .. autoclass:: UserApp :members: .. autoclass:: ClientApp :members: globus-globus-sdk-python-6a080e4/docs/authorization/globus_app/config.rst000066400000000000000000000016641513221403200267070ustar00rootroot00000000000000.. _globus_app_config: .. currentmodule:: globus_sdk.globus_app GlobusApp Configuration ======================= Reference --------- Data Model ^^^^^^^^^^ .. autoclass:: globus_sdk.GlobusAppConfig() :members: :exclude-members: token_validation_error_handler :member-order: bysource .. In the above class, "token_validation_error_handler" is a callable so sphinx wants to document it as a method. Instead, we explicitly exclude it and document it in the class docstring as an ``ivar``. Providers ^^^^^^^^^ .. autoclass:: TokenStorageProvider() :members: for_globus_app :member-order: bysource .. autoclass:: LoginFlowManagerProvider() :members: for_globus_app :member-order: bysource .. autoclass:: TokenValidationErrorHandler() :members: :special-members: __call__ :member-order: bysource .. autoclass:: IDTokenDecoderProvider() :members: for_globus_app :member-order: bysource globus-globus-sdk-python-6a080e4/docs/authorization/globus_app/index.rst000066400000000000000000000003101513221403200265340ustar00rootroot00000000000000.. _globus_app: Globus App ========== For a narrative-style introduction to the concepts contained within GlobusApp, see :ref:`using_globus_app`. .. toctree:: :maxdepth: 1 apps config globus-globus-sdk-python-6a080e4/docs/authorization/globus_authorizers.rst000066400000000000000000000054031513221403200272340ustar00rootroot00000000000000.. _globus_authorizers: Globus Authorizers ================== .. currentmodule:: globus_sdk.authorizers Globus SDK clients need credentials (tokens) to authenticate against services and authorize their various API calls. Using Globus Auth, the Globus OAuth2 service, clients can be provided with credentials which are either static or dynamic. The interface used to handle these credentials is :class:`GlobusAuthorizer`. :class:`GlobusAuthorizer` defines methods which provide an ``Authorization`` header for HTTP requests, and different implementations handle Refresh Tokens, Access Tokens, Client Credentials, and their various usage modes. A :class:`GlobusAuthorizer` is an object which defines the following two operations: - get an ``Authorization`` header - handle a 401 Unauthorized error Clients contain authorizers, and use them to get and refresh their credentials. Whenever using the :ref:`Service Clients `, you should be passing in an authorizer when you create a new client unless otherwise specified. The type of authorizer you will use depends very much on your application, but if you want examples you should look at the :ref:`examples section `. It may help to start with the examples and come back to the reference documentation afterwards. The Authorizer Interface ------------------------ We define the interface for ``GlobusAuthorizer`` objects in terms of an Abstract Base Class: .. autoclass:: GlobusAuthorizer :members: :member-order: bysource Authorizers within this SDK fall into two categories: * "Static Authorizers" already contain all authorization data and simply format it into the proper authorization header. These all inherit from the ``StaticGlobusAuthorizer`` class. * "Renewing Authorizer" take some initial parameters but internally define a functional behavior to acquire new authorization data as necessary. These all inherit from the ``RenewingGlobusAuthorizer`` class. .. autoclass:: StaticGlobusAuthorizer :member-order: bysource :show-inheritance: .. autoclass:: RenewingAuthorizer :member-order: bysource :show-inheritance: Authorizer Types ---------------- .. currentmodule:: globus_sdk All of these types of authorizers can be imported from ``globus_sdk.authorizers``. .. autoclass:: AccessTokenAuthorizer :members: :member-order: bysource :show-inheritance: .. autoclass:: RefreshTokenAuthorizer :members: :member-order: bysource :show-inheritance: .. autoclass:: ClientCredentialsAuthorizer :members: :member-order: bysource :show-inheritance: .. autoclass:: BasicAuthorizer :members: :member-order: bysource :show-inheritance: .. autoclass:: NullAuthorizer :members: :member-order: bysource :show-inheritance: globus-globus-sdk-python-6a080e4/docs/authorization/index.rst000066400000000000000000000004231513221403200244060ustar00rootroot00000000000000Globus SDK Authorization ======================== Components of the Globus SDK which handle application authorization. .. toctree:: :maxdepth: 1 globus_app/index globus_authorizers scopes_and_consents/index login_flows token_caching/index gare globus-globus-sdk-python-6a080e4/docs/authorization/login_flows.rst000066400000000000000000000061141513221403200256240ustar00rootroot00000000000000.. _login_flow_managers: Login Flow Managers =================== .. currentmodule:: globus_sdk.login_flows This page provides references for the LoginFlowManager abstract class and some concrete implementations. A login flow manager is a class responsible for driving a user through a login flow, with the ultimate goal of obtaining tokens. The tokens are required to make requests against any Globus services. Interface --------- .. autoclass:: LoginFlowManager :members: Command Line ------------ As the name might suggest, a CommandLineLoginFlowManager drives user logins through the command line (stdin/stdout). When run, the manager will print a URL to the console then prompt a user to navigate to that URL and enter the resulting auth code back into the terminal. Example Code: .. code-block:: pycon >>> from globus_sdk import NativeAppAuthClient >>> from globus_sdk.scopes import TransferScopes >>> from globus_sdk.login_flows import CommandLineLoginFlowManager >>> login_client = NativeAppAuthClient(client_id=client_id) >>> manager = CommandLineLoginFlowManager(login_client) >>> >>> token_response = manager.run_login_flow( ... GlobusAuthorizationParameters(required_scopes=[TransferScopes.all]) ... ) Please authenticate with Globus here: ------------------------------------- https://auth.globus.org/v2/oauth2/authorize?cli...truncated... ------------------------------------- Enter the resulting Authorization Code here: .. autoclass:: CommandLineLoginFlowManager :members: :member-order: bysource :show-inheritance: .. autoexception:: CommandLineLoginFlowEOFError Local Server ------------ A LocalServerLoginFlowManager drives more automated, but less portable, login flows compared with its command line counterpart. When run, rather than printing the authorization URL, the manager will open it in the user's default browser. Alongside this, the manager will start a local web server to receive the auth code upon completion of the login flow. This provides a more user-friendly login experience as there is no manually copy/pasting of links and codes. It also requires however that the python process be running in an environment with access to a supported browser. As such, this flow is not suitable for headless environments (e.g., while ssh-ed into a cluster node). .. note:: This login manager is only supported for native clients. Example Usage: .. code-block:: pycon >>> from globus_sdk import NativeAppAuthClient >>> from globus_sdk.scopes import TransferScopes >>> from globus_sdk.login_flows import LocalServerLoginFlowManager >>> login_client = NativeAppAuthClient(client_id=client_id) >>> manager = LocalServerLoginFlowManager(login_client) >>> >>> token_response = manager.run_login_flow( ... GlobusAuthorizationParameters(required_scopes=[TransferScopes.all]) ... ) .. autoclass:: LocalServerLoginFlowManager :members: :member-order: bysource :show-inheritance: .. autoexception:: LocalServerLoginError .. autoexception:: LocalServerEnvironmentalLoginError globus-globus-sdk-python-6a080e4/docs/authorization/scopes_and_consents/000077500000000000000000000000001513221403200266005ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/authorization/scopes_and_consents/consents.rst000066400000000000000000000023311513221403200311650ustar00rootroot00000000000000.. _consents: .. py:currentmodule:: globus_sdk.scopes.consents Consents ======== The Consents model provides a data model for loading consent information polled from Globus Auth's ``get_consents`` API. Consents are modeled as a ``ConsentForest`` full of ``ConsentTrees`` containing related ``Consents``. These consents detail a path of authorization grants that have been provided by a user to client applications for token grants under certain scoped contexts. While the consent model classes themselves are exposed here in ``globus_sdk.scopes.consents``, most objects are actually loaded from a :meth:`globus_sdk.AuthClient.get_consents` response, using the attached :meth:`globus_sdk.GetConsentsResponse.to_forest()` method: .. code-block:: python import globus_sdk from globus_sdk.scopes.consents import ConsentForest my_identity_id = ... client = globus_sdk.AuthClient(...) response = client.get_consents(my_identity_id) consent_forest: ConsentForest = response.to_forest() Reference --------- .. autoclass:: ConsentForest :members: .. autoclass:: ConsentTree :members: .. autoclass:: Consent :members: .. autoexception:: ConsentParseError .. autoexception:: ConsentTreeConstructionError globus-globus-sdk-python-6a080e4/docs/authorization/scopes_and_consents/index.rst000066400000000000000000000020741513221403200304440ustar00rootroot00000000000000Scopes and Consents =================== Globus uses OAuth2 scopes to control access to different APIs and allow applications provide a least-privilege security guarantee to their users. Overview -------- A "Consent" is a record, in Globus Auth, that an application is allowed to take some action on behalf of a user. A "Scope" is the name of some action or set of actions. For example, ``urn:globus:auth:scope:groups.api.globus.org:all`` is a scope which gives full access to Globus Groups. Users _consent_ to allow applications, like the Globus CLI, to get credentials which are "valid for this scope", and thereby to view and manipulate their group memberships. For more information, see `this docs.globus.org explanation of scopes and consents `_. Reference --------- Within the Globus SDK, Scopes and Consents are modeled using several objects which make learning about and manipulating these data easier. .. toctree:: :maxdepth: 1 scopes scope_collections consents scope_parsing globus-globus-sdk-python-6a080e4/docs/authorization/scopes_and_consents/scope_collections.rst000066400000000000000000000120621513221403200330420ustar00rootroot00000000000000.. _scope_collections: .. currentmodule:: globus_sdk.scopes ScopeCollections ================ OAuth2 Scopes for various Globus services are represented by ``ScopeCollection`` objects. These are containers for constant :class:`Scope` objects. Scope collections are provided directly via ``globus_sdk.scopes`` and are also accessible via the relevant client classes. Direct Use ---------- To use the scope collections directly, import from ``globus_sdk.scopes``. For example, one might use the Transfer "all" scope during a login flow like so: .. code-block:: python import globus_sdk from globus_sdk.scopes import TransferScopes CLIENT_ID = "" client = globus_sdk.NativeAppAuthClient(CLIENT_ID) client.oauth2_start_flow(requested_scopes=[TransferScopes.all]) ... As Client Attributes -------------------- Token scopes are associated with a particular client which will use that token. Because of this, each service client contains a ``ScopeCollection`` attribute (``client.scopes``) defining the relevant scopes for that client. For most client classes, this is a class attribute. For example, accessing ``TransferClient.scopes`` is valid: .. code-block:: python import globus_sdk CLIENT_ID = "" client = globus_sdk.NativeAppAuthClient(CLIENT_ID) client.oauth2_start_flow(requested_scopes=[globus_sdk.TransferClient.scopes.all]) ... # or, potentially, after there is a concrete client tc = globus_sdk.TransferClient() client.oauth2_start_flow(requested_scopes=[tc.scopes.all]) As Instance Attributes and Methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Some client classes only provide their scopes for instances. These cases cover services which are distributed or contain multiple subservices with their own scopes. For example, ``GCSClient`` and ``SpecificFlowClient`` each have a ``scopes`` attribute of ``None`` on their classes. In the case of ``SpecificFlowClient``, scopes are populated whenever an instance is instantiated. So the following usage is valid: .. code-block:: python import globus_sdk FLOW_ID = "" client = globus_sdk.SpecificFlowClient(FLOW_ID) flow_user_scope = client.scopes.user In the case of GCS, a distributed service, ``scopes`` is always ``None``. However, :meth:`globus_sdk.GCSClient.get_gcs_endpoint_scopes` and :meth:`globus_sdk.GCSClient.get_gcs_collection_scopes` are available helpers for getting specific collections of scopes. Using a Scope Collection to Get Matching Tokens ----------------------------------------------- A ``ScopeCollection`` contains the resource server name used to get token data from a token response. To elaborate on the above example: .. code-block:: python import globus_sdk from globus_sdk.scopes import TransferScopes CLIENT_ID = "" client = globus_sdk.NativeAppAuthClient(CLIENT_ID) client.oauth2_start_flow(requested_scopes=[TransferScopes.all]) authorize_url = client.oauth2_get_authorize_url() print("Please go to this URL and login:", authorize_url) auth_code = input("Please enter the code you get after login here: ").strip() token_response = client.oauth2_exchange_code_for_tokens(auth_code) # use the `resource_server` of a ScopeBuilder to grab the associated token # data from the response tokendata = token_response.by_resource_server[TransferScopes.resource_server] Reference --------- Collection Types ~~~~~~~~~~~~~~~~ .. autoclass:: ScopeCollection :members: :show-inheritance: .. autoclass:: StaticScopeCollection :members: :show-inheritance: .. autoclass:: DynamicScopeCollection :members: :show-inheritance: .. autoclass:: GCSEndpointScopes :members: :show-inheritance: .. autoclass:: GCSCollectionScopes :members: :show-inheritance: .. autoclass:: SpecificFlowScopes :members: :show-inheritance: Collection Constants ~~~~~~~~~~~~~~~~~~~~ .. py:data:: globus_sdk.scopes.data.AuthScopes Globus Auth scopes. .. listknownscopes:: globus_sdk.scopes.AuthScopes :example_scope: view_identity_set .. py:data:: globus_sdk.scopes.data.ComputeScopes Compute scopes. .. listknownscopes:: globus_sdk.scopes.ComputeScopes .. py:data:: globus_sdk.scopes.data.FlowsScopes Globus Flows scopes. .. listknownscopes:: globus_sdk.scopes.FlowsScopes .. py:data:: globus_sdk.scopes.data.GroupsScopes Groups scopes. .. listknownscopes:: globus_sdk.scopes.GroupsScopes .. py:data:: globus_sdk.scopes.data.NexusScopes Nexus scopes. .. listknownscopes:: globus_sdk.scopes.NexusScopes .. warning:: Use of Nexus is deprecated. Users should use Groups instead. .. py:data:: globus_sdk.scopes.data.SearchScopes Globus Search scopes. .. listknownscopes:: globus_sdk.scopes.SearchScopes .. py:data:: globus_sdk.scopes.data.TimersScopes Globus Timers scopes. .. listknownscopes:: globus_sdk.scopes.TimersScopes .. py:data:: globus_sdk.scopes.data.TransferScopes Globus Transfer scopes. .. listknownscopes:: globus_sdk.scopes.TransferScopes globus-globus-sdk-python-6a080e4/docs/authorization/scopes_and_consents/scope_parsing.rst000066400000000000000000000011101513221403200321570ustar00rootroot00000000000000.. _scope_parsing: .. currentmodule:: globus_sdk.scopes Scope Parsing ============= Scope parsing is handled by the :class:`ScopeParser` type. Additionally, :class:`Scope` objects define a :meth:`parse() ` method which wraps parser usage. :class:`ScopeParser` provides classmethods as its primary interface, so there is no need to instantiate the parser in order to use it. ScopeParser Reference --------------------- .. autoclass:: ScopeParser :members: :show-inheritance: .. autoclass:: ScopeParseError .. autoclass:: ScopeCycleError globus-globus-sdk-python-6a080e4/docs/authorization/scopes_and_consents/scopes.rst000066400000000000000000000050211513221403200306240ustar00rootroot00000000000000.. _scopes: .. currentmodule:: globus_sdk.scopes Scopes ====== The SDK provides a ``Scope`` object which is the class model for a scope. ``Scope``\s can be parsed from strings and serialized to strings, and support programmatic manipulations to describe dependent scopes. ``Scope`` can be constructed using its initializer, via ``Scope.parse``, or via :meth:`ScopeParser.parse`. For example, one can create a ``Scope`` object for the OIDC ``openid`` scope: .. code-block:: python from globus_sdk.scopes import Scope openid_scope = Scope("openid") ``Scope`` objects primarily provide three main pieces of functionality: * deserializing (parsing a single scope) * serializing (stringifying) * scope tree construction Tree Construction ~~~~~~~~~~~~~~~~~ ``Scope`` objects provide a tree-like interface for constructing scopes and their dependencies. Because ``Scope`` objects are immutable, trees are constructed by building new scopes. For example, the transfer scope dependent upon a collection scope may be constructed by means of ``Scope`` methods thusly: .. code-block:: python from globus_sdk.scopes import GCSCollectionScopeBuilder, TransferScopes, Scope MAPPED_COLLECTION_ID = "...ID HERE..." # create the scope object, and get the data_access_scope as a string data_access_scope = GCSCollectionScopeBuilder(MAPPED_COLLECTION_ID).data_access # add data_access as an optional dependency transfer_scope = TransferScopes.all.with_dependency(data_access_scope, optional=True) ``Scope``\s can be used in most of the same locations where scope strings can be used, but you can also call ``str(scope)`` to get a stringified representation. Serializing Scopes ~~~~~~~~~~~~~~~~~~ Whenever scopes are being sent to Globus services, they need to be encoded as strings. All scope objects support this by means of their defined ``__str__`` method. For example, the following is an example of ``str()`` and ``repr()`` usage: .. code-block:: pycon >>> from globus_sdk.scopes import Scope >>> foo = Scope("foo") >>> bar = Scope("bar") >>> bar = bar.with_dependency(Scope("baz")) >>> foo = foo.with_dependency(bar) >>> print(str(foo)) foo[bar[baz]] >>> print(str(bar)) bar[baz] >>> alpha = Scope("alpha") >>> alpha = alpha.with_dependency("beta", optional=True) >>> print(str(alpha)) alpha[*beta] >>> print(repr(alpha)) Scope("alpha", dependencies=[Scope("beta", optional=True)]) Reference ~~~~~~~~~ .. autoclass:: Scope :members: :member-order: bysource globus-globus-sdk-python-6a080e4/docs/authorization/token_caching/000077500000000000000000000000001513221403200253425ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/authorization/token_caching/index.rst000066400000000000000000000011711513221403200272030ustar00rootroot00000000000000 Token Caching ============= The documentation in this section provides references for interfaces and standard implementations for caching OAuth2 tokens. While there are two distinct class hierarchies, :ref:`token_storages` and its predecessor :ref:`storage_adapters`, we recommend using the former. ``TokenStorage`` is a newer iteration of the token storage interface and includes a superset of the functionality previously supported in ``StorageAdapter``. All constructs from both hierarchies are importable from the ``globus_sdk.token_storage`` namespace. .. toctree:: :maxdepth: 1 token_storages storage_adapters globus-globus-sdk-python-6a080e4/docs/authorization/token_caching/storage_adapters.rst000066400000000000000000000074701513221403200314330ustar00rootroot00000000000000.. _storage_adapters: Storage Adapters (Legacy) ========================= .. warning:: The class hierarchy documented here is legacy. We recommend using the newer class hierarchy documented at :ref:`token_storages`. The StorageAdapter component provides a way of storing and loading the tokens received from authentication and token refreshes. Usage ----- StorageAdapter is available under the name ``globus_sdk.token_storage.legacy``. Storage adapters are the main objects of this subpackage. Primarily, usage should revolve around creating a storage adapter, potentially loading data from it, and using it as the ``on_refresh`` handler for an authorizer. For example: .. code-block:: python import os import globus_sdk from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter my_file_adapter = SimpleJSONFileAdapter(os.path.expanduser("~/mytokens.json")) if not my_file_adapter.file_exists(): # ... do a login flow, getting back initial tokens # elided for simplicity here token_response = ... # now store the tokens, and pull out the tokens for the # resource server we want my_file_adapter.store(token_response) by_rs = token_response.by_resource_server tokens = by_rs["transfer.api.globus.org"] else: # otherwise, we already did this whole song-and-dance, so just # load the tokens from that file tokens = my_file_adapter.get_token_data("transfer.api.globus.org") # RereshTokenAuthorizer and ClientCredentialsAuthorizer both use # `on_refresh` callbacks # this feature is therefore only relevant for those auth types # # auth_client is the internal auth client used for refreshes, # and which was used in the login flow # note that this is all normal authorizer usage wherein # my_file_adapter is providing the on_refresh callback auth_client = ... authorizer = globus_sdk.RefreshTokenAuthorizer( tokens["refresh_token"], auth_client, access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=my_file_adapter.on_refresh, ) # or, for client credentials authorizer = globus_sdk.ClientCredentialsAuthorizer( auth_client, ["urn:globus:auth:transfer.api.globus.org:all"], access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=my_file_adapter.on_refresh, ) # and then use the authorizer on a client! tc = globus_sdk.TransferClient(authorizer=authorizer) Adapter Types ------------- .. module:: globus_sdk.token_storage.legacy ``globus_sdk.token_storage.legacy`` provides base classes for building your own storage adapters, and several complete adapters. The :class:`SimpleJSONFileAdapter` is good for the "simplest possible" persistent storage, using a JSON file to store token data. :class:`MemoryAdapter` is even simpler still, and is great for writing and testing code which uses the ``StorageAdapter`` interface backed by an in-memory structure. The :class:`SQLiteAdapter` is more complex, for applications like the globus-cli which need to store various tokens and additional configuration. In addition to basic token storage, the :class:`SQLiteAdapter` provides for namespacing of the token data, and for additional configuration storage. Reference --------- .. autoclass:: StorageAdapter :members: :member-order: bysource :show-inheritance: .. autoclass:: MemoryAdapter :members: :member-order: bysource :show-inheritance: .. autoclass:: FileAdapter :members: :member-order: bysource :show-inheritance: .. autoclass:: SimpleJSONFileAdapter :members: :member-order: bysource :show-inheritance: .. autoclass:: SQLiteAdapter :members: :member-order: bysource :show-inheritance: globus-globus-sdk-python-6a080e4/docs/authorization/token_caching/token_storages.rst000066400000000000000000000043541513221403200311310ustar00rootroot00000000000000.. _token_storages: .. currentmodule:: globus_sdk.token_storage Token Storages ============== Interacting with Globus services requires the use of Globus Auth-issued OAuth2 tokens. To assist in reuse of these tokens, the SDK provides an interface to store and retrieve this data across different storage backends. In addition to the interface, :class:`TokenStorage`, the SDK provides concrete implementations for some of the most common storage backends: - :class:`JSONTokenStorage` for storing tokens in a local JSON file. - :class:`SQLiteTokenStorage` for storing tokens in a local SQLite database. - :class:`MemoryTokenStorage` for storing tokens in process memory. Reference --------- .. autoclass:: TokenStorage :members: :member-order: bysource .. autoclass:: TokenStorageData :members: :member-order: bysource File-based Token Storages ^^^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: JSONTokenStorage .. autoclass:: SQLiteTokenStorage :members: close, iter_namespaces Ephemeral Token Storages ^^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: MemoryTokenStorage Validating Token Storage ^^^^^^^^^^^^^^^^^^^^^^^^ Alongside the above storage-specific implementations which supply built-in token storage to common locations, the SDK provides a unique token storage called :class:`ValidatingTokenStorage`. This class isn't concerned with the actual storage of tokens, but rather their validity. A :class:`ValidatingTokenStorage` is created with one or more :class:`TokenDataValidators `, each of which define a custom token validation that will be performed during the storage or retrieval of a token. The SDK provides a number of validators out-of-the-box to meet common validation requirements: :ref:`token_data_validators`. .. autoclass:: ValidatingTokenStorage .. autoclass:: TokenDataValidator :members: :member-order: bysource .. autoclass:: TokenValidationContext :members: :member-order: bysource .. autoclass:: TokenValidationError :members: :member-order: bysource .. _token_data_validators: .. rubric:: Concrete Validators .. autoclass:: NotExpiredValidator .. autoclass:: HasRefreshTokensValidator .. autoclass:: ScopeRequirementsValidator .. autoclass:: UnchangingIdentityIDValidator globus-globus-sdk-python-6a080e4/docs/changelog.rst000066400000000000000000000000371513221403200223270ustar00rootroot00000000000000.. include:: ../changelog.rst globus-globus-sdk-python-6a080e4/docs/conf.py000066400000000000000000000042311513221403200211450ustar00rootroot00000000000000#!/usr/bin/env python3 import datetime import globus_sdk intersphinx_mapping = { "python": ("https://docs.python.org/3", None), } # pull signature docs from type hints, into body # and keep the signatures concise autodoc_typehints = "description" # do not generate doc stubs for inherited parameters from a superclass, # merely because they are type annotated autodoc_typehints_description_target = "documented_params" # sphinx extensions (minimally, we want autodoc and viewcode to build the site) # plus, we have our own custom extension in the SDK to include extensions = [ # sphinx-included extensions "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", # other packages "sphinx_copybutton", "sphinx_design", "sphinx_issues", # our custom one "globus_sdk._internal.extensions.sphinxext", ] project = "globus-sdk" copyright = f"2016-{datetime.datetime.today().strftime('%Y')}, Globus" author = "Globus Team" # The short X.Y version. version = globus_sdk.__version__ # The full version, including alpha/beta/rc tags. release = version major_version = version.partition(".")[0] issues_github_path = "globus/globus-sdk-python" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] # HTML Theme Options html_show_sourcelink = True html_theme = "furo" html_title = f"globus-sdk v{major_version}" html_theme_options = { "light_css_variables": { "color-brand-primary": "#27518F", }, } html_logo = "_static/logo.png" html_static_path = ["_static"] html_css_files = ["css/globus_sdk_tab_borders.css"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "friendly" pygments_dark_style = "monokai" # this is a furo-specific option # Output file base name for HTML help builder. htmlhelp_basename = "globus-sdk-doc" globus-globus-sdk-python-6a080e4/docs/config.rst000066400000000000000000000046021513221403200216470ustar00rootroot00000000000000.. _config: Globus SDK Configuration ======================== The behaviors of the SDK can be controlled either through environment variables, or by passing parameters to clients and other objects. .. note:: SDK v1.x and v2.x supported the use of ``/etc/globus.cfg`` and ``~/.globus.cfg`` to set certain values. This feature was removed in v3.0 in favor of new environment variables for setting these values. Environment Variables --------------------- Each of these environment variables will be read automatically by the SDK. Environment variables have lower precedence than explicit values set in the interpreter. If ``GLOBUS_SDK_VERIFY_SSL="false"`` is set and a client is created with ``verify_ssl=True``, the resulting client will have SSL verification turned on. ``GLOBUS_SDK_VERIFY_SSL`` Used to configure SSL/TLS verification, typically to handle SSL/TLS-intercepting firewalls. By default, all connections to servers are verified. Set ``GLOBUS_SDK_VERIFY_SSL="false"`` to disable verification, or set ``GLOBUS_SDK_VERIFY_SSL="/path/to/ca-bundle.cert"`` to use an alternate certificate authority bundle file. ``GLOBUS_SDK_HTTP_TIMEOUT`` Adjust the timeout when HTTP requests are made. By default, requests have a 60 second read timeout -- for slower responses, try setting ``GLOBUS_SDK_HTTP_TIMEOUT=120`` ``GLOBUS_SDK_ENVIRONMENT`` The name of the environment to use. Set ``GLOBUS_SDK_ENVIRONMENT="preview"`` to use the Globus Preview environment. ``GLOBUS_SDK_SERVICE_URL_*`` Override the URL used for a given service. The suffix of this environment variable must match the service name string used by the SDK in all caps (``SEARCH``, ``TRANSFER``, etc). For example, set ``GLOBUS_SDK_SERVICE_URL_TRANSFER="https://proxy-device.example.org/"`` to direct the SDK to use a custom URL when contacting the Globus Transfer service. Config-Related Functions ------------------------ There are two functions available to translate the configuration described above into the URLs to use for accessing Globus services. To return specifically the URL for the Globus Web App in a given environment: .. autofunction:: globus_sdk.config.get_webapp_url To return the URL for any other service in a given environment: .. autofunction:: globus_sdk.config.get_service_url Note that *no other imports* from ``globus_sdk.config`` are considered public. globus-globus-sdk-python-6a080e4/docs/core/000077500000000000000000000000001513221403200205765ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/core/base_client.rst000066400000000000000000000053301513221403200236010ustar00rootroot00000000000000BaseClient ========== All service clients support the low level interface, provided by the ``BaseClient``, from which all client types inherit. A client object contains a ``transport``, an object responsible for sending requests, encoding data, and handling potential retries. It also may include an optional ``authorizer``, an object responsible for handling token authentication for requests. Closing Resources via Clients ----------------------------- When used as context managers, clients automatically call their ``close()`` method on exit. Closing a client closes the transport object attached to it if the transport was created implicitly during init. This means a transport passed in from the outside will not be closed, but one which was created by the client will be. For most cases, users are recommended to use the context manager form, and to allow clients to both create and close the transport: .. code-block:: python from globus_sdk import SearchClient, UserApp with UserApp("sample-app", client_id="FILL_IN_HERE") as app: with SearchClient(app=app) as client: ... # any usage # after the context manager, any transport is implicitly closed However, if transports are created explicitly, they are not automatically closed, and the user becomes responsible for closing them. For example, in the following usage, the user must close the transport: .. code-block:: python from globus_sdk import SearchClient, UserApp from globus_sdk.transport import RequestsTransport my_transport = RequestsTransport(http_timeout=120.0) with UserApp("sample-app", client_id="FILL_IN_HERE") as app: with SearchClient(app=app, transport=my_transport) as client: ... # any usage # At this stage, the transport will still be open. # It should be explicitly closed: my_transport.close() .. note:: The SDK cannot tell whether or not an explicitly passed transport was bound to a name before it was passed. Therefore, in usage like the following, the transport will not automatically be closed: .. code-block:: python with SearchClient(app=app, transport=RequestsTransport()) as client: ... In order to close the transport in such a case, you must explicitly close it. Since transports are bound to ``client.transport``, the following usage would be a valid resolution: .. code-block:: python with SearchClient(app=app, transport=RequestsTransport()) as client: ... client.transport.close() Reference --------- BaseClient ^^^^^^^^^^ .. autoclass:: globus_sdk.BaseClient :members: scopes, resource_server, attach_globus_app, get, put, post, patch, delete, request :member-order: bysource globus-globus-sdk-python-6a080e4/docs/core/exceptions.rst000066400000000000000000000105361513221403200235160ustar00rootroot00000000000000Exceptions ========== All Globus SDK errors inherit from ``GlobusError``, and all SDK error classes are importable from ``globus_sdk``. You can therefore capture *all* errors thrown by the SDK by looking for ``GlobusError``, as in .. code-block:: python import logging from globus_sdk import TransferClient, GlobusError try: tc = TransferClient(...) # search with no parameters will throw an exception eps = tc.endpoint_search() except GlobusError: logging.exception("Globus Error!") raise In most cases, it's best to look for specific subclasses of ``GlobusError``. For example, to write code that distinguishes between network failures and unexpected API conditions, use ``NetworkError`` and ``GlobusAPIError`` .. code-block:: python import logging from globus_sdk import TransferClient, GlobusError, GlobusAPIError, NetworkError try: tc = TransferClient(...) eps = tc.endpoint_search(filter_fulltext="myendpointsearch") for ep in eps: print(ep["display_name"]) ... except GlobusAPIError as e: # Error response from the REST service, check the code and message for # details. logging.error( "Got a Globus API Error\n" f"Error Code: {e.code}\n" f"Error Message: {e.message}" ) raise except NetworkError: logging.error("Network Failure. Possibly a firewall or connectivity issue") raise except GlobusError: logging.exception("Totally unexpected GlobusError!") raise else: ... Of course, if you want to learn more information about the response, you should inspect it more than this. All errors raised by the SDK should be instances of ``GlobusError``. Malformed calls to Globus SDK methods typically raise ``GlobusSDKUsageError``, but, in rare cases, may raise standard python exceptions (``ValueError``, ``OSError``, etc.) Error Classes ------------- .. autoclass:: globus_sdk.GlobusError :members: :show-inheritance: .. autoclass:: globus_sdk.GlobusSDKUsageError :members: :show-inheritance: .. autoclass:: globus_sdk.ValidationError :members: :show-inheritance: .. autoclass:: globus_sdk.GlobusAPIError :members: :show-inheritance: .. autoclass:: globus_sdk.NetworkError :members: :show-inheritance: .. autoclass:: globus_sdk.GlobusConnectionError :members: :show-inheritance: .. autoclass:: globus_sdk.GlobusTimeoutError :members: :show-inheritance: .. autoclass:: globus_sdk.GlobusConnectionTimeoutError :members: :show-inheritance: .. _error_subdocuments: ErrorSubdocuments ----------------- Errors returned from APIs may define a series of subdocuments, each containing an error object. This is used if there were multiple errors encountered and the API wants to send them back all at once. All instances of ``GlobusAPIError`` define an attribute, ``errors``, which is an array of ``ErrorSubdocument``\s. Error handling code can inspect these sub-errors like so: .. code-block:: python try: some_complex_globus_operation() except GlobusAPIError as e: if e.errors: print("sub-errors encountered") print("(code, message)") for suberror in e.errors: print(f"({suberror.code}, {suberror.message}") .. autoclass:: globus_sdk.exc.ErrorSubdocument :members: .. _error_info: ErrorInfo --------- ``GlobusAPIError`` and its subclasses all support an ``info`` property which may contain parsed error data. The ``info`` is guaranteed to be there, but its attributes should be tested before use, as in .. code-block:: python # if 'err' is an API error, then 'err.info' is an 'ErrorInfoContainer', # a wrapper which holds ErrorInfo objects # 'err.info.consent_required' is a 'ConsentRequiredInfo', which should be # tested for truthy/falsey-ness before use if err.info.consent_required: print( "Got a ConsentRequired error with scopes:", err.info.consent_required.required_scopes, ) .. autoclass:: globus_sdk.exc.ErrorInfoContainer :members: .. autoclass:: globus_sdk.exc.ErrorInfo :members: .. autoclass:: globus_sdk.exc.AuthorizationParameterInfo :members: :show-inheritance: .. autoclass:: globus_sdk.exc.ConsentRequiredInfo :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/core/index.rst000066400000000000000000000003431513221403200224370ustar00rootroot00000000000000Globus SDK Core =============== Underlying components of the Globus SDK. .. toctree:: :maxdepth: 1 base_client transport responses paging exceptions warnings .. toctree:: :hidden: utils globus-globus-sdk-python-6a080e4/docs/core/paging.rst000066400000000000000000000145451513221403200226060ustar00rootroot00000000000000Paging and Paginators ===================== Globus SDK Client objects have paginated methods which return paginators. A paginated API is one which returns data in multiple API calls. This is used in cases where the the full set of results is too large to return all at once, or where getting all results is slow and a few results are wanted faster. A good example of paginated data would be search results: the first "page" of data may be the first 10 results, and the next "page" consists of the next 10 results. The number of results per call is the page size. Each page is an API response with a number of results equal to the page size. Paging in the Globus SDK can be done by iterating over pages (responses) or by iterating over items (individual results). Paginators ---------- A :py:class:`~globus_sdk.paging.Paginator` object is an iterable provided by the Globus SDK. Paginators support iteration over pages with the method ``pages()`` and iteration over items with the method ``items()``. Paginators have fixed parameters which are set when the paginator is created. Once a method returns a paginator, you don't need to pass it any additional data -- ``pages()`` or ``items()`` will operate based on the original parameters to the paginator. .. _making_paginated_calls: Making Paginated Calls ---------------------- Globus SDK client objects define paginated variants of methods. The normal method is said to be "unpaginated", and returns a single page of results. The paginated variant, prefixed with ``paginated.``, returns a paginated. For example, :class:`globus_sdk.TransferClient` has a paginated method, :py:meth:`~globus_sdk.TransferClient.endpoint_search`. Once you have a client object, calls to the unpaginated method are done like so: .. code-block:: python import globus_sdk # for information on getting an authorizer, see the SDK Tutorial tc = globus_sdk.TransferClient(authorizer=...) # unpaginated calls can still return iterable results! # endpoint_search() returns an iterable response for endpoint_info in tc.endpoint_search("tutorial"): print("got endpoint_id:", endpoint_info["id"]) The paginated variant of this same method is accessed nearly identically. But instead of calling ``endpoint_search(...)``, we'll invoke ``paginated.endpoint_search(...)``. Here are three variants of code with the same basic effect: .. code-block:: python # note the call to `items()` at the end of this line! for endpoint_info in tc.paginated.endpoint_search("tutorial").items(): print("got endpoint_id:", endpoint_info["id"]) # equivalently, call `pages()` and iterate over the items in each page for page in tc.paginated.endpoint_search("tutorial").pages(): for endpoint_info in page: print("got endpoint_id:", endpoint_info["id"]) # iterating on a paginator without calling `pages()` or `items()` is # equivalent to iterating on `pages()` for page in tc.paginated.endpoint_search("tutorial"): for endpoint_info in page: print("got endpoint_id:", endpoint_info["id"]) Do I need to use pages()? What is it for? ----------------------------------------- If your use-case is satisfied with ``items()``, then stick with ``items()``! ``pages()`` iteration is important when there is useful data in the page other than the individual items. For example, :meth:`TransferClient.endpoint_search ` returns the total number of results for the search as a field on each page. Most use-cases can be solved with ``items()``, and ``pages()`` will be available to you if or when you need it. Typed Paginators with Paginator.wrap ------------------------------------ This is an alternate syntax for getting a paginated call. It is more verbose, but preserves type annotation information correctly. It is therefore preferable for users who want to type-check their code with ``mypy``. ``Paginator.wrap`` converts any client method into a callable which returns a paginator. Its usage is very similar to the ``.paginated`` syntax. .. code-block:: python import globus_sdk from globus_sdk.paging import Paginator tc = globus_sdk.TransferClient(...) # convert `tc.endpoint_search` into a call returning a paginator paginated_call = Paginator.wrap(tc.endpoint_search) # now the result is a paginator and we can use `pages()` or `items()` as # normal for endpoint_info in paginated_call("tutorial").items(): print("got endpoint_id:", endpoint_info["id"]) However, if using ``mypy`` to run ``reveal_type``, the results of ``tc.paginated.task_successful_transfers`` and ``Paginator.wrap(tc.task_successful_transfers)`` are very different: .. code-block:: python # def (task_id: Union[uuid.UUID, builtins.str], *, query_params: Union[builtins.dict[builtins.str, Any], None] =) -> globus_sdk.services.transfer.response.iterable.IterableTransferResponse reveal_type(tc.task_successful_transfers) # def [PageT <: globus_sdk.response.GlobusHTTPResponse] (*Any, **Any) -> globus_sdk.paging.base.Paginator[PageT`-1] reveal_type(tc.paginated.task_successful_transfers) # def (task_id: Union[uuid.UUID, builtins.str], *, query_params: Union[builtins.dict[builtins.str, Any], None] =) -> globus_sdk.paging.base.Paginator[globus_sdk.services.transfer.response.iterable.IterableTransferResponse*] reveal_type(Paginator.wrap(tc.task_successful_transfers)) Paginator Types --------------- ``globus_sdk.paging`` defines several paginator classes and methods. For the most part, you do not need to interact with these classes or methods except through ``pages()`` or ``items()``. The ``paging`` subpackage also defines the ``PaginatorTable``, which is used to define the ``paginated`` attribute on client objects. .. autofunction:: globus_sdk.paging.has_paginator .. autoclass:: globus_sdk.paging.Paginator :members: :show-inheritance: .. autoclass:: globus_sdk.paging.PaginatorTable :members: :show-inheritance: .. autoclass:: globus_sdk.paging.MarkerPaginator :members: :show-inheritance: .. autoclass:: globus_sdk.paging.NextTokenPaginator :members: :show-inheritance: .. autoclass:: globus_sdk.paging.LastKeyPaginator :members: :show-inheritance: .. autoclass:: globus_sdk.paging.HasNextPaginator :members: :show-inheritance: .. autoclass:: globus_sdk.paging.LimitOffsetTotalPaginator :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/core/responses.rst000066400000000000000000000007601513221403200233540ustar00rootroot00000000000000Responses ========= Unless noted otherwise, all method return values for Globus SDK Clients are ``GlobusHTTPResponse`` objects. To customize client methods with additional detail, the SDK uses subclasses of ``GlobusHTTPResponse``. .. autoclass:: globus_sdk.response.GlobusHTTPResponse :members: :show-inheritance: .. autoclass:: globus_sdk.response.IterableResponse :members: :show-inheritance: .. autoclass:: globus_sdk.response.ArrayResponse :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/core/transport.rst000066400000000000000000000035041513221403200233660ustar00rootroot00000000000000Transport Layer =============== The transport consists of a transport object ( :class:`RequestsTransport `), but also tooling for handling retries. It is possible to either register custom retry check methods, or to override the Transport used by a client in order to customize this behavior. Transport ~~~~~~~~~ .. autoclass:: globus_sdk.transport.RequestsTransport :members: :member-order: bysource ``RequestsTransport`` objects include an attribute, ``globus_client_info`` which provides the ``X-Globus-Client-Info`` header which is sent to Globus services. It is an instance of ``GlobusClientInfo``: .. autoclass:: globus_sdk.transport.GlobusClientInfo :members: :member-order: bysource Retries ~~~~~~~ These are the components used by the ``RequestsTransport`` to implement retry logic. .. autoclass:: globus_sdk.transport.RetryContext :members: :member-order: bysource .. autoclass:: globus_sdk.transport.RetryCheckResult :members: :member-order: bysource .. data:: globus_sdk.transport.RetryCheck The type for a retry check, a callable which takes a ``RetryContext`` and returns a ``RetryCheckResult``. Equivalent to ``Callable[[globus_sdk.transport.RetryContext], globus_sdk.transport.RetryCheckResult]`` .. autoclass:: globus_sdk.transport.RetryCheckRunner :members: :member-order: bysource .. autoclass:: globus_sdk.transport.RetryCheckFlags :members: :member-order: bysource .. autodecorator:: globus_sdk.transport.set_retry_check_flags Data Encoders ~~~~~~~~~~~~~ .. autoclass:: globus_sdk.transport.RequestEncoder :members: :member-order: bysource .. autoclass:: globus_sdk.transport.JSONRequestEncoder :members: :member-order: bysource .. autoclass:: globus_sdk.transport.FormRequestEncoder :members: :member-order: bysource globus-globus-sdk-python-6a080e4/docs/core/utils.rst000066400000000000000000000015301513221403200224670ustar00rootroot00000000000000Utilities ========= .. warning:: These components are *not* intended for outside use, but are internal to the Globus SDK. They may change in backwards-incompatible ways in minor or patch releases of the SDK. This documentation is included here for completeness. MissingType and MISSING ----------------------- The ``MISSING`` sentinel value is used as an alternative to ``None`` in APIs which accept ``null`` as a valid value. Whenever ``MISSING`` is included in a request, it will be removed before the request is sent to the service. As a result, where ``MISSING`` is used as the default for a value, ``None`` can be used to explicitly pass the value ``null``. .. py:class:: globus_sdk.MissingType This is the type of ``MISSING``. .. py:data:: globus_sdk.MISSING The ``MISSING`` sentinel value. It is a singleton. globus-globus-sdk-python-6a080e4/docs/core/warnings.rst000066400000000000000000000062261513221403200231660ustar00rootroot00000000000000Warnings ======== The following warnings can be emitted by the Globus SDK to indicate a problem, or a future change, which is not necessarily an error. .. autoclass:: globus_sdk.RemovedInV5Warning :members: :show-inheritance: By default, Python will not display deprecation warnings to end users, but testing frameworks like pytest will enable deprecation warnings for developers. .. seealso:: * `Python's warnings module`_ * `pytest's warnings documentation`_ Enabling deprecation warnings ----------------------------- By default, Python ignores deprecation warnings, so end users of your application will not see warnings. However, you may want to enable deprecation warnings to help prepare for coming changes in the Globus SDK. Deprecation warnings can be enabled in several ways: #. The ``PYTHONWARNINGS`` environment variable #. The Python executable ``-W`` argument #. The Python ``warnings.filterwarnings()`` function .. rubric:: The ``PYTHONWARNINGS`` environment variable Deprecation warnings can be enabled using this shell syntax: .. code-block:: bash # POSIX shell example export PYTHONWARNINGS="error::DeprecationWarning" python ... # Inline example PYTHONWARNINGS="error::DeprecationWarning" python ... .. code-block:: pwsh # Powershell example $env:PYTHONWARNINGS="error::DeprecationWarning" python ... .. rubric:: The Python executable ``-W`` argument Deprecation warnings can be enabled using this Python executable argument: .. code-block:: text python -W "error::DeprecationWarning" ... .. rubric:: The Python ``warnings.filterwarnings()`` function Deprecation warnings can be enabled in Python code: .. code-block:: python import warnings warnings.filterwarnings("error", category=DeprecationWarning) Disabling deprecation warnings ------------------------------ Python testing frameworks like pytest enable deprecation warnings by default. Deprecation warnings can be disabled in several ways: #. The ``PYTHONWARNINGS`` environment variable #. The pytest executable ``-W`` argument #. The ``pytest.ini`` (or similar) file .. rubric:: The ``PYTHONWARNINGS`` environment variable You can disable deprecation warnings using environment variables: .. code-block:: bash # POSIX shell example export PYTHONWARNINGS="ignore::DeprecationWarning" pytest ... # Inline example PYTHONWARNINGS="ignore::DeprecationWarning" pytest ... .. code-block:: pwsh # Powershell example $env:PYTHONWARNINGS="ignore::DeprecationWarning" pytest ... .. rubric:: The pytest executable ``-W`` argument You can disable deprecation warnings using pytest's ``-W`` argument: .. code-block:: text pytest -W "ignore::DeprecationWarning" ... .. rubric:: The ``pytest.ini`` (or similar) file You can disable warnings using a pytest configuration file like ``pytest.ini``: .. code-block:: ini [pytest] filterwarnings = ignore::DeprecationWarning .. Links .. ----- .. _Python's warnings module: https://docs.python.org/3/library/warnings.html .. _pytest's warnings documentation: https://docs.pytest.org/en/latest/how-to/capture-warnings.html globus-globus-sdk-python-6a080e4/docs/examples/000077500000000000000000000000001513221403200214645ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/auth_manage_projects/000077500000000000000000000000001513221403200256465ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/auth_manage_projects/index.rst000066400000000000000000000037431513221403200275160ustar00rootroot00000000000000Manage Globus Auth Projects =========================== .. note:: The following scripts, when run, may leave tokens in a JSON file in your home directory. Be sure to delete these tokens after use. List Projects via the Auth API ------------------------------ The following is a very small and simple script using the Globus Auth Developer APIs. It uses the tutorial client ID from the :ref:`tutorials `. For simplicity, the script will prompt for login on each use. .. literalinclude:: list_projects.py :caption: ``list_projects.py`` [:download:`download `] :language: python List and Create Projects via the Auth API ----------------------------------------- The next example builds upon the earlier example by offering a pair of features, List and Create. Argument parsing allows for an action to be selected, which is then executed by calling the appropriate function. .. literalinclude:: list_and_create_projects.py :caption: ``list_and_create_projects.py`` [:download:`download `] :language: python List, Create, and Delete Projects via the Auth API -------------------------------------------------- .. warning:: The following script has destructive capabilities. Deleting projects may be harmful to your production applications. Only delete with care. The following example expands upon the former by adding delete functionality. Because Delete requires authentication under a session policy, the login code grows here to include a storage adapter (with data kept in ``~/.sdk-manage-projects.json``). If a policy failure is encountered, the code will prompt the user to login again to satisfy the policy and then reexecute the desired activity. As a result, this example is significantly more complex, but it still follows the same basic pattern as above. .. literalinclude:: manage_projects.py :caption: ``manage_projects.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/examples/auth_manage_projects/list_and_create_projects.py000066400000000000000000000042021513221403200332470ustar00rootroot00000000000000#!/usr/bin/env python import argparse import os import globus_sdk from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter( os.path.expanduser("~/.sdk-manage-projects.json") ) SCOPES = [globus_sdk.AuthClient.scopes.manage_projects, "openid", "email"] RESOURCE_SERVER = globus_sdk.AuthClient.resource_server # tutorial client ID # we recommend replacing this with your own client for any production use-cases CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" NATIVE_CLIENT = globus_sdk.NativeAppAuthClient(CLIENT_ID) def do_login_flow(): NATIVE_CLIENT.oauth2_start_flow(requested_scopes=SCOPES) authorize_url = NATIVE_CLIENT.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = NATIVE_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens.by_resource_server[RESOURCE_SERVER] def get_auth_client(): tokens = do_login_flow() return globus_sdk.AuthClient( authorizer=globus_sdk.AccessTokenAuthorizer(tokens["access_token"]) ) def create_project(args): auth_client = get_auth_client() userinfo = auth_client.userinfo() print( auth_client.create_project( args.name, contact_email=userinfo["email"], admin_ids=userinfo["sub"], ) ) def list_projects(): auth_client = get_auth_client() for project in auth_client.get_projects(): print(f"name: {project['display_name']}") print(f"id: {project['id']}") print() def main(): parser = argparse.ArgumentParser() parser.add_argument("action", choices=["create", "list"]) parser.add_argument("-n", "--name", help="Project name for create") args = parser.parse_args() execute(parser, args) def execute(parser, args): if args.action == "create": if args.name is None: parser.error("create requires --name") create_project(args) elif args.action == "list": list_projects() else: raise NotImplementedError() if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/auth_manage_projects/list_projects.py000066400000000000000000000022531513221403200311060ustar00rootroot00000000000000#!/usr/bin/env python import globus_sdk SCOPES = [globus_sdk.AuthClient.scopes.manage_projects, "openid", "email"] RESOURCE_SERVER = globus_sdk.AuthClient.resource_server # tutorial client ID # we recommend replacing this with your own client for any production use-cases CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" NATIVE_CLIENT = globus_sdk.NativeAppAuthClient(CLIENT_ID) def do_login_flow(): NATIVE_CLIENT.oauth2_start_flow(requested_scopes=SCOPES) authorize_url = NATIVE_CLIENT.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = NATIVE_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens.by_resource_server[RESOURCE_SERVER] def get_auth_client(): tokens = do_login_flow() return globus_sdk.AuthClient( authorizer=globus_sdk.AccessTokenAuthorizer(tokens["access_token"]) ) def main(): auth_client = get_auth_client() for project in auth_client.get_projects(): print(f"name: {project['display_name']}") print(f"id: {project['id']}") print() if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/auth_manage_projects/manage_projects.py000066400000000000000000000111341513221403200313610ustar00rootroot00000000000000#!/usr/bin/env python import argparse import os import globus_sdk from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter( os.path.expanduser("~/.sdk-manage-projects.json") ) SCOPES = [globus_sdk.AuthClient.scopes.manage_projects, "openid", "email"] RESOURCE_SERVER = globus_sdk.AuthClient.resource_server # tutorial client ID # we recommend replacing this with your own client for any production use-cases CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" NATIVE_CLIENT = globus_sdk.NativeAppAuthClient(CLIENT_ID) def do_login_flow(*, session_params: dict | None = None): NATIVE_CLIENT.oauth2_start_flow(requested_scopes=SCOPES) # special note! # this works because oauth2_get_authorize_url supports session error data # as parameters to build the authorization URL # you could do this manually with the following supported parameters: # - session_required_identities # - session_required_single_domain # - session_required_policies authorize_url = NATIVE_CLIENT.oauth2_get_authorize_url(**session_params) print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = NATIVE_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens def get_tokens(): if not MY_FILE_ADAPTER.file_exists(): # do a login flow, getting back initial tokens response = do_login_flow() # now store the tokens and pull out the correct token MY_FILE_ADAPTER.store(response) tokens = response.by_resource_server[RESOURCE_SERVER] else: # otherwise, we already did login; load the tokens from that file tokens = MY_FILE_ADAPTER.get_token_data(RESOURCE_SERVER) return tokens def get_auth_client(): tokens = get_tokens() return globus_sdk.AuthClient( authorizer=globus_sdk.AccessTokenAuthorizer(tokens["access_token"]) ) def create_project(args): auth_client = get_auth_client() userinfo = auth_client.userinfo() print( auth_client.create_project( args.name, contact_email=userinfo["email"], admin_ids=userinfo["sub"], ) ) def delete_project(args): auth_client = get_auth_client() print(auth_client.delete_project(args.project_id)) def list_projects(): auth_client = get_auth_client() for project in auth_client.get_projects(): print(f"name: {project['display_name']}") print(f"id: {project['id']}") print() def main(): parser = argparse.ArgumentParser() parser.add_argument("action", choices=["create", "delete", "list"]) parser.add_argument("-p", "--project-id", help="Project ID for delete") parser.add_argument("-n", "--name", help="Project name for create") args = parser.parse_args() try: execute(parser, args) except globus_sdk.GlobusAPIError as err: if not err.info.authorization_parameters: raise err_params = err.info.authorization_parameters session_params = {} if err_params.session_required_identities: print("session required identities detected") session_params["session_required_identities"] = ( err_params.session_required_identities ) if err_params.session_required_single_domain: print("session required single domain detected") session_params["session_required_single_domain"] = ( err_params.session_required_single_domain ) if err_params.session_required_policies: print("session required policies detected") session_params["session_required_policies"] = ( err_params.session_required_policies ) print(session_params) print(err_params) response = do_login_flow(session_params=session_params) # now store the tokens MY_FILE_ADAPTER.store(response) print( "Reauthenticated successfully to satisfy " "session requirements. Will now try again.\n" ) # try the action again execute(parser, args) def execute(parser, args): if args.action == "create": if args.name is None: parser.error("create requires --name") create_project(args) elif args.action == "delete": if args.project_id is None: parser.error("delete requires --project-id") delete_project(args) elif args.action == "list": list_projects() else: raise NotImplementedError() if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/authorization.rst000066400000000000000000000076541513221403200251320ustar00rootroot00000000000000.. _examples-authorization: API Authorization ----------------- Using a ``GlobusAuthorizer`` is hard to grasp without a few examples to reference. The basic usage should be to create these at client instantiation time. Access Token Authorization on AuthClient and TransferClient ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Perhaps you're in a part of your application that only sees Access Tokens. Access Tokens are used to directly authenticate calls against Globus APIs, and are limited-lifetime credentials. You have distinct Access Tokens for each Globus service which you want to access. With the tokens in hand, it's just a simple matter of wrapping the tokens in :class:`AccessTokenAuthorizer ` objects. .. code-block:: python from globus_sdk import AuthClient, TransferClient, AccessTokenAuthorizer AUTH_ACCESS_TOKEN = "..." TRANSFER_ACCESS_TOKEN = "..." # note that we don't provide the client ID in this case # if you're using an Access Token you can't do the OAuth2 flows auth_client = AuthClient(authorizer=AccessTokenAuthorizer(AUTH_ACCESS_TOKEN)) transfer_client = TransferClient( authorizer=AccessTokenAuthorizer(TRANSFER_ACCESS_TOKEN) ) Refresh Token Authorization on AuthClient and TransferClient ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Refresh Tokens are long-lived credentials used to get new Access Tokens whenever they expire. However, it would be very awkward to create a new client instance every time your credentials expire! Instead, use a :class:`RefreshTokenAuthorizer ` to automatically re-up your credentials whenever they near expiration. Re-upping credentials is an operation that requires having client credentials for Globus Auth, so creating the authorizer is more complex this time. .. code-block:: python from globus_sdk import ( AuthClient, TransferClient, ConfidentialAppAuthClient, RefreshTokenAuthorizer, ) # for doing the refresh CLIENT_ID = "..." CLIENT_SECRET = "..." # the actual tokens AUTH_REFRESH_TOKEN = "..." TRANSFER_REFRESH_TOKEN = "..." # making the authorizer requires that we have an AuthClient which can talk # OAuth2 to Globus Auth internal_auth_client = ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) # now let's bake a couple of authorizers auth_authorizer = RefreshTokenAuthorizer(AUTH_REFRESH_TOKEN, internal_auth_client) transfer_authorizer = RefreshTokenAuthorizer( TRANSFER_REFRESH_TOKEN, internal_auth_client ) # auth_client here is totally different from "internal_auth_client" above # the former is being used to request new tokens periodically, while this # one represents a user authenticated with those tokens auth_client = AuthClient(authorizer=auth_authorizer) # transfer_client doesn't have to contend with this duality -- it's always # representing a user transfer_client = TransferClient(authorizer=transfer_authorizer) Basic Auth on an AuthClient ~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you're using an :class:`AuthClient ` to do OAuth2 flows, you likely want to authenticate it using your client credentials -- the client ID and client secret. The preferred method is to use the ``AuthClient`` subclass which automatically specifies its authorizer. Internally, this will use a ``BasicAuthorizer`` to do Basic Authentication. By way of example: .. code-block:: python from globus_sdk import ConfidentialAppAuthClient CLIENT_ID = "..." CLIENT_SECRET = "..." client = ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) and you're off to the races! Under the hood, this is implicitly running .. code-block:: python AuthClient(authorizer=BasicAuthorizer(CLIENT_ID, CLIENT_SECRET)) but don't do this yourself -- ``ConfidentialAppAuthClient`` has different methods from the base ``AuthClient``. globus-globus-sdk-python-6a080e4/docs/examples/client_credentials.rst000066400000000000000000000103101513221403200260440ustar00rootroot00000000000000Client Credentials Authentication --------------------------------- This is an example of the use of the Globus SDK to carry out an OAuth2 Client Credentials Authentication flow. The goal here is to have an application authenticate in Globus Auth directly, as itself. Unlike many other OAuth2 flows, the application does not act on behalf of a user, but on its own behalf. This flow is suitable for automated cases in which an application, even one as simple as a ``cron`` job, makes use of Globus outside of the context of a specific end-user interaction. Get a Client ~~~~~~~~~~~~ In order to complete an OAuth2 flow to get tokens, you must have a client definition registered with Globus Auth. To do so, follow the relevant documentation for the `Globus Auth Service `_ or go directly to `developers.globus.org `_ to do the registration. During registration, make sure that the "Native App" checkbox is unchecked. Once your client is created, expand it on the Projects page and click "Generate Secret". Save the secret in a secure location accessible from your code. Do the Flow ~~~~~~~~~~~ You should specifically use the :class:`ConfidentialAppAuthClient ` type of ``AuthClient``, as it has been customized to handle this flow. The shortest version of the flow looks like this: .. code-block:: python import globus_sdk # you must have a client ID CLIENT_ID = "..." # the secret, loaded from wherever you store it CLIENT_SECRET = "..." confidential_client = globus_sdk.ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) token_response = confidential_client.oauth2_client_credentials_tokens( requested_scopes=globus_sdk.TransferClient.scopes.all ) # the useful values that you want at the end of this globus_transfer_data = token_response.by_resource_server[ globus_sdk.TransferClient.resource_server ] globus_transfer_token = globus_transfer_data["access_token"] Use the Resulting Tokens ~~~~~~~~~~~~~~~~~~~~~~~~ The Client Credentials Grant will only produce Access Tokens, not Refresh Tokens, so you should pass its results directly to the :class:`AccessTokenAuthorizer `. For example, after running the code above, .. code-block:: python authorizer = globus_sdk.AccessTokenAuthorizer(globus_transfer_token) tc = globus_sdk.TransferClient(authorizer=authorizer) print(f"Endpoints Belonging to {CLIENT_ID}@clients.auth.globus.org:") for ep in tc.endpoint_search(filter_scope="my-endpoints"): print(f"[{ep['id']}] {ep['display_name']}") Note that we're doing a search for "my endpoints", but we refer to the results as belonging to ``@clients.auth.globus.org``. The "current user" is not any human user, but the client itself. Handling Token Expiration ~~~~~~~~~~~~~~~~~~~~~~~~~ When you get access tokens, you also get their expiration time in seconds. You can inspect the ``globus_transfer_data`` structure in the example to see. Tokens should have a long enough lifetime for any short-running operations (less than a day). When your tokens are expired, you should just request new ones by making another Client Credentials request. Depending on your needs, you may need to track the expiration times along with your tokens. Using ClientCredentialsAuthorizer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The SDK also provides a specialized Authorizer which can be used to automatically handle token expiration. Use it like so: .. code-block:: python import globus_sdk # you must have a client ID and secret CLIENT_ID = "..." CLIENT_SECRET = "..." confidential_client = globus_sdk.ConfidentialAppAuthClient( client_id=CLIENT_ID, client_secret=CLIENT_SECRET ) cc_authorizer = globus_sdk.ClientCredentialsAuthorizer( confidential_client, globus_sdk.TransferClient.scopes.all ) # create a new client tc = globus_sdk.TransferClient(authorizer=cc_authorizer) # usage is still the same print(f"Endpoints Belonging to {CLIENT_ID}@clients.auth.globus.org:") for ep in tc.endpoint_search(filter_scope="my-endpoints"): print(f"[{ep['id']}] {ep['display_name']}") globus-globus-sdk-python-6a080e4/docs/examples/create_and_run_flow/000077500000000000000000000000001513221403200254645ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/create_and_run_flow/index.rst000066400000000000000000000036061513221403200273320ustar00rootroot00000000000000Create & Run a Flow =================== These examples guide you through creating and running a **flow** using the Globus Flows service. Note that users are restricted to only creating one **flow** unless covered by a Globus subscription. Therefore, these examples will also include an option to delete your **flow**. Create and Delete Hello World Flow ---------------------------------- This script provides commands to create, list, and delete your **flows**. The **flow** definition used for it is simple and baked into the script, but this demonstrates the minimal **flow** creation and deletion process. .. literalinclude:: manage_flow_minimal.py :caption: ``manage_flow_minimal.py`` [:download:`download `] :language: python Run a Flow ---------- This next example is distinct. It runs a flow but has no capability to create or delete a flow. Note how ``SpecificFlowClient`` is used -- this class allows users to access the flow-specific scope and provides the methods associated with that scope of running **flows** and resuming **runs**. The login code is slightly different from the previous example, as it has to key off of the Flow ID in order to act appropriately. .. literalinclude:: run_flow_minimal.py :caption: ``run_flow_minimal.py`` [:download:`download `] :language: python Create, Delete, and Run Flows ----------------------------- The following example combines the previous two. It has to further enhance the login code to account for the two different styles of login which it supports, but minimal other adjustments are needed. Depending on the operation chosen, either the ``FlowsClient`` or the ``SpecificFlowClient`` will be used, and the login flow will also be appropriately parametrized. .. literalinclude:: manage_flow.py :caption: ``manage_flow.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/examples/create_and_run_flow/manage_flow.py000066400000000000000000000111101513221403200303070ustar00rootroot00000000000000#!/usr/bin/env python import argparse import os import sys import globus_sdk from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter(os.path.expanduser("~/.sdk-manage-flow.json")) # tutorial client ID # we recommend replacing this with your own client for any production use-cases CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" NATIVE_CLIENT = globus_sdk.NativeAppAuthClient(CLIENT_ID) def do_login_flow(scope): NATIVE_CLIENT.oauth2_start_flow(requested_scopes=scope, refresh_tokens=True) authorize_url = NATIVE_CLIENT.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = NATIVE_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens def get_authorizer(flow_id=None): if flow_id: resource_server = flow_id scope = globus_sdk.SpecificFlowClient(flow_id).scopes.user else: resource_server = globus_sdk.FlowsClient.resource_server scope = globus_sdk.FlowsClient.scopes.manage_flows # try to load the tokens from the file, possibly returning None if MY_FILE_ADAPTER.file_exists(): tokens = MY_FILE_ADAPTER.get_token_data(resource_server) else: tokens = None if tokens is None: # do a login flow, getting back initial tokens response = do_login_flow(scope) # now store the tokens and pull out the correct token MY_FILE_ADAPTER.store(response) tokens = response.by_resource_server[resource_server] return globus_sdk.RefreshTokenAuthorizer( tokens["refresh_token"], NATIVE_CLIENT, access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=MY_FILE_ADAPTER.on_refresh, ) def get_flows_client(): return globus_sdk.FlowsClient(authorizer=get_authorizer()) def get_specific_flow_client(flow_id): authorizer = get_authorizer(flow_id) return globus_sdk.SpecificFlowClient(flow_id, authorizer=authorizer) def create_flow(args): flows_client = get_flows_client() print( flows_client.create_flow( title=args.title, definition={ "StartAt": "DoIt", "States": { "DoIt": { "Type": "Action", "ActionUrl": "https://actions.globus.org/hello_world", "Parameters": { "echo_string": "Hello, Asynchronous World!", }, "End": True, } }, }, input_schema={}, subtitle="A flow created by the SDK tutorial", ) ) def delete_flow(args): flows_client = get_flows_client() print(flows_client.delete_flow(args.flow_id)) def list_flows(): flows_client = get_flows_client() for flow in flows_client.list_flows(filter_roles="flow_owner"): print(f"title: {flow['title']}") print(f"id: {flow['id']}") print() def run_flow(args): flow_client = get_specific_flow_client(args.flow_id) print(flow_client.run_flow({})) def logout(): for tokendata in MY_FILE_ADAPTER.get_by_resource_server().values(): for tok_key in ("access_token", "refresh_token"): token = tokendata[tok_key] NATIVE_CLIENT.oauth2_revoke_token(token) os.remove(MY_FILE_ADAPTER.filename) def main(): parser = argparse.ArgumentParser() parser.add_argument("action", choices=["logout", "create", "delete", "list", "run"]) parser.add_argument("-f", "--flow-id", help="Flow ID for delete and run") parser.add_argument("-t", "--title", help="Name for create") args = parser.parse_args() try: if args.action == "logout": logout() elif args.action == "create": if args.title is None: parser.error("create requires --title") create_flow(args) elif args.action == "delete": if args.flow_id is None: parser.error("delete requires --flow-id") delete_flow(args) elif args.action == "list": list_flows() elif args.action == "run": if args.flow_id is None: parser.error("run requires --flow-id") run_flow(args) else: raise NotImplementedError() except globus_sdk.FlowsAPIError as e: print(f"API Error: {e.code} {e.message}") print(e.text) sys.exit(1) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/create_and_run_flow/manage_flow_minimal.py000066400000000000000000000072251513221403200320310ustar00rootroot00000000000000#!/usr/bin/env python import argparse import os import sys import globus_sdk from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter(os.path.expanduser("~/.sdk-manage-flow.json")) SCOPES = [globus_sdk.FlowsClient.scopes.manage_flows] RESOURCE_SERVER = globus_sdk.FlowsClient.resource_server # tutorial client ID # we recommend replacing this with your own client for any production use-cases CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" NATIVE_CLIENT = globus_sdk.NativeAppAuthClient(CLIENT_ID) def do_login_flow(): NATIVE_CLIENT.oauth2_start_flow(requested_scopes=SCOPES, refresh_tokens=True) authorize_url = NATIVE_CLIENT.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = NATIVE_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens def get_authorizer(): # try to load the tokens from the file, possibly returning None if MY_FILE_ADAPTER.file_exists(): tokens = MY_FILE_ADAPTER.get_token_data(RESOURCE_SERVER) else: tokens = None if tokens is None: # do a login flow, getting back initial tokens response = do_login_flow() # now store the tokens and pull out the correct token MY_FILE_ADAPTER.store(response) tokens = response.by_resource_server[RESOURCE_SERVER] return globus_sdk.RefreshTokenAuthorizer( tokens["refresh_token"], NATIVE_CLIENT, access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=MY_FILE_ADAPTER.on_refresh, ) def get_flows_client(): return globus_sdk.FlowsClient(authorizer=get_authorizer()) def create_flow(args): flows_client = get_flows_client() print( flows_client.create_flow( title=args.title, definition={ "StartAt": "DoIt", "States": { "DoIt": { "Type": "Action", "ActionUrl": "https://actions.globus.org/hello_world", "Parameters": { "echo_string": "Hello, Asynchronous World!", }, "End": True, } }, }, input_schema={}, subtitle="A flow created by the SDK tutorial", ) ) def delete_flow(args): flows_client = get_flows_client() print(flows_client.delete_flow(args.flow_id)) def list_flows(): flows_client = get_flows_client() for flow in flows_client.list_flows(filter_roles="flow_owner"): print(f"title: {flow['title']}") print(f"id: {flow['id']}") print() def main(): parser = argparse.ArgumentParser() parser.add_argument("action", choices=["create", "delete", "list"]) parser.add_argument("-f", "--flow-id", help="Flow ID for delete") parser.add_argument("-t", "--title", help="Name for create") args = parser.parse_args() try: if args.action == "create": if args.title is None: parser.error("create requires --title") create_flow(args) elif args.action == "delete": if args.flow_id is None: parser.error("delete requires --flow-id") delete_flow(args) elif args.action == "list": list_flows() else: raise NotImplementedError() except globus_sdk.FlowsAPIError as e: print(f"API Error: {e.code} {e.message}") print(e.text) sys.exit(1) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/create_and_run_flow/run_flow_minimal.py000066400000000000000000000043041513221403200314000ustar00rootroot00000000000000#!/usr/bin/env python import argparse import os import sys import globus_sdk from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter(os.path.expanduser("~/.sdk-manage-flow.json")) # tutorial client ID # we recommend replacing this with your own client for any production use-cases CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" NATIVE_CLIENT = globus_sdk.NativeAppAuthClient(CLIENT_ID) def do_login_flow(scope): NATIVE_CLIENT.oauth2_start_flow(requested_scopes=scope, refresh_tokens=True) authorize_url = NATIVE_CLIENT.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = NATIVE_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens def get_authorizer(flow_id): scopes = globus_sdk.SpecificFlowClient(flow_id).scopes # try to load the tokens from the file, possibly returning None if MY_FILE_ADAPTER.file_exists(): tokens = MY_FILE_ADAPTER.get_token_data(flow_id) else: tokens = None if tokens is None: # do a login flow, getting back initial tokens response = do_login_flow(scopes.user) # now store the tokens and pull out the correct token MY_FILE_ADAPTER.store(response) tokens = response.by_resource_server[flow_id] return globus_sdk.RefreshTokenAuthorizer( tokens["refresh_token"], NATIVE_CLIENT, access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=MY_FILE_ADAPTER.on_refresh, ) def get_flow_client(flow_id): authorizer = get_authorizer(flow_id) return globus_sdk.SpecificFlowClient(flow_id, authorizer=authorizer) def run_flow(args): flow_client = get_flow_client(args.FLOW_ID) print(flow_client.run_flow({})) def main(): parser = argparse.ArgumentParser() parser.add_argument("FLOW_ID", help="Flow ID to run") args = parser.parse_args() try: run_flow(args) except globus_sdk.FlowsAPIError as e: print(f"API Error: {e.code} {e.message}") print(e.text) sys.exit(1) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/endpoint_type_enum.rst000066400000000000000000000065531513221403200261340ustar00rootroot00000000000000Transfer Endpoint Type Enum --------------------------- If your application needs to support Globus Connect Server version 4 and version 5, Globus Connect Personal, and Shared Endpoints, you may need to determine what type of endpoint or collection you are handling. The `Globus documentation site `_ offers several references on this subject, including `a section on this exact subject matter `_ and Transfer API documentation `describing the different types of endpoints `_. In python, the logic for doing this is easily expressed in the form of an enum class for the different endpoint types, and a function which takes an Endpoint Document (from the Transfer service) and returns the matching enum member. The helper can be made a classmethod of the enum, encapsulating the logic in one class, all as follows: .. code-block:: python from enum import Enum, auto class TransferEndpointType(Enum): # Globus Connect Personal GCP = auto() # Globus Connect Server version 5 Endpoint GCSV5_ENDPOINT = auto() # GCSv5 collections GUEST_COLLECTION = auto() MAPPED_COLLECTION = auto() # a Shared Endpoint (also sometimes referred to as a Guest Collection # on Globus Connect Personal) SHARE = auto() # any other endpoint document is most likely GCSv4, but not necessarily # this technically includes legacy types of endpoints which are not GCSv4 # most applications can treat this case as meaning "GCSv4" # in fact, the "nice_name" method below does so! NON_GCSV5_ENDPOINT = auto() @classmethod def nice_name(cls, eptype: TransferEndpointType) -> str: return { cls.GCP: "Globus Connect Personal", cls.GCSV5_ENDPOINT: "Globus Connect Server v5 Endpoint", cls.GUEST_COLLECTION: "Guest Collection", cls.MAPPED_COLLECTION: "Mapped Collection", cls.SHARE: "Shared Endpoint", cls.NON_GCSV5_ENDPOINT: "GCSv4 Endpoint", }.get(eptype, "UNKNOWN") @classmethod def determine_endpoint_type(cls, ep_doc: dict) -> TransferEndpointType: """ Given an endpoint document from Transfer, determine what type of endpoint or collection it is """ if ep_doc.get("is_globus_connect") is True: return cls.GCP if ep_doc.get("non_functional") is True: return cls.GCSV5_ENDPOINT has_host = ep_doc.get("host_endpoint_id") is not None if ep_doc.get("gcs_version"): try: major, _minor, _patch = ep_doc["gcs_version"].split(".") except ValueError: # split -> unpack didn't give 3 values major = None gcsv5 = major == "5" else: gcsv5 = False if gcsv5: if has_host: return cls.GUEST_COLLECTION else: return cls.MAPPED_COLLECTION elif has_host: return cls.SHARE return cls.NON_GCSV5_ENDPOINT globus-globus-sdk-python-6a080e4/docs/examples/group_listing.rst000066400000000000000000000113371513221403200251100ustar00rootroot00000000000000.. _example_group_listing: Group Listing Script -------------------- This example script provides a CSV-formatted listing of the current user's groups. It uses the tutorial client ID from the :ref:`tutorials `. For simplicity, the script will prompt for login on each use. .. code-block:: python import globus_sdk from globus_sdk.scopes import GroupsScopes CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) auth_client.oauth2_start_flow( requested_scopes=GroupsScopes.view_my_groups_and_memberships ) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) groups_tokens = tokens.by_resource_server["groups.api.globus.org"] # construct an AccessTokenAuthorizer and use it to construct the # TransferClient groups_client = globus_sdk.GroupsClient( authorizer=globus_sdk.AccessTokenAuthorizer(groups_tokens["access_token"]) ) # print out in CSV format # note that 'name' could have a comma in it, so this is slightly unsafe print("ID,Name,Type,Session Enforcement,Roles") for group in groups_client.get_my_groups(): # parse the group to get data for output if group.get("enforce_session"): session_enforcement = "strict" else: session_enforcement = "not strict" roles = ",".join({m["role"] for m in group["my_memberships"]}) print( ",".join( [ group["id"], group["name"], group["group_type"], session_enforcement, roles, ] ) ) .. _example_group_listing_with_token_storage: Group Listing With Token Storage -------------------------------- ``globus_sdk.token_storage.legacy`` provides tools for managing refresh tokens. The following example script shows how you might use this to provide a complete script which lists the current user's groups using refresh tokens. .. code-block:: python import os from globus_sdk import GroupsClient, NativeAppAuthClient, RefreshTokenAuthorizer from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" AUTH_CLIENT = NativeAppAuthClient(CLIENT_ID) MY_FILE_ADAPTER = SimpleJSONFileAdapter( os.path.expanduser("~/.list-my-globus-groups-tokens.json") ) def do_login_flow(): AUTH_CLIENT.oauth2_start_flow( requested_scopes=GroupsClient.scopes.view_my_groups_and_memberships, refresh_tokens=True, ) authorize_url = AUTH_CLIENT.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = AUTH_CLIENT.oauth2_exchange_code_for_tokens(auth_code) return tokens if not MY_FILE_ADAPTER.file_exists(): # do a login flow, getting back initial tokens response = do_login_flow() # now store the tokens and pull out the Groups tokens MY_FILE_ADAPTER.store(response) tokens = response.by_resource_server[GroupsClient.resource_server] else: # otherwise, we already did login; load the tokens from that file tokens = MY_FILE_ADAPTER.get_token_data(GroupsClient.resource_server) # construct the RefreshTokenAuthorizer which writes back to storage on refresh authorizer = RefreshTokenAuthorizer( tokens["refresh_token"], AUTH_CLIENT, access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=MY_FILE_ADAPTER.on_refresh, ) # use that authorizer to authorize the activity of the groups client groups_client = GroupsClient(authorizer=authorizer) # print out in CSV format # note that 'name' could have a comma in it, so this is slightly unsafe print("ID,Name,Type,Session Enforcement,Roles") for group in groups_client.get_my_groups(): # parse the group to get data for output if group.get("enforce_session"): session_enforcement = "strict" else: session_enforcement = "not strict" roles = ",".join({m["role"] for m in group["my_memberships"]}) print( ",".join( [ group["id"], group["name"], group["group_type"], session_enforcement, roles, ] ) ) globus-globus-sdk-python-6a080e4/docs/examples/guest_collection_creation.rst000066400000000000000000000062751513221403200274560ustar00rootroot00000000000000.. _example_guest_collection_creation: Guest Collection Creation Script -------------------------------- The following is a script for a Globus client identity to create a GCSv5 guest collection on an existing mapped collection that it has a valid mapping for. The constants defined do not refer to a real GCSv5 installation, or client identity, so the script cannot be run as is. This script can be tweaked to use a human user identity instead of a client by changing the authorizer from a ClientCredentialsAuthorizer to an AccessTokenAuthorizer or RefreshTokenAuthorizer using a user token. The script assumes the mapped collection is on a storage gateway using the POSIX connector. Other connectors may need to have connector specific policy documents passed to create the user credential. .. code-block:: python import globus_sdk from globus_sdk import scopes # constants endpoint_hostname = "abc.xyz.data.globus.org" endpoint_id = "59544bb0-8aa3-4c73-9ce4-06d66887bc89" mapped_collection_id = "a1c2f515-254a-48a1-a5de-3ea51d783638" storage_gateway_id = "1b949deb-d608-403c-a226-a533892789c6" # client credentials # This client identity must have the needed permissions to create a guest # collection on the mapped collection, and a valid mapping to a local account # on the storage gateway that matches the local_username # If using user tokens, the user must be the one with the correct permissions # and identity mapping. client_id = "4de65cd7-4363-4510-b652-f8d15a43a0af" client_secret = "*redacted*" local_username = "local-username" # The scope the client will need, note that primary scope is for the endpoint, # but it has a dependency on the mapped collection's data_access scope scope = scopes.Scope(scopes.GCSEndpointScopeBuilder(endpoint_id).manage_collections) scope = scope.with_dependency( scopes.GCSCollectionScopeBuilder(mapped_collection_id).data_access ) # Build a GCSClient to act as the client by using a ClientCredentialsAuthorizor confidential_client = globus_sdk.ConfidentialAppAuthClient( client_id=client_id, client_secret=client_secret ) authorizer = globus_sdk.ClientCredentialsAuthorizer(confidential_client, scopes=scope) client = globus_sdk.GCSClient(endpoint_hostname, authorizer=authorizer) # The identity creating the guest collection must have a user credential on # the mapped collection. # Note that this call is connector specific. Most connectors will require # connector specific policies to be passed here, but POSIX does not. credential_document = globus_sdk.UserCredentialDocument( storage_gateway_id=storage_gateway_id, identity_id=client_id, username=local_username, ) client.create_user_credential(credential_document) # Create the collection collection_document = globus_sdk.GuestCollectionDocument( public="True", collection_base_path="/", display_name="guest_collection", mapped_collection_id=mapped_collection_id, ) response = client.create_collection(collection_document) guest_collection_id = response["id"] print(f"guest collection {guest_collection_id} created") globus-globus-sdk-python-6a080e4/docs/examples/index.rst000066400000000000000000000006731513221403200233330ustar00rootroot00000000000000.. _examples: Globus SDK Examples =================== Each of these pages contains an example of a piece of SDK functionality. .. toctree:: minimal_transfer_script/index auth_manage_projects/index create_and_run_flow/index token_storage/index group_listing authorization native_app client_credentials three_legged_oauth recursive_ls endpoint_type_enum timer_management/index guest_collection_creation globus-globus-sdk-python-6a080e4/docs/examples/minimal_transfer_script/000077500000000000000000000000001513221403200264025ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/minimal_transfer_script/index.rst000066400000000000000000000053151513221403200302470ustar00rootroot00000000000000.. _example_minimal_transfer: File Transfer Scripts ===================== Minimal File Transfer Script ---------------------------- The following is an extremely minimal script to demonstrate a file transfer using the :class:`TransferClient `. It uses the tutorial client ID from the :ref:`tutorials `. For simplicity, the script will prompt for login on each use. .. note:: You will need to replace the values for ``source_collection_id`` and ``dest_collection_id`` with UUIDs of collections that you have access to. .. literalinclude:: transfer_minimal.py :caption: ``transfer_minimal.py`` [:download:`download `] :language: python Minimal File Transfer Script Handling ConsentRequired ----------------------------------------------------- The above example works with certain endpoint types, but will fail if either the source or destination endpoint requires a ``data_access`` scope. This requirement will cause the Transfer submission to fail with a ``ConsentRequired`` error. The example below catches the ``ConsentRequired`` error and retries the submission after a second login. This kind of "reactive" handling of ``ConsentRequired`` is the simplest strategy to design and implement. We'll also enhance the example to take endpoint IDs from the command line. .. literalinclude:: transfer_consent_required_reactive.py :caption: ``transfer_consent_required_reactive.py`` [:download:`download `] :language: python Best-Effort Proactive Handling of ConsentRequired ------------------------------------------------- The above example works in most cases, and especially when there is a low cost to failing and retrying an activity. However, in some cases, responding to ``ConsentRequired`` errors when the task is submitted is not acceptable. For example, for scripts used in batch job systems, the user cannot respond to the error until the job is already executing. The user would rather handle such issues when submitting their job. ``ConsentRequired`` errors in this case can be avoided on a best-effort basis. Note, however, that the process for consenting ahead of time is more error prone and complex. The example below enhances the previous reactive error handling to try an ``ls`` operation before starting to build the task data. If the ``ls`` fails with ``ConsentRequired``, the user can be put through the relevant login flow. And if not, we can relatively safely assume that any errors are not relevant. .. literalinclude:: transfer_consent_required_proactive.py :caption: ``transfer_consent_required_proactive.py`` [:download:`download `] :language: python transfer_consent_required_proactive.py000066400000000000000000000071211513221403200362270ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/minimal_transfer_scriptimport argparse import globus_sdk from globus_sdk.scopes import TransferScopes parser = argparse.ArgumentParser() parser.add_argument("SRC") parser.add_argument("DST") args = parser.parse_args() CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) # we will need to do the login flow potentially twice, so define it as a # function # # we default to using the Transfer "all" scope, but it is settable here # look at the ConsentRequired handler below for how this is used def login_and_get_transfer_client(*, scopes=TransferScopes.all): # note that 'requested_scopes' can be a single scope or a list # this did not matter in previous examples but will be leveraged in # this one auth_client.oauth2_start_flow(requested_scopes=scopes) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) transfer_tokens = tokens.by_resource_server["transfer.api.globus.org"] # return the TransferClient object, as the result of doing a login return globus_sdk.TransferClient( authorizer=globus_sdk.AccessTokenAuthorizer(transfer_tokens["access_token"]) ) # get an initial client to try with, which requires a login flow transfer_client = login_and_get_transfer_client() # now, try an ls on the source and destination to see if ConsentRequired # errors are raised consent_required_scopes = [] def check_for_consent_required(target): try: transfer_client.operation_ls(target, path="/") # catch all errors and discard those other than ConsentRequired # e.g. ignore PermissionDenied errors as not relevant except globus_sdk.TransferAPIError as err: if err.info.consent_required: consent_required_scopes.extend(err.info.consent_required.required_scopes) check_for_consent_required(args.SRC) check_for_consent_required(args.DST) # the block above may or may not populate this list # but if it does, handle ConsentRequired with a new login if consent_required_scopes: print( "One of your endpoints requires consent in order to be used.\n" "You must login a second time to grant consents.\n\n" ) transfer_client = login_and_get_transfer_client(scopes=consent_required_scopes) # from this point onwards, the example is exactly the same as the reactive # case, including the behavior to retry on ConsentRequiredErrors. This is # not obvious, but there are cases in which it is necessary -- for example, # if a user consents at the start, but the process of building task_data is # slow, they could revoke their consent before the submission step # # in the common case, a single submission with no retry would suffice task_data = globus_sdk.TransferData( source_endpoint=args.SRC, destination_endpoint=args.DST ) task_data.add_item( "/share/godata/file1.txt", # source "/~/example-transfer-script-destination.txt", # dest ) def do_submit(client): task_doc = client.submit_transfer(task_data) task_id = task_doc["task_id"] print(f"submitted transfer, task_id={task_id}") try: do_submit(transfer_client) except globus_sdk.TransferAPIError as err: if not err.info.consent_required: raise print( "Encountered a ConsentRequired error.\n" "You must login a second time to grant consents.\n\n" ) transfer_client = login_and_get_transfer_client( scopes=err.info.consent_required.required_scopes ) do_submit(transfer_client) transfer_consent_required_reactive.py000066400000000000000000000050441513221403200360370ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/minimal_transfer_scriptimport argparse import globus_sdk from globus_sdk.scopes import TransferScopes parser = argparse.ArgumentParser() parser.add_argument("SRC") parser.add_argument("DST") args = parser.parse_args() CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) # we will need to do the login flow potentially twice, so define it as a # function # # we default to using the Transfer "all" scope, but it is settable here # look at the ConsentRequired handler below for how this is used def login_and_get_transfer_client(*, scopes=TransferScopes.all): auth_client.oauth2_start_flow(requested_scopes=scopes) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) transfer_tokens = tokens.by_resource_server["transfer.api.globus.org"] # return the TransferClient object, as the result of doing a login return globus_sdk.TransferClient( authorizer=globus_sdk.AccessTokenAuthorizer(transfer_tokens["access_token"]) ) # get an initial client to try with, which requires a login flow transfer_client = login_and_get_transfer_client() # create a Transfer task consisting of one or more items task_data = globus_sdk.TransferData( source_endpoint=args.SRC, destination_endpoint=args.DST ) task_data.add_item( "/share/godata/file1.txt", # source "/~/example-transfer-script-destination.txt", # dest ) # define the submission step -- we will use it twice below def do_submit(client): task_doc = client.submit_transfer(task_data) task_id = task_doc["task_id"] print(f"submitted transfer, task_id={task_id}") # try to submit the task # if it fails, catch the error... try: do_submit(transfer_client) except globus_sdk.TransferAPIError as err: # if the error is something other than consent_required, reraise it, # exiting the script with an error message if not err.info.consent_required: raise # we now know that the error is a ConsentRequired # print an explanatory message and do the login flow again print( "Encountered a ConsentRequired error.\n" "You must login a second time to grant consents.\n\n" ) transfer_client = login_and_get_transfer_client( scopes=err.info.consent_required.required_scopes ) # finally, try the submission a second time, this time with no error # handling do_submit(transfer_client) globus-globus-sdk-python-6a080e4/docs/examples/minimal_transfer_script/transfer_minimal.py000066400000000000000000000026731513221403200323160ustar00rootroot00000000000000import globus_sdk from globus_sdk.scopes import TransferScopes CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) # requested_scopes specifies a list of scopes to request # instead of the defaults, only request access to the Transfer API auth_client.oauth2_start_flow(requested_scopes=TransferScopes.all) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) transfer_tokens = tokens.by_resource_server["transfer.api.globus.org"] # construct an AccessTokenAuthorizer and use it to construct the # TransferClient transfer_client = globus_sdk.TransferClient( authorizer=globus_sdk.AccessTokenAuthorizer(transfer_tokens["access_token"]) ) # Replace these with your own collection UUIDs source_collection_id = "..." dest_collection_id = "..." # create a Transfer task consisting of one or more items task_data = globus_sdk.TransferData( source_endpoint=source_collection_id, destination_endpoint=dest_collection_id ) task_data.add_item( "/share/godata/file1.txt", # source "/~/minimal-example-transfer-script-destination.txt", # dest ) # submit, getting back the task ID task_doc = transfer_client.submit_transfer(task_data) task_id = task_doc["task_id"] print(f"submitted transfer, task_id={task_id}") globus-globus-sdk-python-6a080e4/docs/examples/native_app.rst000066400000000000000000000060031513221403200243430ustar00rootroot00000000000000.. _examples_native_app_login: Native App Login ---------------- This is an example of the use of the Globus SDK to carry out an OAuth2 Native App Authentication flow. The goal here is to have a user authenticate in Globus Auth, and for the SDK to procure tokens which may be used to authenticate SDK calls against various services for that user. Get a Client ~~~~~~~~~~~~ In order to complete an OAuth2 flow to get tokens, you must have a client definition registered with Globus Auth. To do so, follow the relevant documentation for the `Globus Auth Service `_ or go directly to `developers.globus.org `_ to do the registration. Make sure, when registering your application, that you enter ``https://auth.globus.org/v2/web/auth-code`` into the "Redirect URIs" field. This is necessary to leverage the default behavior of the SDK, and is typically sufficient for this type of application. Do the Flow ~~~~~~~~~~~ If you want to copy-paste an example, you'll need at least a ``client_id`` for your ``AuthClient`` object. You should also specifically use the :class:`NativeAppAuthClient ` type of ``AuthClient``, as it has been customized to handle this flow. The shortest version of the flow looks like this: .. code-block:: python import globus_sdk # you must have a client ID # for demonstration purposes, this is the tutorial client ID CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" client = globus_sdk.NativeAppAuthClient(CLIENT_ID) client.oauth2_start_flow() authorize_url = client.oauth2_get_authorize_url() print("Please go to this URL and login: {0}".format(authorize_url)) auth_code = input("Please enter the code you get after login here: ").strip() token_response = client.oauth2_exchange_code_for_tokens(auth_code) # the useful values that you want at the end of this globus_auth_data = token_response.by_resource_server["auth.globus.org"] globus_transfer_data = token_response.by_resource_server["transfer.api.globus.org"] globus_auth_token = globus_auth_data["access_token"] globus_transfer_token = globus_transfer_data["access_token"] With Refresh Tokens ~~~~~~~~~~~~~~~~~~~ The flow above will give you access tokens (short-lived credentials), good for one-off operations. However, if you want a persistent credential to access the logged-in user's Globus resources, you need to request a long-lived credential called a Refresh Token. ``refresh_tokens`` is a boolean option to the ``oauth2_start_flow`` method. When False, the flow will terminate with a collection of Access Tokens, which are simple limited lifetime credentials for accessing services. When True, the flow will terminate not only with the Access Tokens, but additionally with a set of Refresh Tokens which can be used **indefinitely** to request new Access Tokens. The default is False. Simply add this option to the example above: .. code-block:: python client.oauth2_start_flow(refresh_tokens=True) globus-globus-sdk-python-6a080e4/docs/examples/recursive_ls.rst000066400000000000000000000162051513221403200247270ustar00rootroot00000000000000Recursive ``ls`` via TransferClient ----------------------------------- The Globus Transfer API does not offer a recursive variant of the ``ls`` operation. There are several reasons for this, but most obviously: ``ls`` is synchronous, and a recursive listing may be very slow. This example demonstrates how to write a breadth-first traversal of a dir tree using a local deque to implement recursive ``ls``. You will need a properly authenticated :class:`TransferClient `. .. code-block:: python from collections import deque def _recursive_ls_helper(tc, ep, queue, max_depth): while queue: abs_path, rel_path, depth = queue.pop() path_prefix = rel_path + "/" if rel_path else "" res = tc.operation_ls(ep, path=abs_path) if depth < max_depth: queue.extend( ( res["path"] + item["name"], path_prefix + item["name"], depth + 1, ) for item in res["DATA"] if item["type"] == "dir" ) for item in res["DATA"]: item["name"] = path_prefix + item["name"] yield item # tc: a TransferClient # ep: an endpoint ID # path: the path to list recursively def recursive_ls(tc, ep, path, max_depth=3): queue = deque() queue.append((path, "", 0)) yield from _recursive_ls_helper(tc, ep, queue, max_depth) This acts as a generator function, which you can then use for iteration, or evaluate with ``list()`` or other expressions which will iterate over values from the generator. adding sleep ~~~~~~~~~~~~ One of the issues with the above recursive listing tooling is that it can easily run into rate limits on very large dir trees with a fast filesystem. To avoid issues, simply add a periodic sleep. For example, we could add a ``sleep_frequency`` and ``sleep_duration``, then count the number of ``ls`` calls that have been made. Every ``sleep_frequency`` calls, sleep for ``sleep_duration``. The modifications in the helper would be something like so: .. code-block:: python import time def _recursive_ls_helper(tc, ep, queue, max_depth, sleep_frequency, sleep_duration): call_count = 0 while queue: abs_path, rel_path, depth = queue.pop() path_prefix = rel_path + "/" if rel_path else "" res = tc.operation_ls(ep, path=abs_path) call_count += 1 if call_count % sleep_frequency == 0: time.sleep(sleep_duration) # as above ... parameter passthrough ~~~~~~~~~~~~~~~~~~~~~ What if you want to pass parameters to the ``ls`` calls? Accepting that some behaviors -- like order-by -- might not behave as expected if passed to the recursive calls, you can still do-so. Add ``ls_params``, a dictionary of additional parameters to pass to the underlying ``operation_ls`` invocations. The helper can assume that a dict is passed, and the wrapper would just initialize it as ``{}`` if nothing is passed. Something like so: .. code-block:: python def _recursive_ls_helper(tc, ep, queue, max_depth, ls_params): call_count = 0 while queue: abs_path, rel_path, depth = queue.pop() path_prefix = rel_path + "/" if rel_path else "" res = tc.operation_ls(ep, path=abs_path, **ls_params) # as above ... # importantly, the params should default to `None` and be rewritten to a # dict in the function body (parameter default bindings are modifiable) def recursive_ls(tc, ep, path, max_depth=3, ls_params=None): ls_params = ls_params or {} queue = deque() queue.append((path, "", 0)) yield from _recursive_ls_helper( tc, ep, queue, max_depth, sleep_frequency, sleep_duration, ls_params ) What if we want to have different parameters to the top-level ``ls`` call from any of the recursive calls? For example, maybe we want to filter the items found in the initial directory, but not in subdirectories. In that case, we just add on another layer: ``top_level_ls_params``, and we only use those parameters on the initial call. .. code-block:: python def _recursive_ls_helper( tc, ep, queue, max_depth, ls_params, top_level_ls_params, ): first_call = True while queue: abs_path, rel_path, depth = queue.pop() path_prefix = rel_path + "/" if rel_path else "" use_params = ls_params if first_call: # on modern pythons, dict expansion can be used to easily # combine dicts use_params = {**ls_params, **top_level_ls_params} first_call = False res = tc.operation_ls(ep, path=abs_path, **use_params) # again, the rest of the loop is the same ... def recursive_ls( tc, ep, path, max_depth=3, ls_params=None, top_level_ls_params=None, ): ls_params = ls_params or {} top_level_ls_params = top_level_ls_params or {} ... With Sleep and Parameter Passing ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ We can combine sleeps and parameter passing into one final, complete example: .. code-block:: python import time from collections import deque def _recursive_ls_helper( tc, ep, queue, max_depth, sleep_frequency, sleep_duration, ls_params, top_level_ls_params, ): call_count = 0 while queue: abs_path, rel_path, depth = queue.pop() path_prefix = rel_path + "/" if rel_path else "" use_params = ls_params if call_count == 0: use_params = {**ls_params, **top_level_ls_params} res = tc.operation_ls(ep, path=abs_path, **use_params) call_count += 1 if call_count % sleep_frequency == 0: time.sleep(sleep_duration) if depth < max_depth: queue.extend( ( res["path"] + item["name"], path_prefix + item["name"], depth + 1, ) for item in res["DATA"] if item["type"] == "dir" ) for item in res["DATA"]: item["name"] = path_prefix + item["name"] yield item def recursive_ls( tc, ep, path, max_depth=3, sleep_frequency=10, sleep_duration=0.5, ls_params=None, top_level_ls_params=None, ): ls_params = ls_params or {} top_level_ls_params = top_level_ls_params or {} queue = deque() queue.append((path, "", 0)) yield from _recursive_ls_helper( tc, ep, queue, max_depth, sleep_frequency, sleep_duration, ls_params, top_level_ls_params, ) globus-globus-sdk-python-6a080e4/docs/examples/three_legged_oauth.rst000066400000000000000000000146441513221403200260450ustar00rootroot00000000000000.. _examples_three_legged_oauth_login: Three Legged OAuth with Flask ----------------------------- This type of authorization is used for web login with a server-side application. For example, a Django app or other application server handles requests. This example uses Flask, but should be easily portable to other application frameworks. Components ~~~~~~~~~~ There are two components to this application: login and logout. Login sends a user to Globus Auth to get credentials, and then may act on the user's behalf. Logout invalidates server-side credentials, so that the application may no longer take actions for the user, and the client-side session, allowing for a fresh login if desired. Register an App ~~~~~~~~~~~~~~~ In order to complete an OAuth2 flow to get tokens, you must have a client definition registered with Globus Auth. To do so, follow the relevant documentation for the `Globus Auth Service `_ or go directly to `developers.globus.org `_ to do the registration. Make sure that the "Native App" checkbox is unchecked, and list ``http://localhost:5000/login`` in the "Redirect URIs". On the projects page, expand the client description and click "Generate Secret". Save the resulting secret a file named ``example_app.conf``, along with the client ID: .. code-block:: python SERVER_NAME = "localhost:5000" # this is the session secret, used to protect the Flask session. You should # use a longer secret string known only to your application # details are beyond the scope of this example SECRET_KEY = "abc123!" APP_CLIENT_ID = "" APP_CLIENT_SECRET = "" Shared Utilities ~~~~~~~~~~~~~~~~ Some pieces that are of use for both parts of this flow. First, you'll need to install ``Flask`` and the ``globus-sdk``. Assuming you want to do so into a fresh virtualenv: .. code-block:: bash $ virtualenv example-venv ... $ source example-venv/bin/activate $ pip install flask globus-sdk ... You'll also want a shared function for loading the SDK ``AuthClient`` which represents your application, as you'll need it in a couple of places. Create it, along with the definition for your Flask app, in ``example_app.py``: .. code-block:: python from flask import Flask, url_for, session, redirect, request import globus_sdk app = Flask(__name__) app.config.from_pyfile("example_app.conf") # actually run the app if this is called as a script if __name__ == "__main__": app.run() def load_app_client(): return globus_sdk.ConfidentialAppAuthClient( app.config["APP_CLIENT_ID"], app.config["APP_CLIENT_SECRET"] ) Login ~~~~~ Let's add login functionality to the end of ``example_app.py``, along with a basic index page: .. code-block:: python @app.route("/") def index(): """ This could be any page you like, rendered by Flask. For this simple example, it will either redirect you to login, or print a simple message. """ if not session.get("is_authenticated"): return redirect(url_for("login")) return "You are successfully logged in!" @app.route("/login") def login(): """ Login via Globus Auth. May be invoked in one of two scenarios: 1. Login is starting, no state in Globus Auth yet 2. Returning to application during login, already have short-lived code from Globus Auth to exchange for tokens, encoded in a query param """ # the redirect URI, as a complete URI (not relative path) redirect_uri = url_for("login", _external=True) client = load_app_client() client.oauth2_start_flow(redirect_uri) # If there's no "code" query string parameter, we're in this route # starting a Globus Auth login flow. # Redirect out to Globus Auth if "code" not in request.args: auth_uri = client.oauth2_get_authorize_url() return redirect(auth_uri) # If we do have a "code" param, we're coming back from Globus Auth # and can start the process of exchanging an auth code for a token. else: code = request.args.get("code") tokens = client.oauth2_exchange_code_for_tokens(code) # store the resulting tokens in the session session.update(tokens=tokens.by_resource_server, is_authenticated=True) return redirect(url_for("index")) Logout ~~~~~~ Logout is very simple -- it's just a matter of cleaning up the session. It does the added work of cleaning up any tokens you fetched by invalidating them in Globus Auth beforehand: .. code-block:: python @app.route("/logout") def logout(): """ - Revoke the tokens with Globus Auth. - Destroy the session state. - Redirect the user to the Globus Auth logout page. """ client = load_app_client() # Revoke the tokens with Globus Auth for token in ( token_info["access_token"] for token_info in session["tokens"].values() ): client.oauth2_revoke_token(token) # Destroy the session state session.clear() # the return redirection location to give to Globus AUth redirect_uri = url_for("index", _external=True) # build the logout URI with query params # there is no tool to help build this (yet!) globus_logout_url = ( "https://auth.globus.org/v2/web/logout" + "?client={}".format(app.config["PORTAL_CLIENT_ID"]) + "&redirect_uri={}".format(redirect_uri) + "&redirect_name=Globus Example App" ) # Redirect the user to the Globus Auth logout page return redirect(globus_logout_url) Using the Tokens ~~~~~~~~~~~~~~~~ Using the tokens thus acquired is a simple matter of pulling them out of the session and putting one into an ``AccessTokenAuthorizer``. For example, one might do the following: .. code-block:: python authorizer = globus_sdk.AccessTokenAuthorizer( session["tokens"]["transfer.api.globus.org"]["access_token"] ) transfer_client = globus_sdk.TransferClient(authorizer=authorizer) print("Endpoints belonging to the current logged-in user:") for ep in transfer_client.endpoint_search(filter_scope="my-endpoints"): print("[{}] {}".format(ep["id"], ep["display_name"])) globus-globus-sdk-python-6a080e4/docs/examples/timer_management/000077500000000000000000000000001513221403200250005ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/timer_management/create_timer.py000066400000000000000000000035631513221403200300240ustar00rootroot00000000000000#!/usr/bin/env python import argparse import datetime import globus_sdk from globus_sdk.experimental.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" USER_APP = UserApp("manage-timers-example", client_id=NATIVE_CLIENT_ID) def main(): parser = argparse.ArgumentParser() # the source, destination, and path to a file or dir to sync parser.add_argument("SOURCE_COLLECTION") parser.add_argument("DESTINATION_COLLECTION") parser.add_argument("PATH") parser.add_argument( "--interval-seconds", help="How frequently the timer runs, in seconds (default: 1 hour)", default=3600, type=int, ) parser.add_argument( "--days", help="How many days to run the timer (default: 2)", default=2, type=int, ) args = parser.parse_args() client = globus_sdk.TimersClient(app=USER_APP) body = globus_sdk.TransferData( source_endpoint=args.SOURCE_COLLECTION, destination_endpoint=args.DESTINATION_COLLECTION, ) body.add_item(args.PATH, args.PATH) # the timer will run until the end date, on whatever interval was requested schedule = globus_sdk.RecurringTimerSchedule( interval_seconds=args.interval_seconds, end={ "condition": "time", "datetime": datetime.datetime.now() + datetime.timedelta(days=args.days), }, ) timer = client.create_timer( timer=globus_sdk.TransferTimer( name=( "create-timer-example " f"[created at {datetime.datetime.now().isoformat()}]" ), body=body, schedule=schedule, ) ) print("Finished submitting timer.") print(f"timer_id: {timer['timer']['job_id']}") if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/timer_management/create_timer_data_access.py000066400000000000000000000055671513221403200323440ustar00rootroot00000000000000#!/usr/bin/env python import argparse import datetime import globus_sdk from globus_sdk.experimental.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" USER_APP = UserApp("manage-timers-example", client_id=NATIVE_CLIENT_ID) def uses_data_access(transfer_client, collection_id): doc = transfer_client.get_endpoint(collection_id) if doc["entity_type"] != "GCSv5_mapped_collection": return False if doc["high_assurance"]: return False return True def main(): parser = argparse.ArgumentParser() # the source, destination, and path to a file or dir to sync parser.add_argument("SOURCE_COLLECTION") parser.add_argument("DESTINATION_COLLECTION") parser.add_argument("PATH") parser.add_argument( "--interval-seconds", help="How frequently the timer runs, in seconds (default: 1 hour)", default=3600, type=int, ) parser.add_argument( "--days", help="How many days to run the timer (default: 2)", default=2, type=int, ) args = parser.parse_args() timers_client = globus_sdk.TimersClient(app=USER_APP) transfer_client = globus_sdk.TransferClient(app=USER_APP) # check if the source or destination use 'data_access' scopes # if so, register these requirements with the app if uses_data_access(transfer_client, args.SOURCE_COLLECTION): timers_client.add_app_transfer_data_access_scope(args.SOURCE_COLLECTION) if uses_data_access(transfer_client, args.DESTINATION_COLLECTION): timers_client.add_app_transfer_data_access_scope(args.DESTINATION_COLLECTION) # from this point onwards, the example is the same as the basic create_timer.py # script -- we've handled the nuance of data_access # # when the timer submission runs, you *may* be prompted to login again, if # 'data_access' requirements were detected body = globus_sdk.TransferData( source_endpoint=args.SOURCE_COLLECTION, destination_endpoint=args.DESTINATION_COLLECTION, ) body.add_item(args.PATH, args.PATH) # the timer will run until the end date, on whatever interval was requested schedule = globus_sdk.RecurringTimerSchedule( interval_seconds=args.interval_seconds, end={ "condition": "time", "datetime": datetime.datetime.now() + datetime.timedelta(days=args.days), }, ) timer = timers_client.create_timer( timer=globus_sdk.TransferTimer( name=( "create-timer-example " f"[created at {datetime.datetime.now().isoformat()}]" ), body=body, schedule=schedule, ) ) print("Finished submitting timer.") print(f"timer_id: {timer['timer']['job_id']}") if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/timer_management/delete_timer.py000066400000000000000000000011171513221403200300140ustar00rootroot00000000000000#!/usr/bin/env python import argparse import globus_sdk from globus_sdk.experimental.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" USER_APP = UserApp("manage-timers-example", client_id=NATIVE_CLIENT_ID) def main(): parser = argparse.ArgumentParser() parser.add_argument("TIMER_ID") args = parser.parse_args() client = globus_sdk.TimersClient(app=USER_APP) client.delete_job(args.TIMER_ID) print("Finished deleting timer.") if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/timer_management/index.rst000066400000000000000000000035141513221403200266440ustar00rootroot00000000000000.. _timer_management_examples: Timer Management ---------------- These examples demonstrate how to create, list, and delete timers with the SDK. Create a timer ~~~~~~~~~~~~~~ This script creates a new timer, on source and destination collections provided via the command-line. It syncs an input file or directory between the two. The script assumes that the path being synced is the same on the source and destination for simplicity. .. note:: This example does not handle ``data_access`` scope requirements. See the later example to handle this. .. literalinclude:: create_timer.py :caption: ``create_timer.py`` [:download:`download `] :language: python List timers ~~~~~~~~~~~ This script lists your current timers. .. literalinclude:: list_timers.py :caption: ``list_timers.py`` [:download:`download `] :language: python Delete a timer ~~~~~~~~~~~~~~ This script deletes a timer by ID. .. literalinclude:: delete_timer.py :caption: ``delete_timer.py`` [:download:`download `] :language: python Create a timer with ``data_access`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This script is similar to the ``create_timer.py`` example above. However, it also handles ``data_access`` scope requirements for the source and destination collections. Discovering ``data_access`` requirements requires the use of a ``TransferClient`` to look up the collections. As in the simpler example, this script creates a new timer, on source and destination collections provided via the command-line. It syncs an input file or directory between the two, and assumes that the path is the same on the source and destination. .. literalinclude:: create_timer_data_access.py :caption: ``create_timer_data_access.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/examples/timer_management/list_timers.py000066400000000000000000000011761513221403200277150ustar00rootroot00000000000000#!/usr/bin/env python import globus_sdk from globus_sdk.experimental.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" USER_APP = UserApp("manage-timers-example", client_id=NATIVE_CLIENT_ID) def main(): client = globus_sdk.TimersClient(app=USER_APP) first = True for record in client.list_jobs(query_params={"filter_active": True})["jobs"]: if not first: print("---") first = False print("name:", record["name"]) print("id:", record["job_id"]) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/docs/examples/timer_operations.rst000066400000000000000000000002041513221403200255750ustar00rootroot00000000000000:orphan: The documentation which was found on this page has moved to :ref:`Timer Management Examples `. globus-globus-sdk-python-6a080e4/docs/examples/token_storage/000077500000000000000000000000001513221403200243305ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/examples/token_storage/dynamodb_token_storage.py000066400000000000000000000145161513221403200314320ustar00rootroot00000000000000from __future__ import annotations import argparse import time import typing as t import boto3 import globus_sdk from globus_sdk.token_storage import StorageAdapter CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" tablename = "example-globus-tokenstorage" parser = argparse.ArgumentParser() parser.add_argument( "--create", action="store_true", ) boto_client = boto3.client("dynamodb") auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) class DynamoDBStorageAdapter(StorageAdapter): def __init__(self, client, tablename: str, namespace: str = "DEFAULT") -> None: """ :param client: A boto3 DyanmoDB client to use :param tablename: The name of the dynamodb table to use :param namespace: A namespace for all keys within this storage. Setting up explicit namespacing allows for multiple storage adapters for multiple users or applications to share a table. """ self.client = client self.tablename = tablename self.namespace = namespace def _compute_key(self, resource_server: str) -> str: """ Compute the 'token_data_id' used for storage and retrieval vis-a-vis a specific resource_server/namespace combination. This is defined as a simple delimited string which starts with the namespace given. Globus keys tokens by the ``resource_server`` string, but also has additional context about which user and application were being used. For the storage adapter, we will need to use namespacing to separate users. Consider setting ``namespace`` to a value like a user ID or a combination of user ID and authentication context. """ return f"{self.namespace}:{resource_server}" def store(self, token_response: globus_sdk.OAuthTokenResponse) -> None: for resource_server, token_data in token_response.by_resource_server.items(): key = self._compute_key(resource_server) dynamo_item = { "token_data_id": {"S": key}, "resource_server": {"S": resource_server}, "access_token": {"S": token_data["access_token"]}, "refresh_token": {"S": token_data["refresh_token"]}, "expires_at_seconds": {"N": str(token_data["expires_at_seconds"])}, "scope": {"S": token_data["scope"]}, } # avoid setting `refresh_token` if it is null (meaning the # login flow used access tokens only) if token_data["refresh_token"] is None: del dynamo_item["refresh_token"] self.client.put_item(TableName=self.tablename, Item=dynamo_item) def get_token_data(self, resource_server: str) -> dict[str, t.Any] | None: key = self._compute_key(resource_server) wrapped_item = self.client.get_item( TableName=self.tablename, Key={"token_data_id": {"S": key}}, ConsistentRead=True, ) if "Item" not in wrapped_item: return None dynamo_item = wrapped_item["Item"] return { "resource_server": dynamo_item["resource_server"]["S"], "access_token": dynamo_item["access_token"]["S"], "refresh_token": dynamo_item.get("refresh_token", {"S": None})["S"], "expires_at_seconds": int(dynamo_item["expires_at_seconds"]["N"]), "scope": dynamo_item["scope"]["S"], } def create_table(): # create a table with a key of "token_data_id" # this is a nonspecific string key which we will compute # # the relationship of "token_data_id" to the token will be explained below boto_client.create_table( TableName=tablename, KeySchema=[{"AttributeName": "token_data_id", "KeyType": "HASH"}], AttributeDefinitions=[{"AttributeName": "token_data_id", "AttributeType": "S"}], BillingMode="PROVISIONED", ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, ) print(f"'{tablename}' create in progress.") # poll status until the table is "ACTIVE" print("Waiting for active status (Ctrl+C to cancel)...", end="", flush=True) status = None while status != "ACTIVE": time.sleep(1) try: r = boto_client.describe_table(TableName=tablename) except boto_client.exceptions.ResourceNotFoundException: continue print(".", end="", flush=True) status = r["Table"]["TableStatus"] print("ok") def do_login_flow(storage: StorageAdapter): auth_client.oauth2_start_flow( requested_scopes=globus_sdk.GroupsClient.scopes.view_my_groups_and_memberships, refresh_tokens=True, ) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) storage.store(tokens) return tokens.by_resource_server[globus_sdk.GroupsClient.resource_server] def group_list(storage: StorageAdapter): tokens = storage.get_token_data(globus_sdk.GroupsClient.resource_server) if tokens is None: tokens = do_login_flow(storage) authorizer = globus_sdk.RefreshTokenAuthorizer( tokens["refresh_token"], auth_client, access_token=tokens["access_token"], expires_at=tokens["expires_at_seconds"], on_refresh=storage.store, ) groups_client = globus_sdk.GroupsClient(authorizer=authorizer) print("ID,Name,Type,Session Enforcement,Roles") for group in groups_client.get_my_groups(): # parse the group to get data for output if group.get("enforce_session"): session_enforcement = "strict" else: session_enforcement = "not strict" roles = ",".join({m["role"] for m in group["my_memberships"]}) print( ",".join( [ group["id"], group["name"], group["group_type"], session_enforcement, roles, ] ) ) if __name__ == "__main__": args = parser.parse_args() if args.create: create_table() else: storage = DynamoDBStorageAdapter(boto_client, tablename) group_list(storage) globus-globus-sdk-python-6a080e4/docs/examples/token_storage/index.rst000066400000000000000000000014771513221403200262020ustar00rootroot00000000000000.. _example_token_storage: Token Storage Adapters ====================== DynamoDB Token Storage ---------------------- The following example demonstrates a token storage adapter which uses AWS DynamoDB as the backend storage mechanism. Unlike the builtin adapters for JSON and sqlite, there is no capability here for an enumeration of all of the tokens in storage. This is because DynamoDB functions as a key-value store, and can efficiently map keys, but features slow sequential scans for enumeration. The example therefore demonstrates that key-value stores with limited or no capabilities for table scans can be used to implement the token storage interface. .. literalinclude:: dynamodb_token_storage.py :caption: ``dynamodb_token_storage.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/experimental/000077500000000000000000000000001513221403200223435ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/000077500000000000000000000000001513221403200241615ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/endpoints_and_collections/000077500000000000000000000000001513221403200314045ustar00rootroot00000000000000identifying_entity_type.rst000066400000000000000000000001011513221403200370150ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/endpoints_and_collections :orphan: Identifying Entity Type ======================= TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/flows/000077500000000000000000000000001513221403200253135ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/flows/create.rst000066400000000000000000000000471513221403200273110ustar00rootroot00000000000000 Creating a Flow =============== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/flows/delete.rst000066400000000000000000000000471513221403200273100ustar00rootroot00000000000000 Deleting a Flow =============== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/flows/index.rst000066400000000000000000000001361513221403200271540ustar00rootroot00000000000000 :orphan: Flows ===== .. toctree:: :maxdepth: 1 create list run delete globus-globus-sdk-python-6a080e4/docs/experimental/examples/flows/list.rst000066400000000000000000000000431513221403200270150ustar00rootroot00000000000000 Listing Flows ============= TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/flows/run.rst000066400000000000000000000000451513221403200266500ustar00rootroot00000000000000 Running a Flow ============== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/index.rst000066400000000000000000000010361513221403200260220ustar00rootroot00000000000000 Updated Examples ================ This experimental doc restructures the existing :ref:`examples` section by: * Grouping examples by common topic * Consolidating & updating examples into a more uniform active format * Updating references to leverage the latest SDK constructs (notably GlobusApp) While in ``experimental`` it should be considered a work in progress and subject to change. Once complete, it'll be merged into the main documentation, replacing the existing examples section. .. toctree:: :maxdepth: 2 oauth2/index globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/000077500000000000000000000000001513221403200253635ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/authorizers.rst000066400000000000000000000000651513221403200304750ustar00rootroot00000000000000 :orphan: Using Authorizers ================= TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/customizing_token_storage.rst000066400000000000000000000001051513221403200334100ustar00rootroot00000000000000 :orphan: Customizing Token Storage ========================= TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/globus_app.rst000066400000000000000000000172671513221403200302650ustar00rootroot00000000000000 .. _using_globus_app: .. py:currentmodule:: globus_sdk Using a GlobusApp ================= Programmatic communication with Globus services relies on the authorization of requests. Management and resolution of this authorization can become an arduous task, especially when a script needs to interact with different services each carrying an individual set of complex authentication and authorization requirements. To assist with this task, this library provides a utility construct called a GlobusApp. A :py:class:`~GlobusApp` is a distinct object which will manage Globus Auth requirements (i.e., **scopes**) identified by their associated service (i.e., **resource server**). In addition to storing them, a GlobusApp provides a mechanism to resolve unmet requirements through browser- and API-based authorization flows, supplying the resulting tokens to bound clients as requested. There are two flavors of GlobusApp: * :py:class:`~UserApp`, a GlobusApp for interactions between an end user and Globus services. Operations are performed as a *user identity*. * :py:class:`~ClientApp`, a GlobusApp for interactions between a client (i.e. service account) and Globus services. Operations are performed as a *client identity*. Setup ----- A GlobusApp is a heavily configurable object. For common scripting usage however, instantiation only requires two parameters: #. **App Name** - A human readable name to identify your app in HTTP requests and token caching (e.g., "My Cool Weathervane"). #. **Client Info** - either a *Native Client's* ID or a *Confidential Client's* ID and secret pair. * There are important distinctions to consider when choosing your client type; see `Developing an Application Using Globus Auth `_. A simplified heuristic to help choose the client type however is: * Use a *Confidential Client* when your client needs to own cloud resources itself and will be used in a trusted environment where you can securely hold a secret. * Use a *Native Client* when your client will be facilitating interactions between a user and a service, particularly if it is bundled within a script or cli tool to be distributed to end-users' machines. .. Note:: Both UserApps and ClientApps require a client. In a UserApp, the client sends requests representing a user identity; whereas in a ClientApp, the requests represent the client identity itself. Once instantiated, a GlobusApp can be passed to any service client using the init ``app`` keyword argument (e.g. ``TransferClient(app=my_app)``). Doing this will bind the app to the client, registering a default set of scopes requirements for the service client's resource server and configuring the app as the service client's auth provider. .. tab-set:: .. tab-item:: UserApp Construct a UserApp then bind it to a Transfer client and a Flows client. .. Note:: ``UserApp.__init__(...)`` currently only supports Native clients. Confidential client support is forthcoming. .. code-block:: python import globus_sdk CLIENT_ID = "..." my_app = globus_sdk.UserApp("my-user-app", client_id=CLIENT_ID) transfer_client = globus_sdk.TransferClient(app=my_app) flows_client = globus_sdk.FlowsClient(app=my_app) .. tab-item:: ClientApp Construct a ClientApp, then bind it to a Transfer client and a Flows client. .. Note:: ``ClientApp.__init__(...)`` requires the ``client_secret`` keyword argument. Native clients, which lack secrets, are not allowed. .. code-block:: python import globus_sdk CLIENT_ID = "..." CLIENT_SECRET = "..." my_app = globus_sdk.ClientApp( "my-client-app", client_id=CLIENT_ID, client_secret=CLIENT_SECRET ) transfer_client = globus_sdk.TransferClient(app=my_app) flows_client = globus_sdk.FlowsClient(app=my_app) Usage ----- From this point, the app manages scope validation, token caching and routing for any bound clients. In the above example, listing a client's or user's flows becomes as simple as: .. code-block:: python flows = flows_client.list_flows()["flows"] If cached tokens are missing, expired, or otherwise insufficient (e.g., the first time you run the script), the app will automatically initiate an auth flow to acquire new tokens. With a UserApp, the app will print a URL to the terminal with a prompt instructing a the user to follow the link and enter the code they're given back into the terminal. With a ClientApp, the app will retrieve tokens programmatically through a Globus Auth API. Once this auth flow has finished, the app will cache tokens for future use and invocation of your requested method will proceed as expected. Manually Running Login Flows ---------------------------- While your app will automatically initiate and oversee login flows when needed, sometimes an author may want to explicitly control when an authorization occurs. To manually trigger a login flow, call ``GlobusApp.login(...)``. The app will evaluate the current scope requirements against available tokens, initiating a login flow if it determines that any requirements across any resource servers are unmet. Resulting tokens will be cached for future use. This method accepts two optional keyword args: - ``auth_params``, a collection of additional auth parameters to customize the login. This allows for specifications such as requiring that a user be logged in with an MFA token or rendering the authorization webpage with a specific message. - ``force``, a boolean flag instructing the app to perform a login flow regardless of whether it is required. .. code-block:: python from globus_sdk.gare import GlobusAuthorizationParameters ... my_app.login( auth_params=GlobusAuthorizationParameters( session_message="Please authenticate with MFA", session_required_mfa=True, ) ) Manually Defining Scope Requirements ------------------------------------ Globus service client classes all maintain an internal list of default scope requirements to be attached to any bound app. These scopes represent an approximation of a "standard set" for each service. This list however is not sufficient for all use cases. For example, the FlowsClient defines its default scopes as ``flows:view_flows`` and ``flows:run_status`` (read-only access). These scopes will not be sufficient for a script which needs to create new flows or modify existing ones. For that script, the author must manually attach the ``flows:manage_flows`` scope to the app. This can be done in one of two ways: #. Through a service client initialization, using the ``app_scopes`` kwarg. .. code-block:: python from globus_sdk import Scope, FlowsClient FlowsClient(app=my_app, app_scopes=[Scope(FlowsClient.scopes.manage_flows)]) This approach results in an app which only requires the ``flows:manage_flows`` scope. The default scopes (``flows:view_flows`` and ``flows:run_status``) are not registered. #. Through a service client's ``add_app_scope`` method. .. code-block:: python from globus_sdk import FlowsClient flows_client = FlowsClient(app=my_app) flows_client.add_app_scope(FlowsClient.scopes.manage_flows) This approach will add the ``flows:manage_flows`` scope to the app's existing set of scopes. Since ``app_scopes`` was omitted in the client initialization, the default scopes are registered as well. globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/index.rst000066400000000000000000000001221513221403200272170ustar00rootroot00000000000000 OAuth2 at Globus ================ .. toctree:: :maxdepth: 1 globus_app globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/login_flows.rst000066400000000000000000000000711513221403200304350ustar00rootroot00000000000000 :orphan: Running Login Flows =================== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/oauth2/three_legged_oauth.rst000066400000000000000000000001411513221403200317270ustar00rootroot00000000000000 :orphan: Performing Three-Legged OAuth in Flask ======================================== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/projects/000077500000000000000000000000001513221403200260125ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/projects/create.rst000066400000000000000000000000551513221403200300070ustar00rootroot00000000000000 Creating a Project ================== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/projects/delete.rst000066400000000000000000000000551513221403200300060ustar00rootroot00000000000000 Deleting a Project ================== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/projects/index.rst000066400000000000000000000001461513221403200276540ustar00rootroot00000000000000 :orphan: Auth Projects ============= .. toctree:: :maxdepth: 1 create list delete globus-globus-sdk-python-6a080e4/docs/experimental/examples/projects/list.rst000066400000000000000000000000511513221403200275130ustar00rootroot00000000000000 Listing Projects ================ TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/transferring_data/000077500000000000000000000000001513221403200276565ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/transferring_data/recursive_ls.rst000066400000000000000000000001231513221403200331110ustar00rootroot00000000000000 :orphan: Recursively Listing a Filesystem ================================ TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/transferring_data/schedule_transfer/000077500000000000000000000000001513221403200333565ustar00rootroot00000000000000index.rst000066400000000000000000000000751513221403200351420ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/experimental/examples/transferring_data/schedule_transfer :orphan: Scheduling a Transfer ===================== TODO globus-globus-sdk-python-6a080e4/docs/experimental/examples/transferring_data/task_deadlines.rst000066400000000000000000000000771513221403200333660ustar00rootroot00000000000000 :orphan: Setting Task Deadlines ====================== TODO globus-globus-sdk-python-6a080e4/docs/experimental/index.rst000066400000000000000000000017601513221403200242100ustar00rootroot00000000000000.. experimental_root: Globus SDK Experimental Components ================================== .. warning:: The ``experimental`` module contains new "unstable" interfaces. These interfaces are subject to breaking changes. **Use at your own risk.** Experimental Construct Lifecycle -------------------------------- A construct is added in the ``experimental`` module when we, the maintainers, are not sufficiently confident that it represents the best possible interface for the underlying functionality. This frequently occurs when we have a design which shows promise but would like to solicit feedback from the community before committing to it. Once an interface has been evaluated and proven to be sufficiently coherent, we will "stabilize" it, moving it to an appropriate section in the main module and leaving behind an alias in the requisite experimental module to minimize import breakage. These aliases will persist until the next major version release of the SDK (e.g., v3 -> v4). globus-globus-sdk-python-6a080e4/docs/index.rst000066400000000000000000000026041513221403200215110ustar00rootroot00000000000000Globus SDK for Python ===================== .. module:: globus_sdk This SDK provides a convenient Pythonic interface to `Globus `_ web APIs, including the Transfer API and the Globus Auth API. Documentation for the APIs is available at https://docs.globus.org/api/. Two interfaces are provided - a low level interface, supporting only ``GET``, ``PUT``, ``POST``, and ``DELETE`` operations, and a high level interface providing helper methods for common API resources. Additionally, some tools for interacting with local endpoint definitions are provided. Source code is available at https://github.com/globus/globus-sdk-python. Table of Contents ----------------- .. toctree:: :caption: User Guide :maxdepth: 1 user_guide/installation user_guide/getting_started/index user_guide/usage_patterns/index .. toctree:: :caption: Reference :maxdepth: 2 services/index local_endpoints Authorization config core/index .. toctree:: :caption: Unstable :maxdepth: 1 testing/index Experimental Components experimental/examples/index .. toctree:: :caption: Examples :maxdepth: 1 examples/index .. toctree:: :caption: Changes :maxdepth: 1 versioning changelog upgrading .. toctree:: :caption: Additional Info :maxdepth: 1 license globus-globus-sdk-python-6a080e4/docs/license.rst000066400000000000000000000253531513221403200220320ustar00rootroot00000000000000Apache License ############## Version 2.0, January 2004 http://www.apache.org/licenses/ ---- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ---- APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. .. code-block:: text Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. globus-globus-sdk-python-6a080e4/docs/local_endpoints.rst000066400000000000000000000014761513221403200235650ustar00rootroot00000000000000.. _local_endpoints: Local Endpoints =============== .. currentmodule:: globus_sdk Unlike SDK functionality for accessing Globus APIs, the locally available Globus Endpoints require special treatment. These accesses are not authenticated via Globus Auth, and may rely upon the state of the local filesystem, running processes, and the permissions of local users. Globus Connect Server --------------------- .. autoclass:: LocalGlobusConnectServer :members: :member-order: bysource Globus Connect Personal ----------------------- Globus Connect Personal endpoints belonging to the current user may be accessed via instances of the following class: .. autoclass:: LocalGlobusConnectPersonal :members: :member-order: bysource .. autoclass:: GlobusConnectPersonalOwnerInfo :members: :member-order: bysource globus-globus-sdk-python-6a080e4/docs/scopes.rst000066400000000000000000000001361513221403200216740ustar00rootroot00000000000000:orphan: The documentation which was found on this page has moved to :ref:`Scopes `. globus-globus-sdk-python-6a080e4/docs/services/000077500000000000000000000000001513221403200214715ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/services/auth.rst000066400000000000000000000061151513221403200231670ustar00rootroot00000000000000Globus Auth =========== .. currentmodule:: globus_sdk There are several types of client object for communicating with the Globus Auth service. A client object may represent your application (as the driver of authentication and authorization flows), in which case the :class:`NativeAppAuthClient` or :class:`ConfidentialAppAuthClient` classes should generally be used. Client Classes -------------- .. autoclass:: AuthClient :members: :member-order: bysource :show-inheritance: .. autoclass:: AuthLoginClient :members: :member-order: bysource :show-inheritance: .. autoclass:: NativeAppAuthClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class .. autoclass:: ConfidentialAppAuthClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Helper Objects -------------- The :class:`IdentityMap` is a specialized object which aids in the particular use-case in which the Globus Auth :meth:`AuthClient.get_identities` API is being used to resolve large numbers of usernames or IDs. It combines caching, request batching, and other functionality. .. We set special-members so that __getitem__ and __delitem__ are included. But then we need to exclude specific members because we don't want people reading about __weakref__ in our docs. .. autoclass:: IdentityMap :members: :special-members: :exclude-members: __dict__,__weakref__ :show-inheritance: .. autoclass:: IDTokenDecoder :show-inheritance: .. autoclass:: DependentScopeSpec Auth Responses -------------- .. autoclass:: OAuthTokenResponse :members: :show-inheritance: .. autoclass:: OAuthAuthorizationCodeResponse :members: :show-inheritance: .. autoclass:: OAuthRefreshTokenResponse :members: :show-inheritance: .. autoclass:: OAuthClientCredentialsResponse :members: :show-inheritance: .. autoclass:: OAuthDependentTokenResponse :members: :show-inheritance: .. autoclass:: GetConsentsResponse :members: :show-inheritance: .. autoclass:: GetIdentitiesResponse :members: :show-inheritance: Errors ------ .. autoexception:: AuthAPIError OAuth2 Flow Managers -------------------- These objects represent in-progress OAuth2 authentication flows. Most typically, you should not use these objects directly, but rather rely on the :class:`NativeAppAuthClient` or :class:`ConfidentialAppAuthClient` objects to manage these for you through their ``oauth2_*`` methods. All Flow Managers inherit from the :class:`GlobusOAuthFlowManager \ <.GlobusOAuthFlowManager>` abstract class. They are a combination of a store for OAuth2 parameters specific to the authentication method you are using and methods which act upon those parameters. .. autoclass:: globus_sdk.services.auth.GlobusNativeAppFlowManager :members: :show-inheritance: .. autoclass:: globus_sdk.services.auth.GlobusAuthorizationCodeFlowManager :members: :show-inheritance: Abstract Flow Manager ~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: globus_sdk.services.auth.flow_managers.GlobusOAuthFlowManager :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/compute.rst000066400000000000000000000027061513221403200237040ustar00rootroot00000000000000Globus Compute ============== .. currentmodule:: globus_sdk The standard way to interact with the Globus Compute service is through the `Globus Compute SDK `_, a separate, specialized toolkit that offers enhanced functionality for Globus Compute. Under the hood, the Globus Compute SDK uses the following clients to interact with the Globus Compute API. Advanced users may choose to work directly with these clients for custom implementations. There are two client classes, supporting versions 2 and 3 of the Globus Compute API. Where feasible, new projects should use :class:`ComputeClientV3`, which supports the latest API features and improvements. .. autoclass:: ComputeClientV2 :members: :member-order: bysource :show-inheritance: :exclude-members: error_class, scopes .. attribute:: scopes .. listknownscopes:: globus_sdk.scopes.ComputeScopes :base_name: ComputeClientV2.scopes .. autoclass:: ComputeClientV3 :members: :member-order: bysource :show-inheritance: :exclude-members: error_class, scopes .. attribute:: scopes .. listknownscopes:: globus_sdk.scopes.ComputeScopes :base_name: ComputeClientV3.scopes Client Errors ------------- When an API error occurs, :class:`ComputeClientV2` and :class:`ComputeClientV3` will raise ``ComputeAPIError``. .. autoclass:: ComputeAPIError :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/flows.rst000066400000000000000000000051551513221403200233630ustar00rootroot00000000000000Globus Flows ============= .. currentmodule:: globus_sdk The Flows service allows users to create automation workflows (or, simply, "flows"). When a flow is started, it must be authorized to perform actions on the user's behalf. Because a running flow (or, simply, a "run") can perform actions on the user's behalf, the Globus SDK has two client classes that can interact with the Flows service: a :class:`FlowsClient` and a :class:`SpecificFlowClient`. They differ in what operations they can perform and, as a result, what scopes they require: * :class:`FlowsClient` is able to create, update, and delete flows. It is also able to retrieve information about flows and runs. Users must consent to allow the client application to administer flows and runs. See :class:`FlowsClient.scopes` for a complete list of scopes. * :class:`SpecificFlowClient` must be instantiated with a specific flow ID so it can construct the scope associated with the flow. It is then able to start that specific flow. If a run associated with the flow becomes inactive for any reason, it is able to resume the run, too. Users must consent to allow the specific flow to perform actions on their behalf. The specific flow scope can be accessed via ``SpecificFlowClient.scopes.user`` after the :class:`SpecificFlowClient` has been instantiated. Applications that create and then start a flow would therefore need to use both classes. .. autoclass:: FlowsClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class, scopes .. attribute:: scopes .. listknownscopes:: globus_sdk.scopes.FlowsScopes :base_name: FlowsClient.scopes .. autoclass:: SpecificFlowClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Client Errors ------------- When an error occurs, a :class:`FlowsClient` will raise a ``FlowsAPIError``. .. autoclass:: FlowsAPIError :members: :show-inheritance: Helper Objects -------------- ``RunActivityNotificationPolicy`` objects encode the data for ``activity_notification_policy`` when ``run_flow`` is called. Using the helper object allows you to have typing-time validation of your data when calling this API. .. autoclass:: RunActivityNotificationPolicy :members: :show-inheritance: Responses --------- .. autoclass:: IterableFlowsResponse :members: :show-inheritance: .. autoclass:: globus_sdk.services.flows.IterableRunsResponse :members: :show-inheritance: .. autoclass:: globus_sdk.services.flows.IterableRunLogsResponse :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/gcs.rst000066400000000000000000000062651513221403200230100ustar00rootroot00000000000000Globus Connect Server API ========================= .. currentmodule:: globus_sdk The Globus Connect Server Manager API (GCS Manager API) runs on a Globus Connect Server Endpoint and allows management of the Endpoint, Storage Gateways, Collections, and other resources. Unlike other Globus services, there is no single central API used to contact GCS Manager instances. Therefore, the :class:`GCSClient` is always initialized with the FQDN (DNS name) of the GCS Endpoint. e.g. ``gcs = GCSClient("abc.def.data.globus.org")`` Client ------ The primary interface for the GCS Manager API is the :class:`GCSClient` class. .. autoclass:: GCSClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Helper Objects -------------- .. autoclass:: EndpointDocument :members: :show-inheritance: .. autoclass:: GCSRoleDocument :members: :show-inheritance: .. autoclass:: StorageGatewayDocument :members: :show-inheritance: .. autoclass:: UserCredentialDocument :members: :show-inheritance: Collections ~~~~~~~~~~~ .. autoclass:: CollectionDocument :members: :show-inheritance: .. autoclass:: MappedCollectionDocument :members: :show-inheritance: .. autoclass:: GuestCollectionDocument :members: :show-inheritance: Storage Gateway Policies ~~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: StorageGatewayPolicies :members: :show-inheritance: .. autoclass:: ActiveScaleStoragePolicies :members: :show-inheritance: .. autoclass:: AzureBlobStoragePolicies :members: :show-inheritance: .. autoclass:: BlackPearlStoragePolicies :members: :show-inheritance: .. autoclass:: BoxStoragePolicies :members: :show-inheritance: .. autoclass:: CephStoragePolicies :members: :show-inheritance: .. autoclass:: CollectionPolicies :members: :show-inheritance: .. autoclass:: GoogleCloudStorageCollectionPolicies :members: :show-inheritance: .. autoclass:: GoogleCloudStoragePolicies :members: :show-inheritance: .. autoclass:: GoogleDriveStoragePolicies :members: :show-inheritance: .. autoclass:: HPSSStoragePolicies :members: :show-inheritance: .. autoclass:: IrodsStoragePolicies :members: :show-inheritance: .. autoclass:: OneDriveStoragePolicies :members: :show-inheritance: .. autoclass:: POSIXCollectionPolicies :members: :show-inheritance: .. autoclass:: POSIXStagingCollectionPolicies :members: :show-inheritance: .. autoclass:: POSIXStagingStoragePolicies :members: :show-inheritance: .. autoclass:: POSIXStoragePolicies :members: :show-inheritance: .. autoclass:: S3StoragePolicies :members: :show-inheritance: .. autoclass:: ConnectorTable :members: :show-inheritance: .. autoclass:: GlobusConnectServerConnector :members: :show-inheritance: Client Errors ------------- When an error occurs, a :class:`GCSClient` will raise this specialized type of error, rather than a generic :class:`GlobusAPIError`. .. autoclass:: GCSAPIError :members: :show-inheritance: GCS Responses ------------- .. autoclass:: IterableGCSResponse :members: :show-inheritance: .. autoclass:: UnpackingGCSResponse :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/groups.rst000066400000000000000000000033701513221403200235450ustar00rootroot00000000000000Globus Groups ============= .. currentmodule:: globus_sdk .. autoclass:: GroupsClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Helper Objects -------------- These helper objects make it easier to create and submit data to a :class:`GroupsClient`. Additionally, they may be used in concert with the :class:`GroupsManager` to perform operations. These enums define values which can be passed to other helpers: .. autoclass:: GroupMemberVisibility :members: :undoc-members: .. autoclass:: GroupRequiredSignupFields :members: :undoc-members: .. autoclass:: GroupRole :members: :undoc-members: .. autoclass:: GroupVisibility :members: :undoc-members: Payload Types ~~~~~~~~~~~~~ A :class:`BatchMembershipActions` defines how to formulate requests to add, remove, or modify memberships in a group. It can be used to formulate multiple operations to submit in a single request to the service. .. autoclass:: BatchMembershipActions :members: A :class:`GroupPolicies` object defines the various policies which can be set on a group. It can be used with the :class:`GroupsClient` or the :class:`GroupsManager`. .. autoclass:: GroupPolicies :members: High-Level Client Wrappers ~~~~~~~~~~~~~~~~~~~~~~~~~~ The :class:`GroupsManager` is a high-level helper which wraps a :class:`GroupsClient`. Many common operations which require assembling a :class:`BatchMembershipActions` and submitting the result can be achieved with a single method-call on a :class:`GroupsManager`. .. autoclass:: GroupsManager :members: Client Errors ------------- When an error occurs, a :class:`GroupsClient` will raise this type of error: .. autoclass:: GroupsAPIError :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/index.rst000066400000000000000000000030661513221403200233370ustar00rootroot00000000000000.. _services: Service Clients =============== .. currentmodule:: globus_sdk The Globus SDK provides a client class for every public Globus API. Each client object takes authentication credentials via :ref:`GlobusAuthorizers `. Once instantiated, a Client gives you high-level interface to make API calls, without needing to know Globus API endpoints or their various parameters. For example, you could use the :class:`TransferClient` to list your task history very simply: .. code-block:: python from globus_sdk import TransferClient, AccessTokenAuthorizer # you must have a valid transfer token for this to work tc = TransferClient(authorizer=AccessTokenAuthorizer("TRANSFER_TOKEN_STRING")) print("My Last 25 Tasks:") # `filter` to get Delete Tasks (default is just Transfer Tasks) for task in tc.task_list(limit=25, filter="type:TRANSFER,DELETE"): print(task["task_id"], task["type"], task["status"]) .. note:: Multi-Thread and Multi-Process Safety Each Globus SDK client class holds a networking session object to interact with the Globus API. Using a previously created service client object after forking or between multiple threads should be considered unsafe. In multi-processing applications, it is recommended to create service client objects after process forking and to ensure that there is only one service client instance created per process. .. toctree:: :caption: Service Clients :maxdepth: 1 auth compute flows groups search timers transfer gcs globus-globus-sdk-python-6a080e4/docs/services/search.rst000066400000000000000000000011061513221403200234660ustar00rootroot00000000000000Globus Search ============= .. currentmodule:: globus_sdk .. autoclass:: SearchClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Helper Objects -------------- .. autoclass:: SearchQueryV1 :members: :show-inheritance: .. autoclass:: SearchScrollQuery :members: :show-inheritance: Client Errors ------------- When an error occurs, a :class:`SearchClient` will raise this specialized type of error, rather than a generic :class:`GlobusAPIError`. .. autoclass:: SearchAPIError :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/timers.rst000066400000000000000000000021501513221403200235240ustar00rootroot00000000000000Globus Timers ============= .. currentmodule:: globus_sdk .. autoclass:: TimersClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Helper Objects -------------- A helper is provided for constructing Transfer and Flows timers: .. autoclass:: FlowTimer :members: :show-inheritance: .. autoclass:: TransferTimer :members: :show-inheritance: In order to schedule a timer, pass a ``schedule`` with relevant parameters. This can be done using the two schedule helper classes .. autoclass:: OnceTimerSchedule :members: :show-inheritance: .. autoclass:: RecurringTimerSchedule :members: :show-inheritance: TimerJob (legacy) ~~~~~~~~~~~~~~~~~ The ``TimerJob`` class is still supported for creating timers, but it is not recommended. New users should prefer the ``TransferTimer`` class. .. autoclass:: TimerJob :members: :show-inheritance: Client Errors ------------- When an error occurs on calls to the Timers service, a :class:`TimersClient` will raise a ``TimersAPIError``. .. autoclass:: TimersAPIError :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/services/transfer.rst000066400000000000000000000016151513221403200240520ustar00rootroot00000000000000Globus Transfer =============== .. currentmodule:: globus_sdk Client ------ The primary interface for the Globus Transfer API is the :class:`TransferClient` class. .. autoclass:: TransferClient :members: :member-order: bysource :show-inheritance: :exclude-members: error_class Helper Objects -------------- These helper objects make it easier to correctly create data for consumption by a :class:`TransferClient`. .. autoclass:: TransferData :members: :show-inheritance: .. autoclass:: DeleteData :members: :show-inheritance: Client Errors ------------- When an error occurs, a :class:`TransferClient` will raise this specialized type of error, rather than a generic :class:`GlobusAPIError`. .. autoclass:: TransferAPIError :members: :show-inheritance: Transfer Responses ------------------ .. autoclass:: IterableTransferResponse :members: :show-inheritance: globus-globus-sdk-python-6a080e4/docs/testing/000077500000000000000000000000001513221403200213235ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/testing/getting_started.rst000066400000000000000000000150611513221403200252470ustar00rootroot00000000000000.. warning:: This component is an *alpha*. Interfaces may change outside of the normal semver policy. Getting Started with testing ============================ Dependencies ------------ This toolchain requires the ``responses`` library. ``globus_sdk.testing`` is tested to operate with the latest version of ``responses``. Recommended Fixtures -------------------- Under pytest, this is the recommended fixture for setting up responses and guaranteeing that requests are sent to the production hostnames: .. code-block:: python @pytest.fixture(autouse=True) def mocked_responses(monkeypatch): responses.start() monkeypatch.setitem(os.environ, "GLOBUS_SDK_ENVIRONMENT", "production") yield responses.stop() responses.reset() Usage ----- Activating Individual Responses ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Once ``responses`` has been activated, each response fixture can be loaded and activated by name: .. code-block:: python from globus_sdk.testing import load_response # load_response will add the response to `responses` and return it load_response("auth.get_identities") # "case" is used to have a single name map to multiple responses data = load_response("auth.get_identities", case="multiple") Responses can also be activated by passing an SDK client method, bound or unbound, as in: .. code-block:: python import globus_sdk from globus_sdk.testing import load_response load_response(globus_sdk.AuthClient.get_identities) load_response(globus_sdk.AuthClient.get_identities, case="unauthorized") # or, with a bound method ac = globus_sdk.AuthClient() load_response(ac.get_identities, case="multiple") Activating "Scenarios" ~~~~~~~~~~~~~~~~~~~~~~ Some sets of fixtures may describe a scenario, and therefore it's desirable to load all of them at once: .. code-block:: python from globus_sdk.testing import load_response_set fixtures = load_response_set("scenario.foo") Getting Responses and ResponseSets without Activating ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you want to fetch a ``ResponseSet`` or ``RegisteredResponse`` without activating it, you can do this via the ``get_response_set`` method. Responses must always be part of a response set, and the default name for an individual response is ``"default"``. .. code-block:: python from globus_sdk import AuthClient from globus_sdk.testing import get_response_set # rset will not be activated rset = get_response_set(AuthClient.get_identities) # you can get an individual response from rset get_ids = rset.get("default") # you can manually activate a whole set rset.activate_all() # or just one response from it by name rset.activate("default") Note that activating a whole response set may or may not make sense. For example, the response set for ``AuthClient.get_identities`` provides various responses for the same API call. Registering Response Sets ~~~~~~~~~~~~~~~~~~~~~~~~~ You can register your own response sets dynamically, and then load them up with the same ``load_response_set`` method. Note that custom response sets will override the builtin response sets, if names match. .. code-block:: python from globus_sdk.testing import load_response_set, register_response_set import uuid # register a scenario under which Globus Auth get_identities and Globus # Transfer operation_ls both return payloads of `{"foo": "bar"}` # use an autogenerated endpoint ID and put it into the response metadata # register_response_set takes dict data and converts it to fixtures endpoint_id = str(uuid.uuid1()) register_response_set( "foobar", { "get_identities": { "service": "auth", "path": "/v2/api/identities", "json": {"foo": "bar"}, }, "operation_ls": { "service": "transfer", "path": f"/operation/endpoint/{endpoint_id}/ls", "json": {"foo": "bar"}, }, }, metadata={ "endpoint_id": endpoint_id, }, ) # activate the result, and get it as a ResponseSet fixtures = load_response_set("foobar") # you can then pull the epid from the metadata epid = fixtures.metadata["endpoint_id"] transfer_client.operation_ls(epid) ``register_response_set`` can therefore be used to load fixture data early in a tetstsuite run (e.g. as an autouse session-level fixture), for reference later in the testsuite. Loading Responses without Registering ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Because ``RegisteredResponse`` takes care of resolving ``"auth"`` to the Auth URL, ``"transfer"`` to the Transfer URL, and so forth, you might want to use ``globus_sdk.testing`` in lieu of ``responses`` even when registering single responses for individual tests. To support this mode of usage, ``load_response`` can take a ``RegisteredResponse`` instance, and ``load_response_set`` can take a ``ResponseSet`` instance. Consider the following example of a parametrized test which uses ``load_response(RegisteredResponse(...))`` as a replacement for ``responses.add``: .. code-block:: python from globus_sdk.testing import load_response, RegisteredResponse import pytest @pytest.mark.parametrize("message", ["foo", "bar"]) def test_get_identities_sends_back_strange_message(message): load_response( RegisteredResponse( service="auth", path="/v2/api/identities", json={"message": message}, ) ) ac = globus_sdk.AuthClient() res = ac.get_identities(usernames="foo@example.com") assert res["message"] == message In this mode of usage, the response set registry is skipped altogether. It is not necessary to name or organize the response fixtures in a way that is usable outside of the specific test. Using non-default responses.RequestsMock objects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default, all methods in ``globus_sdk.testing`` which converse with ``responses`` use the default mock. This is the behavior offered by ``responses.add(...)`` and similar methods. However, you can pass a custom ``RequestsMock`` if so desired to the following methods: * ``get_last_request`` * ``load_response_set`` * ``load_response`` as a keyword argument, ``requests_mock``. e.g. .. code-block:: python from globus_sdk.testing import get_last_request import responses custom_mock = responses.RequestsMock(...) ... get_last_request(requests_mock=custom_mock) globus-globus-sdk-python-6a080e4/docs/testing/index.rst000066400000000000000000000006651513221403200231730ustar00rootroot00000000000000.. warning:: This component is an *alpha*. Interfaces may change outside of the normal semver policy. .. testing_root: Globus SDK testing ================== .. warning:: The exact data and payloads provided via ``testing`` are a best approximation of API responses. They may change in any SDK release to be more accurate. .. toctree:: :caption: Contents getting_started reference methods/index globus-globus-sdk-python-6a080e4/docs/testing/methods/000077500000000000000000000000001513221403200227665ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/testing/methods/auth.rst000066400000000000000000000001651513221403200244630ustar00rootroot00000000000000Globus Auth testing Method List =============================== .. enumeratetestingfixtures:: globus_sdk.AuthClient globus-globus-sdk-python-6a080e4/docs/testing/methods/flows.rst000066400000000000000000000002651513221403200246550ustar00rootroot00000000000000Globus Flows testing Method List ================================ .. enumeratetestingfixtures:: globus_sdk.FlowsClient .. enumeratetestingfixtures:: globus_sdk.SpecificFlowClient globus-globus-sdk-python-6a080e4/docs/testing/methods/gcs.rst000066400000000000000000000002101513221403200242650ustar00rootroot00000000000000Globus Connect Server testing Method List ========================================= .. enumeratetestingfixtures:: globus_sdk.GCSClient globus-globus-sdk-python-6a080e4/docs/testing/methods/groups.rst000066400000000000000000000001731513221403200250400ustar00rootroot00000000000000Globus Groups testing Method List ================================= .. enumeratetestingfixtures:: globus_sdk.GroupsClient globus-globus-sdk-python-6a080e4/docs/testing/methods/index.rst000066400000000000000000000005671513221403200246370ustar00rootroot00000000000000.. warning:: This component is an *alpha*. Interfaces may change outside of the normal semver policy. testing Method List =================== These pages list all methods which have ``globus_sdk.testing`` response data, and the casenames for those data. .. toctree:: :caption: Clients auth groups transfer gcs timers flows search globus-globus-sdk-python-6a080e4/docs/testing/methods/search.rst000066400000000000000000000001731513221403200247660ustar00rootroot00000000000000Globus Search testing Method List ================================= .. enumeratetestingfixtures:: globus_sdk.SearchClient globus-globus-sdk-python-6a080e4/docs/testing/methods/timers.rst000066400000000000000000000001731513221403200250240ustar00rootroot00000000000000Globus Timers testing Method List ================================= .. enumeratetestingfixtures:: globus_sdk.TimersClient globus-globus-sdk-python-6a080e4/docs/testing/methods/transfer.rst000066400000000000000000000002011513221403200253350ustar00rootroot00000000000000Globus Transfer testing Method List =================================== .. enumeratetestingfixtures:: globus_sdk.TransferClient globus-globus-sdk-python-6a080e4/docs/testing/reference.rst000066400000000000000000000012161513221403200240130ustar00rootroot00000000000000.. warning:: This component is an *alpha*. Interfaces may change outside of the normal semver policy. testing Reference ================= .. module:: globus_sdk.testing Functions --------- .. autofunction:: get_last_request .. autofunction:: register_response_set .. autofunction:: get_response_set .. autofunction:: load_response_set .. autofunction:: load_response .. autofunction:: construct_error Classes ------- .. autoclass:: RegisteredResponse :members: :member-order: bysource .. autoclass:: ResponseList :members: :member-order: bysource .. autoclass:: ResponseSet :members: :member-order: bysource globus-globus-sdk-python-6a080e4/docs/tokenstorage.rst000066400000000000000000000001371513221403200231060ustar00rootroot00000000000000:orphan: The documentation which was found on this page has moved to :ref:`storage_adapters`. globus-globus-sdk-python-6a080e4/docs/tutorial.rst000066400000000000000000000001601513221403200222400ustar00rootroot00000000000000:orphan: The documentation which was found on this page has moved to :ref:`Getting Started `. globus-globus-sdk-python-6a080e4/docs/upgrading.rst000066400000000000000000000706251513221403200223720ustar00rootroot00000000000000.. _upgrading: Upgrading ========= This guide covers upgrading and migration between Globus SDK versions. It is meant to help explain and resolve incompatibilities and breaking changes, and does not cover all new features. When upgrading, you should also read the relevant section of the :ref:`changelog`. The changelog can also be a source of information about new features between major releases. Many explanations are written in terms of ``TransferClient`` for consistency, but apply to all client classes, including ``AuthClient``, ``NativeAppAuthClient``, ``ConfidentialAppAuthClient``, ``SearchClient``, and ``GroupsClient``. Version Parsing --------------- In the event that a codebase must support multiple versions of the globus-sdk at the same time, consider adding this snippet: .. code-block:: python import importlib.metadata GLOBUS_SDK_VERSION = importlib.metadata.distribution("globus_sdk").version GLOBUS_SDK_MAJOR_VERSION = int(GLOBUS_SDK_VERSION.split(".")[0]) This will parse the Globus SDK version information into a tuple and grab the first element (the major version number) as an integer. Then, code can dispatch with .. code-block:: python if GLOBUS_SDK_MAJOR_VERSION < 3: pass # do one thing else: pass # do another From 3.x to 4.0 --------------- ``TransferData`` and ``DeleteData`` Do Not Take a ``TransferClient`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The signatures for these two data constructors have changed to remove support for ``transfer_client`` as their first parameter. Generally, update usage which passed a client to omit it: .. code-block:: python from globus_sdk import TransferClient, TransferData, DeleteData # globus-sdk v3 tc = TransferClient(...) tdata = TransferData(tc, SRC_COLLECTION, DST_COLLECTION) tc.submit_transfer(tdata) tc = TransferClient(...) ddata = DeleteData(tc, COLLECTION) tc.submit_delete(tdata) # globus-sdk v4 tdata = TransferData(SRC_COLLECTION, DST_COLLECTION) tc = TransferClient(...) tc.submit_transfer(tdata) ddata = DeleteData(COLLECTION) tc = TransferClient(...) tc.submit_delete(tdata) Users who are using keyword arguments to pass collection IDs without a ``transfer_client`` do not need to make any change. For example: .. code-block:: python from globus_sdk import TransferData, DeleteData # globus-sdk v3 or v4 tdata = TransferData( source_endpoint=SRC_COLLECTION, destination_endpoint=DST_COLLECTION ) ddata = DeleteData(endpoint=COLLECTION) The client object was used to fetch a ``submission_id`` on initialization. Users typically will rely on ``TransferClient.submit_transfer()`` and ``TransferClient.submit_delete()`` filling in this value. To control when a submission ID is fetched, use ``TransferClient.get_submission_id()``, as in: .. code-block:: python from globus_sdk import TransferClient, TransferData # globus-sdk v3 or v4 tc = TransferClient(...) submission_id = tc.get_submission_id()["value"] tdata = TransferData( source_endpoint=SRC_COLLECTION, destination_endpoint=DST_COLLECTION, submission_id=submission_id, ) ``ConfidentialAppAuthClient`` Cannot Directly Call ``get_identities`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Users of client identities are now required to get tokens in order to use the Get Identities API, and will need to use the ``AuthClient`` class for this purpose. This can most simply be managed by use of a ``ClientApp`` to automatically fetch the appropriate tokens. Update usage like so: .. code-block:: python # globus-sdk v3 from globus_sdk import ConfidentialAppAuthClient client = ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) identities = client.get_identities(usernames="globus@globus.org") # globus-sdk v4 from globus_sdk import ClientApp, AuthClient app = ClientApp(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) client = AuthClient(app=app) identities = client.get_identities(usernames="globus@globus.org") Scope Constants Are Now Objects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Under version 3, many scopes were provided as string constants. For example, ``globus_sdk.TransferClient.scopes.all`` was a string. In version 4, these constants are now :class:`Scope ` objects. They can be rendered to strings using ``str()`` and no longer need to be converted to :class:`Scope `\s in order to use methods. Convert usage which stringifies scopes like so: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import AuthScopes my_scope_str: str = AuthScopes.openid # globus-sdk v4 from globus_sdk.scopes import AuthScopes my_scope_str: str = str(AuthScopes.openid) And convert usage which builds scope objects like so: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import AuthScopes, Scope my_scope: Scope = Scope(AuthScopes.openid) # globus-sdk v4 from globus_sdk.scopes import AuthScopes, Scope my_scope: Scope = AuthScopes.openid ``ScopeBuilder``\s are now ``ScopeCollection``\s ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ As part of the refactor of scope constants, the objects which were previously called "scope builders" are now "scope collections". Scope collections may be static or dynamic, depending on whether or not they statically provide their scopes at the class level or dynamically compute scopes as instance attributes. The following entities are therefore renamed in addition to having changes to their implementations: .. csv-table:: :header: "Old name", "New name" "``GCSEndpointScopeBuilder``", "``GCSEndpointScopes``" "``GCSCollectionScopeBuilder``", "``GCSCollectionScopes``" "``SpecificFlowScopeBuilder``", "``SpecificFlowScopes``" Scope collections provide ``Scope`` objects, not strings. Therefore, update code like so: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import Scope, SpecificFlowScopeBuilder my_flow_scope = Scope(SpecificFlowScopeBuilder(FLOW_ID).user) # globus-sdk v4 from globus_sdk.scopes import SpecificFlowScopes my_flow_scope = SpecificFlowScopes(FLOW_ID).user Scopes Are Immutable and Have New Methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :class:`Scope ` object in v3 of the SDK could be updated with in-place modifications. In v4, these objects are now frozen, and their methods have been altered to suit their immutability. In particular, ``add_dependency`` has been replaced with ``with_dependency``, which builds and returns a new scope rather than making changes to an existing value. Update ``add_dependency`` usage like so: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import Scope my_scope = Scope(ROOT_SCOPE_STRING) my_scope.add_dependency(DEPENCENCY_STRING) # globus-sdk v4 from globus_sdk.scopes import Scope my_scope = Scope(ROOT_SCOPE_STRING) my_scope = my_scope.with_dependency(DEPENCENCY_STRING) For optional dependencies, the ``optional`` parameter must now be specified when creating the dependency scope, not when adding it: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import Scope my_scope = Scope(ROOT_SCOPE_STRING) my_scope.add_dependency(DEPENDENCY_STRING, optional=True) # globus-sdk v4 from globus_sdk.scopes import Scope my_scope = Scope(ROOT_SCOPE_STRING) dependency = Scope(DEPENDENCY_STRING, optional=True) my_scope = my_scope.with_dependency(dependency) ScopeParser Is Now Separate from Scope ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Scope parsing has been split from ``Scope`` to a new class, ``ScopeParser``. Additionally, ``Scope.serialize`` and ``Scope.deserialize`` have been removed, and ``Scope.parse`` is now a wrapper over ``ScopeParser.parse`` which always builds and returns one scope. Users who need to parse multiple scopes should rely on ``ScopeParser.parse``. For example, update like so: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import Scope my_scopes: list[Scope] = Scope.parse(scope_string) # globus-sdk v4 from globus_sdk.scopes import Scope, ScopeParser my_scopes: list[Scope] = ScopeParser.parse(scope_string) Scope Collections Provide ``__iter__``, not ``__str__`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In version 3, the SDK scope collection objects provided a pretty printer in the form of ``str()``. Users could call ``str(TransferClient.scopes)`` to see the available scopes. In version 4, this has been removed, but the collection types provide ``__iter__`` over their member scopes instead. Therefore, you can fetch all scopes for the Globus Transfer service via ``list(TransferClient.scopes)`` or similar usage. Token Storage Subpackage Renamed ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The subpackage providing token storage components has been renamed and slightly restructured. The package name is changed from ``globus_sdk.tokenstorage`` to ``globus_sdk.token_storage``. Furthermore, the legacy :ref:`storage adapters ` are now only available from ``globus_sdk.token_storage.legacy``. Therefore, usages of the modern :ref:`token storage interface ` should update like so: .. code-block:: python # globus-sdk v3 from globus_sdk.tokenstorage import JSONTokenStorage # globus-sdk v4 from globus_sdk.token_storage import JSONTokenStorage For legacy adapter usage, update like so: .. code-block:: python # globus-sdk v3 from globus_sdk.tokenstorage import SimpleJSONFileAdapter # globus-sdk v4 from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter .. note:: The ``legacy`` interface is soft-deprecated. In version 4.0.0 it will not emit deprecation warnings. Future SDK versions will eventually deprecate and remove these interfaces. Deprecated Timers Aliases Removed ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ During the version 3 lifecycle, the ``TimersClient`` and ``TimersAPIError`` classes were renamed. Their original names, ``TimerClient`` and ``TimerAPIError`` were retained as compatibility aliases. These have been removed. Use ``TimersClient`` and ``TimersAPIError``. Deprecated Experimental Aliases Removed ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ During the version 3 lifecycle, several modules were added under ``globus_sdk.experimental`` and later promoted to new names in the main ``globus_sdk`` namespace. Compatibility aliases were left in place. Under version 4, the compatibility aliases have been removed. The removed alias and new module names are shown in the table below. .. csv-table:: :header: "Removed alias", "New name" "``globus_sdk.experimental.auth_requirements_error``", "``globus_sdk.gare``" "``globus_sdk.experimental.globus_app``", "``globus_sdk.globus_app``" "``globus_sdk.experimental.scope_parser``", "``globus_sdk.scopes``" "``globus_sdk.experimental.consents``", "``globus_sdk.scopes.consents``" "``globus_sdk.experimental.tokenstorage``", "``globus_sdk.token_storage``" "``globus_sdk.experimental.login_flow_manager``", "``globus_sdk.login_flows``" ``SearchQuery`` is Removed, use ``SearchQueryV1`` Instead ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``SearchQuery`` helper was removed in version 4 in favor of the :class:`SearchQueryV1 ` type. Simply replace one type with the other for most simple usages: .. code-block:: python # globus-sdk v3 from globus_sdk import SearchQuery query = SearchQuery(q="foo") # globus-sdk v4 from globus_sdk import SearchQuery query = SearchQueryV1(q="foo") Note that ``SearchQuery`` supported the query string, ``q``, as a positional argument, but ``SearchQueryV1`` requires that it is passed as a named parameter. ``SearchQuery`` also supported helper methods which are not provided by ``SearchQueryV1``. These must be replaced by setting the relevant parameters directly or on initialization. For example: .. code-block:: python # globus-sdk v3 from globus_sdk import SearchQuery query = SearchQuery(q="foo") query.set_offset(100) # removed in v4 # globus-sdk v4 from globus_sdk import SearchQuery query = SearchQueryV1(q="foo", offset=100) # on init # or query = SearchQueryV1(q="foo") query["offset"] = 100 # by setting a field .. note:: :class:`SearchQueryV1 ` was added in ``globus-sdk`` version 3, so this transition can be made prior to upgrading to version 4. ``SearchClient.create_entry`` and ``SearchClient.update_entry`` Removed ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These methods were deprecated in version 3 in favor of ``SearchClient.ingest``, which provides greater functionality and a more uniform interface. For any document being passed by these methods, upgrade to using an ingest document with ``"ingest_type": "GMetaEntry"``. Consult the :extdoclink:`Search Ingest Guide ` for details on the document formats. ``MutableScope`` is Removed, use ``Scope`` Instead ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``MutableScope`` type was removed in version 4 in favor of the :class:`Scope ` type. When manipulating scopes as objects, use :class:`Scope ` anywhere that ``MutableScope`` was used, for example: .. code-block:: python # globus-sdk v3 from globus_sdk.scopes import MutableScope my_scope = MutableScope("urn:globus:auth:scopes:transfer.api.globus.org:all") # globus-sdk v4 from globus_sdk.scopes import Scope my_scope = Scope("urn:globus:auth:scopes:transfer.api.globus.org:all") .. note:: The :class:`Scope ` type was added in Globus SDK v3, so this transition can be made prior to upgrading to version 4. ``requested_scopes`` is Required ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Several methods have historically taken an optional parameter, ``requested_scopes``. - ``ConfidentialAppAuthClient.oauth2_client_credentials_tokens`` - ``ConfidentialAppAuthClient.oauth2_start_flow`` - ``NativeAppAuthClient.oauth2_start_flow`` In previous versions of the SDK, these methods provided a default value for ``requested_scopes`` of ``"openid profile email urn:globus:auth:scopes:transfer.api.globus.org:all"``. This default has now been removed and users should always specify the scopes they need when using these methods. Users of ``GlobusApp`` constructs (``UserApp`` and ``ClientApp``) do not need to update their usage. The default could only be used by applications which only use Globus Transfer and Globus Auth. Change: .. code-block:: python # globus-sdk v3 auth_client.oauth2_start_flow() authorize_url = auth_client.oauth2_get_authorize_url() # globus-sdk v4 auth_client.oauth2_start_flow(requested_scopes=globus_sdk.TransferClient.scopes.all) authorize_url = auth_client.oauth2_get_authorize_url() Customizing the Transport Has Changed ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In version 3, SDK users could customize the ``RequestsTransport`` object contained within a client in two ways. One was to customize a client class by setting the ``transport_class`` class attribute, and the other was to pass ``transport_params`` to the client initializer. In version 4, these mechanisms have been replaced with support for passing a ``RequestsTransport`` object directly to the initializer. For users who are customizing the parameters to the transport class, they should now explicitly instantiate the transport object: .. code-block:: python # globus-sdk v3 import globus_sdk client = globus_sdk.GroupsClient(transport_params={"http_timeout": 120.0}) # globus-sdk v4 import globus_sdk from globus_sdk.transport import RequestsTransport client = globus_sdk.GroupsClient(transport=RequestsTransport(http_timeout=120.0)) or use the ``tune()`` context manager: .. code-block:: python # globus-sdk v4 import globus_sdk client = globus_sdk.GroupsClient() with client.transport.tune(http_timeout=120.0): my_groups = client.get_my_groups() Retry Check Configuration Moved to ``retry_config`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In Globus SDK v3, a client's ``transport`` contained all of its retry behaviors, including the checks which are run on each request, the configuration of those checks, and the sleep and backoff behaviors. Under v4, the configuration of checks has been split off into a separate attribute of the client, ``retry_config``. These changes primarily impact users who were using a custom ``RequestsTransport`` class, and should simplify their usage. For example, in order to treat only 502s as retriable transient errors, users previously had a custom transport type. This could then be configured on a custom client class: .. code-block:: python # globus-sdk v3 import globus_sdk from globus_sdk.transport import RequestsTransport class MyTransport(RequestsTransport): TRANSIENT_ERROR_STATUS_CODES = (502,) class MyClientClass(globus_sdk.GroupsClient): transport_class = MyTransport client = MyClientClass() Under SDK v4, in order to customize the same information, users can simply create a client and then modify the attributes of the ``retry_config`` object: .. code-block:: python # globus-sdk v4 import globus_sdk client = globus_sdk.GroupsClient() client.retry_config.transient_error_status_codes = (502,) Similar to the ``tune()`` context manager of ``RequestsTransport``, there is also a ``tune()`` context manager for the retry configuration. ``tune()`` supports the ``max_sleep``, ``max_retries``, and ``backoff`` configurations, which users of ``RequestsTransport.tune()`` may already recognize. For example, users can suppress retries: .. code-block:: python # globus-sdk v4 import globus_sdk client = globus_sdk.GroupsClient() with client.retry_config.tune(max_retries=1): my_groups = client.get_my_groups() A ``retry_config`` can also be passed to clients on initialization: .. code-block:: python # globus-sdk v4 import globus_sdk from globus_sdk.transport import RetryConfig client = globus_sdk.GroupsClient(retry_config=RetryConfig(max_retries=2)) my_groups = client.get_my_groups() Clients No Longer Define ``base_path`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In version 3 and earlier, client classes defined an attribute ``base_path`` which was joined as a prefix to request paths for the HTTP methods: ``get()``, ``put()``, ``post()``, ``patch()``, ``delete()``, ``head()``, and ``request()``. The ``base_path`` attribute has been removed and direct use of HTTP APIs now requires the full path when bare HTTP methods are used. ``base_path`` values were also used in the testing tools defined by ``globus_sdk.testing`` and have similarly been removed. The ``base_path`` was automatically deduplicated when provided to SDK version 3, meaning that code which includes this prefix will work on both SDK version 3 and version 4. For example, ``TransferClient`` defined a ``base_path`` of ``"v0.10"``. As a result, the request URI for a ``get()`` HTTP call would be mapped as follows: .. code-block:: python import globus_sdk tc = globus_sdk.TransferClient() # GET https://transfer.api.globus.org/v0.10/foo/bar tc.get("/foo/bar") In version 4, without the ``base_path``, the mapping is as follows: .. code-block:: python # GET https://transfer.api.globus.org/foo/bar tc.get("/foo/bar") Due to the deduplication of a leading ``base_path`` in version 3, the following snippet has the same effect in both versions: .. code-block:: python # GET https://transfer.api.globus.org/v0.10/foo/bar tc.get("/v0.10/foo/bar") Clients with a ``base_path`` and the values they defined in version 3 are listed below. ================== =========== Client Class base_path ================== =========== ``TransferClient`` ``"v0.10"`` ``GroupsClient`` ``"v2"`` ================== =========== From 1.x or 2.x to 3.0 ----------------------- The :ref:`v3 changelog ` covers the full list of changes made in version 3 of the Globus SDK. Because version 2 did not introduce any changes to the SDK code other than supported python versions, you may also want to view this section when upgrading from version 1. Type Annotations ~~~~~~~~~~~~~~~~ The Globus SDK now provides PEP 561 type annotation data. This means that codebases which use ``mypy`` or similar tools to check type annotations may see new warnings or errors when using version 3 of the SDK. .. note:: If you believe an annotation in the SDK is incorrect, please visit our `issue tracker `_ to file a bug report! Automatic Retries ~~~~~~~~~~~~~~~~~ Globus SDK client methods now automatically retry failing requests when encountering network errors and certain classes of server errors (e.g. rate limiting). For most users, retry logic can be removed. Change: .. code-block:: python import globus_sdk # globus-sdk v1 or v2 tc = globus_sdk.TransferClient(...) response = None count, max_retries = 0, 10 while response is None and count < max_retries: count += 1 try: # any operation, just an example response = tc.get_endpoint(foo) except globus_sdk.NetworkError: pass # globus-sdk v3 tc = globus_sdk.TransferClient(...) response = tc.get_endpoint(foo) # again, just an example operation Updates to BaseClient Usage ~~~~~~~~~~~~~~~~~~~~~~~~~~~ You may be using the globus-sdk ``BaseClient`` object to implement a custom client or for type annotations. Firstly, ``BaseClient`` is available from the base ``globus_sdk`` namespace. Change: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk.base import BaseClient # globus-sdk v3 from globus_sdk import BaseClient Secondly, creating a ``BaseClient`` is different. Previously, initializing a ``BaseClient`` had one required positional argument ``service``. Now, this exists as a class attribute, which subclasses can overwrite. Change: .. code-block:: python # globus-sdk v1 or v2 class MyClient(BaseClient): pass MyClient("my-service", **kwargs) # globus-sdk v3 class MyClient(BaseClient): service_name = "my-service" MyClient(**kwargs) Import exceptions from globus_sdk ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Several exceptions which were available in v2 under ``globus_sdk.exc`` are now only available from the ``globus_sdk`` namespace. Change: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk.exc import SearchAPIError, TransferAPIError, AuthAPIError # globus-sdk v3 from globus_sdk import SearchAPIError, TransferAPIError, AuthAPIError Note that this also may appear in your exception handling, as in: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk import exc try: ... except exc.TransferAPIError: # by way of example, any error here ... # globus-sdk v3 import globus_sdk try: ... except globus_sdk.TransferAPIError: ... Low Level API for Passing Data is Improved ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In version 2 of the SDK, passing data to client ``post()``, ``put()``, and ``patch()`` methods required the use of either ``json_body`` or ``text_body``. Furthermore, ``text_body`` would (confusingly!) send a FORM body if it were passed a dictionary. Now, these behaviors are described by ``data`` (a body for these HTTP methods) and ``encoding`` (an explicit data format parameter). If the ``encoding`` is not set, the default behavior is that if ``data`` is a dictionary, it will be sent as JSON. If ``data`` is a string, it will be sent as text. ``encoding`` can be set to ``"json"`` or ``"form"`` to explicitly format the data. Change code for a JSON PUT like so: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk import TransferClient tc = TransferClient(...) tc.put("/some/custom/path", json_body={"a": "dict", "of": "data"}) # globus-sdk v3 from globus_sdk import TransferClient tc = TransferClient(...) tc.put("/some/custom/path", data={"a": "dict", "of": "data"}) Or a FORM POST like so: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk import TransferClient tc = TransferClient(...) tc.post("/some/custom/path", text_body={"a": "dict", "of": "data"}) # globus-sdk v3 from globus_sdk import TransferClient tc = TransferClient(...) tc.put("/some/custom/path", data={"a": "dict", "of": "data"}, encoding="form") Passthrough Parameters are Explicit ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Many methods in version 2 accepted arbitrary keyword arguments which were then transformed into query or body parameters based on the context. This is no longer allowed, but methods can still be passed additional query parameters in the form of a ``query_params`` dict. For example, if the Transfer API is known to support a query param ``foo=bar`` for ``GET Endpoint``, but the SDK does not include this parameter, the way that it can be added to a request has changed as follows: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk import TransferClient tc = TransferClient(...) tc.get_endpoint(epid, foo="bar") # globus-sdk v3 from globus_sdk import TransferClient tc = TransferClient(...) tc.get_endpoint(epid, query_params={"foo": "bar"}) .. note:: If a parameter which you need is not supported by the Globus SDK, use ``query_params`` to work around it! But also, feel free to visit our `issue tracker `_ to request an improvement. Responses are always GlobusHTTPResponse ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In version 2, ``GlobusHTTPResponse`` inherited from a base class, ``GlobusResponse``. In version 3, the distinction has been eliminated and responses are only ``GlobusHTTPResponse``. This may appear in contexts where you type annotate or use ``isinstance`` checks to check the type of an object. Change: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk.response import GlobusResponse data = some_complex_func() if isinstance(data, GlobusResponse): ... # globus-sdk v3 from globus_sdk import GlobusHTTPResponse data = some_complex_func() if isinstance(data, GlobusHTTPResponse): ... Pagination is now explicit ~~~~~~~~~~~~~~~~~~~~~~~~~~ In version 2, paginated methods of ``TransferClient`` returned a ``PaginatedResource`` iterable type. In version 3, no methods return paginators by default, and pagination is always opt-in. See also :ref:`doc on making paginated calls `. Change: .. code-block:: python # globus-sdk v1 or v2 from globus_sdk import TransferClient tc = TransferClient(...) for endpoint_info in tc.endpoint_search("query"): ... # globus-sdk v3 from globus_sdk import TransferClient tc = TransferClient(...) for endpoint_info in tc.paginated.endpoint_search("query").items(): ... Authorizer Methods ~~~~~~~~~~~~~~~~~~ ``GlobusAuthorizer`` objects have had their methods modified. In particular, in version 2, authorizers have a method ``set_authorization_header`` for modifying a dict. This has been replaced in version 3 with a method ``get_authorization_header`` which returns an ``Authorization`` header value. Configuration has Changed ~~~~~~~~~~~~~~~~~~~~~~~~~ The Globus SDK no longer reads configuration data from ``/etc/globus.cfg`` or ``~/.globus.cfg``. If you are using these files to customize the behavior of the SDK, see :ref:`the configuration documentation `. Internal Changes to components including Config ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Several modules and components which are considered mostly or entirely internal have been reorganized. In particular, if you are using undocumented methods from ``globus_sdk.config``, note that this has been largely rewritten. (These are not considered public APIs.) From 1.x to 2.0 --------------- Also see the :ref:`v2 changelog `. When upgrading from version 1 to version 2 of the Globus SDK, no code changes should be necessary. Version 2 removed support for python2 but made no other changes. Simply ensure that you are running python 3.6 or later and update version specifications to ``globus_sdk>=2,<3``. globus-globus-sdk-python-6a080e4/docs/user_guide/000077500000000000000000000000001513221403200220015ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/000077500000000000000000000000001513221403200251705ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/index.rst000066400000000000000000000014511513221403200270320ustar00rootroot00000000000000.. _tutorials: .. _getting_started: Getting Started =============== These docs cover basic usage of the Globus SDK. They takes you through a simple step-by-step flow for registering your application, and then using that registered application to login and interact with services. Two example scripts are offered -- one using the ``GlobusApp`` class and one without it. ``GlobusApp`` is recommended for most use cases, but there are scenarios which are not supported by it. Reading the non-``GlobusApp`` example may also enhance your understanding of how ``GlobusApp`` works. .. toctree:: :caption: How To Use the SDK :maxdepth: 1 Register an App in Globus Auth Create a Minimal Script Create a Minimal Script Without GlobusApp globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/list_groups.py000066400000000000000000000011531513221403200301140ustar00rootroot00000000000000import globus_sdk # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # create your app my_app = globus_sdk.UserApp("my-user-app", client_id=CLIENT_ID) # create a client with your app groups_client = globus_sdk.GroupsClient(app=my_app) # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/list_groups_improved.py000066400000000000000000000010371513221403200320220ustar00rootroot00000000000000import globus_sdk # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" with globus_sdk.UserApp("my-user-app", client_id=CLIENT_ID) as my_app: with globus_sdk.GroupsClient(app=my_app) as groups_client: my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/list_groups_noapp.py000066400000000000000000000024641513221403200313170ustar00rootroot00000000000000import globus_sdk # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # create a client for interactions with Globus Auth auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) # using that client, do a login flow for Globus Groups credentials auth_client.oauth2_start_flow( requested_scopes=globus_sdk.GroupsClient.scopes.view_my_groups_and_memberships ) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) # extract tokens from the response which match Globus Groups groups_tokens = tokens.by_resource_server[globus_sdk.GroupsClient.resource_server] # construct an AccessTokenAuthorizer and use it to construct the GroupsClient groups_client = globus_sdk.GroupsClient( authorizer=globus_sdk.AccessTokenAuthorizer(groups_tokens["access_token"]) ) # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/list_groups_noapp_with_storage.py000066400000000000000000000040141513221403200340670ustar00rootroot00000000000000import os import globus_sdk from globus_sdk.token_storage import JSONTokenStorage # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # define a client for interactions with Globus Auth auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) # define token storage where tokens will be stored between runs token_storage = JSONTokenStorage( os.path.expanduser("~/.list-my-globus-groups-tokens.json") ) # if there is no stored token file, we have not yet logged in if not token_storage.file_exists(): # do a login flow, getting back a token response auth_client.oauth2_start_flow( requested_scopes=globus_sdk.GroupsClient.scopes.view_my_groups_and_memberships, refresh_tokens=True, ) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() token_response = auth_client.oauth2_exchange_code_for_tokens(auth_code) # now store the tokens token_storage.store_token_response(token_response) # load the tokens from the storage -- either freshly stored or loaded from disk token_data = token_storage.get_token_data(globus_sdk.GroupsClient.resource_server) # construct the RefreshTokenAuthorizer which writes back to storage on refresh authorizer = globus_sdk.RefreshTokenAuthorizer( token_data.refresh_token, auth_client, access_token=token_data.access_token, expires_at=token_data.expires_at_seconds, on_refresh=token_storage.store_token_response, ) # use that authorizer to authorize the activity of the groups client groups_client = globus_sdk.GroupsClient(authorizer=authorizer) # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/list_groups_with_login.py000066400000000000000000000014331513221403200323400ustar00rootroot00000000000000import globus_sdk # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" with globus_sdk.UserApp("my-user-app", client_id=CLIENT_ID) as my_app: with globus_sdk.GroupsClient(app=my_app) as groups_client: # Important! The login step needs to happen after the `groups_client` is created # so that the app will know that you need credentials for Globus Groups my_app.login() # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/minimal_script.rst000066400000000000000000000107551513221403200307440ustar00rootroot00000000000000.. currentmodule:: globus_sdk .. _minimal_script_tutorial: How to Create A Minimal Script ============================== This is a basic tutorial in the use of the Globus SDK. You can jump right in by using the ``CLIENT_ID`` seen in the example code blocks below! That is the ID of the tutorial client, which lets you get started quickly and easily. When you are ready to create your own application, follow :ref:`How to Register an App in Globus Auth ` and use its ``CLIENT_ID`` in the rest of the tutorial to get your own app setup. For readers who prefer to start with complete working examples, jump ahead to the :ref:`example scripts ` at the end before reviewing the doc. Define your App Object ---------------------- Accessing Globus APIs as a user requires that you login to your new app and get it tokens, credentials providing access the service. The SDK provides a construct which represents an application. A :class:`GlobusApp` is an object which can respond to requests for new or existing tokens and store those tokens (by default, in ``~/.globus/app/``). :class:`UserApp` is the type of :class:`GlobusApp` used for human user, login-driven scenarios -- this is always the right type of application object to use when you want to interact with services using your own account. Start by defining an application object using :class:`UserApp`: .. code-block:: python import globus_sdk # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # create your app my_app = globus_sdk.UserApp("my-user-app", client_id=CLIENT_ID) .. note:: The default behavior for a :class:`UserApp` is to do a CLI-based login flow. This behavior, and more, can be disabled or customized in numerous ways. For the full menu of options, look at the documentation about :ref:`Using a GlobusApp! ` Access the APIs via Clients --------------------------- Once you have an app defined, you can use it with client objects to access various Globus APIs. When you attempt to interact with a service using an app-bound service client, the app will automatically prompt you to login if valid credentials are unavailable. Start by defining the client object: .. code-block:: python groups_client = globus_sdk.GroupsClient(app=my_app) And now you can use it to perform some simple interaction, like listing your groups: .. code-block:: python # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) When ``groups_client.get_my_groups()`` runs in the example above, the SDK will prompt you to login. .. _minimal_script_complete_examples: Summary: Complete Examples -------------------------- For ease of use, here are a set of examples. One of them is exactly the same as the tutorial steps above, in a single block. The next is a version of the tutorial which leverages the context manager interfaces of the app and client to do cleanup. This is slightly more verbose, but such usage is recommended because it ensures that network and filesystem resources associated with the client and app are properly closed. The final example includes an explicit login step, so you can control when that login flow happens! Like the previous example, it uses the context manager style to ensure proper cleanup. *These examples are complete. They should run without errors "as is".* .. tab-set:: .. tab-item:: Tutorial Recap .. literalinclude:: list_groups.py :caption: ``list_groups.py`` [:download:`download `] :language: python .. tab-item:: With Context Managers This example is the same as the tutorial, but safely cleans up resources. .. literalinclude:: list_groups_improved.py :caption: ``list_groups_improved.py`` [:download:`download `] :language: python .. tab-item:: Explicit ``login()`` Step This example is very similar to the tutorial, but uses a separate login step. .. literalinclude:: list_groups_with_login.py :caption: ``list_groups_with_login.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/minimal_script_noapp.rst000066400000000000000000000241421513221403200321340ustar00rootroot00000000000000.. currentmodule:: globus_sdk .. _minimal_script_noapp_tutorial: How to Create A Minimal Script without GlobusApp ================================================ :class:`GlobusApp` provides a number of useful abstractions in the SDK. It handles login flows and storage of tokens, coupled with later retrieval of those tokens for use. It can keep track of which clients have been created and registered with an app, and therefore make intelligent decisions about how and when to prompt users to login. New users should read :ref:`the guide for writing a minimal script ` before reading this doc. :class:`GlobusApp` is built from several simpler components which can be used to implement similar behaviors. This doc covers how to write a simple script, but without using :class:`GlobusApp`. For readers who prefer to start with complete examples, jump ahead to these sections before reviewing the doc: - :ref:`Login and List Groups (simple) ` - :ref:`Login and List Groups (with storage) ` Cases for not Using GlobusApp ----------------------------- There are at least three main reasons to be interested in defining applications without :class:`GlobusApp`: 1. You have a use case which doesn't fit the behaviors of an app. e.g., Implementations of *APIs* or *services*. 2. Any legacy codebase, predating :class:`GlobusApp`, will use the underlying constructs to implement login behaviors. 3. Customizing and extending :class:`GlobusApp` to suit your use case may require understanding the underlying components. .. note:: Prior to the introduction of :class:`GlobusApp`, these tools were the only way that an application could be written with the ``globus_sdk``. If you are maintaining an existing application, you may need to be strategic about when and how to upgrade such usages. Define an Auth Client Instance ------------------------------ In order to interact with Globus Auth, you will need a client object. This will be the driver of the login flow. .. code-block:: python import globus_sdk # this is the tutorial client ID # replace this string with your ID for production use CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # create a client for interactions with Globus Auth auth_client = globus_sdk.NativeAppAuthClient(CLIENT_ID) This uses a :class:`NativeAppAuthClient`, which is one of the two types of client object supporting logins. The other is :class:`ConfidentialAppAuthClient`, which is for clients which authenticate themselves using a stored secret. There are differences in behavior between the two types of Auth client, but :class:`NativeAppAuthClient` is more appropriate for scripts which you may later redistribute. Login and Consent for Groups Access ----------------------------------- In OAuth2, login flows are always driven through a web browser. In order to connect our simple script to the browser context, we will go through a challenge-response flow. The script will print out a login URL. Upon logging in and returning to the script, you paste in a verification code, which is then exchanged for tokens. For simplicity, we'll print the login prompt to stdout and accept the authorization code with a prompt for input. Additionally, we will need to specify what scopes (what actions on what services) we want access to in our script. This will drive the consent prompt in the Globus Auth web interface. .. code-block:: python # using that client, do a login flow for Globus Groups credentials auth_client.oauth2_start_flow( requested_scopes=globus_sdk.GroupsClient.scopes.view_my_groups_and_memberships ) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() tokens = auth_client.oauth2_exchange_code_for_tokens(auth_code) Create and Use a GroupsClient ----------------------------- To make use of the tokens procured in the previous step, you'll need to create a client object, pass it the appropriate token data, and use it to call out to the Globus Groups API. Credentials are passed through a generic "authorizer" interface which allows the tokens to be passed statically or as a reference to some dynamic data source. .. code-block:: python # extract tokens from the response which match Globus Groups groups_tokens = tokens.by_resource_server[globus_sdk.GroupsClient.resource_server] # construct an AccessTokenAuthorizer and use it to construct the GroupsClient groups_client = globus_sdk.GroupsClient( authorizer=globus_sdk.AccessTokenAuthorizer(groups_tokens["access_token"]) ) # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) .. _list_groups_noapp: Recap: List Groups Script ------------------------- The previous sections can be combined into a working script. *The following example is complete. It should run without modification "as-is".* .. literalinclude:: list_groups_noapp.py :caption: ``list_groups_noapp.py`` [:download:`download `] :language: python Adding in Refresh Tokens & Token Storage ---------------------------------------- To expand upon this example, it is possible to request long-lived tokens called "refresh tokens", which are valid until they are revoked or go unused for a long period. Making use of refresh tokens is most appropriate if we also store the tokens between runs of the script, so that we can reuse the tokens. Refresh tokens operate by getting an access token, like the example above, but allowing you to automatically replace or "refresh" that token any time it expires. We will therefore also need to elaborate our usage to handle these automatic refreshes. Requesting Refresh Tokens ^^^^^^^^^^^^^^^^^^^^^^^^^ To request refresh tokens, simply pass ``refresh_tokens=True`` to the ``oauth2_start_flow`` call: .. code-block:: python auth_client.oauth2_start_flow( requested_scopes=globus_sdk.GroupsClient.scopes.view_my_groups_and_memberships, refresh_tokens=True, ) Defining Token Storage ^^^^^^^^^^^^^^^^^^^^^^ Token storage abstractions are defined in the SDK which provide the ability to read or write token data in a structured way. Defining a token storage object is simple: .. code-block:: python import os from globus_sdk.token_storage import JSONTokenStorage token_storage = JSONTokenStorage( os.path.expanduser("~/.list-my-globus-groups-tokens.json") ) Linking a Login Flow to Token Storage ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To connect the tokens from login to a token storage, use the token storage method ``store_token_response()`` at the end of the login flow. And in order to make the script only prompt for login if there are no tokens, we can ask the ``JSONTokenStorage.file_exists()`` method whether or not there is a file. This rewrites our login block to be nested under a ``file_exists()`` check: .. code-block:: python # if there is no stored token file, we have not yet logged in if not token_storage.file_exists(): # do a login flow, getting back a token response auth_client.oauth2_start_flow( requested_scopes=globus_sdk.GroupsClient.scopes.view_my_groups_and_memberships, refresh_tokens=True, ) authorize_url = auth_client.oauth2_get_authorize_url() print(f"Please go to this URL and login:\n\n{authorize_url}\n") auth_code = input("Please enter the code here: ").strip() token_response = auth_client.oauth2_exchange_code_for_tokens(auth_code) # now store the tokens token_storage.store_token_response(token_response) Building a RefreshTokenAuthorizer ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Token storage defines how the data gets stored, and how it is retrieved. The storage is also integral to how refresh tokens are used -- we need a place to store updated tokens whenever we have a refresh. We will load the groups token out of the token storage and use it to construct a :class:`RefreshTokenAuthorizer`, which handles automatic refreshes. To write updated tokens back into the storage, we pass it back into the authorizer, like so: .. code-block:: python # load the tokens from the storage -- either freshly stored or loaded from disk token_data = token_storage.get_token_data(globus_sdk.GroupsClient.resource_server) # construct the RefreshTokenAuthorizer which writes back to storage on refresh authorizer = globus_sdk.RefreshTokenAuthorizer( token_data.refresh_token, auth_client, access_token=token_data.access_token, expires_at=token_data.expires_at_seconds, on_refresh=token_storage.store_token_response, ) Construct and Use the GroupsClient ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Now that we have a new authorizer, it is simple to construct and use a new client, just as before: .. code-block:: python # use that authorizer to authorize the activity of the groups client groups_client = globus_sdk.GroupsClient(authorizer=authorizer) # call out to the Groups service to get a listing my_groups = groups_client.get_my_groups() # print in CSV format print("ID,Name,Roles") for group in my_groups: roles = "|".join({m["role"] for m in group["my_memberships"]}) print(",".join([group["id"], f'"{group["name"]}"', roles])) .. _list_groups_noapp_with_storage: Recap: List Groups with RefreshTokens ------------------------------------- As a complete example of the List Groups script with token storage and a refresh token authorizer, the above sections can be combined into the following script: *The following example is complete. It should run without modification "as-is".* .. literalinclude:: list_groups_noapp_with_storage.py :caption: ``list_groups_noapp_with_storage.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/user_guide/getting_started/register_app.rst000066400000000000000000000051731513221403200304140ustar00rootroot00000000000000.. _tutorial_register_app: How to Register an App in Globus Auth ===================================== An OAuth2 Client, also sometimes called an "app", represents an application or script. When a user logs into an application, they must grant consent for the client to perform any required actions. At the end of a login flow, Globus Auth issues the client appropriate credentials for those actions. Having your own app registered with the service also lets you set certain app-level settings for user logins, and keeps your application isolated from everyone else's apps using the service. .. note:: SDK Tutorials and Examples frequently make use of a "tutorial client". This is a publicly registered application which makes the examples succeed with no edits, but using the tutorial client for production use cases is not recommended or supported. Developers who wish to learn more about OAuth2 at Globus are encouraged to read the `Clients, Scopes, and Consents `_ documentation. Creating a Native App --------------------- There are several different types of applications which you can register with Globus Auth. For simplicity, this tutorial will only cover registration of the "Native App" type. This is the suitable type of application for scripts and distributed applications which aren't able to use secrets. Steps ^^^^^ 1. Navigate to the `Developer Site `_ 2. Select "Register a thick client or script that will be installed and run by users on their devices." 3. Create or Select a Project * A project is a collection of apps with a shared list of administrators. * If you don't own any projects, you will automatically be prompted to create one. * If you do, you will be prompted to either select an existing or create a new one. 4. Creating or selecting a project will prompt you for another login, sign in with an account that administers your project. 5. Give your App a name; this is what users will see when they are asked to authorize your app. 6. Click "Register App". This will create your app and take you to a page describing it. 7. Copy the "Client UUID" from the page. * This ID can be thought of as your App's "username". It is non-secure information and as such, feel free to hardcode it into scripts. .. note:: In many tutorials and code samples we assume that the Client UUID is available in the variable ``CLIENT_ID``. In many cases, you can copy down code samples and plug in your Client UUID and the examples will work without further modification! globus-globus-sdk-python-6a080e4/docs/user_guide/installation.rst000066400000000000000000000013521513221403200252350ustar00rootroot00000000000000Installation ============ The Globus SDK requires `Python 3 `_. If a supported version of Python is not already installed on your system, see this `Python installation guide \ `_. The simplest way to install the Globus SDK is using the ``pip`` package manager (https://pypi.python.org/pypi/pip), which is included in most Python installations: :: pip install globus-sdk This will install the Globus SDK and it's dependencies. Bleeding edge versions of the Globus SDK can be installed by checking out the git repository and installing it manually: :: git clone https://github.com/globus/globus-sdk-python.git cd globus-sdk-python pip install . globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/000077500000000000000000000000001513221403200250255ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/000077500000000000000000000000001513221403200276425ustar00rootroot00000000000000create_guest_collection/000077500000000000000000000000001513221403200344505ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfercreate_guest_collection_client_owned.py000066400000000000000000000053401513221403200444430ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/create_guest_collectionimport globus_sdk from globus_sdk.globus_app import ClientApp # Confidential Client ID/Secret - CONFIDENTIAL_CLIENT_ID = "..." CONFIDENTIAL_CLIENT_SECRET = "..." # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc ENDPOINT_HOSTNAME = "https://b7a4f1.75bc.data.globus.org" STORAGE_GATEWAY_ID = "947460f6-3fcd-4acc-9683-d71e14e5ace1" MAPPED_COLLECTION_ID = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" def main(): with ClientApp( "my-simple-client-collection", client_id=CONFIDENTIAL_CLIENT_ID, client_secret=CONFIDENTIAL_CLIENT_SECRET, ) as app: with globus_sdk.GCSClient(ENDPOINT_HOSTNAME, app=app) as gcs_client: create_guest_collection(gcs_client) def create_guest_collection(gcs_client: globus_sdk.GCSClient): # Comment out this line if the mapped collection is high assurance attach_data_access_scope(gcs_client, MAPPED_COLLECTION_ID) ensure_user_credential(gcs_client) collection_request = globus_sdk.GuestCollectionDocument( public=True, collection_base_path="/", display_name="example_guest_collection", mapped_collection_id=MAPPED_COLLECTION_ID, ) collection = gcs_client.create_collection(collection_request) print(f"Created guest collection. Collection ID: {collection['id']}") def attach_data_access_scope(gcs_client, collection_id): """Compose and attach a ``data_access`` scope for the supplied collection""" endpoint_scopes = gcs_client.get_gcs_endpoint_scopes(gcs_client.endpoint_client_id) collection_scopes = gcs_client.get_gcs_collection_scopes(collection_id) data_access = globus_sdk.Scope(collection_scopes.data_access, optional=True) manage_collections = globus_sdk.Scope( endpoint_scopes.manage_collections, dependencies=(data_access,) ) gcs_client.add_app_scope(manage_collections) def ensure_user_credential(gcs_client): """ Ensure that the client has a user credential on the client. This is the mapping between Globus Auth (OAuth2) and the local system's permissions. """ # Depending on the endpoint & storage gateway, this request document may need to # include more complex information such as a local username. # Consult with the endpoint owner for more detailed info on user mappings and # other specific requirements. req = globus_sdk.UserCredentialDocument(storage_gateway_id=STORAGE_GATEWAY_ID) try: gcs_client.create_user_credential(req) except globus_sdk.GCSAPIError as err: # A user credential already exists, no need to create it. if err.http_status != 409: raise if __name__ == "__main__": main() create_guest_collection_user_owned.py000066400000000000000000000051541513221403200441460ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/create_guest_collectionimport globus_sdk from globus_sdk.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc ENDPOINT_HOSTNAME = "https://b7a4f1.75bc.data.globus.org" STORAGE_GATEWAY_ID = "947460f6-3fcd-4acc-9683-d71e14e5ace1" MAPPED_COLLECTION_ID = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" def main(): with UserApp("my-simple-user-collection", client_id=NATIVE_CLIENT_ID) as app: with globus_sdk.GCSClient(ENDPOINT_HOSTNAME, app=app) as client: create_guest_collection(client) def create_guest_collection(gcs_client: globus_sdk.GCSClient): # Comment out this line if the mapped collection is high assurance attach_data_access_scope(gcs_client, MAPPED_COLLECTION_ID) ensure_user_credential(gcs_client) collection_request = globus_sdk.GuestCollectionDocument( public=True, collection_base_path="/", display_name="example_guest_collection", mapped_collection_id=MAPPED_COLLECTION_ID, ) collection = gcs_client.create_collection(collection_request) print(f"Created guest collection. Collection ID: {collection['id']}") def attach_data_access_scope(gcs_client, collection_id): """Compose and attach a ``data_access`` scope for the supplied collection""" endpoint_scopes = gcs_client.get_gcs_endpoint_scopes(gcs_client.endpoint_client_id) collection_scopes = gcs_client.get_gcs_collection_scopes(collection_id) data_access = globus_sdk.Scope(collection_scopes.data_access, optional=True) manage_collections = globus_sdk.Scope( endpoint_scopes.manage_collections, dependencies=(data_access,) ) gcs_client.add_app_scope(manage_collections) def ensure_user_credential(gcs_client): """ Ensure that the user has a user credential on the client. This is the mapping between Globus Auth (OAuth2) and the local system's permissions. """ # Depending on the endpoint & storage gateway, this request document may need to # include more complex information such as a local username. # Consult with the endpoint owner for more detailed info on user mappings and # other particular requirements. req = globus_sdk.UserCredentialDocument(storage_gateway_id=STORAGE_GATEWAY_ID) try: gcs_client.create_user_credential(req) except globus_sdk.GCSAPIError as err: # A user credential already exists, no need to create it. if err.http_status != 409: raise if __name__ == "__main__": main() index.rst000066400000000000000000000051541513221403200363160ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/create_guest_collectionCreating a Guest Collection =========================== Within the Globus Ecosystem, data is managed through the abstraction of *Collections*. The example included on this page demonstrate how to create specifically a *Guest Collection* using the Globus Python SDK. Guest collections, formerly known as "Shares", are collections which provide access to a subdirectory of an existing collection through a particular user or client's local permissions. Guest collections are a great way to set up data automation. They may be scoped down to a particular directory within an existing "Mapped Collection" and don't implicitly inherit the same authorization timeout requirements as their parent Mapped Collection. Once created, they can be shared to other users/entities, in effect giving another entity access, through you, to some underlying data. .. Warning:: While guest collections don't implicitly inherit their parent mapped collection's authorization timeout in some cases they do or alternatively may be disabled entirely. This is a decision made by the endpoint owner, not Globus. Because requirements can vary so drastically between endpoints, we recommend consulting with the particular endpoint's documentation and/or owner to determine whether guest collections provide the desired level of access with the desired minimization of authorization. .. Note:: The scripts reference a globus hosted "tutorial" mapped collection. This is just to provide as simple of a functioning example out of the box as possible. For actual application, replace the IDs with the relevant collection and storage gateway IDs. .. tab-set:: .. tab-item:: User-owned Collection This script demonstrates how to create a guest collection owned by a human. It will prompt the user to authenticate through a browser and authorize the script to act on their behalf. .. literalinclude:: create_guest_collection_user_owned.py :caption: ``create_guest_collection_user_owned.py`` [:download:`download `] :language: python .. tab-item:: Client-owned Collection This script demonstrates how to create a guest collection owned by a client (i.e. a service account). It will automatically request and use client access tokens based on the supplied client ID and secret. .. literalinclude:: create_guest_collection_client_owned.py :caption: ``create_guest_collection_client_owned.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/detecting_data_access/000077500000000000000000000000001513221403200341225ustar00rootroot00000000000000index.rst000066400000000000000000000127311513221403200357100ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/detecting_data_access.. currentmodule:: globus_sdk .. _userguide_detecting_data_access: Detecting data_access ===================== Globus Collections come in several varieties, but only some of them have a ``data_access`` scope. ``data_access`` scopes control application access to collections, allowing users to revoke access for an application independent from other application permissions. Revoking consent stops data transfers and other operations. Because only some collection types have ``data_access`` scopes, application authors interacting with these collections may need to detect the type of collection and determine whether or not the scope will be needed. For readers who prefer to start with complete working examples, jump ahead to the :ref:`example script `. Accessing Collections in Globus Transfer ---------------------------------------- The Globus Transfer service acts as a central registration hub for collections. Therefore, in order to get information about an unknown collection, we will need a :class:`TransferClient` with credentials. The following snippet creates a client and uses it to fetch a collection from the service: .. code-block:: python import globus_sdk # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc # replace with your own COLLECTION_ID COLLECTION_ID = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" with globus_sdk.UserApp( "detect-data-access-example", client_id=NATIVE_CLIENT_ID ) as app: transfer_client = globus_sdk.TransferClient(app=app) collection_doc = transfer_client.get_endpoint(COLLECTION_ID) .. note:: Careful readers may note that we use the :meth:`TransferClient.get_endpoint` method to lookup a collection. The Transfer service contains both Endpoints and Collections, and both document types are available from the Get Endpoint API. Reading Collection Type ----------------------- There are two attributes we need from the collection document to determine whether or not a ``data_access`` scope is used. First, whether or not the collection is a GCSv5 Mapped Collection: .. code-block:: python entity_type = collection_doc["entity_type"] is_v5_mapped_collection = entity_type == "GCSv5_mapped_collection" Second, whether or not the collection is a High Assurance Collection: .. code-block:: python is_high_assurance = collection_doc["high_assurance"] Once we have this information, we can deduce whether or not ``data_access`` is needed with the following boolean assignment: .. code-block:: python collection_uses_data_access = is_v5_mapped_collection and not is_high_assurance Converting Logic to a Helper Function ------------------------------------- In order to make the logic above reusable, we need to rephrase. One of the simpler approaches is to define a helper function which accepts the :class:`TransferClient` and collection ID as inputs. Here's a definition of such a helper which is broadly applicable: .. code-block:: python def uses_data_access( transfer_client: globus_sdk.TransferClient, collection_id: str ) -> bool: """ Use the provided `transfer_client` to lookup a collection by ID. Return `True` if the collection uses a `data_access` scope and `False` otherwise. """ doc = transfer_client.get_endpoint(collection_id) if doc["entity_type"] != "GCSv5_mapped_collection": return False if doc["high_assurance"]: return False return True Guarding ``data_access`` Scope Handling --------------------------------------- Now that we have a reusable helper for determining whether or not collections use a ``data_access`` scope, it's possible to use this to drive logic for scope manipulations. For example, we can choose to add ``data_access`` requirements to a :class:`GlobusApp` like so: .. code-block:: python # Globus Tutorial Collection 1 & 2 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d # replace with your desired collections SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" if uses_data_access(transfer_client, SRC_COLLECTION): transfer_client.add_app_data_access_scope(SRC_COLLECTION) if uses_data_access(transfer_client, DST_COLLECTION): transfer_client.add_app_data_access_scope(DST_COLLECTION) .. _userguide_detecting_data_access_example: Summary: Complete Example ------------------------- With these modifications in place, we can compile the above tooling into a complete script. *This example is complete. It should run without errors "as is".* .. literalinclude:: submit_transfer_detect_data_access.py :caption: ``submit_transfer_detect_data_access.py`` [:download:`download `] :language: python .. note:: Because the ``data_access`` requirement can't be detected until after you have logged in to the app, it is possible for this to result in a "double login" scenario. First, you login providing consent for Transfer, but then a ``data_access`` scope is found to be needed. You then have to login again to satisfy that requirement. submit_transfer_detect_data_access.py000066400000000000000000000035401513221403200434700ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/detecting_data_accessimport globus_sdk def uses_data_access( transfer_client: globus_sdk.TransferClient, collection_id: str ) -> bool: """ Use the provided `transfer_client` to lookup a collection by ID. Based on the record, return `True` if it uses a `data_access` scope and `False` otherwise. """ doc = transfer_client.get_endpoint(collection_id) if doc["entity_type"] != "GCSv5_mapped_collection": return False if doc["high_assurance"]: return False return True # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 & 2 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d # replace with your desired collections SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" SRC_PATH = "/home/share/godata/file1.txt" DST_PATH = "/~/example-transfer-script-destination.txt" with globus_sdk.UserApp( "detect-data-access-example", client_id=NATIVE_CLIENT_ID ) as app: with globus_sdk.TransferClient(app=app) as transfer_client: # check if either source or dest needs data_access, and if so add the relevant # requirement if uses_data_access(transfer_client, SRC_COLLECTION): transfer_client.add_app_data_access_scope(SRC_COLLECTION) if uses_data_access(transfer_client, DST_COLLECTION): transfer_client.add_app_data_access_scope(DST_COLLECTION) transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) task = transfer_client.submit_transfer(transfer_request) print(f"Submitted transfer. Task ID: {task['task_id']}.") globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/index.rst000066400000000000000000000011701513221403200315020ustar00rootroot00000000000000.. _userguide_data_transfer: Data Transfer ============= Globus provides numerous features and components aimed at robust and reliable data transfer, integrating with your existing storage systems. These docs focus on Globus Connect Personal, Globus Connect Server, and Globus Transfer. Additionally, integrating these components with other Globus services like Timers is covered. .. toctree:: :caption: How to Manage Data Transfers using the SDK :maxdepth: 1 submit_transfer/index scheduled_transfers/index detecting_data_access/index create_guest_collection/index transfer_relative_deadline/index globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/000077500000000000000000000000001513221403200336715ustar00rootroot00000000000000create_timer.py000066400000000000000000000047271513221403200366410ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/scheduled_transfersimport datetime import globus_sdk from globus_sdk.experimental.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" SRC_PATH = "/share/godata/file1.txt" # Globus Tutorial Collection 2 # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" DST_PATH = "/~/example-timer-destination.txt" # as with an immediate data transfer, we take our input data and wrap them in # a TransferData object, representing the transfer task transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) # we'll define the timer as one which runs every hour for 3 days # declare these data in the form of a "schedule" for the timer # # a wide variety of schedules are possible here; to setup a recurring timer: # - you MUST declare an interval for the timer (`interval_seconds`) # - you MAY declare an end condition (`end`) schedule = globus_sdk.RecurringTimerSchedule( interval_seconds=3600, end={ "condition": "time", "datetime": datetime.datetime.now() + datetime.timedelta(days=3), }, ) with UserApp("manage-timers-example", client_id=NATIVE_CLIENT_ID) as app: # create a TimersClient to interact with the service, and register any data_access # scopes for the collections with globus_sdk.TimersClient(app=app) as timers_client: # Omit this step if the collections are either # (1) A guest collection or (2) high assurance. timers_client.add_app_transfer_data_access_scope( (SRC_COLLECTION, DST_COLLECTION) ) # submit the creation request to the service, printing out the # ID of your new timer after it's created -- you can find it in # https://app.globus.org/activity/timers timer = timers_client.create_timer( globus_sdk.TransferTimer( name=( "create-timer-example " f"[created at {datetime.datetime.now().isoformat()}]" ), body=transfer_request, schedule=schedule, ) ) print("Finished submitting timer.") print(f"timer_id: {timer['timer']['job_id']}") create_timer_detect_data_access.py000066400000000000000000000063421513221403200424760ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/scheduled_transfersimport datetime import globus_sdk from globus_sdk.experimental.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" SRC_PATH = "/home/share/godata/file1.txt" # Globus Tutorial Collection 2 # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" DST_PATH = "/~/example-timer-destination.txt" def uses_data_access(client: globus_sdk.TransferClient, collection_id: str) -> bool: """ Lookup the given collection ID. Having looked up the record, return `True` if it uses a `data_access` scope and `False` otherwise. """ doc = client.get_endpoint(collection_id) if doc["entity_type"] != "GCSv5_mapped_collection": return False if doc["high_assurance"]: return False return True # we'll define the timer as one which runs every hour for 3 days # declare these data in the form of a "schedule" for the timer # # a wide variety of schedules are possible here; to setup a recurring timer: # - you MUST declare an interval for the timer (`interval_seconds`) # - you MAY declare an end condition (`end`) schedule = globus_sdk.RecurringTimerSchedule( interval_seconds=3600, end={ "condition": "time", "datetime": datetime.datetime.now() + datetime.timedelta(days=3), }, ) with UserApp("manage-timers-example", client_id=NATIVE_CLIENT_ID) as app: # as with an immediate data transfer, we take our input data and wrap them in # a TransferData object, representing the transfer task transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) with ( # we need a TransferClient, for the needs_data_access helper globus_sdk.TransferClient(app=app) as transfer_client, # create a TimersClient to interact with the service, and register any # data_access scopes for the collections globus_sdk.TimersClient(app=app) as timers_client, ): # Detect on each collection whether or not we need `data_access` scopes and # apply if necessary if uses_data_access(transfer_client, SRC_COLLECTION): timers_client.add_app_transfer_data_access_scope(SRC_COLLECTION) if uses_data_access(transfer_client, DST_COLLECTION): timers_client.add_app_transfer_data_access_scope(DST_COLLECTION) # submit the creation request to the service, printing out the # ID of your new timer after it's created -- you can find it in # https://app.globus.org/activity/timers timer = timers_client.create_timer( globus_sdk.TransferTimer( name=( "create-timer-example " f"[created at {datetime.datetime.now().isoformat()}]" ), body=transfer_request, schedule=schedule, ) ) print("Finished submitting timer.") print(f"timer_id: {timer['timer']['job_id']}") index.rst000066400000000000000000000057711513221403200354650ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers.. currentmodule:: globus_sdk .. _userguide_scheduled_transfers: Scheduling a Repeating Transfer =============================== The Globus Timers service allows users to schedule tasks to run at a future date or on a recurring schedule. In particular, Timers allows scheduled ``Transfer Tasks``, which can be submitted very similarly to submissions to the Globus Transfer service. Creating a Transfer Timer ------------------------- To setup recurring transfers, you will need to create a timer. The timer *contains* a Transfer Task submission. It will submit that Transfer Task each time it runs. .. literalinclude:: create_timer.py :caption: ``create_timer.py`` [:download:`download `] :language: python Discover Data Access Scopes and Create a Timer ---------------------------------------------- Unlike direct ``Transfer Task`` submission, creating a Timer with unknown inputs won't give you an error immediately if you need ``data_access`` scopes because that information isn't available until the Timer runs. Therefore, if the input collections are variable, we need to enhance the previous example to automatically determine whether or not the ``data_access`` scope is needed. We'll do this with a new ``uses_data_access`` helper and a :class:`TransferClient`: .. code-block:: python with globus_sdk.TransferClient(app=app) as transfer_client: ... # a code block which can use the helper def uses_data_access(client: globus_sdk.TransferClient, collection_id: str) -> bool: """ Lookup the given collection ID. Having looked up the record, return `True` if it uses a `data_access` scope and `False` otherwise. """ doc = client.get_endpoint(collection_id) if doc["entity_type"] != "GCSv5_mapped_collection": return False if doc["high_assurance"]: return False return True This will allow us to guard our use of the ``data_access`` scope thusly: .. code-block:: python if uses_data_access(transfer_client, SRC_COLLECTION): timers_client.add_app_transfer_data_access_scope(SRC_COLLECTION) if uses_data_access(transfer_client, DST_COLLECTION): timers_client.add_app_transfer_data_access_scope(DST_COLLECTION) .. note:: Because the ``data_access`` requirement can't be detected until after you have logged in to the app, it is possible for this to result in a "double login" scenario. First, you login providing consent for Timers and Transfer, but then a ``data_access`` scope is found to be needed. You then have to login again to satisfy that requirement. The ``UserApp`` will track the addition until you use ``app.logout``, however, so this only happens the first time the script runs. With these modifications in place, the resulting script looks like so: .. literalinclude:: create_timer_detect_data_access.py :caption: ``create_timer_detect_data_access.py`` [:download:`download `] :language: python globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/submit_transfer/000077500000000000000000000000001513221403200330515ustar00rootroot00000000000000index.rst000066400000000000000000000065771513221403200346520ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/submit_transfer.. _userguide_submit_transfer: Initiating a Transfer ===================== Moving data within the Globus Ecosystem is performed by submitting a ``Transfer Task`` against the Globus Transfer service. The below examples demonstrate how to do that using a globus sdk ``TransferClient``. They are split into two categories: #. :ref:`transferring-between-known-collections` - both source and destination collections are known in advance and are likely be hardcoded into your script. #. :ref:`transferring-between-unknown-collections` - either the source or destination collection will be determined at runtime (e.g. by script argument). We differentiate these examples because certain collections have special auth requirements which must either be defined up front or fixed reactively if omitted. Certain collections (mapped non-high assurance ones) require that a special scope ("data_access") to be attached to the transfer request to grant Transfer access to that collection's data. If both collections are known this can be done proactively with a call to the ``add_app_data_access_scope`` method. If, however, one or more collections are unknown, the script must reactively solve the ``ConsentRequired`` error that is raised when the transfer is submitted. .. _transferring-between-known-collections: Transferring data between two known collections ----------------------------------------------- .. note:: The script references two globus hosted "tutorial" collections. Replace these ids & paths with your own collection ids and paths to move your own data. .. note:: Some collections require you to attach a "data_access" scope to your transfer request. You should evaluate whether this is necessary for both your source and destination collections and omit the ``transfer_client.add_app_data_access_scope`` calls as needed. A collection requires "data_access" if it is (1) a mapped collection and (2) is not high assurance. .. literalinclude:: submit_transfer_collections_known.py :caption: ``submit_transfer_collections_known.py`` [:download:`download `] :language: python .. _transferring-between-unknown-collections: Transferring data where at least one collection is unknown ---------------------------------------------------------- In the case where your script does not know the full set of collections that it will be interacting with, you may need to reactively respond to ``ConsentRequired`` errors instead of proactively attaching the "data_access" scope. This script demonstrates how to do that by: #. Enabling auto-redrive of GAREs on the GlobusApp. #. Submitting the transfer without any "data_access" scopes. If the API signals that we did need a data_access scope, the script will prompt the user to login again with the proper consent & re-attempt the transfer submission. .. note:: The script references two globus hosted "tutorial" collections. Replace these ids & paths with your own collection ids and paths to move your own data. .. warning:: Given that this script reactively fixes auth states, it could involve two user login interactions instead of the one guaranteed by the above proactive approach. .. literalinclude:: submit_transfer_collections_unknown.py :caption: ``submit_transfer_collections_unknown.py`` [:download:`download `] :language: python submit_transfer_collections_known.py000066400000000000000000000026151513221403200423710ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/submit_transferimport globus_sdk from globus_sdk.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" SRC_PATH = "/share/godata/file1.txt" # Globus Tutorial Collection 2 # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" DST_PATH = "/~/example-transfer-script-destination.txt" def main(): with UserApp("my-simple-transfer", client_id=NATIVE_CLIENT_ID) as app: with globus_sdk.TransferClient(app=app) as client: submit_transfer(client) def submit_transfer(transfer_client: globus_sdk.TransferClient): # Comment out each of these lines if the referenced collection is either # (1) A guest collection or (2) high assurance. transfer_client.add_app_data_access_scope(SRC_COLLECTION) transfer_client.add_app_data_access_scope(DST_COLLECTION) transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) task = transfer_client.submit_transfer(transfer_request) print(f"Submitted transfer. Task ID: {task['task_id']}.") if __name__ == "__main__": main() submit_transfer_collections_unknown.py000066400000000000000000000023431513221403200427320ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/submit_transferimport globus_sdk from globus_sdk import GlobusAppConfig, UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" SRC_PATH = "/share/godata/file1.txt" # Globus Tutorial Collection 2 # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" DST_PATH = "/~/example-transfer-script-destination.txt" def main(): with UserApp( "my-simple-transfer", client_id=NATIVE_CLIENT_ID, config=GlobusAppConfig(auto_redrive_gares=True), ) as app: with globus_sdk.TransferClient(app=app) as client: submit_transfer(client) def submit_transfer(transfer_client: globus_sdk.TransferClient): transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) task = transfer_client.submit_transfer(transfer_request) print(f"Submitted transfer. Task ID: {task['task_id']}.") if __name__ == "__main__": main() transfer_relative_deadline/000077500000000000000000000000001513221403200351275ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transferindex.rst000066400000000000000000000102441513221403200367710ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline.. currentmodule:: globus_sdk .. _userguide_transfer_relative_deadline: Setting a Relative Deadline for a Transfer ========================================== When transferring or deleting data via Globus Transfer, users are able to set a ``deadline`` for their tasks. This allows you to declare a time by which the task must be completed -- if the deadline is reached and the task is still in progress, it will be cancelled. The ``deadline`` field in a Transfer task takes a date and time as a string, with support for common ISO 8601 format. Because of the use of standard formats, it is easy to use the Python ``datetime`` module to compute a relative deadline at some point in the future. You can use this to easily submit tasks with deadlines limited to the next minute, hour, or day. You can use this, for example, to enforce that a Transfer Task which takes too long results in errors (even if it is making slow progress). .. note:: Not all ISO 8601 syntaxes are supported. We recommend sticking to the restrictive subset defined by RFC 3339, in which date-time data is typically formatted as "YYYY-MM-DD'T'HH:mm:SSZ". :py:meth:`datetime.isoformat() ` follows this format and is therefore a great choice for Python programmers! For readers who prefer to start with complete working examples, jump ahead to the :ref:`example script `. Computing and Formatting a Deadline ----------------------------------- We need to compute a relative deadline -- some point into the future -- and format it as a string. We'll express that idea in a function which takes a :class:`datetime.timedelta` as an ``offset``, an amount of time into the future. This gives us a generic phrasing of getting a future date: .. code-block:: python import datetime def make_relative_deadline(offset: datetime.timedelta) -> str: now = datetime.datetime.now(tz=datetime.timezone.utc) deadline = now + offset return deadline.isoformat() We can then see that this works by testing it out: .. code-block:: pycon >>> make_relative_deadline(datetime.timedelta(minutes=10)) '2003-09-21T18:58:09.279314+00:00' Creating a Task with the Deadline --------------------------------- ``deadline`` is an initialization parameter to :class:`TransferData` and :class:`DeleteData`. Along with all of our other parameters to create the Transfer Task, here's a sample task document with a deadline set for "an hour from now": .. code-block:: python # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" SRC_PATH = "/share/godata/file1.txt" # Globus Tutorial Collection 2 # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" DST_PATH = "/~/example-transfer-script-destination.txt" # create a Transfer Task request document, including a relative deadline transfer_request = globus_sdk.TransferData( SRC_COLLECTION, DST_COLLECTION, deadline=make_relative_deadline(datetime.timedelta(hours=1)), ) transfer_request.add_item(SRC_PATH, DST_PATH) This is then valid to submit with a :class:`TransferClient`: .. code-block:: python tc = globus_sdk.TransferClient(...) tc.submit_transfer(transfer_request) .. _userguide_transfer_relative_deadline_example: Summary: Complete Example ------------------------- For a complete example script, we will need to also include a :class:`GlobusApp` for login, so that we can setup the :class:`TransferClient` correctly. We'll also take a small step to make the script work with mapped collections which require the ``data_access`` scope, like the tutorial collections. With those small additions, the above examples can be turned into a working script! *This example is complete. It should run without errors "as is".* .. literalinclude:: submit_transfer_relative_deadline.py :caption: ``submit_transfer_relative_deadline.py`` [:download:`download `] :language: python submit_transfer_relative_deadline.py000066400000000000000000000031241513221403200444300ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadlineimport datetime import globus_sdk from globus_sdk.globus_app import UserApp # Tutorial Client ID - NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # Globus Tutorial Collection 1 # https://app.globus.org/file-manager/collections/6c54cade-bde5-45c1-bdea-f4bd71dba2cc SRC_COLLECTION = "6c54cade-bde5-45c1-bdea-f4bd71dba2cc" SRC_PATH = "/home/share/godata/file1.txt" # Globus Tutorial Collection 2 # https://app.globus.org/file-manager/collections/31ce9ba0-176d-45a5-add3-f37d233ba47d DST_COLLECTION = "31ce9ba0-176d-45a5-add3-f37d233ba47d" DST_PATH = "/~/example-transfer-script-destination.txt" def make_relative_deadline(offset: datetime.timedelta) -> str: now = datetime.datetime.now(tz=datetime.timezone.utc) deadline = now + offset return deadline.isoformat() with UserApp("relative-deadline-transfer", client_id=NATIVE_CLIENT_ID) as app: with globus_sdk.TransferClient(app=app) as transfer_client: # Comment out each of these lines if the referenced collection is either # (1) A guest collection or (2) high assurance. transfer_client.add_app_data_access_scope(SRC_COLLECTION) transfer_client.add_app_data_access_scope(DST_COLLECTION) transfer_request = globus_sdk.TransferData( SRC_COLLECTION, DST_COLLECTION, deadline=make_relative_deadline(datetime.timedelta(hours=1)), ) transfer_request.add_item(SRC_PATH, DST_PATH) task = transfer_client.submit_transfer(transfer_request) print(f"Submitted transfer. Task ID: {task['task_id']}") globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/index.rst000066400000000000000000000016261513221403200266730ustar00rootroot00000000000000.. _userguide_patterns: Usage Patterns ============== Globus offers a wide variety of services and functionality to assist research science. Additionally, ``globus_sdk`` itself offers high level abstractions over these capabilities. Tying together the various components into a working application is therefore often complex, and this section of the documentation provides specific scenarios or patterns of use which are reusable across projects. Reference documentation is indexed by specific component, and therefore separated into pages and sections by each subservice. By contrast, the Usage Pattern documentation is organized by how a user is trying to leverage Globus for their needs. Data Transfer ------------- These documents cover various ways of setting up and coordinating data transfers. .. toctree:: :caption: Contents :maxdepth: 1 data_transfer/index sessions_and_consents/index globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consents/000077500000000000000000000000001513221403200314315ustar00rootroot00000000000000coalescing_requirements_errors/000077500000000000000000000000001513221403200376605ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consentscoalesce_gares.py000066400000000000000000000132711513221403200431750ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consents/coalescing_requirements_errorsimport globus_sdk.gare def coalesce( *gares: globus_sdk.gare.GARE, session_message: str | None = None, prompt: str | None = None, ) -> list[globus_sdk.gare.GARE]: # build a list of GARE fields which are allowed to merge safe_fields = ["session_required_policies", "required_scopes"] if session_message is not None: safe_fields.append("session_message") if prompt is not None: safe_fields.append("prompt") # Build lists of GAREs that can and cannot be merged candidates, non_candidates = [], [] for g in gares: if _is_candidate(g, safe_fields): candidates.append(g) else: non_candidates.append(g) # if no GAREs were safe to merge, return early if not candidates: return non_candidates # merge safe GAREs and override any provided field values combined = _safe_combine(candidates) if session_message is not None: combined.authorization_parameters.session_message = session_message if prompt is not None: combined.authorization_parameters.prompt = prompt # return the reduced list of GAREs return [combined] + non_candidates def _is_candidate(g: globus_sdk.gare.GARE, safe_fields: list[str]) -> bool: params = g.authorization_parameters # check all of the supported GARE fields for field_name in ( "session_message", "session_required_identities", "session_required_policies", "session_required_single_domain", "session_required_mfa", "required_scopes", "prompt", ): # if the field is considered safe, ignore it if field_name in safe_fields: continue # if the field isn't considered safe and it is set, # then the GARE shouldn't be merged if getattr(params, field_name) is not None: return False # if we didn't find any invalidating fields, it must be safe to merge return True def _safe_combine(mergeable_gares: list[globus_sdk.gare.GARE]) -> globus_sdk.gare.GARE: code = "AuthorizationRequired" if all(g.code == "ConsentRequired" for g in mergeable_gares): code = "ConsentRequired" combined_params = globus_sdk.gare.GlobusAuthorizationParameters( session_required_policies=_concat( [ g.authorization_parameters.session_required_policies for g in mergeable_gares ] ), required_scopes=_concat( [g.authorization_parameters.required_scopes for g in mergeable_gares] ), ) return globus_sdk.gare.GARE(code=code, authorization_parameters=combined_params) def _concat(values: list[list[str] | None]) -> list[str] | None: if all(v is None for v in values): return None return [element for value in values if value is not None for element in value] if __name__ == "__main__": # these are example errors case1 = globus_sdk.gare.to_gare( { "code": "ConsentRequired", "authorization_parameters": {"required_scopes": ["foo"]}, } ) case2 = globus_sdk.gare.to_gare( { "code": "ConsentRequired", "authorization_parameters": {"required_scopes": ["bar"]}, } ) case3 = globus_sdk.gare.to_gare( { "code": "AuthorizationRequired", "authorization_parameters": {"required_scopes": ["baz"]}, } ) case4 = globus_sdk.gare.to_gare( { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_policies": [ "f2047039-2f07-4f13-b21b-b2edf7f9d329", "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7", ], }, } ) case5 = globus_sdk.gare.to_gare( { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_policies": [ "f2047039-2f07-4f13-b21b-b2edf7f9d329", "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7", ], "session_required_mfa": True, }, } ) case6 = globus_sdk.gare.to_gare( { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_policies": ["ba10f6f1-5b23-4703-bfb7-4fdd7b529546"], "session_message": "needs a policy", }, } ) case7 = globus_sdk.gare.to_gare( { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_policies": ["ba10f6f1-5b23-4703-bfb7-4fdd7b529546"], "prompt": "login", }, } ) print("\n--full merge--\n") print("\ncombining two:") for g in coalesce(case1, case2): print(" -", g) print("\ncombining three:") for g in coalesce(case1, case2, case3): print(" -", g) print("\ncombining four:") for g in coalesce(case1, case2, case3, case4): print(" -", g) print("\n--no merge--\n") print("\ncombining two:") for g in coalesce(case1, case5): print(" -", g) print("\ncombining two:") for g in coalesce(case4, case5): print(" -", g) print("\ncombining two:") for g in coalesce(case2, case6): print(" -", g) print("\ncombining two:") for g in coalesce(case4, case7): print(" -", g) print("\n--merge due to explicit param--\n") print("\ncombining two:") for g in coalesce(case1, case6, session_message="explicit message"): print(" -", g) print("\ncombining two:") for g in coalesce(case4, case7, prompt="login"): print(" -", g) index.rst000066400000000000000000000165331513221403200415310ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consents/coalescing_requirements_errors.. _userguide_coalesce_gares: Coalescing Requirements Errors ============================== A common pattern for applications which want to provide the best possible user experience is to defer raising errors until all discoverable issues are identified. Instead of immediately, eagerly raising an error, the application can provide a superior interface by collecting all of the errors at once and presenting them to the user to resolve. In the context of Globus Auth applications, this can manifest as multiple interactions -- direct or indirect -- which produce Globus Auth Requirements Errors (represented in the SDK as the :class:`globus_sdk.gare.GARE` class). Each :class:`globus_sdk.gare.GARE` can be resolved by a separate login flow, but for an application which collects 3 ``GARE``\s, this would mean asking the user to login 3 times in a row! Applications which collect multiple such errors want to present the user with the fewest possible login flows. Unfortunately, ``GARE``\s are capable of expressing constraints which cannot be safely merged into a single login flow -- requirements may be mutually exclusive, or they may overlap in ill-defined ways. Merging or "coalescing" ``GARE``\s is difficult to define in the general case, but if you have more knowledge of your applications' requirements, you may be able to do more than is generally safe. In this doc, we'll share some theoretical cases of ``GARE``\s which - definitely can be merged together safely - definitely cannot be merged together safely - possibly can be merged together For readers who prefer to start with complete working examples, jump ahead to the :ref:`example script ` which shows a simple and safe merge procedure -- although it might not simplify all possible combinations. GAREs Which Can Safely Merge ---------------------------- Two of the fields in a ``GARE`` are arrays of values which are always combined with "and" semantics. As a result, they can always be safely combined with array concatenation. These fields are: - ``required_scopes`` - ``session_required_policies`` Additionally, the ``code`` field is defined as a non-semantic hint, and is therefore safe to rewrite. Although services may use other values in practice, there are only two well-defined values for the ``code`` string: - ``ConsentRequired``: indicates that the user must consent to additional scopes in order to authorize the resource server(s) to complete the requested action. - ``AuthorizationRequired``: indicates that this is a Globus Auth Requirements Error that is not more specifically described by any other code. Because ``AuthorizationRequired`` is generic, it can always be safely used for a ``GARE`` produced from other requirements. Safe Merge Example ^^^^^^^^^^^^^^^^^^ For example, .. code-block:: json { "code": "ConsentRequired", "authorization_parameters": { "required_scopes": ["foo"] } } and .. code-block:: json { "code": "AuthorizationRequired", "authorization_parameters": { "required_scopes": ["bar"], "session_required_policies": [ "f2047039-2f07-4f13-b21b-b2edf7f9d329", "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7" ] } } can safely merge into .. code-block:: json { "code": "AuthorizationRequired", "authorization_parameters": { "required_scopes": ["foo", "bar"], "session_required_policies": [ "f2047039-2f07-4f13-b21b-b2edf7f9d329", "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7" ] } } GAREs Which Cannot Safely Merge ------------------------------- There are no strictly defined merge semantics for any of the fields in ``GARE``\s, but in particular we can see problems when trying to merge together ``session_required_single_domain``. This field expresses the idea that a user must have an identity from *one of* the listed domains, meaning it uses "or" semantics. However, two separate ``GARE``\s naturally communicate "and" semantics, so merging two such lists together produces an incorrect result. Unsafe Merge Example ^^^^^^^^^^^^^^^^^^^^ For example, .. code-block:: json { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_single_domain": ["umich.edu"] } } and .. code-block:: json { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_single_domain": ["stanford.edu"] } } cannot merge together! As a pair, these documents express "the user must have an in-session ``umich.edu`` identity" **and** "the user must have an in-session ``stanford.edu`` identity". If we combine them into .. code-block:: json { "code": "AuthorizationRequired", "authorization_parameters": { "session_required_single_domain": ["umich.edu", "stanford.edu"] } } we accidentally express the idea that "the user must have an in-session ``umich.edu`` identity" **or** "the user must have an in-session ``stanford.edu`` identity". We have accidentally transformed the "and" into an "or"! GAREs Which Can Safely Merge *Sometimes* ---------------------------------------- Some fields in ``GARE``\s cannot be merged without some decision being made. For example, ``session_message`` is a string message to display to the user. When combining two ``GARE``\s with distinct messages, how should the result be formulated? Should the messages be joined with some separator? Should a new message be composed? There is no strictly correct answer. However! An application which is, itself, replacing the ``session_message`` can safely ignore this conflict because it was planning to change the message anyway. Maybe Safe Merge Example ^^^^^^^^^^^^^^^^^^^^^^^^ For example, .. code-block:: json { "code": "ConsentRequired", "authorization_parameters": { "session_message": "You need authorization for Service Foo!", "required_scopes": ["foo"] } } and .. code-block:: json { "code": "ConsentRequired", "authorization_parameters": { "session_message": "You need authorization for Service Bar!", "required_scopes": ["bar"] } } Could be combined into .. code-block:: json { "code": "ConsentRequired", "authorization_parameters": { "session_message": "You need to authorize use of Services Foo and Bar.", "required_scopes": ["foo", "bar"] } } This is safe because ``session_message`` is known to be non-semantic for the purpose of authorizing the user. .. _userguide_coalesce_gares_example: Summary: Complete Example ------------------------- Even knowing that ``GARE``\s cannot be safely combined in many cases, as established in the introduction above, doing so is highly desirable for some applications. This example merges together these documents, represented as :class:`globus_sdk.gare.GARE` objects, only in cases which are defined to be safe. If ``session_message`` or ``prompt`` values are supplied, they will override any values for these fields present in the GAREs, allowing the ``GARE``\s to merge more aggressively. .. literalinclude:: coalesce_gares.py :caption: ``coalesce_gares.py`` [:download:`download `] :language: python .. note:: This example discards extra fields, which a ``GARE`` may store, but which are not part of the format specification. handling_transfer_auth_params/000077500000000000000000000000001513221403200374265ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consentsindex.rst000066400000000000000000000244731513221403200413010ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consents/handling_transfer_auth_params.. _userguide_handle_transfer_auth_params: Handling Authorization Parameters from a Transfer Operation =========================================================== Globus Transfer interacts with Collections and their underlying Endpoints both synchronously and asynchronously. The example below demonstrates a synchronous interaction -- ``ls`` on a collection -- with handling for the possibility that a session-related error is returned. The session error can be parsed into a Globus Auth Requirements Error document -- a :class:`globus_sdk.gare.GARE` -- via ``globus_sdk.gare``. Once the error is recognized, the parameters parsed from it can be fed back into a login flow. Doing so will update the user's session, resolving the issue. Creating a Test Collection -------------------------- In order to test this sample script in full, you will need a collection with a session policy which requires users to login with a specific ID or within a certain time period. The following section covers a recommended test collection configuration. This will require administrative access to a Globus Connect Server endpoint. .. note:: Globus tutorial resources do not contain session policies, making them easier to use. However, this means they are not suitable for the example script given below. Tutorial collections will work, but they will not demonstrate the interesting features in use. Create a POSIX Storage Gateway With a Timeout Policy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ On a Globus Connect Server Endpoint which you administer, create a new Storage Gateway with a policy explicitly designed for testing. The gateway will need the following properties, with the following recommended values: - gateway type: ``posix`` - display name: ``sdk-test-policy`` - root path: ``/`` - domain requirements: require a domain in which you have an identity - authentication timeout in minutes: 1 You can create a gateway with a command like the following, which uses the ``uchicago.edu`` domain as an example: .. code-block:: bash globus-connect-server storage-gateway create posix \ sdk-test-gateway --authentication-timeout-mins 1 \ --domain uchicago.edu Record the storage gateway ID: .. code-block:: bash STORAGE_GATEWAY_ID="..." Using a one minute timeout will make the session handling very easy to test. Simply interact with the collection, wait one minute, and then try using it again -- you should be prompted to log in a second time to meet the timeout requirement. .. tip:: You do not need to be an administrator for the domain you set, but you do need an identity from that domain. You are setting a policy for your Storage Gateway with this setting to require identities from that domain. If you don't have a ``uchicago.edu`` identity, you could create a gateway like the example, but you wouldn't be able to use it! Create a Mapped Collection on that Gateway ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The Storage Gateway encodes the requisite policy. No special configuration is needed for the mapped collection used for testing. Simply create a collection on the test gateway, e.g.: .. code-block:: bash globus-connect-server collection create \ "$STORAGE_GATEWAY_ID" / sdk-test-collection Note the collection ID, we will need it in the following steps. Run ``ls`` on the Collection ---------------------------- Running a simple ``ls`` on this collection is no different from doing so on any other collection. We have setup a 1 minute timeout for the test collection, so this will likely work the first time that it runs. .. code-block:: python import globus_sdk # this is the SDK tutorial client ID, replace with your own ID NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # set the collection ID to your test collection COLLECTION_ID = "..." with globus_sdk.UserApp("ls-session", client_id=NATIVE_CLIENT_ID) as app: client = globus_sdk.TransferClient(app=app) # because the recommended test configuration uses a mapped collection # without High Assurance capabilities, it will have a data_access scope # requirement # comment out this line if your collection does not use data_access client.add_app_data_access_scope(COLLECTION_ID) ls_result = client.operation_ls(COLLECTION_ID) for item in ls_result: name = item["name"] if item["type"] == "dir": name += "/" print(name) Run Again, Observe the Session Error ------------------------------------ The 1 minute timeout guarantees that we can trivially produce a session timeout error by waiting one minute and running the script again. You should see a stacktrace like the following: .. code-block:: text Traceback (most recent call last): File "/home/demo-user/ls_session.py", line 18, in ls_result = client.operation_ls(COLLECTION_ID) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/demo-user/.venv/lib/python3.2/globus_sdk/services/transfer/client.py", line 1286, in operation_ls self.get(f"operation/endpoint/{endpoint_id}/ls", query_params=query_params) File "/home/demo-user/.venv/lib/python3.2/globus_sdk/client.py", line 273, in get return self.request( ^^^^^^^^^^^^^ File "/home/demo-user/.venv/lib/python3.2/globus_sdk/client.py", line 461, in request raise self.error_class(r) globus_sdk.services.transfer.errors.TransferAPIError: ('GET', 'https://transfer.api.globus.org/v0.10/operation/endpoint/2a0d28b8-d2a3-4143-9f76-93ab09497b85/ls', 'Bearer', 502, 'ExternalError.DirListingFailed.LoginFailed', 'Command Failed: Error (login)\nEndpoint: uc-restricted-col (2a0d28b8-d2a3-4143-9f76-93ab09497b85)\nServer: 44.201.14.78:443\nMessage: Login Failed\n---\nDetails: 530-Login incorrect. : GlobusError: v=1 c=LOGIN_DENIED\\r\\n530-GridFTP-Message: None of your authenticated identities are from domains allowed by resource policies\\r\\n530-GridFTP-JSON-Result: {"DATA_TYPE": "result#1.1.0", "authorization_parameters": {"session_required_single_domain": ["uchicago.edu"]}, "code": "permission_denied", "detail": {"DATA_TYPE": "not_from_allowed_domain#1.0.0", "allowed_domains": ["uchicago.edu"]}, "has_next_page": false, "http_response_code": 403, "message": "None of your authenticated identities are from domains allowed by resource policies"}\\r\\n530 End.\\r\\n\n', '7U5dNqzDn') The exact details will vary, but the primary content of the error is that you do not satisfy the session requirements of the collection. Parse the Error and Confirm ``authorization_parameters`` are present ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Handling and parsing this error requires that we add an exception handler. To enhance readability, we will wrap the ``ls`` operation in a function. The caught exception will have an ``info`` property with populated ``authorization_parameters``. We can check for this easily: .. code-block:: python import globus_sdk # this is the SDK tutorial client ID, replace with your own ID NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # set the collection ID to your test collection COLLECTION_ID = "..." def print_ls_data(client): ls_result = client.operation_ls(COLLECTION_ID) for item in ls_result: name = item["name"] if item["type"] == "dir": name += "/" print(name) with globus_sdk.UserApp("ls-session", client_id=NATIVE_CLIENT_ID) as app: client = globus_sdk.TransferClient(app=app) # because the recommended test configuration uses a mapped collection # without High Assurance capabilities, it will have a data_access scope # requirement # comment out this line if your collection does not use data_access client.add_app_data_access_scope(COLLECTION_ID) # do the `ls` and detect the authorization_parameters as being present try: print_ls_data() except globus_sdk.TransferAPIError as err: if err.info.authorization_parameters: print("An authorization requirement was not met.") else: raise Convert the Error to GARE Format ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Globus Auth Requirements Errors ("GAREs") are a standardized format for Globus Auth session information to be passed between components. When these ``authorization_parameters`` errors are encountered, they can be converted to the GARE format using the tooling in ``globus_sdk.gare``: .. code-block:: python import globus_sdk.gare try: print_ls_data(client) except globus_sdk.TransferAPIError as err: if err.info.authorization_parameters: print("An authorization requirement was not met.") gare = globus_sdk.gare.to_gare(err) print("GARE data:", str(gare)) else: raise Redrive Logins with the Parsed Authorization Params --------------------------------------------------- Now that we have the data parsed into GARE format, it is relatively simple to pass the parsed ``authorization_parameters`` data to a login flow. Because the underlying requirement in this case is a new login flow, we must also set ``prompt=login`` in the parameters. Having done so, we can repeat the login step using that information: .. code-block:: python try: print_ls_data(client) except globus_sdk.TransferAPIError as err: if err.info.authorization_parameters: print("An authorization requirement was not met. Logging in again...") gare = globus_sdk.gare.to_gare(err) params = gare.authorization_parameters # set 'prompt=login', which guarantees a fresh login without # reliance on the browser session params.prompt = "login" # pass these parameters into a login flow USER_APP.login(auth_params=params) else: raise Summary: Complete Example ------------------------- Combining the pieces above, we can construct a working complete example script. A collection with session requirements is needed in order to demonstrate the behavior. .. literalinclude:: ls_with_session_handling.py :caption: ``ls_with_session_handling.py`` [:download:`download `] :language: python ls_with_session_handling.py000066400000000000000000000034471513221403200450700ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consents/handling_transfer_auth_paramsimport globus_sdk import globus_sdk.gare # this is the SDK tutorial client ID, replace with your own ID NATIVE_CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" # set the collection ID to your test collection COLLECTION_ID = "..." def print_ls_data(client): ls_result = client.operation_ls(COLLECTION_ID) for item in ls_result: name = item["name"] if item["type"] == "dir": name += "/" print(name) with globus_sdk.UserApp("ls-session", client_id=NATIVE_CLIENT_ID) as app: client = globus_sdk.TransferClient(app=app) # because the recommended test configuration uses a mapped collection # without High Assurance capabilities, it will have a data_access scope # requirement # comment out this line if your collection does not use data_access client.add_app_data_access_scope(COLLECTION_ID) # try to run the desired operation (`print_ls_data`) try: print_ls_data(client) # catch the possible API error except globus_sdk.TransferAPIError as err: # if there are authorization parameters data in the error, # use it to redrive login if err.info.authorization_parameters: print("An authorization requirement was not met. Logging in again...") gare = globus_sdk.gare.to_gare(err) params = gare.authorization_parameters # set 'prompt=login', which guarantees a fresh login without # reliance on the browser session params.prompt = "login" # pass these parameters into a login flow app.login(auth_params=params) # rerun the desired print_ls_data() operation print_ls_data(client) # otherwise, there are no authorization parameters, so reraise the error else: raise globus-globus-sdk-python-6a080e4/docs/user_guide/usage_patterns/sessions_and_consents/index.rst000066400000000000000000000024741513221403200333010ustar00rootroot00000000000000.. _userguide_sessions_and_consents: Sessions & Consents =================== Globus resources can be protected with fine-grained policies which require authentication with specific accounts or which require periodic reauthentication. These policies are enforced via the authentication system, Globus Auth. To satisfy a policy requirement, users need to be prompted to update their **session** in Globus Auth. In some cases, applications can be written to preemptively provide the right authentication information, but especially with session expirations and time-based policies, it is not always possible to prevent session related errors from occurring. Correctly handling a session error requires that you generally follow a workflow of: - define the operation to attempt (e.g., a function) - run the operation - catch any session errors - redrive a login flow based on those errors - run the operation again Relatedly, missing consent for an operation is sometimes only detectable after the operation has been attempted. In these cases, trying an operation, prompting for consent, and retrying the operation closely resembles the session handling workflow. .. toctree:: :caption: How to Manage Sessions & Consents with the SDK :maxdepth: 1 handling_transfer_auth_params/index coalescing_requirements_errors/index globus-globus-sdk-python-6a080e4/docs/versioning.rst000066400000000000000000000030311513221403200225600ustar00rootroot00000000000000.. _versioning: Versioning Policy ================= The Globus SDK follows `Semantic Versioning `_. That means that we use version numbers of the form **MAJOR.MINOR.PATCH**. When the SDK needs to make incompatible API changes, the **MAJOR** version number will be incremented. **MINOR** and **PATCH** version increments indicate new features or bugfixes. Public Interfaces ----------------- Features documented here are public and all other components of the SDK should be considered private. Undocumented components may be subject to backwards incompatible changes without increments to the **MAJOR** version. Recommended Pinning ------------------- We recommend that users of the SDK pin only to the major version which they require. e.g. specify ``globus-sdk>=3.7,<4.0`` in your package requirements. Upgrade Caveat -------------- It is always possible for new features or bugfixes to cause issues. If you are installing the SDK into mission-critical production systems, we strongly encourage you to establish a method of pinning the exact version used and testing upgrades. Deprecation Warnings -------------------- ``globus-sdk`` will emit deprecation warnings for features which are removed in the next release. The Python interpreter applies a filter by default which ignores all deprecation warnings. Users are can enable deprecation warnings by normal Python mechanisms. e.g. By applying a filter: .. code-block:: python import warnings warnings.simplefilter("default") # this enables DeprecationWarnings globus-globus-sdk-python-6a080e4/pyproject.toml000066400000000000000000000135471513221403200216440ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.2"] build-backend = "setuptools.build_meta" [project] name = "globus-sdk" version = "4.3.1" authors = [ { name = "Globus Team", email = "support@globus.org" }, ] description = "Globus SDK for Python" keywords = ["globus"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "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", "Programming Language :: Python :: 3.14", ] requires-python = ">=3.9" dependencies = [ "requests>=2.19.1,<3.0.0", "pyjwt[crypto]>=2.0.0,<3.0.0", # cryptography 3.4.0 is known-bugged, see: # https://github.com/pyca/cryptography/issues/5756 # pyjwt requires cryptography>=3.3.1, # so there's no point in setting a lower bound than that "cryptography>=3.3.1,!=3.4.0", # depend on the latest version of typing-extensions on python versions which do # not have all of the typing features we use 'typing_extensions>=4.0; python_version<"3.11"', # python versions older than 3.9 don't have importlib.resources 'importlib_resources>=5.12.0; python_version<"3.9"', ] [project.readme] file = "README.rst" content-type = "text/x-rst" [project.license] text = "Apache-2.0" [project.urls] Homepage = "https://github.com/globus/globus-sdk-python" [dependency-groups] docs = [ "sphinx", "sphinx-copybutton", "sphinx-issues", "furo", "sphinx-design", # required for testing modules to load "responses", # required for the PR pre-build step in RTD "scriv", ] coverage = ["coverage[toml]"] test = [ {include-group = "coverage"}, "pytest", "pytest-xdist", "pytest-randomly", "flaky", "responses", ] test-mindeps = [ {include-group = "test"}, # this version of requests is newer than the minimum in package data # however, if we pin back to 2.19.1, we break 'responses' behaviors used by the # testsuite "requests==2.22.0", "pyjwt==2.0.0", "cryptography==3.3.1", "typing_extensions==4.0", ] typing = [ "mypy", "types-docutils", "types-jwt", "types-requests", "typing-extensions>=4.0", # although 'responses' is provided by the test requirements, it also # must be installed for proper type-checking on globus_sdk.testing "responses", # similarly, sphinx is needed to type-check our sphinx extension "sphinx", ] typing-mindeps = [ {include-group = "typing"}, "typing-extensions==4.0", ] check-project-metadata = [ "ruamel.yaml<0.19", "mddj==0.4.2", ] [tool.setuptools.packages.find] where = ["src"] namespaces = false [tool.setuptools.package-data] globus_sdk = [ "py.typed", "__init__.pyi", ] "globus_sdk.login_flows.local_server_login_flow_manager.html_files" = ["*.html"] # non-packaging tool configs follow [tool.pytest.ini_options] addopts = "--no-success-flaky-report --color=yes" testpaths = ["tests"] norecursedirs = ["tests/non-pytest", "tests/benchmark"] filterwarnings = [ "error", ] [tool.coverage.run] parallel = true source = ["globus_sdk"] # omit must be specified in a way which matches the # tox environment installations, so lead with `**` omit = [ "**/globus_sdk/testing/*", ] [tool.coverage.paths] # path remapping specifies that any installation of a package in a # site-packages directory (e.g. in tox) should be treated equivalently to src/ source = [ "src/", "*/site-packages/", ] [tool.coverage.report] show_missing = true skip_covered = true fail_under = 93 exclude_lines =[ # the pragma to disable coverage "pragma: no cover", # don't complain if tests don't hit unimplemented methods/modes "raise NotImplementedError", # don't check on executable components of importable modules "if __name__ == .__main__.:", # don't check coverage on type checking conditionals "if t.TYPE_CHECKING:", # skip overloads "@t.overload", ] [tool.scriv] version = "literal: pyproject.toml: project.version" format = "rst" output_file = "changelog.rst" entry_title_template = 'v{{ version }} ({{ date.strftime("%Y-%m-%d") }})' rst_header_chars = "=-" categories = [ "Python Support", "Breaking Changes", "Added", "Removed", "Changed", "Deprecated", "Fixed", "Documentation", "Security", "Development", ] [tool.isort] profile = "black" known_first_party = ["tests", "globus_sdk"] [tool.mypy] strict = true sqlite_cache = true warn_unreachable = true warn_no_return = true [tool.pylint] load-plugins = ["pylint.extensions.docparams"] accept-no-param-doc = "false" [tool.pylint."messages control"] disable = [ # formatting and cosmetic rules (handled by 'black', etc) "format", "C", # refactoring rules (e.g. duplicate or similar code) are very prone to # false positives "R", # emitted when pylint fails to import a module; these warnings # are usually false-positives for optional dependencies "import-error", # "disallowed" usage of our own classes and objects gets underfoot "protected-access", # incorrect mis-reporting of lazily loaded attributes makes this lint # unusable "no-name-in-module", # objections to log messages doing eager (vs lazy) string formatting # the perf benefit of deferred logging doesn't always outweigh the readability cost "logging-fstring-interpolation", "logging-format-interpolation", # fixme comments are often useful; re-enable this to quickly find FIXME and # TODO comments "fixme", # most SDK methods currently do not document the exceptions which they raise # this is an area for potential improvement "missing-raises-doc", ] [tool.pylint.variables] ignored-argument-names = "args|kwargs" globus-globus-sdk-python-6a080e4/requirements/000077500000000000000000000000001513221403200214415ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/README.rst000066400000000000000000000004711513221403200231320ustar00rootroot00000000000000Requirements Data ----------------- These ``*.txt`` data are autogenerated from ``[dependency-groups]`` data. There are gitignored, hidden ``.in`` files in this directory as a part of that process. Modify the sources manually, then regenerate the "pinned" or "locked" dependencies with ``tox r -m freezedeps``. globus-globus-sdk-python-6a080e4/requirements/py3.10/000077500000000000000000000000001513221403200223735ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/py3.10/test.txt000066400000000000000000000017121513221403200241140ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests coverage==7.11.3 # via -r .test.in exceptiongroup==1.3.0 # via pytest execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in idna==3.11 # via requests iniconfig==2.3.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest pytest==9.0.1 # via # -r .test.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test.in pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses requests==2.32.5 # via responses responses==0.25.8 # via -r .test.in tomli==2.3.0 # via # coverage # pytest typing-extensions==4.15.0 # via exceptiongroup urllib3==2.5.0 # via # requests # responses globus-globus-sdk-python-6a080e4/requirements/py3.10/typing.txt000066400000000000000000000027511513221403200244530ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # tox p -m freezedeps # alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests docutils==0.21.2 # via sphinx idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx markupsafe==3.0.3 # via jinja2 mypy==1.18.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy packaging==25.0 # via sphinx pathspec==0.12.1 # via mypy pygments==2.19.2 # via sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # sphinx responses==0.25.8 # via -r .typing.in snowballstemmer==3.0.1 # via sphinx sphinx==8.1.3 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx tomli==2.3.0 # via # mypy # sphinx types-cryptography==3.3.23.2 # via types-jwt types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in types-requests==2.32.4.20250913 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy urllib3==2.5.0 # via # requests # responses # types-requests globus-globus-sdk-python-6a080e4/requirements/py3.11/000077500000000000000000000000001513221403200223745ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/py3.11/docs.txt000066400000000000000000000035641513221403200240750ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # tox p -m freezedeps # accessible-pygments==0.0.5 # via furo alabaster==1.0.0 # via sphinx attrs==25.4.0 # via scriv babel==2.17.0 # via sphinx beautifulsoup4==4.14.2 # via furo certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests click==8.3.1 # via # click-log # scriv click-log==0.4.0 # via scriv docutils==0.21.2 # via sphinx furo==2025.9.25 # via -r .docs.in idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via # scriv # sphinx markdown-it-py==4.0.0 # via scriv markupsafe==3.0.3 # via jinja2 mdurl==0.1.2 # via markdown-it-py packaging==25.0 # via sphinx pygments==2.19.2 # via # accessible-pygments # furo # sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # scriv # sphinx responses==0.25.8 # via -r .docs.in roman-numerals-py==3.1.0 # via sphinx scriv==1.7.0 # via -r .docs.in snowballstemmer==3.0.1 # via sphinx soupsieve==2.8 # via beautifulsoup4 sphinx==8.2.3 # via # -r .docs.in # furo # sphinx-basic-ng # sphinx-copybutton # sphinx-design # sphinx-issues sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 # via -r .docs.in sphinx-design==0.6.1 # via -r .docs.in sphinx-issues==5.0.1 # via -r .docs.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx typing-extensions==4.15.0 # via beautifulsoup4 urllib3==2.5.0 # via # requests # responses globus-globus-sdk-python-6a080e4/requirements/py3.11/test.txt000066400000000000000000000014711513221403200241170ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests coverage==7.11.3 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in idna==3.11 # via requests iniconfig==2.3.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest pytest==9.0.1 # via # -r .test.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test.in pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses requests==2.32.5 # via responses responses==0.25.8 # via -r .test.in urllib3==2.5.0 # via # requests # responses globus-globus-sdk-python-6a080e4/requirements/py3.11/typing.txt000066400000000000000000000027401513221403200244520ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # tox p -m freezedeps # alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests docutils==0.21.2 # via sphinx idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx markupsafe==3.0.3 # via jinja2 mypy==1.18.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy packaging==25.0 # via sphinx pathspec==0.12.1 # via mypy pygments==2.19.2 # via sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # sphinx responses==0.25.8 # via -r .typing.in roman-numerals-py==3.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx sphinx==8.2.3 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in types-requests==2.32.4.20250913 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy urllib3==2.5.0 # via # requests # responses # types-requests globus-globus-sdk-python-6a080e4/requirements/py3.12/000077500000000000000000000000001513221403200223755ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/py3.12/test.txt000066400000000000000000000014711513221403200241200ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests coverage==7.11.3 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in idna==3.11 # via requests iniconfig==2.3.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest pytest==9.0.1 # via # -r .test.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test.in pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses requests==2.32.5 # via responses responses==0.25.8 # via -r .test.in urllib3==2.5.0 # via # requests # responses globus-globus-sdk-python-6a080e4/requirements/py3.12/typing.txt000066400000000000000000000027401513221403200244530ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # tox p -m freezedeps # alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests docutils==0.21.2 # via sphinx idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx markupsafe==3.0.3 # via jinja2 mypy==1.18.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy packaging==25.0 # via sphinx pathspec==0.12.1 # via mypy pygments==2.19.2 # via sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # sphinx responses==0.25.8 # via -r .typing.in roman-numerals-py==3.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx sphinx==8.2.3 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in types-requests==2.32.4.20250913 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy urllib3==2.5.0 # via # requests # responses # types-requests globus-globus-sdk-python-6a080e4/requirements/py3.13/000077500000000000000000000000001513221403200223765ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/py3.13/test.txt000066400000000000000000000014711513221403200241210ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests coverage==7.11.3 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in idna==3.11 # via requests iniconfig==2.3.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest pytest==9.0.1 # via # -r .test.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test.in pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses requests==2.32.5 # via responses responses==0.25.8 # via -r .test.in urllib3==2.5.0 # via # requests # responses globus-globus-sdk-python-6a080e4/requirements/py3.13/typing.txt000066400000000000000000000027401513221403200244540ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # # tox p -m freezedeps # alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests docutils==0.21.2 # via sphinx idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx markupsafe==3.0.3 # via jinja2 mypy==1.18.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy packaging==25.0 # via sphinx pathspec==0.12.1 # via mypy pygments==2.19.2 # via sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # sphinx responses==0.25.8 # via -r .typing.in roman-numerals-py==3.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx sphinx==8.2.3 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in types-requests==2.32.4.20250913 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy urllib3==2.5.0 # via # requests # responses # types-requests globus-globus-sdk-python-6a080e4/requirements/py3.14/000077500000000000000000000000001513221403200223775ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/py3.14/test.txt000066400000000000000000000014711513221403200241220ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests coverage==7.11.3 # via -r .test.in execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in idna==3.11 # via requests iniconfig==2.3.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest pytest==9.0.1 # via # -r .test.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test.in pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses requests==2.32.5 # via responses responses==0.25.8 # via -r .test.in urllib3==2.5.0 # via # requests # responses globus-globus-sdk-python-6a080e4/requirements/py3.14/typing.txt000066400000000000000000000027401513221403200244550ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # # tox p -m freezedeps # alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests docutils==0.21.2 # via sphinx idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx markupsafe==3.0.3 # via jinja2 mypy==1.18.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy packaging==25.0 # via sphinx pathspec==0.12.1 # via mypy pygments==2.19.2 # via sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # sphinx responses==0.25.8 # via -r .typing.in roman-numerals-py==3.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx sphinx==8.2.3 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx types-cryptography==3.3.23.2 # via types-jwt types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in types-requests==2.32.4.20250913 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy urllib3==2.5.0 # via # requests # responses # types-requests globus-globus-sdk-python-6a080e4/requirements/py3.9/000077500000000000000000000000001513221403200223235ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/requirements/py3.9/test-mindeps.txt000066400000000000000000000025511513221403200255030ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests cffi==2.0.0 # via cryptography chardet==3.0.4 # via requests coverage==7.10.7 # via -r .test-mindeps.in cryptography==3.3.1 # via -r .test-mindeps.in exceptiongroup==1.2.2 # via pytest execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test-mindeps.in idna==2.8 # via requests importlib-metadata==8.7.0 # via pytest-randomly iniconfig==2.1.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pycparser==2.23 # via cffi pygments==2.19.2 # via pytest pyjwt==2.0.0 # via -r .test-mindeps.in pytest==8.4.2 # via # -r .test-mindeps.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test-mindeps.in pytest-xdist==3.8.0 # via -r .test-mindeps.in pyyaml==6.0.3 # via responses requests==2.22.0 # via # -r .test-mindeps.in # responses responses==0.23.1 # via -r .test-mindeps.in six==1.17.0 # via cryptography tomli==2.3.0 # via # coverage # pytest types-pyyaml==6.0.12.20250915 # via responses typing-extensions==4.0.0 # via -r .test-mindeps.in urllib3==1.25.11 # via # requests # responses zipp==3.23.0 # via importlib-metadata globus-globus-sdk-python-6a080e4/requirements/py3.9/test.txt000066400000000000000000000020471513221403200240460ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # tox p -m freezedeps # certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests coverage==7.10.7 # via -r .test.in exceptiongroup==1.3.0 # via pytest execnet==2.1.2 # via pytest-xdist flaky==3.8.1 # via -r .test.in idna==3.11 # via requests importlib-metadata==8.7.0 # via pytest-randomly iniconfig==2.1.0 # via pytest packaging==25.0 # via pytest pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest pytest==8.4.2 # via # -r .test.in # pytest-randomly # pytest-xdist pytest-randomly==4.0.1 # via -r .test.in pytest-xdist==3.8.0 # via -r .test.in pyyaml==6.0.3 # via responses requests==2.32.5 # via responses responses==0.25.8 # via -r .test.in tomli==2.3.0 # via # coverage # pytest typing-extensions==4.15.0 # via exceptiongroup urllib3==2.5.0 # via # requests # responses zipp==3.23.0 # via importlib-metadata globus-globus-sdk-python-6a080e4/requirements/py3.9/typing.txt000066400000000000000000000030761513221403200244040ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # tox p -m freezedeps # alabaster==0.7.16 # via sphinx babel==2.17.0 # via sphinx certifi==2025.11.12 # via requests charset-normalizer==3.4.4 # via requests docutils==0.21.2 # via sphinx idna==3.11 # via requests imagesize==1.4.1 # via sphinx importlib-metadata==8.7.0 # via sphinx jinja2==3.1.6 # via sphinx markupsafe==3.0.3 # via jinja2 mypy==1.18.2 # via -r .typing.in mypy-extensions==1.1.0 # via mypy packaging==25.0 # via sphinx pathspec==0.12.1 # via mypy pygments==2.19.2 # via sphinx pyyaml==6.0.3 # via responses requests==2.32.5 # via # responses # sphinx responses==0.25.8 # via -r .typing.in snowballstemmer==3.0.1 # via sphinx sphinx==7.4.7 # via -r .typing.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 # via sphinx sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx tomli==2.3.0 # via # mypy # sphinx types-cryptography==3.3.23.2 # via types-jwt types-docutils==0.22.3.20251115 # via -r .typing.in types-jwt==0.1.3 # via -r .typing.in types-requests==2.32.4.20250913 # via -r .typing.in typing-extensions==4.15.0 # via # -r .typing.in # mypy urllib3==2.5.0 # via # requests # responses # types-requests zipp==3.23.0 # via importlib-metadata globus-globus-sdk-python-6a080e4/scripts/000077500000000000000000000000001513221403200204055ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/scripts/changelog2md.py000077500000000000000000000071221513221403200233160ustar00rootroot00000000000000#!/usr/bin/env python """ Extract a changelog section from the full changelog contents, convert ReST and sphinx-issues syntaxes to GitHub-flavored Markdown, and print the results. Defaults to selecting the most recent (topmost) changelog section. Can alternatively provide output for a specific version with `--target`. e.g. ./scripts/changelog2md.py --target 3.20.0 """ import argparse import pathlib import re import typing as t REPO_ROOT = pathlib.Path(__file__).parent.parent CHANGELOG_PATH = REPO_ROOT / "changelog.rst" CHANGELOG_ANCHOR_PATTERN = re.compile( r"^\.\. _changelog-\d+\.\d+\.\d+(?:\.?(?:post|a|b|dev)\d+)?:$", re.MULTILINE ) CHANGELOG_HEADER_PATTERN = re.compile(r"^v(\d+\.\d+\.\d+).*$", re.MULTILINE) H2_RST_PATTERN = re.compile(r"=+") H3_RST_PATTERN = re.compile(r"-+") SPHINX_ISSUES_PR_PATTERN = re.compile(r":pr:`(\d+)`") SPHINX_ISSUES_ISSUE_PATTERN = re.compile(r":issue:`(\d+)`") SPHINX_ISSUES_USER_PATTERN = re.compile(r":user:`([^`]+)`") def iter_changelog_chunks(changelog_content: str) -> t.Iterator[str]: # first one precedes the first changelog chunks = CHANGELOG_ANCHOR_PATTERN.split(changelog_content)[1:] yield from chunks def _trim_empty_lines(lines: list[str]) -> None: while lines[0] == "": lines.pop(0) while lines[-1] == "": lines.pop() def get_last_changelog(changelog_content: str) -> list[str]: latest_changes = next(iter_changelog_chunks(changelog_content)) lines = latest_changes.split("\n") idx = 0 while idx < len(lines) and H2_RST_PATTERN.fullmatch(lines[idx]) is None: idx += 1 lines = lines[idx + 1 :] _trim_empty_lines(lines) return lines def _iter_target_section(target: str, changelog_content: str) -> t.Iterator[str]: started = False for line in changelog_content.split("\n"): if not started: if m := CHANGELOG_HEADER_PATTERN.match(line): if m.group(1) == target: started = True continue if CHANGELOG_ANCHOR_PATTERN.fullmatch(line): return if H2_RST_PATTERN.fullmatch(line): continue yield line def get_changelog_section(target: str, changelog_content: str) -> list[str]: lines = list(_iter_target_section(target, changelog_content)) _trim_empty_lines(lines) return lines def convert_rst_to_md(lines: list[str]) -> t.Iterator[str]: skip = False for i, line in enumerate(lines): if skip: skip = False continue try: peek = lines[i + 1] except IndexError: peek = None updated = line if peek is not None and H3_RST_PATTERN.fullmatch(peek): skip = True updated = f"## {updated}" updated = SPHINX_ISSUES_PR_PATTERN.sub(r"#\1", updated) updated = SPHINX_ISSUES_ISSUE_PATTERN.sub(r"#\1", updated) updated = SPHINX_ISSUES_USER_PATTERN.sub(r"@\1", updated) updated = updated.replace("``", "`") yield updated def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "--target", "-t", help="A target version to use. Defaults to latest." ) args = parser.parse_args() full_changelog = CHANGELOG_PATH.read_text() if args.target: changelog_section = get_changelog_section(args.target, full_changelog) else: changelog_section = get_last_changelog(full_changelog) for line in convert_rst_to_md(changelog_section): print(line) if __name__ == "__main__": main() globus-globus-sdk-python-6a080e4/scripts/ensure_exports_are_documented.py000077500000000000000000000066641513221403200271210ustar00rootroot00000000000000#!/usr/bin/env python from __future__ import annotations import functools import glob import os import pathlib import re import sys import typing as t REPO_ROOT = pathlib.Path(__file__).parent.parent _ALL_NAME_PATTERN = re.compile(r'\s+"(\w+)",?') PACKAGE_LOCS_TO_SCAN = ( "globus_sdk/__init__.pyi", "globus_sdk/login_flows/", "globus_sdk/gare/", "globus_sdk/globus_app/", "globus_sdk/scopes/", "globus_sdk/response.py", "globus_sdk/testing/", ) DEPRECATED_NAMES: set[str] = set() def load_docs() -> dict[str, str]: all_docs = {} for file in glob.glob("docs/**/*.rst", recursive=True): with open(file) as f: all_docs[file] = f.read() return all_docs def iter_all_documented_names() -> t.Iterable[str]: # names under these directives # # .. autoclass:: # .. autoclass:: () # .. autofunction:: # .. autoexception:: # .. autodata:: autodoc_pattern = re.compile( r""" ^\.\.\s+auto(?:class|function|exception|data)::\s+ # auto-directive (uncaptured) (?:\w+\.)*(\w+)(?:\(.*\))?$ # symbol name (captured) """, flags=re.MULTILINE | re.X, ) # names under these directives # # .. py:class:: # .. py:data:: pydoc_pattern = re.compile( r""" ^\.\.\s+py:(?:data|class)::\s+ # directive (?:\w+\.)*(\w+)(?:\(.*\))?$ # symbol name (captured) """, flags=re.MULTILINE | re.X, ) for data in load_docs().values(): for match in autodoc_pattern.finditer(data): yield match.group(1) for match in pydoc_pattern.finditer(data): yield match.group(1) @functools.lru_cache def all_documented_names() -> frozenset[str]: return frozenset(iter_all_documented_names()) def is_documented(name: str) -> bool: return name in all_documented_names() def get_names_from_all_list(file_path: str) -> list[str]: with open(f"src/{file_path}") as fp: contents = fp.readlines() names: list[str] = [] found_all = False for line in contents: if found_all: if line.strip() == ")": break # Extract the actual symbol from the line. # i.e., ' "Foo",\n' -> 'Foo' name_match = _ALL_NAME_PATTERN.match(line) if name_match is not None: names.append(name_match.group(1)) else: if line.strip() == "__all__ = (": found_all = True else: continue return [n for n in names if not n.startswith("_")] def ensure_exports_are_documented() -> bool: success = True used_deprecations = set() for loc in PACKAGE_LOCS_TO_SCAN: if loc.endswith("/"): loc = f"{loc}/__init__.py" for name in get_names_from_all_list(loc): if name in DEPRECATED_NAMES: used_deprecations.add(name) continue if not is_documented(name): print(f"'src/{loc}::{name}' was not found in doc directives") success = False if unused_deprecations := (DEPRECATED_NAMES - used_deprecations): print(f"unused deprecations: {unused_deprecations}") success = False return success if __name__ == "__main__": os.chdir(REPO_ROOT) if not ensure_exports_are_documented(): exit(1) sys.exit(0) globus-globus-sdk-python-6a080e4/scripts/ensure_min_python_is_tested.py000066400000000000000000000035421513221403200265730ustar00rootroot00000000000000# this script should only be called via # tox run -e check-min-python-is-tested # # no other usages are supported import pathlib import sys import mddj.api import ruamel.yaml dj = mddj.api.DJ() YAML = ruamel.yaml.YAML(typ="safe") REPO_ROOT = pathlib.Path(__file__).parent.parent requires_python_version = dj.read.requires_python(lower_bound=True) print("requires-python:", requires_python_version) with open(REPO_ROOT / ".github" / "workflows" / "test.yaml") as f: workflow = YAML.load(f) includes = workflow["jobs"]["test"]["strategy"]["matrix"]["include"] for include in includes: if include["name"] == "Linux": break else: raise ValueError("Could not find 'Linux' in the test matrix.") for environment in include["tox-post-environments"]: if environment.endswith("-mindeps"): break else: raise ValueError("Could not find a '-mindeps' tox-post-environment.") python_version, _, _ = environment.partition("-") print("test-mindeps job python:", python_version) if python_version != f"py{requires_python_version}": print("ERROR: ensure_min_python_is_tested.py failed!") print( f"\nPackage data sets 'Requires-Python: >={requires_python_version}', " f"but the test-mindeps job is configured to test '{python_version}'.\n", file=sys.stderr, ) sys.exit(1) tox_min_python_version = dj.read.tox.min_python_version() print("tox min python version:", tox_min_python_version) if tox_min_python_version != requires_python_version: print("ERROR: ensure_min_python_is_tested.py failed!") print( f"\nPackage data sets 'Requires-Python: >={requires_python_version}', " "but tox is configured to test with a minimum of " f"'{tox_min_python_version}'.\n", file=sys.stderr, ) sys.exit(1) globus-globus-sdk-python-6a080e4/scripts/rtd-pre-sphinx-build.sh000077500000000000000000000021071513221403200247250ustar00rootroot00000000000000#!/bin/bash VERSION=$(grep '^version' pyproject.toml | head -n 1 | cut -d '"' -f2) case "$READTHEDOCS_VERSION_TYPE" in external) echo "detected PR build" VERSION="${VERSION}-pr-${READTHEDOCS_VERSION_NAME}" ;; branch) case "${READTHEDOCS_VERSION_NAME}" in latest) echo "detected 'latest' branch build" VERSION="${VERSION}-dev" ;; *) echo "detected non-'latest' branch build" echo "exiting(ok)..." exit 0 ;; esac ;; tag | unknown) echo "not a PR or branch build" echo "exiting(ok)..." exit 0 ;; *) echo "unrecognized build type" echo "exiting(fail)..." exit 1 ;; esac echo "detection succeeded: VERSION=${VERSION}" if [ -z "$(find changelog.d -name '*.rst')" ]; then echo "no changes visible in changelog.d/" echo "exiting without running 'scriv collect'" exit 0 fi scriv collect --keep --version "$VERSION" -v DEBUG globus-globus-sdk-python-6a080e4/setup.py000066400000000000000000000000461513221403200204300ustar00rootroot00000000000000from setuptools import setup setup() globus-globus-sdk-python-6a080e4/src/000077500000000000000000000000001513221403200175055ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/000077500000000000000000000000001513221403200216415ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/__init__.py000066400000000000000000000020041513221403200237460ustar00rootroot00000000000000import importlib.metadata import logging import sys from ._internal.lazy_import import ( default_dir_implementation, default_getattr_implementation, load_all_tuple, ) __version__ = importlib.metadata.distribution("globus_sdk").version def _force_eager_imports() -> None: current_module = sys.modules[__name__] for attr in __all__: getattr(current_module, attr) # # all lazy SDK attributes are defined in __init__.pyi # # to add an attribute, write the relevant import in `__init__.pyi` and update # the `__all__` tuple there # __all__ = load_all_tuple(__name__, "__init__.pyi") __getattr__ = default_getattr_implementation(__name__, "__init__.pyi") __dir__ = default_dir_implementation(__name__) del load_all_tuple del default_getattr_implementation del default_dir_implementation # configure logging for a library, per python best practices: # https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library logging.getLogger("globus_sdk").addHandler(logging.NullHandler()) globus-globus-sdk-python-6a080e4/src/globus_sdk/__init__.pyi000066400000000000000000000135701513221403200241310ustar00rootroot00000000000000from ._missing import MISSING, MissingType from .authorizers import ( AccessTokenAuthorizer, BasicAuthorizer, ClientCredentialsAuthorizer, NullAuthorizer, RefreshTokenAuthorizer, ) from .client import BaseClient from .exc import ( ErrorSubdocument, GlobusAPIError, GlobusConnectionError, GlobusConnectionTimeoutError, GlobusError, GlobusSDKUsageError, GlobusTimeoutError, NetworkError, RemovedInV5Warning, ValidationError, ) from .globus_app import ClientApp, GlobusApp, GlobusAppConfig, UserApp from .local_endpoint import ( GlobusConnectPersonalOwnerInfo, LocalGlobusConnectPersonal, LocalGlobusConnectServer, ) from .response import ArrayResponse, GlobusHTTPResponse, IterableResponse from .scopes import Scope, ScopeCycleError, ScopeParseError from .services.auth import ( AuthAPIError, AuthClient, AuthLoginClient, ConfidentialAppAuthClient, DependentScopeSpec, GetConsentsResponse, GetIdentitiesResponse, IdentityMap, IDTokenDecoder, NativeAppAuthClient, OAuthAuthorizationCodeResponse, OAuthClientCredentialsResponse, OAuthDependentTokenResponse, OAuthRefreshTokenResponse, OAuthTokenResponse, ) from .services.compute import ( ComputeAPIError, ComputeClientV2, ComputeClientV3, ) from .services.flows import ( FlowsAPIError, FlowsClient, IterableFlowsResponse, RunActivityNotificationPolicy, SpecificFlowClient, ) from .services.gcs import ( ActiveScaleStoragePolicies, AzureBlobStoragePolicies, BlackPearlStoragePolicies, BoxStoragePolicies, CephStoragePolicies, CollectionDocument, CollectionPolicies, ConnectorTable, EndpointDocument, GCSAPIError, GCSClient, GCSRoleDocument, GlobusConnectServerConnector, GoogleCloudStorageCollectionPolicies, GoogleCloudStoragePolicies, GoogleDriveStoragePolicies, GuestCollectionDocument, HPSSStoragePolicies, IrodsStoragePolicies, IterableGCSResponse, MappedCollectionDocument, OneDriveStoragePolicies, POSIXCollectionPolicies, POSIXStagingCollectionPolicies, POSIXStagingStoragePolicies, POSIXStoragePolicies, S3StoragePolicies, StorageGatewayDocument, StorageGatewayPolicies, UnpackingGCSResponse, UserCredentialDocument, ) from .services.groups import ( BatchMembershipActions, GroupMemberVisibility, GroupPolicies, GroupRequiredSignupFields, GroupRole, GroupsAPIError, GroupsClient, GroupsManager, GroupVisibility, ) from .services.search import ( SearchAPIError, SearchClient, SearchQueryV1, SearchScrollQuery, ) from .services.timers import ( FlowTimer, OnceTimerSchedule, RecurringTimerSchedule, TimerJob, TimersAPIError, TimersClient, TransferTimer, ) from .services.transfer import ( CreateTunnelData, DeleteData, IterableTransferResponse, TransferAPIError, TransferClient, TransferData, ) __version__ = "x.y.z" def _force_eager_imports() -> None: ... __all__ = ( "AccessTokenAuthorizer", "BasicAuthorizer", "ClientCredentialsAuthorizer", "NullAuthorizer", "RefreshTokenAuthorizer", "BaseClient", "ErrorSubdocument", "GlobusAPIError", "GlobusConnectionError", "GlobusConnectionTimeoutError", "GlobusError", "GlobusSDKUsageError", "GlobusTimeoutError", "NetworkError", "RemovedInV5Warning", "ValidationError", "ClientApp", "GlobusApp", "GlobusAppConfig", "UserApp", "GlobusConnectPersonalOwnerInfo", "LocalGlobusConnectPersonal", "LocalGlobusConnectServer", "ArrayResponse", "GlobusHTTPResponse", "IterableResponse", "Scope", "ScopeCycleError", "ScopeParseError", "AuthAPIError", "AuthClient", "AuthLoginClient", "ConfidentialAppAuthClient", "DependentScopeSpec", "GetConsentsResponse", "GetIdentitiesResponse", "IdentityMap", "NativeAppAuthClient", "OAuthAuthorizationCodeResponse", "OAuthClientCredentialsResponse", "OAuthDependentTokenResponse", "OAuthRefreshTokenResponse", "OAuthTokenResponse", "IDTokenDecoder", "ComputeAPIError", "ComputeClientV2", "ComputeClientV3", "FlowsAPIError", "FlowsClient", "IterableFlowsResponse", "RunActivityNotificationPolicy", "SpecificFlowClient", "ActiveScaleStoragePolicies", "AzureBlobStoragePolicies", "BlackPearlStoragePolicies", "BoxStoragePolicies", "CephStoragePolicies", "CollectionDocument", "CollectionPolicies", "ConnectorTable", "EndpointDocument", "GCSAPIError", "GCSClient", "GCSRoleDocument", "GlobusConnectServerConnector", "GoogleCloudStorageCollectionPolicies", "GoogleCloudStoragePolicies", "GoogleDriveStoragePolicies", "GuestCollectionDocument", "HPSSStoragePolicies", "IrodsStoragePolicies", "IterableGCSResponse", "MappedCollectionDocument", "OneDriveStoragePolicies", "POSIXCollectionPolicies", "POSIXStagingCollectionPolicies", "POSIXStagingStoragePolicies", "POSIXStoragePolicies", "S3StoragePolicies", "StorageGatewayDocument", "StorageGatewayPolicies", "UnpackingGCSResponse", "UserCredentialDocument", "BatchMembershipActions", "GroupMemberVisibility", "GroupPolicies", "GroupRequiredSignupFields", "GroupRole", "GroupsAPIError", "GroupsClient", "GroupsManager", "GroupVisibility", "SearchAPIError", "SearchClient", "SearchQueryV1", "SearchScrollQuery", "OnceTimerSchedule", "RecurringTimerSchedule", "TimerJob", "TimersAPIError", "TimersClient", "FlowTimer", "TransferTimer", "DeleteData", "IterableTransferResponse", "TransferAPIError", "TransferClient", "TransferData", "MISSING", "MissingType", "__version__", "_force_eager_imports", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/000077500000000000000000000000001513221403200236145ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/__init__.py000066400000000000000000000000001513221403200257130ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/classprop.py000066400000000000000000000041611513221403200261760ustar00rootroot00000000000000""" WARNING: for internal use only. Everything in SDK private modules is meant to be internal only, but that holds for this module **in particular**. Usage: from globus_sdk._internal.classprop import classproperty class A: @classproperty def foo(self_or_cls): ... """ from __future__ import annotations import os import sys import typing as t T = t.TypeVar("T") R = t.TypeVar("R") def _in_sphinx_build() -> bool: # pragma: no cover # check if `sphinx-build` was used to invoke return os.path.basename(sys.argv[0]) in ["sphinx-build", "sphinx-build.exe"] class _classproperty(t.Generic[T, R]): """ A hybrid class/instance property descriptor. On a class, the decorated method will be invoked with `cls`. On an instance, the decorated method will be invoked with `self`. """ def __init__(self, func: t.Callable[[type[T] | T], R]) -> None: self.func = func def __get__(self, obj: T | None, cls: type[T]) -> R: # NOTE: our __get__ here prefers the object over the class when possible # although well-defined behavior for a descriptor, this contradicts the # expectation that developers may have from `classmethod` if obj is None: return self.func(cls) return self.func(obj) # if running under sphinx, define this as the stacked classmethod(property(...)) # decoration, so that proper autodoc generation happens # this is based on the python3.9 behavior which supported stacking these decorators # however, that support was pulled in 3.10 and is not going to be reintroduced at # present # therefore, this sphinx behavior may not be stable in the long term if _in_sphinx_build(): # pragma: no cover def classproperty(func: t.Callable[[T | type[T]], R]) -> _classproperty[T, R]: # type ignore this because # - it doesn't match the return type # - mypy doesn't understand classmethod(property(...)) on older pythons return classmethod(property(func)) # type: ignore else: def classproperty(func: t.Callable[[T | type[T]], R]) -> _classproperty[T, R]: return _classproperty(func) globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/000077500000000000000000000000001513221403200260135ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/__init__.py000066400000000000000000000000001513221403200301120ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/globus_sdk_flake8.py000066400000000000000000000117371513221403200317640ustar00rootroot00000000000000from __future__ import annotations import ast import typing as t CODEMAP: dict[str, str] = { # SDK001 is necessary for SDK002 enforcement to be easy # otherwise, we would have to have a more sophisticated linter which knows about # lexical scopes! "SDK001": "SDK001 loggers should be named 'log'", "SDK002": "SDK002 never use 'log.info'", # don't do `isinstance(x, MissingType)` -- use `x is MISSING` instead "SDK003": "SDK003 use `is MISSING`, not `isinstance(..., MissingType)`", } class Plugin: name = "globus-sdk-flake8" version = "1.0.0" # args to init determine plugin behavior. see: # https://flake8.pycqa.org/en/latest/plugin-development/plugin-parameters.html#indicating-desired-data # # by having "tree" as an init arg, we tell flake8 that we are an AST-handling # plugin, run once per file def __init__(self, tree: ast.AST) -> None: self.tree = tree # Plugin.run() is how checks will run. For detail, see implementation of: # https://flake8.pycqa.org/en/latest/internal/checker.html#flake8.checker.FileChecker.run_ast_checks def run(self) -> t.Iterator[tuple[int, int, str, type]]: visitor = SDKVisitor() visitor.visit(self.tree) for lineno, col, code in visitor.collect: yield lineno, col, CODEMAP[code], type(self) class SDKVisitor(ast.NodeVisitor): def __init__(self) -> None: super().__init__() self.collect: list[tuple[int, int, str]] = [] def _record(self, node: ast.expr | ast.stmt, code: str) -> None: self.collect.append((node.lineno, node.col_offset, code)) def visit_Assign(self, node: ast.Assign) -> None: if matches_sdk001(node): self._record(node, "SDK001") self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: if matches_sdk003(node): self._record(node, "SDK003") elif matches_sdk002(node): self._record(node, "SDK002") self.generic_visit(node) def matches_sdk001(node: ast.Assign) -> bool: """ A matcher for the SDK001 lint rule. Checks for `x = logging.getLogger(__name__)` where `x` is not `log`. :param node: the assignment statement AST node to check """ # the value must be a call and must be using attr access in the function call # this eliminates bare funcs like `x = foo()` but allows `x = foo.bar()` if not ( isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute) ): return False call_node: ast.Call = node.value func_node: ast.Attribute = node.value.func # make sure it's 'logging.getLogger' and no other function if not ( func_node.attr == "getLogger" and isinstance(func_node.value, ast.Name) and func_node.value.id == "logging" ): return False # the assignee must be a single variable and it must be a name node if not (len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)): return False # if the assigned name is `log`, then it cannot be a match name_node: ast.Name = node.targets[0] if name_node.id == "log": return False # confirm that the `logging.getLogger` args are a single name -- that's the # form it will be when `__name__` is passed if len(call_node.args) != 1 or not isinstance(call_node.args[0], ast.Name): return False # if the argument is some other name, e.g. `logging.getLogger(my_variable)`, # then that cannot be a match logger_arg: ast.Name = call_node.args[0] if logger_arg.id != "__name__": return False return True # all conditions met! def matches_sdk002(node: ast.Call) -> bool: """ A matcher for the SDK002 lint rule. Checks for `log.info(...)` :param node: the function call AST node to check """ func_node = node.func # the function call must be of the form 'OBJ.info(...)' if not (isinstance(func_node, ast.Attribute) and func_node.attr == "info"): return False # and, more specifically, the object must be named 'log', so # it's `log.info(...) if not (isinstance(func_node.value, ast.Name) and func_node.value.id == "log"): return False return True # all conditions met! def matches_sdk003(node: ast.Call) -> bool: """ A matcher for the SDK003 lint rule. Checks for `isinstance(x, MissingType)` :param node: the function call AST node to check """ # it must be a call to a function named "isinstance" if not (isinstance(node.func, ast.Name) and node.func.id == "isinstance"): return False # if the number of arguments is improper, it can't be a violation (not a # valid 'isinstance' call) if len(node.args) != 2: return False right_hand_side = node.args[1] # the second argument must be the name 'MissingType' if not ( isinstance(right_hand_side, ast.Name) and right_hand_side.id == "MissingType" ): return False return True # all conditions met! globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/000077500000000000000000000000001513221403200300455ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/__init__.py000066400000000000000000000022221513221403200321540ustar00rootroot00000000000000""" A Globus SDK Sphinx Extension for Autodoc of Class Methods """ from __future__ import annotations import typing as t from sphinx.application import Sphinx from .autodoc_hooks import after_autodoc_signature_replace_MISSING_repr from .directives import ( AutoMethodList, CopyParams, EnumerateTestingFixtures, ExpandTestingFixture, ExternalDocLink, ListKnownScopes, PaginatedUsage, ) from .roles import extdoclink_role def setup(app: Sphinx) -> dict[str, t.Any]: app.add_directive("automethodlist", AutoMethodList) app.add_directive("listknownscopes", ListKnownScopes) app.add_directive("enumeratetestingfixtures", EnumerateTestingFixtures) app.add_directive("expandtestfixture", ExpandTestingFixture) app.add_directive("extdoclink", ExternalDocLink) app.add_directive("paginatedusage", PaginatedUsage) app.add_directive("sdk-sphinx-copy-params", CopyParams) app.add_role("extdoclink", extdoclink_role) app.connect( "autodoc-process-signature", after_autodoc_signature_replace_MISSING_repr ) return { "parallel_read_safe": True, "parallel_write_safe": True, } globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/autodoc_hooks.py000066400000000000000000000021451513221403200332620ustar00rootroot00000000000000from __future__ import annotations import typing as t import sphinx.application def after_autodoc_signature_replace_MISSING_repr( # pylint: disable=missing-param-doc,missing-type-doc # noqa: E501 app: sphinx.application.Sphinx, # pylint: disable=unused-argument what: str, # pylint: disable=unused-argument name: str, # pylint: disable=unused-argument obj: object, # pylint: disable=unused-argument options: t.Any, # pylint: disable=unused-argument signature: str | None, return_annotation: str | None, ) -> tuple[str | None, str | None]: """ convert to MISSING in autodoc signatures :param signature: the signature after autodoc parsing/rendering :param return_annotation: the return type annotation, including the leading `->`, after autodoc parsing/rendering """ if signature is not None: signature = signature.replace("", "MISSING") if return_annotation is not None: return_annotation = return_annotation.replace("", "MISSING") return signature, return_annotation globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directives/000077500000000000000000000000001513221403200322065ustar00rootroot00000000000000__init__.py000066400000000000000000000011321513221403200342350ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesfrom .add_content_directive import AddContentDirective from .automethodlist import AutoMethodList from .copy_params import CopyParams from .enumerate_testing_fixtures import EnumerateTestingFixtures from .expand_testing_fixture import ExpandTestingFixture from .externaldoclink import ExternalDocLink from .list_known_scopes import ListKnownScopes from .paginated_usage import PaginatedUsage __all__ = ( "AddContentDirective", "AutoMethodList", "CopyParams", "EnumerateTestingFixtures", "ExpandTestingFixture", "ExternalDocLink", "ListKnownScopes", "PaginatedUsage", ) add_content_directive.py000066400000000000000000000037761513221403200370360ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesimport typing as t from docutils import nodes from docutils.nodes import Node from docutils.parsers.rst import Directive from docutils.statemachine import StringList from sphinx.util.nodes import nested_parse_with_titles class AddContentDirective(Directive): # Directive source: # https://sourceforge.net/p/docutils/code/HEAD/tree/trunk/docutils/docutils/parsers/rst/__init__.py # # for information on how to write directives, see also: # https://docutils.sourceforge.io/docs/howto/rst-directives.html#the-directive-class def gen_rst(self) -> t.Iterator[str]: yield from [] def run(self) -> t.Sequence[Node]: # implementation is based on the docs for parsing directive content as ReST doc: # https://www.sphinx-doc.org/en/master/extdev/markupapi.html#parsing-directive-content-as-rest # which is how we will create rst here and have sphinx parse it # we need to add content lines into a ViewList which represents the content # of this directive, each one is appended with a "source" name # the best documentation for docutils is the source, so for ViewList, see... # https://sourceforge.net/p/docutils/code/HEAD/tree/trunk/docutils/docutils/statemachine.py # writing the source name with angle-brackets seems to be the norm, but it's not # clear why -- perhaps docutils will care about the source in some way viewlist = StringList() linemarker = "<" + self.__class__.__name__.lower() + ">" for line in self.gen_rst(): viewlist.append(line, linemarker) # create a section node (it doesn't really matter what node type we use here) # and add the viewlist we just created as the children of the new node via the # nested_parse method # then return the children (i.e. discard the docutils node) node = nodes.section() node.document = self.state.document nested_parse_with_titles(self.state, viewlist, node) return node.children automethodlist.py000066400000000000000000000022011513221403200355410ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesimport types import typing as t from docutils.parsers.rst import directives from ..utils import classname2methods from .add_content_directive import AddContentDirective class AutoMethodList(AddContentDirective): has_content = False required_arguments = 1 optional_arguments = 0 option_spec = {"include_methods": directives.unchanged} def gen_rst(self) -> t.Iterator[str]: classname = self.arguments[0] include_methods = [] if "include_methods" in self.options: include_methods = self.options["include_methods"].strip().split(",") yield "" yield "**Methods**" yield "" for methodname, method in classname2methods(classname, include_methods): if not is_paginated_method(method): yield f"* :py:meth:`~{classname}.{methodname}`" else: yield ( f"* :py:meth:`~{classname}.{methodname}`, " f"``paginated.{methodname}()``" ) yield "" def is_paginated_method(func: types.FunctionType) -> bool: return getattr(func, "_has_paginator", False) copy_params.py000066400000000000000000000016071513221403200350220ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesimport typing as t from pydoc import locate from ..utils import read_sphinx_params from .add_content_directive import AddContentDirective class CopyParams(AddContentDirective): has_content = True required_arguments = 1 optional_arguments = 0 def gen_rst(self) -> t.Iterator[str]: source_object_name: str = self.arguments[0] if not source_object_name.startswith("globus_sdk"): source_object_name = f"globus_sdk.{source_object_name}" source_object = locate(source_object_name) content: t.Iterable[str] if self.content: content = iter(self.content) else: content = () for line in content: if line.strip() == "": break yield line yield from read_sphinx_params(source_object.__doc__) for line in content: yield line enumerate_testing_fixtures.py000066400000000000000000000037201513221403200401560ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesimport typing as t from docutils.parsers.rst import directives import globus_sdk from ..utils import classname2methods, locate_class from .add_content_directive import AddContentDirective class EnumerateTestingFixtures(AddContentDirective): has_content = False required_arguments = 1 optional_arguments = 0 option_spec = { "header_underline_char": directives.unchanged, } def gen_rst(self) -> t.Iterator[str]: from globus_sdk.testing import get_response_set underline_char = self.options.get("header_underline_char", "-") classname = self.arguments[0] cls = locate_class(classname) if not issubclass(cls, globus_sdk.BaseClient): msg = f"Expected {cls} to be a subclass of BaseClient" raise RuntimeError(msg) service_name = cls.service_name yield classname yield underline_char * len(classname) yield "" yield ( f":class:`{classname}` has registered responses " "for the following methods:" ) yield "" for methodname, method in classname2methods(classname, []): try: rset = get_response_set(method) # success -> has a response except ValueError: continue # error -> has no response, continue for casename in rset.cases(): # use "attr" rather than "meth" so that sphinx does not add parens # the use of the method as an attribute of the class or instance better # matches how `testing` handles things yield ( ".. dropdown:: " f':py:attr:`~{classname}.{methodname}` (``case="{casename}"``)' ) yield "" yield f" .. expandtestfixture:: {service_name}.{methodname}" yield f" :case: {casename}" yield "" yield "" expand_testing_fixture.py000066400000000000000000000031701513221403200372640ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesimport json import typing as t from docutils.parsers.rst import directives from globus_sdk.testing import ResponseList from .add_content_directive import AddContentDirective class ExpandTestingFixture(AddContentDirective): has_content = False required_arguments = 1 optional_arguments = 0 option_spec = { "case": directives.unchanged, } def gen_rst(self) -> t.Iterator[str]: from globus_sdk.testing import get_response_set response_set_name = self.arguments[0] casename = "default" if "case" in self.options: casename = self.options["case"].strip() response_set = get_response_set(response_set_name) response = response_set.lookup(casename) if isinstance(response, ResponseList): # If the default responses is a list of responses, use the first one response = response.responses[0] if response.json is not None: yield ".. code-block:: json" yield "" output_lines = json.dumps( response.json, indent=2, separators=(",", ": ") ).split("\n") for line in output_lines: yield f" {line}" elif response.body is not None: yield ".. code-block:: text" yield "" output_lines = response.body.split("\n") for line in output_lines: yield f" {line}" else: raise RuntimeError( "Error loading example content for response " f"{response_set_name}:{casename}. Neither JSON nor text body was found." ) externaldoclink.py000066400000000000000000000034151513221403200356720ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesfrom __future__ import annotations import typing as t from docutils.parsers.rst import directives from ..utils import derive_doc_url_base from .add_content_directive import AddContentDirective class ExternalDocLink(AddContentDirective): has_content = False required_arguments = 1 optional_arguments = 0 # allow for spaces in the argument string final_argument_whitespace = True option_spec = { "base_url": directives.unchanged, "service": directives.unchanged, "ref": directives.unchanged_required, } def gen_rst(self) -> t.Iterator[str]: message = self.arguments[0].strip() service: str | None = self.options.get("service") default_base_url: str = derive_doc_url_base(service) base_url = self.options.get("base_url", default_base_url) relative_link = self.options["ref"] # use a trailing `__` to make the hyperlink an "anonymous hyperlink" # # the reason for this is that sphinx will warn (error with -W) if we generate # rst with duplicate target names, as when an autodoc method name matches the # (snake_cased) message for a hyperlink, a common scenario # the conflicts are stipulated by docutils to cause warnings, along with a # conflict resolution procedure specified under the implicit hyperlink section # of the spec: # https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#implicit-hyperlink-targets # # for details on anonymous hyperlinks, see also: # https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#anonymous-hyperlinks yield ( f"See `{message} <{base_url}/{relative_link}>`__ in the " "API documentation for details." ) list_known_scopes.py000066400000000000000000000034131513221403200362450ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesfrom __future__ import annotations import typing as t from pydoc import locate from docutils.parsers.rst import directives from globus_sdk.scopes import Scope, ScopeCollection from .add_content_directive import AddContentDirective class ListKnownScopes(AddContentDirective): has_content = False required_arguments = 1 optional_arguments = 0 option_spec = { "example_scope": directives.unchanged, # Allow overriding the base name to match how the ScopeCollection will # be accessed. "base_name": directives.unchanged, } def gen_rst(self) -> t.Iterator[str]: sc_name = self.arguments[0] sc_basename = sc_name.split(".")[-1] if "base_name" in self.options: sc_basename = self.options["base_name"] example_scope = None if "example_scope" in self.options: example_scope = self.options["example_scope"].strip() known_scopes = extract_known_scopes(sc_name) if example_scope is None: example_scope = known_scopes[0] yield "" yield "Various scopes are available as attributes of this object." yield f"For example, access the ``{example_scope}`` scope with" yield "" yield f">>> {sc_basename}.{example_scope}" yield "" yield "**Supported Scopes**" yield "" for s in known_scopes: yield f"* ``{s}``" yield "" yield "" def extract_known_scopes(scope_collection_name: str) -> list[str]: sc = locate(scope_collection_name) if isinstance(sc, ScopeCollection): return [name for name in dir(sc) if isinstance(getattr(sc, name), Scope)] raise RuntimeError( f"Expected {sc} to be a scope collection, but got {type(sc)} instead" ) paginated_usage.py000066400000000000000000000013111513221403200356150ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/directivesimport typing as t from .add_content_directive import AddContentDirective class PaginatedUsage(AddContentDirective): has_content = False required_arguments = 1 optional_arguments = 0 def gen_rst(self) -> t.Iterator[str]: yield "This method supports paginated access. " yield "To use the paginated variant, give the same arguments as normal, " yield "but prefix the method name with ``paginated``, as in" yield "" yield ".. code-block::" yield "" yield f" client.paginated.{self.arguments[0]}(...)" yield "" yield "For more information, see" yield ":ref:`how to make paginated calls `." globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/roles.py000066400000000000000000000016721513221403200315510ustar00rootroot00000000000000from __future__ import annotations import typing as t from docutils import nodes from .utils import derive_doc_url_base def extdoclink_role( name: str, # pylint: disable=unused-argument rawtext: str, text: str, lineno: int, # pylint: disable=unused-argument inliner: t.Any, # pylint: disable=unused-argument options: t.Any | None = None, # pylint: disable=unused-argument content: str | None = None, # pylint: disable=unused-argument ) -> tuple[list[nodes.Node], list[t.Any]]: if " " not in text: raise ValueError("extdoclink role must contain space-separated text") linktext, _, ref = text.rpartition(" ") if not (ref.startswith("<") and ref.endswith(">")): raise ValueError("extdoclink role reference must be in angle brackets") ref = ref[1:-1] base_url = derive_doc_url_base(None) node = nodes.reference(rawtext, linktext, refuri=f"{base_url}/{ref}") return [node], [] globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/extensions/sphinxext/utils.py000066400000000000000000000070151513221403200315620ustar00rootroot00000000000000from __future__ import annotations import functools import inspect import textwrap import types import typing as t from pydoc import locate def locate_class(classname: str) -> type: cls = locate(classname) if not inspect.isclass(cls): raise RuntimeError( f"uh-oh, {classname} is not a class name? type(classname)={type(cls)}" ) return cls def classname2methods( classname: str, include_methods: t.Sequence[str] ) -> list[tuple[str, types.FunctionType]]: """Resolve a class name to a list of (public) method names + function objects. Takes a classname and a list of method names to avoid filtering out. :param classname: The name to resolve to a class :param include_methods: A list or tuple of method names which would normally be excluded as private, but which should be included in the result """ cls = locate_class(classname) # get methods of the object as [(name, ), ...] methods = inspect.getmembers(cls, predicate=inspect.isfunction) def methodname_is_good(m: str) -> bool: if m in include_methods: return True # filter out dunder-methods and `_private` methods if m.startswith("_"): return False # filter out any inherited methods which are not overloaded if m not in cls.__dict__: return False return True return [(name, value) for name, value in methods if methodname_is_good(name)] def derive_doc_url_base(service: str | None) -> str: if service is None: return "https://docs.globus.org/api" elif service == "groups": return "https://groups.api.globus.org/redoc#operation" elif service == "gcs": return "https://docs.globus.org/globus-connect-server/v5/api" elif service == "flows": return "https://globusonline.github.io/globus-flows#tag" elif service == "compute": return "https://compute.api.globus.org/redoc#tag" else: raise ValueError(f"Unsupported extdoclink service '{service}'") @functools.cache def read_sphinx_params(docstring: str) -> tuple[str, ...]: """ Given a docstring, extract the `:param:` declarations into a tuple of strings. :param docstring: The ``__doc__`` to parse, as it appeared on the original object Params start with `:param ...` and end - at the end of the string - at the next param - when a non-indented, non-param line is found Whitespace lines within a param doc are supported. All produced param strings are dedented. """ docstring = textwrap.dedent(docstring) result: list[str] = [] current: list[str] = [] for line in docstring.splitlines(): if not current: if line.startswith(":param"): current = [line] else: continue else: # a new param -- flush the current one and restart if line.startswith(":param"): result.append("\n".join(current).strip()) current = [line] # a continuation line for the current param (indented) # or a blank line -- it *could* be a continuation of param doc (include it) elif line != line.lstrip() or not line: current.append(line) # otherwise this is a populated line, not indented, and without a `:param` # start -- stop looking for more param doc else: break if current: result.append("\n".join(current).strip()) return tuple(result) globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/guards.py000066400000000000000000000075311513221403200254610ustar00rootroot00000000000000from __future__ import annotations import sys import typing as t import uuid # some error types use guards, so import from the specific module to avoid circularity from globus_sdk.exc.base import ValidationError if t.TYPE_CHECKING: from globus_sdk._internal.serializable import Serializable if sys.version_info >= (3, 10): from typing import TypeGuard else: from typing_extensions import TypeGuard T = t.TypeVar("T") S = t.TypeVar("S", bound="Serializable") def is_list_of(data: t.Any, typ: type[T]) -> TypeGuard[list[T]]: return isinstance(data, list) and all(isinstance(item, typ) for item in data) def is_optional(data: t.Any, typ: type[T]) -> TypeGuard[T | None]: return data is None or isinstance(data, typ) def is_optional_list_of(data: t.Any, typ: type[T]) -> TypeGuard[list[T] | None]: return data is None or ( isinstance(data, list) and all(isinstance(item, typ) for item in data) ) # this class is a namespace, separating validators (which error) from TypeGuards class validators: @staticmethod def str_(name: str, value: t.Any) -> str: if isinstance(value, str): return value raise ValidationError(f"'{name}' must be a string") @staticmethod def int_(name: str, value: t.Any) -> int: if isinstance(value, int): return value raise ValidationError(f"'{name}' must be an int") @staticmethod def opt_str(name: str, value: t.Any) -> str | None: if is_optional(value, str): return value raise ValidationError(f"'{name}' must be a string or null") @staticmethod def opt_bool(name: str, value: t.Any) -> bool | None: if is_optional(value, bool): return value raise ValidationError(f"'{name}' must be a bool or null") @staticmethod def str_list(name: str, value: t.Any) -> list[str]: if is_list_of(value, str): return value raise ValidationError(f"'{name}' must be a list of strings") @staticmethod def opt_str_list(name: str, value: t.Any) -> list[str] | None: if is_optional_list_of(value, str): return value raise ValidationError(f"'{name}' must be a list of strings or null") @staticmethod def opt_str_list_or_commasep(name: str, value: t.Any) -> list[str] | None: if is_optional_list_of(value, str): return value if isinstance(value, str): return value.split(",") raise ValidationError( f"'{name}' must be a list of strings or a comma-delimited string or null" ) @staticmethod def instance_or_dict(name: str, value: t.Any, cls: type[S]) -> S: if isinstance(value, cls): return value if isinstance(value, dict): return cls.from_dict(value) raise ValidationError( f"'{name}' must be a '{cls.__name__}' object or a dictionary" ) @staticmethod def uuidlike(name: str, s: t.Any) -> uuid.UUID | str: """ Raise an error if the input is not a UUID :param s: the UUID|str value :param name: the name for this value to use in error messages :raises TypeError: if the input is not a UUID|str :raises ValueError: if the input is a non-UUID str Example usage: .. code-block:: python def frob_it(collection_id: uuid.UUID | str) -> Frob: validators.uuidlike(collection_id, name="collection_id") return Frob(collection_id) """ if isinstance(s, uuid.UUID): return s elif not isinstance(s, str): raise ValidationError(f"'{name}' must be a UUID or str (value='{s}')") try: uuid.UUID(s) except ValueError as e: raise ValidationError(f"'{name}' must be a valid UUID (value='{s}')") from e return s globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/lazy_import.py000066400000000000000000000202721513221403200265420ustar00rootroot00000000000000""" Tooling for an extremely simple lazy-import system, based on inspection of pyi files. Given a base module name (used for lookup and error messages) and the name of a pyi file, we can use the pyi file to lookup import locations. i.e. Given foo.py foo.pyi bar.py then if `foo.pyi` has an import `from .bar import BarType`, it is possible to *read* `foo.pyi` at runtime and use that information to load `BarType` from `bar`. Rationale --------- Why use this type of lazy importer? The major goals are: - type information is still available to type checkers (and other tools like IDEs) - we minimize manual duplication of names - we minimize specialized knowledge needed to update our package exports - the lazy importer itself is easy to maintain and update ``.pyi`` type stubs are designed for use with type checkers. The data format choice here is "regular type stubs". We treat it as runtime data, which is a bit unusual, but we are guaranteed that `mypy` and other tools can understand it without issue. We reduce duplication with this technique, and the content in the ``.pyi`` file should be easy for most developers to read and modify. """ from __future__ import annotations import ast import sys import typing as t def load_all_tuple(modname: str, pyi_filename: str) -> tuple[str, ...]: """ Load the __all__ tuple from a ``.pyi`` file. This should run before the getattr and dir implementations are defined, as those use the runtime ``__all__`` tuple. :param modname: The name of the module doing the load. Usually ``__name__``. :param pyi_filename: The name of the ``pyi`` file relative to ``modname``. ``importlib.resources`` will use both of these fields to load the ``pyi`` data, so the file must be in package metadata. """ return _ParsedPYIData.load(modname, pyi_filename).all_names def default_getattr_implementation( modname: str, pyi_filename: str ) -> t.Callable[[str], t.Any]: """ Build an implementation of module ``__getattr__`` given the module name and the pyi file which will drive lazy imports. :param modname: The name of the module where ``__getattr__`` is being added. Usually ``__name__``. :param pyi_filename: The name of the ``pyi`` file relative to ``modname``. ``importlib.resources`` will use both of these fields to load the ``pyi`` data, so the file must be in package metadata. """ module_object = sys.modules[modname] all_tuple = module_object.__all__ def getattr_implementation(name: str) -> t.Any: if name in all_tuple: value = load_attr(modname, pyi_filename, name) setattr(module_object, name, value) return value raise AttributeError(f"module {modname} has no attribute {name}") return getattr_implementation def default_dir_implementation(modname: str) -> t.Callable[[], list[str]]: """ Build an implementation of module ``__dir__`` given the module name. :param modname: The name of the module where ``__dir__`` is being added. Usually ``__name__``. """ # dir(globus_sdk) should include everything exported in __all__ # as well as some explicitly selected attributes from the default dir() output # on a module # # see also: # https://discuss.python.org/t/how-to-properly-extend-standard-dir-search-with-module-level-dir/4202 module_object = sys.modules[modname] all_tuple = module_object.__all__ def dir_implementation() -> list[str]: return list(all_tuple) + [ # __all__ itself can be inspected "__all__", # useful to figure out where a package is installed "__file__", "__path__", ] return dir_implementation def load_attr(modname: str, pyi_filename: str, attrname: str) -> t.Any: """ Execute an import of a single attribute in the manner that it was declared in a ``.pyi`` file. The import in the pyi data is expected to be a `from x import y` statement. Only the specific attribute will be imported, even if the pyi declares multiple imports from the same module. :param modname: The name of the module importing the attribute. Usually ``__name__``. :param pyi_filename: The name of the ``pyi`` file relative to ``modname``. ``importlib.resources`` will use both of these fields to load the ``pyi`` data, so the file must be in package metadata. :param attrname: The name of the attribute to load. """ import importlib attr_source = find_source_module(modname, pyi_filename, attrname) attr_source_mod = importlib.import_module(attr_source, modname) return getattr(attr_source_mod, attrname) def find_source_module(modname: str, pyi_filename: str, attrname: str) -> str: """ Find the source module which provides an attribute, based on a declared import in a ``.pyi`` file. The ``.pyi`` data will be parsed as AST and scanned for an appropriate import. :param modname: The name of the module importing the attribute. Usually ``__name__``. :param pyi_filename: The name of the ``pyi`` file relative to ``modname``. ``importlib.resources`` will use both of these fields to load the ``pyi`` data, so the file must be in package metadata. :param attrname: The name of the attribute to load. """ parsed = _ParsedPYIData.load(modname, pyi_filename) return parsed.module_for_attr(attrname) class _ParsedPYIData: _CACHE: dict[tuple[str, str], _ParsedPYIData] = {} @classmethod def load(cls, module_name: str, pyi_filename: str) -> _ParsedPYIData: if (module_name, pyi_filename) not in cls._CACHE: cls._CACHE[(module_name, pyi_filename)] = cls(module_name, pyi_filename) return cls._CACHE[(module_name, pyi_filename)] def __init__(self, module_name: str, pyi_filename: str) -> None: self.module_name = module_name self.pyi_filename = pyi_filename self._ast = _parse_pyi_ast(module_name, pyi_filename) self._import_attr_map: dict[str, str] = {} self._all_names: list[str] = [] self._load() @property def all_names(self) -> tuple[str, ...]: return tuple(self._all_names) def module_for_attr(self, attrname: str) -> str: if attrname not in self._import_attr_map: raise LookupError( f"Could not find import of '{attrname}' in '{self.module_name}'." ) return self._import_attr_map[attrname] def _load(self) -> None: for statement in self._ast.body: self._load_statement(statement) def _load_statement(self, statement: ast.AST) -> None: if isinstance(statement, ast.ImportFrom): # type ignore the possibility of 'import_from.module == None' # as it's not possible from parsed code module_name = ( # type: ignore[operator] "." * statement.level ) + statement.module for alias in statement.names: attr_name = alias.name if alias.asname is None else alias.asname self._import_attr_map[attr_name] = module_name elif isinstance(statement, ast.Assign): if len(statement.targets) != 1: return target = statement.targets[0] if not isinstance(target, ast.Name): return if target.id != "__all__": return if not isinstance(statement.value, ast.Tuple): raise ValueError( f"While reading '__all__' for '{self.module_name}' from " f"'{self.pyi_filename}', '__all__' was not a tuple " ) for element in statement.value.elts: if not isinstance(element, ast.Constant) or not isinstance( element.value, str ): continue self._all_names.append(element.value) def _parse_pyi_ast(anchor_module_name: str, pyi_filename: str) -> ast.Module: import importlib.resources source = ( importlib.resources.files(anchor_module_name) # pylint: disable=no-member .joinpath(pyi_filename) .read_bytes() ) return ast.parse(source) globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/remarshal.py000066400000000000000000000123141513221403200261450ustar00rootroot00000000000000""" This module provides internal helpers for remarshalling data from one shape into another. This may be as simple as converting strings from one form to another, or as sophisticated as building a specific internal object in a configurable way. """ from __future__ import annotations import collections.abc import sys import typing as t import uuid from globus_sdk._missing import MISSING, MissingType if sys.version_info >= (3, 10): from typing import TypeAlias else: from typing_extensions import TypeAlias T = t.TypeVar("T") R = t.TypeVar("R") # Omittable[T] means "T may be missing" # NullableOmittable[T] means "T may be missing or null" # # in type systems this kind of construction is sometimes called "Optional" or "Maybe" # but Python uses "Optional" to mean "T | None" and in the SDK, "None" means "null" Omittable: TypeAlias[T] = t.Union[T, MissingType] NullableOmittable: TypeAlias[T] = t.Union[Omittable[T], None] def stringify(value: NullableOmittable[object]) -> NullableOmittable[str]: """ Convert a value to a string, with handling for None and Missing. :param value: The stringifiable object """ if value is None: return None if value is MISSING: return MISSING return str(value) @t.overload def listify(value: None) -> None: ... @t.overload def listify(value: MissingType) -> MissingType: ... @t.overload def listify(value: t.Iterable[T]) -> list[T]: ... def listify(value: NullableOmittable[t.Iterable[T]]) -> NullableOmittable[list[T]]: """ Convert any iterable to a list, with handling for None and Missing. :param value: The iterable of objects """ if value is None: return None if value is MISSING: return MISSING if isinstance(value, list): return value return list(value) def strseq_iter( value: t.Iterable[str | uuid.UUID] | str | uuid.UUID, ) -> t.Iterator[str]: """ Iterate over one or more string/string-convertible values. :param value: The stringifiable object or objects to iterate over This function handles strings, which are themselves iterable, by producing the string itself, not it's characters: >>> list("foo") ['f', 'o', 'o'] >>> list(strseq_iter("foo")) ['foo'] It also accepts and converts UUIDs and iterables thereof: >>> list(strseq_iter(UUID(int=0))) ['00000000-0000-0000-0000-000000000000'] >>> list(strseq_iter([UUID(int=0), UUID(int=1)])) ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'] This helps handle cases where a string is passed to a function expecting an iterable of strings, as well as cases where an iterable of UUID objects is accepted for a list of IDs, or something similar. """ if isinstance(value, str): yield value elif isinstance(value, uuid.UUID): yield str(value) else: for x in value: yield str(x) @t.overload def strseq_listify(value: None) -> None: ... @t.overload def strseq_listify(value: MissingType) -> MissingType: ... @t.overload def strseq_listify( value: t.Iterable[str | uuid.UUID] | str | uuid.UUID, ) -> list[str]: ... def strseq_listify( value: NullableOmittable[t.Iterable[str | uuid.UUID] | str | uuid.UUID], ) -> NullableOmittable[list[str]]: """ A wrapper over strseq_iter which produces list outputs. This method takes responsibility for checking for MISSING and None values. Unlike strseq_iter, this may be the "last mile" remarshalling step before data is actually passed to the network layer. Therefore, it makes sense for this helper to handle (MISSING | None). :param value: The stringifiable object or iterable of objects """ if value is None: return None if value is MISSING: return MISSING return list(strseq_iter(value)) @t.overload def list_map(value: None, mapped_function: t.Callable[[T], R]) -> None: ... @t.overload def list_map( value: MissingType, mapped_function: t.Callable[[T], R] ) -> MissingType: ... @t.overload def list_map(value: t.Iterable[T], mapped_function: t.Callable[[T], R]) -> list[R]: ... def list_map( value: NullableOmittable[t.Iterable[T]], mapped_function: t.Callable[[T], R] ) -> NullableOmittable[list[R]]: """ Like list(map()) but handles None|MISSING. :param value: The iterable of objects over which to map :param mapped_function: The function to map """ if value is None: return None if value is MISSING: return MISSING return [mapped_function(element) for element in value] @t.overload def commajoin(value: MissingType) -> MissingType: ... @t.overload def commajoin(value: None) -> None: ... @t.overload def commajoin(value: str | uuid.UUID | t.Iterable[str | uuid.UUID]) -> str: ... def commajoin( value: NullableOmittable[str | uuid.UUID | t.Iterable[str | uuid.UUID]], ) -> NullableOmittable[str]: if value is None: return None if value is MISSING: return MISSING # note that this explicit handling of Iterable allows for objects to be # passed to this function and be stringified by the `str()` call if isinstance(value, collections.abc.Iterable): return ",".join(strseq_iter(value)) return str(value) globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/serializable.py000066400000000000000000000042301513221403200266330ustar00rootroot00000000000000from __future__ import annotations import inspect import sys import typing as t if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self class Serializable: """ This is a base class for helpers which represent data which can be loaded to or from a dictionary. Serializable classes: - know what fields they have, based on their initializer signatures - support `to_dict()` and `from_dict()` conversions - typically use `globus_sdk._internal.guards.validators` to check attribute types """ _EXCLUDE_VARS: t.ClassVar[tuple[str, ...]] = ("self", "extra") extra: dict[str, t.Any] @classmethod def _supported_fields(cls) -> list[str]: signature = inspect.signature(cls.__init__) return [ name for name in signature.parameters.keys() if name not in cls._EXCLUDE_VARS ] @classmethod def from_dict(cls, data: dict[str, t.Any]) -> Self: """ Instantiate from a dictionary. :param data: The dictionary to create the error from. """ # Extract any extra fields extras = {k: v for k, v in data.items() if k not in cls._supported_fields()} kwargs: dict[str, t.Any] = {"extra": extras} # Ensure required fields are supplied for field_name in cls._supported_fields(): kwargs[field_name] = data.get(field_name) return cls(**kwargs) def to_dict(self, include_extra: bool = False) -> dict[str, t.Any]: """ Render to a dictionary. :param include_extra: Whether to include stored extra (non-standard) fields in the returned dictionary. """ result = {} # Set any authorization parameters for field in self._supported_fields(): value = getattr(self, field) if value is not None: if isinstance(value, Serializable): value = value.to_dict(include_extra=include_extra) result[field] = value # Set any extra fields if include_extra: result.update(self.extra) return result globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/type_definitions.py000066400000000000000000000011601513221403200275400ustar00rootroot00000000000000from __future__ import annotations import datetime import typing as t # these types are aliases meant for internal use IntLike = t.Union[int, str] DateLike = t.Union[str, datetime.datetime] class ResponseLike(t.Protocol): @property def http_status(self) -> int: ... @property def http_reason(self) -> str: ... @property def headers(self) -> t.Mapping[str, str]: ... @property def content_type(self) -> str | None: ... @property def text(self) -> str: ... @property def binary_content(self) -> bytes: ... class Closable(t.Protocol): def close(self) -> None: ... globus-globus-sdk-python-6a080e4/src/globus_sdk/_internal/utils.py000066400000000000000000000021231513221403200253240ustar00rootroot00000000000000from __future__ import annotations import hashlib import platform def sha256_string(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() def get_nice_hostname() -> str | None: """ Get the current hostname, with the following added behavior: - if it ends in '.local', strip that suffix, as this is a frequent macOS behavior 'DereksCoolMacbook.local' -> 'DereksCoolMacbook' - if the hostname is undiscoverable, return None """ name = platform.node() if name.endswith(".local"): return name[: -len(".local")] return name or None def slash_join(a: str, b: str | None) -> str: """ Join a and b with a single slash, regardless of whether they already contain a trailing/leading slash or neither. :param a: the first path component :param b: the second path component """ if not b: # "" or None, don't append a slash return a if a.endswith("/"): if b.startswith("/"): return a[:-1] + b return a + b if b.startswith("/"): return a + b return a + "/" + b globus-globus-sdk-python-6a080e4/src/globus_sdk/_missing.py000066400000000000000000000057741513221403200240400ustar00rootroot00000000000000""" The definition of the MISSING sentinel and its type. These are exposed publicly as `globus_sdk.MISSING` and `globus_sdk.MissingType`. """ from __future__ import annotations import typing as t T = t.TypeVar("T") # type checkers don't know that MISSING is a sentinel, so we will describe it # differently at typing time, allowing for type narrowing on `is MISSING` and # similar checks if t.TYPE_CHECKING: # pretend that `MISSING: MissingType` is an enum at type-checking time # # enums are treated as `Literal[...]` values and are narrowed under simple # checks, as unions and literal types are # therefore, under this definition, `MissingType ~= Literal[MissingType.MISSING]` # # Therefore, consider this example: # # x: int | float | MissingType # if x is not MISSING: # reveal_type(x) # # This is effectively the same as if we wrote: # # x: int | float | Literal["a"] # if x != "a": # reveal_type(x) # # Both should show `x: int | float` import enum class MissingType(enum.Enum): MISSING = enum.auto() MISSING = MissingType.MISSING else: class MissingType: def __init__(self) -> None: # disable instantiation, but gated to be able to run once # when this module is imported if "MISSING" in globals(): raise TypeError("MissingType should not be instantiated") def __bool__(self) -> bool: return False def __copy__(self) -> MissingType: return self def __deepcopy__(self, memo: dict[int, t.Any]) -> MissingType: return self # unpickling a MissingType should always return the "MISSING" sentinel def __reduce__(self) -> str: return "MISSING" def __repr__(self) -> str: return "" # a sentinel value for "missing" values which are distinguished from `None` (null) # this is the default used to indicate that a parameter was not passed, so that # method calls passing `None` can be distinguished from those which did not pass any # value # users should typically not use this value directly, but it is part of the # public SDK interfaces along with its type for annotation purposes MISSING = MissingType() @t.overload def filter_missing(data: dict[str, t.Any]) -> dict[str, t.Any]: ... @t.overload def filter_missing(data: None) -> None: ... def filter_missing(data: dict[str, t.Any] | None) -> dict[str, t.Any] | None: if data is None: return None return {k: v for k, v in data.items() if v is not MISSING} def none2missing(obj: T | None) -> T | MissingType: """ A converter for interfaces which take "nullable" to mean "omittable", to adapt them to usage sites which require use of MISSING for omittable elements. :param obj: The nullable object to convert to an omittable object. """ if obj is None: return MISSING return obj globus-globus-sdk-python-6a080e4/src/globus_sdk/_payload.py000066400000000000000000000037731513221403200240150ustar00rootroot00000000000000from __future__ import annotations import abc import sys import typing as t if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self # TODO: Remove this dispatch after we drop Python 3.8 support. # In 3.9+ `dict.__class_getitem__` is available. if t.TYPE_CHECKING: # pylint: disable=unsubscriptable-object _PayloadBaseDict = t.Dict[str, t.Any] else: _PayloadBaseDict = dict class GlobusPayload(_PayloadBaseDict): """ A class for defining helper objects which wrap some kind of "payload" dict. Typical for helper objects which formulate a request payload. Payload types inheriting from this class can be passed directly to the client ``post()``, ``put()``, and ``patch()`` methods. These methods will recognize a ``PayloadBase`` and apply conversions for serialization with the requested encoder (e.g. as a JSON request body). """ class AbstractGlobusPayload(GlobusPayload, abc.ABC): """ An abstract class which is a GlobusPayload. This is a shim which is needed because we have a metaclass conflict between dict:type and ABC:ABCMeta. Setting the metaclass helps type checkers understand that such classes are abstract. """ # explicitly define `__new__` in order to check for abstract methods which # were not redefined def __new__(cls, *args: t.Any, **kwargs: t.Any) -> Self: obj = super().__new__(cls, *args, **kwargs) abstractmethods: frozenset[str] = ( obj.__abstractmethods__ # type: ignore[attr-defined] ) if abstractmethods: s = "" if len(abstractmethods) == 1 else "s" methodnames = ", ".join(f"'{f}'" for f in abstractmethods) # this error very closely imitates the errors produced by ABCMeta raise TypeError( f"Can't instantiate abstract class {cls.__name__} without " f"an implementation for abstract method{s} {methodnames}" ) return obj globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/000077500000000000000000000000001513221403200242205ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/__init__.py000066400000000000000000000010371513221403200263320ustar00rootroot00000000000000from .access_token import AccessTokenAuthorizer from .base import GlobusAuthorizer, NullAuthorizer, StaticGlobusAuthorizer from .basic import BasicAuthorizer from .client_credentials import ClientCredentialsAuthorizer from .refresh_token import RefreshTokenAuthorizer from .renewing import RenewingAuthorizer __all__ = [ "GlobusAuthorizer", "NullAuthorizer", "StaticGlobusAuthorizer", "BasicAuthorizer", "AccessTokenAuthorizer", "RefreshTokenAuthorizer", "ClientCredentialsAuthorizer", "RenewingAuthorizer", ] globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/access_token.py000066400000000000000000000015471513221403200272420ustar00rootroot00000000000000import logging from globus_sdk._internal.utils import sha256_string from .base import StaticGlobusAuthorizer log = logging.getLogger(__name__) class AccessTokenAuthorizer(StaticGlobusAuthorizer): """ Implements Authorization using a single Access Token with no Refresh Tokens. This is sent as a Bearer token in the header -- basically unadorned. :param access_token: An access token for Globus Auth """ def __init__(self, access_token: str) -> None: log.debug( "Setting up an AccessTokenAuthorizer. It will use an " "auth type of Bearer and cannot handle 401s." ) self.access_token = access_token self.header_val = "Bearer %s" % access_token self.access_token_hash = sha256_string(self.access_token) log.debug(f'Bearer token has hash "{self.access_token_hash}"') globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/base.py000066400000000000000000000033201513221403200255020ustar00rootroot00000000000000from __future__ import annotations import abc class GlobusAuthorizer(metaclass=abc.ABCMeta): """ A ``GlobusAuthorizer`` is a very simple object which generates valid Authorization headers. It may also have handling for responses that indicate that it has provided an invalid Authorization header. """ @abc.abstractmethod def get_authorization_header(self) -> str | None: """ Get the value for the ``Authorization`` header from this authorizer. If this method returns ``None``, then no ``Authorization`` header should be used. """ def handle_missing_authorization(self) -> bool: """ This operation should be called if a request is made with an Authorization header generated by this object which returns a 401 (HTTP Unauthorized). If the ``GlobusAuthorizer`` thinks that it can take some action to remedy this, it should update its state and return ``True``. If the Authorizer cannot do anything in the event of a 401, this *may* update state, but importantly returns ``False``. By default, this always returns ``False`` and takes no other action. """ return False class StaticGlobusAuthorizer(GlobusAuthorizer): """A static authorizer has some static string as its header val which it always returns as the authz header.""" header_val: str def get_authorization_header(self) -> str: return self.header_val class NullAuthorizer(GlobusAuthorizer): """ This Authorizer implements No Authentication -- as in, it ensures that there is no Authorization header. """ def get_authorization_header(self) -> None: return None globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/basic.py000066400000000000000000000016621513221403200256600ustar00rootroot00000000000000import base64 import logging from .base import StaticGlobusAuthorizer log = logging.getLogger(__name__) class BasicAuthorizer(StaticGlobusAuthorizer): """ This Authorizer implements Basic Authentication. Given a "username" and "password", they are sent base64 encoded in the header. :param username: Username component for Basic Auth :param password: Password component for Basic Auth """ def __init__(self, username: str, password: str) -> None: log.debug( "Setting up a BasicAuthorizer. It will use an " "auth type of Basic and cannot handle 401s." ) log.debug(f"BasicAuthorizer.username = {username}") self.username = username self.password = password to_b64 = f"{username}:{password}" self.header_val = f"Basic {_b64str(to_b64)}" def _b64str(s: str) -> str: return base64.b64encode(s.encode("utf-8")).decode("utf-8") globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/client_credentials.py000066400000000000000000000077741513221403200304440ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import globus_sdk from globus_sdk.scopes import Scope, ScopeParser from .renewing import RenewingAuthorizer log = logging.getLogger(__name__) class ClientCredentialsAuthorizer( RenewingAuthorizer["globus_sdk.OAuthClientCredentialsResponse"] ): r""" Implementation of a RenewingAuthorizer that renews confidential app client Access Tokens using a ConfidentialAppAuthClient and a set of scopes to fetch a new Access Token when the old one expires. Example usage looks something like this: >>> import globus_sdk >>> confidential_client = globus_sdk.ConfidentialAppAuthClient( client_id=..., client_secret=...) >>> scopes = "..." >>> cc_authorizer = globus_sdk.ClientCredentialsAuthorizer( >>> confidential_client, scopes) >>> # create a new client >>> transfer_client = globus_sdk.TransferClient(authorizer=cc_authorizer) any client that inherits from :class:`BaseClient ` should be able to use a ClientCredentialsAuthorizer to act as the client itself. :param confidential_client: client object with a valid id and client secret :param scopes: A string of space-separated scope names being requested for the access tokens that will be used for the Authorization header. These scopes must all be for the same resource server, or else the token response will have multiple access tokens. :param access_token: Initial Access Token to use, only used if ``expires_at`` is also set. Must be requested with the same set of scopes passed to this authorizer. :param expires_at: Expiration time for the starting ``access_token`` expressed as a POSIX timestamp (i.e. seconds since the epoch) :param on_refresh: A callback which is triggered any time this authorizer fetches a new access_token. The ``on_refresh`` callable is invoked on the :class:`globus_sdk.OAuthClientCredentialsResponse` object resulting from the token being refreshed. It should take only one positional argument, the token response object. This is useful for implementing storage for Access Tokens, as the ``on_refresh`` callback can be used to update the Access Tokens and their expiration times. """ def __init__( self, confidential_client: globus_sdk.ConfidentialAppAuthClient, scopes: str | Scope | t.Iterable[str | Scope], *, access_token: str | None = None, expires_at: int | None = None, on_refresh: ( None | t.Callable[[globus_sdk.OAuthClientCredentialsResponse], t.Any] ) = None, ) -> None: # values for _get_token_data self.confidential_client = confidential_client self.scopes = ScopeParser.serialize(scopes) log.debug( "Setting up ClientCredentialsAuthorizer with confidential_client=" f"[instance:{id(confidential_client)}] and scopes={self.scopes}" ) super().__init__(access_token, expires_at, on_refresh) def _get_token_response(self) -> globus_sdk.OAuthClientCredentialsResponse: """ Make a request for new tokens, using a 'client_credentials' grant. """ return self.confidential_client.oauth2_client_credentials_tokens( requested_scopes=self.scopes ) def _extract_token_data( self, res: globus_sdk.OAuthClientCredentialsResponse ) -> dict[str, t.Any]: """ Get the tokens .by_resource_server, Ensure that only one token was gotten, and return that token. """ token_data = res.by_resource_server.values() if len(token_data) != 1: raise ValueError( "Attempting get new access token for client credentials " "authorizer didn't return exactly one token. Ensure scopes " f"{self.scopes} are for only one resource server." ) return next(iter(token_data)) globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/refresh_token.py000066400000000000000000000106641513221403200274370ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import globus_sdk from .renewing import RenewingAuthorizer log = logging.getLogger(__name__) class RefreshTokenAuthorizer( RenewingAuthorizer["globus_sdk.OAuthRefreshTokenResponse"] ): """ Implements Authorization using a Refresh Token to periodically fetch renewed Access Tokens. It may be initialized with an Access Token, or it will fetch one the first time that ``get_authorization_header()`` is called. Example usage looks something like this: .. code-block:: pycon >>> import globus_sdk >>> auth_client = globus_sdk.ConfidentialAppAuthClient(client_id=..., client_secret=...) >>> # do some flow to get a refresh token from auth_client >>> rt_authorizer = globus_sdk.RefreshTokenAuthorizer(refresh_token, auth_client) >>> # create a new client >>> transfer_client = globus_sdk.TransferClient(authorizer=rt_authorizer) Anything which inherits from :class:`BaseClient ` will automatically support usage of the ``RefreshTokenAuthorizer``. :param refresh_token: Refresh Token for Globus Auth :param auth_client: ``AuthClient`` capable of using the ``refresh_token`` :param access_token: Initial Access Token to use, only used if ``expires_at`` is also set :param expires_at: Expiration time for the starting ``access_token`` expressed as a POSIX timestamp (i.e. seconds since the epoch) :param on_refresh: A callback which is triggered any time this authorizer fetches a new access_token. The ``on_refresh`` callable is invoked on the :class:`globus_sdk.OAuthRefreshTokenResponse` object resulting from the token being refreshed. It should take only one argument, the token response object. This is useful for implementing storage for Access Tokens, as the ``on_refresh`` callback can be used to update the Access Tokens and their expiration times. """ # noqa: E501 def __init__( self, refresh_token: str, auth_client: globus_sdk.AuthLoginClient, *, access_token: str | None = None, expires_at: int | None = None, on_refresh: ( None | t.Callable[[globus_sdk.OAuthRefreshTokenResponse], t.Any] ) = None, ) -> None: log.debug( "Setting up RefreshTokenAuthorizer with auth_client=" f"[instance:{id(auth_client)}]" ) # per type checkers, this is unreachable... but it is, of course, reachable... # that's... the point if isinstance(auth_client, globus_sdk.AuthClient): # type: ignore[unreachable] raise globus_sdk.GlobusSDKUsageError( "RefreshTokenAuthorizer requires an AuthLoginClient, not an " "AuthClient. In past versions of the SDK, it was possible to " "use an AuthClient if it was correctly authorized, but this is " "no longer allowed. " "Proper usage should typically use a NativeAppAuthClient or " "ConfidentialAppAuthClient." ) # required for _get_token_data self.refresh_token = refresh_token self.auth_client = auth_client super().__init__(access_token, expires_at, on_refresh) def _get_token_response(self) -> globus_sdk.OAuthRefreshTokenResponse: """ Make a refresh token grant """ return self.auth_client.oauth2_refresh_token(self.refresh_token) def _extract_token_data( self, res: globus_sdk.OAuthRefreshTokenResponse ) -> dict[str, t.Any]: """ Get the tokens .by_resource_server, Ensure that only one token was gotten, and return that token. If the token_data includes a "refresh_token" field, update self.refresh_token to that value. """ token_data_list = list(res.by_resource_server.values()) if len(token_data_list) != 1: raise ValueError( "Attempting refresh for refresh token authorizer " "didn't return exactly one token. Possible service error." ) token_data = next(iter(token_data_list)) # handle refresh_token being present # mandated by OAuth2: https://tools.ietf.org/html/rfc6749#section-6 if "refresh_token" in token_data: self.refresh_token = token_data["refresh_token"] return token_data globus-globus-sdk-python-6a080e4/src/globus_sdk/authorizers/renewing.py000066400000000000000000000160001513221403200264050ustar00rootroot00000000000000from __future__ import annotations import abc import logging import time import typing as t from globus_sdk import exc from globus_sdk._internal.utils import sha256_string from .base import GlobusAuthorizer if t.TYPE_CHECKING: from globus_sdk.services.auth import OAuthTokenResponse log = logging.getLogger(__name__) # Provides a buffer for token expiration time to account for # possible delays or clock skew. EXPIRES_ADJUST_SECONDS = 60 # the type of the response which is produced by the authorizer, received by it, and # passed to the `on_refresh` callback ResponseT = t.TypeVar("ResponseT", bound="OAuthTokenResponse") class RenewingAuthorizer(GlobusAuthorizer, t.Generic[ResponseT], metaclass=abc.ABCMeta): r""" A ``RenewingAuthorizer`` is an abstract superclass to any authorizer that needs to get new Access Tokens in order to form Authorization headers. It may be passed an initial Access Token, but if so must also be passed an expires_at value for that token. It provides methods that handle the logic for checking and adjusting expiration time, callbacks on renewal, and 401 handling. To make an authorizer that implements this class implement the _get_token_response and _extract_token_data methods for that authorization type, :param access_token: Initial Access Token to use, only used if ``expires_at`` is also set :param expires_at: Expiration time for the starting ``access_token`` expressed as a POSIX timestamp (i.e. seconds since the epoch) :param on_refresh: A callback which is triggered any time this authorizer fetches a new access_token. The ``on_refresh`` callable is invoked on the response object resulting from the token being refreshed. It should take only one argument, the token response object. This is useful for implementing storage for Access Tokens, as the ``on_refresh`` callback can be used to update the Access Tokens and their expiration times. """ def __init__( self, access_token: str | None = None, expires_at: int | None = None, on_refresh: None | t.Callable[[ResponseT], t.Any] = None, ) -> None: self._access_token: str | None = None self._access_token_hash: str | None = None log.debug( "Setting up a RenewingAuthorizer. It will use an " "auth type of Bearer and can handle 401s." ) if (access_token is not None and expires_at is None) or ( access_token is None and expires_at is not None ): raise exc.GlobusSDKUsageError( "A RenewingAuthorizer cannot be initialized with one of " "access_token and expires_at. Either provide both or neither." ) self.access_token = access_token self.expires_at = expires_at self.on_refresh = on_refresh if self.access_token is not None: log.debug( "RenewingAuthorizer will start by using access_token " f'with hash "{self._access_token_hash}"' ) # if data were unspecified, fetch a new access token else: log.debug( "Creating RenewingAuthorizer without Access " "Token. Fetching initial token now." ) self._get_new_access_token() @property def access_token(self) -> str | None: return self._access_token @access_token.setter def access_token(self, value: str | None) -> None: self._access_token = value if value: self._access_token_hash = sha256_string(value) @abc.abstractmethod def _get_token_response(self) -> ResponseT: """ Using whatever method the specific authorizer implementing this class does, get a new token response. """ @abc.abstractmethod def _extract_token_data(self, res: ResponseT) -> dict[str, t.Any]: """ Given a token response object, get the first element of token_response.by_resource_server This method is expected to enforce that by_resource_server is only returning one access token, and return a ValueError otherwise. """ def _get_new_access_token(self) -> None: """ Given token data from _get_token_response and _extract_token_data, set the access token and expiration time, calculate the new token hash, and call on_refresh """ # get the first (and only) token res = self._get_token_response() token_data = self._extract_token_data(res) self.expires_at = token_data["expires_at_seconds"] self.access_token = token_data["access_token"] log.debug( "RenewingAuthorizer.access_token updated to " f'token with hash "{self._access_token_hash}"' ) if callable(self.on_refresh): log.debug("will call on_refresh callback") self.on_refresh(res) log.debug("on_refresh callback finished") def ensure_valid_token(self) -> None: """ Check that the authorizer has a valid token. Checks that the token is set and that the expiration time is in the future. This is called implicitly by ``get_authorization_header``, but you can call it explicitly if you want to ensure that a token gets refreshed. This can be useful in order to get at a new, valid token via the ``on_refresh`` handler. """ log.debug("RenewingAuthorizer checking expiration time") if self.access_token is None: log.debug("RenewingAuthorizer has no token") else: if ( self.expires_at is not None and time.time() <= self.expires_at - EXPIRES_ADJUST_SECONDS ): log.debug("RenewingAuthorizer determined time has not yet expired") return else: log.debug("RenewingAuthorizer has a token, but it is expired") log.debug("RenewingAuthorizer fetching new Access Token") self._get_new_access_token() def get_authorization_header(self) -> str: """ Check to see if a new token is needed and return "Bearer " """ self.ensure_valid_token() log.debug(f'bearer token has hash "{self._access_token_hash}"') return f"Bearer {self.access_token}" def handle_missing_authorization(self) -> bool: """ The renewing authorizer can respond to a service 401 by immediately invalidating its current Access Token. When this happens, the next call to ``set_authorization_header()`` will result in a new Access Token being fetched. """ log.debug( "RenewingAuthorizer seeing 401. Invalidating " "token and preparing for refresh." ) # None for expires_at invalidates any current token self.expires_at = None # respond True, as in "we took some action, the 401 *may* be resolved" return True globus-globus-sdk-python-6a080e4/src/globus_sdk/client.py000066400000000000000000000542021513221403200234740ustar00rootroot00000000000000from __future__ import annotations import logging import sys import types import typing as t import urllib.parse from globus_sdk import GlobusSDKUsageError, config, exc from globus_sdk._internal.classprop import classproperty from globus_sdk._internal.type_definitions import Closable from globus_sdk._internal.utils import slash_join from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.paging import PaginatorTable from globus_sdk.response import GlobusHTTPResponse from globus_sdk.scopes import Scope, ScopeCollection from globus_sdk.transport import RequestCallerInfo, RequestsTransport, RetryConfig from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS if sys.version_info >= (3, 10): from typing import TypeAlias else: from typing_extensions import TypeAlias if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusApp log = logging.getLogger(__name__) _DataParamType: TypeAlias = t.Union[None, str, bytes, t.Dict[str, t.Any]] class BaseClient: r""" Abstract base class for clients with error handling for Globus APIs. :param app: A ``GlobusApp`` which will be used for handling authorization and storing and validating tokens. Passing an ``app`` will automatically include a client's default scopes in the ``app``'s scope requirements unless specific ``app_scopes`` are given. If ``app_name`` is not given, the ``app``'s ``app_name`` will be used. Mutually exclusive with ``authorizer``. :param app_scopes: Optional list of ``Scope`` objects to be added to ``app``'s scope requirements instead of ``default_scope_requirements``. Requires ``app``. :param authorizer: A ``GlobusAuthorizer`` which will generate Authorization headers. Mutually exclusive with ``app``. :param app_name: Optional "nice name" for the application. Has no bearing on the semantics of client actions. It is just passed as part of the User-Agent string, and may be useful when debugging issues with the Globus Team. If both``app`` and ``app_name`` are given, this value takes priority. :param base_url: The URL for the service. Most client types initialize this value intelligently by default. Set it when inheriting from BaseClient or communicating through a proxy. This value takes precedence over the class attribute of the same name. :param transport: A :class:`RequestsTransport` object for sending and retrying requests. By default, one will be constructed by the client. :param retry_config: A :class:`RetryConfig` object with parameters to control request retry behavior. By default, one will be constructed by the client. """ # service name is used to lookup a service URL from config service_name: str = "_base" # the URL for the service # NOTE: this is not the only way to define a base url. See the docstring of the # `BaseClient._resolve_base_url` method for more details. base_url: str = "_base" #: the class for errors raised by this client on HTTP 4xx and 5xx errors #: this can be set in subclasses, but must always be a subclass of GlobusError error_class: type[exc.GlobusAPIError] = exc.GlobusAPIError #: the scopes for this client may be present as a ``ScopeCollection`` scopes: ScopeCollection | None = None def __init__( self, *, environment: str | None = None, base_url: str | None = None, app: GlobusApp | None = None, app_scopes: list[Scope] | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: # check for input parameter conflicts if app_scopes and not app: raise exc.GlobusSDKUsageError( f"A {type(self).__name__} must have an 'app' to use 'app_scopes'." ) if app and authorizer: raise exc.GlobusSDKUsageError( f"A {type(self).__name__} cannot use both an 'app' and an 'authorizer'." ) self._resources_to_close: list[Closable] = [] # Determine the client's environment # Either the provided kwarg or derived from the app used # # If neither is specified, fallback to the GLOBUS_SDK_ENVIRONMENT environment # variable. if environment: self.environment = environment elif app: self.environment = app.config.environment else: self.environment = config.get_environment_name() # resolve the base_url for the client (see docstring for resolution precedence) self.base_url = self._resolve_base_url(base_url, self.environment) self.retry_config: RetryConfig = retry_config or RetryConfig() self._register_standard_retry_checks(self.retry_config) # the client owns the Transport, and is responsible for closing on close, # if and only if the client creates the Transport if transport is not None: self.transport = transport else: self.transport = RequestsTransport() self._resources_to_close.append(self.transport) log.debug(f"initialized transport of type {type(self.transport)}") # setup paginated methods self.paginated = PaginatorTable(self) # set application name if available from app_name # if this is not set, `app.app_name` may be applied below self._app_name: str | None = None if app_name is not None: self.app_name = app_name # attach the app or authorizer provided # starting app attributes as `None` and calling the attachment method self.authorizer = authorizer self._app: GlobusApp | None = None self.app_scopes: list[Scope] | None = None if app: self.attach_globus_app(app, app_scopes=app_scopes) @property def default_scope_requirements(self) -> list[Scope]: """ Scopes that will automatically be added to this client's app's scope_requirements during _finalize_app. For clients with static scope requirements this can just be a static value. Clients with dynamic requirements should use @property and must return sane results while the Base Client is being initialized. """ raise NotImplementedError def _register_standard_retry_checks(self, retry_config: RetryConfig) -> None: """ Setup the standard checks for this client. This is called during init and may be overridden by subclasses. """ retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) @classmethod def _resolve_base_url(cls, init_base_url: str | None, environment: str) -> str: """ Resolve the client's base url. Precedence (this evaluation will fall through if an option is not set): 1. [Highest] Constructor `base_url` value. 2. Class `base_url` attribute. 3. Class `service_name` attribute (computed). :param init_base_url: The `base_url` value supplied to the constructor. :param environment: The environment to use for service URL resolution. :returns: The resolved base URL. :raises: GlobusSDKUsageError if base_url cannot be resolved. """ if init_base_url is not None: log.debug(f"Creating client of type {cls}") return init_base_url elif cls.base_url != "_base": log.debug(f"Creating client of type {cls}") return cls.base_url elif cls.service_name != "_base": log.debug(f'Creating client of type {cls} for service "{cls.service_name}"') return config.get_service_url(cls.service_name, environment) raise GlobusSDKUsageError( f"Unable to resolve base_url in client {cls}. " f"Clients must define either one or both of 'base_url' and 'service_name'." ) def attach_globus_app( self, app: GlobusApp, app_scopes: list[Scope] | None = None ) -> None: """ Attach a ``GlobusApp`` to this client and, conversely, register this client with that app. The client's default scopes will be added to the app's scope requirements unless ``app_scopes`` is used to override this. If the ``app_name`` is not set on the client, it will be set to match that of the app. .. note:: This method is only safe to call once per client object. It is implicitly called if the client is initialized with an app. :param app: The ``GlobusApp`` to attach to this client. :param app_scopes: Optional list of ``Scope`` objects to be added to ``app``'s scope requirements instead of ``default_scope_requirements``. These will be stored in the ``app_scopes`` attribute of the client. :raises GlobusSDKUsageError: If the attachment appears to conflict with the state of the client. e.g., an app or authorizer is already in place. """ # If there are any incompatible or ambiguous data, usage error. # "In the face of ambiguity, refuse the temptation to guess." if self._app: raise exc.GlobusSDKUsageError( f"Cannot attach GlobusApp to {type(self).__name__} when one is " "already attached." ) if self.app_scopes: # technically, we *could* allow for this, but it's not clear what # it would mean if a user wrote the following: # # c = ClientClass() # c.app_scopes = [foo] # c.attach_globus_app(app, app_scopes=[bar]) # # did the user expect a merge, overwrite, or other behavior? raise exc.GlobusSDKUsageError( f"Cannot attach GlobusApp to {type(self).__name__} when `app_scopes` " "is already set. " "The scopes for this client cannot be consistently resolved." ) if self.authorizer: raise exc.GlobusSDKUsageError( f"Cannot attach GlobusApp to {type(self).__name__} when it " "has an authorizer assigned." ) if self.resource_server is None: raise exc.GlobusSDKUsageError( "Unable to use an 'app' with a client with no " "'resource_server' defined." ) # the client's environment must match the app's # # there are only two ways to get to a mismatch: # # 1. pass an explicit environment which doesn't match the app, e.g., # `MyClient(environment="a", app=app)` where `app.config.environment="b"` # # 2. initialize a client without an app and later attach an app which doesn't # match, e.g., `MyClient(environment="a").attach_globus_app(app)` # # in these cases, the user has explicitly given us conflicting instructions if self.environment != app.config.environment: raise exc.GlobusSDKUsageError( f"[Environment Mismatch] {type(self).__name__}'s environment " f"({self.environment}) does not match the GlobusApp's configured " f"environment ({app.config.environment})." ) # now, assign the app, app_name, and scopes self._app = app self.app_scopes = app_scopes or self.default_scope_requirements if self.app_name is None: self.app_name = app.app_name # register the scope requirements on the app side self._app.add_scope_requirements({self.resource_server: self.app_scopes}) # finally, set up auto-gare redriving by attaching any necessary retry checks. self.retry_config.checks.register_many_checks(app.get_client_retry_checks()) def add_app_scope( self, scope_collection: str | Scope | t.Iterable[str | Scope] ) -> BaseClient: """ Add a given scope collection to this client's ``GlobusApp`` scope requirements for this client's ``resource_server``. This allows defining additional scope requirements beyond the client's ``default_scope_requirements``. Returns ``self`` for chaining. Raises ``GlobusSDKUsageError`` if this client was not initialized with a ``GlobusApp``. :param scope_collection: A scope or scopes of ``str | Scope | t.Iterable[str | Scope]`` to be added to the app's required scopes. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python app = UserApp("myapp", ...) flows_client = ( FlowsClient(app=app) .add_app_scope(FlowsScopes.manage_flows) .add_app_scope(FlowsScopes.run_manage) ) """ if not self._app: raise exc.GlobusSDKUsageError( "Cannot 'add_app_scope' on a client that does not have an 'app'." ) if self.resource_server is None: raise ValueError( "Unable to use an 'app' with a client with no " "'resource_server' defined." ) self._app.add_scope_requirements({self.resource_server: scope_collection}) return self @property def app_name(self) -> str | None: return self._app_name @app_name.setter def app_name(self, value: str) -> None: self._app_name = self.transport.user_agent = value @classproperty def resource_server( # pylint: disable=missing-param-doc self_or_cls: BaseClient | type[BaseClient], ) -> str | None: """ The resource_server name for the API and scopes associated with this client. This information is pulled from the ``scopes`` attribute of the client class. If the client does not have associated scopes, this value will be ``None``. This must return sane results while the Base Client is being initialized. """ if self_or_cls.scopes is None: return None return self_or_cls.scopes.resource_server def close(self) -> None: """ Close all resources which are owned by this client. This only closes transports which are created implicitly via client init. Externally constructed transports will not be closed. """ for resource in self._resources_to_close: log.debug( f"closing resource of type {type(resource).__name__} " f"for {type(self).__name__}" ) resource.close() # clients can act as context managers, and such usage calls close() def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: self.close() def get( # pylint: disable=missing-param-doc self, path: str, *, query_params: dict[str, t.Any] | None = None, headers: dict[str, str] | None = None, automatic_authorization: bool = True, ) -> GlobusHTTPResponse: """ Make a GET request to the specified path. See :py:meth:`~.BaseClient.request` for details on the various parameters. """ log.debug(f"GET to {path} with query_params {query_params}") return self.request( "GET", path, query_params=query_params, headers=headers, automatic_authorization=automatic_authorization, ) def post( # pylint: disable=missing-param-doc self, path: str, *, query_params: dict[str, t.Any] | None = None, data: _DataParamType = None, headers: dict[str, str] | None = None, encoding: str | None = None, automatic_authorization: bool = True, ) -> GlobusHTTPResponse: """ Make a POST request to the specified path. See :py:meth:`~.BaseClient.request` for details on the various parameters. """ log.debug(f"POST to {path} with query_params {query_params}") return self.request( "POST", path, query_params=query_params, data=data, headers=headers, encoding=encoding, automatic_authorization=automatic_authorization, ) def delete( # pylint: disable=missing-param-doc self, path: str, *, query_params: dict[str, t.Any] | None = None, headers: dict[str, str] | None = None, automatic_authorization: bool = True, ) -> GlobusHTTPResponse: """ Make a DELETE request to the specified path. See :py:meth:`~.BaseClient.request` for details on the various parameters. """ log.debug(f"DELETE to {path} with query_params {query_params}") return self.request( "DELETE", path, query_params=query_params, headers=headers, automatic_authorization=automatic_authorization, ) def put( # pylint: disable=missing-param-doc self, path: str, *, query_params: dict[str, t.Any] | None = None, data: _DataParamType = None, headers: dict[str, str] | None = None, encoding: str | None = None, automatic_authorization: bool = True, ) -> GlobusHTTPResponse: """ Make a PUT request to the specified path. See :py:meth:`~.BaseClient.request` for details on the various parameters. """ log.debug(f"PUT to {path} with query_params {query_params}") return self.request( "PUT", path, query_params=query_params, data=data, headers=headers, encoding=encoding, automatic_authorization=automatic_authorization, ) def patch( # pylint: disable=missing-param-doc self, path: str, *, query_params: dict[str, t.Any] | None = None, data: _DataParamType = None, headers: dict[str, str] | None = None, encoding: str | None = None, automatic_authorization: bool = True, ) -> GlobusHTTPResponse: """ Make a PATCH request to the specified path. See :py:meth:`~.BaseClient.request` for details on the various parameters. """ log.debug(f"PATCH to {path} with query_params {query_params}") return self.request( "PATCH", path, query_params=query_params, data=data, headers=headers, encoding=encoding, automatic_authorization=automatic_authorization, ) def request( self, method: str, path: str, *, query_params: dict[str, t.Any] | None = None, data: _DataParamType = None, headers: dict[str, str] | None = None, encoding: str | None = None, allow_redirects: bool = True, stream: bool = False, automatic_authorization: bool = True, ) -> GlobusHTTPResponse: """ Send an HTTP request :param method: HTTP request method, as an all caps string :param path: Path for the request, with or without leading slash :param query_params: Parameters to be encoded as a query string :param headers: HTTP headers to add to the request. Authorization headers may be overwritten unless ``automatic_authorization`` is False. :param data: Data to send as the request body. May pass through encoding. :param encoding: A way to encode request data. "json", "form", and "text" are all valid values. Custom encodings can be used only if they are registered with the transport. By default, strings get "text" behavior and all other objects get "json". :param allow_redirects: Follow Location headers on redirect response automatically. Defaults to ``True`` :param stream: Do not immediately download the response content. Defaults to ``False`` :param automatic_authorization: Use this client's ``app`` or ``authorizer`` to automatically generate an Authorization header. :raises GlobusAPIError: a `GlobusAPIError` will be raised if the response to the request is received and has a status code in the 4xx or 5xx categories """ # prepare data... # copy headers if present rheaders = {**headers} if headers else {} # if a client is asked to make a request against a full URL, not just the path # component, then do not resolve the path, simply pass it through as the URL if path.startswith(("https://", "http://")): url = path else: url = slash_join(self.base_url, urllib.parse.quote(path)) caller_info = RequestCallerInfo(retry_config=self.retry_config) # either use given authorizer or get one from app if automatic_authorization: caller_info.authorizer = self.authorizer if self._app and (resource_server := self.resource_server): caller_info.resource_server = resource_server caller_info.authorizer = self._app.get_authorizer(resource_server) # make the request log.debug("request will hit URL: %s", url) r = self.transport.request( method, url, caller_info=caller_info, data=data, query_params=query_params, headers=rheaders, encoding=encoding, allow_redirects=allow_redirects, stream=stream, ) log.debug("request made to URL: %s", r.url) if 200 <= r.status_code < 400: log.debug(f"request completed with response code: {r.status_code}") return GlobusHTTPResponse(r, self) log.debug(f"request completed with (error) response code: {r.status_code}") raise self.error_class(r) globus-globus-sdk-python-6a080e4/src/globus_sdk/config/000077500000000000000000000000001513221403200231065ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/config/__init__.py000066400000000000000000000004511513221403200252170ustar00rootroot00000000000000from .env_vars import get_environment_name, get_http_timeout, get_ssl_verify from .environments import EnvConfig, get_service_url, get_webapp_url __all__ = ( "EnvConfig", "get_environment_name", "get_ssl_verify", "get_http_timeout", "get_service_url", "get_webapp_url", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/config/env_vars.py000066400000000000000000000062401513221403200253050ustar00rootroot00000000000000""" Definition and loading of standard environment variables, plus a wrappers for loading and parsing values. This does not include service URL env vars (see environments.py for loading of those) """ from __future__ import annotations import logging import os import pathlib import typing as t log = logging.getLogger(__name__) T = t.TypeVar("T") ENVNAME_VAR = "GLOBUS_SDK_ENVIRONMENT" HTTP_TIMEOUT_VAR = "GLOBUS_SDK_HTTP_TIMEOUT" SSL_VERIFY_VAR = "GLOBUS_SDK_VERIFY_SSL" @t.overload def _load_var( varname: str, default: t.Any, explicit_value: t.Any | None, convert: t.Callable[[t.Any, t.Any], T], ) -> T: ... @t.overload def _load_var( varname: str, default: str, explicit_value: str | None, ) -> str: ... def _load_var( varname: str, default: t.Any, explicit_value: t.Any | None = None, convert: t.Callable[[t.Any, t.Any], T] | None = None, ) -> t.Any: # use the explicit value if given and non-None, otherwise, do an env lookup value = ( explicit_value if explicit_value is not None else os.getenv(varname, default) ) if convert: value = convert(value, default) # only info log on non-default *values* # meaning that if we define the default as 'foo' and someone explicitly sets 'foo', # no info log gets emitted if value != default: log.debug(f"on lookup, non-default setting: {varname}={value}") else: log.debug(f"on lookup, default setting: {varname}={value}") return value def _ssl_verify_cast( value: t.Any, default: t.Any # pylint: disable=unused-argument ) -> bool | str: if isinstance(value, bool): return value if not isinstance(value, (str, pathlib.Path)): msg = f"Value {value} of type {type(value)} cannot be used for SSL verification" raise ValueError(msg) if isinstance(value, str): if value.lower() in {"y", "yes", "t", "true", "on", "1"}: return True if value.lower() in {"n", "no", "f", "false", "off", "0"}: return False if os.path.isfile(value): return value if isinstance(value, pathlib.Path) and value.is_file(): return str(value.absolute()) raise ValueError( "SSL verification value must be a valid boolean value " f"or a path to a file that exists (got {value})" ) def _optfloat_cast(value: t.Any, default: t.Any) -> float | None: try: return float(value) except ValueError: pass if value == "": return t.cast(float, default) log.error(f'Value "{value}" can\'t cast to optfloat') raise ValueError(f"Invalid config float: {value}") def get_environment_name(inputenv: str | None = None) -> str: return _load_var(ENVNAME_VAR, "production", explicit_value=inputenv) def get_ssl_verify(value: bool | str | pathlib.Path | None = None) -> bool | str: return _load_var( SSL_VERIFY_VAR, default=True, explicit_value=value, convert=_ssl_verify_cast ) def get_http_timeout(value: float | None = None) -> float | None: ret = _load_var( HTTP_TIMEOUT_VAR, 60.0, explicit_value=value, convert=_optfloat_cast ) if ret == -1.0: return None return ret globus-globus-sdk-python-6a080e4/src/globus_sdk/config/environments.py000066400000000000000000000111321513221403200262050ustar00rootroot00000000000000from __future__ import annotations import logging import os import typing as t from .env_vars import get_environment_name log = logging.getLogger(__name__) # the format string for a service URL pulled out of the environment # these are handled with uppercased service names, e.g. # `GLOBUS_SDK_SERVICE_URL_SEARCH=...` _SERVICE_URL_VAR_FORMAT = "GLOBUS_SDK_SERVICE_URL_{}" class EnvConfig: envname: str domain: str no_dotapi: list[str] = ["app", "auth"] automate_services: list[str] = ["actions", "flows", "timer"] # this same dict is inherited (and therefore shared!) by all subclasses _registry: dict[str, type[EnvConfig]] = {} # this is an easier hook to use than metaclass definition -- register every subclass # in this dict automatically # # as a result, anyone can define # # class BetaEnv(EnvConfig): # domain = "beta.foo.bar.example.com" # envname = "beta" # # and retrieve it with get_config_by_name("beta") def __init_subclass__(cls, **kwargs: t.Any): super().__init_subclass__(**kwargs) cls._registry[cls.envname] = cls @classmethod def get_service_url(cls, service: str) -> str: # you can override any name with a config attribute service_url_attr = f"{service}_url" if hasattr(cls, service_url_attr): return t.cast(str, getattr(cls, service_url_attr)) # the typical pattern for a service hostname is X.api.Y # X=transfer, Y=preview.globus.org => transfer.api.preview.globus.org # check `no_dotapi` for services which don't have `.api` in their names if service in cls.no_dotapi: return f"https://{service}.{cls.domain}/" if service in cls.automate_services: return f"https://{cls.envname}.{service}.automate.globus.org/" return f"https://{service}.api.{cls.domain}/" @classmethod def get_by_name(cls, env: str) -> type[EnvConfig] | None: return cls._registry.get(env) def get_service_url(service: str, environment: str | None = None) -> str: """ Return the base URL for the given service in this environment. For example: >>> from globus_sdk.config import get_service_url >>> get_service_url("auth", environment="preview") 'https://auth.preview.globus.org/' >>> get_service_url("search", environment="production") 'https://search.api.globus.org/' :param service: The short name of the service to get the URL for :param environment: The name of the environment to use. If unspecified, this will use the ``GLOBUS_SDK_ENVIRONMENT`` environment variable. """ log.debug(f'Service URL Lookup for "{service}" under env "{environment}"') environment = environment or get_environment_name() # check for an environment variable of the form # GLOBUS_SDK_SERVICE_URL_* # and use it ahead of any env config if set varname = _SERVICE_URL_VAR_FORMAT.format(service.upper()) from_env = os.getenv(varname) if from_env: log.debug(f"Got URL from env var, {varname}={from_env}") return from_env conf = EnvConfig.get_by_name(environment) if not conf: raise ValueError(f'Unrecognized environment "{environment}"') url = conf.get_service_url(service) log.debug(f'Service URL Lookup Result: "{service}" is at "{url}"') return url def get_webapp_url(environment: str | None = None) -> str: """ Return the URL to access the Globus web app in the given environment. For example: >>> get_webapp_url("preview") 'https://app.preview.globus.org/' :param environment: The name of the environment to use. If unspecified, this will use the ``GLOBUS_SDK_ENVIRONMENT`` environment variable. """ environment = environment or get_environment_name() return get_service_url("app", environment=environment) # # public environments # class ProductionEnvConfig(EnvConfig): envname = "production" domain = "globus.org" nexus_url = "https://nexus.api.globusonline.org/" timer_url = "https://timer.automate.globus.org/" flows_url = "https://flows.automate.globus.org/" actions_url = "https://actions.automate.globus.org/" class PreviewEnvConfig(EnvConfig): envname = "preview" domain = "preview.globus.org" # # environments for internal use only # for envname in ["sandbox", "integration", "test", "staging"]: # use `type()` rather than the `class` syntax to control classnames type( f"{envname.title()}EnvConfig", (EnvConfig,), { "envname": envname, "domain": f"{envname}.globuscs.info", }, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/000077500000000000000000000000001513221403200224205ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/__init__.py000066400000000000000000000036461513221403200245420ustar00rootroot00000000000000import importlib import sys import typing as t from .api import ErrorSubdocument, GlobusAPIError from .base import GlobusError, GlobusSDKUsageError, ValidationError from .err_info import ( AuthorizationParameterInfo, ConsentRequiredInfo, ErrorInfo, ErrorInfoContainer, ) from .warnings import RemovedInV5Warning, warn_deprecated __all__ = ( "GlobusError", "GlobusSDKUsageError", "ValidationError", "GlobusAPIError", "ErrorSubdocument", "NetworkError", "GlobusTimeoutError", "GlobusConnectionTimeoutError", "GlobusConnectionError", "convert_request_exception", "ErrorInfo", "ErrorInfoContainer", "AuthorizationParameterInfo", "ConsentRequiredInfo", "RemovedInV5Warning", "warn_deprecated", ) # imports from `globus_sdk.exc.convert` are done lazily # # this ensures that we do not eagerly import `requests` when attempting to use SDK # components which do not need it, but which do need errors (e.g., RemovedInV5Warning) # and we avoid paying the performance penalty for importing the relevant dependencies if t.TYPE_CHECKING: from .convert import ( GlobusConnectionError, GlobusConnectionTimeoutError, GlobusTimeoutError, NetworkError, convert_request_exception, ) else: _LAZY_IMPORT_TABLE = { "convert": { "GlobusConnectionError", "GlobusConnectionTimeoutError", "GlobusTimeoutError", "NetworkError", "convert_request_exception", } } def __getattr__(name: str) -> t.Any: for modname, items in _LAZY_IMPORT_TABLE.items(): if name in items: mod = importlib.import_module("." + modname, __name__) value = getattr(mod, name) setattr(sys.modules[__name__], name, value) return value raise AttributeError(f"module {__name__} has no attribute {name}") globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/api.py000066400000000000000000000411351513221403200235470ustar00rootroot00000000000000from __future__ import annotations import enum import logging import sys import textwrap import typing as t from globus_sdk._internal import guards from .base import GlobusError from .err_info import ErrorInfoContainer if t.TYPE_CHECKING: import requests log = logging.getLogger(__name__) _CACHE_SENTINEL = object() class _ErrorFormat(enum.Enum): undefined = enum.auto() jsonapi = enum.auto() type_zero = enum.auto() class GlobusAPIError(GlobusError): """ Wraps errors returned by a REST API. :ivar int http_status: HTTP status code :ivar str code: Error code from the API or ``None`` for unclassified errors :ivar str request_id: The 'request_id' included in the error data, if any. :ivar list[str] messages: A list of error messages, extracted from the response data. If the data cannot be parsed or does not contain any clear message fields, this list may be empty. :ivar list[GlobusSubError] errors: A list of sub-error documents, as would be presented by JSON:API APIs and similar interfaces. """ MESSAGE_FIELDS = ["message", "detail", "title"] RECOGNIZED_AUTHZ_SCHEMES = ["bearer", "basic", "globus-goauthtoken"] def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: self._cached_raw_json: t.Any = _CACHE_SENTINEL self.http_status = r.status_code # defaults, may be rewritten during parsing self.code: str | None = None self.request_id: str | None = None self.messages: list[str] = [] self.errors: list[ErrorSubdocument] = [] self._info: ErrorInfoContainer | None = None self._underlying_response = r self._parse_response() if sys.version_info >= (3, 11): self.add_note( # pylint: disable=no-member ( "This exception was caused by an API error. " "The response body is as follows:\n\n" ) + textwrap.indent(self.text, " ") ) super().__init__(*self._get_args()) @property def message(self) -> str | None: """ An error message from the API. If there are multiple messages available, this will contain all messages joined with semicolons. If there is no message available, this will be ``None``. """ if self.messages: return "; ".join(self.messages) return None @property def http_reason(self) -> str: """ The HTTP reason string from the response. This is the part of the status line after the status code, and typically is a string description of the status. If the status line is ``HTTP/1.1 404 Not Found``, then this is the string ``"Not Found"``. """ return self._underlying_response.reason @property def headers(self) -> t.Mapping[str, str]: """ The HTTP response headers as a case-insensitive mapping. For example, ``headers["Content-Length"]`` and ``headers["content-length"]`` are treated as equivalent. """ return self._underlying_response.headers @property def content_type(self) -> str | None: return self.headers.get("Content-Type") def _get_mimetype(self, content_type: str) -> str: return content_type.split(";")[0].strip() def _jsonapi_mimetype(self) -> bool: if self.content_type is None: return False return self._get_mimetype(self.content_type) == "application/vnd.api+json" def _json_mimetype(self) -> bool: if self.content_type is None: return False mimetype = self._get_mimetype(self.content_type) if mimetype == "application/json": return True if mimetype.startswith("application/") and mimetype.endswith("+json"): return True return False @property def raw_json(self) -> dict[str, t.Any] | None: """ Get the verbatim error message received from a Globus API, interpreted as JSON data If the body cannot be loaded as JSON, this is None """ if self._cached_raw_json == _CACHE_SENTINEL: self._cached_raw_json = None if self._json_mimetype(): try: # technically, this could be a non-dict JSON type, like a list or # string but in those cases the user can just cast -- the "normal" # case is a dict self._cached_raw_json = self._underlying_response.json() except ValueError: log.error( "Error body could not be JSON decoded! " "This means the Content-Type is wrong, or the " "body is malformed!" ) return t.cast("dict[str, t.Any]", self._cached_raw_json) @property def _dict_data(self) -> dict[str, t.Any]: """ A "type asserting" wrapper over raw_json which errors if the type is not dict. """ if not isinstance(self.raw_json, dict): raise ValueError("cannot use _dict_data when data is non-dict type") return self.raw_json @property def text(self) -> str: """ Get the verbatim error message received from a Globus API as a *string* """ return self._underlying_response.text @property def binary_content(self) -> bytes: """ The error message received from a Globus API in bytes. """ return self._underlying_response.content @property def info(self) -> ErrorInfoContainer: """ An ``ErrorInfoContainer`` with parsed error data. The ``info`` of an error is guaranteed to be present, but all of its contents may be falsey if the error could not be parsed. """ if self._info is None: json_data = self.raw_json if isinstance(self.raw_json, dict) else None self._info = ErrorInfoContainer(json_data) return self._info def _get_request_authorization_scheme(self) -> str | None: try: authz_h = self._underlying_response.request.headers["Authorization"] authz_scheme = authz_h.split()[0] if authz_scheme.lower() in self.RECOGNIZED_AUTHZ_SCHEMES: return authz_scheme except (IndexError, KeyError): pass return None def _get_args(self) -> list[t.Any]: """ Get arguments to pass to the Exception base class. These args are displayed in stack traces. """ args = [ self._underlying_response.request.method, self._underlying_response.url, self._get_request_authorization_scheme(), self.http_status, self.code, # if the message is "", try using response reason # for details on these, and some examples, see # https://datatracker.ietf.org/doc/html/rfc7231#section-6.1 self.message or self._underlying_response.reason, ] if self.request_id: args.append(self.request_id) return args def _parse_response(self) -> bool: """ This is an intermediate step between 'raw_json' (loading bare JSON data) and the "real" parsing methods. In order to better support subclassing with short-circuiting behaviors if parsing goes awry, all of the parsing methods return True if parsing should continue or False if it was aborted. _parse_response() pulls the JSON body and does the following: - on non-dict JSON data, log and abort early. Don't error since this is already part of exception construction, just stop parsing. - Attempt to detect the error format in use, then dispatch to the relevant subparser: JSON:API, Type Zero, or Undefined - if subparsing succeeded, call the `_post_parse_hook` hook to allow subclasses to trivially add more computed attributes. This could also be done by altering `__init__` but it's nicer to have a dedicated hook because it is guaranteed to only be called if the rest of parsing succeeded """ if self.raw_json is None: log.debug("Error body was not parsed as JSON") return False if not isinstance(self.raw_json, dict): log.warning( # type: ignore[unreachable] "Error body could not be parsed as JSON because it was not a dict" ) return False error_format = self._detect_error_format() subparse_result: bool if error_format == _ErrorFormat.jsonapi: subparse_result = self._parse_jsonapi_error_format() elif error_format == _ErrorFormat.type_zero: subparse_result = self._parse_type_zero_error_format() else: subparse_result = self._parse_undefined_error_format() if not subparse_result: return False return self._post_parse_hook() def _post_parse_hook(self) -> bool: """ An internal callback for extra customizations after fully successful parsing. By default, does nothing. """ return True def _detect_error_format(self) -> _ErrorFormat: # if the JSON:API mimetype was used, inspect the data to make sure it is # well-formed if self._jsonapi_mimetype(): errors = self._dict_data.get("errors") if not guards.is_list_of(errors, dict): return _ErrorFormat.undefined elif len(errors) < 1: return _ErrorFormat.undefined elif not all(isinstance(error_doc, dict) for error_doc in errors): return _ErrorFormat.undefined # only use 'jsonapi' if everything checked out return _ErrorFormat.jsonapi # now evaluate attributes for Type Zero errors under the same paradigm # check each attribute and only return 'type_zero' if nothing failed if not isinstance(self._dict_data.get("code"), str): return _ErrorFormat.undefined elif not isinstance(self._dict_data.get("message"), str): return _ErrorFormat.undefined # request_id is not required, but must be a string if present elif "request_id" in self._dict_data and not isinstance( self._dict_data["request_id"], str ): return _ErrorFormat.undefined return _ErrorFormat.type_zero def _parse_jsonapi_error_format(self) -> bool: """ Parsing a JSON:API Error This is only called after the field type for 'errors' has been checked. However, the nested/underlying fields will not have been checked yet. """ self.errors = [ErrorSubdocument(e) for e in self._dict_data["errors"]] self.code = self._extract_code_from_error_array(self.errors) self.messages = self._extract_messages_from_error_array(self.errors) return True def _parse_type_zero_error_format(self) -> bool: """ Parsing a Type Zero Error This is only called after Type Zero has been detected. Therefore, we already have assurances about the values in 'code' and 'message'. Note that 'request_id' could be absent but *must* be a string if present. """ self.code = self._dict_data["code"] self.messages = [self._dict_data["message"]] self.request_id = self._dict_data.get("request_id") if guards.is_list_of(self._dict_data.get("errors"), dict): raw_errors = self._dict_data["errors"] else: raw_errors = [self._dict_data] self.errors = [ ErrorSubdocument(e, message_fields=self.MESSAGE_FIELDS) for e in raw_errors ] return True def _parse_undefined_error_format(self) -> bool: """ Undefined Parsing: best effort support for unknown data shapes This is also a great place for custom parsing to hook in for different APIs if we know that there's an unusual format in use """ # attempt to pull out errors if possible and valid if guards.is_list_of(self._dict_data.get("errors"), dict): raw_errors = self._dict_data["errors"] # if no 'errors' were found, or 'errors' is invalid, then # 'errors' should be set to contain the root document else: raw_errors = [self._dict_data] self.errors = [ ErrorSubdocument(e, message_fields=self.MESSAGE_FIELDS) for e in raw_errors ] # use 'code' if present and correct type if isinstance(self._dict_data.get("code"), str): self.code = t.cast(str, self._dict_data["code"]) # otherwise, pull 'code' from the sub-errors array elif self.errors: # in undefined parse cases, the code will be left as `"Error"` for # a higher degree of backwards compatibility maybe_code = self._extract_code_from_error_array(self.errors) if maybe_code is not None: self.code = maybe_code # either there is an array of subdocument errors or there is not, # in which case we load only from the root doc self.messages = self._extract_messages_from_error_array( self.errors or [ErrorSubdocument(self._dict_data, message_fields=self.MESSAGE_FIELDS)] ) return True def _extract_messages_from_error_array( self, errors: list[ErrorSubdocument] ) -> list[str]: """ Extract 'messages' from an array of errors (JSON:API or otherwise) Each subdocument *may* define its messages, so this is the aggregate of messages from those documents which had messages. Note that subdocuments may be instructed about their `message_fields` by the error class and parsing path which they take. Therefore, this may be extracting `"message"`, `"detail"`, or `"title"` in the base implementation and other fields if a subclass customizes this further. """ ret: list[str] = [] for doc in errors: if doc.message is not None: ret.append(doc.message) return ret def _extract_code_from_error_array( self, errors: list[ErrorSubdocument] ) -> str | None: """ Extract a 'code' field from an array of errors (JSON:API or otherwise) This is done by checking if each error document has the same 'code' """ codes: set[str] = set() for error in errors: if error.code is not None: codes.add(error.code) if len(codes) == 1: return codes.pop() return None class ErrorSubdocument: """ Error subdocuments as returned by Globus APIs. :ivar dict raw: The unparsed error subdocument """ # the default set of fields to use for message extraction, in order # selected to match the fields defined by JSON:API by default DEFAULT_MESSAGE_FIELDS: tuple[str, ...] = ("detail", "title") def __init__( self, data: dict[str, t.Any], *, message_fields: t.Sequence[str] | None = None ) -> None: self.raw = data self._message_fields: tuple[str, ...] if message_fields is not None: self._message_fields = tuple(message_fields) else: self._message_fields = self.DEFAULT_MESSAGE_FIELDS @property def message(self) -> str | None: """ The 'message' string of this subdocument, derived from its data based on the parsing context. May be `None` if no message is defined. """ return _extract_message_from_dict(self.raw, self._message_fields) @property def code(self) -> str | None: """ The 'code' string of this subdocument, derived from its data based on the parsing context. May be `None` if no code is defined. """ if isinstance(self.raw.get("code"), str): return t.cast(str, self.raw["code"]) return None def get(self, key: str, default: t.Any = None) -> t.Any | None: """ A dict-like getter for the raw data. :param key: The string key to use for lookup :param default: The default value to use """ return self.raw.get(key, default) def _extract_message_from_dict( data: dict[str, t.Any], message_fields: tuple[str, ...] ) -> str | None: """ Extract a single message string from a dict if one is present. """ for f in message_fields: if isinstance(data.get(f), str): return t.cast(str, data[f]) return None globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/base.py000066400000000000000000000014201513221403200237010ustar00rootroot00000000000000class GlobusError(Exception): """ Root of the Globus Exception hierarchy. """ class GlobusSDKUsageError(GlobusError, ValueError): """ A ``GlobusSDKUsageError`` may be thrown in cases in which the SDK detects that it is being used improperly. These errors typically indicate that some contract regarding SDK usage (e.g. required order of operations) has been violated. """ class ValidationError(GlobusError, ValueError): """ A ``ValidationError`` may be raised when the SDK is instructed to handle or parse data which can be seen to be invalid without an external service interaction. These errors typically do not indicate a usage error similar to ``GlobusSDKUsageError``, but rather that the data is invalid. """ globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/convert.py000066400000000000000000000031231513221403200244510ustar00rootroot00000000000000from __future__ import annotations import typing as t import requests from .base import GlobusError # Wrappers around requests exceptions, so the SDK is somewhat independent from details # about requests class NetworkError(GlobusError): """ Error communicating with the REST API server. Holds onto original exception data, but also takes a message to explain potentially confusing or inconsistent exceptions passed to us """ def __init__(self, msg: str, exc: Exception, *args: t.Any, **kwargs: t.Any) -> None: super().__init__(msg) self.underlying_exception = exc class GlobusTimeoutError(NetworkError): """The REST request timed out.""" class GlobusConnectionTimeoutError(GlobusTimeoutError): """The request timed out during connection establishment. These errors are safe to retry.""" class GlobusConnectionError(NetworkError): """A connection error occurred while making a REST request.""" def convert_request_exception(exc: requests.RequestException) -> GlobusError: """ Converts incoming requests.Exception to a Globus NetworkError :param exc: The exception to "convert" by wrapping it """ if isinstance(exc, requests.ConnectTimeout): return GlobusConnectionTimeoutError("ConnectTimeoutError on request", exc) if isinstance(exc, requests.Timeout): return GlobusTimeoutError("TimeoutError on request", exc) elif isinstance(exc, requests.ConnectionError): return GlobusConnectionError("ConnectionError on request", exc) else: return NetworkError("NetworkError on request", exc) globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/err_info.py000066400000000000000000000164701513221403200246050ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t from globus_sdk._internal import guards log = logging.getLogger(__name__) class ErrorInfo: """ Errors may contain "containers" of data which are testable (define ``__bool__``). When they have data, they should ``bool()`` as ``True`` """ _has_data: bool def __bool__(self) -> bool: return self._has_data def __str__(self) -> str: if self: attrmap = ", ".join( [f"{k}={v}" for k, v in self.__dict__.items() if not k.startswith("_")] ) else: attrmap = ":" return f"{self.__class__.__name__}({attrmap})" class AuthorizationParameterInfo(ErrorInfo): """ AuthorizationParameterInfo objects may contain information about the 'authorization_parameters' of an error. They test as truthy when the error has valid 'authorization_parameters' data. :ivar session_message: A message from the server :vartype session_message: str, optional :ivar session_required_identities: A list of identity IDs as strings which are being requested by the server :vartype session_required_identities: list of str, optional :ivar session_required_single_domain: A list of domains which are being requested by the server ("single domain" because the user should choose one) :vartype session_required_single_domain: list of str, optional :ivar session_required_policies: A list of policies required for the session. :vartype session_required_policies: list of str, optional :ivar session_required_mfa: Whether MFA is required for the session. :vartype session_required_mfa: bool, optional **Examples** >>> try: >>> ... # something >>> except GlobusAPIError as err: >>> # get a parsed AuthorizationParameterInfo object, and check if it's truthy >>> authz_params = err.info.authorization_parameters >>> if not authz_params: >>> raise >>> # whatever handling code is desired... >>> print("got authz params:", authz_params) """ def __init__(self, error_data: dict[str, t.Any]) -> None: # data is there if this key is present and it is a dict self._has_data = isinstance(error_data.get("authorization_parameters"), dict) data = t.cast( t.Dict[str, t.Any], error_data.get("authorization_parameters", {}) ) self.session_message: str | None = self._parse_session_message(data) self.session_required_identities: list[str] | None = ( self._parse_session_required_identities(data) ) self.session_required_single_domain: list[str] | None = ( self._parse_session_required_single_domain(data) ) self.session_required_policies: list[str] | None = ( self._parse_session_required_policies(data) ) self.session_required_mfa = self._parse_session_required_mfa(data) def _parse_session_message(self, data: dict[str, t.Any]) -> str | None: session_message = data.get("session_message") if isinstance(session_message, str): return session_message elif session_message is not None: self._warn_type("session_message", "str", session_message) return None def _parse_session_required_identities( self, data: dict[str, t.Any] ) -> list[str] | None: session_required_identities = data.get("session_required_identities") if guards.is_list_of(session_required_identities, str): return session_required_identities elif session_required_identities is not None: self._warn_type( "session_required_identities", "list[str]", session_required_identities, ) return None def _parse_session_required_single_domain( self, data: dict[str, t.Any] ) -> list[str] | None: session_required_single_domain = data.get("session_required_single_domain") if guards.is_list_of(session_required_single_domain, str): return session_required_single_domain elif session_required_single_domain is not None: self._warn_type( "session_required_single_domain", "list[str]", session_required_single_domain, ) return None def _parse_session_required_policies( self, data: dict[str, t.Any] ) -> list[str] | None: session_required_policies = data.get("session_required_policies") if isinstance(session_required_policies, str): return session_required_policies.split(",") elif guards.is_list_of(session_required_policies, str): return session_required_policies elif session_required_policies is not None: self._warn_type( "session_required_policies", "list[str]|str", session_required_policies ) return None def _parse_session_required_mfa(self, data: dict[str, t.Any]) -> bool | None: session_required_mfa = data.get("session_required_mfa") if isinstance(session_required_mfa, bool): return session_required_mfa elif session_required_mfa is not None: self._warn_type("session_required_mfa", "bool", session_required_mfa) return None def _warn_type(self, key: str, expected: str, got: t.Any) -> None: log.warning( f"During ErrorInfo instantiation, got unexpected type for '{key}'. " f"Expected '{expected}'. Got '{got}'" ) class ConsentRequiredInfo(ErrorInfo): """ ConsentRequiredInfo objects contain required consent information for an error. They test as truthy if the error was marked as a ConsentRequired error. :ivar required_scopes: A list of scopes requested by the server :vartype required_scopes: list of str, optional """ def __init__(self, error_data: dict[str, t.Any]) -> None: # data is only considered parseable if this error has the code 'ConsentRequired' has_code = error_data.get("code") == "ConsentRequired" data = error_data if has_code else {} self.required_scopes = self._parse_required_scopes(data) # but the result is only considered valid if both parts are present self._has_data = has_code and bool(self.required_scopes) def _parse_required_scopes(self, data: dict[str, t.Any]) -> list[str]: if guards.is_list_of(data.get("required_scopes"), str): return t.cast("list[str]", data["required_scopes"]) elif isinstance(data.get("required_scope"), str): return [data["required_scope"]] return [] class ErrorInfoContainer: """ This is a wrapper type which contains various error info objects for parsed error data. It is attached to API errors as the ``.info`` attribute. :ivar authorization_parameters: A parsed AuthorizationParameterInfo object :ivar consent_required: A parsed ConsentRequiredInfo object """ def __init__(self, error_data: dict[str, t.Any] | None) -> None: self.authorization_parameters = AuthorizationParameterInfo(error_data or {}) self.consent_required = ConsentRequiredInfo(error_data or {}) def __str__(self) -> str: return f"{self.authorization_parameters}|{self.consent_required}" globus-globus-sdk-python-6a080e4/src/globus_sdk/exc/warnings.py000066400000000000000000000006661513221403200246320ustar00rootroot00000000000000from __future__ import annotations import warnings class RemovedInV5Warning(DeprecationWarning): """ This warning indicates that a feature or usage was detected which will be unsupported in globus-sdk version 5. Users are encouraged to resolve these warnings when possible. """ def warn_deprecated(message: str, stacklevel: int = 2) -> None: warnings.warn(message, RemovedInV5Warning, stacklevel=stacklevel) globus-globus-sdk-python-6a080e4/src/globus_sdk/experimental/000077500000000000000000000000001513221403200243365ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/experimental/__init__.py000066400000000000000000000000001513221403200264350ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/gare/000077500000000000000000000000001513221403200225575ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/gare/__init__.py000066400000000000000000000004141513221403200246670ustar00rootroot00000000000000from ._auth_requirements_error import GARE, GlobusAuthorizationParameters from ._functional_api import has_gares, is_gare, to_gare, to_gares __all__ = ( "GARE", "GlobusAuthorizationParameters", "to_gare", "to_gares", "is_gare", "has_gares", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/gare/_auth_requirements_error.py000066400000000000000000000120541513221403200302470ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk._internal.guards import validators from globus_sdk._internal.serializable import Serializable class GlobusAuthorizationParameters(Serializable): """ Data class containing authorization parameters that can be passed during an authentication flow to control how the user will authenticate. When used with a GARE this represents the additional authorization parameters needed in order to complete a request that had insufficient authorization state. :ivar session_message: A message to be displayed to the user. :vartype session_message: str, optional :ivar session_required_identities: A list of identities required for the session. :vartype session_required_identities: list of str, optional :ivar session_required_policies: A list of policies required for the session. :vartype session_required_policies: list of str, optional :ivar session_required_single_domain: A list of domains required for the session. :vartype session_required_single_domain: list of str, optional :ivar session_required_mfa: Whether MFA is required for the session. :vartype session_required_mfa: bool, optional :ivar required_scopes: A list of scopes for which consent is required. :vartype required_scopes: list of str, optional :ivar prompt: The OIDC 'prompt' parameter, for which Globus Auth currently supports the values 'login' and 'none'. :vartype prompt: str, optional :ivar extra: A dictionary of additional fields that were provided. May be used for forward/backward compatibility. :vartype extra: dict """ def __init__( self, *, session_message: str | None = None, session_required_identities: list[str] | None = None, session_required_policies: list[str] | None = None, session_required_single_domain: list[str] | None = None, session_required_mfa: bool | None = None, required_scopes: list[str] | None = None, prompt: str | None = None, extra: dict[str, t.Any] | None = None, ) -> None: self.session_message = validators.opt_str("session_message", session_message) self.session_required_identities = validators.opt_str_list( "session_required_identities", session_required_identities ) self.session_required_policies = validators.opt_str_list( "session_required_policies", session_required_policies ) self.session_required_single_domain = validators.opt_str_list( "session_required_single_domain", session_required_single_domain ) self.session_required_mfa = validators.opt_bool( "session_required_mfa", session_required_mfa ) self.required_scopes = validators.opt_str_list( "required_scopes", required_scopes ) self.prompt = validators.opt_str("prompt", prompt) self.extra = extra or {} def __repr__(self) -> str: extra_repr = "" if self.extra: extra_repr = ", extra=..." attrs = [ f"{name}={getattr(self, name)!r}" for name in ( "session_message", "session_required_identities", "session_required_policies", "session_required_single_domain", "session_required_mfa", "required_scopes", "prompt", ) ] return "GlobusAuthorizationParameters(" + ", ".join(attrs) + extra_repr + ")" class GARE(Serializable): """ Represents a Globus Auth Requirements Error. A Globus Auth Requirements Error is a class of error that is returned by Globus services to indicate that additional authorization is required in order to complete a request and contains information that can be used to request the appropriate authorization. :ivar code: The error code for this error. :vartype code: str :ivar authorization_parameters: The authorization parameters for this error. :vartype authorization_parameters: GlobusAuthorizationParameters :ivar extra: A dictionary of additional fields that were provided. May be used for forward/backward compatibility. :vartype extra: dict """ def __init__( self, code: str, authorization_parameters: dict[str, t.Any] | GlobusAuthorizationParameters, *, extra: dict[str, t.Any] | None = None, ) -> None: self.code = validators.str_("code", code) self.authorization_parameters = validators.instance_or_dict( "authorization_parameters", authorization_parameters, GlobusAuthorizationParameters, ) self.extra = extra or {} def __repr__(self) -> str: extra_repr = "" if self.extra: extra_repr = ", extra=..." return ( f"GARE(code={self.code!r}, " f"authorization_parameters={self.authorization_parameters!r}" f"{extra_repr})" ) globus-globus-sdk-python-6a080e4/src/globus_sdk/gare/_functional_api.py000066400000000000000000000125021513221403200262630ustar00rootroot00000000000000from __future__ import annotations import logging import sys import typing as t from globus_sdk import exc from ._auth_requirements_error import GARE from ._variants import ( LegacyAuthorizationParametersError, LegacyAuthRequirementsErrorVariant, LegacyConsentRequiredAPError, LegacyConsentRequiredTransferError, LegacyDependentConsentRequiredAuthError, ) if sys.version_info >= (3, 10): from typing import TypeAlias else: from typing_extensions import TypeAlias AnyErrorDocumentType: TypeAlias = ( "exc.GlobusAPIError | exc.ErrorSubdocument | dict[str, t.Any]" ) log = logging.getLogger(__name__) def to_gare(error: AnyErrorDocumentType) -> GARE | None: """ Converts a GlobusAPIError, ErrorSubdocument, or dict into a GARE by attempting to match to GARE (preferred) or legacy variants. .. note:: A GlobusAPIError may contain multiple errors, and in this case only a single GARE is returned for the first error that matches a known format. If the provided error does not match a known format, None is returned. :param error: The error to convert. """ from globus_sdk.exc import ErrorSubdocument, GlobusAPIError # GlobusAPIErrors may contain more than one error, so we consider all of them # even though we only return the first. if isinstance(error, GlobusAPIError): # first, try to parse a GARE from the root document, # and if we can do so, return it authreq_error = _lenient_dict2gare(error.raw_json) if authreq_error is not None: return authreq_error # Iterate over ErrorSubdocuments for subdoc in error.errors: authreq_error = _lenient_dict2gare(subdoc.raw) if authreq_error is not None: # Return only the first auth requirements error we encounter return authreq_error # We failed to find a Globus Auth Requirements Error return None elif isinstance(error, ErrorSubdocument): return _lenient_dict2gare(error.raw) else: return _lenient_dict2gare(error) def to_gares(errors: list[AnyErrorDocumentType]) -> list[GARE]: """ Converts a list of GlobusAPIErrors, ErrorSubdocuments, or dicts into a list of GAREs by attempting to match each error to GARE (preferred) or legacy variants. .. note:: A GlobusAPIError may contain multiple errors, so the result list could be longer than the provided list. If no errors match any known formats, an empty list is returned. :param errors: The errors to convert. """ maybe_gares: list[GARE | None] = [] for error in errors: # when handling an API error, avoid `to_gare(error)` because that will # only unpack a single result if isinstance(error, exc.GlobusAPIError): # Use the ErrorSubdocuments when handling API error types maybe_gares.extend(to_gare(e) for e in error.errors) # Also use the root document, but only if there is an `"errors"` # key inside of the error document # Why? Because the *default* for `.errors` when there is no inner # `"errors"` array is an array containing the root document as a # subdocument if isinstance(error.raw_json, dict) and "errors" in error.raw_json: # use dict parsing directly so that the native descent in 'to_gare' # to subdocuments does not apply in this case maybe_gares.append(_lenient_dict2gare(error.raw_json)) else: maybe_gares.append(to_gare(error)) # Remove any errors that did not resolve to a Globus Auth Requirements Error return [error for error in maybe_gares if error is not None] def _lenient_dict2gare(error_dict: dict[str, t.Any] | None) -> GARE | None: """ Parse a GARE from a dict, accepting legacy variants. If given ``None``, returns ``None``. This allows this to accept inputs which are themselves dict|None. :param error_dict: the error input :eturns: ``None`` on a failed parse """ if error_dict is None: return None # Prefer a proper auth requirements error, if possible try: return GARE.from_dict(error_dict) except exc.ValidationError as err: log.debug(f"Failed to parse error as 'GARE' ({err})") supported_variants: list[type[LegacyAuthRequirementsErrorVariant]] = [ LegacyAuthorizationParametersError, LegacyConsentRequiredTransferError, LegacyConsentRequiredAPError, LegacyDependentConsentRequiredAuthError, ] for variant in supported_variants: try: return variant.from_dict(error_dict).to_auth_requirements_error() except exc.ValidationError as err: log.debug(f"Failed to parse error as '{variant.__name__}' ({err})") return None def is_gare(error: AnyErrorDocumentType) -> bool: """ Return True if the provided error matches a known Globus Auth Requirements Error format. :param error: The error to check. """ return to_gare(error) is not None def has_gares(errors: list[AnyErrorDocumentType]) -> bool: """ Return True if any of the provided errors match a known Globus Auth Requirements Error format. :param errors: The errors to check. """ return any(is_gare(error) for error in errors) globus-globus-sdk-python-6a080e4/src/globus_sdk/gare/_variants.py000066400000000000000000000200321513221403200251140ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk import exc from globus_sdk._internal.guards import validators from globus_sdk._internal.serializable import Serializable from ._auth_requirements_error import GARE, GlobusAuthorizationParameters V = t.TypeVar("V", bound="LegacyAuthRequirementsErrorVariant") class LegacyAuthRequirementsErrorVariant(t.Protocol): """ Protocol for errors which can be converted to a Globus Auth Requirements Error. """ @classmethod def from_dict(cls: type[V], data: dict[str, t.Any]) -> V: pass def to_auth_requirements_error(self) -> GARE: ... class LegacyDependentConsentRequiredAuthError(Serializable): """ The dependent_consent_required error format emitted by the Globus Auth service. """ def __init__( self, *, error: t.Literal["dependent_consent_required"], errors: list[dict[str, str]], extra: dict[str, t.Any] | None = None, ) -> None: self.error = _validate_dependent_consent_required_literal("error", error) try: first_error = errors[0] if not isinstance(first_error, dict): raise TypeError except (AttributeError, KeyError, TypeError) as _error: msg = "'errors' must be list of errors with at least one error object" raise exc.ValidationError(msg) from _error self.errors = errors self.unapproved_scopes = validators.str_list( "unapproved_scopes", first_error.get("unapproved_scopes", []) ) self.extra = extra or {} def to_auth_requirements_error(self) -> GARE: """ Return a GlobusAuthRequirementsError representing this error. """ return GARE( code="ConsentRequired", authorization_parameters=GlobusAuthorizationParameters( required_scopes=self.unapproved_scopes, ), ) class LegacyConsentRequiredTransferError(Serializable): """ The ConsentRequired error format emitted by the Globus Transfer service. """ def __init__( self, *, code: t.Literal["ConsentRequired"], required_scopes: list[str], extra: dict[str, t.Any] | None = None, ) -> None: self.code = _validate_consent_required_literal("code", code) self.required_scopes = validators.str_list("required_scopes", required_scopes) self.extra = extra or {} def to_auth_requirements_error(self) -> GARE: """ Return a GlobusAuthRequirementsError representing this error. """ return GARE( code=self.code, authorization_parameters=GlobusAuthorizationParameters( required_scopes=self.required_scopes, session_message=self.extra.get("message"), ), extra=self.extra, ) class LegacyConsentRequiredAPError(Serializable): """ The ConsentRequired error format emitted by the legacy Globus Transfer Action Providers. """ def __init__( self, *, code: t.Literal["ConsentRequired"], required_scope: str, extra: dict[str, t.Any] | None, ) -> None: self.code = _validate_consent_required_literal("code", code) self.required_scope = validators.str_("required_scope", required_scope) self.extra = extra or {} def to_auth_requirements_error(self) -> GARE: """ Return a GlobusAuthRequirementsError representing this error. Normalizes the required_scope field to a list and uses the description to set the session message. """ return GARE( code=self.code, authorization_parameters=GlobusAuthorizationParameters( required_scopes=[self.required_scope], session_message=self.extra.get("description"), extra=self.extra.get("authorization_parameters"), ), extra={ k: v for k, v in self.extra.items() if k != "authorization_parameters" }, ) class LegacyAuthorizationParameters(Serializable): """ An Authorization Parameters object that describes all known variants in use by Globus services. """ def __init__( self, *, session_message: str | None = None, session_required_identities: list[str] | None = None, session_required_policies: str | list[str] | None = None, session_required_single_domain: str | list[str] | None = None, session_required_mfa: bool | None = None, prompt: t.Literal["login"] | None = None, extra: dict[str, t.Any] | None = None, ) -> None: self.session_message = validators.opt_str("session_message", session_message) self.session_required_identities = validators.opt_str_list( "session_required_identities", session_required_identities ) self.session_required_policies = validators.opt_str_list_or_commasep( "session_required_policies", session_required_policies ) self.session_required_single_domain = validators.opt_str_list_or_commasep( "session_required_single_domain", session_required_single_domain ) self.session_required_mfa = validators.opt_bool( "session_required_mfa", session_required_mfa ) if prompt in [None, "login"]: self.prompt = prompt else: raise exc.ValidationError("'prompt' must be 'login' or null") self.extra = extra or {} def to_authorization_parameters( self, ) -> GlobusAuthorizationParameters: """ Return a normalized GlobusAuthorizationParameters instance representing these parameters. Normalizes fields that may have been provided as comma-delimited strings to lists of strings. """ return GlobusAuthorizationParameters( session_message=self.session_message, session_required_identities=self.session_required_identities, session_required_mfa=self.session_required_mfa, session_required_policies=self.session_required_policies, session_required_single_domain=self.session_required_single_domain, prompt=self.prompt, extra=self.extra, ) class LegacyAuthorizationParametersError(Serializable): """ Defines an Authorization Parameters error that describes all known variants in use by Globus services. """ DEFAULT_CODE = "AuthorizationRequired" def __init__( self, *, authorization_parameters: dict[str, t.Any] | LegacyAuthorizationParameters, code: str | None = None, extra: dict[str, t.Any] | None = None, ) -> None: # Apply default, if necessary self.code = validators.str_("code", code or self.DEFAULT_CODE) self.authorization_parameters = validators.instance_or_dict( "authorization_parameters", authorization_parameters, LegacyAuthorizationParameters, ) self.extra = extra or {} def to_auth_requirements_error(self) -> GARE: """ Return a GlobusAuthRequirementsError representing this error. """ authorization_parameters = ( self.authorization_parameters.to_authorization_parameters() ) return GARE( authorization_parameters=authorization_parameters, code=self.code, extra=self.extra, ) def _validate_consent_required_literal( name: str, value: t.Any ) -> t.Literal["ConsentRequired"]: if value == "ConsentRequired": return "ConsentRequired" raise exc.ValidationError(f"'{name}' must be the string 'ConsentRequired'") def _validate_dependent_consent_required_literal( name: str, value: t.Any ) -> t.Literal["dependent_consent_required"]: if value == "dependent_consent_required": return "dependent_consent_required" msg = f"'{name}' must be the string 'dependent_consent_required'" raise exc.ValidationError(msg) globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/000077500000000000000000000000001513221403200237745ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/__init__.py000066400000000000000000000007631513221403200261130ustar00rootroot00000000000000from .app import GlobusApp from .client_app import ClientApp from .config import GlobusAppConfig from .protocols import ( IDTokenDecoderProvider, LoginFlowManagerProvider, TokenStorageProvider, TokenValidationErrorHandler, ) from .user_app import UserApp __all__ = ( "GlobusApp", "UserApp", "ClientApp", "GlobusAppConfig", # Protocols "IDTokenDecoderProvider", "TokenValidationErrorHandler", "TokenStorageProvider", "LoginFlowManagerProvider", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/app.py000066400000000000000000000560461513221403200251410ustar00rootroot00000000000000from __future__ import annotations import abc import contextlib import copy import logging import sys import types import typing as t import uuid from json import JSONDecodeError from requests import Response from globus_sdk import ( AuthClient, AuthLoginClient, GlobusSDKUsageError, IDTokenDecoder, ) from globus_sdk._internal.type_definitions import Closable from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.gare import GARE, GlobusAuthorizationParameters, to_gare from globus_sdk.scopes import AuthScopes, Scope, ScopeParser from globus_sdk.token_storage import ( ScopeRequirementsValidator, TokenStorage, TokenValidationError, ValidatingTokenStorage, ) from globus_sdk.transport import ( RetryCheck, RetryCheckFlags, RetryCheckResult, RetryContext, set_retry_check_flags, ) from .authorizer_factory import AuthorizerFactory from .config import DEFAULT_CONFIG, KNOWN_TOKEN_STORAGES, GlobusAppConfig from .protocols import TokenStorageProvider if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self log = logging.getLogger(__name__) class GlobusApp(metaclass=abc.ABCMeta): """ The abstract base class for managing authentication across services. A single ``GlobusApp`` may be bound to many service clients, providing each them with dynamically updated authorization tokens. The app is responsible for ensuring these tokens are up-to-date and validly scoped; including initiating login flows to acquire new tokens when necessary. See :class:`UserApp` to oversee interactions with a human. See :class:`ClientApp` to oversee interactions with a service account. .. warning:: GlobusApp is **not** thread safe. :ivar ValidatingTokenStorage token_storage: The interface used by the app to store, retrieve, and validate Globus Auth-issued tokens. :ivar dict[str, list[Scope]] scope_requirements: A copy of the app's aggregate scope requirements. Modifying the returned dict will not affect the app's internal store. To add scope requirements, instead use the :meth:`add_scope_requirements` method. """ _login_client: AuthLoginClient # a bool is used to track whether or not the AuthorizerFactory is ready for use # this allows code during init call into otherwise unsafe codepaths in the app, # namely those which manipulate scope requirements _authorizer_factory: AuthorizerFactory[GlobusAuthorizer] _token_storage: TokenStorage token_storage: ValidatingTokenStorage def __init__( self, app_name: str = "Unnamed Globus App", *, login_client: AuthLoginClient | None = None, client_id: uuid.UUID | str | None = None, client_secret: str | None = None, scope_requirements: ( t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None ) = None, config: GlobusAppConfig = DEFAULT_CONFIG, ) -> None: self.app_name = app_name self.config = config self._token_validation_error_handling_enabled = True self._resources_to_close: list[Closable] = [] self.client_id, self._login_client = self._resolve_client_info( app_name=self.app_name, config=self.config, client_id=client_id, client_secret=client_secret, login_client=login_client, ) self._scope_requirements = self._resolve_scope_requirements(scope_requirements) # create the inner token storage object, and pick up on whether or not this # call handled creation or got a value from the outside self._token_storage, token_storage_created_here = self._resolve_token_storage( app_name=self.app_name, client_id=self.client_id, config=self.config, ) # create a consent client for token validation # this client won't be ready for immediate use, but will have the app attached # at the end of init consent_client = AuthClient(environment=config.environment) self._resources_to_close.append(consent_client) # create the requisite token storage for the app, with validation based on # the provided parameters # if the token storage was created by the app, the validating wrapper is added # to the resources to close when closed self.token_storage = self._initialize_validating_token_storage( token_storage=self._token_storage, consent_client=consent_client, scope_requirements=self._scope_requirements, ) if token_storage_created_here: self._resources_to_close.append(self.token_storage) # setup an ID Token Decoder based on config; build one if it was not provided self._id_token_decoder = self._initialize_id_token_decoder( app_name=self.app_name, config=self.config, login_client=self._login_client ) # initialize our authorizer factory self._initialize_authorizer_factory() self._authorizer_factory_initialized = True # finally, attach the app to the internal consent client # this needs to wait until the very end of the app initialization process so # that the authorizer factory is all ready to accept the client # registering its scope requirements # # additionally, this will ensure that openid scope requirement is always # registered (it's required for token identity validation). consent_client.attach_globus_app(self, app_scopes=[AuthScopes.openid]) def _resolve_scope_requirements( self, scope_requirements: ( t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None ), ) -> dict[str, list[Scope]]: if scope_requirements is None: return {} return { resource_server: list(self._iter_scopes(scopes)) for resource_server, scopes in scope_requirements.items() } def _resolve_client_info( self, app_name: str, config: GlobusAppConfig, login_client: AuthLoginClient | None, client_id: uuid.UUID | str | None, client_secret: str | None, ) -> tuple[uuid.UUID | str, AuthLoginClient]: """ Extracts a client_id and login_client from GlobusApp initialization parameters, validating that the parameters were provided correctly. Depending on which parameters were provided, this method will either: 1. Create a new login client from the supplied credentials. * The actual initialization is performed by the subclass using the abstract method: _initialize_login_client``. 2. Extract the client_id from a supplied login_client. If a new client is created here, it is also added to the set of resources to close when this app is closed. :returns: tuple of client_id and login_client :raises: GlobusSDKUsageError if a single client ID or login client could not be definitively resolved. """ if login_client and client_id: msg = "Mutually exclusive parameters: client_id and login_client." raise GlobusSDKUsageError(msg) if login_client: # User provided an explicit login client, extract the client_id. if login_client.client_id is None: msg = "A GlobusApp login_client must have a discoverable client_id." raise GlobusSDKUsageError(msg) if login_client.environment != config.environment: raise GlobusSDKUsageError( "[Environment Mismatch] The login_client's environment " f"({login_client.environment}) does not match the GlobusApp's " f"configured environment ({config.environment})." ) return login_client.client_id, login_client elif client_id: # User provided an explicit client_id, construct a login client login_client = self._initialize_login_client( app_name, config, client_id, client_secret ) self._resources_to_close.append(login_client) return client_id, login_client else: raise GlobusSDKUsageError( "Could not set up a globus login client. One of client_id or " "login_client is required." ) @abc.abstractmethod def _initialize_login_client( self, app_name: str, config: GlobusAppConfig, client_id: uuid.UUID | str, client_secret: str | None, ) -> AuthLoginClient: """ Initializes and returns an AuthLoginClient to be used in authorization requests. """ def _initialize_validating_token_storage( self, token_storage: TokenStorage, consent_client: AuthClient, scope_requirements: t.Mapping[str, t.Sequence[Scope]], ) -> ValidatingTokenStorage: """ Initializes the validating token storage for the app. """ validating_token_storage = ValidatingTokenStorage(token_storage) # construct ValidatingTokenStorage around the TokenStorage and # our initial scope requirements scope_validator = ScopeRequirementsValidator(scope_requirements, consent_client) # use validators to enforce invariants about scopes validating_token_storage.validators.append(scope_validator) return validating_token_storage def _resolve_token_storage( self, app_name: str, client_id: uuid.UUID | str, config: GlobusAppConfig ) -> tuple[TokenStorage, bool]: """ Resolve the raw token storage to be used by the app, and whether or not it was created explicitly here. The storage may be: 1. A TokenStorage instance provided by the user, which we use directly. 2. A TokenStorageProvider, which we use to get a TokenStorage. 3. A string value, which we map onto supported TokenStorage types. And in the case of (1), the bool is false. In the case of (2) and (3) it is true. :returns: TokenStorage instance to be used by the app, and whether or not this function created the storage. :raises: GlobusSDKUsageError if the provided token_storage value is unsupported. """ token_storage = config.token_storage # TODO - make namespace configurable namespace = "DEFAULT" if isinstance(token_storage, TokenStorage): return (token_storage, False) elif isinstance(token_storage, TokenStorageProvider): return ( token_storage.for_globus_app( app_name=app_name, config=config, client_id=client_id, namespace=namespace, ), True, ) elif token_storage in KNOWN_TOKEN_STORAGES: provider = KNOWN_TOKEN_STORAGES[token_storage] return ( provider.for_globus_app( app_name=app_name, config=config, client_id=client_id, namespace=namespace, ), True, ) raise GlobusSDKUsageError( f"Unsupported token_storage value: {token_storage}. Must be a " f"TokenStorage, TokenStorageProvider, or a supported string value." ) def _initialize_id_token_decoder( self, *, app_name: str, config: GlobusAppConfig, login_client: AuthLoginClient ) -> IDTokenDecoder: """ Create an IDTokenDecoder or use the one provided via config, and set it on the token storage adapters. It is only set on inner storage if the decoder was not already set, so a non-null value won't be overwritten. This must run near the end of app initialization, when the `_token_storage` (inner) and `token_storage` (validating storage, outer) storages have both been initialized. """ if isinstance(self.config.id_token_decoder, IDTokenDecoder): id_token_decoder: IDTokenDecoder = self.config.id_token_decoder else: id_token_decoder = self.config.id_token_decoder.for_globus_app( app_name=app_name, config=config, login_client=login_client, ) if self._token_storage.id_token_decoder is None: self._token_storage.id_token_decoder = id_token_decoder self.token_storage.id_token_decoder = id_token_decoder return id_token_decoder @abc.abstractmethod def _initialize_authorizer_factory(self) -> None: """ Initializes self._authorizer_factory to be used for generating authorizers to authorize requests. """ def login( self, *, auth_params: GlobusAuthorizationParameters | None = None, force: bool = False, ) -> None: """ Log a user or client into the app, if needed, storing the resulting tokens. A login flow will be performed if any of the following are true: * The kwarg ``auth_params`` is provided. * The kwarg ``force`` is set to True. * The method ``self.login_required()`` evaluates to True. :param auth_params: An optional set of authorization parameters to establish requirements and controls for the login flow. :param force: If True, perform a login flow even if one does not appear to be necessary. """ if auth_params or force or self.login_required(): self._run_login_flow(auth_params) def login_required(self) -> bool: """ Determine if a login flow will be required to interact with resource servers under the current scope requirements. This will return false if any of the following are true: * Access tokens have never been issued. * Access tokens have been issued but have insufficient scopes. * Access tokens have expired and wouldn't be resolved with refresh tokens. :returns: True if a login flow appears to be required, False otherwise. """ for resource_server in self._scope_requirements.keys(): try: with self._disabled_token_validation_error_handler(): self.get_authorizer(resource_server) except TokenValidationError: return True return False def logout(self) -> None: """ Log the current user or client out of the app. This will remove and revoke all tokens stored for the current app user. """ # Revoke all tokens, removing them from the underlying token storage inner_token_storage = self.token_storage.token_storage for resource_server in self._scope_requirements.keys(): token_data = inner_token_storage.get_token_data(resource_server) if token_data: self._login_client.oauth2_revoke_token(token_data.access_token) if token_data.refresh_token: self._login_client.oauth2_revoke_token(token_data.refresh_token) inner_token_storage.remove_token_data(resource_server) # Invalidate any cached authorizers self._authorizer_factory.clear_cache() def close(self) -> None: """ Close all resources currently held by the app. This does not trigger a logout. """ for resource in self._resources_to_close: log.debug( f"closing resource of type {type(resource).__name__} " f"for {type(self).__name__}(app_name={self.app_name!r})" ) resource.close() # apps can act as context managers, and such usage calls close() def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: self.close() @abc.abstractmethod def _run_login_flow( self, auth_params: GlobusAuthorizationParameters | None = None ) -> None: """ Run an authorization flow to get new tokens which are stored and available for the next authorizer gotten by get_authorizer. :param auth_params: An optional set of authorization parameters to establish requirements and controls for the login flow. """ def _auth_params_with_required_scopes( self, auth_params: GlobusAuthorizationParameters | None = None, ) -> GlobusAuthorizationParameters: """ Either make a new GlobusAuthorizationParameters with this app's required scopes or combine this app's required scopes with given auth_params. """ required_scopes = [] for scope_list in self._scope_requirements.values(): required_scopes.extend(scope_list) if not auth_params: auth_params = GlobusAuthorizationParameters() parsed_required_scopes = [] for s in auth_params.required_scopes or []: parsed_required_scopes.extend(ScopeParser.parse(s)) # merge scopes for deduplication to minimize url request length # this is useful even if there weren't any auth_param scope requirements # as the app's scope_requirements can have duplicates combined_scopes = ScopeParser.merge_scopes( required_scopes, parsed_required_scopes ) auth_params.required_scopes = [str(s) for s in combined_scopes] return auth_params @contextlib.contextmanager def _disabled_token_validation_error_handler(self) -> t.Iterator[None]: """ Context manager to disable token validation error handling (as a default) for the duration of the context. """ # Record the starting value so we can reset it after the context ends. initial_val = self._token_validation_error_handling_enabled self._token_validation_error_handling_enabled = False try: yield finally: self._token_validation_error_handling_enabled = initial_val def add_scope_requirements( self, scope_requirements: t.Mapping[str, str | Scope | t.Iterable[str | Scope]] ) -> None: """ Add given scope requirements to the app's scope requirements. Any duplicate requirements will be deduplicated with existing requirements. :param scope_requirements: a dict of Scopes indexed by resource server that will be added to this app's scope requirements """ for resource_server, scopes in scope_requirements.items(): curr = self._scope_requirements.setdefault(resource_server, []) curr.extend(self._iter_scopes(scopes)) self._authorizer_factory.clear_cache(*scope_requirements.keys()) def get_authorizer(self, resource_server: str) -> GlobusAuthorizer: """ Get a ``GlobusAuthorizer`` for a resource server. This method will be called by service clients while making HTTP requests. :param resource_server: The resource server for which the requested Authorizer should provide authorization headers. """ error_handling_enabled = self._token_validation_error_handling_enabled try: # Disable token validation error handling for nested calls. # This will ultimately ensure that the error handler is only called once # by the root `get_authorizer` invocation. with self._disabled_token_validation_error_handler(): return self._authorizer_factory.get_authorizer(resource_server) except TokenValidationError as e: if error_handling_enabled and self.config.token_validation_error_handler: # Dispatch to the configured error handler if one is set then retry. self.config.token_validation_error_handler(self, e) return self._authorizer_factory.get_authorizer(resource_server) raise e def get_client_retry_checks(self) -> t.Sequence[RetryCheck]: if not self.config.auto_redrive_gares: return [] else: return [_RedriveGlobusAppGARE(self)] @property def scope_requirements(self) -> dict[str, list[Scope]]: """ Access a copy of app's aggregate scope requirements. Modifying the returned dict will not affect the app's scope requirements. To add scope requirements, use ``GlobusApp.add_scope_requirements()``. .. note:: Users may observe that Globus Auth (``'auth.globus.org'``) is always present, and always maps to the ``openid`` scope, even when the user has not added this scope. This mapping is expected, as the ``openid`` scope is needed internally for the functionality provided by ``GlobusApp``. """ # Scopes are mutable objects so we return a deepcopy return copy.deepcopy(self._scope_requirements) def _iter_scopes( self, scopes: str | Scope | t.Iterable[str | Scope] ) -> t.Iterator[Scope]: """Normalize scopes in various formats to an iterator of Scope objects.""" if isinstance(scopes, str): yield from ScopeParser.parse(scopes) elif isinstance(scopes, Scope): yield scopes else: for item in scopes: if isinstance(item, str): yield from ScopeParser.parse(item) else: yield item @set_retry_check_flags(RetryCheckFlags.RUN_ONCE) class _RedriveGlobusAppGARE: """ A client.transport RetryCheck specific to GlobusApps. Upon receiving a GARE-parsable 403 response, this check will initiate a login, refresh the authorizer, and dispatch a retry. """ def __init__(self, app: GlobusApp) -> None: self.app = app def __call__(self, ctx: RetryContext) -> RetryCheckResult: if (resource_server := ctx.caller_info.resource_server) is None: return RetryCheckResult.no_decision elif (gare := self._load_response_gare(ctx.response)) is None: return RetryCheckResult.no_decision log.debug("Intercepted re-drivable GARE; initiating app login.") self.app.login(auth_params=gare.authorization_parameters) updated_authorizer = self.app.get_authorizer(resource_server) log.debug("Acquired updated authorizer after GARE login; retrying.") ctx.caller_info.authorizer = updated_authorizer return RetryCheckResult.do_retry @staticmethod def _load_response_gare(response: Response | None) -> GARE | None: """Return a parsed GARE from a 403 response or None if not possible.""" if response is None or response.status_code != 403: return None try: decoded_body = response.json() except JSONDecodeError: return None else: return to_gare(decoded_body) globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/authorizer_factory.py000066400000000000000000000255721513221403200303040ustar00rootroot00000000000000from __future__ import annotations import abc import time import typing as t import globus_sdk from globus_sdk.authorizers import ( AccessTokenAuthorizer, ClientCredentialsAuthorizer, GlobusAuthorizer, RefreshTokenAuthorizer, ) from globus_sdk.services.auth import OAuthTokenResponse from globus_sdk.token_storage import ValidatingTokenStorage from globus_sdk.token_storage.validating_token_storage import MissingTokenError GA = t.TypeVar("GA", bound=GlobusAuthorizer) class AuthorizerFactory( t.Generic[GA], metaclass=abc.ABCMeta, ): """ An ``AuthorizerFactory`` is an interface for getting some class of ``GlobusAuthorizer`` from a ``ValidatingTokenStorage`` that meets the authorization requirements used to initialize the ``ValidatingTokenStorage``. An ``AuthorizerFactory`` keeps a cache of authorizer objects that are reused until its ``store_token_response`` method is called. """ def __init__(self, token_storage: ValidatingTokenStorage) -> None: """ :param token_storage: The ``ValidatingTokenStorage`` used for defining and validating the set of authorization requirements that constructed authorizers will meet and accessing underlying token storage """ self.token_storage = token_storage self._authorizer_cache: dict[str, GA] = {} def store_token_response_and_clear_cache( self, token_res: OAuthTokenResponse ) -> None: """ Store a token response in the underlying ``ValidatingTokenStorage`` and clear the authorizer cache. This should not be called when a ``RenewingAuthorizer`` created by this factory gets new tokens for itself as there is no need to clear the cache. :param token_res: An ``OAuthTokenResponse`` containing token data to be stored in the underlying ``ValidatingTokenStorage``. """ self.token_storage.store_token_response(token_res) self.clear_cache() def clear_cache(self, *resource_servers: str) -> None: """ Clear the authorizer cache for the given resource servers. If no resource servers are given, clear the entire cache. :param resource_servers: The resource servers for which to clear the cache """ if not resource_servers: self._authorizer_cache = {} else: for resource_server in resource_servers: if resource_server in self._authorizer_cache: del self._authorizer_cache[resource_server] def get_authorizer(self, resource_server: str) -> GA: """ Either retrieve a cached authorizer for the given resource server or construct a new one if none is cached. :param resource_server: The resource server the authorizer will produce authentication for :raises: :exc:`MissingTokenError` if the underlying ``TokenStorage`` does not have any token data for the given resource server. :raises: :exc:`UnmetScopeRequirementsError` if the stored token data does not meet the scope requirements for the given resource server. :returns: A ``GlobusAuthorizer`` for the given resource server """ if resource_server in self._authorizer_cache: return self._authorizer_cache[resource_server] new_authorizer = self._make_authorizer(resource_server) self._authorizer_cache[resource_server] = new_authorizer return new_authorizer @abc.abstractmethod def _make_authorizer(self, resource_server: str) -> GA: """ Construct the ``GlobusAuthorizer`` class specific to this ``AuthorizerFactory`` :param resource_server: The resource server the authorizer will produce authentication for """ class AccessTokenAuthorizerFactory(AuthorizerFactory[AccessTokenAuthorizer]): """ An ``AuthorizerFactory`` that constructs ``AccessTokenAuthorizer``. """ def __init__(self, token_storage: ValidatingTokenStorage) -> None: super().__init__(token_storage) self._cached_authorizer_expiration: dict[str, int] = {} def store_token_response_and_clear_cache( self, token_res: OAuthTokenResponse ) -> None: super().store_token_response_and_clear_cache(token_res) self._cached_authorizer_expiration = {} def clear_cache(self, *resource_servers: str) -> None: if not resource_servers: self._cached_authorizer_expiration = {} else: for resource_server in resource_servers: if resource_server in self._cached_authorizer_expiration: del self._cached_authorizer_expiration[resource_server] super().clear_cache(*resource_servers) def get_authorizer(self, resource_server: str) -> AccessTokenAuthorizer: """ Either retrieve a cached authorizer for the given resource server or construct a new one if none is cached. :param resource_server: The resource server the authorizer will produce authentication for :raises: :exc:`MissingTokenError` if the underlying ``TokenStorage`` does not have any token data for the given resource server. :raises: :exc:`UnmetScopeRequirementsError` if the stored token data does not meet the scope requirements for the given resource server. :raises: :exc:`ExpiredTokenError` if the stored access token for the given resource server has expired. :returns: An ``AccessTokenAuthorizer`` for the given resource server """ if resource_server in self._cached_authorizer_expiration: if self._cached_authorizer_expiration[resource_server] < time.time(): del self._cached_authorizer_expiration[resource_server] del self._authorizer_cache[resource_server] return super().get_authorizer(resource_server) def _make_authorizer(self, resource_server: str) -> AccessTokenAuthorizer: """ Construct an ``AccessTokenAuthorizer`` for the given resource server. :param resource_server: The resource server the authorizer will produce authentication for :raises: :exc:`ExpiredTokenError` if the stored access token for the given resource server has expired """ token_data = self.token_storage.get_token_data(resource_server) return AccessTokenAuthorizer(token_data.access_token) class RefreshTokenAuthorizerFactory(AuthorizerFactory[RefreshTokenAuthorizer]): """ An ``AuthorizerFactory`` that constructs ``RefreshTokenAuthorizer``. """ def __init__( self, token_storage: ValidatingTokenStorage, auth_login_client: globus_sdk.AuthLoginClient, ) -> None: """ :param token_storage: The ``ValidatingTokenStorage`` used for defining and validating the set of authorization requirements that constructed authorizers will meet and accessing underlying token storage :auth_login_client: The ``AuthLoginCLient` used for refreshing tokens with Globus Auth """ super().__init__(token_storage) self.auth_login_client = auth_login_client def _make_authorizer(self, resource_server: str) -> RefreshTokenAuthorizer: """ Construct a ``RefreshTokenAuthorizer`` for the given resource server. :param resource_server: The resource server the authorizer will produce authentication for :raises: :exc:`MissingTokenError` if the stored token data for the given resource server does not have a refresh token """ token_data = self.token_storage.get_token_data(resource_server) # this condition should be unreachable -- `get_token_data` will invoke the # `refresh_token` validator # the only way to reach it would be to manipulate the validators such that # the check is removed if token_data.refresh_token is None: # pragma: no cover raise ValueError("token data missing required field refresh_token") return RefreshTokenAuthorizer( refresh_token=token_data.refresh_token, auth_client=self.auth_login_client, access_token=token_data.access_token, expires_at=token_data.expires_at_seconds, on_refresh=self.token_storage.store_token_response, ) class ClientCredentialsAuthorizerFactory( AuthorizerFactory[ClientCredentialsAuthorizer] ): """ An ``AuthorizerFactory`` that constructs ``ClientCredentialsAuthorizer``. ClientCredentialAuthorizers are a special flavor of RenewingAuthorizers which use the client credentials grant type and a refresh token to keep up-to-date access tokens for a resource server. """ def __init__( self, token_storage: ValidatingTokenStorage, confidential_client: globus_sdk.ConfidentialAppAuthClient, scope_requirements: dict[str, list[globus_sdk.Scope]], ) -> None: """ :param token_storage: The ``ValidatingTokenStorage`` used for defining and validating the set of authorization requirements that constructed authorizers will meet and accessing underlying token storage :param confidential_client: The ``ConfidentialAppAuthClient`` that will get client credentials tokens from Globus Auth to act as itself """ self.confidential_client = confidential_client self.scope_requirements = scope_requirements super().__init__(token_storage) def _make_authorizer( self, resource_server: str, ) -> ClientCredentialsAuthorizer: """ Construct a ``ClientCredentialsAuthorizer`` for the given resource server. Does not require that tokens exist in the token storage but will use them if present. :param resource_server: The resource server the authorizer will produce authentication for. The ``ValidatingTokenStorage`` used to create the ``ClientCredentialsAuthorizerFactory`` must have scope requirements defined for this resource server. """ scopes = self.scope_requirements.get(resource_server) if scopes is None: raise ValueError( "ValidatingTokenStorage has no scope_requirements for " f"resource_server {resource_server}" ) try: token_data = self.token_storage.get_token_data(resource_server) access_token = token_data.access_token expires_at = token_data.expires_at_seconds except MissingTokenError: access_token, expires_at = None, None return ClientCredentialsAuthorizer( confidential_client=self.confidential_client, scopes=scopes, access_token=access_token, expires_at=expires_at, on_refresh=self.token_storage.store_token_response, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/client_app.py000066400000000000000000000116401513221403200264660ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk import AuthLoginClient, ConfidentialAppAuthClient, GlobusSDKUsageError from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.scopes import Scope from .app import GlobusApp from .authorizer_factory import ClientCredentialsAuthorizerFactory from .config import DEFAULT_CONFIG, GlobusAppConfig class ClientApp(GlobusApp): """ A ``GlobusApp`` for managing authentication state of a service account for use in service clients. A ``ClientApp`` requires the use of a confidential client created in a `Globus Project `. Client info may be passed either with the **client_id** and **client_secret** parameters or as a full **login_client**. ``ClientApps`` are configured by supplying a :class:`GlobusAppConfig` object to the **config** parameter. Of note however, **login_flow_manager** must not be set; a ``ClientApp`` does not use a login flow manager. See :class:`GlobusApp` for method signatures. .. rubric:: Example Usage: .. code-block:: python app = ClientApp("myapp", client_id=CLIENT_ID, client_secret=CLIENT_SECRET) transfer_client = TransferClient(app=app) res = transfer_client.endpoint_search("Tutorial Collection") :param app_name: A human-readable string to identify this app. :param login_client: A login client bound to a specific native client id or confidential client id/secret. Mutually exclusive with **client_id** and **client_secret**. :param client_id: A confidential client ID. Mutually exclusive with **login_client**. :param client_secret: A confidential client secret. Mutually exclusive with **login_client**. :param scope_requirements: A mapping of resource server to initial scope requirements. :param config: A data class containing configuration parameters for the app. """ _login_client: ConfidentialAppAuthClient _authorizer_factory: ClientCredentialsAuthorizerFactory # type:ignore def __init__( self, app_name: str = "Unnamed Globus App", *, login_client: ConfidentialAppAuthClient | None = None, client_id: uuid.UUID | str | None = None, client_secret: str | None = None, scope_requirements: ( t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None ) = None, config: GlobusAppConfig = DEFAULT_CONFIG, ) -> None: if config.login_flow_manager is not None: raise GlobusSDKUsageError("A ClientApp cannot use a login_flow_manager") if login_client and not isinstance(login_client, ConfidentialAppAuthClient): raise GlobusSDKUsageError( "A ClientApp must use a ConfidentialAppAuthClient for its login_client" ) super().__init__( app_name, login_client=login_client, client_id=client_id, client_secret=client_secret, scope_requirements=scope_requirements, config=config, ) def _initialize_login_client( self, app_name: str, config: GlobusAppConfig, client_id: uuid.UUID | str, client_secret: str | None, ) -> AuthLoginClient: if not client_secret: raise GlobusSDKUsageError( "A ClientApp requires a client_secret to initialize its own login " "client." ) return ConfidentialAppAuthClient( client_id=client_id, client_secret=client_secret, app_name=app_name, environment=config.environment, ) def _initialize_authorizer_factory(self) -> None: self._authorizer_factory = ClientCredentialsAuthorizerFactory( token_storage=self.token_storage, confidential_client=self._login_client, scope_requirements=self._scope_requirements, ) def _run_login_flow( self, auth_params: GlobusAuthorizationParameters | None = None ) -> None: """ Run an authorization flow to get new tokens which are stored and available for the next authorizer gotten by get_authorizer. As a ClientApp this is just a client credentials call. :param auth_params: A GlobusAuthorizationParameters to control authentication only the required_scopes parameter is used. """ auth_params = self._auth_params_with_required_scopes(auth_params) if not auth_params.required_scopes: raise GlobusSDKUsageError( "A ClientApp cannot get tokens without configured required scopes." ) token_response = self._login_client.oauth2_client_credentials_tokens( requested_scopes=auth_params.required_scopes ) self._authorizer_factory.store_token_response_and_clear_cache(token_response) globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/config.py000066400000000000000000000124471513221403200256230ustar00rootroot00000000000000from __future__ import annotations import dataclasses import typing as t import globus_sdk from globus_sdk.config import get_environment_name from globus_sdk.login_flows import ( CommandLineLoginFlowManager, LocalServerLoginFlowManager, LoginFlowManager, ) from globus_sdk.token_storage import ( JSONTokenStorage, MemoryTokenStorage, SQLiteTokenStorage, TokenStorage, TokenValidationError, ) from globus_sdk.token_storage.validating_token_storage import IdentityMismatchError from .protocols import ( IDTokenDecoderProvider, LoginFlowManagerProvider, TokenStorageProvider, TokenValidationErrorHandler, ) if t.TYPE_CHECKING: from .app import GlobusApp KnownLoginFlowManager = t.Literal["command-line", "local-server"] KNOWN_LOGIN_FLOW_MANAGERS: dict[KnownLoginFlowManager, LoginFlowManagerProvider] = { "command-line": CommandLineLoginFlowManager, "local-server": LocalServerLoginFlowManager, } KnownTokenStorage = t.Literal["json", "sqlite", "memory"] KNOWN_TOKEN_STORAGES: dict[KnownTokenStorage, t.Type[TokenStorageProvider]] = { "json": JSONTokenStorage, "sqlite": SQLiteTokenStorage, "memory": MemoryTokenStorage, } def resolve_by_login_flow(app: GlobusApp, error: TokenValidationError) -> None: """ An error handler for GlobusApp token access errors that will retry the login flow if the error is a TokenValidationError. :param app: The GlobusApp instance which encountered an error. :param error: The encountered token validation error. """ if isinstance(error, IdentityMismatchError): # An identity mismatch error indicates incorrect use of the app. Not something # that can be resolved by running a login flow. raise error app.login(force=True) @dataclasses.dataclass(frozen=True) class GlobusAppConfig: """ An immutable dataclass used to control the behavior of a :class:`GlobusApp`. :ivar bool request_refresh_tokens: Whether to request ``refresh tokens`` (expire after 6 months of no use) or use exclusively ``access tokens`` (expire 2 hours after issuance). Default: ``False``. :ivar bool auto_redrive_gares: If true, Globus Authorization Required Errors (GAREs) encountered during service interaction will automatically trigger a login flow to obtain new tokens and retry the failed request. Default: ``False``. :ivar str | ``TokenStorage`` | ``TokenStorageProvider`` token_storage: A class responsible for storing and retrieving tokens. This may be either a well-known provider (one of :class:`"json" `, :class:`"sqlite" `, or :class:`"memory" `) or a custom storage/provider. Default: ``"json"``. :ivar str | ``LoginFlowManager`` | ``LoginFlowManagerProvider`` login_flow_manager: A class responsible for overseeing Globus Auth login flows. This may be either be a well-known provider (one of :class:`"command-line" ` or :class:`"local-server" `) or a custom manager/provider. Default: ``"command-line"``. .. note:: **login_flow_manager** may be ignored when using a :class:`ClientApp`. :ivar str | None login_redirect_uri: The destination for Globus Auth to send a user after once completed a login flow. Default: ``None``. .. note:: **login_redirect_url** may be ignored when using a :class:`NativeAppAuthClient`. Explicit values must be pre-registered on your client `here `_. :ivar ``TokenValidationErrorHandler`` | None token_validation_error_handler: A handler invoked to resolve errors raised during token validation. Set this to ``None`` to disable auto-login on service token validation errors. Default: ``resolve_by_login_flow`` (runs a login flow, storing the resulting tokens). :ivar ``IDTokenDecoder`` | ``IDTokenDecoderProvider`` id_token_decoder: An ID token decoder or a provider which produces a decoder. The decoder is used when decoding ``id_token`` JWTs from Globus Auth. Defaults to ``IDTokenDecoder``. :ivar str environment: The Globus environment of services to interact with. This is mostly used for testing purposes. This may additionally be set with the environment variable `GLOBUS_SDK_ENVIRONMENT`. Default: ``"production"``. """ login_flow_manager: ( KnownLoginFlowManager | LoginFlowManagerProvider | LoginFlowManager | None ) = None login_redirect_uri: str | None = None token_storage: KnownTokenStorage | TokenStorageProvider | TokenStorage = "json" request_refresh_tokens: bool = False auto_redrive_gares: bool = False token_validation_error_handler: TokenValidationErrorHandler | None = ( resolve_by_login_flow ) id_token_decoder: globus_sdk.IDTokenDecoder | IDTokenDecoderProvider = ( globus_sdk.IDTokenDecoder ) environment: str = dataclasses.field(default_factory=get_environment_name) DEFAULT_CONFIG: GlobusAppConfig = GlobusAppConfig() globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/protocols.py000066400000000000000000000060721513221403200263770ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid if t.TYPE_CHECKING: from globus_sdk import AuthLoginClient, IDTokenDecoder from globus_sdk.login_flows import LoginFlowManager from globus_sdk.token_storage import TokenStorage, TokenValidationError from .app import GlobusApp from .config import GlobusAppConfig @t.runtime_checkable class TokenStorageProvider(t.Protocol): r""" A protocol for a factory which can create ``TokenStorage``\s. SDK-provided :ref:`token_storages` support this protocol. """ @classmethod def for_globus_app( cls, *, app_name: str, config: GlobusAppConfig, client_id: uuid.UUID | str, namespace: str, ) -> TokenStorage: """ Create a ``TokenStorage`` for use in a GlobusApp. :param app_name: The name supplied to the GlobusApp. :param config: The configuration supplied to the GlobusApp. :param client_id: The client_id of to the GlobusApp. :param namespace: A namespace to use for instantiating a TokenStorage. """ @t.runtime_checkable class LoginFlowManagerProvider(t.Protocol): r""" A protocol for a factory which can create ``LoginFlowManager``\s. SDK-provided :ref:`login_flow_managers` support this protocol. """ @classmethod def for_globus_app( cls, *, app_name: str, config: GlobusAppConfig, login_client: AuthLoginClient ) -> LoginFlowManager: """ Create a ``CommandLineLoginFlowManager`` for use in a GlobusApp. :param app_name: The name supplied to the GlobusApp. :param config: The configuration supplied to the GlobusApp. :param login_client: A login client to use for instantiating a LoginFlowManager. """ @t.runtime_checkable class IDTokenDecoderProvider(t.Protocol): r""" A protocol for a factory which can create ``IDTokenDecoder``\s. The SDK-provided ``IDTokenDecoder`` class supports this protocol. """ @classmethod def for_globus_app( cls, *, app_name: str, config: GlobusAppConfig, login_client: AuthLoginClient, ) -> IDTokenDecoder: """ Create an ``IDTokenDecoder`` for use in a GlobusApp. :param app_name: The name supplied to the GlobusApp. :param config: The configuration supplied to the GlobusApp. :param login_client: A login client to use for instantiating an ``IDTokenDecoder``. """ class TokenValidationErrorHandler(t.Protocol): """ A handler invoked when a :class:`TokenValidationError` is raised during a service client call. If this call completes without raising an exception, the service client call will retry once more (subsequent errors will not call this handler). """ def __call__(self, app: GlobusApp, error: TokenValidationError) -> None: """ Resolve a token validation error. :param app: The GlobusApp instance which encountered an error. :param error: The error which was encountered. """ globus-globus-sdk-python-6a080e4/src/globus_sdk/globus_app/user_app.py000066400000000000000000000157721513221403200262000ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk import ( AuthClient, AuthLoginClient, ConfidentialAppAuthClient, GlobusSDKUsageError, NativeAppAuthClient, Scope, ) from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows import CommandLineLoginFlowManager, LoginFlowManager from globus_sdk.token_storage import ( HasRefreshTokensValidator, NotExpiredValidator, TokenStorage, UnchangingIdentityIDValidator, ValidatingTokenStorage, ) from .app import GlobusApp from .authorizer_factory import ( AccessTokenAuthorizerFactory, RefreshTokenAuthorizerFactory, ) from .config import DEFAULT_CONFIG, KNOWN_LOGIN_FLOW_MANAGERS, GlobusAppConfig from .protocols import LoginFlowManagerProvider class UserApp(GlobusApp): """ A ``GlobusApp`` for managing authentication state of a user for use in service clients. Typically, a ``UserApp`` will use a native client, requiring a **client_id** created in a `Globus Project `_. More advanced use cases however, may additionally supply a **client_secret** or full **login_client** with confidential client credentials. ``UserApps`` are configured by supplying a :class:`GlobusAppConfig` object to the **config** parameter. Of note, login flow behavior involves printing and prompting the user for input using std::in and std::out. This behavior can be customized with the **login_flow_manager** config attribute. See :class:`GlobusApp` for method signatures. .. rubric:: Example Usage: .. code-block:: python app = UserApp("myapp", client_id=NATIVE_CLIENT_ID) transfer_client = TransferClient(app=app) res = transfer_client.endpoint_search("Tutorial Collection") :param app_name: A human-readable string to identify this app. :param login_client: A login client bound to a specific native client id or confidential client id/secret. Mutually exclusive with **client_id** and **client_secret**. :param client_id: A native or confidential client ID. Mutually exclusive with **login_client**. :param client_secret: A confidential client secret. Mutually exclusive with **login_client**. :param scope_requirements: A mapping of resource server to initial scope requirements. :param config: A data class containing configuration parameters for the app. """ _login_client: NativeAppAuthClient | ConfidentialAppAuthClient _authorizer_factory: ( # type:ignore AccessTokenAuthorizerFactory | RefreshTokenAuthorizerFactory ) def __init__( self, app_name: str = "Unnamed Globus App", *, login_client: AuthLoginClient | None = None, client_id: uuid.UUID | str | None = None, client_secret: str | None = None, scope_requirements: ( t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None ) = None, config: GlobusAppConfig = DEFAULT_CONFIG, ) -> None: super().__init__( app_name, login_client=login_client, client_id=client_id, client_secret=client_secret, scope_requirements=scope_requirements, config=config, ) self._login_flow_manager = self._resolve_login_flow_manager( app_name=self.app_name, login_client=self._login_client, config=config, ) def _resolve_login_flow_manager( self, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig ) -> LoginFlowManager: login_flow_manager = config.login_flow_manager if isinstance(login_flow_manager, LoginFlowManager): return login_flow_manager elif isinstance(login_flow_manager, LoginFlowManagerProvider): provider = login_flow_manager elif login_flow_manager is None: provider = CommandLineLoginFlowManager elif login_flow_manager in KNOWN_LOGIN_FLOW_MANAGERS: provider = KNOWN_LOGIN_FLOW_MANAGERS[login_flow_manager] else: allowed_keys = ", ".join(repr(k) for k in KNOWN_LOGIN_FLOW_MANAGERS.keys()) raise GlobusSDKUsageError( f"Unsupported login_flow_manager value: {login_flow_manager!r}. " f"Expected {allowed_keys}, a , or a " f"." ) return provider.for_globus_app( app_name=app_name, config=config, login_client=login_client ) def _initialize_login_client( self, app_name: str, config: GlobusAppConfig, client_id: uuid.UUID | str, client_secret: str | None, ) -> AuthLoginClient: if client_secret: return ConfidentialAppAuthClient( app_name=app_name, client_id=client_id, client_secret=client_secret, environment=config.environment, ) else: return NativeAppAuthClient( app_name=app_name, client_id=client_id, environment=config.environment, ) def _initialize_validating_token_storage( self, token_storage: TokenStorage, consent_client: AuthClient, scope_requirements: t.Mapping[str, t.Sequence[Scope]], ) -> ValidatingTokenStorage: validating_token_storage = super()._initialize_validating_token_storage( token_storage, consent_client, scope_requirements ) validating_token_storage.validators.append(UnchangingIdentityIDValidator()) return validating_token_storage def _initialize_authorizer_factory(self) -> None: if self.config.request_refresh_tokens: self._authorizer_factory = RefreshTokenAuthorizerFactory( token_storage=self.token_storage, auth_login_client=self._login_client ) self.token_storage.validators.insert(0, HasRefreshTokensValidator()) else: self._authorizer_factory = AccessTokenAuthorizerFactory( token_storage=self.token_storage ) self.token_storage.validators.insert(0, NotExpiredValidator()) def _run_login_flow( self, auth_params: GlobusAuthorizationParameters | None = None ) -> None: """ Run an authorization flow to get new tokens which are stored and available for the next authorizer gotten by get_authorizer. As a UserApp this always involves an interactive login flow with the user driven by the app's LoginFlowManager. :param auth_params: A GlobusAuthorizationParameters to control how the user will authenticate. If not passed """ auth_params = self._auth_params_with_required_scopes(auth_params) token_response = self._login_flow_manager.run_login_flow(auth_params) self._authorizer_factory.store_token_response_and_clear_cache(token_response) globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/000077500000000000000000000000001513221403200246335ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/__init__.py000066400000000000000000000003651513221403200267500ustar00rootroot00000000000000from .personal import GlobusConnectPersonalOwnerInfo, LocalGlobusConnectPersonal from .server import LocalGlobusConnectServer __all__ = ( "GlobusConnectPersonalOwnerInfo", "LocalGlobusConnectPersonal", "LocalGlobusConnectServer", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/personal/000077500000000000000000000000001513221403200264565ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/personal/__init__.py000066400000000000000000000002771513221403200305750ustar00rootroot00000000000000from .endpoint import LocalGlobusConnectPersonal from .owner_info import GlobusConnectPersonalOwnerInfo __all__ = ( "LocalGlobusConnectPersonal", "GlobusConnectPersonalOwnerInfo", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/personal/endpoint.py000066400000000000000000000156211513221403200306550ustar00rootroot00000000000000from __future__ import annotations import os import typing as t from globus_sdk.exc import GlobusSDKUsageError from .owner_info import GlobusConnectPersonalOwnerInfo if t.TYPE_CHECKING: import globus_sdk def _on_windows() -> bool: """ Per python docs, this is a safe, reliable way of checking the platform. sys.platform offers more detail -- more than we want, in this case. """ return os.name == "nt" class LocalGlobusConnectPersonal: r""" A LocalGlobusConnectPersonal object represents the available SDK methods for inspecting and controlling a running Globus Connect Personal installation. These objects do *not* inherit from BaseClient and do not provide methods for interacting with any Globus Service APIs. :param config_dir: Path to a non-default configuration directory. On Linux, this is the same as the value passed to Globus Connect Personal's `-dir` flag (i.e. the default value is ``~/.globusonline``). """ def __init__(self, *, config_dir: str | None = None) -> None: self._config_dir = config_dir self._endpoint_id: str | None = None def _detect_config_dir(self) -> str: if _on_windows(): appdata = os.getenv("LOCALAPPDATA") if appdata is None: raise GlobusSDKUsageError( "LOCALAPPDATA not detected in Windows environment. " "Either ensure this variable is set or pass an explicit " "config_dir to LocalGlobusConnectPersonal" ) return os.path.join(appdata, "Globus Connect") return os.path.expanduser("~/.globusonline") @property def config_dir(self) -> str: """ The ``config_dir`` for this endpoint. If no directory was given during initialization, this will be computed based on the current platform and environment. """ if not self._config_dir: self._config_dir = self._detect_config_dir() return self._config_dir @property def _local_data_dir(self) -> str: return ( self.config_dir if _on_windows() else os.path.join(self.config_dir, "lta") ) @t.overload def get_owner_info( self, ) -> globus_sdk.GlobusConnectPersonalOwnerInfo | None: ... @t.overload def get_owner_info( self, auth_client: None ) -> globus_sdk.GlobusConnectPersonalOwnerInfo | None: ... @t.overload def get_owner_info( self, auth_client: globus_sdk.AuthClient ) -> dict[str, t.Any] | None: ... def get_owner_info( self, auth_client: globus_sdk.AuthClient | None = None ) -> None | globus_sdk.GlobusConnectPersonalOwnerInfo | dict[str, t.Any]: """ Look up the local GCP information, returning a :class:`GlobusConnectPersonalOwnerInfo` object. The result may have an ``id`` or ``username`` set (depending on the underlying data). If you pass an AuthClient, this method will return a dict from the Get Identities API instead of the info object. This can fail (e.g. with network errors if there is no connectivity), so passing this value should be coupled with additional error handling. In either case, the result may be ``None`` if the data is missing or cannot be parsed. .. note:: The data returned by this method is not checked for accuracy. It is possible for a user to modify the files used by GCP to list a different user. :param auth_client: An AuthClient to use to lookup the full identity information for the GCP owner **Examples** Getting a username: >>> from globus_sdk import LocalGlobusConnectPersonal >>> local_gcp = LocalGlobusConnectPersonal() >>> local_gcp.get_owner_info() GlobusConnectPersonalOwnerInfo(username='foo@globusid.org') or you may get back an ID: >>> local_gcp = LocalGlobusConnectPersonal() >>> local_gcp.get_owner_info() GlobusConnectPersonalOwnerInfo(id='7deda7cc-077b-11ec-a137-67523ecffd4b') Check the result easily by looking to see if these values are ``None``: >>> local_gcp = LocalGlobusConnectPersonal() >>> info = local_gcp.get_owner_info() >>> has_username = info.username is not None """ fname = os.path.join(self._local_data_dir, "gridmap") try: # read file data into an owner info object try: owner_info = GlobusConnectPersonalOwnerInfo._from_file(fname) except ValueError: # may ValueError on invalid DN data return None except OSError as e: # no such file or directory if e.errno == 2: return None raise if auth_client is None: return owner_info if owner_info.id is not None: res = auth_client.get_identities(ids=owner_info.id) elif owner_info.username is not None: res = auth_client.get_identities(usernames=owner_info.username) else: # pragma: no cover raise ValueError("Something went wrong. Could not parse owner info.") try: # could get no data back in theory, if the identity isn't visible return t.cast(t.Dict[str, t.Any], res["identities"][0]) except (KeyError, IndexError): return None @property def endpoint_id(self) -> str | None: """ The endpoint ID of the local Globus Connect Personal endpoint installation. This value is loaded whenever it is first accessed, but saved after that. .. note:: This attribute is not checked for accuracy. It is possible for a user to modify the files used by GCP to list a different ``endpoint_id``. Usage: >>> from globus_sdk import TransferClient, LocalGlobusConnectPersonal >>> local_ep = LocalGlobusConnectPersonal() >>> ep_id = local_ep.endpoint_id >>> tc = TransferClient(...) # needs auth details >>> for f in tc.operation_ls(ep_id): >>> print("Local file: ", f["name"]) You can also reset the value, causing it to load again on next access, with ``del local_ep.endpoint_id`` """ if self._endpoint_id is None: fname = os.path.join(self._local_data_dir, "client-id.txt") try: with open(fname, encoding="utf-8") as fp: self._endpoint_id = fp.read().strip() except OSError as e: # no such file or directory gets ignored, everything else reraise if e.errno != 2: raise return self._endpoint_id @endpoint_id.deleter def endpoint_id(self) -> None: """ Deleter for LocalGlobusConnectPersonal.endpoint_id """ self._endpoint_id = None globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/personal/owner_info.py000066400000000000000000000057461513221403200312110ustar00rootroot00000000000000from __future__ import annotations import base64 import shlex import uuid class _B32DecodeError(ValueError): """custom exception type""" def _b32decode(v: str) -> str: # should start with "u_" if not v.startswith("u_"): raise _B32DecodeError("should start with 'u_'") v = v[2:] # wrong length if len(v) != 26: raise _B32DecodeError("wrong length") # append padding and uppercase so that b32decode will work v = v.upper() + (6 * "=") # try to decode try: return str(uuid.UUID(bytes=base64.b32decode(v))) # if it fails, then it can't be a b32-encoded identity except ValueError as err: raise _B32DecodeError("decode and load as UUID failed") from err def _parse_dn_username(s: str) -> tuple[str, bool]: try: user, is_id = _b32decode(s), True except _B32DecodeError: user, is_id = f"{s}@globusid.org", False return (user, is_id) class GlobusConnectPersonalOwnerInfo: """ Information about the owner of the local Globus Connect Personal endpoint. Users should never create these objects directly, but instead rely upon :meth:`LocalGlobusConnectPersonal.get_owner_info()`. The info object contains ether ``id`` or ``username``. Parsing an info object from local data cannot guarantee that the ``id`` or ``username`` value will be found. Whichever one is present will be set and the other attribute will be ``None``. :ivar id: The Globus Auth ID of the endpoint owner :vartype id: str or None :ivar username: The Globus Auth Username of the endpoint owner :vartype username: str or None :param config_dn: A DN value from GCP configuration, which will be parsed into username or ID """ _GRIDMAP_DN_START = '"/C=US/O=Globus Consortium/OU=Globus Connect User/CN=' username: str | None id: str | None def __init__(self, *, config_dn: str) -> None: lineinfo = shlex.split(config_dn) if len(lineinfo) != 2: raise ValueError("Malformed DN: not right length") dn, _local_username = lineinfo username_or_id = dn.split("=", 4)[-1] user, is_id = _parse_dn_username(username_or_id) if is_id: self.username = None self.id = user else: self.username = user self.id = None def __str__(self) -> str: return ( "GlobusConnectPersonalOwnerInfo(" + ( f"username={self.username}" if self.username is not None else f"id={self.id}" ) + ")" ) # private methods for SDK usage only @classmethod def _from_file(cls, filename: str) -> GlobusConnectPersonalOwnerInfo: with open(filename, encoding="utf-8") as fp: for line in fp: if line.startswith(cls._GRIDMAP_DN_START): return cls(config_dn=line.strip()) raise ValueError("Could not find GCP DN in data stream") globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/server/000077500000000000000000000000001513221403200261415ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/server/__init__.py000066400000000000000000000001301513221403200302440ustar00rootroot00000000000000from .endpoint import LocalGlobusConnectServer __all__ = ("LocalGlobusConnectServer",) globus-globus-sdk-python-6a080e4/src/globus_sdk/local_endpoint/server/endpoint.py000066400000000000000000000052051513221403200303350ustar00rootroot00000000000000from __future__ import annotations import json import pathlib import typing as t class LocalGlobusConnectServer: r""" A LocalGlobusConnectServer object represents the available SDK methods for inspecting and controlling a running Globus Connect Server installation. These objects do *not* inherit from BaseClient and do not provide methods for interacting with any Globus Service APIs. :param info_path: The path to the info file used to inspect the local system """ def __init__( self, *, info_path: str | pathlib.Path = "/var/lib/globus-connect-server/info.json", ) -> None: self.info_path = pathlib.Path(info_path) self._loaded_info: dict[str, t.Any] | None = None @property def info_dict(self) -> dict[str, t.Any] | None: """ The info.json data for the local Globus Connect Server, as a dict. If the info.json file is not present or cannot be parsed, the data is None. This indicates that there is no local Globus Connect Server node, or if there is one, it cannot be used or examined by the SDK. For example, containerized applications using the SDK may not be able to interact with Globus Connect Server on the container host. """ if self._loaded_info is None: if self.info_path.is_file(): with open(self.info_path, encoding="utf-8") as fp: try: parsed_data = json.load(fp) except (UnicodeDecodeError, json.JSONDecodeError): pass else: if isinstance(parsed_data, dict): self._loaded_info = t.cast(t.Dict[str, t.Any], parsed_data) return self._loaded_info @info_dict.deleter def info_dict(self) -> None: self._loaded_info = None @property def endpoint_id(self) -> str | None: """ The endpoint ID of the local Globus Connect Server endpoint. None if the data cannot be loaded or is malformed. """ if self.info_dict is not None: epid = self.info_dict.get("endpoint_id") if isinstance(epid, str): return epid return None @property def domain_name(self) -> str | None: """ The domain name of the local Globus Connect Server endpoint. None if the data cannot be loaded or is malformed. """ if self.info_dict is not None: domain = self.info_dict.get("domain_name") if isinstance(domain, str): return domain return None globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/000077500000000000000000000000001513221403200241635ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/__init__.py000066400000000000000000000010221513221403200262670ustar00rootroot00000000000000from .command_line_login_flow_manager import ( CommandLineLoginFlowEOFError, CommandLineLoginFlowManager, ) from .local_server_login_flow_manager import ( LocalServerEnvironmentalLoginError, LocalServerLoginError, LocalServerLoginFlowManager, ) from .login_flow_manager import LoginFlowManager __all__ = ( "CommandLineLoginFlowManager", "CommandLineLoginFlowEOFError", "LocalServerLoginError", "LocalServerEnvironmentalLoginError", "LocalServerLoginFlowManager", "LoginFlowManager", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/command_line_login_flow_manager.py000066400000000000000000000126421513221403200331000ustar00rootroot00000000000000from __future__ import annotations import textwrap import typing as t from contextlib import contextmanager import globus_sdk from globus_sdk._internal.utils import get_nice_hostname from globus_sdk.exc.base import GlobusError from globus_sdk.gare import GlobusAuthorizationParameters from .login_flow_manager import LoginFlowManager if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusAppConfig class CommandLineLoginFlowEOFError(GlobusError, EOFError): """ An error raised when a CommandLineLoginFlowManager reads an EOF when prompting for a code. """ class CommandLineLoginFlowManager(LoginFlowManager): """ A login flow manager which drives authorization-code token grants through the command line. :param AuthLoginClient login_client: The client that will be making Globus Auth API calls required for a login flow. .. note:: If this client is a :class:`globus_sdk.ConfidentialAppAuthClient`, an explicit `redirect_uri` param is required. :param str redirect_uri: The redirect URI to use for the login flow. When the `login_client` is a native client, this defaults to a Globus-hosted URL. :param bool request_refresh_tokens: A signal of whether refresh tokens are expected to be requested, in addition to access tokens. :param str native_prefill_named_grant: A string to prefill in a Native App login flow. This value is only used if the `login_client` is a native client. """ def __init__( self, login_client: globus_sdk.AuthLoginClient, *, redirect_uri: str | None = None, request_refresh_tokens: bool = False, native_prefill_named_grant: str | None = None, ) -> None: super().__init__( login_client, request_refresh_tokens=request_refresh_tokens, native_prefill_named_grant=native_prefill_named_grant, ) if redirect_uri is None: # Confidential clients must always define their own custom redirect URI. if isinstance(login_client, globus_sdk.ConfidentialAppAuthClient): msg = "Use of a Confidential client requires an explicit redirect_uri." raise globus_sdk.GlobusSDKUsageError(msg) # Native clients may infer the globus-provided helper page if omitted. redirect_uri = login_client.base_url + "v2/web/auth-code" self.redirect_uri = redirect_uri @classmethod def for_globus_app( cls, app_name: str, login_client: globus_sdk.AuthLoginClient, config: GlobusAppConfig, ) -> CommandLineLoginFlowManager: """ Create a ``CommandLineLoginFlowManager`` for use in a GlobusApp. :param app_name: The name of the app. Will be prefilled in native auth flows. :param login_client: A client used to make Globus Auth API calls. :param config: A GlobusApp-bounded object used to configure login flow manager. :raises GlobusSDKUsageError: if login_redirect_uri is not set on the config but a ConfidentialAppAuthClient is supplied. """ hostname = get_nice_hostname() if hostname: prefill = f"{app_name} on {hostname}" else: prefill = app_name return cls( login_client, redirect_uri=config.login_redirect_uri, request_refresh_tokens=config.request_refresh_tokens, native_prefill_named_grant=prefill, ) def run_login_flow( self, auth_parameters: GlobusAuthorizationParameters, ) -> globus_sdk.OAuthTokenResponse: """ Run an interactive login flow on the command line to get tokens for the user. :param auth_parameters: ``GlobusAuthorizationParameters`` passed through to the authentication flow to control how the user will authenticate. """ authorize_url = self._get_authorize_url(auth_parameters, self.redirect_uri) self.print_authorize_url(authorize_url) auth_code = self.prompt_for_code() return self.login_client.oauth2_exchange_code_for_tokens(auth_code) def print_authorize_url(self, authorize_url: str) -> None: """ Prompt the user to authenticate using the provided ``authorize_url``. :param authorize_url: The URL at which the user will login and consent to application accesses. """ login_prompt = "Please authenticate with Globus here:" print( textwrap.dedent( f""" {login_prompt} {"-" * len(login_prompt)} {authorize_url} {"-" * len(login_prompt)} """ ) ) def prompt_for_code(self) -> str: """ Prompt the user to enter an authorization code. :returns: The authorization code entered by the user. """ code_prompt = "Enter the resulting Authorization Code here: " with self._handle_input_errors(): return input(code_prompt).strip() @contextmanager def _handle_input_errors(self) -> t.Iterator[None]: try: yield except EOFError as e: msg = ( "An EOF was read when an authorization code was expected." " (Are you running this in an interactive terminal?)" ) raise CommandLineLoginFlowEOFError(msg) from e globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_manager/000077500000000000000000000000001513221403200325545ustar00rootroot00000000000000__init__.py000066400000000000000000000004201513221403200346020ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_managerfrom .errors import LocalServerEnvironmentalLoginError, LocalServerLoginError from .local_server_login_flow_manager import LocalServerLoginFlowManager __all__ = [ "LocalServerLoginError", "LocalServerEnvironmentalLoginError", "LocalServerLoginFlowManager", ] errors.py000066400000000000000000000005361513221403200343670ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_managerclass LocalServerLoginError(Exception): """An error raised during a LocalServerLoginFlowManager's run.""" class LocalServerEnvironmentalLoginError(LocalServerLoginError): """ Error raised when a local server login flow fails to start due to incompatible environment conditions (e.g., a remote session or text-only browser). """ html_files/000077500000000000000000000000001513221403200346235ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_manager__init__.py000066400000000000000000000000001513221403200367220ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_manager/html_fileslocal_server_landing_page.html000066400000000000000000000146001513221403200426620ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_manager/html_files Globus Login

Globus Login Result

$login_result. You may close this tab.

$post_login_message

local_server.py000066400000000000000000000067621513221403200355420ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_manager""" Classes used by the LocalServerLoginFlowManager to automatically receive an auth code from a redirect after user authentication through a locally run web-server. These classes generally shouldn't need to be used directly. """ from __future__ import annotations import importlib.resources import os import queue import socket import sys import time import typing as t from datetime import timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from string import Template from urllib.parse import parse_qsl, urlparse from . import html_files from .errors import LocalServerLoginError _IS_WINDOWS = os.name == "nt" DEFAULT_HTML_TEMPLATE = Template( importlib.resources.files(html_files) .joinpath("local_server_landing_page.html") .read_text() ) class RedirectHandler(BaseHTTPRequestHandler): """ BaseHTTPRequestHandler to be used by RedirectHTTPServer. Displays the RedirectHTTPServer's html_template and parses auth_code out of the redirect url. """ server: RedirectHTTPServer def do_GET(self) -> None: self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() html_template = self.server.html_template query_params = dict(parse_qsl(urlparse(self.path).query)) code = query_params.get("code") if code: self.wfile.write( html_template.substitute( post_login_message="", login_result="Login successful" ).encode("utf-8") ) self.server.return_code(code) else: msg = query_params.get("error_description", query_params.get("error")) self.wfile.write( html_template.substitute( post_login_message=msg, login_result="Login failed" ).encode("utf-8") ) self.server.return_code(LocalServerLoginError(msg)) class RedirectHTTPServer(HTTPServer): """ An HTTPServer which accepts an HTML `Template` to be displayed to the user and uses a Queue to receive an auth_code from its RequestHandler. """ WAIT_TIMEOUT = timedelta(minutes=15) def __init__( self, server_address: tuple[str, int], handler_class: type[BaseHTTPRequestHandler], html_template: Template, ) -> None: super().__init__(server_address, handler_class) self.html_template = html_template self._auth_code_queue: queue.Queue[str | BaseException] = queue.Queue() def handle_error( self, request: socket.socket | tuple[bytes, socket.socket], client_address: t.Any, ) -> None: _, excval, _ = sys.exc_info() assert excval is not None self._auth_code_queue.put(excval) def return_code(self, code: str | BaseException) -> None: self._auth_code_queue.put_nowait(code) def wait_for_code(self) -> str | BaseException: # Windows needs special handling as blocking prevents ctrl-c interrupts if _IS_WINDOWS: deadline = time.time() + self.WAIT_TIMEOUT.total_seconds() while time.time() < deadline: try: return self._auth_code_queue.get() except queue.Empty: time.sleep(1) else: try: return self._auth_code_queue.get(block=True, timeout=3600) except queue.Empty: pass raise LocalServerLoginError("Login timed out. Please try again.") local_server_login_flow_manager.py000066400000000000000000000201071513221403200414400ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/local_server_login_flow_managerfrom __future__ import annotations import os import threading import typing as t import webbrowser from contextlib import contextmanager from string import Template import globus_sdk from globus_sdk._internal.utils import get_nice_hostname from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows.login_flow_manager import LoginFlowManager from .errors import LocalServerEnvironmentalLoginError, LocalServerLoginError from .local_server import DEFAULT_HTML_TEMPLATE, RedirectHandler, RedirectHTTPServer if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusAppConfig # a list of text-only browsers which are not allowed for use because they don't work # with Globus Auth login flows and seize control of the terminal # # see the webbrowser library for a list of names: # https://github.com/python/cpython/blob/69cdeeb93e0830004a495ed854022425b93b3f3e/Lib/webbrowser.py#L489-L502 BROWSER_DENY_LIST = ["lynx", "www-browser", "links", "elinks", "w3m"] def _check_remote_session() -> None: """ Try to check if this is being run during a remote session, if so raise LocalServerLoginFlowError """ if bool(os.environ.get("SSH_TTY", os.environ.get("SSH_CONNECTION"))): raise LocalServerEnvironmentalLoginError( "Cannot use LocalServerLoginFlowManager in a remote session" ) def _open_webbrowser(url: str) -> None: """ Get a default browser and open given url If browser is known to be text-only, or opening fails, raise LocalServerLoginFlowError """ try: browser = webbrowser.get() if hasattr(browser, "name"): browser_name = browser.name elif hasattr(browser, "_name"): # MacOSXOSAScript only supports a public name attribute in py311 and later. # https://github.com/python/cpython/issues/82828 browser_name = browser._name else: raise LocalServerEnvironmentalLoginError( "Unable to determine local browser name." ) if browser_name in BROWSER_DENY_LIST: raise LocalServerEnvironmentalLoginError( "Cannot use LocalServerLoginFlowManager with " f"text-only browser '{browser_name}'" ) if not browser.open(url, new=1): raise LocalServerEnvironmentalLoginError( f"Failed to open browser '{browser_name}'" ) except webbrowser.Error as exc: raise LocalServerEnvironmentalLoginError("Failed to open browser") from exc class LocalServerLoginFlowManager(LoginFlowManager): """ A login flow manager which uses a locally hosted server to drive authentication-code token grants. The local server is used as the authorization redirect URI, automatically receiving the auth code from Globus Auth after authentication/consent. :param AuthLoginClient login_client: The client that will be making Globus Auth API calls required for a login flow. :param bool request_refresh_tokens: A signal of whether refresh tokens are expected to be requested, in addition to access tokens. :param str native_prefill_named_grant: A string to prefill in a Native App login flow. This value is only used if the `login_client` is a native client. :param Template html_template: Optional HTML Template to be populated with the values login_result and post_login_message and displayed to the user. A simple default is supplied if not provided which informs the user that the login was successful and that they may close the browser window. :param tuple[str, int] server_address: Optional tuple of the form (host, port) to specify an address to run the local server at. Defaults to ("127.0.0.1", 0). """ def __init__( self, login_client: globus_sdk.AuthLoginClient, *, request_refresh_tokens: bool = False, native_prefill_named_grant: str | None = None, server_address: tuple[str, int] = ("localhost", 0), html_template: Template = DEFAULT_HTML_TEMPLATE, ) -> None: super().__init__( login_client, request_refresh_tokens=request_refresh_tokens, native_prefill_named_grant=native_prefill_named_grant, ) self.server_address = server_address self.html_template = html_template @classmethod def for_globus_app( cls, app_name: str, login_client: globus_sdk.AuthLoginClient, config: GlobusAppConfig, ) -> LocalServerLoginFlowManager: """ Create a ``LocalServerLoginFlowManager`` for use in a GlobusApp. :param app_name: The name of the app. Will be prefilled in native auth flows. :param login_client: A client used to make Globus Auth API calls. :param config: A GlobusApp-bounded object used to configure login flow manager. :raises GlobusSDKUsageError: if app config is incompatible with the manager. """ if config.login_redirect_uri: # A "local server" relies on the user being redirected back to the server # running on the local machine, so it can't use a custom redirect URI. msg = "Cannot define a custom redirect_uri for LocalServerLoginFlowManager." raise globus_sdk.GlobusSDKUsageError(msg) if not isinstance(login_client, globus_sdk.NativeAppAuthClient): # Globus Auth has special provisions for native clients which allow implicit # redirect url grant to localhost:. This is required for the # LocalServerLoginFlowManager to work and is not reproducible in # confidential clients. msg = "LocalServerLoginFlowManager is only supported for Native Apps." raise globus_sdk.GlobusSDKUsageError(msg) hostname = get_nice_hostname() if hostname: prefill = f"{app_name} on {hostname}" else: prefill = app_name return cls( login_client, request_refresh_tokens=config.request_refresh_tokens, native_prefill_named_grant=prefill, ) def run_login_flow( self, auth_parameters: GlobusAuthorizationParameters, ) -> globus_sdk.OAuthTokenResponse: """ Run an interactive login flow using a locally hosted server to get tokens for the user. :param auth_parameters: ``GlobusAuthorizationParameters`` passed through to the authentication flow to control how the user will authenticate. :raises LocalServerEnvironmentalLoginError: If the local server login flow cannot be run due to known failure conditions such as remote sessions or text-only browsers. :raises LocalServerLoginError: If the local server login flow fails for any reason. """ _check_remote_session() with self.background_local_server() as server: host, port = server.socket.getsockname() redirect_uri = f"http://{host}:{port}" # open authorize url in web-browser for user to authenticate authorize_url = self._get_authorize_url(auth_parameters, redirect_uri) _open_webbrowser(authorize_url) # get auth code from server auth_code = server.wait_for_code() if isinstance(auth_code, BaseException): msg = f"Authorization failed with unexpected error:\n{auth_code}." raise LocalServerLoginError(msg) # get and return tokens return self.login_client.oauth2_exchange_code_for_tokens(auth_code) @contextmanager def background_local_server(self) -> t.Iterator[RedirectHTTPServer]: """ Starts a RedirectHTTPServer in a thread as a context manager. """ server = RedirectHTTPServer( server_address=self.server_address, handler_class=RedirectHandler, html_template=self.html_template, ) thread = threading.Thread(target=server.serve_forever) thread.daemon = True thread.start() yield server server.shutdown() globus-globus-sdk-python-6a080e4/src/globus_sdk/login_flows/login_flow_manager.py000066400000000000000000000111151513221403200303650ustar00rootroot00000000000000from __future__ import annotations import abc import globus_sdk from globus_sdk._missing import none2missing from globus_sdk.gare import GlobusAuthorizationParameters class LoginFlowManager(metaclass=abc.ABCMeta): """ The abstract base class defining the interface for managing login flows. Implementing classes must supply a ``run_login_flow`` method. Utility functions starting an authorization-code grant flow and getting an authorization-code URL are provided on the class. :ivar AuthLoginClient login_client: A native or confidential login client to be used by the login flow manager. :ivar bool request_refresh_tokens: A signal of whether refresh tokens are expected to be requested, in addition to access tokens. :ivar str native_prefill_named_grant: A string to prefill in a Native App login flow. This value is only to be used if the `login_client` is a native client. """ def __init__( self, login_client: globus_sdk.AuthLoginClient, *, request_refresh_tokens: bool = False, native_prefill_named_grant: str | None = None, ) -> None: if not isinstance( login_client, (globus_sdk.NativeAppAuthClient, globus_sdk.ConfidentialAppAuthClient), ): raise globus_sdk.GlobusSDKUsageError( f"{type(self).__name__} requires a NativeAppAuthClient or " f"ConfidentialAppAuthClient, but got a {type(login_client).__name__}." ) self.login_client = login_client self.request_refresh_tokens = request_refresh_tokens self.native_prefill_named_grant = native_prefill_named_grant def _get_authorize_url( self, auth_parameters: GlobusAuthorizationParameters, redirect_uri: str ) -> str: """ Utility method to provide a simpler interface for subclasses to start an authorization flow and get an authorization URL. """ self._oauth2_start_flow(auth_parameters, redirect_uri) # prompt is assigned first because its usage is type-ignored below # this makes it clear that the ignore applies to that usage prompt = none2missing(auth_parameters.prompt) return self.login_client.oauth2_get_authorize_url( session_required_identities=none2missing( auth_parameters.session_required_identities ), session_required_single_domain=none2missing( auth_parameters.session_required_single_domain ), session_required_policies=none2missing( auth_parameters.session_required_policies ), session_required_mfa=none2missing(auth_parameters.session_required_mfa), session_message=none2missing(auth_parameters.session_message), prompt=prompt, # type: ignore[arg-type] ) def _oauth2_start_flow( self, auth_parameters: GlobusAuthorizationParameters, redirect_uri: str ) -> None: """ Start an authorization flow with the class's login_client, returning an authorization URL to direct a user to. """ login_client = self.login_client requested_scopes = auth_parameters.required_scopes if not requested_scopes: raise globus_sdk.GlobusSDKUsageError( f"{type(self).__name__} cannot start a login flow without scopes " "in the authorization parameters." ) # Native and Confidential App clients have different signatures for this method, # so they must be type checked & called independently. if isinstance(login_client, globus_sdk.NativeAppAuthClient): login_client.oauth2_start_flow( requested_scopes, redirect_uri=redirect_uri, refresh_tokens=self.request_refresh_tokens, prefill_named_grant=none2missing(self.native_prefill_named_grant), ) elif isinstance(login_client, globus_sdk.ConfidentialAppAuthClient): login_client.oauth2_start_flow( redirect_uri, requested_scopes, refresh_tokens=self.request_refresh_tokens, ) @abc.abstractmethod def run_login_flow( self, auth_parameters: GlobusAuthorizationParameters, ) -> globus_sdk.OAuthTokenResponse: """ Run a login flow to get tokens for a user. :param auth_parameters: ``GlobusAuthorizationParameters`` passed through to the authentication flow to control how the user will authenticate. """ globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/000077500000000000000000000000001513221403200231065ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/__init__.py000066400000000000000000000010161513221403200252150ustar00rootroot00000000000000from .base import Paginator, has_paginator from .last_key import LastKeyPaginator from .limit_offset import HasNextPaginator, LimitOffsetTotalPaginator from .marker import MarkerPaginator, NullableMarkerPaginator from .next_token import NextTokenPaginator from .table import PaginatorTable __all__ = ( "Paginator", "PaginatorTable", "has_paginator", "MarkerPaginator", "NullableMarkerPaginator", "NextTokenPaginator", "LastKeyPaginator", "HasNextPaginator", "LimitOffsetTotalPaginator", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/base.py000066400000000000000000000143101513221403200243710ustar00rootroot00000000000000from __future__ import annotations import abc import functools import inspect import sys import typing as t from globus_sdk.response import GlobusHTTPResponse if sys.version_info >= (3, 10): from typing import ParamSpec else: from typing_extensions import ParamSpec PageT = t.TypeVar("PageT", bound=GlobusHTTPResponse) P = ParamSpec("P") R = t.TypeVar("R", bound=GlobusHTTPResponse) C = t.TypeVar("C", bound=t.Callable[..., GlobusHTTPResponse]) # stub for mypy class _PaginatedFunc(t.Generic[PageT]): _has_paginator: bool _paginator_class: type[Paginator[PageT]] _paginator_items_key: str | None _paginator_params: dict[str, t.Any] class Paginator(t.Iterable[PageT], metaclass=abc.ABCMeta): """ Base class for all paginators. This guarantees is that they have generator methods named ``pages`` and ``items``. Iterating on a Paginator is equivalent to iterating on its ``pages``. :param method: A bound method of an SDK client, used to generate a paginated variant :param items_key: The key to use within pages of results to get an array of items :param client_args: Arguments to the underlying method which are passed when the paginator is instantiated. i.e. given ``client.paginated.foo(a, b, c=1)``, this will be ``(a, b)``. The paginator will pass these arguments to each call of the bound method as it pages. :param client_kwargs: Keyword arguments to the underlying method, like ``client_args`` above. ``client.paginated.foo(a, b, c=1)`` will pass this as ``{"c": 1}``. As with ``client_args``, it's passed to each paginated call. """ # the arguments which must be supported on the paginated method in order # for the paginator to pass them _REQUIRES_METHOD_KWARGS: tuple[str, ...] = () def __init__( self, method: t.Callable[..., t.Any], *, items_key: str | None = None, client_args: tuple[t.Any, ...], client_kwargs: dict[str, t.Any], # the Base paginator must accept arbitrary additional kwargs to indicate that # its child classes could define and use additional kwargs **kwargs: t.Any, ) -> None: self.method = method self.items_key = items_key self.client_args = client_args self.client_kwargs = client_kwargs def __iter__(self) -> t.Iterator[PageT]: yield from self.pages() @abc.abstractmethod def pages(self) -> t.Iterator[PageT]: """``pages()`` yields GlobusHTTPResponse objects, each one representing a page of results.""" def items(self) -> t.Iterator[t.Any]: """ ``items()`` of a paginator is a generator which yields each item in each page of results. ``items()`` may raise a ``ValueError`` if the paginator was constructed without identifying a key for use within each page of results. This may be the case for paginators whose pages are not primarily an array of data. """ if self.items_key is None: raise ValueError( "Cannot provide items() iteration on a paginator where 'items_key' " "is not set." ) for page in self.pages(): yield from page[self.items_key] @classmethod def wrap(cls, method: t.Callable[P, R]) -> t.Callable[P, Paginator[R]]: """ This is an alternate method for getting a paginator for a paginated method which correctly preserves the type signature of the paginated method. It should be used on instances of clients and only passed bound methods of those clients. For example, given usage >>> tc = TransferClient() >>> paginator = tc.paginated.endpoint_search(...) a well-typed paginator can be acquired with >>> tc = TransferClient() >>> paginated_call = Paginator.wrap(tc.endpoint_search) >>> paginator = paginated_call(...) Although the syntax is slightly more verbose, this allows `mypy` and other type checkers to more accurately infer the type of the paginator. :param method: The method to convert to a paginator """ if not inspect.ismethod(method): raise TypeError(f"Paginator.wrap can only be used on methods, not {method}") if not getattr(method, "_has_paginator", False): raise ValueError(f"'{method}' is not a paginated method") as_paginated = t.cast(_PaginatedFunc[PageT], method) paginator_class = as_paginated._paginator_class paginator_params = as_paginated._paginator_params paginator_items_key = as_paginated._paginator_items_key @functools.wraps(method) def paginated_method(*args: t.Any, **kwargs: t.Any) -> Paginator[PageT]: return paginator_class( method, client_args=tuple(args), client_kwargs=kwargs, items_key=paginator_items_key, **paginator_params, ) return t.cast(t.Callable[P, Paginator[R]], paginated_method) def has_paginator( paginator_class: type[Paginator[PageT]], items_key: str | None = None, **paginator_params: t.Any, ) -> t.Callable[[C], C]: """ Mark a callable -- typically a client method -- as having pagination parameters. Usage: >>> class MyClient(BaseClient): >>> @has_paginator(MarkerPaginator) >>> def foo(...): ... This will mark ``MyClient.foo`` as paginated with marker style pagination. It will then be possible to get a paginator for ``MyClient.foo`` via >>> c = MyClient(...) >>> paginator = c.paginated.foo() :param paginator_class: The type of paginator used by this method :param items_key: The key to use within pages of results to get an array of items :param paginator_params: Additional parameters to pass to the paginator constructor """ def decorate(func: C) -> C: as_paginated = t.cast(_PaginatedFunc[PageT], func) as_paginated._has_paginator = True as_paginated._paginator_class = paginator_class as_paginated._paginator_items_key = items_key as_paginated._paginator_params = paginator_params return func return decorate globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/last_key.py000066400000000000000000000020011513221403200252640ustar00rootroot00000000000000from __future__ import annotations import typing as t from .base import PageT, Paginator class LastKeyPaginator(Paginator[PageT]): _REQUIRES_METHOD_KWARGS = ("last_key",) def __init__( self, method: t.Callable[..., t.Any], *, items_key: str | None = None, client_args: tuple[t.Any, ...], client_kwargs: dict[str, t.Any], ) -> None: super().__init__( method, items_key=items_key, client_args=client_args, client_kwargs=client_kwargs, ) self.last_key: str | None = None def pages(self) -> t.Iterator[PageT]: has_next_page = True while has_next_page: if self.last_key: self.client_kwargs["last_key"] = self.last_key current_page = self.method(*self.client_args, **self.client_kwargs) yield current_page self.last_key = current_page.get("last_key") has_next_page = current_page["has_next_page"] globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/limit_offset.py000066400000000000000000000045111513221403200261450ustar00rootroot00000000000000from __future__ import annotations import typing as t from .base import PageT, Paginator class _LimitOffsetBasedPaginator(Paginator[PageT]): # pylint: disable=abstract-method _REQUIRES_METHOD_KWARGS = ("limit", "offset") def __init__( self, method: t.Callable[..., t.Any], *, items_key: str | None = None, get_page_size: t.Callable[[dict[str, t.Any]], int], max_total_results: int, page_size: int, client_args: tuple[t.Any, ...], client_kwargs: dict[str, t.Any], ) -> None: super().__init__( method, items_key=items_key, client_args=client_args, client_kwargs=client_kwargs, ) self.get_page_size = get_page_size self.max_total_results = max_total_results self.limit = page_size self.offset = 0 def _update_limit(self) -> None: if ( self.max_total_results is not None and self.offset + self.limit > self.max_total_results ): self.limit = self.max_total_results - self.offset self.client_kwargs["limit"] = self.limit def _update_and_check_offset(self, current_page: dict[str, t.Any]) -> bool: self.offset += self.get_page_size(current_page) self.client_kwargs["offset"] = self.offset return ( self.max_total_results is not None and self.offset >= self.max_total_results ) class HasNextPaginator(_LimitOffsetBasedPaginator[PageT]): def pages(self) -> t.Iterator[PageT]: has_next_page = True while has_next_page: self._update_limit() current_page = self.method(*self.client_args, **self.client_kwargs) yield current_page if self._update_and_check_offset(current_page): return has_next_page = current_page["has_next_page"] class LimitOffsetTotalPaginator(_LimitOffsetBasedPaginator[PageT]): def pages(self) -> t.Iterator[PageT]: has_next_page = True while has_next_page: self._update_limit() current_page = self.method(*self.client_args, **self.client_kwargs) yield current_page if self._update_and_check_offset(current_page): return has_next_page = self.offset < current_page["total"] globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/marker.py000066400000000000000000000035101513221403200247400ustar00rootroot00000000000000from __future__ import annotations import typing as t from .base import PageT, Paginator class MarkerPaginator(Paginator[PageT]): """ A paginator which uses `has_next_page` and `marker` from payloads, sets the `marker` query param to page. This is the default method for GCS pagination, so it's very simple. """ _REQUIRES_METHOD_KWARGS = ("marker",) def __init__( self, method: t.Callable[..., t.Any], *, items_key: str | None = None, marker_key: str = "marker", client_args: tuple[t.Any, ...], client_kwargs: dict[str, t.Any], ) -> None: super().__init__( method, items_key=items_key, client_args=client_args, client_kwargs=client_kwargs, ) self.marker: str | None = None self.marker_key = marker_key def _check_has_next_page(self, page: dict[str, t.Any]) -> bool: return bool(page.get("has_next_page", False)) def pages(self) -> t.Iterator[PageT]: has_next_page = True while has_next_page: if self.marker: self.client_kwargs["marker"] = self.marker current_page = self.method(*self.client_args, **self.client_kwargs) yield current_page self.marker = current_page.get(self.marker_key) has_next_page = self._check_has_next_page(current_page) class NullableMarkerPaginator(MarkerPaginator[PageT]): """ A paginator which uses a ``marker`` from payloads and sets the ``marker`` query param to page. Unlike the base MarkerPaginator, it checks for a null marker to indicate an end to pagination. (vs an explicit has_next_page key) """ def _check_has_next_page(self, page: dict[str, t.Any]) -> bool: return page.get(self.marker_key) is not None globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/next_token.py000066400000000000000000000023751513221403200256450ustar00rootroot00000000000000from __future__ import annotations import typing as t from .base import PageT, Paginator class NextTokenPaginator(Paginator[PageT]): """ A paginator which uses `next_token` from payloads to set the `next_token` query param to page. Very similar to GCS's marker paginator, but only used for Transfer's get_shared_endpoint_list """ _REQUIRES_METHOD_KWARGS = ("next_token",) def __init__( self, method: t.Callable[..., t.Any], *, items_key: str | None = None, client_args: tuple[t.Any, ...], client_kwargs: dict[str, t.Any], ) -> None: super().__init__( method, items_key=items_key, client_args=client_args, client_kwargs=client_kwargs, ) self.next_token: str | None = None def pages(self) -> t.Iterator[PageT]: has_next_page = True while has_next_page: if self.next_token: self.client_kwargs["next_token"] = self.next_token current_page = self.method(*self.client_args, **self.client_kwargs) yield current_page self.next_token = current_page.get("next_token") has_next_page = current_page.get("next_token") is not None globus-globus-sdk-python-6a080e4/src/globus_sdk/paging/table.py000066400000000000000000000054401513221403200245520ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk.response import GlobusHTTPResponse from .base import Paginator C = t.TypeVar("C", bound=t.Callable[..., GlobusHTTPResponse]) class PaginatorTable: """ A PaginatorTable maps multiple methods of an SDK client to paginated variants. Given a method, client.foo annotated with the `has_paginator` decorator, the table will gain a function attribute `foo` (name matching is automatic) which returns a Paginator. Clients automatically build and attach paginator tables under the ``paginated`` attribute. That is, if `client` has two methods `foo` and `bar` which are marked as paginated, that will let us call >>> client.paginated.foo() >>> client.paginated.bar() where ``client.paginated`` is a ``PaginatorTable``. Paginators are iterables of response pages, so ultimate usage is like so: >>> paginator = client.paginated.foo() # returns a paginator >>> for page in paginator: # a paginator is an iterable of pages (response objects) >>> print(json.dumps(page.data)) # you can handle each response object in turn A ``PaginatorTable`` is built automatically as part of client instantiation. Creation of ``PaginatorTable`` objects is considered a private API. """ def __init__(self, client: t.Any) -> None: self._client = client # _bindings is a lazily loaded table of names -> callables which # return paginators self._bindings: dict[str, t.Callable[..., Paginator[t.Any]]] = {} def _add_binding( self, methodname: str, bound_method: t.Callable[..., t.Any] ) -> None: self._bindings[methodname] = Paginator.wrap(bound_method) def __getattr__(self, attrname: str) -> t.Callable[..., Paginator[t.Any]]: if attrname not in self._bindings: # this could raise AttributeError -- in which case, let it! method = getattr(self._client, attrname) try: self._bindings[attrname] = Paginator.wrap(method) # ValueError is raised if the method being wrapped is not paginated except ValueError as e: raise AttributeError(f"'{attrname}' is not a paginated method") from e return self._bindings[attrname] # customize pickling methods to ensure that the object is pickle-safe def __getstate__(self) -> dict[str, t.Any]: # when pickling, drop any bound methods d = dict(self.__dict__) # copy d["_bindings"] = {} return d # custom __setstate__ to avoid an infinite loop on `getattr` before `_bindings` is # populated # see: https://docs.python.org/3/library/pickle.html#object.__setstate__ def __setstate__(self, d: dict[str, t.Any]) -> None: self.__dict__.update(d) globus-globus-sdk-python-6a080e4/src/globus_sdk/py.typed000066400000000000000000000000001513221403200233260ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/response.py000066400000000000000000000214071513221403200240550ustar00rootroot00000000000000from __future__ import annotations import collections.abc import json import logging import typing as t from globus_sdk._internal import guards log = logging.getLogger(__name__) if t.TYPE_CHECKING: from requests import Response import globus_sdk __all__ = ( "GlobusHTTPResponse", "IterableResponse", "ArrayResponse", ) class GlobusHTTPResponse: """ Response object that wraps an HTTP response from the underlying HTTP library. If the response is JSON, the parsed data will be available in ``data``, otherwise ``data`` will be ``None`` and ``text`` should be used instead. The most common response data is a JSON dictionary. To make handling this type of response as seamless as possible, the ``GlobusHTTPResponse`` object implements the immutable mapping protocol for dict-style access. This is just an alias for access to the underlying data. If the response data is not a dictionary or list, item access will raise ``TypeError``. >>> print("Response ID": r["id"]) # alias for r.data["id"] :ivar client: The client instance which made the request """ def __init__( self, response: Response | GlobusHTTPResponse, client: globus_sdk.BaseClient | None = None, ) -> None: # init on a GlobusHTTPResponse: we are wrapping this data # the _response is None if isinstance(response, GlobusHTTPResponse): if client is not None: raise ValueError("Redundant client with wrapped response") self._wrapped: GlobusHTTPResponse | None = response self._response: Response | None = None self.client: globus_sdk.BaseClient = self._wrapped.client # copy parsed JSON data off of '_wrapped' self._parsed_json: t.Any = self._wrapped._parsed_json # init on a Response object, this is the "normal" case # _wrapped is None else: if client is None: raise ValueError("Missing client with normal response") self._wrapped = None self._response = response self.client = client # JSON decoding may raise a ValueError due to an invalid JSON # document. In the case of trying to fetch the "data" on an HTTP # response, this means we didn't get a JSON response. # store this as None, as in "no data" # # if the caller *really* wants the raw body of the response, they can # always use `text` try: self._parsed_json = self._response.json() except ValueError: log.warning("response data did not parse as JSON, data=None") self._parsed_json = None @property def _raw_response(self) -> Response: # this is an internal property which traverses any series of wrapped responses # until reaching a requests response object if self._response is not None: return self._response elif self._wrapped is not None: return self._wrapped._raw_response else: # unreachable # pragma: no cover raise ValueError("could not find an inner response object") @property def http_status(self) -> int: """The HTTP response status, as an integer.""" return self._raw_response.status_code @property def http_reason(self) -> str: """ The HTTP reason string from the response. This is the part of the status line after the status code, and typically is a string description of the status. If the status line is ``HTTP/1.1 200 OK``, then this is the string ``"OK"``. """ return self._raw_response.reason @property def headers(self) -> t.Mapping[str, str]: """ The HTTP response headers as a case-insensitive mapping. For example, ``headers["Content-Length"]`` and ``headers["content-length"]`` are treated as equivalent. """ return self._raw_response.headers @property def content_type(self) -> str | None: return self.headers.get("Content-Type") @property def text(self) -> str: """The raw response data as a string.""" return self._raw_response.text @property def binary_content(self) -> bytes: """ The raw response data in bytes. """ return self._raw_response.content @property def data(self) -> t.Any: return self._parsed_json def get(self, key: str, default: t.Any = None) -> t.Any: """ ``get`` is just an alias for ``data.get(key, default)``, but with the added checks that if ``data`` is ``None`` or a list, it returns the default. :param key: The string key to lookup in the response if it is a dict :param default: The default value to be used if the data is null or a list """ if guards.is_optional(self.data, list): return default # NB: `default` is provided as a positional because the native dict type # doesn't recognize a keyword argument `default` return self.data.get(key, default) def __str__(self) -> str: """The default __str__ for a response assumes that the data is valid JSON-dump-able.""" if self.data is not None: return json.dumps(self.data, indent=2, separators=(",", ": ")) return self.text def __repr__(self) -> str: return f"{self.__class__.__name__}({self.text})" def __getitem__(self, key: str | int | slice) -> t.Any: # force evaluation of the data property outside of the upcoming # try-catch so that we don't accidentally catch TypeErrors thrown # during the getter function itself data = self.data try: return data[key] except TypeError as err: log.error( f"Can't index into responses with underlying data of type {type(data)}" ) # re-raise with an altered message and error type -- the issue is that # whatever data is in the response doesn't support indexing (e.g. a response # that is just an integer, parsed as json) # # "type" is ambiguous, but we don't know if it's the fault of the # class at large, or just a particular call's `data` property raise ValueError( "This type of response data does not support indexing." ) from err def __contains__(self, item: t.Any) -> bool: """ ``x in response`` is an alias for ``x in response.data`` """ if self.data is None: return False return item in self.data def __bool__(self) -> bool: """ ``bool(response)`` is an alias for ``bool(response.data)`` """ return bool(self.data) class IterableResponse(GlobusHTTPResponse): """This response class adds an __iter__ method on an 'iter_key' variable. The assumption is that iter produces dicts or dict-like mappings.""" default_iter_key: t.ClassVar[str] iter_key: str def __init__( self, response: Response | GlobusHTTPResponse, client: globus_sdk.BaseClient | None = None, *, iter_key: str | None = None, ) -> None: if not hasattr(self, "default_iter_key"): raise TypeError( "Cannot instantiate an iterable response from a class " "which does not define a default iteration key." ) iter_key = iter_key if iter_key is not None else self.default_iter_key self.iter_key = iter_key super().__init__(response, client) def __iter__(self) -> t.Iterator[t.Mapping[t.Any, t.Any]]: if not isinstance(self.data, dict): raise TypeError( "Cannot iterate on IterableResponse data when " f"type is '{type(self.data).__name__}'" ) return iter(self.data[self.iter_key]) class ArrayResponse(GlobusHTTPResponse): """This response class adds an ``__iter__`` method which assumes that the top-level data of the response is a JSON array.""" def __iter__(self) -> t.Iterator[t.Any]: if not isinstance(self.data, list): raise TypeError( "Cannot iterate on ArrayResponse data when " f"type is '{type(self.data).__name__}'" ) return iter(self.data) def __len__(self) -> int: """ ``len(response)`` is an alias for ``len(response.data)`` """ if not isinstance(self.data, collections.abc.Sequence): raise TypeError( "Cannot take len() on ArrayResponse data when " f"type is '{type(self.data).__name__}'" ) return len(self.data) globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/000077500000000000000000000000001513221403200231355ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/__init__.py000066400000000000000000000015271513221403200252530ustar00rootroot00000000000000from .collection import DynamicScopeCollection, ScopeCollection, StaticScopeCollection from .data import ( AuthScopes, ComputeScopes, FlowsScopes, GCSCollectionScopes, GCSEndpointScopes, GroupsScopes, NexusScopes, SearchScopes, SpecificFlowScopes, TimersScopes, TransferScopes, ) from .errors import ScopeCycleError, ScopeParseError from .parser import ScopeParser from .representation import Scope __all__ = ( "ScopeCollection", "StaticScopeCollection", "DynamicScopeCollection", "Scope", "ScopeParser", "ScopeParseError", "ScopeCycleError", "GCSCollectionScopes", "GCSEndpointScopes", "AuthScopes", "ComputeScopes", "FlowsScopes", "SpecificFlowScopes", "GroupsScopes", "NexusScopes", "SearchScopes", "TimersScopes", "TransferScopes", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/_graph_parser.py000066400000000000000000000235701513221403200263320ustar00rootroot00000000000000from __future__ import annotations import dataclasses import sys import typing as t from collections import defaultdict, deque from .errors import ScopeCycleError, ScopeParseError SPECIAL_CHARACTERS = set("[]* ") SPECIAL_TOKENS = set("[]*") class ScopeGraph: def __init__(self) -> None: self.top_level_scopes: set[tuple[str, bool]] = set() self.nodes: set[str] = set() self.edges: set[tuple[str, str, bool]] = set() self.adjacency_matrix: dict[str, set[tuple[str, str, bool]]] = defaultdict(set) def breadth_first_walk(self) -> t.Iterator[tuple[str, bool]]: """ Do a BFS across the forest, returning nodes (as tuples) in BFS order. For inspection of the graph after parsing. """ bfs_queue: t.Deque[tuple[str, bool]] = deque(self.top_level_scopes) while bfs_queue: yield (current := bfs_queue.popleft()) edges = self.adjacency_matrix[current[0]] for _, dest, optional in edges: bfs_queue.append((dest, optional)) def add_edge(self, src: str, dest: str, optional: bool) -> None: self.edges.add((src, dest, optional)) self.adjacency_matrix[src].add((src, dest, optional)) def _normalize_optionals(self) -> None: to_remove: set[tuple[str, str, bool]] = set() for edge in self.edges: src, dest, optional = edge if not optional: continue # The current edge is optional; see if it's superseded by required edge required_variant = (src, dest, False) if required_variant in self.edges: to_remove.add(edge) self.edges = self.edges - to_remove for edge in to_remove: src, _, _ = edge self.adjacency_matrix[src].remove(edge) def _check_cycles(self) -> None: # explore the graph using an iterative Depth-First Search # as we explore the graph, keep track of paths of ancestry being explored # if we ever find a back-edge along one of those paths of ancestry, that # means that there must be a cycle # start from the top-level nodes (which we know to be the roots of this # forest-shaped graph) # we will track this as the set of paths to continue to branch and explore in a # stack and pop from it until it is empty, thus implementing DFS # # conceptually, the paths could be implemented as `list[str]`, which would # preserve the order in which we encountered each node. Using a set is a # micro-optimization which makes checks faster, since we only care to detect # *that* there was a cycle, not what the shape of that cycle was paths_to_explore: list[tuple[set[str], str]] = [ ({node}, node) for node, _ in self.top_level_scopes ] while paths_to_explore: path, terminus = paths_to_explore.pop() # get out-edges from the last node in the path children = self.adjacency_matrix[terminus] # if the node was a leaf, no children, we are done exploring this path if not children: continue # for each child edge, do two basic things: # - check if we found a back-edge (cycle!) # - create a new path to explore, with the child node as its current # terminus for edge in children: _, dest, _ = edge if dest in path: raise ScopeCycleError(f"A cycle was found involving '{dest}'") paths_to_explore.append((path.union((dest,)), dest)) def __str__(self) -> str: lines = ["digraph scopes {", ' rankdir="LR";', ""] for node, optional in self.top_level_scopes: lines.append(f" {'*' if optional else ''}{node}") lines.append("") # do two passes to put all non-optional edges first for source, dest, optional in self.edges: if optional: continue lines.append(f" {source} -> {dest};") for source, dest, optional in self.edges: if not optional: continue lines.append(f' {source} -> {dest} [ label = "optional" ];') lines.append("") lines.append("}") return "\n".join(lines) @classmethod def parse(cls, scopes: str) -> ScopeGraph: trees = ScopeTreeNode.parse(scopes) graph = cls._convert_trees(trees) graph._normalize_optionals() graph._check_cycles() return graph @classmethod def _convert_trees(cls, trees: list[ScopeTreeNode]) -> ScopeGraph: graph = ScopeGraph() node_queue: t.Deque[ScopeTreeNode] = deque() for tree_node in trees: node_queue.append(tree_node) graph.top_level_scopes.add((tree_node.scope_string, tree_node.optional)) while node_queue: tree_node = node_queue.pop() scope_string = tree_node.scope_string graph.nodes.add(scope_string) for dep in tree_node.dependencies: node_queue.append(dep) graph.add_edge(scope_string, dep.scope_string, dep.optional) return graph # pass slots=True on 3.10+ # it's not strictly necessary, but it improves performance if sys.version_info >= (3, 10): _add_dataclass_kwargs: dict[str, bool] = {"slots": True} else: _add_dataclass_kwargs: dict[str, bool] = {} @dataclasses.dataclass(**_add_dataclass_kwargs) class ScopeTreeNode: # # This is an intermediate representation for scope parsing. # scope_string: str optional: bool dependencies: list[ScopeTreeNode] = dataclasses.field(default_factory=list) def add_dependency(self, subtree: ScopeTreeNode) -> None: self.dependencies.append(subtree) @staticmethod def parse(scope_string: str) -> list[ScopeTreeNode]: tokens = _tokenize(scope_string) return _parse_tokens(tokens) def _tokenize(scope_string: str) -> list[str]: tokens: list[str] = [] start = 0 for idx, current_char, next_char in _peek_enumerate(scope_string): if current_char not in SPECIAL_CHARACTERS: continue _reject_bad_adjacent_characters(current_char, next_char) if start != idx: tokens.append(scope_string[start:idx]) start = idx + 1 if current_char in SPECIAL_TOKENS: tokens.append(current_char) elif current_char == " ": pass else: raise NotImplementedError remainder = scope_string[start:].strip() if remainder: tokens.append(remainder) return tokens def _reject_bad_adjacent_characters(current_char: str, next_char: str | None) -> None: """Given a pair of adjacent characters during tokenization, raise appropriate errors if they are not valid.""" if next_char is None: return if (current_char, next_char) == ("*", " "): raise ScopeParseError("'*' must not be followed by a space") elif current_char == "]" and next_char not in (" ", "]"): raise ScopeParseError("']' may only be followed by a space or ']'") elif (current_char, next_char) == (" ", "["): raise ScopeParseError("'[' cannot have a preceding space") def _parse_tokens(tokens: list[str]) -> list[ScopeTreeNode]: # value to return ret: list[ScopeTreeNode] = [] # track whether or not the current scope is optional (has a preceding *) current_optional = False # keep a stack of "parents", each time we enter a `[` context, push the last scope # and each time we exit via a `]`, pop from the stack parents: list[ScopeTreeNode] = [] # track the current (or, by similar terminology, "last") complete scope seen current_scope: ScopeTreeNode | None = None for _, token, next_token in _peek_enumerate(tokens): _reject_bad_adjacent_tokens(token, next_token) if token == "*": current_optional = True elif token == "[": if not current_scope: raise ScopeParseError("found '[' without a preceding scope string") parents.append(current_scope) elif token == "]": if not parents: raise ScopeParseError("found ']' with no matching '[' preceding it") parents.pop() else: current_scope = ScopeTreeNode(token, optional=current_optional) current_optional = False if parents: parents[-1].add_dependency(current_scope) else: ret.append(current_scope) if parents: raise ScopeParseError("unclosed brackets, missing ']'") return ret def _reject_bad_adjacent_tokens(current_token: str, next_token: str | None) -> None: """ Given a pair of tokens from parsing, raise appropriate errors if they are not a valid sequence. """ if current_token == "*": if next_token is None: raise ScopeParseError("ended in optional marker") elif next_token in SPECIAL_TOKENS: raise ScopeParseError( "a scope string must always follow an optional marker" ) elif (current_token, next_token) == ("[", None): raise ScopeParseError("ended in left bracket") elif (current_token, next_token) == ("[", "]"): raise ScopeParseError("found empty brackets") elif (current_token, next_token) == ("[", "["): raise ScopeParseError("found double left-bracket") def _peek_enumerate(data: str | list[str]) -> t.Iterator[tuple[int, str, str | None]]: """ An iterator producing (index, character, next_char) or else producing (index, str, next_str) (Depending on whether or not the input is a string or list of strings) """ if not data: return prev: str = data[0] for idx, c in enumerate(data[1:]): yield (idx, prev, c) prev = c yield (len(data) - 1, prev, None) globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/collection.py000066400000000000000000000057751513221403200256600ustar00rootroot00000000000000from __future__ import annotations import abc import typing as t from .representation import Scope class ScopeCollection(abc.ABC): """ The common base for scope collections. ScopeCollections act as namespaces with attribute access to get scopes. They can also be iterated to get all of their defined scopes and provide the appropriate resource_server string for use in OAuth2 flows. """ @property @abc.abstractmethod def resource_server(self) -> str: ... @abc.abstractmethod def __iter__(self) -> t.Iterator[Scope]: ... class StaticScopeCollection(ScopeCollection): """ A static scope collection is a data container which provides various scopes as class attributes. ``resource_server`` must be available as a class attribute. """ resource_server: t.ClassVar[str] def __iter__(self) -> t.Iterator[Scope]: for view in (vars(self).values(), vars(self.__class__).values()): for value in view: if isinstance(value, Scope): yield value class DynamicScopeCollection(ScopeCollection): """ The base type for dynamic scope collections, where the resource server is variable. The default implementation takes the resource server as the only init-time parameter. :param resource_server: The resource_server to use for all scopes attached to this scope collection. """ # DynamicScopeCollection classes are expected to provide # the scope names which they provide as a classvar # these are often properties for dynamic computation _scope_names: t.ClassVar[tuple[str, ...]] def __init__(self, resource_server: str) -> None: self._resource_server = resource_server def __iter__(self) -> t.Iterator[Scope]: for name in self._scope_names: value = getattr(self, name) if isinstance(value, Scope): yield value @property def resource_server(self) -> str: return self._resource_server def _urn_scope(resource_server: str, scope_name: str) -> Scope: """ Convert a short name + resource server string to a scope, in the Globus Auth URN format. Example Usage: >>> _urn_scope("transfer.api.globus.org", "all") Scope('urn:globus:auth:scope:transfer.api.globus.org:all') :param resource_server: The resource server string. :param scope_name: The short name for the scope. """ return Scope(f"urn:globus:auth:scope:{resource_server}:{scope_name}") def _url_scope(resource_server: str, scope_name: str) -> Scope: """ Convert a short name + resource server string to a scope, in the Globus Auth URL format. Example Usage: >>> _url_scope("actions.globus.org", "hello_world") Scope('https://auth.globus.org/scopes/actions.globus.org/hello_world') :param resource_server: The resource server string. :param scope_name: The short name for the scope. """ return Scope(f"https://auth.globus.org/scopes/{resource_server}/{scope_name}") globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/consents/000077500000000000000000000000001513221403200247715ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/consents/__init__.py000066400000000000000000000004001513221403200270740ustar00rootroot00000000000000from ._errors import ConsentParseError, ConsentTreeConstructionError from ._model import Consent, ConsentForest, ConsentTree __all__ = ( "Consent", "ConsentTree", "ConsentForest", "ConsentParseError", "ConsentTreeConstructionError", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/consents/_errors.py000066400000000000000000000011371513221403200270200ustar00rootroot00000000000000from __future__ import annotations import typing as t if t.TYPE_CHECKING: from ._model import Consent class ConsentParseError(Exception): """An error raised if consent parsing/loading fails.""" def __init__(self, message: str, raw_consent: dict[str, t.Any]) -> None: super().__init__(message) self.raw_consent = raw_consent class ConsentTreeConstructionError(Exception): """An error raised if consent tree construction fails.""" def __init__(self, message: str, consents: list[Consent]) -> None: super().__init__(message) self.consents = consents globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/consents/_model.py000066400000000000000000000314771513221403200266160ustar00rootroot00000000000000""" This module provides convenience data structures to model and interact with Globus Auth consents. The resources defined herein are: * ``Consent`` * A data object modeling a user's grant for a client to perform some scoped operation on their behalf. * This grant is conditional on the invocation path leading to the client's attempted operation being initiated through a chain of similarly scoped operations (consents) defined in the "dependency_path". * ``ConsentTree`` * A tree composed of Consent nodes with edges modeling the dependency relationships between them. * A `meets_scope_requirements` method is defined to check whether a scope requirement, including dependent scope requirements, is satisfied by the tree. * ``ConsentForest`` * A collection of all ConsentTrees for a user rooted under a particular client (the client that initiated the request for consents). * A `meets_scope_requirements` method is defined to check whether a scope requirement, including dependent scope requirements, is satisfied by any tree in the forest. """ from __future__ import annotations import textwrap import typing as t import uuid from dataclasses import dataclass from datetime import datetime from ..parser import ScopeParser from ..representation import Scope from ._errors import ConsentParseError, ConsentTreeConstructionError @dataclass class Consent: """ Consent Data Object This object models: * A grant which a user has provided for a client to perform a particular scoped operation on their behalf. * The consent is conditional on the invocation path leading to the client's attempted operation being initiated through a chain of similarly scoped operations (consents) defined in the "dependency_path". """ client: uuid.UUID | str scope: uuid.UUID | str scope_name: str id: int effective_identity: uuid.UUID | str # A list representing the path of consent dependencies leading from a "root consent" # to this. The last element of this list will always be this consent's ID. # Downstream dependency relationships may exist but will not be defined here. dependency_path: list[int] created: datetime updated: datetime last_used: datetime status: str allows_refresh: bool auto_approved: bool atomically_revocable: bool @classmethod def load(cls, data: t.Mapping[str, t.Any]) -> Consent: """ Load a Consent object from a raw data dictionary. :param data: A dictionary containing the raw consent data. :raises: ConsentParseError if the data is missing a required key. """ try: return cls( id=data["id"], client=data["client"], scope=data["scope"], effective_identity=data["effective_identity"], dependency_path=data["dependency_path"], scope_name=data["scope_name"], created=datetime.fromisoformat(data["created"]), updated=datetime.fromisoformat(data["updated"]), last_used=datetime.fromisoformat(data["last_used"]), status=data["status"], allows_refresh=data["allows_refresh"], auto_approved=data["auto_approved"], atomically_revocable=data["atomically_revocable"], ) except KeyError as e: raise ConsentParseError( f"Failed to load Consent object. Missing required key: {e}.", raw_consent=dict(data), ) from e def __str__(self) -> str: client_id = str(self.client) return f"Consent [{self.id}]: Client [{client_id}] -> Scope [{self.scope_name}]" class ConsentForest: """ A ConsentForest is a data structure which models relationships between Consents, objects describing explicit access users have granted to particular clients. It exists to expose a simple interface for evaluating whether resource server grant requirements, as defined by a scope object are satisfied. Consents should be retrieved from the AuthClient's ``get_consents`` method. Example usage: >>> auth_client = AuthClient(...) >>> identity_id = ... >>> forest = auth_client.get_consents(identity_id).to_forest() >>> >>> # Check whether the forest meets a scope requirement >>> data_access_scope = GCSCollectionScopes(collection_id).data_access >>> scope = TransferScopes.all.with_dependency(data_access_scope) >>> forest.meets_scope_requirements(scope) The following diagram demonstrates a Consent Forest in which a user has consented to a client ("CLI") initiating transfers against two collections, both of which require a "data_access" dynamic scope. Contained Scope String: `transfer:all[:data_access :data_access]` .. code-block:: rst [Consent A ] [Consent B ] [Client: CLI ] -> [Client: Transfer ] [Scope: transfer:all] [Scope: :data_access] | | [Consent C ] |--------------> [Client: Transfer ] [Scope: :data_access] """ def __init__(self, consents: t.Iterable[t.Mapping[str, t.Any] | Consent]) -> None: """ :param consents: An iterable of consent data objects. Typically, this will be a ConsentForestResponse retrieved via `auth_client.get_consents(identity)`. This iterable may contain either raw consent data as a dict or pre-loaded Consents. """ self.nodes = [ consent if isinstance(consent, Consent) else Consent.load(consent) for consent in consents ] # Build an index on consent id for constant time lookups self._node_by_id = {node.id: node for node in self.nodes} self.edges = self._compute_edges() self.trees = self._build_trees() def __str__(self) -> str: # indent 4 for inner elements, so that we can put their headings at 2 indent trees = textwrap.indent("\n".join(str(t) for t in self.trees), " ") nodes = textwrap.indent("\n".join(str(n) for n in self.nodes), " ") return f"ConsentForest\n nodes\n{nodes}\n trees\n{trees}" def _compute_edges(self) -> dict[int, set[int]]: """ Compute the edges of the forest mapping parent -> child. A consent's parent node id is defined as the penultimate element of the consent's dependency path. A consent with dependency list of length 1 is a root node (has no parent). """ edges: dict[int, set[int]] = {node.id: set() for node in self.nodes} for node in self.nodes: if len(node.dependency_path) > 1: parent_id = node.dependency_path[-2] try: edges[parent_id].add(node.id) except KeyError as e: raise ConsentTreeConstructionError( f"Failed to compute forest edges. Missing parent node: {e}.", consents=self.nodes, ) from e return edges def _build_trees(self) -> list[ConsentTree]: """ Build out the list of trees in the forest. A distinct tree is built out for each "root nodes" (nodes with no parents). """ # A node with dependency path length 1 has no parents, so it is a root. roots = [node for node in self.nodes if len(node.dependency_path) == 1] return [ConsentTree(root.id, self) for root in roots] def get_node(self, consent_id: int) -> Consent: return self._node_by_id[consent_id] def meets_scope_requirements( self, scopes: Scope | str | t.Sequence[Scope | str] ) -> bool: """ Check whether this consent meets one or more scope requirements. A consent forest meets a particular scope requirement if any consent tree inside the forest meets the scope requirements. :param scopes: A single scope, a list of scopes, or a scope string to check against the forest. :returns: True if all scope requirements are met, False otherwise. """ for scope in _normalize_scope_types(scopes): if not any(tree.meets_scope_requirements(scope) for tree in self.trees): return False return True class ConsentTree: """ A tree of Consent nodes with edges modeling the dependency relationships between them. :raises: ConsentParseError if the tree cannot be constructed due to missing consent dependencies. """ def __init__(self, root_id: int, forest: ConsentForest) -> None: self.root = forest.get_node(root_id) self.nodes = [self.root] self._node_by_id = {root_id: self.root} self.edges: dict[int, set[int]] = {} self._populate_connected_nodes_and_edges(forest) def _populate_connected_nodes_and_edges(self, forest: ConsentForest) -> None: """ Populate the nodes and edges of the tree by traversing the forest. Nodes/Edges are included in the tree iff they are reachable from the root. """ nodes_to_evaluate = {self.root.id} while nodes_to_evaluate: consent_id = nodes_to_evaluate.pop() consent = forest.get_node(consent_id) self.edges[consent.id] = forest.edges[consent.id] for child_id in self.edges[consent.id]: if child_id not in self._node_by_id: self.nodes.append(forest.get_node(child_id)) self._node_by_id[child_id] = forest.get_node(child_id) nodes_to_evaluate.add(child_id) def get_node(self, consent_id: int) -> Consent: return self._node_by_id[consent_id] def meets_scope_requirements(self, scope: Scope) -> bool: """ Check whether this consent tree meets a particular scope requirement. :param scope: A Scope requirement to check against the tree. """ return self._meets_scope_requirements_recursive(self.root, scope) def _meets_scope_requirements_recursive(self, node: Consent, scope: Scope) -> bool: """ Check recursively whether a consent node meets the scope requirements defined by a scope object. """ if node.scope_name != scope.scope_string: return False for dependent_scope in scope.dependencies: for child_id in self.edges[node.id]: if self._meets_scope_requirements_recursive( self.get_node(child_id), dependent_scope ): # We found a child containing this full dependent scope tree # Move onto the next dependent scope tree break else: # We didn't find any child containing this full dependent scope tree return False # We found at least one child containing each full dependent scope tree return True @property def max_depth(self) -> int: return self._max_depth_recursive(self.root, 1) def _max_depth_recursive(self, node: Consent, depth: int) -> int: if len(self.edges[node.id]) == 0: return depth return max( self._max_depth_recursive(self.get_node(child_id), depth + 1) for child_id in self.edges[node.id] ) def __str__(self) -> str: """Returns a textual representation of the tree to stdout (one line per node)""" return self._str_recursive(self.root, 0) def _str_recursive(self, node: Consent, tab_depth: int) -> str: _str = f"{' ' * tab_depth} - {node}" for child_id in self.edges[node.id]: _str += "\n" + self._str_recursive(self.get_node(child_id), tab_depth + 2) return _str def _normalize_scope_types( scopes: Scope | str | t.Sequence[Scope | str], ) -> list[Scope]: """ Normalize the input scope types into a list of Scope objects. Strings are parsed into 1 or more Scopes using `ScopeParser.parse`. :param scopes: Some collection of 0 or more scopes as Scope or scope strings. :returns: A list of Scope objects. """ if isinstance(scopes, Scope): return [scopes] elif isinstance(scopes, str): return ScopeParser.parse(scopes) else: scope_list = [] for scope in scopes: if isinstance(scope, str): scope_list.extend(ScopeParser.parse(scope)) else: scope_list.append(scope) return scope_list globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/000077500000000000000000000000001513221403200240465ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/__init__.py000066400000000000000000000010741513221403200261610ustar00rootroot00000000000000from .auth import AuthScopes from .compute import ComputeScopes from .flows import FlowsScopes, SpecificFlowScopes from .gcs import GCSCollectionScopes, GCSEndpointScopes from .groups import GroupsScopes, NexusScopes from .search import SearchScopes from .timers import TimersScopes from .transfer import TransferScopes __all__ = ( "AuthScopes", "ComputeScopes", "FlowsScopes", "SpecificFlowScopes", "GCSEndpointScopes", "GCSCollectionScopes", "GroupsScopes", "NexusScopes", "SearchScopes", "TimersScopes", "TransferScopes", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/auth.py000066400000000000000000000014371513221403200253660ustar00rootroot00000000000000from ..collection import StaticScopeCollection, _urn_scope from ..representation import Scope class _AuthScopes(StaticScopeCollection): resource_server = "auth.globus.org" openid = Scope("openid") email = Scope("email") profile = Scope("profile") manage_projects = _urn_scope(resource_server, "manage_projects") view_authentications = _urn_scope(resource_server, "view_authentications") view_clients = _urn_scope(resource_server, "view_clients") view_clients_and_scopes = _urn_scope(resource_server, "view_clients_and_scopes") view_consents = _urn_scope(resource_server, "view_consents") view_identities = _urn_scope(resource_server, "view_identities") view_identity_set = _urn_scope(resource_server, "view_identity_set") AuthScopes = _AuthScopes() globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/compute.py000066400000000000000000000006651513221403200261030ustar00rootroot00000000000000from ..collection import StaticScopeCollection, _url_scope class _ComputeScopes(StaticScopeCollection): # The Compute service breaks the scopes/resource server convention: its resource # server is a service name and its scopes are built around the client ID. resource_server = "funcx_service" client_id = "facd7ccc-c5f4-42aa-916b-a0e270e2c2a9" all = _url_scope(client_id, "all") ComputeScopes = _ComputeScopes() globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/flows.py000066400000000000000000000056531513221403200255630ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from ..collection import ( DynamicScopeCollection, StaticScopeCollection, _url_scope, ) from ..representation import Scope class _FlowsScopes(StaticScopeCollection): # The Flows service breaks the scopes/resource server convention: its # resource server is a domain name but its scopes are built around the # client ID. resource_server = "flows.globus.org" client_id = "eec9b274-0c81-4334-bdc2-54e90e689b9a" all = _url_scope(client_id, "all") manage_flows = _url_scope(client_id, "manage_flows") view_flows = _url_scope(client_id, "view_flows") run = _url_scope(client_id, "run") run_status = _url_scope(client_id, "run_status") run_manage = _url_scope(client_id, "run_manage") FlowsScopes = _FlowsScopes() class SpecificFlowScopes(DynamicScopeCollection): """ This defines the scopes for a single flow (as distinct from the Flows service). It primarily provides the `user` scope which is typically needed to start a run of a flow. Example usage: .. code-block:: python sc = SpecificFlowScopes("my-flow-id-here") flow_scope = sc.user """ _scope_names = ("user",) def __init__(self, flow_id: uuid.UUID | str) -> None: _flow_id = str(flow_id) super().__init__(_flow_id) self.user: Scope = _url_scope( _flow_id, f"flow_{_flow_id.replace('-', '_')}_user" ) @classmethod def _build_class_stub(cls) -> SpecificFlowScopes: """ This internal helper builds a "stub" object so that ``SpecificFlowClient.scopes`` is typed as ``SpecificFlowScopes`` but raises appropriate errors at runtime access via the class. """ return _SpecificFlowScopesClassStub() class _SpecificFlowScopesClassStub(SpecificFlowScopes): """ This stub object ensures that the type deductions for type checkers (e.g. mypy) on SpecificFlowClient.scopes are correct. Primarily, it should be possible to access the `scopes` attribute, the `user` scope, and the `resource_server`, but these usages should raise specific and informative runtime errors. Our types are therefore less accurate for class-var access, but more accurate for instance-var access. """ def __init__(self) -> None: super().__init__("") def __getattribute__(self, name: str) -> t.Any: if name == "user": _raise_attr_error("scopes") if name == "resource_server": _raise_attr_error("resource_server") return object.__getattribute__(self, name) def _raise_attr_error(name: str) -> t.NoReturn: raise AttributeError( f"It is not valid to attempt to access the '{name}' attribute of the " "SpecificFlowClient class. " f"Instead, instantiate a SpecificFlowClient and access the '{name}' attribute " "from that instance." ) globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/gcs.py000066400000000000000000000025201513221403200251730ustar00rootroot00000000000000from functools import cached_property from ..collection import DynamicScopeCollection, _url_scope, _urn_scope from ..representation import Scope class GCSEndpointScopes(DynamicScopeCollection): """ A dynamic ScopeCollection with a named property for the GCS manage_collections scope. "manage_collections" is a scope on GCS Endpoints. The resource_server string should be the GCS Endpoint ID. **Examples** >>> sc = GCSEndpointScopes("xyz") >>> mc_scope = sb.manage_collections """ _scope_names = ("manage_collections",) @cached_property def manage_collections(self) -> Scope: return _urn_scope(self.resource_server, "manage_collections") class GCSCollectionScopes(DynamicScopeCollection): """ A dynamic ScopeCollection with a named property for the GCS data_access scope. "data_access" is a scope on GCS Collections. The resource_server string should be the GCS Collection ID. **Examples** >>> sc = GCSCollectionScopes("xyz") >>> da_scope = sc.data_access >>> https_scope = sc.https """ _scope_names = ("data_access", "https") @cached_property def data_access(self) -> Scope: return _url_scope(self.resource_server, "data_access") @cached_property def https(self) -> Scope: return _url_scope(self.resource_server, "https") globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/groups.py000066400000000000000000000010021513221403200257300ustar00rootroot00000000000000from ..collection import StaticScopeCollection, _urn_scope class _GroupsScopes(StaticScopeCollection): resource_server = "groups.api.globus.org" all = _urn_scope(resource_server, "all") view_my_groups_and_memberships = _urn_scope( resource_server, "view_my_groups_and_memberships" ) class _NexusScopes(StaticScopeCollection): resource_server = "nexus.api.globus.org" groups = _urn_scope(resource_server, "groups") GroupsScopes = _GroupsScopes() NexusScopes = _NexusScopes() globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/search.py000066400000000000000000000006351513221403200256710ustar00rootroot00000000000000from ..collection import StaticScopeCollection, _urn_scope class _SearchScopes(StaticScopeCollection): resource_server = "search.api.globus.org" all = _urn_scope(resource_server, "all") globus_connect_server = _urn_scope(resource_server, "globus_connect_server") ingest = _urn_scope(resource_server, "ingest") search = _urn_scope(resource_server, "search") SearchScopes = _SearchScopes() globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/timers.py000066400000000000000000000003711513221403200257240ustar00rootroot00000000000000from ..collection import StaticScopeCollection, _url_scope class _TimersScopes(StaticScopeCollection): resource_server = "524230d7-ea86-4a52-8312-86065a9e0417" timer = _url_scope(resource_server, "timer") TimersScopes = _TimersScopes() globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/data/transfer.py000066400000000000000000000004531513221403200262460ustar00rootroot00000000000000from ..collection import StaticScopeCollection, _urn_scope class _TransferScopes(StaticScopeCollection): resource_server = "transfer.api.globus.org" all = _urn_scope(resource_server, "all") gcp_install = _urn_scope(resource_server, "gcp_install") TransferScopes = _TransferScopes() globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/errors.py000066400000000000000000000002771513221403200250310ustar00rootroot00000000000000class ScopeParseError(ValueError): """The error raised if scope parsing fails.""" class ScopeCycleError(ScopeParseError): """The error raised if scope parsing discovers a cycle.""" globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/parser.py000066400000000000000000000102031513221403200247770ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk import exc from ._graph_parser import ScopeGraph from .representation import Scope class ScopeParser: """ The ``ScopeParser`` handles the conversion of strings to scopes. Most interfaces are classmethods, meaning users should prefer usage like ``ScopeParser.parse("foo")`` """ @classmethod def parse(cls, scope_string: str) -> list[Scope]: """ Parse an arbitrary scope string to a list of scopes. Zero or more than one scope may be returned, as in the case of an empty string or space-delimited scopes. .. warning:: Parsing passes through an intermediary representation which treats scopes as a graph. This ensures that the behavior of parses matches the treatment of scope strings in Globus Auth authorization flows. However, this also means that the parsing does not allow for strings which represent consent trees with structures in which the same scope appears in multiple parts of the tree. :param scope_string: The string to parse """ # build the graph intermediate representation scope_graph = ScopeGraph.parse(scope_string) # traverse the graph in a reversed BFS scan # # this means we'll handle leaf nodes first, and we'll never reach a # node before its descendants (dependencies) # # as we work, build a lookup table for built Scope objects so that we can # quickly retrieve elements built_scopes: dict[tuple[str, bool], Scope] = {} for name, optionality in list(scope_graph.breadth_first_walk())[::-1]: dependencies: tuple[Scope, ...] = tuple( # the lookup in built_scopes here is safe because of the # reversed BFS ordering built_scopes[(dep_name, dep_optional)] for _, dep_name, dep_optional in scope_graph.adjacency_matrix[name] ) built_scopes[(name, optionality)] = Scope( name, optional=optionality, dependencies=dependencies ) # only return the top-level elements from that build process # (the roots of the forest-shaped graph) return [built_scopes[key] for key in scope_graph.top_level_scopes] @classmethod def merge_scopes(cls, scopes_a: list[Scope], scopes_b: list[Scope]) -> list[Scope]: """ Given two lists of Scopes, merge them into one list of Scopes by parsing them as one combined scope string. :param scopes_a: list of Scopes to be merged with scopes_b :param scopes_b: list of Scopes to be merged with scopes_a """ # dict of base scope_string: list of scopes with that base scope_string return cls.parse( " ".join([str(s) for s in scopes_a] + [str(s) for s in scopes_b]) ) @classmethod def serialize( cls, scopes: str | Scope | t.Iterable[str | Scope], *, reject_empty: bool = True ) -> str: """ Normalize scopes to a space-separated scope string. The results of this method are suitable for sending to Globus Auth. Scopes are not parsed, merged, or normalized by this method. :param scopes: A scope string, scope object, or an iterable of scope strings and scope objects. :param reject_empty: When true (the default), raise an error if the scopes serialize to the empty string. :returns: A space-separated scope string. Example usage: .. code-block:: pycon >>> ScopeParser.serialize([Scope("foo"), "bar", Scope("qux")]) 'foo bar qux' """ scope_iter: t.Iterable[str | Scope] if isinstance(scopes, (str, Scope)): scope_iter = (scopes,) else: scope_iter = scopes result = " ".join(str(scope) for scope in scope_iter) if reject_empty and result == "": raise exc.GlobusSDKUsageError( "'scopes' cannot be the empty string or empty collection." ) return result globus-globus-sdk-python-6a080e4/src/globus_sdk/scopes/representation.py000066400000000000000000000113321513221403200265510ustar00rootroot00000000000000from __future__ import annotations import dataclasses import sys import typing as t # pass slots=True on 3.10+ # it's not strictly necessary, but it improves performance if sys.version_info >= (3, 10): _add_dataclass_kwargs: dict[str, bool] = {"slots": True} else: _add_dataclass_kwargs: dict[str, bool] = {} @dataclasses.dataclass(frozen=True, repr=False, **_add_dataclass_kwargs) class Scope: """ A scope object is a representation of a scope and its dynamic dependencies (other scopes). A scope may be optional (also referred to as "atomically revocable"). An optional scope can be revoked without revoking consent for other scopes which were granted at the same time. Scopes are immutable, and provide several evolver methods which produce new Scopes. In particular, ``with_dependency`` and ``with_dependencies`` create new scopes with added dependencies. ``str(Scope(...))`` produces a valid scope string for use in various methods. :param scope_string: The string which will be used as the basis for this Scope :param optional: The scope may be marked as optional. This means that the scope can be declined by the user without declining consent for other scopes. """ scope_string: str optional: bool = dataclasses.field(default=False) dependencies: tuple[Scope, ...] = dataclasses.field(default=()) def __post_init__( self, ) -> None: if any(c in self.scope_string for c in "[]* "): raise ValueError( "Scope instances may not contain the special characters '[]* '. " "Use Scope.parse instead." ) @classmethod def parse(cls, scope_string: str) -> Scope: """ Deserialize a scope string to a scope object. This is the special case of parsing in which exactly one scope must be returned by the parse. If more than one scope is returned by the parse, a ``ValueError`` will be raised. :param scope_string: The string to parse """ # deferred import because ScopeParser depends on Scope, but Scope.parse # is a wrapper over ScopeParser.parse() from .parser import ScopeParser data = ScopeParser.parse(scope_string) if len(data) != 1: raise ValueError( "`Scope.parse()` did not get exactly one scope. " f"Instead got data={data}" ) return data[0] def with_dependency(self, other_scope: Scope) -> Scope: """ Create a new scope with a dependency. The dependent scope relationship will be stored in the Scope and will be evident in its string representation. :param other_scope: The scope upon which the current scope depends. """ if not isinstance(other_scope, Scope): raise TypeError( "Scope.with_dependency() takes a Scope as its input. " f"Got: '{type(other_scope).__qualname__}'" ) return dataclasses.replace( self, dependencies=self.dependencies + (other_scope,) ) def with_dependencies(self, other_scopes: t.Iterable[Scope]) -> Scope: """ Create a new scope with added dependencies. The dependent scope relationships will be stored in the Scope and will be evident in its string representation. :param other_scopes: The scopes upon which the current scope depends. """ other_scopes_tuple = tuple(other_scopes) for i, item in enumerate(other_scopes_tuple): if not isinstance(item, Scope): raise TypeError( "Scope.with_dependencies() takes " "an iterable of Scopes as its input. " f"At position {i}, got: '{type(item).__qualname__}'" ) return dataclasses.replace( self, dependencies=self.dependencies + other_scopes_tuple ) def with_optional(self, optional: bool) -> Scope: """ Create a new scope with a different 'optional' value. :param optional: Whether or not the scope is optional. """ return dataclasses.replace(self, optional=optional) def __repr__(self) -> str: parts: list[str] = [f"'{self.scope_string}'"] if self.optional: parts.append("optional=True") if self.dependencies: parts.append(f"dependencies={self.dependencies!r}") return "Scope(" + ", ".join(parts) + ")" def __str__(self) -> str: base_scope = ("*" if self.optional else "") + self.scope_string if not self.dependencies: return base_scope return base_scope + "[" + " ".join(str(c) for c in self.dependencies) + "]" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/000077500000000000000000000000001513221403200234645ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/__init__.py000066400000000000000000000000001513221403200255630ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/000077500000000000000000000000001513221403200244255ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/__init__.py000066400000000000000000000022731513221403200265420ustar00rootroot00000000000000from .client import ( AuthClient, AuthLoginClient, ConfidentialAppAuthClient, NativeAppAuthClient, ) from .data import DependentScopeSpec from .errors import AuthAPIError from .flow_managers import ( GlobusAuthorizationCodeFlowManager, GlobusNativeAppFlowManager, ) from .id_token_decoder import IDTokenDecoder from .identity_map import IdentityMap from .response import ( GetConsentsResponse, GetIdentitiesResponse, OAuthAuthorizationCodeResponse, OAuthClientCredentialsResponse, OAuthDependentTokenResponse, OAuthRefreshTokenResponse, OAuthTokenResponse, ) __all__ = ( # client classes "AuthClient", "AuthLoginClient", "NativeAppAuthClient", "ConfidentialAppAuthClient", # errors "AuthAPIError", # high-level helpers "DependentScopeSpec", "IdentityMap", "IDTokenDecoder", # flow managers "GlobusNativeAppFlowManager", "GlobusAuthorizationCodeFlowManager", # responses "GetConsentsResponse", "GetIdentitiesResponse", "OAuthAuthorizationCodeResponse", "OAuthClientCredentialsResponse", "OAuthDependentTokenResponse", "OAuthRefreshTokenResponse", "OAuthTokenResponse", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/_common.py000066400000000000000000000045631513221403200264360ustar00rootroot00000000000000from __future__ import annotations import json import logging import typing as t import jwt from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from globus_sdk._missing import MISSING, MissingType from globus_sdk.response import GlobusHTTPResponse log = logging.getLogger(__name__) class _JWKGetCallbackProto(t.Protocol): def __call__( self, path: str, *, query_params: dict[str, t.Any] | None = None, headers: dict[str, str] | None = None, ) -> GlobusHTTPResponse: ... def get_jwk_data( *, fget: _JWKGetCallbackProto, openid_configuration: GlobusHTTPResponse | dict[str, t.Any], ) -> dict[str, t.Any]: jwks_uri = openid_configuration["jwks_uri"] log.debug("fetching from jwks_uri=%s", jwks_uri) data = fget(jwks_uri).data if not isinstance(data, dict): # how could this happen? # some guesses: # - interfering proxy or cache # - user passed explicit (incorrect) OIDC config raise ValueError( "JWK data was not a dict. This should be an unreachable condition." ) return data def pem_decode_jwk_data( *, jwk_data: dict[str, t.Any], ) -> RSAPublicKey: log.debug("JWK PEM decode requested, decoding...") # decode from JWK to an RSA PEM key for JWT decoding # cast here because this should never be private key jwk_as_pem: RSAPublicKey = t.cast( RSAPublicKey, jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk_data["keys"][0])), ) log.debug("JWK PEM decoding finished successfully") return jwk_as_pem @t.runtime_checkable class SupportsJWKMethods(t.Protocol): client_id: str | None def get_openid_configuration(self) -> GlobusHTTPResponse: ... @t.overload def get_jwk( self, openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[True], ) -> RSAPublicKey: ... @t.overload def get_jwk( self, openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[False], ) -> dict[str, t.Any]: ... def get_jwk( self, openid_configuration: ( GlobusHTTPResponse | dict[str, t.Any] | MissingType ) = MISSING, *, as_pem: bool = False, ) -> RSAPublicKey | dict[str, t.Any]: ... globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/client/000077500000000000000000000000001513221403200257035ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/client/__init__.py000066400000000000000000000004641513221403200300200ustar00rootroot00000000000000from .base_login_client import AuthLoginClient from .confidential_client import ConfidentialAppAuthClient from .native_client import NativeAppAuthClient from .service_client import AuthClient __all__ = ( "AuthClient", "AuthLoginClient", "NativeAppAuthClient", "ConfidentialAppAuthClient", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/client/base_login_client.py000066400000000000000000000336171513221403200317270ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from globus_sdk import client, exc from globus_sdk._internal.remarshal import commajoin from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer, NullAuthorizer from globus_sdk.response import GlobusHTTPResponse from globus_sdk.scopes import AuthScopes, Scope from globus_sdk.transport import RequestsTransport, RetryConfig from .._common import get_jwk_data, pem_decode_jwk_data from ..errors import AuthAPIError from ..flow_managers import GlobusOAuthFlowManager from ..response import ( OAuthAuthorizationCodeResponse, OAuthRefreshTokenResponse, OAuthTokenResponse, ) log = logging.getLogger(__name__) RT = t.TypeVar("RT", bound=GlobusHTTPResponse) class AuthLoginClient(client.BaseClient): """ This client class provides the common base for clients providing login functionality via `Globus Auth `_ :param client_id: The ID of the application provided by registration with Globus Auth. All other initialization parameters are passed through to ``BaseClient``. .. automethodlist:: globus_sdk.AuthLoginClient """ service_name = "auth" error_class = AuthAPIError scopes = AuthScopes def __init__( self, client_id: uuid.UUID | str | None = None, environment: str | None = None, base_url: str | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: super().__init__( environment=environment, base_url=base_url, authorizer=authorizer, app_name=app_name, transport=transport, retry_config=retry_config, ) self.client_id: str | None = str(client_id) if client_id is not None else None # an AuthClient may contain a GlobusOAuth2FlowManager in order to # encapsulate the functionality of various different types of flow # managers self.current_oauth2_flow_manager: GlobusOAuthFlowManager | None = None log.debug( "Finished initializing AuthLoginClient. " f"client_id='{client_id}', type(authorizer)={type(authorizer)}" ) @property def default_scope_requirements(self) -> list[Scope]: raise exc.GlobusSDKUsageError( "AuthLoginClients do not have default_scope_requirements, " "use AuthClient instead." ) # FYI: this get_openid_configuration method is duplicated in AuthClient # if this code is modified, please update that copy as well # # we would like to restructure code using this method to be calling the matching # AuthClient method # for example, a future SDK version may make an AuthLoginClient contain # an AuthClient which it uses def get_openid_configuration(self) -> GlobusHTTPResponse: """ Fetch the OpenID Connect configuration data from the well-known URI for Globus Auth. """ log.debug("Fetching OIDC Config") return self.get("/.well-known/openid-configuration") @t.overload def get_jwk( self, openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[True], ) -> RSAPublicKey: ... @t.overload def get_jwk( self, openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[False], ) -> dict[str, t.Any]: ... # FYI: this get_jwk method is duplicated in AuthClient # if this code is modified, please update that copy as well # # we would like to restructure code using this method to be calling the matching # AuthClient method # for example, a future SDK version may make an AuthLoginClient contain # an AuthClient which it uses def get_jwk( self, openid_configuration: ( GlobusHTTPResponse | dict[str, t.Any] | MissingType ) = MISSING, *, as_pem: bool = False, ) -> RSAPublicKey | dict[str, t.Any]: """ Fetch the Globus Auth JWK. Returns either a dict or an RSA Public Key object depending on ``as_pem``. :param openid_configuration: The OIDC config as a GlobusHTTPResponse or dict. When not provided, it will be fetched automatically. :param as_pem: Decode the JWK to an RSA PEM key, typically for JWT decoding """ if openid_configuration is MISSING: log.debug("No OIDC Config provided, autofetching...") openid_configuration = self.get_openid_configuration() jwk_data = get_jwk_data( fget=self.get, openid_configuration=openid_configuration ) return pem_decode_jwk_data(jwk_data=jwk_data) if as_pem else jwk_data def oauth2_get_authorize_url( self, *, session_required_identities: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, session_required_single_domain: str | t.Iterable[str] | MissingType = MISSING, session_required_policies: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, session_required_mfa: bool | MissingType = MISSING, session_message: str | MissingType = MISSING, prompt: t.Literal["login"] | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> str: """ Get the authorization URL to which users should be sent. This method may only be called after ``oauth2_start_flow`` has been called on this ``AuthClient``. :param session_required_identities: A list of identities must be added to the session. :param session_required_single_domain: A list of domain requirements which must be satisfied by identities added to the session. :param session_required_policies: A list of IDs for policies which must be satisfied by the user. :param session_required_mfa: Whether MFA is required for the session. :param session_message: A message to be displayed to the user by Globus Auth. :param prompt: Control whether a user is required to log in before the authorization step. If set to "login", the user must authenticate with an identity provider even if they are already logged in. Setting this parameter can help ensure that a user's session meets known or unknown session requirement policies and avoid additional login flows. :param query_params: Additional query parameters to include in the authorize URL. Primarily for internal use """ if not self.current_oauth2_flow_manager: log.error("OutOfOrderOperations(get_authorize_url before start_flow)") raise exc.GlobusSDKUsageError( "Cannot get authorize URL until starting an OAuth2 flow. " "Call the oauth2_start_flow() method on this " "AuthClient to resolve" ) query_params = { "session_required_identities": commajoin(session_required_identities), "session_required_single_domain": commajoin(session_required_single_domain), "session_required_policies": commajoin(session_required_policies), "session_required_mfa": session_required_mfa, "session_message": session_message, "prompt": prompt, **(query_params or {}), } auth_url = self.current_oauth2_flow_manager.get_authorize_url( query_params=query_params ) log.debug(f"Got authorization URL: {auth_url}") return auth_url def oauth2_exchange_code_for_tokens( self, auth_code: str ) -> OAuthAuthorizationCodeResponse: """ Exchange an authorization code for a token or tokens. :param auth_code: An auth code typically obtained by sending the user to the authorize URL. The code is a very short-lived credential which this method is exchanging for tokens. Tokens are the credentials used to authenticate against Globus APIs. """ log.debug( "Final Step of 3-legged OAuth2 Flows: " "Exchanging authorization code for token(s)" ) if not self.current_oauth2_flow_manager: log.error("OutOfOrderOperations(exchange_code before start_flow)") raise exc.GlobusSDKUsageError( "Cannot exchange auth code until starting an OAuth2 flow. " "Call the oauth2_start_flow() method on this " "AuthClient to resolve" ) return self.current_oauth2_flow_manager.exchange_code_for_tokens(auth_code) def oauth2_refresh_token( self, refresh_token: str, *, body_params: dict[str, t.Any] | None = None, ) -> OAuthRefreshTokenResponse: r""" Exchange a refresh token for a :class:`OAuthTokenResponse <.OAuthTokenResponse>`, containing an access token. Does a token call of the form .. code-block:: none refresh_token= grant_type=refresh_token plus any additional parameters you may specify. :param refresh_token: A Globus Refresh Token as a string :param body_params: A dict of extra params to encode in the refresh call. """ log.debug("Executing token refresh; typically requires client credentials") form_data = {"refresh_token": refresh_token, "grant_type": "refresh_token"} return self.oauth2_token( form_data, body_params=body_params, response_class=OAuthRefreshTokenResponse ) def oauth2_revoke_token( self, token: str, *, body_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Revoke a token. It can be an Access Token or a Refresh token. This call should be used to revoke tokens issued to your client, rendering them inert and not further usable. Typically, this is incorporated into "logout" functionality, but it should also be used if the client detects that its tokens are in an unsafe location (e.x. found in a world-readable logfile). You can check the "active" status of the token after revocation if you want to confirm that it was revoked. :param token: The token which should be revoked :param body_params: Additional parameters to include in the revocation body, which can help speed the revocation process. Primarily for internal use **Examples** >>> from globus_sdk import ConfidentialAppAuthClient >>> ac = ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) >>> ac.oauth2_revoke_token('') """ log.debug("Revoking token") body = {"token": token} # if this client has no way of authenticating itself but # it does have a client_id, we'll send that in the request no_authentication = self.authorizer is None or isinstance( self.authorizer, NullAuthorizer ) if no_authentication and self.client_id: log.debug("Revoking token with unauthenticated client") body.update({"client_id": self.client_id}) body.update(body_params or {}) return self.post("/v2/oauth2/token/revoke", data=body, encoding="form") @t.overload def oauth2_token( self, form_data: dict[str, t.Any], ) -> OAuthTokenResponse: ... @t.overload def oauth2_token( self, form_data: dict[str, t.Any], *, body_params: dict[str, t.Any] | None, ) -> OAuthTokenResponse: ... @t.overload def oauth2_token( self, form_data: dict[str, t.Any], *, response_class: type[RT], ) -> RT: ... @t.overload def oauth2_token( self, form_data: dict[str, t.Any], *, body_params: dict[str, t.Any] | None, response_class: type[RT], ) -> RT: ... def oauth2_token( self, form_data: dict[str, t.Any], *, body_params: dict[str, t.Any] | None = None, response_class: type[OAuthTokenResponse] | type[RT] = OAuthTokenResponse, ) -> OAuthTokenResponse | RT: """ This is the generic form of calling the OAuth2 Token endpoint. It takes ``form_data``, a dict which will be encoded in a form POST body on the request. Generally, users of the SDK should not call this method unless they are implementing OAuth2 flows. :param response_class: This is used by calls to the oauth2_token endpoint which need to specialize their responses. For example, :meth:`oauth2_get_dependent_tokens \ ` requires a specialize response class to handle the dramatically different format of the Dependent Token Grant response :param form_data: The main body of the request :param body_params: Any additional parameters to be passed through as body parameters. :rtype: ``response_class`` if set, defaults to :py:attr:`~globus_sdk.OAuthTokenResponse` """ log.debug("Fetching new token from Globus Auth") # use the fact that requests implicitly encodes the `data` parameter as # a form POST data = {**form_data, **(body_params or {})} return response_class( self.post( "/v2/oauth2/token", data=data, encoding="form", ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/client/confidential_client.py000066400000000000000000000432241513221403200322570ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from globus_sdk import exc from globus_sdk._internal.remarshal import strseq_iter, strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import BasicAuthorizer from globus_sdk.response import GlobusHTTPResponse from globus_sdk.scopes import Scope, ScopeParser from globus_sdk.transport import RequestsTransport, RetryConfig from ..flow_managers import GlobusAuthorizationCodeFlowManager from ..response import OAuthClientCredentialsResponse, OAuthDependentTokenResponse from .base_login_client import AuthLoginClient log = logging.getLogger(__name__) class ConfidentialAppAuthClient(AuthLoginClient): """ This is a specialized type of ``AuthLoginClient`` used to represent an App with a Client ID and Client Secret wishing to communicate with Globus Auth. It must be given a Client ID and a Client Secret, and furthermore, these will be used to establish a :class:`BasicAuthorizer ` for authorization purposes. Additionally, the Client ID is stored for use in various calls. Confidential Applications are those which have their own credentials for authenticating against Globus Auth. :param client_id: The ID of the application provided by registration with Globus Auth. :param client_secret: The secret string to use for authentication. Secrets can be generated via the Globus developers interface. All other initialization parameters are passed through to ``BaseClient``. .. automethodlist:: globus_sdk.ConfidentialAppAuthClient """ def __init__( self, client_id: uuid.UUID | str, client_secret: str, environment: str | None = None, base_url: str | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: super().__init__( client_id=client_id, authorizer=BasicAuthorizer(str(client_id), client_secret), environment=environment, base_url=base_url, app_name=app_name, transport=transport, retry_config=retry_config, ) def oauth2_client_credentials_tokens( self, requested_scopes: str | Scope | t.Iterable[str | Scope] ) -> OAuthClientCredentialsResponse: r""" Perform an OAuth2 Client Credentials Grant to get access tokens which directly represent your client and allow it to act on its own (independent of any user authorization). This method does not use a ``GlobusOAuthFlowManager`` because it is not at all necessary to do so. :param requested_scopes: The scopes on the token(s) being requested. For example, with a Client ID of "CID1001" and a Client Secret of "RAND2002", you could use this grant type like so: .. code-block:: pycon >>> client = ConfidentialAppAuthClient("CID1001", "RAND2002") >>> tokens = client.oauth2_client_credentials_tokens( ... "urn:globus:auth:scope:transfer.api.globus.org:all" ... ) >>> transfer_token_info = tokens.by_resource_server["transfer.api.globus.org"] >>> transfer_token = transfer_token_info["access_token"] """ # noqa: E501 requested_scopes_string = ScopeParser.serialize(requested_scopes) log.debug( "Fetching token(s) using client credentials, " f"scope={requested_scopes_string}" ) return self.oauth2_token( {"grant_type": "client_credentials", "scope": requested_scopes_string}, response_class=OAuthClientCredentialsResponse, ) def oauth2_start_flow( self, redirect_uri: str, requested_scopes: str | Scope | t.Iterable[str | Scope], *, state: str = "_default", refresh_tokens: bool = False, ) -> GlobusAuthorizationCodeFlowManager: """ Starts or resumes an Authorization Code OAuth2 flow. Under the hood, this is done by instantiating a :class:`GlobusAuthorizationCodeFlowManager <.GlobusAuthorizationCodeFlowManager>` :param redirect_uri: The page that users should be directed to after authenticating at the authorize URL. :param requested_scopes: The scopes on the token(s) being requested. :param state: This string allows an application to pass information back to itself in the course of the OAuth flow. Because the user will navigate away from the application to complete the flow, this parameter lets the app pass an arbitrary string from the starting page to the ``redirect_uri`` :param refresh_tokens: When True, request refresh tokens in addition to access tokens. [Default: ``False``] .. tab-set:: .. tab-item:: Example Usage You can see an example of this flow :ref:`in the usage examples `. .. tab-item:: API Info The Authorization Code Grant flow is described `in the Globus Auth Specification `_. """ log.debug("Starting OAuth2 Authorization Code Grant Flow") self.current_oauth2_flow_manager = GlobusAuthorizationCodeFlowManager( self, redirect_uri, requested_scopes=requested_scopes, state=state, refresh_tokens=refresh_tokens, ) return self.current_oauth2_flow_manager def oauth2_get_dependent_tokens( self, token: str, *, refresh_tokens: bool = False, scope: str | t.Iterable[str] | MissingType = MISSING, additional_params: dict[str, t.Any] | None = None, ) -> OAuthDependentTokenResponse: """ Fetch Dependent Tokens from Globus Auth. This exchanges a token given to this client for a new set of tokens which give it access to resource servers on which it depends. This grant type is intended for use by Resource Servers playing out the following scenario: 1. User has tokens for Service A, but Service A requires access to Service B on behalf of the user 2. Service B should not see tokens scoped for Service A 3. Service A therefore requests tokens scoped only for Service B, based on tokens which were originally scoped for Service A... In order to do this exchange, the tokens for Service A must have scopes which depend on scopes for Service B (the services' scopes must encode their relationship). As long as that is the case, Service A can use this Grant to get those "Dependent" or "Downstream" tokens for Service B. :param token: An access token as a string :param refresh_tokens: When True, request dependent refresh tokens in addition to access tokens. [Default: ``False``] :param scope: The scope or scopes of the dependent tokens which are being requested. Applications are recommended to provide this string to ensure that they are receiving the tokens they expect. If omitted, all available dependent tokens will be returned. :param additional_params: Additional parameters to include in the request body .. tab-set:: .. tab-item:: Example Usage Given a token, getting a dependent token for Globus Groups might look like the following: .. code-block:: python ac = globus_sdk.ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) dependent_token_data = ac.oauth2_get_dependent_tokens( "", scope="urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships", ) group_token_data = dependent_token_data.by_resource_server["groups.api.globus.org"] group_token = group_token_data["access_token"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.oauth2_get_dependent_tokens :case: groups .. tab-item:: API Info ``POST /v2/oauth2/token`` .. extdoclink:: Dependent Token Grant :ref: auth/reference/##dependent_token_grant_post_v2oauth2token """ # noqa: E501 log.debug( "Getting dependent tokens from access token" f"additional_params={additional_params}" ) form_data = { "grant_type": "urn:globus:auth:grant_type:dependent_token", "token": token, # the internal parameter is 'access_type', but using the name # 'refresh_tokens' is consistent with the rest of the SDK and better # communicates expectations back to the user than the OAuth2 spec wording "access_type": "offline" if refresh_tokens else MISSING, "scope": (" ".join(strseq_iter(scope)) if scope is not MISSING else scope), **(additional_params or {}), } return self.oauth2_token(form_data, response_class=OAuthDependentTokenResponse) def oauth2_token_introspect( self, token: str, *, include: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Get information about a Globus Auth token. :param token: An Access Token as a raw string, being evaluated :param include: A value for the ``include`` parameter in the request body. Default is to omit the parameter. :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python ac = globus_sdk.ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) ac.oauth2_token_introspect("") Get information about a Globus Auth token including the full identity set of the user to whom it belongs .. code-block:: python ac = globus_sdk.ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) data = ac.oauth2_token_introspect("", include="identity_set") for identity in data["identity_set"]: print('token authenticates for "{}"'.format(identity)) .. tab-item:: API Info ``POST /v2/oauth2/token/introspect`` .. extdoclink:: Token Introspection :ref: auth/reference/#token_introspection_post_v2_oauth2_token_introspect """ # noqa: E501 log.debug("Checking token validity (introspect)") body = { "token": token, "include": include, } return self.post( "/v2/oauth2/token/introspect", data=body, encoding="form", query_params=query_params, ) def create_child_client( self, name: str, *, public_client: bool | MissingType = MISSING, client_type: ( t.Literal[ "client_identity", "confidential_client", "globus_connect_server", "public_installed_client", "hybrid_confidential_client_resource_server", "resource_server", ] | MissingType ) = MISSING, visibility: t.Literal["public", "private"] | MissingType = MISSING, redirect_uris: t.Iterable[str] | MissingType = MISSING, terms_and_conditions: str | MissingType = MISSING, privacy_policy: str | MissingType = MISSING, required_idp: uuid.UUID | str | MissingType = MISSING, preselect_idp: uuid.UUID | str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Create a new client. Requires the ``manage_projects`` scope. :param name: The display name shown to users on consents. May not contain linebreaks. :param public_client: This is used to infer which OAuth grant_types the client will be able to use. Should be false if the client is capable of keeping secret credentials (such as clients running on a server) and true if it is not (such as native apps). After creation this value is immutable. This option is mutually exclusive with ``client_type``, exactly one must be given. :param client_type: Defines the type of client that will be created. This option is mutually exclusive with ``public_client``, exactly one must be given. .. dropdown:: Values for ``client_type`` .. list-table:: * - ``"confidential_client"`` - Applications that are OAuth confidential clients, and can manage a client secret and requests for user consent. * - ``"public_installed_client"`` - Applications that are OAuth public clients or native applications that are distributed to users, and thus cannot manage a client secret. * - ``"client_identity"`` - Applications that authenticate and act as the application itself. These applications are used for automation and as service or community accounts, and do NOT act on behalf of other users. Also known as a "Service Account". * - ``"resource_server"`` - An API (OAuth resource server) that uses Globus Auth tokens for authentication. Users accessing the service login via Globus and consent for the client to use your API. * - ``"globus_connect_server"`` - Create a client that will service as a Globus Connect Server endpoint. * - ``"hybrid_confidential_client_resource_server"`` - A client which can use any behavior with Globus Auth - an application (confidential or public client), service account, or API. :param visibility: If set to "public", any authenticated entity can view it. When set to "private", only entities in the same project as the client can view it. :param redirect_uris: list of URIs that may be used in OAuth authorization flows. :param terms_and_conditions: URL of client's terms and conditions. :param privacy_policy: URL of client's privacy policy. :param required_idp: In order to use this client a user must have an identity from this IdP in their identity set. :param preselect_idp: This pre-selects the given IdP on the Globus Auth login page if the user is not already authenticated. :param additional_fields: Any additional parameters to be passed through. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> project_id = ... >>> r = ac.create_child_client( ... "My client", ... True, ... True, ... ) >>> client_id = r["client"]["id"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_child_client .. tab-item:: API Info ``POST /v2/api/clients`` .. extdoclink:: Create Client :ref: auth/reference/#create_client """ # Must specify exactly one of public_client or client_type if public_client is not MISSING and client_type is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client does not take both " "'public_client' and 'client_type'. These are mutually exclusive." ) if public_client is MISSING and client_type is MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client requires either 'public_client' or " "'client_type'." ) # terms_and_conditions and privacy_policy must both be set or unset if bool(terms_and_conditions) ^ bool(privacy_policy): raise exc.GlobusSDKUsageError( "terms_and_conditions and privacy_policy must both be set or unset" ) links: dict[str, str | MissingType] | MissingType = MISSING if terms_and_conditions and privacy_policy: links = { "terms_and_conditions": terms_and_conditions, "privacy_policy": privacy_policy, } body: dict[str, t.Any] = { "name": name, "visibility": visibility, "required_idp": required_idp, "preselect_idp": preselect_idp, "public_client": public_client, "client_type": client_type, "redirect_uris": strseq_listify(redirect_uris), "links": links, **(additional_fields or {}), } return self.post("/v2/api/clients", data={"client": body}) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/client/native_client.py000066400000000000000000000154031513221403200311040ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import NullAuthorizer from globus_sdk.response import GlobusHTTPResponse from globus_sdk.scopes import Scope from globus_sdk.transport import RequestsTransport, RetryConfig from ..flow_managers import GlobusNativeAppFlowManager from ..response import OAuthRefreshTokenResponse from .base_login_client import AuthLoginClient log = logging.getLogger(__name__) class NativeAppAuthClient(AuthLoginClient): """ This type of ``AuthLoginClient`` is used to represent a Native App's communications with Globus Auth. It requires a Client ID, and cannot take an ``authorizer``. Native Apps are applications, like the Globus CLI, which are run client-side and therefore cannot keep secrets. Unable to possess client credentials, several Globus Auth interactions have to be specialized to accommodate the absence of a secret. Any keyword arguments given are passed through to the ``AuthClient`` constructor. .. automethodlist:: globus_sdk.NativeAppAuthClient """ def __init__( self, client_id: uuid.UUID | str, environment: str | None = None, base_url: str | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: super().__init__( client_id=client_id, authorizer=NullAuthorizer(), environment=environment, base_url=base_url, app_name=app_name, transport=transport, retry_config=retry_config, ) def oauth2_start_flow( self, requested_scopes: str | Scope | t.Iterable[str | Scope], *, redirect_uri: str | MissingType = MISSING, state: str = "_default", verifier: str | MissingType = MISSING, refresh_tokens: bool = False, prefill_named_grant: str | MissingType = MISSING, ) -> GlobusNativeAppFlowManager: """ Starts a Native App OAuth2 flow. This is done internally by instantiating a :class:`GlobusNativeAppFlowManager <.GlobusNativeAppFlowManager>` While the flow is in progress, the ``NativeAppAuthClient`` becomes non thread-safe as temporary state is stored during the flow. :param requested_scopes: The scopes on the token(s) being requested. Defaults to ``openid profile email urn:globus:auth:scope:transfer.api.globus.org:all`` :param redirect_uri: The page that users should be directed to after authenticating at the authorize URL. Defaults to 'https://auth.globus.org/v2/web/auth-code', which displays the resulting ``auth_code`` for users to copy-paste back into your application (and thereby be passed back to the ``GlobusNativeAppFlowManager``) :param state: The ``redirect_uri`` page will have this included in a query parameter, so you can use it to pass information to that page if you use a custom page. It defaults to the string '_default' :param verifier: A secret used for the Native App flow. It will by default be a freshly generated random string, known only to this ``GlobusNativeAppFlowManager`` instance :param refresh_tokens: When True, request refresh tokens in addition to access tokens. [Default: ``False``] :param prefill_named_grant: Prefill the named grant label on the consent page .. tab-set:: .. tab-item:: Example Usage You can see an example of this flow :ref:`in the usage examples `. .. tab-item:: API Info The Globus Auth specification for Native App grants details modifications to the Authorization Code grant flow as `The PKCE Security Protocol `_. """ log.debug("Starting Native App Grant Flow") self.current_oauth2_flow_manager = GlobusNativeAppFlowManager( self, requested_scopes=requested_scopes, redirect_uri=redirect_uri, state=state, verifier=verifier, refresh_tokens=refresh_tokens, prefill_named_grant=prefill_named_grant, ) return self.current_oauth2_flow_manager def oauth2_refresh_token( self, refresh_token: str, *, body_params: dict[str, t.Any] | None = None, ) -> OAuthRefreshTokenResponse: """ ``NativeAppAuthClient`` specializes the refresh token grant to include its client ID as a parameter in the POST body. It needs this specialization because it cannot authenticate the refresh grant call with client credentials, as is normal. :param refresh_token: The refresh token to use to get a new access token :param body_params: Extra parameters to include in the POST body """ log.debug("Executing token refresh without client credentials") form_data = { "refresh_token": refresh_token, "grant_type": "refresh_token", "client_id": self.client_id, } return self.oauth2_token( form_data, body_params=body_params, response_class=OAuthRefreshTokenResponse ) def create_native_app_instance( self, template_id: uuid.UUID | str, name: str, ) -> GlobusHTTPResponse: """ Create a new native app instance. The new instance is a confidential client. :param template_id: The client ID of the calling native app :param name: The name given to the new app instance .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.NativeAppAuthClient(...) >>> template_id = ... >>> r = ac.create_native_app_instance( ... template_id, ... "My new native app instance", ... ) >>> client_id = r["client"]["id"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_native_app_instance .. tab-item:: API Info ``POST /v2/api/clients`` .. extdoclink:: Create Client :ref: auth/reference/#create_client """ body: dict[str, t.Any] = { "name": name, "template_id": template_id, } return self.post("/v2/api/clients", data={"client": body}) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/client/service_client.py000066400000000000000000002041131513221403200312540ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from globus_sdk import client, exc from globus_sdk._internal.remarshal import commajoin, strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.response import GlobusHTTPResponse, IterableResponse from globus_sdk.scopes import AuthScopes, Scope from globus_sdk.transport import RequestsTransport, RetryConfig if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusApp from .._common import get_jwk_data, pem_decode_jwk_data from ..data import DependentScopeSpec from ..errors import AuthAPIError from ..response import ( GetClientCredentialsResponse, GetClientsResponse, GetConsentsResponse, GetIdentitiesResponse, GetIdentityProvidersResponse, GetPoliciesResponse, GetProjectsResponse, GetScopesResponse, ) log = logging.getLogger(__name__) F = t.TypeVar("F", bound=t.Callable[..., GlobusHTTPResponse]) class AuthClient(client.BaseClient): """ A client for using the `Globus Auth API `_ .. sdk-sphinx-copy-params:: BaseClient This class provides helper methods for most common resources in the Auth API, and the common low-level interface from :class:`BaseClient ` of ``get``, ``put``, ``post``, and ``delete`` methods, which can be used to access any API resource. **Examples** Initializing an ``AuthClient`` to authenticate a user making calls to the Globus Auth service with an access token takes the form >>> from globus_sdk import AuthClient, AccessTokenAuthorizer >>> ac = AuthClient(authorizer=AccessTokenAuthorizer('')) Other authorizers, most notably ``RefreshTokenAuthorizer``, are also supported. .. automethodlist:: globus_sdk.AuthClient """ service_name = "auth" error_class = AuthAPIError scopes = AuthScopes default_scope_requirements = [ AuthScopes.openid, AuthScopes.profile, AuthScopes.email, ] def __init__( self, environment: str | None = None, base_url: str | None = None, app: GlobusApp | None = None, app_scopes: list[Scope] | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: super().__init__( environment=environment, base_url=base_url, app=app, app_scopes=app_scopes, authorizer=authorizer, app_name=app_name, transport=transport, retry_config=retry_config, ) # FYI: this get_openid_configuration method is duplicated in AuthLoginBaseClient # if this code is modified, please update that copy as well # this will ideally be resolved in a future SDK version by making this the only copy def get_openid_configuration(self) -> GlobusHTTPResponse: """ Fetch the OpenID Connect configuration data from the well-known URI for Globus Auth. """ log.debug("Fetching OIDC Config") return self.get("/.well-known/openid-configuration") @t.overload def get_jwk( self, openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[True], ) -> RSAPublicKey: ... @t.overload def get_jwk( self, openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[False], ) -> dict[str, t.Any]: ... # FYI: this get_jwk method is duplicated in AuthLoginBaseClient # if this code is modified, please update that copy as well # this will ideally be resolved in a future SDK version by making this the only copy def get_jwk( self, openid_configuration: ( GlobusHTTPResponse | dict[str, t.Any] | MissingType ) = MISSING, *, as_pem: bool = False, ) -> RSAPublicKey | dict[str, t.Any]: """ Fetch the Globus Auth JWK. Returns either a dict or an RSA Public Key object depending on ``as_pem``. :param openid_configuration: The OIDC config as a GlobusHTTPResponse or dict. When not provided, it will be fetched automatically. :type openid_configuration: None | GlobusHTTPResponse | dict[str, typing.Any] :param as_pem: Decode the JWK to an RSA PEM key, typically for JWT decoding :type as_pem: bool """ if openid_configuration is MISSING: log.debug("No OIDC Config provided, autofetching...") openid_configuration = self.get_openid_configuration() jwk_data = get_jwk_data( fget=self.get, openid_configuration=openid_configuration ) return pem_decode_jwk_data(jwk_data=jwk_data) if as_pem else jwk_data def userinfo(self) -> GlobusHTTPResponse: """ Call the Userinfo endpoint of Globus Auth. Userinfo is specified as part of the OpenID Connect (OIDC) standard, and Globus Auth's Userinfo is OIDC-compliant. The exact data returned will depend upon the set of OIDC-related scopes which were used to acquire the token being used for this call. For details, see the **API Info** below. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python ac = AuthClient(...) info = ac.userinfo() print( 'Effective Identity "{info["sub"]}" has ' f'Full Name "{info["name"]}" and ' f'Email "{info["email"]}"' ) .. tab-item:: API Info ``GET /v2/oauth2/userinfo`` .. extdoclink:: Get Userinfo :ref: auth/reference/#get_or_post_v2_oauth2_userinfo_resource """ log.debug("Looking up OIDC-style Userinfo from Globus Auth") return self.get("/v2/oauth2/userinfo") def get_identities( self, *, usernames: t.Iterable[str] | str | MissingType = MISSING, ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType = MISSING, provision: bool = False, query_params: dict[str, t.Any] | None = None, ) -> GetIdentitiesResponse: r""" Given ``usernames=`` or (exclusive) ``ids=`` as keyword arguments, looks up identity information for the set of identities provided. ```` and ```` in this case are comma-delimited strings listing multiple Identity Usernames or Identity IDs, or iterables of strings, each of which is an Identity Username or Identity ID. If Globus Auth's identity auto-provisioning behavior is desired, ``provision=True`` may be specified. Available with any authentication/client type. :param usernames: A username or list of usernames to lookup. Mutually exclusive with ``ids`` :param ids: An identity ID or list of IDs to lookup. Mutually exclusive with ``usernames`` :param provision: Create identities if they do not exist, allowing clients to get username-to-identity mappings prior to the identity being used :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> # get by ID >>> r = ac.get_identities(ids="46bd0f56-e24f-11e5-a510-131bef46955c") >>> r.data { 'identities': [ { 'email': None, 'id': '46bd0f56-e24f-11e5-a510-131bef46955c', 'identity_provider': '7daddf46-70c5-45ee-9f0f-7244fe7c8707', 'name': None, 'organization': None, 'status': 'unused', 'username': 'globus@globus.org' } ] } >>> ac.get_identities( ... ids=",".join( ... ("46bd0f56-e24f-11e5-a510-131bef46955c", "168edc3d-c6ba-478c-9cf8-541ff5ebdc1c") ... ) ... ) >>> # or by usernames >>> ac.get_identities(usernames="globus@globus.org") >>> ac.get_identities(usernames="globus@globus.org,auth@globus.org") You could also use iterables: .. code-block:: python ac.get_identities(usernames=["globus@globus.org", "auth@globus.org"]) ac.get_identities( ids=["46bd0f56-e24f-11e5-a510-131bef46955c", "168edc3d-c6ba-478c-9cf8-541ff5ebdc1c"] ) The result itself is iterable, so you can use it like so: .. code-block:: python for identity in ac.get_identities(usernames=["globus@globus.org", "auth@globus.org"]): print(identity["id"]) .. tab-item:: API Info ``GET /v2/api/identities`` .. extdoclink:: Get Identities :ref: auth/reference/#v2_api_identities_resources """ # noqa: E501 log.debug("Looking up Globus Auth Identities") query_params = { "usernames": commajoin(usernames), "ids": commajoin(ids), # only specify `provision` if `usernames` is given "provision": ( str(provision).lower() if usernames is not MISSING else MISSING ), **(query_params or {}), } if ( query_params["usernames"] is not MISSING and query_params["ids"] is not MISSING ): log.warning( "get_identities called with both usernames and " "identities set! Expecting an error." ) log.debug(f"query_params={query_params}") return GetIdentitiesResponse( self.get("/v2/api/identities", query_params=query_params) ) def get_identity_providers( self, *, domains: t.Iterable[str] | str | MissingType = MISSING, ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> GetIdentityProvidersResponse: r""" Look up information about identity providers by domains or by IDs. :param domains: A domain or iterable of domains to lookup. Mutually exclusive with ``ids``. :param ids: An identity provider ID or iterable of IDs to lookup. Mutually exclusive with ``domains``. :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> # get by ID >>> r = ac.get_identity_providers(ids="41143743-f3c8-4d60-bbdb-eeecaba85bd9") >>> r.data { 'identity_providers': [ { 'alternative_names': [], 'name': 'Globus ID', 'domains': ['globusid.org'], 'id': '41143743-f3c8-4d60-bbdb-eeecaba85bd9', 'short_name': 'globusid' } ] } >>> ac.get_identities( ... ids=["41143743-f3c8-4d60-bbdb-eeecaba85bd9", "927d7238-f917-4eb2-9ace-c523fa9ba34e"] ... ) >>> # or by domain >>> ac.get_identities(domains="globusid.org") >>> ac.get_identities(domains=["globus.org", "globusid.org"]) The result itself is iterable, so you can use it like so: .. code-block:: python for idp in ac.get_identity_providers(domains=["globus.org", "globusid.org"]): print(f"name: {idp['name']}") print(f"id: {idp['id']}") print(f"domains: {idp['domains']}") print() .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_identity_providers .. tab-item:: API Info ``GET /v2/api/identity_providers`` .. extdoclink:: Get Identity Providers :ref: auth/reference/#get_identity_providers """ # noqa: E501 log.debug("Looking up Globus Auth Identity Providers") if domains is not MISSING and ids is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_identity_providers does not take both " "'domains' and 'ids'. These are mutually exclusive." ) elif domains is MISSING and ids is MISSING: log.warning( "Neither 'domains' nor 'ids' provided to get_identity_providers(). " "This can only succeed if 'query_params' were given." ) query_params = { "domains": commajoin(domains), "ids": commajoin(ids), **(query_params or {}), } log.debug(f"query_params={query_params}") return GetIdentityProvidersResponse( self.get("/v2/api/identity_providers", query_params=query_params) ) # # Developer APIs # def get_project(self, project_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Look up a project. Requires the ``manage_projects`` scope. :param project_id: The ID of the project to lookup .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_project("927d7238-f917-4eb2-9ace-c523fa9ba34e") >>> r.data { 'project': { 'admin_ids': ['41143743-f3c8-4d60-bbdb-eeecaba85bd9'] 'contact_email': 'support@globus.org', 'display_name': 'Globus SDK Demo Project', 'admin_group_ids': None, 'id': '927d7238-f917-4eb2-9ace-c523fa9ba34e', 'project_name': 'Globus SDK Demo Project', 'admins': { 'identities': ['41143743-f3c8-4d60-bbdb-eeecaba85bd9'], 'groups': [], }, } } .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_project .. tab-item:: API Info ``GET /v2/api/projects/{project_id}`` .. extdoclink:: Get Projects :ref: auth/reference/#get_projects """ return self.get(f"/v2/api/projects/{project_id}") def get_projects(self) -> IterableResponse: """ Look up projects on which the authenticated user is an admin. Requires the ``manage_projects`` scope. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_projects() >>> r.data { 'projects': [ { 'admin_ids': ['41143743-f3c8-4d60-bbdb-eeecaba85bd9'] 'contact_email': 'support@globus.org', 'display_name': 'Globus SDK Demo Project', 'admin_group_ids': None, 'id': '927d7238-f917-4eb2-9ace-c523fa9ba34e', 'project_name': 'Globus SDK Demo Project', 'admins': { 'identities': ['41143743-f3c8-4d60-bbdb-eeecaba85bd9'], 'groups': [], }, } ] } The result itself is iterable, so you can use it like so: .. code-block:: python for project in ac.get_projects(): print(f"name: {project['display_name']}") print(f"id: {project['id']}") print() .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_projects .. tab-item:: API Info ``GET /v2/api/projects`` .. extdoclink:: Get Projects :ref: auth/reference/#get_projects """ # noqa: E501 return GetProjectsResponse(self.get("/v2/api/projects")) def create_project( self, display_name: str, contact_email: str, *, admin_ids: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, admin_group_ids: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, ) -> GlobusHTTPResponse: """ Create a new project. Requires the ``manage_projects`` scope. At least one of ``admin_ids`` or ``admin_group_ids`` must be provided. :param display_name: The name of the project :param contact_email: The email address of the project's point of contact :param admin_ids: A list of user IDs to be added as admins of the project :param admin_group_ids: A list of group IDs to be added as admins of the project .. tab-set:: .. tab-item:: Example Usage When creating a project, your account is not necessarily included as an admin. The following snippet uses the ``manage_projects`` scope as well as the ``openid`` and ``email`` scopes to get the current user ID and email address and use those data to setup the project. .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> userinfo = ac.userinfo() >>> identity_id = userinfo["sub"] >>> email = userinfo["email"] >>> r = ac.create_project( ... "My Project", ... contact_email=email, ... admin_ids=identity_id, ... ) >>> project_id = r["project"]["id"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_project .. tab-item:: API Info ``POST /v2/api/projects`` .. extdoclink:: Create Project :ref: auth/reference/#create_project """ body = { "display_name": display_name, "contact_email": contact_email, "admin_ids": strseq_listify(admin_ids), "admin_group_ids": strseq_listify(admin_group_ids), } return self.post("/v2/api/projects", data={"project": body}) def update_project( self, project_id: uuid.UUID | str, *, display_name: str | MissingType = MISSING, contact_email: str | MissingType = MISSING, admin_ids: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, admin_group_ids: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, ) -> GlobusHTTPResponse: """ Update a project. Requires the ``manage_projects`` scope. :param project_id: The ID of the project to update :param display_name: The name of the project :param contact_email: The email address of the project's point of contact :param admin_ids: A list of user IDs to be set as admins of the project :param admin_group_ids: A list of group IDs to be set as admins of the project .. tab-set:: .. tab-item:: Example Usage The following snippet uses the ``manage_projects`` scope as well as the ``email`` scope to get the current user email address and set it as a project's contact email: .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> project_id = ... >>> userinfo = ac.userinfo() >>> email = userinfo["email"] >>> r = ac.update_project(project_id, contact_email=email) .. tab-item:: Example Response Data .. expandtestfixture:: auth.update_project .. tab-item:: API Info ``POST /v2/api/projects`` .. extdoclink:: Update Project :ref: auth/reference/#update_project """ body = { "display_name": display_name, "contact_email": contact_email, "admin_ids": strseq_listify(admin_ids), "admin_group_ids": strseq_listify(admin_group_ids), } return self.put(f"/v2/api/projects/{project_id}", data={"project": body}) def delete_project(self, project_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Delete a project. Requires the ``manage_projects`` scope. :param project_id: The ID of the project to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> project_id = ... >>> r = ac.delete_project(project_id) .. tab-item:: Example Response Data .. expandtestfixture:: auth.delete_project .. tab-item:: API Info ``DELETE /v2/api/projects/{project_id}`` .. extdoclink:: Delete Project :ref: auth/reference/#delete_project """ return self.delete(f"/v2/api/projects/{project_id}") def get_policy(self, policy_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Look up a policy. Requires the ``manage_projects`` scope. :param policy_id: The ID of the policy to lookup .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_policy("f5eaae7e-807f-41be-891a-ec86ff88df8f") >>> r.data { 'policy': { 'high_assurance': False, 'domain_constraints_include': ['globus.org'], 'display_name': 'Display Name', 'description': 'Description', 'id': 'f5eaae7e-807f-41be-891a-ec86ff88df8f', 'domain_constraints_exclude': None, 'project_id': 'da84e531-1afb-43cb-8c87-135ab580516a', 'authentication_assurance_timeout': 35, 'required_mfa": False } } .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_policy .. tab-item:: API Info ``GET /v2/api/policies/{policy_id}`` .. extdoclink:: Get Policies :ref: auth/reference/#get_policies """ return self.get(f"/v2/api/policies/{policy_id}") def get_policies(self) -> IterableResponse: """ Look up policies in projects on which the authenticated user is an admin. Requires the ``manage_projects`` scope. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_policies() >>> r.data { 'policies': [ { 'high_assurance': False, 'domain_constraints_include': ['greenlight.org'], 'display_name': 'GreenLight domain Only Policy', 'description': 'Only allow access from @greenlight.org', 'id': '99d2dc75-3acb-48ff-b5e5-2eee0a5121d1', 'domain_constraints_exclude': None, 'project_id': 'da84e531-1afb-43cb-8c87-135ab580516a', 'authentication_assurance_timeout': 35, 'required_mfa': False }, { 'high_assurance': True, 'domain_constraints_include': None, 'display_name': 'No RedLight domain Policy', 'description': 'Disallow access from @redlight.org', 'id': '5d93ebf0-b4c6-4928-9929-4ac47fc2786d', 'domain_constraints_exclude': ['redlight.org'], 'project_id': 'da84e531-1afb-43cb-8c87-135ab580516a', 'authentication_assurance_timeout': 35, 'required_mfa': True } ] } .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_policies .. tab-item:: API Info ``GET /v2/api/policies`` .. extdoclink:: Get Policies :ref: auth/reference/#get_policies """ return GetPoliciesResponse(self.get("/v2/api/policies")) def create_policy( self, *, project_id: uuid.UUID | str, display_name: str, description: str, high_assurance: bool | MissingType = MISSING, authentication_assurance_timeout: int | MissingType = MISSING, required_mfa: bool | MissingType = MISSING, domain_constraints_include: t.Iterable[str] | None | MissingType = MISSING, domain_constraints_exclude: t.Iterable[str] | None | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Create a new Auth policy. Requires the ``manage_projects`` scope. :param project_id: ID of the project for the new policy :param display_name: A user-friendly name for the policy :param description: A user-friendly description to explain the purpose of the policy :param high_assurance: Whether or not this policy is applied to sessions. :param authentication_assurance_timeout: Number of seconds within which someone must have authenticated to satisfy the policy :param required_mfa: If True, then multi-factor authentication is required. This can only be set to True for high-assurance policies. The default is False. :param domain_constraints_include: A list of domains that can satisfy the policy :param domain_constraints_exclude: A list of domains that cannot satisfy the policy .. note:: ``project_id``, ``display_name``, and ``description`` are all required arguments, although they are not declared as required in the function signature. This is due to a backwards compatible behavior with earlier versions of globus-sdk, and will be changed in a future release which removes the compatible behavior. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.create_policy( ... project_id="da84e531-1afb-43cb-8c87-135ab580516a", ... high_assurance=True, ... authentication_assurance_timeout=35, ... required_mfa=True, ... display_name="No RedLight domain Policy", ... description="Disallow access from @redlight.org", ... domain_constraints_exclude=["redlight.org"], ... ) >>> policy_id = r["policy"]["id"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_policy .. tab-item:: API Info ``POST /v2/api/policies`` .. extdoclink:: Create Policy :ref: auth/reference/#create_policy """ body: dict[str, t.Any] = { "project_id": project_id, "high_assurance": high_assurance, "authentication_assurance_timeout": authentication_assurance_timeout, "required_mfa": required_mfa, "display_name": display_name, "description": description, "domain_constraints_include": strseq_listify(domain_constraints_include), "domain_constraints_exclude": strseq_listify(domain_constraints_exclude), } return self.post("/v2/api/policies", data={"policy": body}) def update_policy( self, policy_id: uuid.UUID | str, *, project_id: uuid.UUID | str | MissingType = MISSING, authentication_assurance_timeout: int | MissingType = MISSING, required_mfa: bool | MissingType = MISSING, display_name: str | MissingType = MISSING, description: str | MissingType = MISSING, domain_constraints_include: t.Iterable[str] | None | MissingType = MISSING, domain_constraints_exclude: t.Iterable[str] | None | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Update a policy. Requires the ``manage_projects`` scope. :param policy_id: ID of the policy to update :param project_id: ID of the project for the new policy :param authentication_assurance_timeout: Number of seconds within which someone must have authenticated to satisfy the policy :param required_mfa: If True, then multi-factor authentication is required. This can only be set to True for high-assurance policies. The default is False. :param display_name: A user-friendly name for the policy :param description: A user-friendly description to explain the purpose of the policy :param domain_constraints_include: A list of domains that can satisfy the policy :param domain_constraints_exclude: A list of domains that can not satisfy the policy .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> policy_id = ... >>> r = ac.update_policy(scope_id, display_name="Greenlight Policy") .. tab-item:: Example Response Data .. expandtestfixture:: auth.update_policy .. tab-item:: API Info ``POST /v2/api/policies/{policy_id}`` .. extdoclink:: Update Policy :ref: auth/reference/#update_policy """ body: dict[str, t.Any] = { "authentication_assurance_timeout": authentication_assurance_timeout, "required_mfa": required_mfa, "display_name": display_name, "description": description, "domain_constraints_include": strseq_listify(domain_constraints_include), "domain_constraints_exclude": strseq_listify(domain_constraints_exclude), "project_id": project_id, } return self.put(f"/v2/api/policies/{policy_id}", data={"policy": body}) def delete_policy(self, policy_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Delete a policy. Requires the ``manage_projects`` scope. :param policy_id: The ID of the policy to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> policy_id = ... >>> r = ac.delete_policy(policy_id) .. tab-item:: Example Response Data .. expandtestfixture:: auth.delete_policy .. tab-item:: API Info ``DELETE /v2/api/policies/{policy_id}`` .. extdoclink:: Delete Policy :ref: auth/reference/#delete_policy """ return self.delete(f"/v2/api/policies/{policy_id}") def get_client( self, *, client_id: uuid.UUID | str | MissingType = MISSING, fqdn: str | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Look up a client by ``client_id`` or (exclusive) by ``fqdn``. Requires the ``manage_projects`` scope. :param client_id: The ID of the client to look up :param fqdn: The fully-qualified domain name of the client to look up .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> # by client_id >>> r = ac.get_client(client_id="6336437e-37e8-4559-82a8-674390c1fd2e") >>> r.data { 'client': { 'required_idp': None, 'name': 'Great client of FOO', 'redirect_uris': [], 'links': { 'privacy_policy': None, 'terms_and_conditions': None }, 'scopes': [], 'grant_types': [ 'authorization_code', 'client_credentials', 'refresh_token' ], 'id': '6336437e-37e8-4559-82a8-674390c1fd2e', 'prompt_for_named_grant': False, 'fqdns': ['globus.org'], 'project': 'da84e531-1afb-43cb-8c87-135ab580516a', 'client_type': 'client_identity', 'visibility': 'private', 'parent_client': None, 'userinfo_from_effective_identity': True, 'preselect_idp': None, 'public_client': False } } >>> # by fqdn >>> fqdn = ... >>> r = ac.get_client(fqdn=fqdn) .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_client .. tab-item:: API Info ``GET /v2/api/clients/{client_id}`` ``GET /v2/api/clients?fqdn={fqdn}`` .. extdoclink:: Get Clients :ref: auth/reference/#get_clients """ # noqa: E501 if client_id is not MISSING and fqdn is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_client does not take both " "'client_id' and 'fqdn'. These are mutually exclusive." ) if client_id is MISSING and fqdn is MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_client requires either 'client_id' or 'fqdn'." ) if client_id is not MISSING: return self.get(f"/v2/api/clients/{client_id}") return self.get("/v2/api/clients", query_params={"fqdn": fqdn}) def get_clients(self) -> IterableResponse: """ Look up clients in projects on which the authenticated user is an admin. Requires the ``manage_projects`` scope. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_clients() >>> r.data { 'clients': [ { 'required_idp': None, 'name': 'Great client of FOO', 'redirect_uris': [], 'links': {'privacy_policy': None, 'terms_and_conditions': None}, 'scopes': [], 'grant_types': ['authorization_code', 'client_credentials', 'refresh_token'], 'id': 'b6001d11-8765-49d3-a503-ba323fc74eee', 'prompt_for_named_grant': False, 'fqdns': ['foo.net'], 'project': 'da84e531-1afb-43cb-8c87-135ab580516a', 'client_type': 'client_identity', 'visibility': 'private', 'parent_client': None, 'userinfo_from_effective_identity': True, 'preselect_idp': None, 'public_client': False, }, { 'required_idp': None, 'name': 'Lessor client of BAR', 'redirect_uris': [], 'links': {'privacy_policy': None, 'terms_and_conditions': None}, 'scopes': [], 'grant_types': ['authorization_code', 'client_credentials', 'refresh_token'], 'id': 'b87f7415-ddf9-4868-8e55-d10c065f733d', 'prompt_for_named_grant': False, 'fqdns': ['bar.org'], 'project': 'da84e531-1afb-43cb-8c87-135ab580516a', 'client_type': 'client_identity', 'visibility': 'private', 'parent_client': None, 'userinfo_from_effective_identity': True, 'preselect_idp': None, 'public_client': False, } ] } .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_clients .. tab-item:: API Info ``GET /v2/api/clients`` .. extdoclink:: Get Clients :ref: auth/reference/#get_clients """ # noqa: E501 return GetClientsResponse(self.get("/v2/api/clients")) def create_client( self, name: str, project: uuid.UUID | str, *, public_client: bool | MissingType = MISSING, client_type: ( MissingType | t.Literal[ "client_identity", "confidential_client", "globus_connect_server", "public_installed_client", "hybrid_confidential_client_resource_server", "resource_server", ] ) = MISSING, visibility: MissingType | t.Literal["public", "private"] = MISSING, redirect_uris: t.Iterable[str] | MissingType = MISSING, terms_and_conditions: str | MissingType = MISSING, privacy_policy: str | MissingType = MISSING, required_idp: uuid.UUID | str | MissingType = MISSING, preselect_idp: uuid.UUID | str | MissingType = MISSING, additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Create a new client. Requires the ``manage_projects`` scope. :param name: The display name shown to users on consents. May not contain linebreaks. :param project: ID representing the project this client belongs to. :param public_client: This is used to infer which OAuth grant_types the client will be able to use. Should be false if the client is capable of keeping secret credentials (such as clients running on a server) and true if it is not (such as native apps). After creation this value is immutable. This option is mutually exclusive with ``client_type``, exactly one must be given. :param client_type: Defines the type of client that will be created. This option is mutually exclusive with ``public_client``, exactly one must be given. .. dropdown:: Values for ``client_type`` .. list-table:: * - ``"confidential_client"`` - Applications that are OAuth confidential clients, and can manage a client secret and requests for user consent. * - ``"public_installed_client"`` - Applications that are OAuth public clients or native applications that are distributed to users, and thus cannot manage a client secret. * - ``"client_identity"`` - Applications that authenticate and act as the application itself. These applications are used for automation and as service or community accounts, and do NOT act on behalf of other users. Also known as a "Service Account". * - ``"resource_server"`` - An API (OAuth resource server) that uses Globus Auth tokens for authentication. Users accessing the service login via Globus and consent for the client to use your API. * - ``"globus_connect_server"`` - Create a client that will service as a Globus Connect Server endpoint. * - ``"hybrid_confidential_client_resource_server"`` - A client which can use any behavior with Globus Auth - an application (confidential or public client), service account, or API. :param visibility: If set to "public", any authenticated entity can view it. When set to "private", only entities in the same project as the client can view it. :param redirect_uris: list of URIs that may be used in OAuth authorization flows. :param terms_and_conditions: URL of client's terms and conditions. :param privacy_policy: URL of client's privacy policy. :param required_idp: In order to use this client a user must have an identity from this IdP in their identity set. :param preselect_idp: This pre-selects the given IdP on the Globus Auth login page if the user is not already authenticated. :param additional_fields: Any additional parameters to be passed through. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> project = ... >>> r = ac.create_client( ... "My client", ... True, ... project, ... True, ... ) >>> client_id = r["client"]["id"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_client .. tab-item:: API Info ``POST /v2/api/clients`` .. extdoclink:: Create Client :ref: auth/reference/#create_client """ # Must specify exactly one of public_client or client_type if public_client is not MISSING and client_type is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client does not take both " "'public_client' and 'client_type'. These are mutually exclusive." ) if public_client is MISSING and client_type is MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client requires either 'public_client' or " "'client_type'." ) body: dict[str, t.Any] = { "name": name, "project": project, "visibility": visibility, "redirect_uris": redirect_uris, "required_idp": required_idp, "preselect_idp": preselect_idp, "public_client": public_client, "client_type": client_type, } # terms_and_conditions and privacy_policy must both be set or unset if bool(terms_and_conditions) ^ bool(privacy_policy): raise exc.GlobusSDKUsageError( "terms_and_conditions and privacy_policy must both be set or unset" ) links: dict[str, str | MissingType] = { "terms_and_conditions": terms_and_conditions, "privacy_policy": privacy_policy, } if terms_and_conditions or privacy_policy: body["links"] = links if additional_fields is not MISSING: body.update(additional_fields) return self.post("/v2/api/clients", data={"client": body}) def update_client( self, client_id: uuid.UUID | str, *, name: str | MissingType = MISSING, visibility: MissingType | t.Literal["public", "private"] = MISSING, redirect_uris: t.Iterable[str] | MissingType = MISSING, terms_and_conditions: str | None | MissingType = MISSING, privacy_policy: str | None | MissingType = MISSING, required_idp: uuid.UUID | str | None | MissingType = MISSING, preselect_idp: uuid.UUID | str | None | MissingType = MISSING, additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Update a client. Requires the ``manage_projects`` scope. :param client_id: ID of the client to update :param name: The display name shown to users on consents. May not contain linebreaks. :param visibility: If set to "public", any authenticated entity can view it. When set to "private", only entities in the same project as the client can view it. :param redirect_uris: list of URIs that may be used in OAuth authorization flows. :param terms_and_conditions: URL of client's terms and conditions. :param privacy_policy: URL of client's privacy policy. :param required_idp: In order to use this client a user must have an identity from this IdP in their identity set. :param preselect_idp: This pre-selects the given IdP on the Globus Auth login page if the user is not already authenticated. :param additional_fields: Any additional parameters to be passed through. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> client_id = ... >>> r = ac.update_client(client_id, name="Foo Utility") .. tab-item:: Example Response Data .. expandtestfixture:: auth.update_client .. tab-item:: API Info ``POST /v2/api/clients/{client_id}`` .. extdoclink:: Update Client :ref: auth/reference/#update_client """ body: dict[str, t.Any] = { "name": name, "visibility": visibility, "redirect_uris": redirect_uris, "required_idp": required_idp, "preselect_idp": preselect_idp, } # terms_and_conditions and privacy_policy must both be set or unset, and if one # is set to `None` they both must be set to `None` # note the subtle differences between this logic for "update" and the matching # logic for "create" # "create" does not need to handle `None` as a distinct and meaningful value if type(terms_and_conditions) is not type(privacy_policy): raise exc.GlobusSDKUsageError( "terms_and_conditions and privacy_policy must both be set or unset" ) links: dict[str, str | None | MissingType] = { "terms_and_conditions": terms_and_conditions, "privacy_policy": privacy_policy, } if terms_and_conditions is not MISSING or privacy_policy is not MISSING: body["links"] = links if additional_fields is not MISSING: body.update(additional_fields) return self.put(f"/v2/api/clients/{client_id}", data={"client": body}) def delete_client(self, client_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Delete a client. Requires the ``manage_projects`` scope. :param client_id: The ID of the client to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> client_id = ... >>> r = ac.delete_policy(client_id) .. tab-item:: Example Response Data .. expandtestfixture:: auth.delete_client .. tab-item:: API Info ``DELETE /v2/api/clients/{client_id}`` .. extdoclink:: Delete Client :ref: auth/reference/#delete_client """ return self.delete(f"/v2/api/clients/{client_id}") def get_client_credentials(self, client_id: uuid.UUID | str) -> IterableResponse: """ Look up client credentials by ``client_id``. Requires the ``manage_projects`` scope. :param client_id: The ID of the client that owns the credentials .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_credentials("6336437e-37e8-4559-82a8-674390c1fd2e") >>> r.data { 'credentials': [ 'name': 'foo', 'id': 'cf88318e-b2dd-43fd-8ea5-2086fc69ffac', 'created': '2023-10-21T22:46:15.845937+00:00', 'client': '6336437e-37e8-4559-82a8-674390c1fd2e', 'secret': None, ] } .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_client_credentials .. tab-item:: API Info ``GET /v2/api/clients/{client_id}/credentials`` .. extdoclink:: Get Client Credentials :ref: auth/reference/#get_client_credentials """ # noqa: E501 return GetClientCredentialsResponse( self.get(f"/v2/api/clients/{client_id}/credentials") ) def create_client_credential( self, client_id: uuid.UUID | str, name: str, ) -> GlobusHTTPResponse: """ Create a new client credential. Requires the ``manage_projects`` scope. :param client_id: ID for the client :param name: The display name of the new credential. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> client_id = ... >>> name = ... >>> r = ac.create_client_credential( ... "25afc56d-02af-4175-8c90-9941ebb623dd", ... "New Credentials", ... ) >>> r.data { 'name': 'New Credentials', 'id': '3a53cb4d-edd6-4ae3-900e-25b38b5fce02', 'created': '2023-10-21T22:46:15.845937+00:00', 'client': '25afc56d-02af-4175-8c90-9941ebb623dd', 'secret': 'abc123', } .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_client_credential .. tab-item:: API Info ``POST /v2/api/clients/{client_id}/credentials`` .. extdoclink:: Create Client Credentials :ref: auth/reference/#create_client_credential """ return self.post( f"/v2/api/clients/{client_id}/credentials", data={"credential": {"name": name}}, ) def delete_client_credential( self, client_id: uuid.UUID | str, credential_id: uuid.UUID | str, ) -> GlobusHTTPResponse: """ Delete a credential. Requires the ``manage_projects`` scope. :param client_id: The ID of the client that owns the credential to delete :param credential_id: The ID of the credential to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> client_id = ... >>> credential_id = ... >>> r = ac.delete_policy(client_id, credential_id) .. tab-item:: Example Response Data .. expandtestfixture:: auth.delete_client_credential .. tab-item:: API Info ``DELETE /v2/api/clients/{client_id}/credentials/{credential_id}`` .. extdoclink:: Delete Credential :ref: auth/reference/#delete_client_credentials """ return self.delete(f"/v2/api/clients/{client_id}/credentials/{credential_id}") def get_scope(self, scope_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Look up a scope by ``scope_id``. Requires the ``manage_projects`` scope. :param scope_id: The ID of the scope to look up .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> r = ac.get_scope(scope_id="6336437e-37e8-4559-82a8-674390c1fd2e") >>> r.data { 'scope': { 'scope_string': 'https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manager', 'allows_refresh_token': False, 'id': '87cf7b34-e1e1-4805-a8d5-51ab59fe6000', 'advertised': False, 'required_domains': [], 'name': 'Client manage scope', 'description': 'Manage configuration of this client', 'client': '3f33d83f-ec0a-4190-887d-0622e7c4ee9a', 'dependent_scopes': [], } } .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_scope .. tab-item:: API Info ``GET /v2/api/scopes/{scope_id}`` .. extdoclink:: Get Scopes :ref: auth/reference/#get_scopes """ # noqa: E501 return self.get(f"/v2/api/scopes/{scope_id}") def get_scopes( self, *, scope_strings: t.Iterable[str] | str | MissingType = MISSING, ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType = MISSING, query_params: dict[str, t.Any] | MissingType = MISSING, ) -> IterableResponse: """ Look up scopes in projects on which the authenticated user is an admin. The scopes response can be filted by ``scope_strings`` or (exclusive) ``ids``. Requires the ``manage_projects`` scope. :param scope_strings: The scope_strings of the scopes to look up :param ids: The ID of the scopes to look up :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> # get all scopes >>> r = ac.get_scopes() >>> r.data { 'scopes': [ { 'scope_string': 'https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manage', 'allows_refresh_token': False, 'id': '70147193-f88a-4da9-9d6e-677c15e790e5', 'advertised': False, 'required_domains': [], 'name': 'Client manage scope', 'description': 'Manage configuration of this client', 'client': '3f33d83f-ec0a-4190-887d-0622e7c4ee9a', 'dependent_scopes': [], }, { 'scope_string': 'https://auth.globus.org/scopes/dfc9a6d3-3373-4a6d-b0a1-b7026d1559d6/view', 'allows_refresh_token': False, 'id': '3793042a-203c-4e86-8dfe-17d407d0bb5f', 'advertised': False, 'required_domains': [], 'name': 'Client view scope', 'description': 'View configuration of this client', 'client': 'dfc9a6d3-3373-4a6d-b0a1-b7026d1559d6', 'dependent_scopes': [], } ] } >>> # by all scope ids >>> scope_ids = ... >>> r = ac.get_scopes(ids=scopes_ides) >>> # by all scope strings >>> scope_strings = ... >>> r = ac.get_scopes(scope_strings=scope_strings) .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_scopes .. tab-item:: API Info ``GET /v2/api/scopes`` ``GET /v2/api/scopes?ids=...`` ``GET /v2/api/scopes?scope_strings=...`` .. extdoclink:: Get Scopes :ref: auth/reference/#get_scopes """ # noqa: E501 if scope_strings is not MISSING and ids is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_scopes does not take both " "'scopes_strings' and 'ids'. These are mutually exclusive." ) if query_params is MISSING: query_params = {} if scope_strings is not MISSING: query_params["scope_strings"] = commajoin(scope_strings) if ids is not MISSING: query_params["ids"] = commajoin(ids) return GetScopesResponse(self.get("/v2/api/scopes", query_params=query_params)) def create_scope( self, client_id: uuid.UUID | str, name: str, description: str, scope_suffix: str, *, required_domains: t.Iterable[str] | MissingType = MISSING, dependent_scopes: t.Iterable[DependentScopeSpec] | MissingType = MISSING, advertised: bool | MissingType = MISSING, allows_refresh_token: bool | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Create a new scope. Requires the ``manage_projects`` scope. :param client_id: ID of the client for the new scope :param name: A display name used to display consents to users, along with description :param description: A description used to display consents to users, along with name :param scope_suffix: String consisting of lowercase letters, number, and underscores. This will be the final part of the scope_string :param required_domains: Domains the user must have linked identities in in order to make use of the scope :param dependent_scopes: Scopes included in the consent for this new scope :param advertised: If True, scope is visible to anyone regardless of client visibility, otherwise, scope visibility is based on client visibility. :param allows_refresh_token: Whether or not the scope allows refresh tokens to be issued. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> client_id = ... >>> r = ac.create_scope( ... client_id, ... "Client Management", ... "Manage client configuration", ... "manage", ... ) >>> scope_id = r["scope"]["id"] .. tab-item:: Example Response Data .. expandtestfixture:: auth.create_scope .. tab-item:: API Info ``POST /v2/api/clients/{client_id}/scopes`` .. extdoclink:: Create Scope :ref: auth/reference/#create_scope """ body: dict[str, t.Any] = { "name": name, "description": description, "scope_suffix": scope_suffix, "advertised": advertised, "allows_refresh_token": allows_refresh_token, "required_domains": required_domains, "dependent_scopes": dependent_scopes, } return self.post(f"/v2/api/clients/{client_id}/scopes", data={"scope": body}) def update_scope( self, scope_id: uuid.UUID | str, *, name: str | MissingType = MISSING, description: str | MissingType = MISSING, scope_suffix: str | MissingType = MISSING, required_domains: t.Iterable[str] | MissingType = MISSING, dependent_scopes: t.Iterable[DependentScopeSpec] | MissingType = MISSING, advertised: bool | MissingType = MISSING, allows_refresh_token: bool | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Update a scope. Requires the ``manage_projects`` scope. :param scope_id: ID of the scope to update :param name: A display name used to display consents to users, along with description :param description: A description used to display consents to users, along with name :param scope_suffix: String consisting of lowercase letters, number, and underscores. This will be the final part of the scope_string :param required_domains: Domains the user must have linked identities in in order to make use of the scope :param dependent_scopes: Scopes included in the consent for this new scope :param advertised: If True, scope is visible to anyone regardless of client visibility, otherwise, scope visibility is based on client visibility. :param allows_refresh_token: Whether or not the scope allows refresh tokens to be issued. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> scope_id = ... >>> r = ac.update_scope(scope_id, scope_suffix="manage") .. tab-item:: Example Response Data .. expandtestfixture:: auth.update_scope .. tab-item:: API Info ``POST /v2/api/scopes/{scope_id}`` .. extdoclink:: Update Scope :ref: auth/reference/#update_scope """ body: dict[str, t.Any] = { "name": name, "description": description, "scope_suffix": scope_suffix, "advertised": advertised, "allows_refresh_token": allows_refresh_token, "required_domains": required_domains, "dependent_scopes": dependent_scopes, } return self.put(f"/v2/api/scopes/{scope_id}", data={"scope": body}) def delete_scope(self, scope_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Delete a scope. Requires the ``manage_projects`` scope. :param scope_id: The ID of the scope to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> scope_id = ... >>> r = ac.delete_scope(scope_id) .. tab-item:: Example Response Data .. expandtestfixture:: auth.delete_scope .. tab-item:: API Info ``DELETE /v2/api/scopes/{scope_id}`` .. extdoclink:: Delete Scopes :ref: auth/reference/#delete_scope """ return self.delete(f"/v2/api/scopes/{scope_id}") def get_consents( self, identity_id: uuid.UUID | str, *, # pylint: disable=redefined-builtin all: bool = False, ) -> GetConsentsResponse: """ Look up consents for a user. If requesting "all" consents, the view_consents scope is required. :param identity_id: The ID of the identity to look up consents for :param all: If true, return all consents, including those that have been issued to other clients. If false, return only consents rooted at this client id for the requested identity. Most clients should pass False. .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) >>> identity_id = ... >>> forest = ac.get_consents(identity_id).to_forest() .. tab-item:: Example Response Data .. expandtestfixture:: auth.get_consents .. tab-item:: API Info ``GET /v2/api/identities/{identity_id}/consents`` """ return GetConsentsResponse( self.get( f"/v2/api/identities/{identity_id}/consents", query_params={"all": all} ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/data.py000066400000000000000000000016721513221403200257160ustar00rootroot00000000000000from __future__ import annotations import uuid from globus_sdk._payload import GlobusPayload class DependentScopeSpec(GlobusPayload): """ Utility class for creating dependent scope values as parameters to :meth:`AuthClient.create_scope ` and :meth:`AuthClient.update_scope `. :param scope: The ID of the dependent scope :param optional: Whether or not the user can decline this specific scope without declining the whole consent. :param requires_refresh_token: Whether or not the dependency requires a refresh token. """ def __init__( self, scope: uuid.UUID | str, optional: bool, requires_refresh_token: bool, ) -> None: super().__init__() self["scope"] = str(scope) self["optional"] = optional self["requires_refresh_token"] = requires_refresh_token globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/errors.py000066400000000000000000000023431513221403200263150ustar00rootroot00000000000000from __future__ import annotations import logging from globus_sdk import exc log = logging.getLogger(__name__) class AuthAPIError(exc.GlobusAPIError): """ Error class for the API components of Globus Auth. """ def _post_parse_hook(self) -> bool: # if there was only one error ID set in the response, use that as the request_id # this allows for some errors to omit the 'id': # # errors=[{"id": "foo"}, {}] # # or for all errors to have the same 'id': # # errors=[{"id": "foo"}, {"id": "foo"}] # # but not for errors to have mixed/different 'id' values: # # errors=[{"id": "foo"}, {"id": "bar"}] # step 1, collect error IDs error_ids = {suberror.get("id") for suberror in self.errors} # step 2, remove `None` from any sub-errors which did not set error_id or # explicitly set it to null error_ids.discard(None) # step 3, check if there was exactly one error ID and it was a string if len(error_ids) == 1: maybe_error_id = error_ids.pop() if isinstance(maybe_error_id, str): self.request_id = maybe_error_id return True globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/flow_managers/000077500000000000000000000000001513221403200272515ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/flow_managers/__init__.py000066400000000000000000000004301513221403200313570ustar00rootroot00000000000000from .authorization_code import GlobusAuthorizationCodeFlowManager from .base import GlobusOAuthFlowManager from .native_app import GlobusNativeAppFlowManager __all__ = ( "GlobusAuthorizationCodeFlowManager", "GlobusOAuthFlowManager", "GlobusNativeAppFlowManager", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/flow_managers/authorization_code.py000066400000000000000000000122361513221403200335210ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import urllib.parse from globus_sdk._internal.utils import slash_join from globus_sdk._missing import filter_missing from globus_sdk.scopes import Scope, ScopeParser from ..response import OAuthAuthorizationCodeResponse from .base import GlobusOAuthFlowManager if t.TYPE_CHECKING: import globus_sdk log = logging.getLogger(__name__) class GlobusAuthorizationCodeFlowManager(GlobusOAuthFlowManager): """ This is the OAuth flow designated for use by Clients wishing to authenticate users in a web application backed by a server-side component (e.g. an API). The key constraint is that there is a server-side system that can keep a Client Secret without exposing it to the web client. For example, a Django application can rely on the webserver to own the secret, so long as it doesn't embed it in any of the pages it generates. The application sends the user to get a temporary credential (an ``auth_code``) associated with its Client ID. It then exchanges that temporary credential for a token, protecting the exchange with its Client Secret (to prove that it really is the application that the user just authorized). :param auth_client: The client used to extract default values for the flow, and also to make calls to the Auth service. :param redirect_uri: The page that users should be directed to after authenticating at the authorize URL. :param requested_scopes: The scopes on the token(s) being requested. :param state: This string allows an application to pass information back to itself in the course of the OAuth flow. Because the user will navigate away from the application to complete the flow, this parameter lets the app pass an arbitrary string from the starting page to the ``redirect_uri`` :param refresh_tokens: When True, request refresh tokens in addition to access tokens. [Default: ``False``] """ def __init__( self, auth_client: globus_sdk.ConfidentialAppAuthClient, redirect_uri: str, requested_scopes: str | Scope | t.Iterable[str | Scope], state: str = "_default", refresh_tokens: bool = False, ) -> None: # convert a scope object or iterable to string immediately on load # and default to the default requested scopes self.requested_scopes: str = ScopeParser.serialize(requested_scopes) # store the remaining parameters directly, with no transformation self.client_id = auth_client.client_id self.auth_client = auth_client self.redirect_uri = redirect_uri self.refresh_tokens = refresh_tokens self.state = state log.debug( "Starting Authorization Code Flow with params: " f"auth_client.client_id={auth_client.client_id} , " f"redirect_uri={redirect_uri} , " f"refresh_tokens={refresh_tokens} , " f"state={state} , " f"requested_scopes={self.requested_scopes}" ) def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str: """ Start a Authorization Code flow by getting the authorization URL to which users should be sent. :param query_params: Additional parameters to include in the authorize URL. Primarily for internal use The returned URL string is encoded to be suitable to display to users in a link or to copy into their browser. Users will be redirected either to your provided ``redirect_uri`` or to the default location, with the ``auth_code`` embedded in a query parameter. """ authorize_base_url = slash_join( self.auth_client.base_url, "/v2/oauth2/authorize" ) log.debug(f"Building authorization URI. Base URL: {authorize_base_url}") log.debug(f"query_params={query_params}") params = { "client_id": self.client_id, "redirect_uri": self.redirect_uri, "scope": self.requested_scopes, "state": self.state, "response_type": "code", "access_type": (self.refresh_tokens and "offline") or "online", **(query_params or {}), } params = filter_missing(params) encoded_params = urllib.parse.urlencode(params) return f"{authorize_base_url}?{encoded_params}" def exchange_code_for_tokens( self, auth_code: str ) -> OAuthAuthorizationCodeResponse: """ The second step of the Authorization Code flow, exchange an authorization code for access tokens (and refresh tokens if specified) :param auth_code: The short-lived code to exchange for tokens """ log.debug( "Performing Authorization Code auth_code exchange. " "Sending client_id and client_secret" ) return self.auth_client.oauth2_token( { "grant_type": "authorization_code", "code": auth_code.encode("utf-8"), "redirect_uri": self.redirect_uri, }, response_class=OAuthAuthorizationCodeResponse, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/flow_managers/base.py000066400000000000000000000045231513221403200305410ustar00rootroot00000000000000from __future__ import annotations import abc import typing as t from ..response import OAuthAuthorizationCodeResponse class GlobusOAuthFlowManager(abc.ABC): """ An abstract class definition that defines the interface for the Flow Managers for Globus Auth. Flow Managers are really just bundles of parameters to Globus Auth's OAuth2 mechanisms, along with some useful utility methods. Primarily they can be used as a simple way of tracking small amounts of state in your application as it leverages Globus Auth for authentication. For sophisticated use cases, the provided Flow Managers will *NOT* be sufficient, but you should consider the provided objects a model. This way of managing OAuth2 flows is inspired by `oauth2client `_. However, because ``oauth2client`` has an uncertain future (as of 2016-08-31), and we would have to wrap it in order to provide a clean API surface anyway, we implement our own set of Flow objects. """ @abc.abstractmethod def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str: """ This method consumes no arguments or keyword arguments, and produces a string URL for the Authorize Step of a 3-legged OAuth2 flow. Most typically, this is the first step of the flow, and the user may be redirected to the URL or provided with a link. The authorize_url may be (usually is) parameterized over attributes of the specific flow manager instance which is generating it. :param query_params: Any additional parameters to be passed through as query params on the URL. """ @abc.abstractmethod def exchange_code_for_tokens( self, auth_code: str ) -> OAuthAuthorizationCodeResponse: """ This method takes an auth_code and produces a response object containing one or more tokens. Most typically, this is the second step of the flow, and consumes the auth_code that was sent to a redirect URI used in the authorize step. The exchange process may be parameterized over attributes of the specific flow manager instance which is generating it. :param auth_code: The authorization code which was produced from the authorization flow """ globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/flow_managers/native_app.py000066400000000000000000000205151513221403200317540ustar00rootroot00000000000000from __future__ import annotations import base64 import hashlib import logging import os import re import typing as t import urllib.parse from globus_sdk._internal.utils import slash_join from globus_sdk._missing import MISSING, MissingType, filter_missing from globus_sdk.exc import GlobusSDKUsageError from globus_sdk.scopes import Scope, ScopeParser from ..response import OAuthAuthorizationCodeResponse from .base import GlobusOAuthFlowManager if t.TYPE_CHECKING: import globus_sdk log = logging.getLogger(__name__) def _make_native_app_challenge( verifier: str | MissingType = MISSING, ) -> tuple[str, str]: """ Produce a challenge and verifier for the Native App flow. The verifier is an unhashed secret, and the challenge is a hashed version of it. The challenge is sent at the start of the flow, and the secret is sent at the end, proving that the same client that started the flow is continuing it. Hashing is always done with simple SHA256. See RFC 7636 for details. :param verifier: The code verifier string used to construct the code challenge. Must be at least 43 characters long and not longer than 128 characters. Must only contain the following characters: [a-zA-Z0-9~_.-]. """ if isinstance(verifier, str): if not 43 <= len(verifier) <= 128: raise GlobusSDKUsageError( f"verifier must be 43-128 characters long: {len(verifier)}" ) if bool(re.search(r"[^a-zA-Z0-9~_.-]", verifier)): raise GlobusSDKUsageError("verifier contained invalid characters") code_verifier: str = verifier else: log.debug( "Autogenerating verifier secret. On low-entropy systems " "this may be insecure" ) code_verifier = ( base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") ) # hash it, pull out a digest hashed_verifier = hashlib.sha256(code_verifier.encode("utf-8")).digest() # urlsafe base64 encode that hash and strip the padding code_challenge = ( base64.urlsafe_b64encode(hashed_verifier).decode("utf-8").rstrip("=") ) # return the verifier and the encoded hash return code_verifier, code_challenge class GlobusNativeAppFlowManager(GlobusOAuthFlowManager): """ This is the OAuth flow designated for use by clients wishing to authenticate users in the absence of a Client Secret. Because these applications run "natively" in the user's environment, they cannot protect a secret. Instead, a temporary secret is generated solely for this authentication attempt. :param auth_client: The client object on which this flow is based. It is used to extract default values for the flow, and also to make calls to the Auth service. :param requested_scopes: The scopes on the token(s) being requested. :param redirect_uri: The page that users should be directed to after authenticating at the authorize URL. Defaults to 'https://auth.globus.org/v2/web/auth-code', which displays the resulting ``auth_code`` for users to copy-paste back into your application (and thereby be passed back to the ``GlobusNativeAppFlowManager``) :param state: The ``redirect_uri`` page will have this included in a query parameter, so you can use it to pass information to that page if you use a custom page. It defaults to the string '_default' :param verifier: A secret used for the Native App flow. It will by default be a freshly generated random string, known only to this ``GlobusNativeAppFlowManager`` instance :param refresh_tokens: When True, request refresh tokens in addition to access tokens. [Default: ``False``] :param prefill_named_grant: Prefill the named grant label on the consent page """ def __init__( self, auth_client: globus_sdk.NativeAppAuthClient, requested_scopes: str | Scope | t.Iterable[str | Scope], redirect_uri: str | MissingType = MISSING, state: str = "_default", verifier: str | MissingType = MISSING, refresh_tokens: bool = False, prefill_named_grant: str | MissingType = MISSING, ) -> None: self.auth_client = auth_client # set client_id, then check for validity self.client_id = auth_client.client_id if not self.client_id: log.error( "Invalid auth_client ID to start Native App Flow: {}".format( self.client_id ) ) raise GlobusSDKUsageError( f'Invalid value for client_id. Got "{self.client_id}"' ) # convert scopes iterable to string immediately on load self.requested_scopes = ScopeParser.serialize(requested_scopes) # default to `/v2/web/auth-code` on whatever environment we're looking # at -- most typically it will be `https://auth.globus.org/` self.redirect_uri = redirect_uri or ( slash_join(auth_client.base_url, "/v2/web/auth-code") ) # make a challenge and secret to keep # if the verifier is provided, it will just be passed back to us, and # if not, one will be generated self.verifier, self.challenge = _make_native_app_challenge(verifier) # store the remaining parameters directly, with no transformation self.refresh_tokens = refresh_tokens self.state = state self.prefill_named_grant = prefill_named_grant log.debug( "Starting Native App Flow with params: " f"auth_client.client_id={auth_client.client_id} , " f"redirect_uri={self.redirect_uri} , " f"refresh_tokens={refresh_tokens} , " f"state={state} , " f"requested_scopes={self.requested_scopes} , " f"verifier=,challenge={self.challenge}" ) if prefill_named_grant is not MISSING: log.debug(f"prefill_named_grant={self.prefill_named_grant}") def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str: """ Start a Native App flow by getting the authorization URL to which users should be sent. :param query_params: Additional query parameters to include in the authorize URL. Primarily for internal use The returned URL string is encoded to be suitable to display to users in a link or to copy into their browser. Users will be redirected either to your provided ``redirect_uri`` or to the default location, with the ``auth_code`` embedded in a query parameter. """ authorize_base_url = slash_join( self.auth_client.base_url, "/v2/oauth2/authorize" ) log.debug(f"Building authorization URI. Base URL: {authorize_base_url}") log.debug(f"query_params={query_params}") params = { "client_id": self.client_id, "redirect_uri": self.redirect_uri, "scope": self.requested_scopes, "state": self.state, "response_type": "code", "code_challenge": self.challenge, "code_challenge_method": "S256", "access_type": (self.refresh_tokens and "offline") or "online", "prefill_named_grant": self.prefill_named_grant, **(query_params or {}), } params = filter_missing(params) encoded_params = urllib.parse.urlencode(params) return f"{authorize_base_url}?{encoded_params}" def exchange_code_for_tokens( self, auth_code: str ) -> OAuthAuthorizationCodeResponse: """ The second step of the Native App flow, exchange an authorization code for access tokens (and refresh tokens if specified). :param auth_code: The short-lived code to exchange for tokens """ log.debug( "Performing Native App auth_code exchange. " "Sending verifier and client_id" ) return self.auth_client.oauth2_token( { "client_id": self.client_id, "grant_type": "authorization_code", "code": auth_code.encode("utf-8"), "code_verifier": self.verifier, "redirect_uri": self.redirect_uri, }, response_class=OAuthAuthorizationCodeResponse, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/id_token_decoder.py000066400000000000000000000134461513221403200302700ustar00rootroot00000000000000from __future__ import annotations import datetime import sys import typing as t import jwt from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from globus_sdk.response import GlobusHTTPResponse from ._common import SupportsJWKMethods if t.TYPE_CHECKING: from globus_sdk import AuthLoginClient, GlobusAppConfig if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self class IDTokenDecoder: """ JWT decoder for OIDC ID tokens issued by Globus Auth. Decoding uses a client object to fetch necessary data from Globus Auth. By default, the OIDC configuration data and JWKs will be cached in an internal dict. An alternative cache can be provided on init to use an alternative storage mechanism. The ``get_jwt_audience`` and ``get_jwt_leeway`` methods supply parameters to decoding. Subclasses can override these methods to customize the decoder. :param auth_client: The client which should be used to callout to Globus Auth as needed. Any AuthClient or AuthLoginClient will work for this purpose. :param jwt_leeway: The JWT leeway to use during decoding, as a number of seconds or a timedelta. The default is 5 minutes. :param jwt_options: The ``options`` passed to the underlying JWT decode function. Defaults to an empty dict. """ def __init__( self, auth_client: SupportsJWKMethods, *, # default to 300 seconds # # valuable inputs to this number: # - expected clock drift per day (6s for a bad clock) # - Windows time sync interval (64s) # - Windows' stated goal of meeting the Kerberos 5 clock skew requirement (5m) # - ntp panic threshold (1000s of drift) # - the knowledge that VM clocks typically run slower and may skew significantly # # NTP panic should be understood as a critical error; 1000s of drift is # therefore too high for us to allow. # # 300s (5m) is therefore chosen to match the Windows desired maximum for # clock drift, and the underlying Kerberos requirement. jwt_leeway: float | datetime.timedelta = 300.0, jwt_options: dict[str, t.Any] | None = None, ) -> None: self._auth_client = auth_client self._openid_configuration: dict[str, t.Any] | None = None self._jwk: RSAPublicKey | None = None self.jwt_leeway: float | datetime.timedelta = jwt_leeway self.jwt_options: dict[str, t.Any] = ( jwt_options if jwt_options is not None else {} ) @classmethod def for_globus_app( cls, *, app_name: str, # pylint: disable=unused-argument config: GlobusAppConfig, # pylint: disable=unused-argument login_client: AuthLoginClient, ) -> Self: """ Create an ``IDTokenDecoder`` for use in a GlobusApp. :param app_name: The name supplied to the GlobusApp. :param config: The configuration supplied to the GlobusApp. :param login_client: A login client to use for instantiating an ``IDTokenDecoder``. """ return cls(login_client) def decode(self, id_token: str, /) -> dict[str, t.Any]: """ The ``decode()`` method takes an ``id_token`` as a string, and decodes it to a dictionary. This method should implicitly retrieve the OpenID configuration and JWK for Globus Auth. :param id_token: The token to decode """ audience = self.get_jwt_audience() openid_configuration = self.get_openid_configuration() jwk = self.get_jwk() signing_algos = openid_configuration["id_token_signing_alg_values_supported"] return jwt.decode( id_token, key=jwk, algorithms=signing_algos, audience=audience, options=self.jwt_options, leeway=self.jwt_leeway, ) def get_jwt_audience(self) -> str | None: """ The audience for JWT verification defaults to the client's client ID. """ return self._auth_client.client_id def store_openid_configuration( self, openid_configuration: dict[str, t.Any] | GlobusHTTPResponse ) -> None: """ Store openid_configuration data for future use in ``decode()``. :param openid_configuration: The configuration data, as fetched via :meth:`AuthClient.get_openid_configuration` """ if isinstance(openid_configuration, GlobusHTTPResponse): self._openid_configuration = openid_configuration.data else: self._openid_configuration = openid_configuration def store_jwk(self, jwk: RSAPublicKey) -> None: """ Store a JWK for future use in ``decode()``. :param jwk: The JWK, as fetched via :meth:`AuthClient.get_jwk` with ``as_pem=True``. """ self._jwk = jwk def get_openid_configuration(self) -> dict[str, t.Any]: """ Fetch the OpenID Configuration for Globus Auth, and cache the result before returning it. If a config was previously stored, return that instead. """ if self._openid_configuration is None: self._openid_configuration = ( self._auth_client.get_openid_configuration().data ) return self._openid_configuration def get_jwk(self) -> RSAPublicKey: """ Fetch the JWK for Globus Auth, and cache the result before returning it. If a key was previously stored, return that instead. """ if not self._jwk: self._jwk = self._auth_client.get_jwk( openid_configuration=self.get_openid_configuration(), as_pem=True ) return self._jwk globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/identity_map.py000066400000000000000000000215211513221403200274660ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from .client import AuthClient def is_username(val: str) -> bool: # If the value parses as a UUID, then it's an ID, not a username. # If it does not parse as such, then it must be a username. try: uuid.UUID(val) return False except ValueError: return True def split_ids_and_usernames( identity_ids: t.Iterable[str], ) -> tuple[set[str], set[str]]: ids = set() usernames = set() for val in identity_ids: if is_username(val): usernames.add(val) else: ids.add(val) return ids, usernames class IdentityMap: r""" There's a common pattern of having a large batch of Globus Auth Identities which you want to inspect. For example, you may have a list of identity IDs fetched from Access Control Lists on Globus Endpoints. In order to display these identities to an end user, you may want to resolve them to usernames. However, naively looking up the identities one-by-one is very inefficient. It's best to do batched lookups with multiple identities at once. In these cases, an ``IdentityMap`` can be used to do those batched lookups for you. An ``IdentityMap`` is a mapping-like type which converts Identity IDs and Identity Names to Identity records (dictionaries) using the Globus Auth API. .. note:: ``IdentityMap`` objects are not full Mappings in the same sense as python dicts and similar objects. By design, they only implement a small part of the Mapping protocol. The basic usage pattern is - create an ``IdentityMap`` with an AuthClient which will be used to call out to Globus Auth - seed the ``IdentityMap`` with IDs and Usernames via :py:meth:`~IdentityMap.add` (you can also do this during initialization) - retrieve identity IDs or Usernames from the map Because the map can be populated with a collection of identity IDs and Usernames prior to lookups being performed, it can improve the efficiency of these operations up to 100x over individual lookups. If you attempt to retrieve an identity which has not been previously added to the map, it will be immediately added. But adding many identities beforehand will improve performance. The ``IdentityMap`` will cache its results so that repeated lookups of the same Identity will not repeat work. It will also map identities both by ID and by Username, regardless of how they're initially looked up. .. warning:: If an Identity is not found in Globus Auth, it will trigger a KeyError when looked up. Your code must be ready to handle KeyErrors when doing a lookup. Correct usage looks something like so:: ac = globus_sdk.AuthClient(...) idmap = globus_sdk.IdentityMap( ac, ["foo@globusid.org", "bar@uchicago.edu"] ) idmap.add("baz@xsede.org") # adding by ID is also valid idmap.add("c699d42e-d274-11e5-bf75-1fc5bf53bb24") # map ID to username assert ( idmap["c699d42e-d274-11e5-bf75-1fc5bf53bb24"]["username"] == "go@globusid.org" ) # map username to ID assert ( idmap["go@globusid.org"]["id"] == "c699d42e-d274-11e5-bf75-1fc5bf53bb24" ) And simple handling of errors:: try: record = idmap["no-such-valid-id@example.org"] except KeyError: username = "NO_SUCH_IDENTITY" else: username = record["username"] or you may achieve this by using the :py:meth:`~.IdentityMap.get` method:: # internally handles the KeyError and returns the default value record = idmap.get("no-such-valid-id@example.org", None) username = record["username"] if record is not None else "NO_SUCH_IDENTITY" :param auth_client: The client object which will be used for lookups against Globus Auth :param identity_ids: A list or other iterable of usernames or identity IDs (potentially mixed together) which will be used to seed the ``IdentityMap`` 's tracking of unresolved Identities. :param id_batch_size: A non-default batch size to use when communicating with Globus Auth. Leaving this set to the default is strongly recommended. :param cache: A dict or other mapping object which will be used to cache results. The default is that results are cached once per IdentityMap object. If you want multiple IdentityMaps to share data, explicitly pass the same ``cache`` to both. .. automethodlist:: globus_sdk.IdentityMap :include_methods: __getitem__,__delitem__ """ # noqa _default_id_batch_size = 100 def __init__( self, auth_client: AuthClient, identity_ids: t.Iterable[str] | None = None, *, id_batch_size: int | None = None, cache: None | t.MutableMapping[str, dict[str, t.Any]] = None, ) -> None: self.auth_client = auth_client self.id_batch_size = id_batch_size or self._default_id_batch_size # uniquify, copy, and split into IDs vs usernames self.unresolved_ids, self.unresolved_usernames = split_ids_and_usernames( [] if identity_ids is None else identity_ids ) # a cache may be passed in via the constructor in order to make multiple # IdentityMap objects share a cache self._cache = cache if cache is not None else {} def _create_batch(self, key: str) -> set[str]: """ Create a batch to do a lookup. For whichever set of unresolved names is appropriate, build the batch to lookup up to *at most* the batch size. Also, remove the unresolved names from tracking so that they will not be looked up again. """ key_is_username = is_username(key) set_to_use = ( self.unresolved_usernames if key_is_username else self.unresolved_ids ) # start the batch with the key being looked up, and if it is in the unresolved # list remove it batch = {key} if key in set_to_use: set_to_use.remove(key) # until we've exhausted the set or filled the batch, keep trying to add while set_to_use and len(batch) < self.id_batch_size: value = set_to_use.pop() # value may already have been looked up if the cache is shared, skip those if value in self._cache: continue batch.add(value) return batch def _fetch_batch_including(self, key: str) -> None: """ Batch resolve identifiers (usernames or IDs), being sure to include the desired, named key. The key also determines which kind of batch will be built -- usernames or IDs. Store the results in the internal cache. """ batch = self._create_batch(key) if is_username(key): response = self.auth_client.get_identities(usernames=batch) else: response = self.auth_client.get_identities(ids=batch) for x in response["identities"]: self._cache[x["id"]] = x self._cache[x["username"]] = x def add(self, identity_id: str) -> bool: """ Add a username or ID to the ``IdentityMap`` for batch lookups later. Returns True if the ID was added for lookup. Returns False if it was rejected as a duplicate of an already known name. :param identity_id: A string Identity ID or Identity Name (a.k.a. "username") to add """ if identity_id in self._cache: return False if is_username(identity_id): if identity_id in self.unresolved_usernames: return False else: self.unresolved_usernames.add(identity_id) return True if identity_id in self.unresolved_ids: return False self.unresolved_ids.add(identity_id) return True def get(self, key: str, default: t.Any | None = None) -> t.Any: """ A dict-like get() method which accepts a default value. :param key: The username or ID to look up :param default: The default value to return if the key is not found """ try: return self[key] except KeyError: return default def __getitem__(self, key: str) -> t.Any: """ ``IdentityMap`` supports dict-like lookups with ``map[key]`` """ if key not in self._cache: self._fetch_batch_including(key) return self._cache[key] def __delitem__(self, key: str) -> None: """ ``IdentityMap`` supports ``del map[key]``. Note that this only removes lookup values from the cache and will not impact the set of unresolved/pending IDs. """ del self._cache[key] globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/000077500000000000000000000000001513221403200262635ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/__init__.py000066400000000000000000000016471513221403200304040ustar00rootroot00000000000000from .clients import GetClientsResponse from .consents import GetConsentsResponse from .credentials import GetClientCredentialsResponse from .identities import GetIdentitiesResponse, GetIdentityProvidersResponse from .oauth import ( OAuthAuthorizationCodeResponse, OAuthClientCredentialsResponse, OAuthDependentTokenResponse, OAuthRefreshTokenResponse, OAuthTokenResponse, ) from .policies import GetPoliciesResponse from .projects import GetProjectsResponse from .scopes import GetScopesResponse __all__ = ( "GetClientCredentialsResponse", "GetClientsResponse", "GetIdentitiesResponse", "GetIdentityProvidersResponse", "GetConsentsResponse", "GetPoliciesResponse", "GetProjectsResponse", "GetScopesResponse", "OAuthAuthorizationCodeResponse", "OAuthClientCredentialsResponse", "OAuthDependentTokenResponse", "OAuthRefreshTokenResponse", "OAuthTokenResponse", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/clients.py000066400000000000000000000004041513221403200302740ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class GetClientsResponse(IterableResponse): """ Response class specific to the Get Clients API Provides iteration on the "clients" array in the response. """ default_iter_key = "clients" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/consents.py000066400000000000000000000011171513221403200304710ustar00rootroot00000000000000from globus_sdk import IterableResponse from globus_sdk.scopes.consents import ConsentForest class GetConsentsResponse(IterableResponse): """ Response class specific to the Get Consents API Provides iteration on the "consents" array in the response. """ default_iter_key = "consents" def to_forest(self) -> ConsentForest: """ Creates a ConsentForest from the consents in this response. ConsentForest is a convenience class to make interacting with the tree of consents simpler. """ return ConsentForest(self) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/credentials.py000066400000000000000000000004321513221403200311310ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class GetClientCredentialsResponse(IterableResponse): """ Response class specific to the Get Credentials API Provides iteration on the "credentials" array in the response. """ default_iter_key = "credentials" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/identities.py000066400000000000000000000010161513221403200307740ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class GetIdentitiesResponse(IterableResponse): """ Response class specific to the Get Identities API Provides iteration on the "identities" array in the response. """ default_iter_key = "identities" class GetIdentityProvidersResponse(IterableResponse): """ Response class specific to the Get Identity Providers API Provides iteration on the "identity_providers" array in the response. """ default_iter_key = "identity_providers" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/oauth.py000066400000000000000000000245161513221403200277650ustar00rootroot00000000000000from __future__ import annotations import json import logging import textwrap import time import typing as t from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from globus_sdk import exc from globus_sdk.response import GlobusHTTPResponse from .._common import SupportsJWKMethods from ..id_token_decoder import IDTokenDecoder log = logging.getLogger(__name__) def _convert_token_info_dict( source_dict: GlobusHTTPResponse, ) -> dict[str, t.Any]: """ Extract a set of fields into a new dict for indexing by resource server. Allow for these fields to be `None` when absent: - "refresh_token" - "token_type" """ expires_in = source_dict.get("expires_in", 0) return { "scope": source_dict["scope"], "access_token": source_dict["access_token"], "refresh_token": source_dict.get("refresh_token"), "token_type": source_dict.get("token_type"), "expires_at_seconds": int(time.time() + expires_in), "resource_server": source_dict["resource_server"], } class _ByScopesGetter: """ A fancy dict-like object for looking up token data by scope name. Allows usage like >>> tokens = OAuthTokenResponse(...) >>> tok = tokens.by_scopes['openid profile']['access_token'] """ def __init__(self, scope_map: dict[str, t.Any]) -> None: self.scope_map = scope_map def __str__(self) -> str: return json.dumps(self.scope_map) def __iter__(self) -> t.Iterator[str]: """iteration gets you every individual scope""" return iter(self.scope_map.keys()) def __getitem__(self, scopename: str) -> dict[str, str | int]: if not isinstance(scopename, str): raise KeyError(f'by_scopes cannot contain non-string value "{scopename}"') # split on spaces scopes = scopename.split() # collect every matching token in a set to dedup # but collect actual results (dicts) in a list rs_names = set() toks = [] for scope in scopes: try: rs_names.add(self.scope_map[scope]["resource_server"]) toks.append(self.scope_map[scope]) except KeyError as err: raise KeyError( ( 'Scope specifier "{}" contains scope "{}" ' "which was not found" ).format(scopename, scope) ) from err # if there isn't exactly 1 token, it's an error if len(rs_names) != 1: raise KeyError( 'Scope specifier "{}" did not match exactly one token!'.format( scopename ) ) # pop the only element in the set return t.cast(t.Dict[str, t.Union[str, int]], toks.pop()) def __contains__(self, item: str) -> bool: """ contains is driven by checking against getitem that way, the definitions are always "in sync" if we update them in the future """ try: self.__getitem__(item) return True except KeyError: pass return False class OAuthTokenResponse(GlobusHTTPResponse): """ Class for responses from the OAuth2 code for tokens exchange used in 3-legged OAuth flows. """ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: super().__init__(*args, **kwargs) self._init_rs_dict() self._init_scopes_getter() def _init_scopes_getter(self) -> None: scope_map = {} for _rs, tok_data in self._by_resource_server.items(): for s in tok_data["scope"].split(): scope_map[s] = tok_data self._by_scopes = _ByScopesGetter(scope_map) def _init_rs_dict(self) -> None: # call the helper at the top level self._by_resource_server = { self["resource_server"]: _convert_token_info_dict(self) } # call the helper on everything in 'other_tokens' self._by_resource_server.update( { unprocessed_item["resource_server"]: _convert_token_info_dict( unprocessed_item ) for unprocessed_item in self["other_tokens"] } ) @property def by_resource_server(self) -> dict[str, dict[str, t.Any]]: """ Representation of the token response in a ``dict`` indexed by resource server. Although ``OAuthTokenResponse.data`` is still available and valid, this representation is typically more desirable for applications doing inspection of access tokens and refresh tokens. """ return self._by_resource_server @property def by_scopes(self) -> _ByScopesGetter: """ Representation of the token response in a dict-like object indexed by scope name (or even space delimited scope names, so long as they match the same token). If you request scopes `scope1 scope2 scope3`, where `scope1` and `scope2` are for the same service (and therefore map to the same token), but `scope3` is for a different service, the following forms of access are valid: >>> tokens = ... >>> # single scope >>> token_data = tokens.by_scopes['scope1'] >>> token_data = tokens.by_scopes['scope2'] >>> token_data = tokens.by_scopes['scope3'] >>> # matching scopes >>> token_data = tokens.by_scopes['scope1 scope2'] >>> token_data = tokens.by_scopes['scope2 scope1'] """ return self._by_scopes def decode_id_token( self, openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any] = None, jwk: RSAPublicKey | None = None, jwt_params: dict[str, t.Any] | None = None, ) -> dict[str, t.Any]: """ Parse the included ID Token (OIDC) as a dict and return it. If you provide the `jwk`, you must also provide `openid_configuration`. :param openid_configuration: The OIDC config as a GlobusHTTPResponse or dict. When not provided, it will be fetched automatically. :param jwk: The JWK as a cryptography public key object. When not provided, it will be fetched and parsed automatically. :param jwt_params: An optional dict of parameters to pass to the jwt decode step. If ``"leeway"`` is included, it will be passed as the ``leeway`` parameter, and all other values are passed as ``options``. """ id_token = self["id_token"] log.debug('Decoding ID Token "%s"', id_token) if not isinstance(self.client, SupportsJWKMethods): raise exc.GlobusSDKUsageError( "decode_id_token() requires a client which supports JWK methods. " "This error suggests that an improper client type is attached to " "the token response." ) else: auth_client: SupportsJWKMethods = self.client decoder = IDTokenDecoder(auth_client) if openid_configuration: decoder.store_openid_configuration(openid_configuration) else: if jwk: raise exc.GlobusSDKUsageError( "passing jwk without openid configuration is not allowed" ) if jwk: decoder.store_jwk(jwk) jwt_params = jwt_params or {} if "leeway" in jwt_params: jwt_params = jwt_params.copy() decoder.jwt_leeway = jwt_params.pop("leeway") decoder.jwt_options = jwt_params return decoder.decode(id_token) def __str__(self) -> str: by_rs = json.dumps(self.by_resource_server, indent=2, separators=(",", ": ")) id_token_to_print = t.cast(t.Optional[str], self.get("id_token")) if id_token_to_print is not None: id_token_to_print = id_token_to_print[:10] + "... (truncated)" return ( f"{self.__class__.__name__}:\n" + f" id_token: {id_token_to_print}\n" + " by_resource_server:\n" + textwrap.indent(by_rs, " ") ) class OAuthAuthorizationCodeResponse(OAuthTokenResponse): """ Class for responses from the OAuth2 'authorization_code' grant. This class of response is returned by methods which get new tokens via a code-exchange, as in 3-legged OAuth or PKCE. For example, :meth:`globus_sdk.ConfidentialAppAuthClient.oauth2_exchange_code_for_tokens` will return an ``OAuthAuthorizationCodeResponse``. """ class OAuthClientCredentialsResponse(OAuthTokenResponse): """ Class for responses from the OAuth2 'client_credentials' grant. This class of response is returned by methods which get new tokens by means of client credentials, namely :meth:`globus_sdk.ConfidentialAppAuthClient.oauth2_client_credentials_tokens`. """ class OAuthRefreshTokenResponse(OAuthTokenResponse): """ Class for responses from the OAuth2 'refresh_token' grant. This class of response is returned by methods which get new tokens from an existing Refresh Token, e.g., :meth:`globus_sdk.NativeAppAuthClient.oauth2_refresh_token`. """ class OAuthDependentTokenResponse(OAuthTokenResponse): """ Class for responses from the OAuth2 'urn:globus:auth:grant_type:dependent_token' grant. This is an extension grant type defined by Globus. :meth:`oauth2_get_dependent_tokens \ ` provides this response, and includes some documentation on its proper usage. """ def _init_rs_dict(self) -> None: # call the helper on everything in the response array self._by_resource_server = { unprocessed_item["resource_server"]: _convert_token_info_dict( unprocessed_item ) for unprocessed_item in self.data } def decode_id_token( self, openid_configuration: None | (GlobusHTTPResponse | dict[str, t.Any]) = None, jwk: RSAPublicKey | None = None, jwt_params: dict[str, t.Any] | None = None, ) -> dict[str, t.Any]: # just in case raise NotImplementedError( "OAuthDependentTokenResponse.decode_id_token() is not and cannot " "be implemented. Dependent Tokens data does not include an " "id_token" ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/policies.py000066400000000000000000000004101513221403200304370ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class GetPoliciesResponse(IterableResponse): """ Response class specific to the Get Policies API Provides iteration on the "policies" array in the response. """ default_iter_key = "policies" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/projects.py000066400000000000000000000004101513221403200304610ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class GetProjectsResponse(IterableResponse): """ Response class specific to the Get Projects API Provides iteration on the "projects" array in the response. """ default_iter_key = "projects" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/auth/response/scopes.py000066400000000000000000000004001513221403200301230ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class GetScopesResponse(IterableResponse): """ Response class specific to the Get Scopes API Provides iteration on the "scopes" array in the response. """ default_iter_key = "scopes" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/compute/000077500000000000000000000000001513221403200251405ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/compute/__init__.py000066400000000000000000000002551513221403200272530ustar00rootroot00000000000000from .client import ComputeClientV2, ComputeClientV3 from .errors import ComputeAPIError __all__ = ( "ComputeAPIError", "ComputeClientV2", "ComputeClientV3", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/compute/client.py000066400000000000000000000276211513221403200270000ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from globus_sdk import GlobusHTTPResponse, client from globus_sdk._internal.remarshal import strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk.scopes import ComputeScopes from .errors import ComputeAPIError log = logging.getLogger(__name__) class ComputeClientV2(client.BaseClient): r""" Client for the Globus Compute API, version 2. .. sdk-sphinx-copy-params:: BaseClient .. automethodlist:: globus_sdk.ComputeClientV2 """ error_class = ComputeAPIError service_name = "compute" scopes = ComputeScopes default_scope_requirements = [ComputeScopes.all] def get_version(self, service: str | MissingType = MISSING) -> GlobusHTTPResponse: """Get the current version of the API and other services. :param service: Service for which to get version information. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Version :service: compute :ref: Root/operation/get_version_v2_version_get """ query_params = {"service": service} return self.get("/v2/version", query_params=query_params) def get_result_amqp_url(self) -> GlobusHTTPResponse: """Generate new credentials (in the form of a connection URL) for connecting to the AMQP service. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Result AMQP URL :service: compute :ref: Root/operation/get_user_specific_result_amqp_url_v2_get_amqp_result_connection_url_get """ # noqa: E501 return self.get("/v2/get_amqp_result_connection_url") def register_endpoint(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Register a new endpoint. :param data: An endpoint registration document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Register Endpoint :service: compute :ref: Endpoints/operation/register_endpoint_v2_endpoints_post """ return self.post("/v2/endpoints", data=data) def get_endpoint(self, endpoint_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get information about a registered endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Endpoint :service: compute :ref: Endpoints/operation/get_endpoint_v2_endpoints__endpoint_uuid__get """ # noqa: E501 return self.get(f"/v2/endpoints/{endpoint_id}") def get_endpoint_status(self, endpoint_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get the status of a registered endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Endpoint Status :service: compute :ref: Endpoints/operation/get_endpoint_status_v2_endpoints__endpoint_uuid__status_get """ # noqa: E501 return self.get(f"/v2/endpoints/{endpoint_id}/status") def get_endpoints(self, role: str | MissingType = MISSING) -> GlobusHTTPResponse: """Get a list of registered endpoints associated with the authenticated user. :param role: Role of the user in relation to endpoints. (e.g.: owner, any) .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Endpoints :service: compute :ref: Endpoints/operation/get_endpoints_v2_endpoints_get """ # noqa: E501 query_params = {"role": role} return self.get("/v2/endpoints", query_params=query_params) def delete_endpoint(self, endpoint_id: uuid.UUID | str) -> GlobusHTTPResponse: """Delete a registered endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Delete Endpoint :service: compute :ref: Endpoints/operation/delete_endpoint_v2_endpoints__endpoint_uuid__delete """ # noqa: E501 return self.delete(f"/v2/endpoints/{endpoint_id}") def lock_endpoint(self, endpoint_id: uuid.UUID | str) -> GlobusHTTPResponse: """Temporarily block registration requests for the endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Lock Endpoint :service: compute :ref: Endpoints/operation/lock_endpoint_v2_endpoints__endpoint_uuid__lock_post """ # noqa: E501 return self.post(f"/v2/endpoints/{endpoint_id}/lock") def register_function(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Register a new function. :param data: A function registration document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Register Function :service: compute :ref: Functions/operation/register_function_v2_functions_post """ # noqa: E501 return self.post("/v2/functions", data=data) def get_function(self, function_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get information about a registered function. :param function_id: The ID of the function. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Function :service: compute :ref: Functions/operation/get_function_v2_functions__function_uuid__get """ # noqa: E501 return self.get(f"/v2/functions/{function_id}") def delete_function(self, function_id: uuid.UUID | str) -> GlobusHTTPResponse: """Delete a registered function. :param function_id: The ID of the function. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Delete Function :service: compute :ref: Functions/operation/delete_function_v2_functions__function_uuid__delete """ # noqa: E501 return self.delete(f"/v2/functions/{function_id}") def get_task(self, task_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get information about a task. :param task_id: The ID of the task. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Task :service: compute :ref: Tasks/operation/get_task_status_and_result_v2_tasks__task_uuid__get """ # noqa: E501 return self.get(f"/v2/tasks/{task_id}") def get_task_batch( self, task_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] ) -> GlobusHTTPResponse: """Get information about a batch of tasks. :param task_ids: The IDs of the tasks. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Task Batch :service: compute :ref: Root/operation/get_batch_status_v2_batch_status_post """ return self.post( "/v2/batch_status", data={"task_ids": strseq_listify(task_ids)} ) def get_task_group(self, task_group_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get a list of task IDs associated with a task group. :param task_group_id: The ID of the task group. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Task Group Tasks :service: compute :ref: TaskGroup/operation/get_task_group_tasks_v2_taskgroup__task_group_uuid__get """ # noqa: E501 return self.get(f"/v2/taskgroup/{task_group_id}") def submit(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Submit a batch of tasks to a Globus Compute endpoint. :param data: The task batch document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Submit Batch :service: compute :ref: Root/operation/submit_batch_v2_submit_post """ # noqa: E501 return self.post("/v2/submit", data=data) class ComputeClientV3(client.BaseClient): r""" Client for the Globus Compute API, version 3. .. automethodlist:: globus_sdk.ComputeClientV3 """ error_class = ComputeAPIError service_name = "compute" scopes = ComputeScopes default_scope_requirements = [ComputeScopes.all] def register_endpoint(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Register a new endpoint. :param data: An endpoint registration document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Register Endpoint :service: compute :ref: Endpoints/operation/register_endpoint_v3_endpoints_post """ return self.post("/v3/endpoints", data=data) def update_endpoint( self, endpoint_id: uuid.UUID | str, data: dict[str, t.Any] ) -> GlobusHTTPResponse: """Update an endpoint. :param endpoint_id: The ID of the endpoint. :param data: An endpoint update document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Update Endpoint :service: compute :ref: Endpoints/operation/update_endpoint_v3_endpoints__endpoint_uuid__put """ # noqa: E501 return self.put(f"/v3/endpoints/{endpoint_id}", data=data) def lock_endpoint(self, endpoint_id: uuid.UUID | str) -> GlobusHTTPResponse: """Temporarily block registration requests for the endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Lock Endpoint :service: compute :ref: Endpoints/operation/lock_endpoint_v3_endpoints__endpoint_uuid__lock_post """ # noqa: E501 return self.post(f"/v3/endpoints/{endpoint_id}/lock") def get_endpoint_allowlist( self, endpoint_id: uuid.UUID | str ) -> GlobusHTTPResponse: """Get a list of IDs for functions allowed to run on an endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Endpoint Allowlist :service: compute :ref: Endpoints/operation/get_endpoint_allowlist_v3_endpoints__endpoint_uuid__allowed_functions_get """ # noqa: E501 return self.get(f"/v3/endpoints/{endpoint_id}/allowed_functions") def register_function(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Register a new function. :param data: A function registration document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Register Function :service: compute :ref: Functions/operation/register_function_v3_functions_post """ return self.post("/v3/functions", data=data) def submit( self, endpoint_id: uuid.UUID | str, data: dict[str, t.Any] ) -> GlobusHTTPResponse: """Submit a batch of tasks to a Globus Compute endpoint. :param endpoint_id: The ID of the Globus Compute endpoint. :param data: The task batch document. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Submit Batch :service: compute :ref: Endpoints/operation/submit_batch_v3_endpoints__endpoint_uuid__submit_post """ # noqa: E501 return self.post(f"/v3/endpoints/{endpoint_id}/submit", data=data) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/compute/errors.py000066400000000000000000000002001513221403200270160ustar00rootroot00000000000000from __future__ import annotations from globus_sdk.exc import GlobusAPIError class ComputeAPIError(GlobusAPIError): pass globus-globus-sdk-python-6a080e4/src/globus_sdk/services/flows/000077500000000000000000000000001513221403200246165ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/flows/__init__.py000066400000000000000000000007001513221403200267240ustar00rootroot00000000000000from .client import FlowsClient, SpecificFlowClient from .data import RunActivityNotificationPolicy from .errors import FlowsAPIError from .response import ( IterableFlowsResponse, IterableRunLogsResponse, IterableRunsResponse, ) __all__ = ( "FlowsAPIError", "FlowsClient", "IterableFlowsResponse", "IterableRunsResponse", "IterableRunLogsResponse", "SpecificFlowClient", "RunActivityNotificationPolicy", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/flows/client.py000066400000000000000000001162011513221403200264470ustar00rootroot00000000000000from __future__ import annotations import logging import sys import typing as t import uuid from globus_sdk import GlobusHTTPResponse, client, paging from globus_sdk._internal import guards from globus_sdk._internal.remarshal import commajoin from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.globus_app import GlobusApp from globus_sdk.scopes import ( FlowsScopes, GCSCollectionScopes, Scope, SpecificFlowScopes, TransferScopes, ) from globus_sdk.transport import RequestsTransport, RetryConfig from .data import RunActivityNotificationPolicy from .errors import FlowsAPIError from .response import ( IterableFlowsResponse, IterableRunLogsResponse, IterableRunsResponse, ) if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self log = logging.getLogger(__name__) class FlowsClient(client.BaseClient): r""" Client for the Globus Flows API. .. sdk-sphinx-copy-params:: BaseClient .. automethodlist:: globus_sdk.FlowsClient """ error_class = FlowsAPIError service_name = "flows" scopes = FlowsScopes default_scope_requirements = [FlowsScopes.all] def create_flow( self, title: str, definition: dict[str, t.Any], input_schema: dict[str, t.Any], subtitle: str | MissingType = MISSING, description: str | MissingType = MISSING, flow_viewers: list[str] | MissingType = MISSING, flow_starters: list[str] | MissingType = MISSING, flow_administrators: list[str] | MissingType = MISSING, run_managers: list[str] | MissingType = MISSING, run_monitors: list[str] | MissingType = MISSING, keywords: list[str] | MissingType = MISSING, subscription_id: uuid.UUID | str | None | MissingType = MISSING, authentication_policy_id: uuid.UUID | str | None | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Create a flow :param title: A non-unique, human-friendly name used for displaying the flow to end users. (1 - 128 characters) :param definition: JSON object specifying flows states and execution order. For a more detailed explanation of the flow definition, see `Authoring Flows `_ :param input_schema: A JSON Schema to which flow run input must conform :param subtitle: A concise summary of the flow’s purpose. (0 - 128 characters) :param description: A detailed description of the flow's purpose for end user display. (0 - 4096 characters) :param flow_viewers: A set of Principal URN values, or the value "public", indicating entities who can view the flow .. dropdown:: Example Values .. code-block:: json [ "public" ] .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param flow_starters: A set of Principal URN values, or the value "all_authenticated_users", indicating entities who can initiate a *run* of the flow .. dropdown:: Example Values .. code-block:: json [ "all_authenticated_users" ] .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param flow_administrators: A set of Principal URN values indicating entities who can perform administrative operations on the flow (create, delete, update) .. dropdown:: Example Values .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param run_managers: A set of Principal URN values indicating entities who can perform management operations on the flow's *runs*. .. dropdown:: Example Values .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param run_monitors: A set of Principal URN values indicating entities who can monitor the flow's *runs*. .. dropdown:: Example Values .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param keywords: A set of terms used to categorize the flow used in query and discovery operations (0 - 1024 items) :param subscription_id: The ID of the subscription to associate with the flow, marking as a subscription tier flow. :param authentication_policy_id: The ID of the authentication policy to associate with the flow. :param additional_fields: Additional Key/Value pairs sent to the create API .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient ... flows = FlowsClient(...) flows.create_flow( title="my-cool-flow", definition={ "StartAt": "the-one-true-state", "States": {"the-one-true-state": {"Type": "Pass", "End": True}}, }, input_schema={ "type": "object", "properties": { "input-a": {"type": "string"}, "input-b": {"type": "number"}, "input-c": {"type": "boolean"}, }, }, ) .. tab-item:: Example Response Data .. expandtestfixture:: flows.create_flow .. tab-item:: API Info .. extdoclink:: Create Flow :service: flows :ref: Flows/paths/~1flows/post """ # noqa E501 data = { "title": title, "definition": definition, "input_schema": input_schema, "subtitle": subtitle, "description": description, "flow_viewers": flow_viewers, "flow_starters": flow_starters, "flow_administrators": flow_administrators, "run_managers": run_managers, "run_monitors": run_monitors, "keywords": keywords, "subscription_id": subscription_id, "authentication_policy_id": authentication_policy_id, **(additional_fields or {}), } return self.post("/flows", data=data) def get_flow( self, flow_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """Retrieve a flow by ID :param flow_id: The ID of the flow to fetch :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Get Flow :service: flows :ref: Flows/paths/~1flows~1{flow_id}/get """ return self.get(f"/flows/{flow_id}", query_params=query_params) @paging.has_paginator(paging.MarkerPaginator, items_key="flows") def list_flows( self, *, filter_roles: str | t.Iterable[str] | MissingType = MISSING, filter_fulltext: str | MissingType = MISSING, orderby: str | t.Iterable[str] | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableFlowsResponse: """ List deployed flows :param filter_roles: A list of role names specifying the roles the user must have for a flow to be included in the response. :param filter_fulltext: A string to use in a full-text search to filter results :param orderby: A criterion for ordering flows in the listing :param marker: A marker for pagination :param query_params: Any additional parameters to be passed through as query params. **Role Filters** ``filter_roles`` accepts a list of roles which are used to filter the results to flows where the caller has any of the specified roles. The valid role values are: - ``flow_viewer`` - ``flow_starter`` - ``flow_administrator`` - ``flow_owner`` - ``run_monitor`` - ``run_manager`` **OrderBy Values** Values for ``orderby`` consist of a field name, a space, and an ordering mode -- ``ASC`` for "ascending" and ``DESC`` for "descending". Supported field names are - ``id`` - ``scope_string`` - ``flow_owners`` - ``flow_administrators`` - ``title`` - ``created_at`` - ``updated_at`` For example, ``orderby="updated_at DESC"`` requests a descending sort on update times, getting the most recently updated flow first. Multiple ``orderby`` values may be given as an iterable, e.g. ``orderby=["updated_at DESC", "title ASC"]``. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python import json import textwrap from globus_sdk import FlowsClient flows = FlowsClient(...) my_frobulate_flows = flows.list_flows( filter_roles="flow_owner", filter_fulltext="frobulate", orderby=("title ASC", "updated_at DESC"), ) for flow_doc in my_frobulate_flows: print(f"Title: {flow_doc['title']}") print(f"Description: {flow_doc['description']}") print("Definition:") print( textwrap.indent( json.dumps( flow_doc["definition"], indent=2, separators=(",", ": "), ), " ", ) ) print() .. tab-item:: Paginated Usage .. paginatedusage:: list_flows .. tab-item:: API Info .. extdoclink:: List Flows :service: flows :ref: Flows/paths/~1flows/get """ query_params = { "filter_roles": commajoin(filter_roles), "filter_fulltext": filter_fulltext, # if `orderby` is an iterable (e.g., generator expression), it gets # converted to a list in this step "orderby": ( orderby if isinstance(orderby, (str, MissingType)) else list(orderby) ), "marker": marker, **(query_params or {}), } return IterableFlowsResponse(self.get("/flows", query_params=query_params)) def update_flow( self, flow_id: uuid.UUID | str, *, title: str | MissingType = MISSING, definition: dict[str, t.Any] | MissingType = MISSING, input_schema: dict[str, t.Any] | MissingType = MISSING, subtitle: str | MissingType = MISSING, description: str | MissingType = MISSING, flow_owner: str | MissingType = MISSING, flow_viewers: list[str] | MissingType = MISSING, flow_starters: list[str] | MissingType = MISSING, flow_administrators: list[str] | MissingType = MISSING, run_managers: list[str] | MissingType = MISSING, run_monitors: list[str] | MissingType = MISSING, keywords: list[str] | MissingType = MISSING, subscription_id: uuid.UUID | str | t.Literal["DEFAULT"] | MissingType = MISSING, authentication_policy_id: uuid.UUID | str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Update a flow Only the parameter `flow_id` is required. Any fields omitted from the request will be unchanged :param flow_id: The ID of the flow to fetch :param title: A non-unique, human-friendly name used for displaying the flow to end users. (1 - 128 characters) :param definition: JSON object specifying flows states and execution order. For a more detailed explanation of the flow definition, see `Authoring Flows `_ :param input_schema: A JSON Schema to which flow run input must conform :param subtitle: A concise summary of the flow’s purpose. (0 - 128 characters) :param description: A detailed description of the flow's purpose for end user display. (0 - 4096 characters) :param flow_owner: An Auth Identity URN to set as flow owner; this must match the Identity URN of the entity calling `update_flow` :param flow_viewers: A set of Principal URN values, or the value "public", indicating entities who can view the flow .. dropdown:: Example Values .. code-block:: json [ "public" ] .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param flow_starters: A set of Principal URN values, or the value "all_authenticated_users", indicating entities who can initiate a *run* of the flow .. dropdown:: Example Values .. code-block:: json [ "all_authenticated_users" ] .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param flow_administrators: A set of Principal URN values indicating entities who can perform administrative operations on the flow (create, delete, update) .. dropdown:: Example Value .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param run_managers: A set of Principal URN values indicating entities who can perform management operations on the flow's *runs*. .. dropdown:: Example Values .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param run_monitors: A set of Principal URN values indicating entities who can monitor the flow's *runs*. .. dropdown:: Example Values .. code-block:: json [ "urn:globus:auth:identity:b44bddda-d274-11e5-978a-9f15789a8150", "urn:globus:groups:id:c1dcd951-3f35-4ea3-9f28-a7cdeaf8b68f" ] :param keywords: A set of terms used to categorize the flow used in query and discovery operations (0 - 1024 items) :param subscription_id: A subscription ID to assign to the flow. :param authentication_policy_id: An authentication policy to assign to the flow. :param additional_fields: Additional Key/Value pairs sent to the create API .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient flows = FlowsClient(...) flows.update_flow( flow_id="581753c7-45da-43d3-ad73-246b46e7cb6b", keywords=["new", "overriding", "keywords"], ) .. tab-item:: Example Response Data .. expandtestfixture:: flows.update_flow .. tab-item:: API Info .. extdoclink:: Update Flow :service: flows :ref: Flows/paths/~1flows~1{flow_id}/put """ # noqa E501 data = { "title": title, "definition": definition, "input_schema": input_schema, "subtitle": subtitle, "description": description, "flow_owner": flow_owner, "flow_viewers": flow_viewers, "flow_starters": flow_starters, "flow_administrators": flow_administrators, "run_managers": run_managers, "run_monitors": run_monitors, "keywords": keywords, "subscription_id": subscription_id, "authentication_policy_id": authentication_policy_id, **(additional_fields or {}), } return self.put(f"/flows/{flow_id}", data=data) def delete_flow( self, flow_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """Delete a flow :param flow_id: The ID of the flow to delete :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: API Info .. extdoclink:: Delete Flow :service: flows :ref: Flows/paths/~1flows~1{flow_id}/delete """ return self.delete(f"/flows/{flow_id}", query_params=query_params) def validate_flow( self, definition: dict[str, t.Any], input_schema: dict[str, t.Any] | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Validate a flow :param definition: JSON object specifying flow states and execution order. For a more detailed explanation of the flow definition, see `Authoring Flows `_ :param input_schema: A JSON Schema to which flow run input must conform .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient ... flows = FlowsClient(...) flows.validate_flow( definition={ "StartAt": "the-one-true-state", "States": {"the-one-true-state": {"Type": "Pass", "End": True}}, }, input_schema={ "type": "object", "properties": { "input-a": {"type": "string"}, "input-b": {"type": "number"}, "input-c": {"type": "boolean"}, }, }, ) .. tab-item:: Example Response Data .. expandtestfixture:: flows.validate_flow .. tab-item:: API Info .. extdoclink:: Validate Flow :service: flows :ref: Flows/paths/~1flows~1validate/post """ # noqa E501 data = { "definition": definition, "input_schema": input_schema, } return self.post("/flows/validate", data=data) @paging.has_paginator(paging.MarkerPaginator, items_key="runs") def list_runs( self, *, filter_flow_id: ( t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType ) = MISSING, filter_roles: str | t.Iterable[str] | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableRunsResponse: """ List all runs. :param filter_flow_id: One or more flow IDs used to filter the results :param filter_roles: A list of role names used to filter the results :param marker: A pagination marker, used to get the next page of results. :param query_params: Any additional parameters to be passed through **Filter Roles Values** The valid values for ``role`` are: - ``run_owner`` - ``run_manager`` - ``run_monitor`` - ``flow_run_manager`` - ``flow_run_monitor`` .. tab-set:: .. tab-item:: Example Usage .. code-block:: python flows = globus_sdk.FlowsClient(...) for run in flows.list_runs(): print(run["run_id"]) .. tab-item:: Example Response Data .. expandtestfixture:: flows.list_runs .. tab-item:: API Info .. extdoclink:: List Runs :service: flows :ref: Runs/paths/~1runs/get """ query_params = { "filter_flow_id": commajoin(filter_flow_id), "filter_roles": commajoin(filter_roles), "marker": marker, **(query_params or {}), } return IterableRunsResponse(self.get("/runs", query_params=query_params)) @paging.has_paginator(paging.MarkerPaginator, items_key="entries") def get_run_logs( self, run_id: uuid.UUID | str, *, limit: int | MissingType = MISSING, reverse_order: bool | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableRunLogsResponse: """ Retrieve the execution logs associated with a run These logs describe state transitions and associated payloads for a run :param run_id: Run ID to retrieve logs for :param limit: Maximum number of log entries to return (server default: 10) (value between 1 and 100 inclusive) :param reverse_order: Return results in reverse chronological order (server default: false) :param marker: Marker for the next page of results (provided by the server) :param query_params: Any additional parameters to be passed through .. tab-set:: .. tab-item:: Paginated Usage .. paginatedusage:: get_run_logs .. tab-item:: Example Response Data .. expandtestfixture:: flows.get_run_logs .. tab-item:: API Info .. extdoclink:: Get Run Logs :service: flows :ref: Runs/paths/~1runs~1{action_id}~1log/get """ query_params = { "limit": limit, "reverse_order": reverse_order, "marker": marker, **(query_params or {}), } return IterableRunLogsResponse( self.get(f"/runs/{run_id}/log", query_params=query_params) ) def get_run( self, run_id: uuid.UUID | str, *, include_flow_description: bool | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Retrieve information about a particular run of a flow :param run_id: The ID of the run to get :param include_flow_description: If set to true, the lookup will attempt to attach metadata about the flow to the run to the run response under the key "flow_description" (default: False) :param query_params: Any additional parameters to be passed through .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient flows = FlowsClient(...) flows.get_run("581753c7-45da-43d3-ad73-246b46e7cb6b") .. tab-item:: Example Response Data .. expandtestfixture:: flows.get_run .. tab-item:: API Info .. extdoclink:: Get Run :service: flows :ref: Flows/paths/~1runs~1{run_id}/get """ query_params = { "include_flow_description": include_flow_description, **(query_params or {}), } return self.get(f"/runs/{run_id}", query_params=query_params) def get_run_definition( self, run_id: uuid.UUID | str, ) -> GlobusHTTPResponse: """ Get the flow definition and input schema at the time the run was started. :param run_id: The ID of the run to get .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient flows = FlowsClient(...) flows.get_run_definition("581753c7-45da-43d3-ad73-246b46e7cb6b") .. tab-item:: Example Response Data .. expandtestfixture:: flows.get_run_definition .. tab-item:: API Info .. extdoclink:: Get Run Definition :service: flows :ref: Flows/paths/~1runs~1{run_id}~1definition/get """ return self.get(f"/runs/{run_id}/definition") def cancel_run(self, run_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Cancel a run. :param run_id: The ID of the run to cancel .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient flows = FlowsClient(...) flows.cancel_run("581753c7-45da-43d3-ad73-246b46e7cb6b") .. tab-item:: Example Response Data .. expandtestfixture:: flows.cancel_run .. tab-item:: API Info .. extdoclink:: Cancel Run :service: flows :ref: Runs/paths/~1runs~1{run_id}~1cancel/post """ return self.post(f"/runs/{run_id}/cancel") def update_run( self, run_id: uuid.UUID | str, *, label: str | MissingType = MISSING, tags: list[str] | MissingType = MISSING, run_monitors: list[str] | MissingType = MISSING, run_managers: list[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Update the metadata of a specific run. :param run_id: The ID of the run to update :param label: A short human-readable title (1 - 64 chars) :param tags: A collection of searchable tags associated with the run. Tags are normalized by stripping leading and trailing whitespace, and replacing all whitespace with a single space. :param run_monitors: A list of authenticated entities (identified by URN) authorized to view this run in addition to the run owner :param run_managers: A list of authenticated entities (identified by URN) authorized to view & cancel this run in addition to the run owner :param additional_fields: Additional Key/Value pairs sent to the run API (this parameter is used to bypass local sdk key validation helping) .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient flows = FlowsClient(...) flows.update_run( "581753c7-45da-43d3-ad73-246b46e7cb6b", label="Crunch numbers for experiment xDA202-batch-10", ) .. tab-item:: Example Response Data .. expandtestfixture:: flows.update_run .. tab-item:: API Info .. extdoclink:: Update Run :service: flows :ref: Runs/paths/~1runs~1{run_id}/put """ data = { "tags": tags, "label": label, "run_monitors": run_monitors, "run_managers": run_managers, **(additional_fields or {}), } return self.put(f"/runs/{run_id}", data=data) def delete_run(self, run_id: uuid.UUID | str) -> GlobusHTTPResponse: """ Delete a run. :param run_id: The ID of the run to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import FlowsClient flows = FlowsClient(...) flows.delete_run("581753c7-45da-43d3-ad73-246b46e7cb6b") .. tab-item:: Example Response Data .. expandtestfixture:: flows.delete_run .. tab-item:: API Info .. extdoclink:: Delete Run :service: flows :ref: Runs/paths/~1runs~1{run_id}~1release/post """ return self.post(f"/runs/{run_id}/release") class SpecificFlowClient(client.BaseClient): r""" Client for interacting with a specific flow through the Globus Flows API. Unlike other client types, this must be provided with a specific flow id. All other arguments are the same as those for :class:`~globus_sdk.BaseClient`. .. sdk-sphinx-copy-params:: BaseClient :param flow_id: The generated UUID associated with a flow .. automethodlist:: globus_sdk.SpecificFlowClient """ error_class = FlowsAPIError service_name = "flows" scopes: SpecificFlowScopes = SpecificFlowScopes._build_class_stub() def __init__( self, flow_id: uuid.UUID | str, *, environment: str | None = None, app: GlobusApp | None = None, app_scopes: list[Scope] | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: self._flow_id = flow_id self.scopes = SpecificFlowScopes(flow_id) super().__init__( app=app, app_scopes=app_scopes, environment=environment, authorizer=authorizer, app_name=app_name, transport=transport, retry_config=retry_config, ) @property def default_scope_requirements(self) -> list[Scope]: return [self.scopes.user] def add_app_transfer_data_access_scope( self, collection_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] ) -> Self: """ Add a dependent ``data_access`` scope for one or more given ``collection_ids`` to this client's ``GlobusApp``, under the Transfer ``all`` scope. Useful for preventing ``ConsentRequired`` errors when starting or resuming runs of flows that use Globus Connect Server mapped collection(s). .. warning:: This method must only be used on ``collection_ids`` for non-High-Assurance GCS Mapped Collections. Use on other collection types, e.g., on GCP Mapped Collections or any form of Guest Collection, will result in "Unknown Scope" errors during the login flow. Returns ``self`` for chaining. Raises ``GlobusSDKUsageError`` if this client was not initialized with an app. :param collection_ids: a collection ID or an iterable of IDs. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python flow_id = ... COLLECTION_ID = ... app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) client = SpecificFlowClient(FLOW_ID, app=app).add_app_transfer_data_access_scope( COLLECTION_ID ) client.run_flow({"collection": COLLECTION_ID}) """ # noqa: E501 if isinstance(collection_ids, (str, uuid.UUID)): guards.validators.uuidlike("collection_ids", collection_ids) # wrap the collection_ids input in a list for consistent iteration below collection_ids_ = [collection_ids] else: # copy to a list so that ephemeral iterables can be iterated multiple times collection_ids_ = list(collection_ids) for i, c in enumerate(collection_ids_): guards.validators.uuidlike(f"collection_ids[{i}]", c) transfer_scope = TransferScopes.all.with_optional(True) for coll_id in collection_ids_: data_access_scope = GCSCollectionScopes( str(coll_id) ).data_access.with_optional(True) transfer_scope = transfer_scope.with_dependency(data_access_scope) specific_flow_scope = self.scopes.user.with_dependency(transfer_scope) self.add_app_scope(specific_flow_scope) return self def run_flow( self, body: dict[str, t.Any], *, label: str | MissingType = MISSING, tags: list[str] | MissingType = MISSING, activity_notification_policy: ( dict[str, t.Any] | RunActivityNotificationPolicy | MissingType ) = MISSING, run_monitors: list[str] | MissingType = MISSING, run_managers: list[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ :param body: The input json object handed to the first flow state. The flows service will validate this object against the flow's supplied input schema. :param label: A short human-readable title (1 - 64 chars) :param tags: A collection of searchable tags associated with the run. Tags are normalized by stripping leading and trailing whitespace, and replacing all whitespace with a single space. :param activity_notification_policy: A policy document which declares when the run will send notification emails. By default, notifications are only sent when a run status changes to ``"INACTIVE"``. :param run_monitors: A list of authenticated entities (identified by URN) authorized to view this run in addition to the run owner :param run_managers: A list of authenticated entities (identified by URN) authorized to view & cancel this run in addition to the run owner :param additional_fields: Additional Key/Value pairs sent to the run API (this parameter is used to bypass local sdk key validation helping) .. tab-set:: .. tab-item:: API Info .. extdoclink:: Run Flow :service: flows :ref: ~1flows~1{flow_id}~1run/post """ data = { "body": body, "tags": tags, "label": label, "activity_notification_policy": activity_notification_policy, "run_monitors": run_monitors, "run_managers": run_managers, **(additional_fields or {}), } return self.post(f"/flows/{self._flow_id}/run", data=data) def resume_run(self, run_id: uuid.UUID | str) -> GlobusHTTPResponse: """ :param run_id: The ID of the run to resume .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import SpecificFlowClient ... flow = SpecificFlowClient(flow_id, ...) flow.resume_run(run_id) .. tab-item:: Example Response Data .. expandtestfixture:: flows.resume_run .. tab-item:: API Info .. extdoclink:: Resume Run :service: flows :ref: Runs/paths/~1flows~1{flow_id}~1runs~1{run_id}~1resume/post """ return self.post(f"/runs/{run_id}/resume") def validate_run( self, body: dict[str, t.Any], *, label: str | MissingType = MISSING, tags: list[str] | MissingType = MISSING, run_monitors: list[str] | MissingType = MISSING, run_managers: list[str] | MissingType = MISSING, activity_notification_policy: ( dict[str, t.Any] | RunActivityNotificationPolicy | MissingType ) = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ :param body: The parameters to validate against the flow's input schema. :param label: A short human-readable title. :param tags: A collection of searchable tags associated with the run. Tags are normalized by stripping leading and trailing whitespace, and replacing all whitespace with a single space. :param run_monitors: A list of Globus Auth principals (identified by URN) authorized to monitor this run (in addition to the run owner). :param run_managers: A list of Globus Auth principals (identified by URN) authorized to manage this run (in addition to the run owner). :param activity_notification_policy: A policy document which declares when the Flows service will send notification emails regarding the run's activity. :param additional_fields: Additional key/value pairs sent to the run API. This parameter can be used to bypass SDK parameter validation. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import SpecificFlowClient ... flow = SpecificFlowClient(flow_id, ...) flow.validate_run(body={"param": "value"}) .. tab-item:: Example Response Data .. expandtestfixture:: flows.validate_run """ data = { "body": body, "tags": tags, "label": label, "run_monitors": run_monitors, "run_managers": run_managers, "activity_notification_policy": activity_notification_policy, } data.update(additional_fields or {}) return self.post(f"/flows/{self._flow_id}/validate_run", data=data) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/flows/data.py000066400000000000000000000014201513221403200260760ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) class RunActivityNotificationPolicy(GlobusPayload): """ A notification policy for a run, determining when emails will be sent. :param status: A list of statuses which will trigger notifications. When the run's status changes, it is evaluated against this list. If the new status matches the policy, an email is sent. """ def __init__( self, status: ( list[t.Literal["INACTIVE", "SUCCEEDED", "FAILED"]] | MissingType ) = MISSING, ) -> None: super().__init__() self["status"] = status globus-globus-sdk-python-6a080e4/src/globus_sdk/services/flows/errors.py000066400000000000000000000025171513221403200265110ustar00rootroot00000000000000from __future__ import annotations from globus_sdk._internal import guards from globus_sdk.exc import ErrorSubdocument, GlobusAPIError class FlowsAPIError(GlobusAPIError): """ Error class to represent error responses from Flows. """ def _parse_undefined_error_format(self) -> bool: """ Treat any top-level "error" key as an "array of size 1". Meaning that we'll see a single subdocument for data shaped like { "error": { "foo": "bar" } } """ # if there is not a top-level 'error' key, no special behavior is defined # fall-back to the base class implementation if not isinstance(self._dict_data.get("error"), dict): return super()._parse_undefined_error_format() self.errors = [ErrorSubdocument(self._dict_data["error"])] self.code = self._extract_code_from_error_array(self.errors) details = self._dict_data["error"].get("detail") if guards.is_list_of(details, dict): self.messages = [ error_detail["msg"] for error_detail in details if isinstance(error_detail.get("msg"), str) ] else: self.messages = self._extract_messages_from_error_array(self.errors) return True globus-globus-sdk-python-6a080e4/src/globus_sdk/services/flows/response.py000066400000000000000000000045121513221403200270300ustar00rootroot00000000000000from globus_sdk import response class IterableFlowsResponse(response.IterableResponse): """ An iterable response containing a "flows" array of flow definitions. This response type is returned by :meth:`FlowsClient.list_flows` and provides iteration over individual flow objects from a single page of results. When iterated over, yields individual flow dictionaries, where each flow typically contains: - ``id``: UUID of the flow - ``title``: Display title of the flow - ``definition``: The flow's JSON definition - ``input_schema``: JSON Schema for flow inputs - ``globus_auth_scope``: Auth scope string for the flow - ``created_at``: Timestamp of flow creation - ``updated_at``: Timestamp of last update """ default_iter_key = "flows" class IterableRunsResponse(response.IterableResponse): """ An iterable response containing a "runs" array of flow run records. This response type is returned by :meth:`FlowsClient.list_runs` and provides iteration over individual run objects from a single page of results. When iterated over, yields individual run dictionaries, where each run typically contains: - ``run_id``: UUID of the run - ``flow_id``: UUID of the flow this run belongs to - ``flow_title``: Title of the flow - ``status``: Current status of the run (e.g., "ACTIVE", "INACTIVE", "SUCCEEDED", "FAILED", "ENDED") - ``start_time``: Timestamp when the run was started - ``completion_time``: Timestamp when the run completed (if applicable) - ``run_owner``: Identity URN of the user who started the run """ default_iter_key = "runs" class IterableRunLogsResponse(response.IterableResponse): """ An iterable response containing an "entries" array of log entries. This response type is returned by :meth:`FlowsClient.get_run_logs` and provides iteration over individual log entries from a single page of results. When iterated over, yields individual log entry dictionaries, where each entry typically contains: - ``time``: Timestamp of the log entry - ``code``: Event code (e.g., "FlowStarted", "ActionStarted", "ActionCompleted") - ``description``: Human-readable description of the event - ``details``: Additional nested information about the event """ default_iter_key = "entries" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/000077500000000000000000000000001513221403200242405ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/__init__.py000066400000000000000000000034761513221403200263630ustar00rootroot00000000000000from .client import GCSClient from .connector_table import ConnectorTable, GlobusConnectServerConnector from .data import ( ActiveScaleStoragePolicies, AzureBlobStoragePolicies, BlackPearlStoragePolicies, BoxStoragePolicies, CephStoragePolicies, CollectionDocument, CollectionPolicies, EndpointDocument, GCSRoleDocument, GoogleCloudStorageCollectionPolicies, GoogleCloudStoragePolicies, GoogleDriveStoragePolicies, GuestCollectionDocument, HPSSStoragePolicies, IrodsStoragePolicies, MappedCollectionDocument, OneDriveStoragePolicies, POSIXCollectionPolicies, POSIXStagingCollectionPolicies, POSIXStagingStoragePolicies, POSIXStoragePolicies, S3StoragePolicies, StorageGatewayDocument, StorageGatewayPolicies, UserCredentialDocument, ) from .errors import GCSAPIError from .response import IterableGCSResponse, UnpackingGCSResponse __all__ = ( "GCSClient", "GCSRoleDocument", "EndpointDocument", "CollectionDocument", "GuestCollectionDocument", "MappedCollectionDocument", "CollectionPolicies", "POSIXCollectionPolicies", "POSIXStagingCollectionPolicies", "GoogleCloudStorageCollectionPolicies", "StorageGatewayDocument", "StorageGatewayPolicies", "POSIXStoragePolicies", "POSIXStagingStoragePolicies", "BlackPearlStoragePolicies", "BoxStoragePolicies", "CephStoragePolicies", "GoogleDriveStoragePolicies", "GoogleCloudStoragePolicies", "OneDriveStoragePolicies", "AzureBlobStoragePolicies", "S3StoragePolicies", "ActiveScaleStoragePolicies", "IrodsStoragePolicies", "HPSSStoragePolicies", "GCSAPIError", "IterableGCSResponse", "UnpackingGCSResponse", "UserCredentialDocument", "GlobusConnectServerConnector", "ConnectorTable", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/client.py000066400000000000000000000666511513221403200261060ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk import client, paging, response from globus_sdk._internal.classprop import classproperty from globus_sdk._internal.remarshal import commajoin from globus_sdk._internal.utils import slash_join from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.globus_app import GlobusApp from globus_sdk.scopes import GCSCollectionScopes, GCSEndpointScopes, Scope from globus_sdk.transport import RequestsTransport, RetryConfig from .data import ( CollectionDocument, EndpointDocument, GCSRoleDocument, StorageGatewayDocument, UserCredentialDocument, ) from .errors import GCSAPIError from .response import IterableGCSResponse, UnpackingGCSResponse C = t.TypeVar("C", bound=t.Callable[..., t.Any]) class GCSClient(client.BaseClient): """ A GCSClient provides communication with the GCS Manager API of a Globus Connect Server instance. For full reference, see the `documentation for the GCS Manager API `_. Unlike other client types, this must be provided with an address for the GCS Manager. All other arguments are the same as those for :class:`~globus_sdk.BaseClient`. .. sdk-sphinx-copy-params:: BaseClient :param gcs_address: The FQDN (DNS name) or HTTPS URL for the GCS Manager API. .. automethodlist:: globus_sdk.GCSClient """ # TODO: under SDK v4.0, service_name should not be set service_name = "globus_connect_server" error_class = GCSAPIError def __init__( self, gcs_address: str, *, app: GlobusApp | None = None, app_scopes: list[Scope] | None = None, environment: str | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, transport: RequestsTransport | None = None, retry_config: RetryConfig | None = None, ) -> None: # check if the provided address was a DNS name or an HTTPS URL if not gcs_address.startswith("https://"): # if it's a DNS name format it accordingly gcs_address = f"https://{gcs_address}/api/" # if it was an HTTPS URL, check that it ends with /api/ elif not gcs_address.endswith(("/api/", "/api")): # if it doesn't, add it gcs_address = slash_join(gcs_address, "/api/") self._endpoint_client_id: str | None = None super().__init__( base_url=gcs_address, environment=environment, app=app, app_scopes=app_scopes, authorizer=authorizer, app_name=app_name, transport=transport, retry_config=retry_config, ) @staticmethod def get_gcs_endpoint_scopes( endpoint_id: uuid.UUID | str, ) -> GCSEndpointScopes: """Given a GCS Endpoint ID, this helper constructs an object containing the scopes for that Endpoint. :param endpoint_id: The ID of the Endpoint See documentation for :class:`globus_sdk.scopes.GCSEndpointScopes` for more information. """ return GCSEndpointScopes(str(endpoint_id)) @staticmethod def get_gcs_collection_scopes( collection_id: uuid.UUID | str, ) -> GCSCollectionScopes: """Given a GCS Collection ID, this helper constructs an object containing the scopes for that Collection. :param collection_id: The ID of the Collection See documentation for :class:`globus_sdk.scopes.GCSCollectionScopes` for more information. """ return GCSCollectionScopes(str(collection_id)) @property def default_scope_requirements(self) -> list[Scope]: return [ GCSClient.get_gcs_endpoint_scopes( self.endpoint_client_id ).manage_collections ] @classproperty def resource_server( # pylint: disable=missing-param-doc self_or_cls: client.BaseClient | type[client.BaseClient], ) -> str | None: """ The resource server for a GCS endpoint is the ID of its GCS Manager Client. This will return None if called as a classmethod as an instantiated ``GCSClient`` is required to look up the client ID from the endpoint. """ if not isinstance(self_or_cls, GCSClient): return None return self_or_cls.endpoint_client_id @property def endpoint_client_id(self) -> str: """ The UUID of the GCS Manager client of the endpoint this client is configured for. This will be equal to the ``endpoint_id`` in most cases, but when they differ the ``client_id`` is the canonical value for the endpoint's resource server and scopes. """ if self._endpoint_client_id: return self._endpoint_client_id else: data = self.get_gcs_info() try: endpoint_id = str(data["client_id"]) except KeyError: print(data) self._endpoint_client_id = endpoint_id return endpoint_id # # endpoint methods # def get_gcs_info( self, query_params: dict[str, t.Any] | None = None ) -> UnpackingGCSResponse: """ Get information about the GCS Manager service this client is configured for. This call is made unauthenticated. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /info`` .. extdoclink:: Get Endpoint :ref: openapi/#getInfo :service: gcs """ return UnpackingGCSResponse( self.get("/info", query_params=query_params, automatic_authorization=False), "info", ) def get_endpoint( self, query_params: dict[str, t.Any] | None = None ) -> UnpackingGCSResponse: """ Get the details of the Endpoint that this client is configured to talk to. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint`` .. extdoclink:: Get Endpoint :ref: openapi_Endpoint/#getEndpoint :service: gcs """ return UnpackingGCSResponse( self.get("/endpoint", query_params=query_params), "endpoint", ) def update_endpoint( self, endpoint_data: dict[str, t.Any] | EndpointDocument, *, include: ( t.Iterable[t.Literal["endpoint"]] | t.Literal["endpoint"] | MissingType ) = MISSING, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Update a GCSv5 Endpoint :param endpoint_data: The endpoint document for the modified endpoint :param include: Optional list of document types to include in the response (currently only supports the value ``["endpoint"]``) :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PATCH /endpoint`` .. extdoclink:: Update Endpoint :ref: openapi_Endpoint/#patchEndpoint :service: gcs """ query_params = {"include": commajoin(include), **(query_params or {})} return UnpackingGCSResponse( self.patch( "/endpoint", data=endpoint_data, query_params=query_params, ), "endpoint", ) # # collection methods # @paging.has_paginator( paging.MarkerPaginator, items_key="data", ) def get_collection_list( self, *, mapped_collection_id: uuid.UUID | str | MissingType = MISSING, filter: ( # pylint: disable=redefined-builtin str | t.Iterable[str] | MissingType ) = MISSING, include: str | t.Iterable[str] | MissingType = MISSING, page_size: int | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ List the Collections on an Endpoint :param mapped_collection_id: Filter collections which were created using this mapped collection ID. :param filter: Filter the returned set to any combination of the following: ``mapped_collections``, ``guest_collections``, ``managed_by_me``, ``created_by_me``. :param include: Names of additional documents to include in the response :param page_size: Number of results to return per page :param marker: Pagination marker supplied by previous API calls in the event a request returns more values than the page size :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /collections`` .. extdoclink:: List Collections :ref: openapi_Collections/#ListCollections :service: gcs """ query_params = { "include": commajoin(include), "page_size": page_size, "marker": marker, "mapped_collection_id": mapped_collection_id, "filter": commajoin(filter), **(query_params or {}), } return IterableGCSResponse(self.get("collections", query_params=query_params)) def get_collection( self, collection_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Lookup a Collection on an Endpoint :param collection_id: The ID of the collection to lookup :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /collections/{collection_id}`` .. extdoclink:: Get Collection :ref: openapi_Collections/#getCollection :service: gcs """ return UnpackingGCSResponse( self.get(f"/collections/{collection_id}", query_params=query_params), "collection", ) def create_collection( self, collection_data: dict[str, t.Any] | CollectionDocument, ) -> UnpackingGCSResponse: """ Create a collection. This is used to create either a mapped or a guest collection. When created, a ``collection:administrator`` role for that collection will be created using the caller’s identity. In order to create a guest collection, the caller must have an identity that matches the Storage Gateway policies. In order to create a mapped collection, the caller must have an ``endpoint:administrator`` or ``endpoint:owner`` role. :param collection_data: The collection document for the new collection .. tab-set:: .. tab-item:: API Info ``POST /collections`` .. extdoclink:: Create Collection :ref: openapi_Collections/#createCollection :service: gcs """ return UnpackingGCSResponse( self.post("/collections", data=collection_data), "collection" ) def update_collection( self, collection_id: uuid.UUID | str, collection_data: dict[str, t.Any] | CollectionDocument, *, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Update a Collection :param collection_id: The ID of the collection to update :param collection_data: The collection document for the modified collection :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PATCH /collections/{collection_id}`` .. extdoclink:: Update Collection :ref: openapi_Collections/#patchCollection :service: gcs """ return UnpackingGCSResponse( self.patch( f"/collections/{collection_id}", data=collection_data, query_params=query_params, ), "collection", ) def delete_collection( self, collection_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete a Collection :param collection_id: The ID of the collection to delete :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``DELETE /collections/{collection_id}`` .. extdoclink:: Delete Collection :ref: openapi_Collections/#deleteCollection :service: gcs """ return self.delete(f"/collections/{collection_id}", query_params=query_params) # # storage gateway methods # @paging.has_paginator( paging.MarkerPaginator, items_key="data", ) def get_storage_gateway_list( self, *, include: str | t.Iterable[str] | MissingType = MISSING, page_size: int | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ List Storage Gateways :param include: Optional document types to include in the response. If 'private_policies' is included, then include private storage gateway policies in the attached storage_gateways document. This requires an ``administrator`` role on the Endpoint. :param page_size: Number of results to return per page :param marker: Pagination marker supplied by previous API calls in the event a request returns more values than the page size :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Paginated Usage .. paginatedusage:: get_storage_gateway_list .. tab-item:: API Info ``GET /storage_gateways`` .. extdoclink:: Delete Collection :ref: openapi_Storage_Gateways/#getStorageGateways :service: gcs """ query_params = { "include": commajoin(include), "page_size": page_size, "marker": marker, **(query_params or {}), } return IterableGCSResponse( self.get("/storage_gateways", query_params=query_params) ) def create_storage_gateway( self, data: dict[str, t.Any] | StorageGatewayDocument, *, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Create a Storage Gateway :param data: Data in the format of a Storage Gateway document, it is recommended to use the ``StorageGatewayDocument`` class to construct this data. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /storage_gateways`` .. extdoclink:: Create Storage Gateway :ref: openapi_Storage_Gateways/#postStorageGateway :service: gcs """ return UnpackingGCSResponse( self.post("/storage_gateways", data=data, query_params=query_params), "storage_gateway", ) def get_storage_gateway( self, storage_gateway_id: uuid.UUID | str, *, include: str | t.Iterable[str] | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Lookup a Storage Gateway by ID :param storage_gateway_id: UUID for the Storage Gateway to be gotten :param include: Optional document types to include in the response. If 'private_policies' is included, then include private storage gateway policies in the attached storage_gateways document. This requires an ``administrator`` role on the Endpoint. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /storage_gateways/`` .. extdoclink:: Get a Storage Gateway :ref: openapi_Storage_Gateways/#getStorageGateway :service: gcs """ query_params = {"include": commajoin(include), **(query_params or {})} return UnpackingGCSResponse( self.get( f"/storage_gateways/{storage_gateway_id}", query_params=query_params, ), "storage_gateway", ) def update_storage_gateway( self, storage_gateway_id: uuid.UUID | str, data: dict[str, t.Any] | StorageGatewayDocument, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Update a Storage Gateway :param storage_gateway_id: UUID for the Storage Gateway to be updated :param data: Data in the format of a Storage Gateway document, it is recommended to use the ``StorageGatewayDocument`` class to construct this data. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PATCH /storage_gateways/`` .. extdoclink:: Update a Storage Gateway :ref: openapi_Storage_Gateways/#patchStorageGateway :service: gcs """ return self.patch( f"/storage_gateways/{storage_gateway_id}", data=data, query_params=query_params, ) def delete_storage_gateway( self, storage_gateway_id: str | uuid.UUID, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete a Storage Gateway :param storage_gateway_id: UUID for the Storage Gateway to be deleted :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``DELETE /storage_gateways/`` .. extdoclink:: Delete a Storage Gateway :ref: openapi_Storage_Gateways/#deleteStorageGateway :service: gcs """ return self.delete( f"/storage_gateways/{storage_gateway_id}", query_params=query_params ) # # role methods # @paging.has_paginator( paging.MarkerPaginator, items_key="data", ) def get_role_list( self, collection_id: uuid.UUID | str | MissingType = MISSING, include: str | MissingType = MISSING, page_size: int | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ List Roles :param collection_id: UUID of a Collection. If given then only roles related to that Collection are returned, otherwise only Endpoint roles are returned. :param include: Pass "all_roles" to request all roles relevant to the resource instead of only those the caller has on the resource :param page_size: Number of results to return per page :param marker: Pagination marker supplied by previous API calls in the event a request returns more values than the page size :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /roles`` .. extdoclink:: Delete a Storage Gateway :ref: openapi_Roles/#listRoles :service: gcs """ query_params = { "include": include, "page_size": page_size, "marker": marker, "collection_id": collection_id, **(query_params or {}), } path = "/roles" return IterableGCSResponse(self.get(path, query_params=query_params)) def create_role( self, data: dict[str, t.Any] | GCSRoleDocument, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Create a Role :param data: Data in the format of a Role document, it is recommended to use the `GCSRoleDocument` class to construct this data. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /roles`` .. extdoclink:: Create Role :ref: openapi_Roles/#postRole :service: gcs """ path = "/roles" return UnpackingGCSResponse( self.post(path, data=data, query_params=query_params), "role", ) def get_role( self, role_id: uuid.UUID | str, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Get a Role by ID :param role_id: UUID for the Role to be gotten :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /roles/{role_id}`` .. extdoclink:: Get Role :ref: openapi_Roles/#getRole :service: gcs """ path = f"/roles/{role_id}" return UnpackingGCSResponse(self.get(path, query_params=query_params), "role") def delete_role( self, role_id: uuid.UUID | str, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete a Role :param role_id: UUID for the Role to be deleted :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``DELETE /roles/{role_id}`` .. extdoclink:: Delete Role :ref: openapi_Roles/#deleteRole :service: gcs """ path = f"/roles/{role_id}" return self.delete(path, query_params=query_params) @paging.has_paginator( paging.MarkerPaginator, items_key="data", ) def get_user_credential_list( self, storage_gateway: uuid.UUID | str | MissingType = MISSING, page_size: int | MissingType = MISSING, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ List User Credentials :param storage_gateway: UUID of a storage gateway to limit results to :param query_params: Additional passthrough query parameters :param page_size: Number of results to return per page :param marker: Pagination marker supplied by previous API calls in the event a request returns more values than the page size .. tab-set:: .. tab-item:: API Info ``GET /user_credentials`` .. extdoclink:: Get User Credential List :ref: openapi_User_Credentials/#getUserCredentials :service: gcs """ query_params = { "storage_gateway": storage_gateway, "page_size": page_size, "marker": marker, **(query_params or {}), } path = "/user_credentials" return IterableGCSResponse(self.get(path, query_params=query_params)) def create_user_credential( self, data: dict[str, t.Any] | UserCredentialDocument, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Create a User Credential :param data: Data in the format of a UserCredential document, it is recommended to use the `UserCredential` class to construct this :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /user_credentials`` .. extdoclink:: Create User Credential :ref: openapi_User_Credentials/#postUserCredential :service: gcs """ path = "/user_credentials" return UnpackingGCSResponse( self.post(path, data=data, query_params=query_params), "user_credential", ) def get_user_credential( self, user_credential_id: uuid.UUID | str, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Get a User Credential by ID :param user_credential_id: UUID for the UserCredential to be gotten :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /user_credentials/{user_credential_id}`` .. extdoclink:: Get a User Credential :ref: openapi_User_Credentials/#getUserCredential :service: gcs """ path = f"/user_credentials/{user_credential_id}" return UnpackingGCSResponse( self.get(path, query_params=query_params), "user_credential" ) def update_user_credential( self, user_credential_id: uuid.UUID | str, data: dict[str, t.Any] | UserCredentialDocument, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ Update a User Credential :param user_credential_id: UUID for the UserCredential to be updated :param data: Data in the format of a UserCredential document, it is recommended to use the `UserCredential` class to construct this :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PATCH /user_credentials/{user_credential_id}`` .. extdoclink:: Update a User Credential :ref: openapi_User_Credentials/#patchUserCredential :service: gcs """ path = f"/user_credentials/{user_credential_id}" return UnpackingGCSResponse( self.patch(path, data=data, query_params=query_params), "user_credential" ) def delete_user_credential( self, user_credential_id: uuid.UUID | str, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete a User Credential :param user_credential_id: UUID for the UserCredential to be deleted :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``DELETE /user_credentials/{user_credential_id}`` .. extdoclink:: Delete User Credential :ref: openapi_User_Credentials/#deleteUserCredential :service: gcs """ path = f"/user_credentials/{user_credential_id}" return self.delete(path, query_params=query_params) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/connector_table.py000066400000000000000000000143201513221403200277530ustar00rootroot00000000000000from __future__ import annotations import dataclasses import re import typing as t import uuid _NORMALIZATION_PATTERN = re.compile(r"[_\- ]+") def _normalize_name(name: str) -> str: return _NORMALIZATION_PATTERN.sub("-", name.strip()).lower() @dataclasses.dataclass class GlobusConnectServerConnector: """ A container for Globus Connect Server Connector descriptions. Contains a ``name`` and a ``connector_id``. """ name: str connector_id: str class ConnectorTable: """ This class defines the known Globus Connect Server Connectors in a mapping structure. It supports access by attribute or via a helper method for doing lookups. For example, all of the following three usages retrieve the Azure Blob connector: .. code-block:: pycon >>> ConnectorTable.AZURE_BLOB >>> ConnectorTable.lookup("Azure Blob") >>> ConnectorTable.lookup("9436da0c-a444-11eb-af93-12704e0d6a4d") Given the results of such a lookup, you can retrieve the canonical name and ID for a connector like so: .. code-block:: pycon >>> connector = ConnectorTable.AZURE_BLOB >>> connector.name 'Azure Blob' >>> connector.connector_id '9436da0c-a444-11eb-af93-12704e0d6a4d' """ _connectors: t.ClassVar[tuple[tuple[str, str, str], ...]] = ( ("ACTIVESCALE", "ActiveScale", "7251f6c8-93c9-11eb-95ba-12704e0d6a4d"), ("AZURE_BLOB", "Azure Blob", "9436da0c-a444-11eb-af93-12704e0d6a4d"), ("BLACKPEARL", "BlackPearl", "7e3f3f5e-350c-4717-891a-2f451c24b0d4"), ("BOX", "Box", "7c100eae-40fe-11e9-95a3-9cb6d0d9fd63"), ("CEPH", "Ceph", "1b6374b0-f6a4-4cf7-a26f-f262d9c6ca72"), ("DROPBOX", "Dropbox", "49b00fd6-63f1-48ae-b27f-d8af4589f876"), ( "GOOGLE_CLOUD_STORAGE", "Google Cloud Storage", "56366b96-ac98-11e9-abac-9cb6d0d9fd63", ), ("GOOGLE_DRIVE", "Google Drive", "976cf0cf-78c3-4aab-82d2-7c16adbcc281"), ("HPSS", "HPSS", "fb656a17-0f69-4e59-95ff-d0a62ca7bdf5"), ("IRODS", "iRODS", "e47b6920-ff57-11ea-8aaa-000c297ab3c2"), ("POSIX", "POSIX", "145812c8-decc-41f1-83cf-bb2a85a2a70b"), ("POSIX_STAGING", "POSIX Staging", "052be037-7dda-4d20-b163-3077314dc3e6"), ("ONEDRIVE", "OneDrive", "28ef55da-1f97-11eb-bdfd-12704e0d6a4d"), ("S3", "S3", "7643e831-5f6c-4b47-a07f-8ee90f401d23"), ) ACTIVESCALE: t.ClassVar[GlobusConnectServerConnector] AZURE_BLOB: t.ClassVar[GlobusConnectServerConnector] BLACKPEARL: t.ClassVar[GlobusConnectServerConnector] BOX: t.ClassVar[GlobusConnectServerConnector] CEPH: t.ClassVar[GlobusConnectServerConnector] DROPBOX: t.ClassVar[GlobusConnectServerConnector] GOOGLE_CLOUD_STORAGE: t.ClassVar[GlobusConnectServerConnector] GOOGLE_DRIVE: t.ClassVar[GlobusConnectServerConnector] HPSS: t.ClassVar[GlobusConnectServerConnector] IRODS: t.ClassVar[GlobusConnectServerConnector] ONEDRIVE: t.ClassVar[GlobusConnectServerConnector] POSIX: t.ClassVar[GlobusConnectServerConnector] POSIX_STAGING: t.ClassVar[GlobusConnectServerConnector] S3: t.ClassVar[GlobusConnectServerConnector] @classmethod def all_connectors(cls) -> t.Iterable[GlobusConnectServerConnector]: """ Return an iterator of all known connectors. """ for attribute, _, _ in cls._connectors: item: GlobusConnectServerConnector = getattr(cls, attribute) yield item @classmethod def lookup(cls, name_or_id: uuid.UUID | str) -> GlobusConnectServerConnector | None: """ Convert a name or ID into a connector object. Returns None if the name or ID is not recognized. Names are normalized before lookup so that they are case-insensitive and spaces, dashes, and underscores are all treated equivalently. For example, ``Google Drive``, ``google-drive``, and ``gOOgle_dRiVe`` are all equivalent. :param name_or_id: The name or ID of the connector """ normalized = _normalize_name(str(name_or_id)) for connector in cls.all_connectors(): if normalized == connector.connector_id or normalized == _normalize_name( connector.name ): return connector return None @classmethod def extend( cls, *, connector_name: str, connector_id: uuid.UUID | str, attribute_name: str | None = None, ) -> type[ConnectorTable]: """ Extend the ConnectorTable class with a new connector, returning a new ConnectorTable subclass. Usage example: .. code-block:: pycon >>> MyTable = ConnectorTable.extend( ... connector_name="Star Trek Transporter", ... connector_id="0b19772d-729a-4c8f-93e1-59d5778cecf3", ... ) >>> obj = MyTable.STAR_TREK_TRANSPORTER >>> obj.connector_id '0b19772d-729a-4c8f-93e1-59d5778cecf3' >>> obj.name 'Star Trek Transporter' :param connector_name: The name of the connector to add :param connector_id: The ID of the connector to add :param attribute_name: The attribute name with which the connector will be attached to the new subclass. Defaults to the connector name uppercased and with spaces converted to underscores. """ if attribute_name is None: attribute_name = connector_name.upper().replace(" ", "_") connector_id_str = str(connector_id) connectors = cls._connectors + ( (attribute_name, connector_name, connector_id_str), ) connector_obj = GlobusConnectServerConnector( name=connector_name, connector_id=connector_id_str ) return type( "ExtendedConnectorTable", (cls,), {"_connectors": connectors, attribute_name: connector_obj}, ) # "render" the _connectors to live attributes of the ConnectorTable for _attribute, _name, _id in ConnectorTable._connectors: setattr( ConnectorTable, _attribute, GlobusConnectServerConnector(name=_name, connector_id=_id), ) del _attribute, _name, _id globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/000077500000000000000000000000001513221403200251515ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/__init__.py000066400000000000000000000034471513221403200272720ustar00rootroot00000000000000from .collection import ( CollectionDocument, CollectionPolicies, GoogleCloudStorageCollectionPolicies, GuestCollectionDocument, MappedCollectionDocument, POSIXCollectionPolicies, POSIXStagingCollectionPolicies, ) from .endpoint import EndpointDocument from .role import GCSRoleDocument from .storage_gateway import ( ActiveScaleStoragePolicies, AzureBlobStoragePolicies, BlackPearlStoragePolicies, BoxStoragePolicies, CephStoragePolicies, GoogleCloudStoragePolicies, GoogleDriveStoragePolicies, HPSSStoragePolicies, IrodsStoragePolicies, OneDriveStoragePolicies, POSIXStagingStoragePolicies, POSIXStoragePolicies, S3StoragePolicies, StorageGatewayDocument, StorageGatewayPolicies, ) from .user_credential import UserCredentialDocument __all__ = ( # endpoint documents "EndpointDocument", # collection documents "MappedCollectionDocument", "GuestCollectionDocument", "CollectionDocument", # collection document second-order helpers "CollectionPolicies", "POSIXCollectionPolicies", "POSIXStagingCollectionPolicies", "GoogleCloudStorageCollectionPolicies", # role document "GCSRoleDocument", # storage gateway document "StorageGatewayDocument", # storage gateway document second-order helpers "StorageGatewayPolicies", "POSIXStoragePolicies", "POSIXStagingStoragePolicies", "BlackPearlStoragePolicies", "BoxStoragePolicies", "CephStoragePolicies", "GoogleDriveStoragePolicies", "GoogleCloudStoragePolicies", "OneDriveStoragePolicies", "AzureBlobStoragePolicies", "S3StoragePolicies", "ActiveScaleStoragePolicies", "IrodsStoragePolicies", "HPSSStoragePolicies", # user credential document "UserCredentialDocument", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/_common.py000066400000000000000000000027201513221403200271530ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk._missing import MISSING VersionTuple = t.Tuple[int, int, int] DatatypeCallback = t.Callable[["DocumentWithInducedDatatype"], t.Optional[VersionTuple]] class DocumentWithInducedDatatype(t.Protocol): DATATYPE_BASE: str DATATYPE_VERSION_IMPLICATIONS: dict[str, VersionTuple] DATATYPE_VERSION_CALLBACKS: tuple[DatatypeCallback, ...] def __contains__(self, key: str) -> bool: # pragma: no cover ... def __setitem__(self, key: str, value: t.Any) -> None: # pragma: no cover ... def __getitem__(self, key: str) -> t.Any: # pragma: no cover ... def deduce_datatype_version(obj: DocumentWithInducedDatatype) -> str: max_deduced_version = (1, 0, 0) for fieldname, version in obj.DATATYPE_VERSION_IMPLICATIONS.items(): if fieldname not in obj or obj[fieldname] is MISSING: continue if version > max_deduced_version: max_deduced_version = version for callback in obj.DATATYPE_VERSION_CALLBACKS: opt_version = callback(obj) if opt_version is not None and opt_version > max_deduced_version: max_deduced_version = opt_version return ".".join(str(x) for x in max_deduced_version) def ensure_datatype(obj: DocumentWithInducedDatatype) -> None: if "DATA_TYPE" not in obj or obj["DATA_TYPE"] is MISSING: obj["DATA_TYPE"] = f"{obj.DATATYPE_BASE}#{deduce_datatype_version(obj)}" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/collection.py000066400000000000000000000575311513221403200276710ustar00rootroot00000000000000from __future__ import annotations import abc import typing as t import uuid from globus_sdk._internal.remarshal import strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import AbstractGlobusPayload from ._common import ( DatatypeCallback, DocumentWithInducedDatatype, VersionTuple, ensure_datatype, ) # # NOTE -- on the organization of arguments in this module -- # # The arguments to each collection type are defined explicitly for good type annotations # and documentation. # However, it's easy for things to get out of sync or different between the various # locations, so we need to impose some order on things to make comparisons easy. # # Complicating this, there are some arguments to specific types which aren't shared # by the common base. # # The rule and rationale used is as follows: # - DATA_TYPE is special, and always first # - next, the common optional arguments (shared by all) # - after that, the specific optional arguments for this type/subtype # - 'additional_fields' is special, and always last # # within those listings of common and specific arguments, the following ordering is # maintained: # - strings, sorted alphabetically # - string lists, sorted alphabetically # - bools, sorted alphabetically # - ints, sorted alphabetically # - dicts and other types, sorted alphabetically # # This makes it possible to do side-by-side comparison of common arguments, to ensure # that they are all present and accounted-for in all contexts, and allows us to compare # definition lists for param docs and arguments against usage sites to ensure that all # arguments which are passed are actually used # def _user_message_length_callback( obj: DocumentWithInducedDatatype, ) -> VersionTuple | None: if ( "user_message" in obj and isinstance(obj["user_message"], str) and len(obj["user_message"]) > 64 ): return (1, 7, 0) return None # Declare a metaclass of ABCMeta even though inheriting from `Payload` renders it # inert. This will let type checkers understand that this class is abstract. class CollectionDocument(AbstractGlobusPayload): """ This is the base class for :class:`~.MappedCollectionDocument` and :class:`~.GuestCollectionDocument`. Parameters common to both of those are defined and documented here. :param data_type: Explicitly set the ``DATA_TYPE`` value for this collection. Normally ``DATA_TYPE`` is deduced from the provided parameters and should not be set. To maximize compatibility with different versions of GCS, only set this value when necessary. :param collection_base_path: The location of the collection on its underlying storage. For a mapped collection, this is an absolute path on the storage system named by the ``storage_gateway_id``. For a guest collection, this is a path relative to the value of the ``root_path`` attribute on the mapped collection identified by the ``mapped_collection_id``. This parameter is optional for updates but required when creating a new collection. :param contact_email: Email address of the support contact for the collection :param contact_info: Other contact information for the collection, e.g. phone number or mailing address :param default_directory: Default directory when using the collection :param department: The department which operates the collection :param description: A text description of the collection :param display_name: Friendly name for the collection :param identity_id: The Globus Auth identity which acts as the owner of the collection :param info_link: Link for more info about the collection :param organization: The organization which maintains the collection :param restrict_transfers_to_high_assurance: Require that transfers of the given type involve only collections that are high assurance. Valid values: "inbound", "outbound", "all", None :param user_message: A message to display to users when interacting with this collection :param user_message_link: A link to additional messaging for users when interacting with this collection :param keywords: A list of keywords used to help searches for the collection :param disable_verify: Disable verification checksums on transfers to and from this collection :param enable_https: Enable or disable HTTPS support (requires a managed endpoint) :param force_encryption: When set to True, all transfers to and from the collection are always encrypted :param force_verify: Force verification checksums on transfers to and from this collection :param public: If True, the collection will be visible to other Globus users :param acl_expiration_mins: Length of time that guest collection permissions are valid. Only settable on HA guest collections and HA mapped collections and used by guest collections attached to it. When set on both the mapped and guest collections, the lesser value is in effect. :param associated_flow_policy: Policy describing Globus flows to run when the collection is accessed. See https://docs.globus.org/api/transfer/endpoints_and_collections/#associated_flow_policy for expected shape. :param additional_fields: Additional data for inclusion in the collection document """ DATATYPE_BASE: str = "collection" DATATYPE_VERSION_IMPLICATIONS: dict[str, tuple[int, int, int]] = { "associated_flow_policy": (1, 15, 0), "activity_notification_policy": (1, 14, 0), "auto_delete_timeout": (1, 13, 0), "skip_auto_delete": (1, 13, 0), "restrict_transfers_to_high_assurance": (1, 12, 0), "acl_expiration_mins": (1, 10, 0), "delete_protected": (1, 8, 0), "guest_auth_policy_id": (1, 6, 0), "disable_anonymous_writes": (1, 5, 0), "force_verify": (1, 4, 0), "sharing_users_allow": (1, 2, 0), "sharing_users_deny": (1, 2, 0), "enable_https": (1, 1, 0), "user_message": (1, 1, 0), "user_message_link": (1, 1, 0), } DATATYPE_VERSION_CALLBACKS: tuple[DatatypeCallback, ...] = ( _user_message_length_callback, ) def __init__( self, *, # data_type data_type: str | MissingType = MISSING, # strs collection_base_path: str | MissingType = MISSING, contact_email: str | None | MissingType = MISSING, contact_info: str | None | MissingType = MISSING, default_directory: str | MissingType = MISSING, department: str | None | MissingType = MISSING, description: str | None | MissingType = MISSING, display_name: str | MissingType = MISSING, identity_id: uuid.UUID | str | MissingType = MISSING, info_link: str | None | MissingType = MISSING, organization: str | MissingType = MISSING, restrict_transfers_to_high_assurance: ( t.Literal["inbound", "outbound", "all"] | MissingType ) = MISSING, user_message: str | None | MissingType = MISSING, user_message_link: str | None | MissingType = MISSING, # str lists keywords: t.Iterable[str] | MissingType = MISSING, # bools disable_verify: bool | MissingType = MISSING, enable_https: bool | MissingType = MISSING, force_encryption: bool | MissingType = MISSING, force_verify: bool | MissingType = MISSING, public: bool | MissingType = MISSING, # ints acl_expiration_mins: int | MissingType = MISSING, # dicts associated_flow_policy: dict[str, t.Any] | MissingType = MISSING, # additional fields additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["collection_type"] = self.collection_type self["DATA_TYPE"] = data_type self["collection_base_path"] = collection_base_path self["contact_email"] = contact_email self["contact_info"] = contact_info self["default_directory"] = default_directory self["department"] = department self["description"] = description self["display_name"] = display_name self["identity_id"] = identity_id self["info_link"] = info_link self["organization"] = organization self["restrict_transfers_to_high_assurance"] = ( restrict_transfers_to_high_assurance ) self["user_message"] = user_message self["user_message_link"] = user_message_link self["keywords"] = strseq_listify(keywords) self["disable_verify"] = disable_verify self["enable_https"] = enable_https self["force_encryption"] = force_encryption self["force_verify"] = force_verify self["public"] = public self["acl_expiration_mins"] = acl_expiration_mins self["associated_flow_policy"] = associated_flow_policy if additional_fields is not MISSING: self.update(additional_fields) @property @abc.abstractmethod def collection_type(self) -> str: raise NotImplementedError class MappedCollectionDocument(CollectionDocument): """ An object used to represent a Mapped Collection for creation or update operations. The initializer supports all writable fields on Mapped Collections but does not include read-only fields like ``id``. Because these documents may be used for updates, no fields are strictly required. However, GCS will require the following fields for creation: - ``storage_gateway_id`` - ``collection_base_path`` All parameters for :class:`~.CollectionDocument` are supported in addition to the parameters below. :param storage_gateway_id: The ID of the storage gateway which hosts this mapped collection. This parameter is required when creating a collection. :param domain_name: DNS name of the virtual host serving this collection :param guest_auth_policy_id: Globus Auth policy ID to set on a mapped collection which is then inherited by its guest collections. :param sharing_users_allow: Connector-specific usernames allowed to create guest collections :param sharing_users_deny: Connector-specific usernames forbidden from creating guest collections :param delete_protected: Enable or disable deletion protection on this collection. Defaults to ``True`` during creation. :param allow_guest_collections: Enable or disable creation and use of Guest Collections on this Mapped Collection :param disable_anonymous_writes: Allow anonymous write ACLs on Guest Collections attached to this Mapped Collection. This option is only usable on non high-assurance collections :param auto_delete_timeout: Delete child guest collections that have not been accessed within the specified timeout period in days. :param policies: Connector-specific collection policies :param sharing_restrict_paths: A PathRestrictions document """ @property def collection_type(self) -> str: return "mapped" def __init__( self, *, # data type data_type: str | MissingType = MISSING, # > common args start < # strs collection_base_path: str | MissingType = MISSING, contact_email: str | None | MissingType = MISSING, contact_info: str | None | MissingType = MISSING, default_directory: str | MissingType = MISSING, department: str | None | MissingType = MISSING, description: str | None | MissingType = MISSING, display_name: str | MissingType = MISSING, identity_id: uuid.UUID | str | MissingType = MISSING, info_link: str | None | MissingType = MISSING, organization: str | MissingType = MISSING, restrict_transfers_to_high_assurance: ( t.Literal["inbound", "outbound", "all"] | MissingType ) = MISSING, user_message: str | None | MissingType = MISSING, user_message_link: str | None | MissingType = MISSING, # str lists keywords: t.Iterable[str] | MissingType = MISSING, # bools disable_verify: bool | MissingType = MISSING, enable_https: bool | MissingType = MISSING, force_encryption: bool | MissingType = MISSING, force_verify: bool | MissingType = MISSING, public: bool | MissingType = MISSING, # ints acl_expiration_mins: int | MissingType = MISSING, # > common args end < # > specific args start < # strs domain_name: str | MissingType = MISSING, guest_auth_policy_id: uuid.UUID | str | None | MissingType = MISSING, storage_gateway_id: uuid.UUID | str | MissingType = MISSING, # str lists sharing_users_allow: t.Iterable[str] | None | MissingType = MISSING, sharing_users_deny: t.Iterable[str] | None | MissingType = MISSING, sharing_restrict_paths: dict[str, t.Any] | None | MissingType = MISSING, # bools delete_protected: bool | MissingType = MISSING, allow_guest_collections: bool | MissingType = MISSING, disable_anonymous_writes: bool | MissingType = MISSING, # ints auto_delete_timeout: int | MissingType = MISSING, # dicts associated_flow_policy: dict[str, t.Any] | MissingType = MISSING, policies: CollectionPolicies | dict[str, t.Any] | MissingType = MISSING, # > specific args end < # additional fields additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__( # data type data_type=data_type, # strings collection_base_path=collection_base_path, contact_email=contact_email, contact_info=contact_info, default_directory=default_directory, department=department, description=description, display_name=display_name, identity_id=identity_id, info_link=info_link, organization=organization, restrict_transfers_to_high_assurance=restrict_transfers_to_high_assurance, user_message=user_message, user_message_link=user_message_link, # bools disable_verify=disable_verify, enable_https=enable_https, force_encryption=force_encryption, force_verify=force_verify, public=public, # ints acl_expiration_mins=acl_expiration_mins, # str lists keywords=keywords, # str dicts associated_flow_policy=associated_flow_policy, # additional fields additional_fields=additional_fields, ) self["domain_name"] = domain_name self["restrict_transfers_to_high_assurance"] = ( restrict_transfers_to_high_assurance ) self["guest_auth_policy_id"] = guest_auth_policy_id self["storage_gateway_id"] = storage_gateway_id self["sharing_users_allow"] = strseq_listify(sharing_users_allow) self["sharing_users_deny"] = strseq_listify(sharing_users_deny) self["delete_protected"] = delete_protected self["allow_guest_collections"] = allow_guest_collections self["disable_anonymous_writes"] = disable_anonymous_writes self["auto_delete_timeout"] = auto_delete_timeout self["sharing_restrict_paths"] = sharing_restrict_paths self["policies"] = policies ensure_datatype(self) class GuestCollectionDocument(CollectionDocument): """ An object used to represent a Guest Collection for creation or update operations. The initializer supports all writable fields on Guest Collections but does not include read-only fields like ``id``. Because these documents may be used for updates, no fields are strictly required. However, GCS will require the following fields for creation: - ``mapped_collection_id`` - ``user_credential_id`` - ``collection_base_path`` All parameters for :class:`~.CollectionDocument` are supported in addition to the parameters below. :param mapped_collection_id: The ID of the mapped collection which hosts this guest collection :param user_credential_id: The ID of the User Credential which is used to access data on this collection. This credential must be owned by the collection’s ``identity_id``. :param skip_auto_delete: Indicates whether the collection is exempt from its parent mapped collection's automatic deletion policy. :param activity_notification_policy: Specification for when a notification email should be sent to a guest collection ``administrator``, ``activity_manager``, and ``activity_monitor`` roles when a transfer task reaches completion. """ @property def collection_type(self) -> str: return "guest" def __init__( self, *, # data type data_type: str | MissingType = MISSING, # > common args start < # strs collection_base_path: str | MissingType = MISSING, contact_email: str | None | MissingType = MISSING, contact_info: str | None | MissingType = MISSING, default_directory: str | MissingType = MISSING, department: str | None | MissingType = MISSING, description: str | None | MissingType = MISSING, display_name: str | MissingType = MISSING, identity_id: uuid.UUID | str | MissingType = MISSING, info_link: str | None | MissingType = MISSING, organization: str | MissingType = MISSING, restrict_transfers_to_high_assurance: ( t.Literal["inbound", "outbound", "all"] | MissingType ) = MISSING, user_message: str | None | MissingType = MISSING, user_message_link: str | None | MissingType = MISSING, # str lists keywords: t.Iterable[str] | MissingType = MISSING, # bools disable_verify: bool | MissingType = MISSING, enable_https: bool | MissingType = MISSING, force_encryption: bool | MissingType = MISSING, force_verify: bool | MissingType = MISSING, public: bool | MissingType = MISSING, # ints acl_expiration_mins: int | MissingType = MISSING, # dicts associated_flow_policy: dict[str, t.Any] | MissingType = MISSING, # > common args end < # > specific args start < mapped_collection_id: uuid.UUID | str | MissingType = MISSING, user_credential_id: uuid.UUID | str | MissingType = MISSING, skip_auto_delete: bool | MissingType = MISSING, activity_notification_policy: dict[str, list[str]] | MissingType = MISSING, # > specific args end < # additional fields additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__( # data type data_type=data_type, # strings collection_base_path=collection_base_path, contact_email=contact_email, contact_info=contact_info, default_directory=default_directory, department=department, description=description, display_name=display_name, identity_id=identity_id, info_link=info_link, organization=organization, restrict_transfers_to_high_assurance=restrict_transfers_to_high_assurance, user_message=user_message, user_message_link=user_message_link, # bools disable_verify=disable_verify, enable_https=enable_https, force_encryption=force_encryption, force_verify=force_verify, public=public, # ints acl_expiration_mins=acl_expiration_mins, # str lists keywords=keywords, # dicts associated_flow_policy=associated_flow_policy, # additional fields additional_fields=additional_fields, ) self["mapped_collection_id"] = mapped_collection_id self["user_credential_id"] = user_credential_id self["skip_auto_delete"] = skip_auto_delete self["activity_notification_policy"] = activity_notification_policy ensure_datatype(self) class CollectionPolicies(AbstractGlobusPayload): """ This is the abstract base type for Collection Policies documents to use as the ``policies`` parameter when creating a MappedCollectionDocument. """ class POSIXCollectionPolicies(CollectionPolicies): """ Convenience class for constructing a Posix Collection Policy document to use as the `policies` parameter when creating a CollectionDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param sharing_groups_allow: POSIX groups which are allowed to create guest collections. :param sharing_groups_deny: POSIX groups which are not allowed to create guest collections. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "posix_collection_policies#1.0.0", sharing_groups_allow: str | t.Iterable[str] | None | MissingType = MISSING, sharing_groups_deny: str | t.Iterable[str] | None | MissingType = MISSING, additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["sharing_groups_allow"] = strseq_listify(sharing_groups_allow) self["sharing_groups_deny"] = strseq_listify(sharing_groups_deny) if additional_fields is not MISSING: self.update(additional_fields) class POSIXStagingCollectionPolicies(CollectionPolicies): """ Convenience class for constructing a Posix Staging Collection Policy document to use as the ``policies`` parameter when creating a CollectionDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param sharing_groups_allow: POSIX groups which are allowed to create guest collections. :param sharing_groups_deny: POSIX groups which are not allowed to create guest collections. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "posix_staging_collection_policies#1.0.0", sharing_groups_allow: str | t.Iterable[str] | None | MissingType = MISSING, sharing_groups_deny: str | t.Iterable[str] | None | MissingType = MISSING, additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["sharing_groups_allow"] = strseq_listify(sharing_groups_allow) self["sharing_groups_deny"] = strseq_listify(sharing_groups_deny) if additional_fields is not MISSING: self.update(additional_fields) class GoogleCloudStorageCollectionPolicies(CollectionPolicies): """ Convenience class for constructing a Google Cloud Storage Collection Policy document to use as the ``policies`` parameter when creating a CollectionDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param project: Google Cloud Platform project ID that is used by this collection :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "google_cloud_storage_collection_policies#1.0.0", project: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["project"] = project if additional_fields is not MISSING: self.update(additional_fields) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/endpoint.py000066400000000000000000000156761513221403200273620ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk._internal.remarshal import strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload from globus_sdk.services.gcs.data._common import DatatypeCallback, ensure_datatype class EndpointDocument(GlobusPayload): r""" :param data_type: Explicitly set the ``DATA_TYPE`` value for this endpoint document. Normally ``DATA_TYPE`` is deduced from the provided parameters and should not be set. To maximize compatibility with different versions of GCS, only set this value when necessary. :param contact_email: Email address of the support contact for this endpoint. This is visible to end users so that they may contact your organization for support. :param contact_info: Other non-email contact information for the endpoint, e.g. phone and mailing address. This is visible to end users for support. :param department: Department within organization that runs the server(s). Searchable. Unicode string, max 1024 characters, no new lines. :param description: A description of the endpoint. :param display_name: Friendly name for the endpoint, not unique. Unicode string, no new lines (\r or \n). Searchable. :param info_link: Link to a web page with more information about the endpoint. The administrator is responsible for running a website at this URL and verifying that it is accepting public connections. :param network_use: Control how Globus interacts with this endpoint over the network. Allowed values are: * ``normal``: (Default) Uses an average level of concurrency and parallelism. The levels depend on the number of physical servers in the endpoint. * ``minimal``: Uses a minimal level of concurrency and parallelism. * ``aggressive``: Uses a high level of concurrency and parallelism. * ``custom``: Uses custom values of concurrency and parallelism set by the endpoint admin. When setting this level, you must also set the ``max_concurrency``, ``preferred_concurrency``, ``max_parallelism``, and ``preferred_parallelism`` properties. :param organization: Organization that runs the server(s). Searchable. Unicode string, max 1024 characters, no new lines. :param subscription_id: The id of the subscription that is managing this endpoint. This may be the special value DEFAULT when using this as input to PATCH or PUT to use the caller’s default subscription id. :param keywords: List of search keywords for the endpoint. Unicode string, max 1024 characters total across all strings. :param allow_udt: Allow data transfer on this endpoint using the UDT protocol. :param public: Flag indicating whether this endpoint is visible to all other Globus users. If false, only users which have been granted a role on the endpoint or one of its collections, or belong to a domain allowed access to any of its storage gateways may view it. :param gridftp_control_channel_port: TCP port for the Globus control channel to listen on. By default, the control channel is passed through 443 with an ALPN header containing the value "ftp". :param max_concurrency: Admin-specified value when the ``network_use`` property’s value is ``"custom"``; otherwise the preset value for the specified ``network_use``. :param max_parallelism: Admin-specified value when the ``network_use`` property’s value is ``"custom"``; otherwise the preset value for the specified ``network_use``. :param preferred_concurrency: Admin-specified value when the ``network_use`` property’s value is ``"custom"``; otherwise the preset value for the specified ``network_use``. :param preferred_parallelism: Admin-specified value when the ``network_use`` property’s value is ``"custom"``; otherwise the preset value for the specified ``network_use``. """ DATATYPE_BASE = "endpoint" DATATYPE_VERSION_IMPLICATIONS: dict[str, tuple[int, int, int]] = { "gridftp_control_channel_port": (1, 1, 0), } DATATYPE_VERSION_CALLBACKS: tuple[DatatypeCallback, ...] = () # Note: The fields below represent the set of mutable endpoint fields in # an Endpoint#1.2.0 document. # https://docs.globus.org/globus-connect-server/v5.4/api/schemas/Endpoint_1_2_0_schema/ # Read-only fields (e.g. "gcs_manager_url", "endpoint_id", and # "earliest_last_access") are intentionally omitted as this data class is designed # for input construction not response parsing. def __init__( self, *, # data type data_type: str | MissingType = MISSING, # strs contact_email: str | MissingType = MISSING, contact_info: str | MissingType = MISSING, department: str | MissingType = MISSING, description: str | MissingType = MISSING, display_name: str | MissingType = MISSING, info_link: str | MissingType = MISSING, network_use: ( t.Literal["normal", "minimal", "aggressive", "custom"] | MissingType ) = MISSING, organization: str | MissingType = MISSING, # nullable strs subscription_id: str | None | MissingType = MISSING, # str lists keywords: t.Iterable[str] | MissingType = MISSING, # bools allow_udt: bool | MissingType = MISSING, public: bool | MissingType = MISSING, # ints max_concurrency: int | MissingType = MISSING, max_parallelism: int | MissingType = MISSING, preferred_concurrency: int | MissingType = MISSING, preferred_parallelism: int | MissingType = MISSING, # nullable ints gridftp_control_channel_port: int | None | MissingType = MISSING, # additional fields additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["DATA_TYPE"] = data_type self["contact_email"] = contact_email self["contact_info"] = contact_info self["department"] = department self["description"] = description self["display_name"] = display_name self["info_link"] = info_link self["network_use"] = network_use self["organization"] = organization self["keywords"] = strseq_listify(keywords) self["allow_udt"] = allow_udt self["public"] = public self["max_concurrency"] = max_concurrency self["max_parallelism"] = max_parallelism self["preferred_concurrency"] = preferred_concurrency self["preferred_parallelism"] = preferred_parallelism self["subscription_id"] = subscription_id self["gridftp_control_channel_port"] = gridftp_control_channel_port if additional_fields is not MISSING: self.update(additional_fields) ensure_datatype(self) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/role.py000066400000000000000000000025671513221403200264760ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload class GCSRoleDocument(GlobusPayload): """ Convenience class for constructing a Role document to use as the `data` parameter to `create_role` :param DATA_TYPE: Versioned document type. :param collection: Collection ID for the collection the role will apply to. This value is omitted when creating an endpoint role or when creating role definitions when creating collections. :param principal: Auth identity or group id URN. Should be in the format urn:globus:auth:[identity|group]:{uuid of identity or group} :param role: Role assigned to the principal. Known values are owner, administrator, access_manager, activity_manager, and activity_monitor """ def __init__( self, DATA_TYPE: str = "role#1.0.0", collection: uuid.UUID | str | MissingType = MISSING, principal: str | MissingType = MISSING, role: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["collection"] = collection self["principal"] = principal self["role"] = role self.update(additional_fields or {}) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/storage_gateway.py000066400000000000000000000530141513221403200307130ustar00rootroot00000000000000from __future__ import annotations import copy import typing as t import uuid from globus_sdk._internal.remarshal import list_map, listify, strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import AbstractGlobusPayload, GlobusPayload from ._common import DatatypeCallback, ensure_datatype class StorageGatewayDocument(GlobusPayload): """ Convenience class for constructing a Storage Gateway document to use as the `data` parameter to ``create_storage_gateway`` or ``update_storage_gateway`` :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param display_name: Name of the Storage Gateway :param connector_id: UUID of the connector type that this Storage Gateway interacts with. :param identity_mappings: A list of IdentityMapping objects which are applied to user identities to attempt to determine what accounts are available for access. :param policies: Connector specific storage policies. It is recommended that you use one of the policy helper classes (e.g. `POSIXStoragePolicies` if you are using the POSIX connector) to create these. :param allowed_domains: List of allowed domains. Users creating credentials or collections on this Storage Gateway must have an identity in one of these domains. :param restrict_paths: Path restrictions within this Storage Gateway. Private. :param high_assurance: Flag indicating if the Storage Gateway requires high assurance features. :param authentication_timeout_mins: Timeout (in minutes) during which a user is required to have authenticated in a session to access this storage gateway. :param users_allow: List of connector-specific usernames allowed to access this Storage Gateway. Private. :param users_deny: List of connector-specific usernames denied access to this Storage Gateway. Private. :param process_user: Local POSIX user the GridFTP server should run as when accessing this Storage Gateway. :param load_dsi_module: Name of the DSI module to load by the GridFTP server when accessing this Storage Gateway. :param require_mfa: Flag indicating that the Storage Gateway requires multi-factor authentication. Only usable on high assurance Storage Gateways. :param additional_fields: Additional data for inclusion in the Storage Gateway document """ DATATYPE_BASE: str = "storage_gateway" DATATYPE_VERSION_IMPLICATIONS: dict[str, tuple[int, int, int]] = { "require_mfa": (1, 1, 0), } DATATYPE_VERSION_CALLBACKS: tuple[DatatypeCallback, ...] = () def __init__( self, DATA_TYPE: str | MissingType = MISSING, display_name: str | MissingType = MISSING, connector_id: uuid.UUID | str | MissingType = MISSING, root: str | MissingType = MISSING, identity_mappings: t.Iterable[dict[str, t.Any]] | MissingType = MISSING, policies: StorageGatewayPolicies | dict[str, t.Any] | MissingType = MISSING, allowed_domains: t.Iterable[str] | MissingType = MISSING, high_assurance: bool | MissingType = MISSING, require_mfa: bool | MissingType = MISSING, authentication_timeout_mins: int | MissingType = MISSING, users_allow: t.Iterable[str] | MissingType = MISSING, users_deny: t.Iterable[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["display_name"] = display_name self["connector_id"] = connector_id self["root"] = root self["allowed_domains"] = strseq_listify(allowed_domains) self["users_allow"] = strseq_listify(users_allow) self["users_deny"] = strseq_listify(users_deny) self["high_assurance"] = high_assurance self["require_mfa"] = require_mfa self["authentication_timeout_mins"] = authentication_timeout_mins self["identity_mappings"] = listify(identity_mappings) self["policies"] = policies self.update(additional_fields or {}) ensure_datatype(self) class StorageGatewayPolicies(AbstractGlobusPayload): """ This is the abstract base type for Storage Policies documents to use as the ``policies`` parameter when creating a StorageGatewayDocument. Several fields on policy documents are marked as ``Private``. This means that they are not visible except to admins and owners of the storage gateway. """ class POSIXStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a POSIX Storage Policy document to use as the ``policies`` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param groups_allow: List of POSIX group IDs allowed to access this Storage Gateway. Private. :param groups_deny: List of POSIX group IDs denied access this Storage Gateway. Private. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "posix_storage_policies#1.0.0", groups_allow: t.Iterable[str] | MissingType = MISSING, groups_deny: t.Iterable[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["groups_allow"] = strseq_listify(groups_allow) self["groups_deny"] = strseq_listify(groups_deny) self.update(additional_fields or {}) class POSIXStagingStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a POSIX Staging Storage Policy document to use as the ``policies`` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param groups_allow: List of POSIX group IDs allowed to access this Storage Gateway. Private. :param groups_deny: List of POSIX group IDs denied access this Storage Gateway. Private. :param stage_app: Path to the stage app. Private. :param environment: A mapping of variable names to values to set in the environment when executing the ``stage_app``. Private. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "posix_staging_storage_policies#1.0.0", groups_allow: t.Iterable[str] | MissingType = MISSING, groups_deny: t.Iterable[str] | MissingType = MISSING, stage_app: str | MissingType = MISSING, environment: t.Iterable[dict[str, str]] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["stage_app"] = stage_app self["groups_allow"] = strseq_listify(groups_allow) self["groups_deny"] = strseq_listify(groups_deny) # make shallow copies of all the dicts passed self["environment"] = list_map(environment, copy.copy) self.update(additional_fields or {}) class BlackPearlStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a BlackPearl Storage Policy document to use as the ``policies`` parameter when creating a StorageGatewayDocument. :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param s3_endpoint: The URL of the S3 endpoint of the BlackPearl appliance to use to access collections on this Storage Gateway. :param bp_access_id_file: Path to the file which provides mappings from usernames within the configured identity domain to the ID and secret associated with the user's BlackPearl account :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "blackpearl_storage_policies#1.0.0", s3_endpoint: str | MissingType = MISSING, bp_access_id_file: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["s3_endpoint"] = s3_endpoint self["bp_access_id_file"] = bp_access_id_file self.update(additional_fields or {}) class BoxStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a Box Storage Policy document to use as the ``policies`` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param enterpriseID: Identifies which Box Enterprise this Storage Gateway is authorized to access. Private. :param boxAppSettings: Values that the Storage Gateway uses to identify and authenticate with the Box API. Private. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "box_storage_policies#1.0.0", enterpriseID: str | MissingType = MISSING, boxAppSettings: dict[str, t.Any] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["enterpriseID"] = enterpriseID self["boxAppSettings"] = boxAppSettings self.update(additional_fields or {}) class CephStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a Ceph Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param s3_endpoint: URL of the S3 API endpoint :param s3_buckets: List of buckets not owned by the collection owner that will be shown in the root of collections created at the base of this Storage Gateway. :param ceph_admin_key_id: Administrator key id used to authenticate with the ceph admin service to obtain user credentials. Private. :param ceph_admin_secret_key: Administrator secret key used to authenticate with the ceph admin service to obtain user credentials. Private. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "ceph_storage_policies#1.0.0", s3_endpoint: str | MissingType = MISSING, s3_buckets: t.Iterable[str] | MissingType = MISSING, ceph_admin_key_id: str | MissingType = MISSING, ceph_admin_secret_key: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["s3_endpoint"] = s3_endpoint self["ceph_admin_key_id"] = ceph_admin_key_id self["ceph_admin_secret_key"] = ceph_admin_secret_key self["s3_buckets"] = strseq_listify(s3_buckets) self.update(additional_fields or {}) class GoogleDriveStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a Google Drive Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param client_id: Client ID registered with the Google Application console to access Google Drive. Private. :param secret: Secret created to access access Google Drive with the client_id in this policy. Private. :param user_api_rate_quota: User API Rate quota associated with this client ID. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "google_drive_storage_policies#1.0.0", client_id: str | MissingType = MISSING, secret: str | MissingType = MISSING, user_api_rate_quota: int | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["client_id"] = client_id self["secret"] = secret self["user_api_rate_quota"] = user_api_rate_quota self.update(additional_fields or {}) class GoogleCloudStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a Google Cloud Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param client_id: Client ID registered with the Google Application console to access Google Drive. Private. :param secret: Secret created to access access Google Drive with the client_id in this policy. Private. :param service_account_key: Credentials for use with service account auth, read from a Google-provided json file. Private. :param buckets: The list of Google Cloud Storage buckets which the Storage Gateway is allowed to access, as well as the list of buckets that will be shown in root level directory listings. If this list is unset, bucket access is unrestricted and all non public credential accessible buckets will be shown in root level directory listings. The value is a list of bucket names. :param projects: The list of Google Cloud Storage projects which the Storage Gateway is allowed to access. If this list is unset, project access is unrestricted. The value is a list of project id strings. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "google_cloud_storage_policies#1.0.0", client_id: str | MissingType = MISSING, secret: str | MissingType = MISSING, service_account_key: dict[str, t.Any] | MissingType = MISSING, buckets: t.Iterable[str] | MissingType = MISSING, projects: t.Iterable[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["client_id"] = client_id self["secret"] = secret self["buckets"] = strseq_listify(buckets) self["projects"] = strseq_listify(projects) self["service_account_key"] = service_account_key self.update(additional_fields or {}) class OneDriveStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a OneDrive Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param client_id: Client ID registered with the MS Application console to access OneDrive. Private. :param secret: Secret created to access access MS with the client_id in this policy. Private. :param tenant: MS Tenant ID from which to allow user logins. Private. :param user_api_rate_limit: User API Rate limit associated with this client ID. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "onedrive_storage_policies#1.0.0", client_id: str | MissingType = MISSING, secret: str | MissingType = MISSING, tenant: str | MissingType = MISSING, user_api_rate_limit: int | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["client_id"] = client_id self["secret"] = secret self["tenant"] = tenant self["user_api_rate_limit"] = user_api_rate_limit self.update(additional_fields or {}) class AzureBlobStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a Azure Blob Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param client_id: Client ID registered with the MS Application console to access Azure Blob Private. :param secret: Secret created to access access MS with the client_id in this policy. Private. :param tenant: MS Tenant ID from which to allow user logins. Private. :param account: Azure Storage account. Private. :param auth_type: Auth type: user, service_principal or user_service_principal :param adls: ADLS support enabled or not. Private. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "azure_blob_storage_policies#1.0.0", client_id: str | MissingType = MISSING, secret: str | MissingType = MISSING, tenant: str | MissingType = MISSING, account: str | MissingType = MISSING, auth_type: str | MissingType = MISSING, adls: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["client_id"] = client_id self["secret"] = secret self["tenant"] = tenant self["account"] = account self["auth_type"] = auth_type self["adls"] = adls self.update(additional_fields or {}) class S3StoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a Google Cloud Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param s3_endpoint: URL of the S3 API endpoint :param s3_buckets: List of buckets not owned by the collection owner that will be shown in the root of collections created at the base of this Storage Gateway. :param s3_user_credential_required: Flag indicating if a Globus User must register a user credential in order to create a Guest Collection on this Storage Gateway. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "s3_storage_policies#1.0.0", s3_endpoint: str | MissingType = MISSING, s3_buckets: t.Iterable[str] | MissingType = MISSING, s3_user_credential_required: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["s3_endpoint"] = s3_endpoint self["s3_user_credential_required"] = s3_user_credential_required self["s3_buckets"] = strseq_listify(s3_buckets) self.update(additional_fields or {}) class ActiveScaleStoragePolicies(S3StoragePolicies): """ The ActiveScale Storage Policy is an alias for the S3 Storage Policy. It even uses S3 policy DATA_TYPE. """ class IrodsStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing an iRODS Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param irods_environment_file: Path to iRODS environment file on the endpoint :param irods_authentication_file: Path to iRODS authentication file on the endpoint :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "irods_storage_policies#1.0.0", irods_environment_file: str | MissingType = MISSING, irods_authentication_file: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["irods_environment_file"] = irods_environment_file self["irods_authentication_file"] = irods_authentication_file self.update(additional_fields or {}) class HPSSStoragePolicies(StorageGatewayPolicies): """ Convenience class for constructing a HPSS Storage Policy document to use as the `policies` parameter when creating a StorageGatewayDocument :param DATA_TYPE: Versioned document type. Defaults to the appropriate type for this class. :param authentication_mech: Authentication mechanism to use with HPSS. :param authenticator: Authentication credentials to use with HPSS. :param uda_checksum_support: Flag indicating whether checksums should be stored in metadata. :param additional_fields: Additional data for inclusion in the policy document """ def __init__( self, DATA_TYPE: str = "hpss_storage_policies#1.0.0", authentication_mech: str | MissingType = MISSING, authenticator: str | MissingType = MISSING, uda_checksum_support: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["authentication_mech"] = authentication_mech self["authenticator"] = authenticator self["uda_checksum_support"] = uda_checksum_support self.update(additional_fields or {}) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/data/user_credential.py000066400000000000000000000035531513221403200307010ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload class UserCredentialDocument(GlobusPayload): """ Convenience class for constructing a UserCredential document to use as the `data` parameter to `create_user_credential` and `update_user_credential` :param DATA_TYPE: Versioned document type. :param identity_id: UUID of the Globus identity this credential will provide access for :param connector_id: UUID of the connector this credential is for :param username: Username of the local account this credential will provide access to, format is connector specific :param display_name: Display name for this credential :param storage_gateway_id: UUID of the storage gateway this credential is for :param policies: Connector specific policies for this credential :param additional_fields: Additional data for inclusion in the document """ def __init__( self, DATA_TYPE: str = "user_credential#1.0.0", identity_id: uuid.UUID | str | MissingType = MISSING, connector_id: uuid.UUID | str | MissingType = MISSING, username: str | MissingType = MISSING, display_name: str | MissingType = MISSING, storage_gateway_id: uuid.UUID | str | MissingType = MISSING, policies: dict[str, t.Any] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = DATA_TYPE self["identity_id"] = identity_id self["connector_id"] = connector_id self["username"] = username self["display_name"] = display_name self["storage_gateway_id"] = storage_gateway_id self["policies"] = policies self.update(additional_fields or {}) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/errors.py000066400000000000000000000021211513221403200261220ustar00rootroot00000000000000from __future__ import annotations import typing as t import requests from globus_sdk import exc class GCSAPIError(exc.GlobusAPIError): """ Error class for the GCS Manager API client """ def __init__(self, r: requests.Response) -> None: self.detail_data_type: str | None = None self.detail: None | str | dict[str, t.Any] = None super().__init__(r) def _get_args(self) -> list[t.Any]: args = super()._get_args() args.append(self.detail_data_type) # only add detail if it's a string (don't want to put a large object into # stacktraces) if isinstance(self.detail, str): args.append(self.detail) return args def _post_parse_hook(self) -> bool: # detail can be a full document, so fetch, then look for a DATA_TYPE # and expose it as a top-level attribute for easy access self.detail = self._dict_data.get("detail") if isinstance(self.detail, dict) and "DATA_TYPE" in self.detail: self.detail_data_type = self.detail["DATA_TYPE"] return True globus-globus-sdk-python-6a080e4/src/globus_sdk/services/gcs/response.py000066400000000000000000000065011513221403200264520ustar00rootroot00000000000000from __future__ import annotations import re import typing as t from globus_sdk.response import GlobusHTTPResponse, IterableResponse class IterableGCSResponse(IterableResponse): """ Response class for non-paged list oriented resources. Allows top level fields to be accessed normally via standard item access, and also provides a convenient way to iterate over the sub-item list in the ``data`` key: >>> print("Path:", r["path"]) >>> # Equivalent to: for item in r["data"] >>> for item in r: >>> print(item["name"], item["type"]) """ default_iter_key = "data" class UnpackingGCSResponse(GlobusHTTPResponse): """ An "unpacking" response looks for a "data" array in the response data, which is expected to have dict elements. The "data" is traversed until the first matching object is found, and this is presented as the ``data`` property of the response. The full response data is available as ``full_data``. If the expected datatype is not found in the array, or the array is missing, the ``data`` will be the full response data (identical to ``full_data``). :param match: Either a string containing a DATA_TYPE prefix, or an arbitrary callable which does the matching """ def _default_unpacking_match( self, spec: str ) -> t.Callable[[dict[str, t.Any]], bool]: if not re.fullmatch(r"\w+", spec): raise ValueError("Invalid UnpackingGCSResponse specification.") def match_func(data: dict[str, t.Any]) -> bool: if not ("DATA_TYPE" in data and isinstance(data["DATA_TYPE"], str)): return False if "#" not in data["DATA_TYPE"]: return False name, _version = data["DATA_TYPE"].split("#", 1) return name == spec return match_func def __init__( self, response: GlobusHTTPResponse, match: str | t.Callable[[dict[str, t.Any]], bool], ) -> None: super().__init__(response) if callable(match): self._match_func = match else: self._match_func = self._default_unpacking_match(match) self._unpacked_data: dict[str, t.Any] | None = None self._did_unpack = False @property def full_data(self) -> t.Any: """ The full, parsed JSON response data. ``None`` if the data cannot be parsed as JSON. """ return self._parsed_json def _unpack(self) -> dict[str, t.Any] | None: """ Unpack the response from the `"data"` array, returning the first match found. If no matches are founds, or the data is the wrong shape, return None. """ if isinstance(self._parsed_json, dict) and isinstance( self._parsed_json.get("data"), list ): for item in self._parsed_json["data"]: if isinstance(item, dict) and self._match_func(item): return item return None @property def data(self) -> t.Any: # only do the unpacking operation once, as it may be expensive on large payloads if not self._did_unpack: self._unpacked_data = self._unpack() self._did_unpack = True if self._unpacked_data is not None: return self._unpacked_data return self._parsed_json globus-globus-sdk-python-6a080e4/src/globus_sdk/services/groups/000077500000000000000000000000001513221403200250035ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/groups/__init__.py000066400000000000000000000007611513221403200271200ustar00rootroot00000000000000from .client import GroupsClient from .data import ( BatchMembershipActions, GroupMemberVisibility, GroupPolicies, GroupRequiredSignupFields, GroupRole, GroupVisibility, ) from .errors import GroupsAPIError from .manager import GroupsManager __all__ = ( "GroupsClient", "GroupsAPIError", "GroupsManager", "BatchMembershipActions", "GroupMemberVisibility", "GroupRequiredSignupFields", "GroupRole", "GroupVisibility", "GroupPolicies", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/groups/client.py000066400000000000000000000343321513221403200266400ustar00rootroot00000000000000from __future__ import annotations import sys import typing as t import uuid from globus_sdk import client, response from globus_sdk._internal.remarshal import commajoin from globus_sdk._missing import MISSING, MissingType from globus_sdk.scopes import GroupsScopes, Scope from .data import BatchMembershipActions, GroupPolicies from .errors import GroupsAPIError if sys.version_info >= (3, 10): from typing import TypeAlias else: from typing_extensions import TypeAlias _VALID_STATUSES_T: TypeAlias = t.Literal[ "active", "declined", "invited", "left", "pending", "rejected", "removed", ] class GroupsClient(client.BaseClient): """ Client for the `Globus Groups API `_. .. sdk-sphinx-copy-params:: BaseClient This provides a relatively low level client to public groups API endpoints. You may also consider looking at the GroupsManager as a simpler interface to more common actions. .. automethodlist:: globus_sdk.GroupsClient """ error_class = GroupsAPIError service_name = "groups" scopes = GroupsScopes @property def default_scope_requirements(self) -> list[Scope]: return [GroupsScopes.view_my_groups_and_memberships] def get_my_groups( self, *, statuses: ( _VALID_STATUSES_T | t.Iterable[_VALID_STATUSES_T] | MissingType ) = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.ArrayResponse: """ Return a list of groups your identity belongs to. :param statuses: If provided, only groups containing memberships with the given status are returned. Valid values are ``active``, ``invited``, ``pending``, ``rejected``, ``removed``, ``left``, and ``declined``. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /v2/groups/my_groups`` .. extdoclink:: Retrieve your groups and membership :service: groups :ref: get_my_groups_and_memberships_v2_groups_my_groups_get """ query_params = {"statuses": commajoin(statuses), **(query_params or {})} return response.ArrayResponse( self.get("/v2/groups/my_groups", query_params=query_params) ) def get_group( self, group_id: uuid.UUID | str, *, include: str | t.Iterable[str] | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get details about a specific group :param group_id: the ID of the group :param include: list of additional fields to include (allowed fields are ``memberships``, ``my_memberships``, ``policies``, ``allowed_actions``, and ``child_ids``) :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /v2/groups/`` .. extdoclink:: Get Group :service: groups :ref: get_group_v2_groups__group_id__get """ query_params = {"include": commajoin(include), **(query_params or {})} return self.get(f"/v2/groups/{group_id}", query_params=query_params) def get_group_by_subscription_id( self, subscription_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Using a subscription ID, find the group which provides that subscription. :param subscription_id: the subscription ID of the group .. tab-set:: .. tab-item:: Example Usage .. code-block:: python from globus_sdk import GroupsClient groups = GroupsClient(...) group_id = groups.get_group_by_subscription_id(subscription_id)["group_id"] .. tab-item:: Example Response Data .. expandtestfixture:: groups.get_group_by_subscription_id .. tab-item:: API Info ``GET /v2/subscription_info/`` .. extdoclink:: Get Group by Subscription ID :service: groups :ref: get_group_by_subscription_id_v2_subscription_info__subscription_id__get """ # noqa: E501 return self.get(f"/v2/subscription_info/{subscription_id}") def delete_group( self, group_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete a group. :param group_id: the ID of the group :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``DELETE /v2/groups/`` .. extdoclink:: Delete a Group :service: groups :ref: delete_group_v2_groups__group_id__delete """ return self.delete(f"/v2/groups/{group_id}", query_params=query_params) def create_group( self, data: dict[str, t.Any], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Create a group. :param data: the group document to create :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /v2/groups`` .. extdoclink:: Create a Group :service: groups :ref: create_group_v2_groups_post """ return self.post("/v2/groups", data=data, query_params=query_params) def update_group( self, group_id: uuid.UUID | str, data: dict[str, t.Any], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Update a given group. :param group_id: the ID of the group :param data: the group document to use for update :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PUT /v2/groups/`` .. extdoclink:: Update a Group :service: groups :ref: update_group_v2_groups__group_id__put """ return self.put(f"/v2/groups/{group_id}", data=data, query_params=query_params) def get_group_policies( self, group_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get policies for the given group. :param group_id: the ID of the group :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /v2/groups//policies`` .. extdoclink:: Get the policies for a group :service: groups :ref: get_policies_v2_groups__group_id__policies_get """ return self.get(f"/v2/groups/{group_id}/policies", query_params=query_params) def set_group_policies( self, group_id: uuid.UUID | str, data: dict[str, t.Any] | GroupPolicies, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Set policies for the group. :param group_id: the ID of the group :param data: the group policy document to set :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PUT /v2/groups//policies`` .. extdoclink:: Set the policies for a group :service: groups :ref: update_policies_v2_groups__group_id__policies_put """ return self.put( f"/v2/groups/{group_id}/policies", data=data, query_params=query_params ) def get_identity_preferences( self, *, query_params: dict[str, t.Any] | None = None ) -> response.GlobusHTTPResponse: """ Get identity preferences. Currently this only includes whether the user allows themselves to be added to groups. :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /v2/preferences`` .. extdoclink:: Get the preferences for your identity set :service: groups :ref: get_identity_set_preferences_v2_preferences_get """ return self.get("/v2/preferences", query_params=query_params) def set_identity_preferences( self, data: dict[str, t.Any], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Set identity preferences. Currently this only includes whether the user allows themselves to be added to groups. :param data: the identity set preferences document :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage .. code-block:: python gc = globus_sdk.GroupsClient(...) gc.set_identity_preferences({"allow_add": False}) .. tab-item:: API Info ``PUT /v2/preferences`` .. extdoclink:: Set the preferences for your identity set :service: groups :ref: put_identity_set_preferences_v2_preferences_put """ return self.put("/v2/preferences", data=data, query_params=query_params) def get_membership_fields( self, group_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get membership fields for your identities. :param group_id: the ID of the group :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /v2/groups//membership_fields`` .. extdoclink:: Get the membership fields for your identity set :service: groups :ref: get_membership_fields_v2_groups__group_id__membership_fields_get """ # noqa: E501 return self.get( f"/v2/groups/{group_id}/membership_fields", query_params=query_params ) def set_membership_fields( self, group_id: uuid.UUID | str, data: dict[t.Any, str], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Set membership fields for your identities. :param group_id: the ID of the group :param data: the membership fields document :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PUT /v2/groups//membership_fields`` .. extdoclink:: Set the membership fields for your identity set :service: groups :ref: put_membership_fields_v2_groups__group_id__membership_fields_put """ # noqa: E501 return self.put( f"/v2/groups/{group_id}/membership_fields", data=data, query_params=query_params, ) def batch_membership_action( self, group_id: uuid.UUID | str, actions: dict[str, t.Any] | BatchMembershipActions, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Execute a batch of actions against several group memberships. :param group_id: the ID of the group :param actions: the batch of membership actions to perform, modifying, creating, and removing memberships in the group :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage .. code-block:: python gc = globus_sdk.GroupsClient(...) group_id = ... batch = globus_sdk.BatchMembershipActions() batch.add_members("ae332d86-d274-11e5-b885-b31714a110e9") batch.invite_members("c699d42e-d274-11e5-bf75-1fc5bf53bb24") gc.batch_membership_action(group_id, batch) .. tab-item:: API Info ``PUT /v2/groups//membership_fields`` .. extdoclink:: Perform actions on members of the group :service: groups :ref: group_membership_post_actions_v2_groups__group_id__post """ return self.post( f"/v2/groups/{group_id}", data=actions, query_params=query_params ) def set_subscription_admin_verified( self, group_id: uuid.UUID | str, subscription_id: uuid.UUID | str | None, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Verify a group as belonging to a subscription or disassociate a verified group from a subscription. :param group_id: the ID of the group :param subscription_id: the ID of the subscription to which the group belongs, or ``None`` to disassociate the group from a subscription :param query_params: additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PUT /v2/groups//subscription_admin_verified`` .. extdoclink:: Update the group's subscription admin verified ID :service: groups :ref: update_subscription_admin_verified_id_v2_groups__group_id__ subscription_admin_verified_put """ return self.put( f"/v2/groups/{group_id}/subscription_admin_verified", data={"subscription_admin_verified_id": subscription_id}, query_params=query_params, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/groups/data.py000066400000000000000000000231271513221403200262730ustar00rootroot00000000000000from __future__ import annotations import enum import typing as t import uuid from globus_sdk._internal.remarshal import strseq_iter from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload T = t.TypeVar("T") class GroupRole(enum.Enum): member = "member" manager = "manager" admin = "admin" _GROUP_ROLE_T = t.Union[GroupRole, t.Literal["member", "manager", "admin"]] class GroupMemberVisibility(enum.Enum): members = "members" managers = "managers" _GROUP_MEMBER_VISIBILITY_T = t.Union[ GroupMemberVisibility, t.Literal["members", "managers"] ] class GroupVisibility(enum.Enum): authenticated = "authenticated" private = "private" _GROUP_VISIBILITY_T = t.Union[GroupVisibility, t.Literal["authenticated", "private"]] class GroupRequiredSignupFields(enum.Enum): institution = "institution" current_project_name = "current_project_name" address = "address" city = "city" state = "state" country = "country" address1 = "address1" address2 = "address2" zip = "zip" phone = "phone" department = "department" field_of_science = "field_of_science" _GROUP_REQUIRED_SIGNUP_FIELDS_T = t.Union[ GroupRequiredSignupFields, t.Literal[ "institution", "current_project_name", "address", "city", "state", "country", "address1", "address2", "zip", "phone", "department", "field_of_science", ], ] def _typename(obj: t.Any) -> str: if isinstance(obj, type) and issubclass(obj, enum.Enum): return obj.__name__ return str(obj) def _fmt_union(obj: t.Any) -> str: return " | ".join(_typename(x) for x in t.get_args(obj)) def _docstring_fixer(cls: type[T]) -> type[T]: """ These type aliases are not always rendered correctly by sphinx autodoc. Therefore, we state the types explicitly using a doc modifier, so that we can reformat them in a sphinx-friendly way. """ if cls.__doc__ is not None: cls.__doc__ = cls.__doc__.format( _GROUP_ROLE_T=_fmt_union(_GROUP_ROLE_T), _GROUP_MEMBER_VISIBILITY_T=_fmt_union(_GROUP_MEMBER_VISIBILITY_T), _GROUP_VISIBILITY_T=_fmt_union(_GROUP_VISIBILITY_T), _GROUP_REQUIRED_SIGNUP_FIELDS_T=_fmt_union(_GROUP_REQUIRED_SIGNUP_FIELDS_T), ) return cls class BatchMembershipActions(GlobusPayload): """ An object used to represent a batch action on memberships of a group. `Perform actions on group members `_. """ def accept_invites( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Accept invites for identities. The identities must belong to the identity set of authenticated user. :param identity_ids: The identities for whom to accept invites """ self.setdefault("accept", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def add_members( self, identity_ids: t.Iterable[uuid.UUID | str], *, role: _GROUP_ROLE_T = "member", ) -> BatchMembershipActions: """ Add a list of identities to a group with the given role. :param identity_ids: The identities to add to the group :param role: The role for the new group members """ self.setdefault("add", []).extend( {"identity_id": identity_id, "role": role} for identity_id in strseq_iter(identity_ids) ) return self def approve_pending( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Approve a list of identities with pending join requests. :param identity_ids: The identities to approve as members of the group """ self.setdefault("approve", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def change_roles( self, role: _GROUP_ROLE_T, identity_ids: t.Iterable[uuid.UUID | str], ) -> BatchMembershipActions: """ Assign a new role to a list of identities. :param role: The new role to assign. :param identity_ids: The identities to assign to the new role. """ self.setdefault("change_role", []).extend( {"role": role, "identity_id": identity_id} for identity_id in identity_ids ) return self def decline_invites( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Decline an invitation for a given set of identities. :param identity_ids: The identities for whom invitations should be declined """ self.setdefault("decline", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def invite_members( self, identity_ids: t.Iterable[uuid.UUID | str], *, role: _GROUP_ROLE_T = "member", ) -> BatchMembershipActions: """ Invite a list of identities to a group with the given role. :param identity_ids: The identities to invite to the group :param role: The role for the invited group members """ self.setdefault("invite", []).extend( {"identity_id": identity_id, "role": role} for identity_id in strseq_iter(identity_ids) ) return self def join(self, identity_ids: t.Iterable[uuid.UUID | str]) -> BatchMembershipActions: """ Join a group with the given identities. The identities must be in the authenticated users identity set. :param identity_ids: The identities to use to join the group """ self.setdefault("join", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def leave( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Leave a group that one of the identities in the authenticated user's identity set is a member of. :param identity_ids: The identities to remove from the group """ self.setdefault("leave", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def reject_join_requests( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Reject identities which have requested to join the group. :param identity_ids: The identities to reject from the group """ self.setdefault("reject", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def remove_members( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Remove members from a group. This must be done as an admin or manager of the group. :param identity_ids: The identities to remove from the group """ self.setdefault("remove", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self def request_join( self, identity_ids: t.Iterable[uuid.UUID | str] ) -> BatchMembershipActions: """ Request to join a group. :param identity_ids: The identities to use to request membership in the group """ self.setdefault("request_join", []).extend( {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @_docstring_fixer class GroupPolicies(GlobusPayload): """ An object used to represent the policy settings of a group. This may be used to set or modify group settings. See also: `API documentation on setting the policies for the group. \ `_ :param is_high_assurance: Whether the group is high assurance or not :param group_visibility: The visibility of the group :type group_visibility: {_GROUP_VISIBILITY_T} :param group_members_visibility: The visibility of the group members :type group_members_visibility: {_GROUP_MEMBER_VISIBILITY_T} :param join_requests: Whether the group allows join requests or not :param signup_fields: The fields required for signup in the group :type signup_fields: typing.Iterable[{_GROUP_REQUIRED_SIGNUP_FIELDS_T}] :param authentication_assurance_timeout: The session timeout for high assurance group policy enforcement """ def __init__( self, *, is_high_assurance: bool, group_visibility: _GROUP_VISIBILITY_T, group_members_visibility: _GROUP_MEMBER_VISIBILITY_T, join_requests: bool, signup_fields: t.Iterable[_GROUP_REQUIRED_SIGNUP_FIELDS_T], authentication_assurance_timeout: int | None | MissingType = MISSING, ) -> None: super().__init__() self["is_high_assurance"] = is_high_assurance self["group_visibility"] = group_visibility self["group_members_visibility"] = group_members_visibility self["join_requests"] = join_requests self["signup_fields"] = list(signup_fields) self["authentication_assurance_timeout"] = authentication_assurance_timeout globus-globus-sdk-python-6a080e4/src/globus_sdk/services/groups/errors.py000066400000000000000000000001741513221403200266730ustar00rootroot00000000000000from globus_sdk import exc class GroupsAPIError(exc.GlobusAPIError): """Error class for the Globus Groups Service.""" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/groups/manager.py000066400000000000000000000210751513221403200267740ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk import response from .client import GroupsClient from .data import ( _GROUP_MEMBER_VISIBILITY_T, _GROUP_REQUIRED_SIGNUP_FIELDS_T, _GROUP_ROLE_T, _GROUP_VISIBILITY_T, BatchMembershipActions, GroupPolicies, ) class GroupsManager: """ A wrapper for the groups client with common membership and group actions wrapped in convenient methods with parameters and type hints. .. automethodlist:: globus_sdk.GroupsManager """ def __init__(self, client: GroupsClient | None = None) -> None: self.client = client or GroupsClient() def create_group( self, name: str, description: str, *, parent_id: uuid.UUID | str | None = None, ) -> response.GlobusHTTPResponse: """ Create a group with the given name. If a parent ID is included, the group will be a subgroup of the given parent group. :param name: The name of the group :param description: A description of the group :param parent_id: The ID of the parent group, if there is one """ data = { "name": name, "description": description, "parent_id": str(parent_id) if parent_id is not None else None, } return self.client.create_group(data=data) def set_group_policies( self, group_id: uuid.UUID | str, *, is_high_assurance: bool, group_visibility: _GROUP_VISIBILITY_T, group_members_visibility: _GROUP_MEMBER_VISIBILITY_T, join_requests: bool, signup_fields: t.Iterable[_GROUP_REQUIRED_SIGNUP_FIELDS_T], authentication_assurance_timeout: int | None = None, ) -> response.GlobusHTTPResponse: """ Set the group policies for the given group. :param group_id: The ID of the group on which to set policies :param is_high_assurance: Whether the group can provide a High Assurance guarantee when used for access controls :param group_visibility: The visibility of the group :param group_members_visibility: The visibility of memberships within the group :param join_requests: Whether the group allows users to request to join :param signup_fields: The required fields for a user to sign up for the group :param authentication_assurance_timeout: The timeout used when this group is used to apply a High Assurance authentication guarantee """ data = GroupPolicies( is_high_assurance=is_high_assurance, group_visibility=group_visibility, group_members_visibility=group_members_visibility, join_requests=join_requests, signup_fields=signup_fields, authentication_assurance_timeout=authentication_assurance_timeout, ) return self.client.set_group_policies(group_id, data=data) def accept_invite( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Accept invite for an identity. The identity must belong to the identity set of the authenticated user. :param group_id: The ID of the group :param identity_id: The identity for whom to accept the invite """ actions = BatchMembershipActions().accept_invites([identity_id]) return self.client.batch_membership_action(group_id, actions) def add_member( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str, *, role: _GROUP_ROLE_T = "member", ) -> response.GlobusHTTPResponse: """ Add an identity to a group with the given role. :param group_id: The ID of the group :param identity_id: The identity to add to the group :param role: The role for the new group member """ actions = BatchMembershipActions().add_members([identity_id], role=role) return self.client.batch_membership_action(group_id, actions) def approve_pending( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Approve an identity with a pending join request. :param group_id: The ID of the group :param identity_id: The identity to approve as a member of the group """ actions = BatchMembershipActions().approve_pending([identity_id]) return self.client.batch_membership_action(group_id, actions) def change_role( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str, role: _GROUP_ROLE_T, ) -> response.GlobusHTTPResponse: """ Change the role of the given identity in the given group. :param group_id: The ID of the group :param identity_id: The identity to assign the *role* to :param role: The role that will be assigned to the *identity_id* """ actions = BatchMembershipActions().change_roles(role, [identity_id]) return self.client.batch_membership_action(group_id, actions) def decline_invite( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Decline an invitation for a given identity. :param group_id: The ID of the group :param identity_id: The identity for whom to decline the invitation """ actions = BatchMembershipActions().decline_invites([identity_id]) return self.client.batch_membership_action(group_id, actions) def invite_member( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str, *, role: _GROUP_ROLE_T = "member", ) -> response.GlobusHTTPResponse: """ Invite an identity to a group with the given role. :param group_id: The ID of the group :param identity_id: The identity to invite as a new group member :param role: The role for the invited group member """ actions = BatchMembershipActions().invite_members([identity_id], role=role) return self.client.batch_membership_action(group_id, actions) def join( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Join a group with the given identity. The identity must be in the authenticated users identity set. :param group_id: The ID of the group :param identity_id: The identity to use to join the group """ actions = BatchMembershipActions().join([identity_id]) return self.client.batch_membership_action(group_id, actions) def leave( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Leave a group that one of the identities in the authenticated user's identity set is a member of. :param group_id: The ID of the group :param identity_id: The identity to remove from the group """ actions = BatchMembershipActions().leave([identity_id]) return self.client.batch_membership_action(group_id, actions) def reject_join_request( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Reject a member that has requested to join the group. :param group_id: The ID of the group :param identity_id: The identity to reject from the group """ actions = BatchMembershipActions().reject_join_requests([identity_id]) return self.client.batch_membership_action(group_id, actions) def remove_member( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Remove a member from a group. This must be done as an admin or manager of the group. :param group_id: The ID of the group :param identity_id: The identity to remove from the group """ actions = BatchMembershipActions().remove_members([identity_id]) return self.client.batch_membership_action(group_id, actions) def request_join( self, group_id: uuid.UUID | str, identity_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ Request to join a group. :param group_id: The ID of the group :param identity_id: The identity to use to request membership in the group """ actions = BatchMembershipActions().request_join([identity_id]) return self.client.batch_membership_action(group_id, actions) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/search/000077500000000000000000000000001513221403200247315ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/search/__init__.py000066400000000000000000000003361513221403200270440ustar00rootroot00000000000000from .client import SearchClient from .data import SearchQueryV1, SearchScrollQuery from .errors import SearchAPIError __all__ = ( "SearchClient", "SearchQueryV1", "SearchScrollQuery", "SearchAPIError", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/search/client.py000066400000000000000000001015471513221403200265710ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from globus_sdk import client, paging, response from globus_sdk._internal.remarshal import strseq_listify from globus_sdk._missing import MISSING, MissingType from globus_sdk.scopes import SearchScopes from .data import SearchQueryV1, SearchScrollQuery from .errors import SearchAPIError from .response import IndexListResponse log = logging.getLogger(__name__) class SearchClient(client.BaseClient): r""" Client for the Globus Search API .. sdk-sphinx-copy-params:: BaseClient This class provides helper methods for most common resources in the API, and basic ``get``, ``put``, ``post``, and ``delete`` methods from the base client that can be used to access any API resource. .. automethodlist:: globus_sdk.SearchClient """ error_class = SearchAPIError service_name = "search" scopes = SearchScopes default_scope_requirements = [SearchScopes.search] # # Index Management # def create_index( self, display_name: str, description: str ) -> response.GlobusHTTPResponse: """ Create a new index. :param display_name: the name of the index :param description: a description of the index New indices default to trial status. For subscribers with a subscription ID, indices can be converted to non-trial by sending a request to support@globus.org .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) r = sc.create_index( "History and Witchcraft", "Searchable information about history and witchcraft", ) print(f"index ID: {r['id']}") .. tab-item:: Example Response Data .. expandtestfixture:: search.create_index .. tab-item:: API Info ``POST /v1/index`` .. extdoclink:: Index Create :ref: search/reference/index_create/ """ log.debug(f"SearchClient.create_index({display_name!r}, ...)") return self.post( "/v1/index", data={"display_name": display_name, "description": description} ) def update_index( self, index_id: uuid.UUID | str, *, display_name: str | MissingType = MISSING, description: str | MissingType = MISSING, ) -> response.GlobusHTTPResponse: """ Update index metadata. :param index_id: the ID of the index :param display_name: the name of the index :param description: a description of the index .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) MY_INDEX_ID = ... r = sc.update_index( MY_INDEX_ID, display_name="My Awesome Index", description="Very awesome searchable data", ) print(f"index ID: {r['id']}") .. tab-item:: Example Response Data .. expandtestfixture:: search.create_index .. tab-item:: API Info ``PATCH /v1/index/`` .. extdoclink:: Index Update :ref: search/reference/index_update/ """ log.debug(f"SearchClient.update_index({index_id!r}, ...)") return self.patch( f"/v1/index/{index_id}", data={"display_name": display_name, "description": description}, ) def delete_index(self, index_id: uuid.UUID | str) -> response.GlobusHTTPResponse: """ Mark an index for deletion. Globus Search does not immediately delete indices. Instead, this API sets the index status to ``"delete-pending"``. Search will move pending tasks on the index to the ``CANCELLED`` state and will eventually delete the index. If the index is a trial index, it will be deleted a few minutes after being marked for deletion. If the index is non-trial, it will be kept for 30 days and will be eligible for use with the ``reopen`` API (see :meth:`~.reopen_index`) during that time. :param index_id: the ID of the index .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) sc.delete_index(index_id) .. tab-item:: Example Response Data .. expandtestfixture:: search.delete_index .. tab-item:: API Info ``DELETE /v1/index/`` .. extdoclink:: Index Delete :ref: search/reference/index_delete/ """ log.debug(f"SearchClient.delete_index({index_id!r}, ...)") return self.delete(f"/v1/index/{index_id}") def reopen_index(self, index_id: uuid.UUID | str) -> response.GlobusHTTPResponse: """ Reopen an index that has been marked for deletion, cancelling the deletion. :param index_id: the ID of the index .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) sc.reopen_index(index_id) .. tab-item:: Example Response Data .. expandtestfixture:: search.reopen_index .. tab-item:: API Info ``POST /v1/index//reopen`` .. extdoclink:: Index Reopen :ref: search/reference/index_reopen/ """ log.debug(f"SearchClient.reopen_index({index_id!r}, ...)") return self.post(f"/v1/index/{index_id}/reopen") def get_index( self, index_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get descriptive data about a Search index, including its title and description and how much data it contains. :param index_id: the ID of the index :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) index = sc.get_index(index_id) assert index["id"] == index_id print(index["display_name"], "(" + index_id + "):", index["description"]) .. tab-item:: API Info ``GET /v1/index/`` .. extdoclink:: Index Show :ref: search/reference/index_show/ """ # noqa: E501 log.debug(f"SearchClient.get_index({index_id})") return self.get(f"/v1/index/{index_id}", query_params=query_params) def index_list( self, *, query_params: dict[str, t.Any] | None = None, ) -> response.IterableResponse: """ Get a list of indices on which the caller has permissions. :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) for index_doc in sc.index_list(): print(index_doc["display_name"], f"({index_doc['id']}):") print(" permissions:", ", ".join(index_doc["permissions"])) .. tab-item:: Example Response Data .. expandtestfixture:: search.index_list .. tab-item:: API Info ``GET /v1/index_list`` .. extdoclink:: Index List :ref: search/reference/index_list/ """ # noqa: E501 log.debug("SearchClient.index_list()") return IndexListResponse(self.get("/v1/index_list", query_params=query_params)) # # Search queries # @paging.has_paginator( paging.HasNextPaginator, items_key="gmeta", get_page_size=lambda x: x["count"], max_total_results=10000, page_size=100, ) def search( self, index_id: uuid.UUID | str, q: str, *, offset: int | MissingType = MISSING, limit: int | MissingType = MISSING, advanced: bool | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Execute a simple Search Query, described by the query string ``q``. :param index_id: the ID of the index :param q: the query string :param offset: an offset for pagination :param limit: the size of a page of results :param advanced: enable 'advanced' query mode, which has sophisticated syntax but may result in BadRequest errors when used if the query is invalid :param query_params: additional parameters to pass as query params For details on query syntax, including the ``advanced`` query behavior, see the :extdoclink:`Search Query Syntax ` documentation. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) result = sc.search(index_id, "query string") advanced_result = sc.search(index_id, 'author: "Ada Lovelace"', advanced=True) .. tab-item:: Paginated Usage .. paginatedusage:: search .. tab-item:: API Info ``GET /v1/index//search`` .. extdoclink:: GET Search Query :ref: search/reference/get_query/ .. tab-item:: Example Response Data .. expandtestfixture:: search.search """ # noqa: E501 query_params = { "q": q, "offset": offset, "limit": limit, "advanced": advanced, **(query_params or {}), } log.debug(f"SearchClient.search({index_id}, ...)") return self.get(f"/v1/index/{index_id}/search", query_params=query_params) @paging.has_paginator( paging.HasNextPaginator, items_key="gmeta", get_page_size=lambda x: x["count"], max_total_results=10000, page_size=100, ) def post_search( self, index_id: uuid.UUID | str, data: dict[str, t.Any] | SearchQueryV1, *, offset: int | MissingType = MISSING, limit: int | MissingType = MISSING, ) -> response.GlobusHTTPResponse: """ Execute a complex Search Query, using a query document to express filters, facets, sorting, field boostring, and other behaviors. :param index_id: The index on which to search :param data: A Search Query document containing the query and any other fields :param offset: offset used in paging (overwrites any offset in ``data``) :param limit: limit the number of results (overwrites any limit in ``data``) For details on query syntax, including the ``advanced`` query behavior, see the :extdoclink:`Search Query Syntax ` documentation. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) query_data = { "q": "user query", "filters": [ { "type": "range", "field_name": "path.to.date", "values": [{"from": "*", "to": "2014-11-07"}], } ], "facets": [ { "name": "Publication Date", "field_name": "path.to.date", "type": "date_histogram", "date_interval": "year", } ], "sort": [{"field_name": "path.to.date", "order": "asc"}], } search_result = sc.post_search(index_id, query_data) .. tab-item:: Paginated Usage .. paginatedusage:: post_search .. tab-item:: API Info ``POST /v1/index//search`` .. extdoclink:: POST Search Query :ref: search/reference/post_query/ """ log.debug(f"SearchClient.post_search({index_id}, ...)") add_kwargs = {} if offset is not MISSING: add_kwargs["offset"] = offset if limit is not MISSING: add_kwargs["limit"] = limit data = {**data, **add_kwargs} return self.post(f"v1/index/{index_id}/search", data=data) @paging.has_paginator(paging.MarkerPaginator, items_key="gmeta") def scroll( self, index_id: uuid.UUID | str, data: dict[str, t.Any] | SearchScrollQuery, *, marker: str | MissingType = MISSING, ) -> response.GlobusHTTPResponse: """ Scroll all data in a Search index. The paginated version of this API should typically be preferred, as it is the intended mode of usage. Note that if data is written or deleted during scrolling, it is possible for scrolling to not include results or show other unexpected behaviors. :param index_id: The index on which to search :param data: A Search Scroll Query document :param marker: marker used in paging (overwrites any marker in ``data``) For details on query syntax, including the ``advanced`` query behavior, see the :extdoclink:`Search Query Syntax ` documentation. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) scroll_result = sc.scroll(index_id, {"q": "*"}) .. tab-item:: Paginated Usage .. paginatedusage:: scroll .. tab-item:: API Info ``POST /v1/index//scroll`` .. extdoclink:: Scroll Query :ref: search/reference/scroll_query/ """ log.debug(f"SearchClient.scroll({index_id}, ...)") add_kwargs = {} if marker is not MISSING: add_kwargs["marker"] = marker data = {**data, **add_kwargs} return self.post(f"v1/index/{index_id}/scroll", data=data) # # Bulk data indexing # def ingest( self, index_id: uuid.UUID | str, data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ Write data to a Search index as an asynchronous task. The data can be provided as a single document or list of documents, but only one ``task_id`` value will be included in the response. :param index_id: The index into which to write data :param data: an ingest document .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) ingest_data = { "ingest_type": "GMetaEntry", "ingest_data": { "subject": "https://example.com/foo/bar", "visible_to": ["public"], "content": {"foo/bar": "some val"}, }, } sc.ingest(index_id, ingest_data) or with multiple entries at once via a GMetaList: .. code-block:: python sc = globus_sdk.SearchClient(...) ingest_data = { "ingest_type": "GMetaList", "ingest_data": { "gmeta": [ { "subject": "https://example.com/foo/bar", "visible_to": ["public"], "content": {"foo/bar": "some val"}, }, { "subject": "https://example.com/foo/bar", "id": "otherentry", "visible_to": ["public"], "content": {"foo/bar": "some otherval"}, }, ] }, } sc.ingest(index_id, ingest_data) .. tab-item:: API Info ``POST /v1/index//ingest`` .. extdoclink:: Ingest :ref: search/reference/ingest/ """ log.debug(f"SearchClient.ingest({index_id}, ...)") return self.post(f"/v1/index/{index_id}/ingest", data=data) # # Bulk delete # def delete_by_query( self, index_id: uuid.UUID | str, data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ Delete data in a Search index as an asynchronous task, deleting all documents which match a given query. The query uses a restricted subset of the syntax available for complex queries, as it is not meaningful to boost, sort, or otherwise rank data in this case. A ``task_id`` value will be included in the response. :param index_id: The index in which to delete data :param data: a query document for documents to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) query_data = { "q": "user query", "filters": [ { "type": "range", "field_name": "path.to.date", "values": [{"from": "*", "to": "2014-11-07"}], } ], } sc.delete_by_query(index_id, query_data) .. tab-item:: API Info ``POST /v1/index//delete_by_query`` .. extdoclink:: Delete By Query :ref: search/reference/delete_by_query/ """ log.debug(f"SearchClient.delete_by_query({index_id}, ...)") return self.post(f"/v1/index/{index_id}/delete_by_query", data=data) def batch_delete_by_subject( self, index_id: uuid.UUID | str, subjects: t.Iterable[str], additional_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete data in a Search index as an asynchronous task, deleting multiple documents based on their ``subject`` values. A ``task_id`` value will be included in the response. :param index_id: The index in which to delete data :param subjects: The subjects to delete, as an iterable of strings :param additional_params: Additional parameters to include in the request body .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) sc.batch_delete_by_subject( index_id, subjects=[ "very-cool-document", "less-cool-document", "document-wearing-sunglasses", ], ) .. tab-item:: Example Response Data .. expandtestfixture:: search.batch_delete_by_subject .. tab-item:: API Info ``POST /v1/index//batch_delete_by_subject`` .. extdoclink:: Delete By Subject :ref: search/reference/batch_delete_by_subject/ """ log.debug(f"SearchClient.batch_delete_by_subject({index_id}, ...)") # convert the provided subjects to a list and use the "safe iter" helper to # ensure that a single string is *not* treated as an iterable of strings, # which is usually not intentional body = { "subjects": strseq_listify(subjects), **(additional_params or {}), } return self.post(f"/v1/index/{index_id}/batch_delete_by_subject", data=body) # # Subject Operations # def get_subject( self, index_id: uuid.UUID | str, subject: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Fetch exactly one Subject document from Search, containing one or more Entries. :param index_id: the index containing this Subject :param subject: the subject string to fetch :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage Fetch the data for subject ``http://example.com/abc`` from index ``index_id``: .. code-block:: python sc = globus_sdk.SearchClient(...) subject_data = sc.get_subject(index_id, "http://example.com/abc") .. tab-item:: API Info ``GET /v1/index//subject`` .. extdoclink:: Get By Subject :ref: search/reference/get_subject/ """ log.debug(f"SearchClient.get_subject({index_id}, {subject}, ...)") query_params = { "subject": subject, **(query_params or {}), } return self.get(f"/v1/index/{index_id}/subject", query_params=query_params) def delete_subject( self, index_id: uuid.UUID | str, subject: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete exactly one Subject document from Search, containing one or more Entries, as an asynchronous task. A ``task_id`` value will be included in the response. :param index_id: the index in which data will be deleted :param subject: the subject string for the Subject document to delete :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage Delete all data for subject ``http://example.com/abc`` from index ``index_id``, even data which is not visible to the current user: .. code-block:: python sc = globus_sdk.SearchClient(...) response = sc.delete_subject(index_id, "http://example.com/abc") task_id = response["task_id"] .. tab-item:: API Info ``DELETE /v1/index//subject`` .. extdoclink:: Delete By Subject :ref: search/reference/delete_subject/ """ log.debug(f"SearchClient.delete_subject({index_id}, {subject}, ...)") query_params = { "subject": subject, **(query_params or {}), } return self.delete(f"/v1/index/{index_id}/subject", query_params=query_params) # # Entry Operations # def get_entry( self, index_id: uuid.UUID | str, subject: str, *, entry_id: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Fetch exactly one Entry document from Search, identified by the combination of ``subject`` string and ``entry_id``, which defaults to ``null``. :param index_id: the index containing this Entry :param subject: the subject string for the Subject document containing this Entry :param entry_id: the entry_id for this Entry, which defaults to ``null`` :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage Lookup the entry with a subject of ``https://example.com/foo/bar`` and a null entry_id: .. code-block:: python sc = globus_sdk.SearchClient(...) entry_data = sc.get_entry(index_id, "http://example.com/foo/bar") Lookup the entry with a subject of ``https://example.com/foo/bar`` and an entry_id of ``foo/bar``: .. code-block:: python sc = globus_sdk.SearchClient(...) entry_data = sc.get_entry(index_id, "http://example.com/foo/bar", entry_id="foo/bar") .. tab-item:: API Info ``GET /v1/index//entry`` .. extdoclink:: Get Entry :ref: search/reference/get_entry/ """ # noqa: E501 log.debug( "SearchClient.get_entry({}, {}, {}, ...)".format( index_id, subject, entry_id ) ) query_params = { "entry_id": entry_id, "subject": subject, **(query_params or {}), } return self.get(f"/v1/index/{index_id}/entry", query_params=query_params) def delete_entry( self, index_id: uuid.UUID | str, subject: str, *, entry_id: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete exactly one Entry document in Search as an asynchronous task. A ``task_id`` value will be included in the response. :param index_id: the index in which data will be deleted :param subject: the subject string for the Subject of the document to delete :param entry_id: the ID string for the Entry to delete :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage Delete an entry with a subject of ``https://example.com/foo/bar`` and a null entry_id: .. code-block:: python sc = globus_sdk.SearchClient(...) sc.delete_entry(index_id, "https://example.com/foo/bar") Delete an entry with a subject of ``https://example.com/foo/bar`` and an entry_id of "foo/bar": .. code-block:: python sc = globus_sdk.SearchClient(...) sc.delete_entry(index_id, "https://example.com/foo/bar", entry_id="foo/bar") .. tab-item:: API Info ``DELETE /v1/index//entry`` .. extdoclink:: Delete Entry :ref: search/reference/delete_entry/ """ # noqa: E501 log.debug( "SearchClient.delete_entry({}, {}, {}, ...)".format( index_id, subject, entry_id ) ) query_params = { "entry_id": entry_id, "subject": subject, **(query_params or {}), } return self.delete(f"/v1/index/{index_id}/entry", query_params=query_params) # # Task Management # def get_task( self, task_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Fetch a Task document by ID, getting task details and status. :param task_id: the task ID from the original task submission :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) task = sc.get_task(task_id) assert task["index_id"] == known_index_id print(task["task_id"], "|", task["state"]) .. tab-item:: API Info ``GET /v1/task/`` .. extdoclink:: Get Task :ref: search/reference/get_task/ """ log.debug(f"SearchClient.get_task({task_id})") return self.get(f"/v1/task/{task_id}", query_params=query_params) def get_task_list( self, index_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Fetch a list of recent Task documents for an index, getting task details and status. :param index_id: the index to query :param query_params: additional parameters to pass as query params .. tab-set:: .. tab-item:: Example Usage .. code-block:: python sc = globus_sdk.SearchClient(...) task_list = sc.get_task_list(index_id) for task in task_list["tasks"]: print(task["task_id"], "|", task["state"]) .. tab-item:: API Info ``GET /v1/task_list/`` .. extdoclink:: Task List :ref: search/reference/task_list/ """ log.debug(f"SearchClient.get_task_list({index_id})") return self.get(f"/v1/task_list/{index_id}", query_params=query_params) # # Role Management # def create_role( self, index_id: uuid.UUID | str, data: dict[str, t.Any], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Create a new role on an index. You must already have the ``owner`` or ``admin`` role on an index to create additional roles. Roles are specified as a role name (one of ``"owner"``, ``"admin"``, or ``"writer"``) and a `Principal URN `_. :param index_id: The index on which to create the role :param data: The partial role document to use for creation :param query_params: Any additional query params to pass .. tab-set:: .. tab-item:: Example Usage .. code-block:: python identity_id = "46bd0f56-e24f-11e5-a510-131bef46955c" sc = globus_sdk.SearchClient(...) sc.create_role( index_id, {"role_name": "writer", "principal": f"urn:globus:auth:identity:{identity_id}"}, ) .. tab-item:: API Info ``POST /v1/index//role`` .. extdoclink:: Create Role :ref: search/reference/role_create/ """ # noqa: E501 log.debug("SearchClient.create_role(%s, ...)", index_id) return self.post( f"/v1/index/{index_id}/role", data=data, query_params=query_params ) def get_role_list( self, index_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ List all roles on an index. You must have the ``owner`` or ``admin`` role on an index to list roles. :param index_id: The index on which to list roles :param query_params: Any additional query params to pass .. tab-set:: .. tab-item:: API Info ``GET /v1/index//role_list`` .. extdoclink:: Get Role List :ref: search/reference/role_list/ """ log.debug("SearchClient.get_role_list(%s)", index_id) return self.get(f"/v1/index/{index_id}/role_list", query_params=query_params) def delete_role( self, index_id: uuid.UUID | str, role_id: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete a role from an index. You must have the ``owner`` or ``admin`` role on an index to delete roles. You cannot remove the last ``owner`` from an index. :param index_id: The index from which to delete a role :param role_id: The role to delete :param query_params: Any additional query params to pass .. tab-set:: .. tab-item:: API Info ``DELETE /v1/index//role/`` .. extdoclink:: Role Delete :ref: search/reference/role_delete/ """ log.debug("SearchClient.delete_role(%s, %s)", index_id, role_id) return self.delete( f"/v1/index/{index_id}/role/{role_id}", query_params=query_params ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/search/data.py000066400000000000000000000070751513221403200262250ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload class SearchQueryV1(GlobusPayload): """ A specialized dict which has helpers for creating and modifying a Search Query document. :param q: The query string. Required unless filters are used. :param limit: A limit on the number of results returned in a single page :param offset: An offset into the set of all results for the query :param advanced: Whether to enable (``True``) or not to enable (``False``) advanced parsing of query strings. The default of ``False`` is robust and guarantees that the query will not error with "bad query string" errors :param filters: a list of filters to apply to the query :param facets: a list of facets to apply to the query :param post_facet_filters: a list of filters to apply after facet results are returned :param boosts: a list of boosts to apply to the query :param sort: a list of fields to sort results :param additional_fields: additional data to include in the query document """ def __init__( self, *, q: str | MissingType = MISSING, limit: int | MissingType = MISSING, offset: int | MissingType = MISSING, advanced: bool | MissingType = MISSING, filters: list[dict[str, t.Any]] | MissingType = MISSING, facets: list[dict[str, t.Any]] | MissingType = MISSING, post_facet_filters: list[dict[str, t.Any]] | MissingType = MISSING, boosts: list[dict[str, t.Any]] | MissingType = MISSING, sort: list[dict[str, t.Any]] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["@version"] = "query#1.0.0" self["q"] = q self["limit"] = limit self["offset"] = offset self["advanced"] = advanced self["filters"] = filters self["facets"] = facets self["post_facet_filters"] = post_facet_filters self["boosts"] = boosts self["sort"] = sort self.update(additional_fields or {}) class SearchScrollQuery(GlobusPayload): """ A scrolling query type, for scrolling the full result set for an index. Scroll queries have more limited capabilities than general searches. They cannot boost fields, sort, or apply facets. They can, however, still apply the same filtering mechanisms which are available to normal queries. Scrolling also differs in that it supports the use of the ``marker`` field, which is used to paginate results. :param q: The query string :param limit: A limit on the number of results returned in a single page :param advanced: Whether to enable (``True``) or not to enable (``False``) advanced parsing of query strings. The default of ``False`` is robust and guarantees that the query will not error with "bad query string" errors :param marker: the marker value :param additional_fields: additional data to include in the query document """ def __init__( self, q: str | MissingType = MISSING, *, limit: int | MissingType = MISSING, advanced: bool | MissingType = MISSING, marker: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["q"] = q self["limit"] = limit self["advanced"] = advanced self["marker"] = marker self.update(additional_fields or {}) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/search/errors.py000066400000000000000000000012321513221403200266150ustar00rootroot00000000000000from __future__ import annotations import typing as t import requests from globus_sdk import exc class SearchAPIError(exc.GlobusAPIError): """ Error class for the Search API client. In addition to the inherited instance variables, provides ``error_data``. :ivar error_data: Additional object returned in the error response. May be a dict, list, or None. """ def __init__(self, r: requests.Response) -> None: self.error_data: dict[str, t.Any] | None = None super().__init__(r) def _post_parse_hook(self) -> bool: self.error_data = self._dict_data.get("error_data") return True globus-globus-sdk-python-6a080e4/src/globus_sdk/services/search/response.py000066400000000000000000000003021513221403200271340ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class IndexListResponse(IterableResponse): """ Iterable response class for /v1/index_list """ default_iter_key = "index_list" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/timers/000077500000000000000000000000001513221403200247675ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/timers/__init__.py000066400000000000000000000005431513221403200271020ustar00rootroot00000000000000from .client import TimersClient from .data import ( FlowTimer, OnceTimerSchedule, RecurringTimerSchedule, TimerJob, TransferTimer, ) from .errors import TimersAPIError __all__ = ( "FlowTimer", "TimersAPIError", "TimersClient", "OnceTimerSchedule", "RecurringTimerSchedule", "TimerJob", "TransferTimer", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/timers/client.py000066400000000000000000000273551513221403200266330ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from globus_sdk import client, exc, response from globus_sdk._internal import guards from globus_sdk.scopes import ( GCSCollectionScopes, Scope, SpecificFlowScopes, TimersScopes, TransferScopes, ) from .data import FlowTimer, TimerJob, TransferTimer from .errors import TimersAPIError log = logging.getLogger(__name__) class TimersClient(client.BaseClient): r""" Client for the Globus Timers API. .. sdk-sphinx-copy-params:: BaseClient .. automethodlist:: globus_sdk.TimersClient """ error_class = TimersAPIError service_name = "timer" scopes = TimersScopes default_scope_requirements = [TimersScopes.timer] def add_app_transfer_data_access_scope( self, collection_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] ) -> TimersClient: """ Add a dependent ``data_access`` scope for one or more given ``collection_ids`` to this client's ``GlobusApp``, under the Transfer ``all`` scope. Useful for preventing ``ConsentRequired`` errors when creating timers that use Globus Connect Server mapped collection(s) as the source or destination. .. warning:: This method must only be used on ``collection_ids`` for non-High-Assurance GCS Mapped Collections. Use on other collection types, e.g., on GCP Mapped Collections or any form of Guest Collection, will result in "Unknown Scope" errors during the login flow. Returns ``self`` for chaining. Raises ``GlobusSDKUsageError`` if this client was not initialized with an app. :param collection_ids: a collection ID or an iterable of IDs. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) client = TimersClient(app=app).add_app_transfer_data_access_scope(COLLECTION_ID) transfer_data = TransferData(COLLECTION_ID, COLLECTION_ID) transfer_data.add_item("/staging/", "/active/") daily_timer = TransferTimer( name="test_timer", schedule=RecurringTimerSchedule(24 * 60 * 60), body=transfer_data ) client.create_timer(daily_timer) """ # noqa: E501 if isinstance(collection_ids, (str, uuid.UUID)): guards.validators.uuidlike("collection_ids", collection_ids) # wrap the collection_ids input in a list for consistent iteration below collection_ids_ = [collection_ids] else: # copy to a list so that ephemeral iterables can be iterated multiple times collection_ids_ = list(collection_ids) for i, c in enumerate(collection_ids_): guards.validators.uuidlike(f"collection_ids[{i}]", c) dependencies: list[Scope] = [] for coll_id in collection_ids_: data_access_scope = GCSCollectionScopes( str(coll_id) ).data_access.with_optional(True) dependencies.append(data_access_scope) transfer_scope = TransferScopes.all.with_dependencies(dependencies) timers_scope = TimersScopes.timer.with_dependency(transfer_scope) self.add_app_scope(timers_scope) return self def add_app_flow_user_scope( self, flow_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] ) -> TimersClient: """ Add a dependent flow ``user`` scope for one or more given ``flow_ids`` to this client's ``GlobusApp``, under the Timers ``timer`` scope. Needed to prevent ``ConsentRequired`` errors when creating flow timers. Returns ``self`` for chaining. Raises ``GlobusSDKUsageError`` if this client was not initialized with an app. :param flow_ids: a flow ID or an iterable of IDs. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) client = TimersClient(app=app) client.add_app_flow_user_scope(FLOW_ID) flow_timer = FlowTimer( name="my flow timer", flow_id=FLOW_ID, schedule=RecurringTimerSchedule(24 * 60 * 60), body={ "body": { "input_key": "input_value", }, }, ) client.create_timer(flow_timer) """ # noqa: E501 if isinstance(flow_ids, (str, uuid.UUID)): guards.validators.uuidlike("flow_ids", flow_ids) # wrap the flow_ids input in a list for consistent iteration below flow_ids_ = [flow_ids] else: # copy to a list so that ephemeral iterables can be iterated multiple times flow_ids_ = list(flow_ids) for i, c in enumerate(flow_ids_): guards.validators.uuidlike(f"flow_ids[{i}]", c) dependencies: list[Scope] = [ SpecificFlowScopes(flow_id).user for flow_id in flow_ids_ ] timers_scope = TimersScopes.timer.with_dependencies(dependencies) self.add_app_scope(timers_scope) return self def list_jobs( self, *, query_params: dict[str, t.Any] | None = None ) -> response.GlobusHTTPResponse: """ ``GET /jobs/`` :param query_params: additional parameters to pass as query params **Examples** >>> timer_client = globus_sdk.TimersClient(...) >>> jobs = timer_client.list_jobs() """ log.debug(f"TimersClient.list_jobs({query_params})") return self.get("/jobs/", query_params=query_params) def get_job( self, job_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ ``GET /jobs/`` :param job_id: the ID of the timer ("job") :param query_params: additional parameters to pass as query params **Examples** >>> timer_client = globus_sdk.TimersClient(...) >>> job = timer_client.get_job(job_id) >>> assert job["job_id"] == job_id """ log.debug(f"TimersClient.get_job({job_id})") return self.get(f"/jobs/{job_id}", query_params=query_params) def create_timer( self, timer: dict[str, t.Any] | TransferTimer | FlowTimer ) -> response.GlobusHTTPResponse: """ :param timer: a document defining the new timer A ``TransferTimer`` object can be constructed from a ``TransferData`` object, which is the recommended way to create a timer for data transfers. **Examples** .. tab-set:: .. tab-item:: Example Usage .. code-block:: pycon >>> transfer_data = TransferData(...) >>> timers_client = globus_sdk.TimersClient(...) >>> create_doc = globus_sdk.TransferTimer( ... name="my-timer", ... schedule={"type": "recurring", "interval": 1800}, ... body=transfer_data, ... ) >>> response = timers_client.create_timer(timer=create_doc) .. tab-item:: Example Response Data .. expandtestfixture:: timer.create_timer .. tab-item:: API Info ``POST /v2/timer`` """ if isinstance(timer, TimerJob): raise exc.GlobusSDKUsageError( "Cannot pass a TimerJob to create_timer(). " "Create a TransferTimer instead." ) log.debug("TimersClient.create_timer(...)") return self.post("/v2/timer", data={"timer": timer}) def create_job( self, data: dict[str, t.Any] | TimerJob ) -> response.GlobusHTTPResponse: """ ``POST /jobs/`` :param data: a timer document used to create the new timer ("job") **Examples** >>> from datetime import datetime, timedelta, timezone >>> callback_url = ... >>> data = ... >>> timer_client = globus_sdk.TimersClient(...) >>> job = TimerJob( ... callback_url, ... data, ... datetime.now(tz=timezone.utc).replace(tzinfo=None), ... timedelta(days=14), ... name="my-timer-job" ... ) >>> timer_result = timers_client.create_job(job) """ if isinstance(data, TransferTimer): raise exc.GlobusSDKUsageError( "Cannot pass a TransferTimer to create_job(). Use create_timer() " "instead." ) log.debug(f"TimersClient.create_job({data})") return self.post("/jobs/", data=data) def update_job( self, job_id: uuid.UUID | str, data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ ``PATCH /jobs/`` :param job_id: the ID of the timer ("job") :param data: a partial timer document used to update the job **Examples** >>> timer_client = globus_sdk.TimersClient(...) >>> timer_client.update_job(job_id, {"name": "new name}"}) """ log.debug(f"TimersClient.update_job({job_id}, {data})") return self.patch(f"/jobs/{job_id}", data=data) def delete_job( self, job_id: uuid.UUID | str, ) -> response.GlobusHTTPResponse: """ ``DELETE /jobs/`` :param job_id: the ID of the timer ("job") **Examples** >>> timer_client = globus_sdk.TimersClient(...) >>> timer_client.delete_job(job_id) """ log.debug(f"TimersClient.delete_job({job_id})") return self.delete(f"/jobs/{job_id}") def pause_job( self, job_id: uuid.UUID | str, ) -> response.GlobusHTTPResponse: """ Make a timer job inactive, preventing it from running until it is resumed. :param job_id: The ID of the timer to pause **Examples** >>> timer_client = globus_sdk.TimersClient(...) >>> timer_client.pause_job(job_id) """ log.debug(f"TimersClient.pause_job({job_id})") return self.post(f"/jobs/{job_id}/pause") def resume_job( self, job_id: uuid.UUID | str, *, update_credentials: bool | None = None, ) -> response.GlobusHTTPResponse: """ Resume an inactive timer job, optionally replacing credentials to resolve issues with insufficient authorization. :param job_id: The ID of the timer to resume :param update_credentials: When true, replace the credentials for the timer using the credentials for this resume call. This can be used to resolve authorization errors (such as session and consent errors), but it also could introduce session and consent errors, if the credentials being used to resume lack some necessary properties of the credentials they're replacing. If not supplied, the Timers service will determine whether to replace credentials according to the reason why the timer job became inactive. **Examples** >>> timer_client = globus_sdk.TimersClient(...) >>> timer_client.resume_job(job_id) """ log.debug(f"TimersClient.resume_job({job_id})") data = {} if update_credentials is not None: data["update_credentials"] = update_credentials return self.post(f"/jobs/{job_id}/resume", data=data) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/timers/data.py000066400000000000000000000313141513221403200262540ustar00rootroot00000000000000from __future__ import annotations # the name "datetime" is used in this module, so use an alternative name # in order to avoid name shadowing import datetime as dt import logging import typing as t import uuid from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload from globus_sdk.services.transfer import TransferData log = logging.getLogger(__name__) class TransferTimer(GlobusPayload): """ A helper for defining a payload for Transfer Timer creation. Use this along with :meth:`create_timer ` to create a timer. .. note:: ``TimersClient`` has two methods for creating timers, ``create_timer`` and ``create_job``. ``create_job`` uses a different API -- only ``create_timer`` will work with this helper class. Users are strongly recommended to use ``create_timer`` and this helper for timer creation. :param name: A name to identify this timer :param schedule: The schedule on which the timer runs :param body: A transfer payload for the timer to use. If it includes ``submission_id`` or ``skip_activation_check``, these parameters will be removed, as they are not supported in timers. The ``schedule`` field determines when the timer will run. Timers may be "run once" or "recurring", and "recurring" timers may specify an end date or a number of executions after which the timer will stop. A ``schedule`` is specified as a dict, but the SDK provides two useful helpers for constructing these data. **Example Schedules** .. tab-set:: .. tab-item:: Run Once, Right Now .. code-block:: python schedule = OnceTimerSchedule() .. tab-item:: Run Once, At a Specific Time .. code-block:: python schedule = OnceTimerSchedule(datetime="2023-09-22T00:00:00Z") .. tab-item:: Run Every 5 Minutes, Until a Specific Time .. code-block:: python schedule = RecurringTimerSchedule( interval_seconds=300, end={"condition": "time", "datetime": "2023-10-01T00:00:00Z"}, ) .. tab-item:: Run Every 30 Minutes, 10 Times .. code-block:: python schedule = RecurringTimerSchedule( interval_seconds=1800, end={"condition": "iterations", "iterations": 10}, ) .. tab-item:: Run Every 10 Minutes, Indefinitely .. code-block:: python schedule = RecurringTimerSchedule(interval_seconds=600) Using these schedules, you can create a timer from a ``TransferData`` object: .. code-block:: pycon >>> from globus_sdk import TransferData, TransferTimer >>> schedule = ... >>> transfer_data = TransferData(...) >>> timer = TransferTimer( ... name="my timer", ... schedule=schedule, ... body=transfer_data, ... ) Submit the timer to the Timers service with :meth:`create_timer `. """ def __init__( self, *, name: str | MissingType = MISSING, schedule: dict[str, t.Any] | RecurringTimerSchedule | OnceTimerSchedule, body: dict[str, t.Any] | TransferData, ) -> None: super().__init__() self["timer_type"] = "transfer" self["name"] = name self["schedule"] = schedule self["body"] = self._preprocess_body(body) def _preprocess_body( self, body: dict[str, t.Any] | TransferData ) -> dict[str, t.Any]: # shallow-copy for dicts, convert any TransferData to a dict new_body = dict(body) # remove the skip_activation_check and submission_id parameters unconditionally # (not supported in timers, but often present in TransferData) new_body.pop("submission_id", None) new_body.pop("skip_activation_check", None) return new_body class FlowTimer(GlobusPayload): """ A helper for defining a payload for Flow Timer creation. Use this along with :meth:`create_timer ` to create a timer. .. note:: ``TimersClient`` has two methods for creating timers: ``create_timer`` and ``create_job``. This helper class only works with the ``create_timer`` method. :param flow_id: The flow ID to run when the timer runs. :param name: A name to identify this timer. :param schedule: The schedule on which the timer runs :param body: A transfer payload for the timer to use. The ``schedule`` field determines when the timer will run. Timers may be "run once" or "recurring", and "recurring" timers may specify an end date or the number of executions after which the timer will stop. A ``schedule`` is specified as a dict, but the SDK provides two helpers for constructing these data. **Example Schedules** .. tab-set:: .. tab-item:: Run Once, Right Now .. code-block:: python schedule = OnceTimerSchedule() .. tab-item:: Run Once, At a Specific Time .. code-block:: python schedule = OnceTimerSchedule(datetime="2023-09-22T00:00:00Z") .. tab-item:: Run Every 5 Minutes, Until a Specific Time .. code-block:: python schedule = RecurringTimerSchedule( interval_seconds=300, end={"condition": "time", "datetime": "2023-10-01T00:00:00Z"}, ) .. tab-item:: Run Every 30 Minutes, 10 Times .. code-block:: python schedule = RecurringTimerSchedule( interval_seconds=1800, end={"condition": "iterations", "iterations": 10}, ) .. tab-item:: Run Every 10 Minutes, Indefinitely .. code-block:: python schedule = RecurringTimerSchedule(interval_seconds=600) Using these schedules, you can create a timer: .. code-block:: pycon >>> from globus_sdk import FlowTimer >>> schedule = ... >>> timer = FlowTimer( ... name="my timer", ... flow_id="00000000-19a9-44e6-9c1a-867da59d84ab", ... schedule=schedule, ... body={ ... "body": { ... "input_key": "input_value", ... }, ... "run_managers": [ ... "urn:globus:auth:identity:11111111-be6a-473a-a027-4cfe4ceeafe3" ... ], ... }, ... ) Submit the timer to the Timers service with :meth:`create_timer `. """ def __init__( self, *, flow_id: uuid.UUID | str, name: str | MissingType = MISSING, schedule: dict[str, t.Any] | RecurringTimerSchedule | OnceTimerSchedule, body: dict[str, t.Any], ) -> None: super().__init__() self["timer_type"] = "flow" self["flow_id"] = flow_id self["name"] = name self["schedule"] = schedule self["body"] = self._preprocess_body(body) def _preprocess_body(self, body: dict[str, t.Any]) -> dict[str, t.Any]: # Additional processing may be added in the future. return body.copy() class RecurringTimerSchedule(GlobusPayload): """ A helper used as part of a *timer* to define when the *timer* will run. A ``RecurringTimerSchedule`` is used to describe a *timer* which runs repeatedly until some end condition is reached. :param interval_seconds: The number of seconds between each run of the timer. :param start: The time at which to start the timer, either as an ISO 8601 string with timezone information, or as a ``datetime.datetime`` object. :param end: The end condition for the timer, as a dict. This either expresses a number of iterations for the timer or an end date. Example ``end`` conditions: .. code-block:: python # run 10 times end = {"condition": "iterations", "iterations": 10} # run until a specific date end = {"condition": "time", "datetime": "2023-10-01T00:00:00Z"} If the end condition is ``time``, then the ``datetime`` value can be expressed as a python ``datetime`` type as well, e.g. .. code-block:: python # end in 10 days end = { "condition": "time", "datetime": datetime.datetime.now() + datetime.timedelta(days=10), } """ def __init__( self, interval_seconds: int, start: str | dt.datetime | MissingType = MISSING, end: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["type"] = "recurring" self["interval_seconds"] = interval_seconds self["start"] = _format_date(start) self["end"] = end # if a datetime is given for part of the end condition, format it (and # shallow-copy the end condition) # primarily, this handles # end={"condition": "time", "datetime": } if isinstance(end, dict): self["end"] = { k: (_format_date(v) if isinstance(v, dt.datetime) else v) for k, v in end.items() } class OnceTimerSchedule(GlobusPayload): """ A helper used as part of a *timer* to define when the *timer* will run. A ``OnceTimerSchedule`` is used to describe a *timer* which runs exactly once. It may be scheduled for a time in the future. :param datetime: The time at which to run the timer, either as an ISO 8601 string with timezone information, or as a ``datetime.datetime`` object. """ def __init__( self, datetime: str | dt.datetime | MissingType = MISSING, ) -> None: super().__init__() self["type"] = "once" self["datetime"] = _format_date(datetime) class TimerJob(GlobusPayload): r""" .. warning:: This method of specifying and creating Timers for data transfer is now deprecated. Users should use ``TimerData`` instead. ``TimerJob`` is still supported for non-transfer use-cases. Helper for creating a timer in the Timers service. Used as the ``data`` argument in :meth:`create_job `. The ``callback_url`` parameter should always be the URL used to run an action provider. :param callback_url: URL for the action which the Timers job will use. :param callback_body: JSON data which Timers will send to the Action Provider on each invocation :param start: The datetime at which to start the Timers job. :param interval: The interval at which the Timers job should recur. Interpreted as seconds if specified as an integer. If ``stop_after_n == 1``, i.e. the job is set to run only a single time, then interval *must* be None. :param name: A (not necessarily unique) name to identify this job in Timers :param stop_after: A date after which the Timers job will stop running :param stop_after_n: A number of executions after which the Timers job will stop :param scope: Timers defaults to the Transfer 'all' scope. Use this parameter to change the scope used by Timers when calling the Transfer Action Provider. .. automethodlist:: globus_sdk.TimerJob """ def __init__( self, callback_url: str, callback_body: dict[str, t.Any], start: dt.datetime | str, interval: dt.timedelta | int | None, *, name: str | None = None, stop_after: dt.datetime | None = None, stop_after_n: int | None = None, scope: str | None = None, ) -> None: super().__init__() self["callback_url"] = callback_url self["callback_body"] = callback_body if isinstance(start, dt.datetime): self["start"] = start.isoformat() else: self["start"] = start if isinstance(interval, dt.timedelta): self["interval"] = int(interval.total_seconds()) else: self["interval"] = interval if name is not None: self["name"] = name if stop_after is not None: self["stop_after"] = stop_after.isoformat() if stop_after_n is not None: self["stop_after_n"] = stop_after_n if scope is not None: self["scope"] = scope def _format_date(date: str | dt.datetime | MissingType) -> str | MissingType: if isinstance(date, dt.datetime): if date.tzinfo is None: date = date.astimezone(dt.timezone.utc) return date.isoformat(timespec="seconds") else: return date globus-globus-sdk-python-6a080e4/src/globus_sdk/services/timers/errors.py000066400000000000000000000056101513221403200266570ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk._internal import guards from globus_sdk.exc import ErrorSubdocument, GlobusAPIError class TimersAPIError(GlobusAPIError): """ Error class to represent error responses from Timers. Implements a dedicated method for parsing error responses from Timers due to the differences between various error formats used. """ def _parse_undefined_error_format(self) -> bool: """ Treat any top-level "error" key as an "array of size 1". Meaning that we'll see a single subdocument for data shaped like { "error": { "foo": "bar" } } Error shapes also include validation errors in a 'details' array: { "detail": [ { "loc": ["body", "start"], "msg": "field required", "type": "value_error.missing" }, { "loc": ["body", "callback_url"], "msg": "field required", "type": "value_error.missing" } ] } """ # if there is not a top-level 'error' key and no top-level # 'detail' key, no special behavior is defined # fall-back to the base class implementation # but before that fallback, try the two relevant branches # if 'error' is present, use it to populate the errors array # extract 'code' and 'messages' from it if isinstance(self._dict_data.get("error"), dict): self.errors = [ErrorSubdocument(self._dict_data["error"])] self.code = self._extract_code_from_error_array(self.errors) self.messages = self._extract_messages_from_error_array(self.errors) return True elif guards.is_list_of(self._dict_data.get("detail"), dict): # collect the errors array from details self.errors = [ ErrorSubdocument(d, message_fields=("msg",)) for d in self._dict_data["detail"] ] # extract a 'code' if there is one self.code = self._extract_code_from_error_array(self.errors) # build custom 'messages' for this case self.messages = [ f"{message}: {loc}" for (message, loc) in _parse_detail_docs(self.errors) ] return True else: return super()._parse_undefined_error_format() def _parse_detail_docs( errors: list[ErrorSubdocument], ) -> t.Iterator[tuple[str, str]]: for d in errors: if d.message is None: continue loc_list = d.get("loc") if not guards.is_list_of(loc_list, str): continue yield (d.message, ".".join(loc_list)) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/000077500000000000000000000000001513221403200253105ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/__init__.py000066400000000000000000000005171513221403200274240ustar00rootroot00000000000000from .client import TransferClient from .data import CreateTunnelData, DeleteData, TransferData from .errors import TransferAPIError from .response import IterableTransferResponse __all__ = ( "TransferClient", "TransferData", "DeleteData", "TransferAPIError", "IterableTransferResponse", "CreateTunnelData", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/client.py000066400000000000000000003151471513221403200271530ustar00rootroot00000000000000from __future__ import annotations import logging import time import typing as t import uuid from globus_sdk import client, exc, paging, response from globus_sdk._internal import guards from globus_sdk._internal.remarshal import commajoin from globus_sdk._internal.type_definitions import DateLike, IntLike from globus_sdk._missing import MISSING, MissingType from globus_sdk.scopes import GCSCollectionScopes, Scope, TransferScopes from globus_sdk.transport import RetryConfig from .data import CreateTunnelData, DeleteData, TransferData from .errors import TransferAPIError from .response import IterableTransferResponse from .transport import TRANSFER_DEFAULT_RETRY_CHECKS log = logging.getLogger(__name__) TransferFilterDict = t.Dict[str, t.Union[str, t.List[str]]] def _datelike_to_str(x: DateLike) -> str: return x if isinstance(x, str) else x.isoformat(timespec="seconds") def _format_completion_time( x: str | tuple[DateLike, DateLike] | MissingType, ) -> str | MissingType: if x is MISSING: return MISSING elif isinstance(x, str): return x else: start_t, end_t = x start_t, end_t = _datelike_to_str(start_t), _datelike_to_str(end_t) return f"{start_t},{end_t}" @t.overload def _format_filter_item(x: str | TransferFilterDict) -> str: ... @t.overload def _format_filter_item(x: MissingType) -> MissingType: ... def _format_filter_item(x: str | TransferFilterDict | MissingType) -> str | MissingType: if x is MISSING: return MISSING elif isinstance(x, str): return x return "/".join(f"{k}:{commajoin(v)}" for k, v in x.items()) def _format_filter( x: str | TransferFilterDict | list[str | TransferFilterDict] | MissingType, ) -> str | list[str] | MissingType: if isinstance(x, list): return [_format_filter_item(y) for y in x] return _format_filter_item(x) def _get_page_size(paged_result: IterableTransferResponse) -> int: return len(paged_result["DATA"]) class TransferClient(client.BaseClient): r""" Client for the `Globus Transfer API `_. .. sdk-sphinx-copy-params:: BaseClient This class provides helper methods for most common resources in the REST API, and basic ``get``, ``put``, ``post``, and ``delete`` methods from the base rest client that can be used to access any REST resource. Detailed documentation is available in the official REST API documentation, which is linked to from the method documentation. Methods that allow arbitrary keyword arguments will pass the extra arguments as query parameters. .. _transfer_filter_formatting: **Filter Formatting** Several methods of ``TransferClient`` take a ``filter`` parameter which can be a string, dict, or list of strings/dicts. When the filter given is a string, it is passed as a single unmodified param. When the filter given is a dict, it is formatted according to the below rules into a single param. When the filter is a list, each item of the list is parsed and passed as separate params. dict parsing rules: - each (key, value) pair in the dict is a clause in the resulting filter string - clauses are each formatted to ``key:value`` - when the value is a list, it is comma-separated, as in ``key:value1,value2`` - clauses are separated with slashes, as in ``key1:value1/key2:value2`` The corresponding external API documentation describes, in detail, the supported filter clauses for each method which uses the ``filter`` parameter. Generally, speaking, filter clauses documented as ``string list`` can be passed as lists to a filter dict, while string, date, and numeric filters should be passed as strings. **Paginated Calls** Methods which support pagination can be called as paginated or unpaginated methods. If the method name is ``TransferClient.foo``, the paginated version is ``TransferClient.paginated.foo``. Using ``TransferClient.endpoint_search`` as an example:: from globus_sdk import TransferClient tc = TransferClient(...) # this is the unpaginated version for x in tc.endpoint_search("tutorial"): print("Endpoint ID: {}".format(x["id"])) # this is the paginated version for page in tc.paginated.endpoint_search("testdata"): for x in page: print("Endpoint ID: {}".format(x["id"])) .. automethodlist:: globus_sdk.TransferClient """ service_name = "transfer" error_class = TransferAPIError scopes = TransferScopes default_scope_requirements = [TransferScopes.all] def _register_standard_retry_checks(self, retry_config: RetryConfig) -> None: """Override the default retry checks.""" retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) def add_app_data_access_scope( self, collection_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] ) -> TransferClient: """ Add a dependent ``data_access`` scope for one or more given ``collection_ids`` to this client's ``GlobusApp``. Useful for resolving ``ConsentRequired`` errors when using standard Globus Connect Server mapped collections. .. warning:: This method must only be used on ``collection_ids`` for non-High-Assurance GCS Mapped Collections. Use on other collection types, e.g., on GCP Mapped Collections or any form of Guest Collection, will result in "Unknown Scope" errors during the login flow. Returns ``self`` for chaining. Raises ``GlobusSDKUsageError`` if this client was not initialized with an app. :param collection_ids: a collection ID or an iterable of IDs. .. tab-set:: .. tab-item:: Example Usage Usage for ``ls`` or a similar operation which points at a single collection: .. code-block:: python app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) client = TransferClient(app=app).add_app_data_access_scope(COLLECTION_ID) res = client.operation_ls(COLLECTION_ID) Usage for ``submit_transfer`` or a similar operation which points at multiple collections: .. code-block:: python app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) client = TransferClient(app=app).add_app_data_access_scope( (COLLECTION_ID_1, COLLECTION_ID_2) ) transfer_data = TransferData( source_endpoint=COLLECTION_ID_1, destination_endpoint=COLLECTION_ID_2, ) transfer_data.add_item("/foo", "bar/baz") ... res = client.submit_transfer({}) """ # noqa: E501 if isinstance(collection_ids, (str, uuid.UUID)): guards.validators.uuidlike("collection_ids", collection_ids) # wrap the collection_ids input in a list for consistent iteration below collection_ids_ = [collection_ids] else: # copy to a list so that ephemeral iterables can be iterated multiple times collection_ids_ = list(collection_ids) for i, c in enumerate(collection_ids_): guards.validators.uuidlike(f"collection_ids[{i}]", c) scope = TransferScopes.all dependencies: list[Scope] = [] for coll_id in collection_ids_: data_access_scope = GCSCollectionScopes( str(coll_id) ).data_access.with_optional(True) dependencies.append(data_access_scope) scope = scope.with_dependencies(dependencies) self.add_app_scope(scope) return self # Convenience methods, providing more pythonic access to common REST # resources # # Endpoint Management # def get_endpoint( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: ID of endpoint to lookup :param query_params: Any additional parameters will be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) endpoint = tc.get_endpoint(endpoint_id) print("Endpoint name:", endpoint["display_name"] or endpoint["canonical_name"]) .. tab-item:: API Info ``GET /endpoint/`` .. extdoclink:: Get Endpoint or Collection by ID :ref: transfer/endpoints_and_collections/#get_endpoint_or_collection_by_id """ # noqa: E501 log.debug(f"TransferClient.get_endpoint({endpoint_id})") return self.get(f"/v0.10/endpoint/{endpoint_id}", query_params=query_params) def update_endpoint( self, endpoint_id: uuid.UUID | str, data: dict[str, t.Any], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: ID of endpoint to lookup :param data: A partial endpoint document with fields to update :param query_params: Any additional parameters will be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) epup = { "display_name": "My New Endpoint Name", "description": "Better Description", } update_result = tc.update_endpoint(endpoint_id, epup) .. tab-item:: API Info ``PUT /endpoint/`` .. extdoclink:: Update Globus Connect Personal collection by id :ref: transfer/gcp_management/#update_collection_by_id """ # noqa: E501 log.debug(f"TransferClient.update_endpoint({endpoint_id}, ...)") return self.put( f"/v0.10/endpoint/{endpoint_id}", data=data, query_params=query_params ) def set_subscription_id( self, collection_id: uuid.UUID | str, subscription_id: uuid.UUID | str | t.Literal["DEFAULT"] | None, ) -> response.GlobusHTTPResponse: """ Set the ``subscription_id`` on a mapped collection. This is primarily used for subscription management on Globus Connect Personal. This operation requires membership in a Globus subscription group and has authorization requirements which depend upon the caller's roles on the subscription group and the endpoint or collection. Guest Collections inherit the subscriptions of their Mapped Collections and cannot have a ``subscription_id`` directly set in this way. :param collection_id: The collection ID which is having its subscription set. :param subscription_id: The ID of the subscription to assign, the special string ``"DEFAULT"``, or ``None``. .. note:: Setting ``subscription_id="DEFAULT"`` results in the service choosing your subscription ID, but requires that you only have one subscription. If you have multiple subscriptions, using ``"DEFAULT"`` will result in an error. Setting ``subscription_id=None`` clears any existing subscription from the collection. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python import globus_sdk MY_SUBSCRIPTION_ID = "..." LOCAL_GCP = globus_sdk.LocalGlobusConnectPersonal() tc = globus_sdk.TransferClient(...) tc.endpoint_set_subscription_id( LOCAL_GCP.endpoint_id, MY_SUBSCRIPTION_ID, ) .. tab-item:: API Info ``PUT /endpoint//subscription`` .. extdoclink:: Associate a Globus Connect Personal Mapped Collection with a Subscription :ref: transfer/gcp_management/#associate_collection_subscription """ # noqa: E501 return self.put( f"/v0.10/endpoint/{collection_id}/subscription", data={"subscription_id": subscription_id}, ) def set_subscription_admin_verified( self, collection_id: uuid.UUID | str, subscription_admin_verified: bool, ) -> response.GlobusHTTPResponse: """ Sets the value of ``subscription_admin_verified`` on a Globus Connect Personal mapped collection. A value of ``True`` grants verified status, and a value of ``False`` revokes verified status. This operation requires membership in a Globus subscription group and has authorization requirements which depend upon the caller's roles on the subscription group and the collection. Subscription administrators can grant or revoke verification on a collection that is associated with their subscription without needing an administrator role on the collection itself. Users with the administrator effective role on the collection can revoke verification on a collection, but must still be a subscription administrator to grant verification. :param collection_id: The collection ID which is having its subscription set. :param subscription_admin_verified: The verification status of the collection expressed as a Boolean type. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python import globus_sdk LOCAL_GCP = globus_sdk.LocalGlobusConnectPersonal() tc = globus_sdk.TransferClient(...) tc.endpoint_set_subscription_id( LOCAL_GCP.endpoint_id, True, ) .. tab-item:: API Info ``PUT /endpoint//subscription_admin_verified`` .. extdoclink:: Set Subscription Admin Verified :ref: transfer/gcp_management/#set_subscription_admin_verified """ # noqa: E501 return self.put( f"/v0.10/endpoint/{collection_id}/subscription_admin_verified", data={"subscription_admin_verified": subscription_admin_verified}, ) def delete_endpoint( self, endpoint_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ :param endpoint_id: ID of endpoint to delete .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) delete_result = tc.delete_endpoint(endpoint_id) .. tab-item:: API Info ``DELETE /endpoint/`` .. extdoclink:: Delete Globus Connect Personal collection by id :ref: transfer/gcp_management/#delete_collection_by_id """ log.debug(f"TransferClient.delete_endpoint({endpoint_id})") return self.delete(f"/v0.10/endpoint/{endpoint_id}") @paging.has_paginator( paging.HasNextPaginator, items_key="DATA", get_page_size=_get_page_size, max_total_results=1000, page_size=100, ) def endpoint_search( self, filter_fulltext: str | MissingType = MISSING, *, filter_scope: str | MissingType = MISSING, filter_owner_id: str | MissingType = MISSING, filter_host_endpoint: uuid.UUID | str | MissingType = MISSING, filter_non_functional: bool | MissingType = MISSING, filter_entity_type: ( t.Literal[ "GCP_mapped_collection", "GCP_guest_collection", "GCSv5_endpoint", "GCSv5_mapped_collection", "GCSv5_guest_collection", ] | MissingType ) = MISSING, limit: int | MissingType = MISSING, offset: int | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" :param filter_fulltext: The string to use in a full text search on endpoints. Effectively, the "search query" which is being requested. May be omitted with specific ``filter_scope`` values. :param filter_scope: A "scope" within which to search for endpoints. This must be one of the limited and known names known to the service, which can be found documented in the **External Documentation** below. Defaults to searching all endpoints (in which case ``filter_fulltext`` is required) :param filter_owner_id: Limit search to endpoints owned by the specified Globus Auth identity. Conflicts with scopes 'my-endpoints', 'my-gcp-endpoints', and 'shared-by-me'. :param filter_host_endpoint: Limit search to endpoints hosted by the specified endpoint. May cause BadRequest or PermissionDenied errors if the endpoint ID given is not valid for this operation. :param filter_non_functional: Limit search to endpoints which have the 'non_functional' flag set to True or False. Mutually exclusive with ``filter_entity_type``. :param filter_entity_type: Limit search to endpoints or collections of a specified entity type. Mutually exclusive with ``filter_non_functional``. :param limit: limit the number of results :param offset: offset used in paging :param query_params: Any additional parameters will be passed through as query params. It is important to be aware that the Endpoint Search API limits you to 1000 results for any search query. .. tab-set:: .. tab-item:: Example Usage Search for a given string as a fulltext search: .. code-block:: python tc = globus_sdk.TransferClient(...) for ep in tc.endpoint_search("String to search for!"): print(ep["display_name"]) Search for a given string, but only on endpoints that you own: .. code-block:: python for ep in tc.endpoint_search("foo", filter_scope="my-endpoints"): print(f"{ep['display_name']} has ID {ep['id']}") .. tab-item:: Paginated Usage .. paginatedusage:: endpoint_search .. tab-item:: API Info .. parsed-literal:: GET /endpoint_search\ ?filter_fulltext=&filter_scope= .. extdoclink:: Endpoint and Collection Search :ref: transfer/endpoint_and_collection_search """ # noqa: E501 query_params = { "filter_scope": filter_scope, "filter_fulltext": filter_fulltext, "filter_owner_id": filter_owner_id, "filter_host_endpoint": filter_host_endpoint, "filter_non_functional": ( 1 if filter_non_functional else ( 0 if isinstance(filter_non_functional, bool) else filter_non_functional ) ), "filter_entity_type": filter_entity_type, "limit": limit, "offset": offset, **(query_params or {}), } log.debug(f"TransferClient.endpoint_search({query_params})") return IterableTransferResponse( self.get("/v0.10/endpoint_search", query_params=query_params) ) def my_effective_pause_rule_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: the endpoint on which the current user's effective pause rules are fetched :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint//my_effective_pause_rule_list`` .. extdoclink:: Get my effective collection pause rules :ref: transfer/endpoints_and_collections/#get_collection_pause_rules """ log.debug(f"TransferClient.my_effective_pause_rule_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( f"/v0.10/endpoint/{endpoint_id}/my_effective_pause_rule_list", query_params=query_params, ) ) # Shared Endpoints def my_shared_endpoint_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: the host endpoint whose shares are listed :param query_params: Additional passthrough query parameters Get a list of shared endpoints for which the user has ``administrator`` or ``access_manager`` on a given host endpoint. .. tab-set:: .. tab-item:: API Info ``GET /endpoint//my_shared_endpoint_list`` .. extdoclink:: Get my guest collection list :ref: transfer/endpoints_and_collections/#get_my_guest_collection_list """ # noqa: E501 log.debug(f"TransferClient.my_shared_endpoint_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( f"/v0.10/endpoint/{endpoint_id}/my_shared_endpoint_list", query_params=query_params, ) ) @paging.has_paginator(paging.NextTokenPaginator, items_key="shared_endpoints") def get_shared_endpoint_list( self, endpoint_id: uuid.UUID | str, *, max_results: int | MissingType = MISSING, next_token: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: the host endpoint whose shares are listed :param max_results: cap to the number of results :param next_token: token used for paging :param query_params: Any additional parameters to be passed through as query params. Get a list of all shared endpoints on a given host endpoint. .. tab-set:: .. tab-item:: Paginated Usage .. paginatedusage:: get_shared_endpoint_list .. tab-item:: API Info ``GET /endpoint//shared_endpoint_list`` .. extdoclink:: Get guest collection list :ref: transfer/endpoints_and_collections/get_guest_collection_list """ log.debug(f"TransferClient.get_shared_endpoint_list({endpoint_id}, ...)") query_params = { "max_results": ( str(max_results) if isinstance(max_results, int) else max_results ), "next_token": next_token, **(query_params or {}), } return IterableTransferResponse( self.get( f"/v0.10/endpoint/{endpoint_id}/shared_endpoint_list", query_params=query_params, ), iter_key="shared_endpoints", ) def create_shared_endpoint( self, data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ :param data: A python dict representation of a ``shared_endpoint`` document .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) shared_ep_data = { "DATA_TYPE": "shared_endpoint", "host_endpoint": host_endpoint_id, "host_path": host_path, "display_name": display_name, # optionally specify additional endpoint fields "description": "my test share", } create_result = tc.create_shared_endpoint(shared_ep_data) endpoint_id = create_result["id"] .. tab-item:: API Info ``POST /shared_endpoint`` .. extdoclink:: Create guest collection :ref: transfer/gcp_management/#create_guest_collection """ log.debug("TransferClient.create_shared_endpoint(...)") return self.post("/v0.10/shared_endpoint", data=data) # Endpoint servers def endpoint_server_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: The endpoint whose servers are being listed :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: API Info ``GET /endpoint//server_list`` .. extdoclink:: Get endpoint or collection server list :ref: transfer/endpoints_and_collections/#get_endpoint_or_collection_server_list """ # noqa: E501 log.debug(f"TransferClient.endpoint_server_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( f"/v0.10/endpoint/{endpoint_id}/server_list", query_params=query_params ) ) def get_endpoint_server( self, endpoint_id: uuid.UUID | str, server_id: IntLike, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint under which the server is registered :param server_id: The ID of the server :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint//server/`` .. extdoclink:: Get server by id :ref: transfer/endpoints_and_collections/#get_server_by_id """ log.debug( "TransferClient.get_endpoint_server(%s, %s, ...)", endpoint_id, server_id ) return self.get( f"/v0.10/endpoint/{endpoint_id}/server/{server_id}", query_params=query_params, ) # # Roles # def endpoint_role_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: The endpoint whose roles are being listed :param query_params: Any additional parameters to be passed through as query params. .. tab-set:: .. tab-item:: API Info ``GET /endpoint//role_list`` .. extdoclink:: Get list of roles :ref: transfer/roles/#role_list """ log.debug(f"TransferClient.endpoint_role_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( f"/v0.10/endpoint/{endpoint_id}/role_list", query_params=query_params ) ) def add_endpoint_role( self, endpoint_id: uuid.UUID | str, role_data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint on which the role is being added :param role_data: A role document for the new role .. tab-set:: .. tab-item:: API Info ``POST /endpoint//role`` .. extdoclink:: Create Globus Connect Personal collection role :ref: transfer/roles/#create_role """ log.debug(f"TransferClient.add_endpoint_role({endpoint_id}, ...)") return self.post(f"/v0.10/endpoint/{endpoint_id}/role", data=role_data) def get_endpoint_role( self, endpoint_id: uuid.UUID | str, role_id: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint on which the role applies :param role_id: The ID of the role :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint//role/`` .. extdoclink:: Get role by id :ref: transfer/roles/#get_role_by_id """ log.debug(f"TransferClient.get_endpoint_role({endpoint_id}, {role_id}, ...)") return self.get( f"/v0.10/endpoint/{endpoint_id}/role/{role_id}", query_params=query_params ) def delete_endpoint_role( self, endpoint_id: uuid.UUID | str, role_id: str ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint on which the role applies :param role_id: The ID of the role to delete .. tab-set:: .. tab-item:: API Info ``DELETE /endpoint//role/`` .. extdoclink:: Delete Globus Connect Personal collection role by id :ref: transfer/roles/#delete_role_by_id """ log.debug(f"TransferClient.delete_endpoint_role({endpoint_id}, {role_id})") return self.delete(f"/v0.10/endpoint/{endpoint_id}/role/{role_id}") # # ACLs # def endpoint_acl_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: The endpoint whose ACLs are being listed :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint//access_list`` .. extdoclink:: Get list of access rules :ref: transfer/acl/#rest_access_get_list """ log.debug(f"TransferClient.endpoint_acl_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( f"/v0.10/endpoint/{endpoint_id}/access_list", query_params=query_params ) ) def get_endpoint_acl_rule( self, endpoint_id: uuid.UUID | str, rule_id: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint on which the access rule applies :param rule_id: The ID of the rule to fetch :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint//access/`` .. extdoclink:: Get access rule by ID :ref: transfer/acl/#get_access_rule_by_id """ log.debug( "TransferClient.get_endpoint_acl_rule(%s, %s, ...)", endpoint_id, rule_id ) return self.get( f"/v0.10/endpoint/{endpoint_id}/access/{rule_id}", query_params=query_params ) def add_endpoint_acl_rule( self, endpoint_id: uuid.UUID | str, rule_data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ :param endpoint_id: ID of endpoint to which to add the acl :param rule_data: A python dict representation of an ``access`` document .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) rule_data = { "DATA_TYPE": "access", "principal_type": "identity", "principal": identity_id, "path": "/dataset1/", "permissions": "rw", } result = tc.add_endpoint_acl_rule(endpoint_id, rule_data) rule_id = result["access_id"] Note that if this rule is being created on a shared endpoint the "path" field is relative to the "host_path" of the shared endpoint. .. tab-item:: API Info ``POST /endpoint//access`` .. extdoclink:: Create access rule :ref: transfer/acl/#rest_access_create """ log.debug(f"TransferClient.add_endpoint_acl_rule({endpoint_id}, ...)") return self.post(f"/v0.10/endpoint/{endpoint_id}/access", data=rule_data) def update_endpoint_acl_rule( self, endpoint_id: uuid.UUID | str, rule_id: str, rule_data: dict[str, t.Any], ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint on which the access rule applies :param rule_id: The ID of the access rule to modify :param rule_data: A partial ``access`` document containing fields to update .. tab-set:: .. tab-item:: API Info ``PUT /endpoint//access/`` .. extdoclink:: Update access rule :ref: transfer/acl/#update_access_rule """ log.debug( "TransferClient.update_endpoint_acl_rule(%s, %s, ...)", endpoint_id, rule_id, ) return self.put( f"/v0.10/endpoint/{endpoint_id}/access/{rule_id}", data=rule_data ) def delete_endpoint_acl_rule( self, endpoint_id: uuid.UUID | str, rule_id: str ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The endpoint on which the access rule applies :param rule_id: The ID of the access rule to remove .. tab-set:: .. tab-item:: API Info ``DELETE /endpoint//access/`` .. extdoclink:: Delete access rule :ref: transfer/acl/#delete_access_rule """ log.debug( "TransferClient.delete_endpoint_acl_rule(%s, %s)", endpoint_id, rule_id ) return self.delete(f"/v0.10/endpoint/{endpoint_id}/access/{rule_id}") # # Bookmarks # def bookmark_list( self, *, query_params: dict[str, t.Any] | None = None ) -> IterableTransferResponse: """ :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /bookmark_list`` .. extdoclink:: Get list of bookmarks :ref: transfer/collection_bookmarks/#get_list_of_bookmarks """ log.debug(f"TransferClient.bookmark_list({query_params})") return IterableTransferResponse( self.get("/v0.10/bookmark_list", query_params=query_params) ) def create_bookmark( self, bookmark_data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ :param bookmark_data: A bookmark document for the bookmark to create .. tab-set:: .. tab-item:: API Info ``POST /bookmark`` .. extdoclink:: Create bookmark :ref: transfer/collection_bookmarks/#create_bookmark """ log.debug(f"TransferClient.create_bookmark({bookmark_data})") return self.post("/v0.10/bookmark", data=bookmark_data) def get_bookmark( self, bookmark_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param bookmark_id: The ID of the bookmark to lookup :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /bookmark/`` .. extdoclink:: Get bookmark by ID :ref: transfer/collection_bookmarks/#get_bookmark_by_id """ log.debug(f"TransferClient.get_bookmark({bookmark_id})") return self.get(f"/v0.10/bookmark/{bookmark_id}", query_params=query_params) def update_bookmark( self, bookmark_id: uuid.UUID | str, bookmark_data: dict[str, t.Any] ) -> response.GlobusHTTPResponse: """ :param bookmark_id: The ID of the bookmark to modify :param bookmark_data: A partial bookmark document with fields to update .. tab-set:: .. tab-item:: API Info ``PUT /bookmark/`` .. extdoclink:: Update bookmark :ref: transfer/collection_bookmarks/#update_bookmark """ log.debug(f"TransferClient.update_bookmark({bookmark_id})") return self.put(f"/v0.10/bookmark/{bookmark_id}", data=bookmark_data) def delete_bookmark( self, bookmark_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: """ :param bookmark_id: The ID of the bookmark to delete .. tab-set:: .. tab-item:: API Info ``DELETE /bookmark/`` .. extdoclink:: Delete bookmark by ID :ref: transfer/collection_bookmarks/#delete_bookmark_by_id """ log.debug(f"TransferClient.delete_bookmark({bookmark_id})") return self.delete(f"/v0.10/bookmark/{bookmark_id}") # # Synchronous Filesys Operations # def operation_ls( self, endpoint_id: uuid.UUID | str, path: str | MissingType = MISSING, *, show_hidden: bool | MissingType = MISSING, orderby: str | list[str] | MissingType = MISSING, limit: int | MissingType = MISSING, offset: int | MissingType = MISSING, # note: filter is a soft keyword in python, so using this name is okay # pylint: disable=redefined-builtin filter: ( str | TransferFilterDict | list[str | TransferFilterDict] | MissingType ) = MISSING, local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param endpoint_id: The ID of the endpoint on which to do a dir listing :param path: Path to a directory on the endpoint to list :param show_hidden: Show hidden files (names beginning in dot). Defaults to true. :param limit: Limit the number of results returned. Defaults to 100,000 , which is also the maximum. :param offset: Offset into the result list, which can be used to page results. :param orderby: One or more order-by options. Each option is either a field name or a field name followed by a space and 'ASC' or 'DESC' for ascending or descending. :param filter: Only return file documents which match these filter clauses. For the filter syntax, see the **External Documentation** linked below. If a dict is supplied as the filter, it is formatted as a set of filter clauses. If a list is supplied, it is passed as multiple params. See :ref:`filter formatting ` for details. :param local_user: Optional value passed to identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param query_params: Additional passthrough query parameters .. note:: Pagination is not supported by the GridFTP protocol, and therefore limit+offset pagination will result in the Transfer service repeatedly fetching an entire directory listing from the server and filtering it before returning it to the client. For latency-sensitive applications, such usage may still be more efficient than asking for a very large directory listing as it reduces the size of the payload passed between the Transfer service and the client. .. tab-set:: .. tab-item:: Example Usage List with a path: .. code-block:: python tc = globus_sdk.TransferClient(...) for entry in tc.operation_ls(ep_id, path="/~/project1/"): print(entry["name"], entry["type"]) List with explicit ordering: .. code-block:: python tc = globus_sdk.TransferClient(...) for entry in tc.operation_ls(ep_id, path="/~/project1/", orderby=["type", "name"]): print(entry["name DESC"], entry["type"]) List filtering to files modified before January 1, 2021. Note the use of an empty "start date" for the filter: .. code-block:: python tc = globus_sdk.TransferClient(...) for entry in tc.operation_ls( ep_id, path="/~/project1/", filter={"last_modified": ["", "2021-01-01"]}, ): print(entry["name"], entry["type"]) .. tab-item:: API Info ``GET /operation/endpoint//ls`` .. extdoclink:: List Directory Contents :ref: transfer/file_operations/#list_directory_contents """ # noqa: E501 query_params = { "path": path, "limit": limit, "offset": offset, "show_hidden": ( 1 if show_hidden else 0 if isinstance(show_hidden, bool) else show_hidden ), "orderby": commajoin(orderby), "filter": _format_filter(filter), "local_user": local_user, **(query_params or {}), } log.debug(f"TransferClient.operation_ls({endpoint_id}, {query_params})") return IterableTransferResponse( self.get( f"/v0.10/operation/endpoint/{endpoint_id}/ls", query_params=query_params ) ) def operation_mkdir( self, endpoint_id: uuid.UUID | str, path: str, *, local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The ID of the endpoint on which to create a directory :param path: Path to the new directory to create :param local_user: Optional value passed to identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) tc.operation_mkdir(ep_id, path="/~/newdir/") .. tab-item:: API Info ``POST /operation/endpoint//mkdir`` .. extdoclink:: Make Directory :ref: transfer/file_operations/#make_directory """ log.debug( "TransferClient.operation_mkdir({}, {}, {})".format( endpoint_id, path, query_params ) ) json_body = { "DATA_TYPE": "mkdir", "path": path, "local_user": local_user, } return self.post( f"/v0.10/operation/endpoint/{endpoint_id}/mkdir", data=json_body, query_params=query_params, ) def operation_rename( self, endpoint_id: uuid.UUID | str, oldpath: str, newpath: str, *, local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The ID of the endpoint on which to rename a file :param oldpath: Path to the old filename :param newpath: Path to the new filename :param local_user: Optional value passed to identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) tc.operation_rename(ep_id, oldpath="/~/file1.txt", newpath="/~/project1data.txt") .. tab-item:: API Info ``POST /operation/endpoint//rename`` .. extdoclink:: Rename :ref: transfer/file_operations/#rename """ # noqa: E501 log.debug( "TransferClient.operation_rename({}, {}, {}, {})".format( endpoint_id, oldpath, newpath, query_params ) ) json_body = { "DATA_TYPE": "rename", "old_path": oldpath, "new_path": newpath, "local_user": local_user, } return self.post( f"/v0.10/operation/endpoint/{endpoint_id}/rename", data=json_body, query_params=query_params, ) def operation_stat( self, endpoint_id: uuid.UUID | str, path: str | MissingType = MISSING, *, local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param endpoint_id: The ID of the collection on which to do the stat operation :param path: Path on the collection to do the stat operation on :param local_user: Optional value passed to identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) tc.operation_stat(ep_id, "/path/to/item") .. tab-item:: Example Response Data .. expandtestfixture:: transfer.operation_stat .. tab-item:: API Info ``GET /operation/endpoint//stat`` .. extdoclink:: Get File or Directory Status :ref: transfer/file_operations/#stat """ query_params = { "path": path, "local_user": local_user, **(query_params or {}), } log.debug(f"TransferClient.operation_stat({endpoint_id}, {query_params})") return self.get( f"/v0.10/operation/endpoint/{endpoint_id}/stat", query_params=query_params ) # # Task Submission # def get_submission_id( self, *, query_params: dict[str, t.Any] | None = None ) -> response.GlobusHTTPResponse: """ :param query_params: Additional passthrough query parameters Submission IDs are required to submit tasks to the Transfer service via the :meth:`submit_transfer <.submit_transfer>` and :meth:`submit_delete <.submit_delete>` methods. Most users will not need to call this method directly, as the methods :meth:`~submit_transfer` and :meth:`~submit_delete` will call it automatically if the data does not contain a ``submission_id``. .. tab-set:: .. tab-item:: API Info ``GET /submission_id`` .. extdoclink:: Get a submission ID :ref: transfer/task_submit/#get_submission_id .. tab-item:: Example Response Data .. expandtestfixture:: transfer.get_submission_id """ log.debug(f"TransferClient.get_submission_id({query_params})") return self.get("/v0.10/submission_id", query_params=query_params) def submit_transfer( self, data: dict[str, t.Any] | TransferData ) -> response.GlobusHTTPResponse: """ :param data: A transfer task document listing files and directories, and setting various options. See :class:`TransferData ` for details Submit a Transfer Task. If no ``submission_id`` is included in the payload, one will be requested and used automatically. The data passed to this method will be modified to include the ``submission_id``. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tdata = globus_sdk.TransferData( source_endpoint_id, destination_endpoint_id, label="SDK example", sync_level="checksum", ) tdata.add_item("/source/path/dir/", "/dest/path/dir/") tdata.add_item("/source/path/file.txt", "/dest/path/file.txt") tc = globus_sdk.TransferClient(...) transfer_result = tc.submit_transfer(tdata) print("task_id =", transfer_result["task_id"]) The `data` parameter can be a normal Python dictionary, or a :class:`TransferData ` object. .. tab-item:: API Info ``POST /transfer`` .. extdoclink:: Submit a transfer task :ref: transfer/task_submit/#submit_transfer_task """ # noqa: E501 log.debug("TransferClient.submit_transfer(...)") if "submission_id" not in data or data["submission_id"] is MISSING: log.debug("submit_transfer autofetching submission_id") data["submission_id"] = self.get_submission_id()["value"] return self.post("/v0.10/transfer", data=data) def submit_delete( self, data: dict[str, t.Any] | DeleteData ) -> response.GlobusHTTPResponse: """ :param data: A delete task document listing files and directories, and setting various options. See :class:`DeleteData ` for details Submit a Delete Task. If no ``submission_id`` is included in the payload, one will be requested and used automatically. The data passed to this method will be modified to include the ``submission_id``. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python ddata = globus_sdk.DeleteData(endpoint_id, recursive=True) ddata.add_item("/dir/to/delete/") ddata.add_item("/file/to/delete/file.txt") tc = globus_sdk.TransferClient(...) delete_result = tc.submit_delete(ddata) print("task_id =", delete_result["task_id"]) The `data` parameter can be a normal Python dictionary, or a :class:`DeleteData ` object. .. tab-item:: API Info ``POST /delete`` .. extdoclink:: Submit a delete task :ref: transfer/task_submit/#submit_delete_task """ log.debug("TransferClient.submit_delete(...)") if "submission_id" not in data or data["submission_id"] is MISSING: log.debug("submit_delete autofetching submission_id") data["submission_id"] = self.get_submission_id()["value"] return self.post("/v0.10/delete", data=data) # # Task inspection and management # @paging.has_paginator( paging.LimitOffsetTotalPaginator, items_key="DATA", get_page_size=_get_page_size, max_total_results=1000, page_size=1000, ) def task_list( self, *, limit: int | MissingType = MISSING, offset: int | MissingType = MISSING, orderby: str | list[str] | MissingType = MISSING, # pylint: disable=redefined-builtin filter: str | TransferFilterDict | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ Get an iterable of task documents owned by the current user. :param limit: limit the number of results :param offset: offset used in paging :param orderby: One or more order-by options. Each option is either a field name or a field name followed by a space and 'ASC' or 'DESC' for ascending or descending. See example usage for details. :param filter: Only return task documents which match these filter clauses. For the filter syntax, see the **External Documentation** linked below. If a dict is supplied as the filter, it is formatted as a set of filter clauses. See :ref:`filter formatting ` for details. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage Fetch 10 tasks and print some basic info: .. code-block:: python tc = TransferClient(...) for task in tc.task_list(limit=10): print( f"Task({task['task_id']}): " f"{task['source_endpoint']} -> {task['destination_endpoint']}" ) Fetch 3 *specific* tasks using a ``task_id`` filter: .. code-block:: python tc = TransferClient(...) task_ids = [ "acb4b581-b3f3-403a-a42a-9da97aaa9961", "39447a3c-e002-401a-b95c-f48b69b4c60a", "02330d3a-987b-4abb-97ed-6a22f8fa365e", ] for task in tc.task_list(filter={"task_id": task_ids}): print( f"Task({task['task_id']}): " f"{task['source_endpoint']} -> {task['destination_endpoint']}" ) Fetch the last 5 failed tasks by submission time: .. code-block:: python tc = TransferClient(...) for task in tc.task_list( limit=5, orderby="request_time DESC", filter={"status": "FAILED"} ): print( f"Task({task['task_id']}) failed: " f"{task['source_endpoint']} -> {task['destination_endpoint']}" ) .. tab-item:: Paginated Usage .. paginatedusage:: task_list .. tab-item:: API Info ``GET /task_list`` .. extdoclink:: Task List :ref: transfer/task/#get_task_list """ # noqa: E501 log.debug("TransferClient.task_list(...)") query_params = { "limit": limit, "offset": offset, "orderby": commajoin(orderby), "filter": _format_filter_item(filter), **(query_params or {}), } return IterableTransferResponse( self.get("/v0.10/task_list", query_params=query_params) ) @paging.has_paginator( paging.LimitOffsetTotalPaginator, items_key="DATA", get_page_size=_get_page_size, max_total_results=1000, page_size=1000, ) def task_event_list( self, task_id: uuid.UUID | str, *, limit: int | MissingType = MISSING, offset: int | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" List events (for example, faults and errors) for a given Task. :param task_id: The ID of the task to inspect :param limit: limit the number of results :param offset: offset used in paging :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage Fetch 10 events and print some basic info: .. code-block:: python tc = TransferClient(...) task_id = ... for event in tc.task_event_list(task_id, limit=10): print(f"Event on Task({task_id}) at {event['time']}:\n{event['description']}") .. tab-item:: Paginated Usage .. paginatedusage:: task_event_list .. tab-item:: API Info ``GET /task//event_list`` .. extdoclink:: Get Event List :ref: transfer/task/#get_event_list """ # noqa: E501 log.debug(f"TransferClient.task_event_list({task_id}, ...)") query_params = { "limit": limit, "offset": offset, **(query_params or {}), } return IterableTransferResponse( self.get(f"/v0.10/task/{task_id}/event_list", query_params=query_params) ) def get_task( self, task_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param task_id: The ID of the task to inspect :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /task/`` .. extdoclink:: Get task by ID :ref: transfer/task/#get_task_by_id """ log.debug(f"TransferClient.get_task({task_id}, ...)") return self.get(f"/v0.10/task/{task_id}", query_params=query_params) def update_task( self, task_id: uuid.UUID | str, data: dict[str, t.Any], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Modify a task. Only tasks which are still running can be modified, and only the ``label`` and ``deadline`` fields can be updated. :param task_id: The ID of the task to modify :param data: A partial task document with fields to update :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``PUT /task/`` .. extdoclink:: Update task by ID :ref: transfer/task/#update_task_by_id """ log.debug(f"TransferClient.update_task({task_id}, ...)") return self.put(f"/v0.10/task/{task_id}", data=data, query_params=query_params) def cancel_task(self, task_id: uuid.UUID | str) -> response.GlobusHTTPResponse: """ Cancel a task which is still running. :param task_id: The ID of the task to cancel .. tab-set:: .. tab-item:: API Info ``POST /task//cancel`` .. extdoclink:: Cancel task by ID :ref: transfer/task/#cancel_task_by_id """ log.debug(f"TransferClient.cancel_task({task_id})") return self.post(f"/v0.10/task/{task_id}/cancel") def task_wait( self, task_id: uuid.UUID | str, *, timeout: int = 10, polling_interval: int = 10 ) -> bool: r""" Wait until a Task is complete or fails, with a time limit. If the task is "ACTIVE" after time runs out, returns ``False``. Otherwise returns ``True``. :param task_id: ID of the Task to wait on for completion :param timeout: Number of seconds to wait in total. Minimum 1. [Default: ``10``] :param polling_interval: Number of seconds between queries to Globus about the Task status. Minimum 1. [Default: ``10``] .. tab-set:: .. tab-item:: Example Usage If you want to wait for a task to terminate, but want to warn every minute that it doesn't terminate, you could: .. code-block:: python tc = TransferClient(...) while not tc.task_wait(task_id, timeout=60): print(f"Another minute went by without {task_id} terminating") Or perhaps you want to check on a task every minute for 10 minutes, and give up if it doesn't complete in that time: .. code-block:: python tc = TransferClient(...) done = tc.task_wait(task_id, timeout=600, polling_interval=60) if not done: print(f"{task_id} didn't successfully terminate!") else: print(f"{task_id} completed") You could print dots while you wait for a task by only waiting one second at a time: .. code-block:: python tc = TransferClient(...) while not tc.task_wait(task_id, timeout=1, polling_interval=1): print(".", end="") print(f"\n{task_id} completed!") """ # noqa: E501 log.debug( "TransferClient.task_wait(%s, %s, %s)", task_id, timeout, polling_interval ) # check valid args if timeout < 1: log.error(f"task_wait() timeout={timeout} is less than minimum of 1s") raise exc.GlobusSDKUsageError( "TransferClient.task_wait timeout has a minimum of 1" ) if polling_interval < 1: log.error( "task_wait() polling_interval={} is less than minimum of 1s".format( polling_interval ) ) raise exc.GlobusSDKUsageError( "TransferClient.task_wait polling_interval has a minimum of 1" ) # ensure that we always wait at least one interval, even if the timeout # is shorter than the polling interval, by reducing the interval to the # timeout if it is larger polling_interval = min(timeout, polling_interval) # helper for readability def timed_out(waited_time: int) -> bool: return waited_time > timeout waited_time = 0 # doing this as a while-True loop actually makes it simpler than doing # while not timed_out(waited_time) because of the end condition while True: # get task, check if status != ACTIVE task = self.get_task(task_id) status = task["status"] if status != "ACTIVE": log.debug( "task_wait(task_id={}) terminated with status={}".format( task_id, status ) ) return True # make sure to check if we timed out before sleeping again, so we # don't sleep an extra polling_interval waited_time += polling_interval if timed_out(waited_time): log.debug(f"task_wait(task_id={task_id}) timed out") return False log.debug(f"task_wait(task_id={task_id}) waiting {polling_interval}s") time.sleep(polling_interval) # unreachable -- end of task_wait def task_pause_info( self, task_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get info about why a task is paused or about to be paused. :param task_id: The ID of the task to inspect :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /task//pause_info`` .. extdoclink:: Get task pause info :ref: transfer/task/#get_task_pause_info """ log.debug(f"TransferClient.task_pause_info({task_id}, ...)") return self.get(f"/v0.10/task/{task_id}/pause_info", query_params=query_params) @paging.has_paginator( paging.NullableMarkerPaginator, items_key="DATA", marker_key="next_marker" ) def task_successful_transfers( self, task_id: uuid.UUID | str, *, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ Get the successful file transfers for a completed Task. .. note:: Only files that were actually transferred are included. This does not include directories, files that were checked but skipped as part of a sync transfer, or files which were skipped due to skip_source_errors being set on the task. :param task_id: The ID of the task to inspect :param marker: A marker for pagination :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage Fetch all transferred files for a task and print some basic info: .. code-block:: python tc = TransferClient(...) task_id = ... for info in tc.task_successful_transfers(task_id): print(f"{info['source_path']} -> {info['destination_path']}") .. tab-item:: Paginated Usage .. paginatedusage:: task_successful_transfers .. tab-item:: API Info ``GET /task//successful_transfers`` .. extdoclink:: Get Task Successful Transfers :ref: transfer/task/#get_task_successful_transfers """ # noqa: E501 log.debug(f"TransferClient.task_successful_transfers({task_id}, ...)") query_params = { "marker": marker, **(query_params or {}), } return IterableTransferResponse( self.get( f"/v0.10/task/{task_id}/successful_transfers", query_params=query_params ) ) @paging.has_paginator( paging.NullableMarkerPaginator, items_key="DATA", marker_key="next_marker" ) def task_skipped_errors( self, task_id: uuid.UUID | str, *, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ Get path and error information for all paths that were skipped due to skip_source_errors being set on a completed transfer Task. :param task_id: The ID of the task to inspect :param marker: A marker for pagination :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage Fetch all skipped errors for a task and print some basic info: .. code-block:: python tc = TransferClient(...) task_id = ... for info in tc.task_skipped_errors(task_id): print(f"{info['error_code']} -> {info['source_path']}") .. tab-item:: Paginated Usage .. paginatedusage:: task_skipped_errors .. tab-item:: API Info ``GET /task//skipped_errors`` .. extdoclink:: Get Task Skipped Errors :ref: transfer/task/#get_task_skipped_errors """ # noqa: E501 log.debug("TransferClient.task_skipped_errors(%s, ...)", task_id) query_params = { "marker": marker, **(query_params or {}), } return IterableTransferResponse( self.get(f"/v0.10/task/{task_id}/skipped_errors", query_params=query_params) ) # # advanced endpoint management (requires endpoint manager role) # def endpoint_manager_monitored_endpoints( self, *, query_params: dict[str, t.Any] | None = None ) -> IterableTransferResponse: """ Get endpoints the current user is a monitor or manager on. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET endpoint_manager/monitored_endpoints`` .. extdoclink:: Get monitored endpoints and collections :ref: transfer/advanced_collection_management/#get_monitored_endpoints_and_collections """ # noqa: E501 log.debug( f"TransferClient.endpoint_manager_monitored_endpoints({query_params})" ) return IterableTransferResponse( self.get( "/v0.10/endpoint_manager/monitored_endpoints", query_params=query_params ) ) def endpoint_manager_hosted_endpoint_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ Get shared endpoints hosted on the given endpoint. :param endpoint_id: The ID of the host endpoint :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/endpoint//hosted_endpoint_list`` .. extdoclink:: Get hosted endpoint list :ref: transfer/advanced_collection_management/#get_hosted_collection_list """ # noqa: E501 log.debug( f"TransferClient.endpoint_manager_hosted_endpoint_list({endpoint_id})" ) return IterableTransferResponse( self.get( f"/v0.10/endpoint_manager/endpoint/{endpoint_id}/hosted_endpoint_list", query_params=query_params, ) ) def endpoint_manager_get_endpoint( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get endpoint details as an admin. :param endpoint_id: The ID of the endpoint :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/endpoint/`` .. extdoclink:: Get endpoint as admin :ref: transfer/advanced_collection_management/#mc_get_endpoint_or_collection """ # noqa: E501 log.debug(f"TransferClient.endpoint_manager_get_endpoint({endpoint_id})") return self.get( f"/v0.10/endpoint_manager/endpoint/{endpoint_id}", query_params=query_params ) def endpoint_manager_acl_list( self, endpoint_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ Get a list of access control rules on specified endpoint as an admin. :param endpoint_id: The ID of the endpoint :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET endpoint_manager/endpoint//access_list`` .. extdoclink:: Get guest collection access list as admin :ref: transfer/advanced_collection_management/#get_guest_collection_access_list_as_admin """ # noqa: E501 log.debug( f"TransferClient.endpoint_manager_endpoint_acl_list({endpoint_id}, ...)" ) return IterableTransferResponse( self.get( f"/v0.10/endpoint_manager/endpoint/{endpoint_id}/access_list", query_params=query_params, ) ) # # endpoint manager task methods # @paging.has_paginator(paging.LastKeyPaginator, items_key="DATA") def endpoint_manager_task_list( self, *, filter_status: str | t.Iterable[str] | MissingType = MISSING, filter_task_id: ( uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType ) = MISSING, filter_owner_id: uuid.UUID | str | MissingType = MISSING, filter_endpoint: uuid.UUID | str | MissingType = MISSING, filter_endpoint_use: t.Literal["source", "destination"] | MissingType = MISSING, filter_is_paused: bool | MissingType = MISSING, filter_completion_time: str | tuple[DateLike, DateLike] | MissingType = MISSING, filter_min_faults: int | MissingType = MISSING, filter_local_user: str | MissingType = MISSING, last_key: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" Get a list of tasks visible via ``activity_monitor`` role, as opposed to tasks owned by the current user. For any query that doesn't specify a ``filter_status`` that is a subset of ``("ACTIVE", "INACTIVE")``, at least one of ``filter_task_id`` or ``filter_endpoint`` is required. :param filter_status: Return only tasks with any of the specified statuses Note that in-progress tasks will have status ``"ACTIVE"`` or ``"INACTIVE"``, and completed tasks will have status ``"SUCCEEDED"`` or ``"FAILED"``. :param filter_task_id: Return only tasks with any of the specified ids. If any of the specified tasks do not involve an endpoint the user has an appropriate role for, a ``PermissionDenied`` error will be returned. This filter can't be combined with any other filter. If another filter is passed, a ``BadRequest`` will be returned. (limit: 50 task IDs) :param filter_owner_id: A Globus Auth identity id. Limit results to tasks submitted by the specified identity, or linked to the specified identity, at submit time. Returns ``UserNotFound`` if the identity does not exist or has never used the Globus Transfer service. If no tasks were submitted by this user to an endpoint the current user has an appropriate role on, an empty result set will be returned. Unless filtering for running tasks (i.e. ``filter_status`` is a subset of ``("ACTIVE", "INACTIVE")``, ``filter_endpoint`` is required when using ``filter_owner_id``. :param filter_endpoint: Single endpoint id. Return only tasks with a matching source or destination endpoint or matching source or destination host endpoint. :param filter_endpoint_use: In combination with ``filter_endpoint``, filter to tasks where the endpoint or collection was used specifically as the source or destination of the transfer. :param filter_is_paused: Return only tasks with the specified ``is_paused`` value. Requires that ``filter_status`` is also passed and contains a subset of ``"ACTIVE"`` and ``"INACTIVE"``. Completed tasks always have ``is_paused`` equal to ``False`` and filtering on their paused state is not useful and not supported. Note that pausing is an async operation, and after a pause rule is inserted it will take time before the ``is_paused`` flag is set on all affected tasks. Tasks paused by id will have the ``is_paused`` flag set immediately. :param filter_completion_time: Start and end date-times separated by a comma, or provided as a tuple of strings or datetime objects. Returns only completed tasks with ``completion_time`` in the specified range. Date strings should be specified in one of the following ISO 8601 formats: ``YYYY-MM-DDTHH:MM:SS``, ``YYYY-MM-DDTHH:MM:SS+/-HH:MM``, or ``YYYY-MM-DDTHH:MM:SSZ``. If no timezone is specified, UTC is assumed. A space can be used between the date and time instead of ``T``. A blank string may be used for either the start or end (but not both) to indicate no limit on that side. If the end date is blank, the filter will also include all active tasks, since they will complete some time in the future. :param filter_min_faults: Minimum number of cumulative faults, inclusive. Return only tasks with ``faults >= N``, where ``N`` is the filter value. Use ``filter_min_faults=1`` to find all tasks with at least one fault. Note that many errors are not fatal and the task may still be successful even if ``faults >= 1``. :param filter_local_user: A valid username for the target system running the endpoint, as a utf8 encoded string. Requires that ``filter_endpoint`` is also set. Return only tasks that have successfully fetched the local user from the endpoint, and match the values of ``filter_endpoint`` and ``filter_local_user`` on the source or on the destination. :param last_key: the last key, for paging :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Example Usage Fetch some tasks and print some basic info: .. code-block:: python tc = TransferClient(...) for task in tc.endpoint_manager_task_list(filter_status="ACTIVE"): print( f"Task({task['task_id']}): " f"{task['source_endpoint']} -> {task['destination_endpoint']}\n" " was submitted by\n" f" {task['owner_string']}" ) .. tab-item:: Paginated Usage .. paginatedusage:: endpoint_manager_task_list For example, fetch and print all active tasks visible via ``activity_monitor`` permissions: .. code-block:: python tc = TransferClient(...) for page in tc.paginated.endpoint_manager_task_list(filter_status="ACTIVE"): for task in page: print( f"Task({task['task_id']}): " f"{task['source_endpoint']} -> {task['destination_endpoint']}\n" " was submitted by\n" f" {task['owner_string']}" ) .. tab-item:: API Info ``GET endpoint_manager/task_list`` .. extdoclink:: Advanced Collection Management: Get tasks :ref: transfer/advanced_collection_management/#get_tasks """ # noqa: E501 log.debug("TransferClient.endpoint_manager_task_list(...)") if filter_endpoint is MISSING and filter_endpoint_use is not MISSING: raise exc.GlobusSDKUsageError( "`filter_endpoint_use` is only valid when `filter_endpoint` is " "also supplied." ) query_params = { "filter_status": commajoin(filter_status), "filter_task_id": commajoin(filter_task_id), "filter_owner_id": filter_owner_id, "filter_endpoint": filter_endpoint, "filter_endpoint_use": filter_endpoint_use, "filter_is_paused": filter_is_paused, "filter_completion_time": _format_completion_time(filter_completion_time), "filter_min_faults": filter_min_faults, "filter_local_user": filter_local_user, "last_key": last_key, **(query_params or {}), } return IterableTransferResponse( self.get("/v0.10/endpoint_manager/task_list", query_params=query_params) ) def endpoint_manager_get_task( self, task_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get task info as an admin. Requires activity monitor effective role on the destination endpoint of the task. :param task_id: The ID of the task to inspect :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/task/`` .. extdoclink:: Advanced Collection Management: Get task :ref: transfer/advanced_collection_management/#get_task """ log.debug(f"TransferClient.endpoint_manager_get_task({task_id}, ...)") return self.get( f"/v0.10/endpoint_manager/task/{task_id}", query_params=query_params ) @paging.has_paginator( paging.LimitOffsetTotalPaginator, items_key="DATA", get_page_size=_get_page_size, max_total_results=1000, page_size=1000, ) def endpoint_manager_task_event_list( self, task_id: uuid.UUID | str, *, limit: int | MissingType = MISSING, offset: int | MissingType = MISSING, filter_is_error: bool | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ List events (for example, faults and errors) for a given task as an admin. Requires activity monitor effective role on the destination endpoint of the task. :param task_id: The ID of the task to inspect :param limit: limit the number of results :param offset: offset used in paging :param filter_is_error: Return only events that are errors. A value of ``False`` (returning only non-errors) is not supported. By default all events are returned. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Paginated Usage .. paginatedusage:: endpoint_manager_task_event_list .. tab-item:: API Info ``GET /task//event_list`` .. extdoclink:: Advanced Collection Management: Get task events :ref: transfer/advanced_collection_management/#get_task_events """ log.debug(f"TransferClient.endpoint_manager_task_event_list({task_id}, ...)") query_params = { "limit": limit, "offset": offset, "filter_is_error": ( 1 if filter_is_error else 0 if isinstance(filter_is_error, bool) else filter_is_error ), **(query_params or {}), } return IterableTransferResponse( self.get( f"/v0.10/endpoint_manager/task/{task_id}/event_list", query_params=query_params, ) ) def endpoint_manager_task_pause_info( self, task_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get details about why a task is paused as an admin. Requires activity monitor effective role on the destination endpoint of the task. :param task_id: The ID of the task to inspect :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/task//pause_info`` .. extdoclink:: Get task pause info as admin :ref: transfer/advanced_collection_management/#get_task_pause_info_as_admin """ # noqa: E501 log.debug(f"TransferClient.endpoint_manager_task_pause_info({task_id}, ...)") return self.get( f"/v0.10/endpoint_manager/task/{task_id}/pause_info", query_params=query_params, ) @paging.has_paginator( paging.NullableMarkerPaginator, items_key="DATA", marker_key="next_marker" ) def endpoint_manager_task_successful_transfers( self, task_id: uuid.UUID | str, *, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" Get the successful file transfers for a completed Task as an admin. :param task_id: The ID of the task to inspect :param marker: A marker for pagination :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Paginated Usage .. paginatedusage:: endpoint_manager_task_successful_transfers .. tab-item:: API Info ``GET /endpoint_manager/task//successful_transfers`` .. extdoclink:: Get task successful transfers as admin :ref: transfer/advanced_collection_management/\ #get_task_successful_transfers_as_admin """ log.debug( "TransferClient.endpoint_manager_task_successful_transfers(%s, ...)", task_id, ) query_params = { "marker": marker, **(query_params or {}), } return IterableTransferResponse( self.get( f"/v0.10/endpoint_manager/task/{task_id}/successful_transfers", query_params=query_params, ) ) @paging.has_paginator( paging.NullableMarkerPaginator, items_key="DATA", marker_key="next_marker" ) def endpoint_manager_task_skipped_errors( self, task_id: uuid.UUID | str, *, marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" Get skipped errors for a completed Task as an admin. :param task_id: The ID of the task to inspect :param marker: A marker for pagination :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: Paginated Usage .. paginatedusage:: endpoint_manager_task_skipped_errors .. tab-item:: API Info ``GET /endpoint_manager/task//skipped_errors`` .. extdoclink:: Get task skipped errors as admin :ref: transfer/advanced_collection_management/\ #get_task_skipped_errors_as_admin """ log.debug( f"TransferClient.endpoint_manager_task_skipped_errors({task_id}, ...)" ) query_params = { "marker": marker, **(query_params or {}), } return IterableTransferResponse( self.get( f"/v0.10/endpoint_manager/task/{task_id}/skipped_errors", query_params=query_params, ) ) def endpoint_manager_cancel_tasks( self, task_ids: t.Iterable[uuid.UUID | str], message: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Cancel a list of tasks as an admin. Requires activity manager effective role on the task(s) source or destination endpoint(s). :param task_ids: List of task ids to cancel :param message: Message given to all users whose tasks have been canceled :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /endpoint_manager/admin_cancel`` .. extdoclink:: Cancel tasks as admin :ref: transfer/advanced_collection_management/#admin_cancel """ str_task_ids = [str(i) for i in task_ids] log.debug( f"TransferClient.endpoint_manager_cancel_tasks({str_task_ids}, {message})" ) data = {"message": message, "task_id_list": str_task_ids} return self.post( "/v0.10/endpoint_manager/admin_cancel", data=data, query_params=query_params ) def endpoint_manager_cancel_status( self, admin_cancel_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get the status of an an admin cancel (result of endpoint_manager_cancel_tasks). :param admin_cancel_id: The ID of the the cancel job to inspect :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/admin_cancel/`` .. extdoclink:: Get cancel status by ID :ref: transfer/advanced_collection_management/#get_cancel_status_by_id """ # noqa: E501 log.debug(f"TransferClient.endpoint_manager_cancel_status({admin_cancel_id})") return self.get( f"/v0.10/endpoint_manager/admin_cancel/{admin_cancel_id}", query_params=query_params, ) def endpoint_manager_pause_tasks( self, task_ids: t.Iterable[uuid.UUID | str], message: str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Pause a list of tasks as an admin. Requires activity manager effective role on the task(s) source or destination endpoint(s). :param task_ids: List of task ids to pause :param message: Message given to all users whose tasks have been paused :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /endpoint_manager/admin_pause`` .. extdoclink:: Pause tasks as admin :ref: transfer/advanced_collection_management/#pause_tasks_as_admin """ str_task_ids = [str(i) for i in task_ids] log.debug( f"TransferClient.endpoint_manager_pause_tasks({str_task_ids}, {message})" ) data = {"message": message, "task_id_list": str_task_ids} return self.post( "/v0.10/endpoint_manager/admin_pause", data=data, query_params=query_params ) def endpoint_manager_resume_tasks( self, task_ids: t.Iterable[uuid.UUID | str], *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Resume a list of tasks as an admin. Requires activity manager effective role on the task(s) source or destination endpoint(s). :param task_ids: List of task ids to resume :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``POST /endpoint_manager/admin_resume`` .. extdoclink:: Resume tasks as admin :ref: transfer/advanced_collection_management/#resume_tasks_as_admin """ str_task_ids = [str(i) for i in task_ids] log.debug(f"TransferClient.endpoint_manager_resume_tasks({str_task_ids})") data = {"task_id_list": str_task_ids} return self.post( "/v0.10/endpoint_manager/admin_resume", data=data, query_params=query_params ) # # endpoint manager pause rule methods # def endpoint_manager_pause_rule_list( self, *, filter_endpoint: uuid.UUID | str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ Get a list of pause rules on endpoints that the current user has the activity monitor effective role on. :param filter_endpoint: An endpoint ID. Limit results to rules on endpoints hosted by this endpoint. Must be activity monitor on this endpoint, not just the hosted endpoints. :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/pause_rule_list`` .. extdoclink:: Get pause rules :ref: transfer/advanced_collection_management/#get_pause_rules """ log.debug("TransferClient.endpoint_manager_pause_rule_list(...)") query_params = { "filter_endpoint": filter_endpoint, **(query_params or {}), } return IterableTransferResponse( self.get( "/v0.10/endpoint_manager/pause_rule_list", query_params=query_params ) ) def endpoint_manager_create_pause_rule( self, data: dict[str, t.Any] | None ) -> response.GlobusHTTPResponse: """ Create a new pause rule. Requires the activity manager effective role on the endpoint defined in the rule. :param data: A pause rule document describing the rule to create .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) rule_data = { "DATA_TYPE": "pause_rule", "message": "Message to users explaining why tasks are paused", "endpoint_id": "339abc22-aab3-4b45-bb56-8d40535bfd80", "identity_id": None, # affect all users on endpoint "start_time": None, # start now } create_result = tc.endpoint_manager_create_pause_rule(ep_data) rule_id = create_result["id"] .. tab-item:: API Info ``POST /endpoint_manager/pause_rule`` .. extdoclink:: Create pause rule :ref: transfer/advanced_collection_management/#create_pause_rule """ log.debug("TransferClient.endpoint_manager_create_pause_rule(...)") return self.post("/v0.10/endpoint_manager/pause_rule", data=data) def endpoint_manager_get_pause_rule( self, pause_rule_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Get an existing pause rule by ID. Requires the activity manager effective role on the endpoint defined in the rule. :param pause_rule_id: ID of pause rule to get :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``GET /endpoint_manager/pause_rule/`` .. extdoclink:: Get pause rule :ref: transfer/advanced_collection_management/#get_pause_rule """ log.debug(f"TransferClient.endpoint_manager_get_pause_rule({pause_rule_id})") return self.get( f"/v0.10/endpoint_manager/pause_rule/{pause_rule_id}", query_params=query_params, ) def endpoint_manager_update_pause_rule( self, pause_rule_id: uuid.UUID | str, data: dict[str, t.Any] | None, ) -> response.GlobusHTTPResponse: """ Update an existing pause rule by ID. Requires the activity manager effective role on the endpoint defined in the rule. Note that non update-able fields in data will be ignored. :param pause_rule_id: The ID of the pause rule to update :param data: A partial pause rule document with fields to update .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TransferClient(...) rule_data = { "message": "Update to pause, reads are now allowed.", "pause_ls": False, "pause_task_transfer_read": False, } update_result = tc.endpoint_manager_update_pause_rule(ep_data) .. tab-item:: API Info ``PUT /endpoint_manager/pause_rule/`` .. extdoclink:: Update pause rule :ref: transfer/advanced_collection_management/#update_pause_rule """ log.debug(f"TransferClient.endpoint_manager_update_pause_rule({pause_rule_id})") return self.put( f"/v0.10/endpoint_manager/pause_rule/{pause_rule_id}", data=data ) def endpoint_manager_delete_pause_rule( self, pause_rule_id: uuid.UUID | str, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ Delete an existing pause rule by ID. Requires the user to see the "editable" field of the rule as True. Any tasks affected by this rule will no longer be once it is deleted. :param pause_rule_id: The ID of the pause rule to delete :param query_params: Additional passthrough query parameters .. tab-set:: .. tab-item:: API Info ``DELETE /endpoint_manager/pause_rule/`` .. extdoclink:: Delete pause rule :ref: transfer/advanced_collection_management/#delete_pause_rule """ log.debug(f"TransferClient.endpoint_manager_delete_pause_rule({pause_rule_id})") return self.delete( f"/v0.10/endpoint_manager/pause_rule/{pause_rule_id}", query_params=query_params, ) # Tunnel methods def create_tunnel( self, data: dict[str, t.Any] | CreateTunnelData, ) -> response.GlobusHTTPResponse: """ :param data: Parameters for the tunnel creation .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TunnelClient(...) result = tc.create_tunnel(data) print(result["data"]["id"]) .. tab-item:: API Info ``POST /v2/tunnels`` """ log.debug("TransferClient.create_tunnel(...)") try: data_element = data["data"] except KeyError as e: raise exc.GlobusSDKUsageError( "create_tunnel() body was malformed (missing the 'data' key). " "Use CreateTunnelData to easily create correct documents." ) from e try: attributes = data_element["attributes"] except KeyError: data_element["attributes"] = {} attributes = data_element["attributes"] if attributes.get("submission_id", MISSING) is MISSING: log.debug("create_tunnel auto-fetching submission_id") attributes["submission_id"] = self.get_submission_id()["value"] r = self.post("/v2/tunnels", data=data) return r def update_tunnel( self, tunnel_id: str | uuid.UUID, update_doc: dict[str, t.Any], ) -> response.GlobusHTTPResponse: r""" :param tunnel_id: The ID of the Tunnel. :param update_doc: The document that will be sent to the patch API. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TunnelClient(...) "data" = { "type": "Tunnel", "attributes": { "state": "STOPPING", }, } result = tc.update_tunnel(tunnel_id, data) print(result["data"]) .. tab-item:: API Info ``PATCH /v2/tunnels/`` """ r = self.patch(f"/v2/tunnels/{tunnel_id}", data=update_doc) return r def get_tunnel( self, tunnel_id: str | uuid.UUID, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param tunnel_id: The ID of the Tunnel which we are fetching details about. :param query_params: Any additional parameters will be passed through as query params. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TunnelClient(...) result = tc.show_tunnel(tunnel_id) print(result["data"]) .. tab-item:: API Info ``GET /v2/tunnels/`` """ log.debug("TransferClient.get_tunnel(...)") r = self.get(f"/v2/tunnels/{tunnel_id}", query_params=query_params) return r def delete_tunnel( self, tunnel_id: str | uuid.UUID, ) -> response.GlobusHTTPResponse: """ :param tunnel_id: The ID of the Tunnel to be deleted. This will clean up all data associated with a Tunnel. Note that Tunnels must be stopped before they can be deleted. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TunnelClient(...) tc.delete_tunnel(tunnel_id) .. tab-item:: API Info ``DELETE /v2/tunnels/`` """ log.debug("TransferClient.delete_tunnel(...)") r = self.delete(f"/v2/tunnels/{tunnel_id}") return r def list_tunnels( self, *, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ :param query_params: Any additional parameters will be passed through as query params. This will list all the Tunnels created by the authorized user. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TunnelClient(...) tc.list_tunnels(tunnel_id) .. tab-item:: API Info ``GET /v2/tunnels/`` """ log.debug("TransferClient.list_tunnels(...)") r = self.get("/v2/tunnels", query_params=query_params) return IterableTransferResponse(r) def get_stream_access_point( self, stream_ap_id: str | uuid.UUID, *, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ :param stream_ap_id: The ID of the steaming access point to lookup. :param query_params: Any additional parameters will be passed through as query params. This will list all the Tunnels created by the authorized user. .. tab-set:: .. tab-item:: Example Usage .. code-block:: python tc = globus_sdk.TunnelClient(...) tc.get_stream_ap(stream_ap_id) .. tab-item:: API Info ``GET /v2/stream_access_points/`` """ log.debug("TransferClient.get_stream_ap(...)") r = self.get( f"/v2/stream_access_points/{stream_ap_id}", query_params=query_params ) return r globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/data/000077500000000000000000000000001513221403200262215ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/data/__init__.py000066400000000000000000000006231513221403200303330ustar00rootroot00000000000000""" Data helper classes for constructing Transfer API documents. All classes should be Payload types, so they can be passed seamlessly to :class:`TransferClient ` methods without conversion. """ from .delete_data import DeleteData from .transfer_data import TransferData from .tunnel_data import CreateTunnelData __all__ = ("TransferData", "DeleteData", "CreateTunnelData") globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/data/delete_data.py000066400000000000000000000135641513221403200310370ustar00rootroot00000000000000from __future__ import annotations import datetime import logging import typing as t import uuid from globus_sdk._internal.remarshal import stringify from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) class DeleteData(GlobusPayload): r""" Convenience class for constructing a delete document, to use as the `data` parameter to :meth:`submit_delete `. At least one item must be added using :meth:`add_item `. :param endpoint: The endpoint ID which is targeted by this deletion Task :param label: A string label for the Task :param submission_id: A submission ID value fetched via :meth:`get_submission_id `. By default, the SDK will fetch and populate this field when :meth:`submit_delete ` is called. :param recursive: Recursively delete subdirectories on the target endpoint [default: ``False``] :param ignore_missing: Ignore nonexistent files and directories instead of treating them as errors. [default: ``False``] :param interpret_globs: Enable expansion of ``\*?[]`` characters in the last component of paths, unless they are escaped with a preceding backslash, ``\\`` [default: ``False``] :param deadline: An ISO-8601 timestamp (as a string) or a datetime object which defines a deadline for the deletion. At the deadline, even if the data deletion is not complete, the job will be canceled. We recommend ensuring that the timestamp is in UTC to avoid confusion and ambiguity. Examples of ISO-8601 timestamps include ``2017-10-12 09:30Z``, ``2017-10-12 12:33:54+00:00``, and ``2017-10-12`` :param notify_on_succeeded: Send a notification email when the delete task completes with a status of SUCCEEDED. [default: ``True``] :param notify_on_failed: Send a notification email when the delete task completes with a status of FAILED. [default: ``True``] :param notify_on_inactive: Send a notification email when the delete task changes status to INACTIVE. e.g. From credentials expiring. [default: ``True``] :param local_user: Optional value passed to identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param additional_fields: additional fields to be added to the delete document. Mostly intended for internal use **Examples** See the :meth:`submit_delete ` documentation for example usage. **External Documentation** See the `Task document definition `_ and `Delete specific fields `_ in the REST documentation for more details on Delete Task documents. .. automethodlist:: globus_sdk.TransferData """ def __init__( self, endpoint: uuid.UUID | str, *, label: str | MissingType = MISSING, submission_id: uuid.UUID | str | MissingType = MISSING, recursive: bool | MissingType = MISSING, ignore_missing: bool | MissingType = MISSING, interpret_globs: bool | MissingType = MISSING, deadline: str | datetime.datetime | MissingType = MISSING, notify_on_succeeded: bool | MissingType = MISSING, notify_on_failed: bool | MissingType = MISSING, notify_on_inactive: bool | MissingType = MISSING, local_user: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["DATA_TYPE"] = "delete" self["DATA"] = [] self["endpoint"] = endpoint self["label"] = label self["submission_id"] = submission_id self["deadline"] = stringify(deadline) self["local_user"] = local_user self["recursive"] = recursive self["ignore_missing"] = ignore_missing self["interpret_globs"] = interpret_globs self["notify_on_succeeded"] = notify_on_succeeded self["notify_on_failed"] = notify_on_failed self["notify_on_inactive"] = notify_on_inactive for k, v in self.items(): log.debug("DeleteData.%s = %s", k, v) if additional_fields is not None: self.update(additional_fields) for option, value in additional_fields.items(): log.debug( f"DeleteData.{option} = {value} (option passed " "in via additional_fields)" ) def add_item( self, path: str, *, additional_fields: dict[str, t.Any] | None = None, ) -> None: """ Add a file or directory or symlink to be deleted. If any of the paths are directories, ``recursive`` must be set True on the top level ``DeleteData``. Symlinks will never be followed, only deleted. Appends a delete_item document to the DATA key of the delete document. :param path: Path to the directory or file to be deleted :param additional_fields: additional fields to be added to the delete item """ item_data = { "DATA_TYPE": "delete_item", "path": path, **(additional_fields or {}), } log.debug('DeleteData[{}].add_item: "{}"'.format(self["endpoint"], path)) self["DATA"].append(item_data) def iter_items(self) -> t.Iterator[dict[str, t.Any]]: """ An iterator of items created by ``add_item``. Each item takes the form of a dictionary. """ yield from iter(self["DATA"]) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/data/transfer_data.py000066400000000000000000000345001513221403200314120ustar00rootroot00000000000000from __future__ import annotations import datetime import logging import typing as t import uuid from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) _sync_level_dict: dict[t.Literal["exists", "size", "mtime", "checksum"], int] = { "exists": 0, "size": 1, "mtime": 2, "checksum": 3, } def _parse_sync_level( sync_level: t.Literal["exists", "size", "mtime", "checksum"] | int | MissingType, ) -> int | MissingType: """ Map sync_level strings to known int values Important: if more levels are added in the future you can always pass as an int """ if isinstance(sync_level, str): try: sync_level = _sync_level_dict[sync_level] except KeyError as err: raise ValueError(f"Unrecognized sync_level {sync_level}") from err return sync_level class TransferData(GlobusPayload): r""" Convenience class for constructing a transfer document, to use as the ``data`` parameter to :meth:`submit_transfer `. At least one item must be added using :meth:`add_item `. :param source_endpoint: The endpoint ID of the source endpoint :param destination_endpoint: The endpoint ID of the destination endpoint :param label: A string label for the Task :param submission_id: A submission ID value fetched via :meth:`get_submission_id \ `. By default, the SDK will fetch and populate this field when :meth:`submit_transfer \ ` is called. :param sync_level: The method used to compare items between the source and destination. One of ``"exists"``, ``"size"``, ``"mtime"``, or ``"checksum"`` See the section below on sync-level for an explanation of values. :param verify_checksum: When true, after transfer verify that the source and destination file checksums match. If they don't, re-transfer the entire file and keep trying until it succeeds. This will create CPU load on both the origin and destination of the transfer, and may even be a bottleneck if the network speed is high enough. [default: ``False``] :param preserve_timestamp: When true, Globus Transfer will attempt to set file timestamps on the destination to match those on the origin. [default: ``False``] :param encrypt_data: When true, all files will be TLS-protected during transfer. [default: ``False``] :param deadline: An ISO-8601 timestamp (as a string) or a datetime object which defines a deadline for the transfer. At the deadline, even if the data transfer is not complete, the job will be canceled. We recommend ensuring that the timestamp is in UTC to avoid confusion and ambiguity. Examples of ISO-8601 timestamps include ``2017-10-12 09:30Z``, ``2017-10-12 12:33:54+00:00``, and ``2017-10-12`` :param skip_source_errors: When true, source permission denied and file not found errors from the source endpoint will cause the offending path to be skipped. [default: ``False``] :param fail_on_quota_errors: When true, quota exceeded errors will cause the task to fail. [default: ``False``] :param delete_destination_extra: Delete files and directories on the destination endpoint which don’t exist on the source endpoint or are a different type. Only applies for recursive directory transfers. [default: ``False``] :param notify_on_succeeded: Send a notification email when the transfer completes with a status of SUCCEEDED. [default: ``True``] :param notify_on_failed: Send a notification email when the transfer completes with a status of FAILED. [default: ``True``] :param notify_on_inactive: Send a notification email when the transfer changes status to INACTIVE. e.g. From credentials expiring. [default: ``True``] :param source_local_user: Optional value passed to the source's identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param destination_local_user: Optional value passed to the destination's identity mapping specifying which local user account to map to. Only usable with Globus Connect Server v5 mapped collections. :param additional_fields: additional fields to be added to the transfer document. Mostly intended for internal use **Sync Levels** The values for ``sync_level`` are used to determine how comparisons are made between files found both on the source and the destination. When files match, no data transfer will occur. For compatibility, this can be an integer ``0``, ``1``, ``2``, or ``3`` in addition to the string values. The meanings are as follows: ===================== ======== value behavior ===================== ======== ``0``, ``exists`` Determine whether or not to transfer based on file existence. If the destination file is absent, do the transfer. ``1``, ``size`` Determine whether or not to transfer based on the size of the file. If destination file size does not match the source, do the transfer. ``2``, ``mtime`` Determine whether or not to transfer based on modification times. If source has a newer modified time than the destination, do the transfer. ``3``, ``checksum`` Determine whether or not to transfer based on checksums of file contents. If source and destination contents differ, as determined by a checksum of their contents, do the transfer. ===================== ======== **Examples** See the :meth:`submit_transfer ` documentation for example usage. **External Documentation** See the `Task document definition \ `_ and `Transfer specific fields \ `_ in the REST documentation for more details on Transfer Task documents. .. automethodlist:: globus_sdk.TransferData """ def __init__( self, source_endpoint: uuid.UUID | str, destination_endpoint: uuid.UUID | str, *, label: str | MissingType = MISSING, submission_id: uuid.UUID | str | MissingType = MISSING, sync_level: ( int | t.Literal["exists", "size", "mtime", "checksum"] | MissingType ) = MISSING, verify_checksum: bool | MissingType = MISSING, preserve_timestamp: bool | MissingType = MISSING, encrypt_data: bool | MissingType = MISSING, deadline: datetime.datetime | str | MissingType = MISSING, skip_source_errors: bool | MissingType = MISSING, fail_on_quota_errors: bool | MissingType = MISSING, delete_destination_extra: bool | MissingType = MISSING, notify_on_succeeded: bool | MissingType = MISSING, notify_on_failed: bool | MissingType = MISSING, notify_on_inactive: bool | MissingType = MISSING, source_local_user: str | MissingType = MISSING, destination_local_user: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() log.debug("Creating a new TransferData object") self["DATA_TYPE"] = "transfer" self["DATA"] = [] self["source_endpoint"] = source_endpoint self["destination_endpoint"] = destination_endpoint self["label"] = label self["submission_id"] = submission_id self["deadline"] = deadline self["source_local_user"] = source_local_user self["destination_local_user"] = destination_local_user self["verify_checksum"] = verify_checksum self["preserve_timestamp"] = preserve_timestamp self["encrypt_data"] = encrypt_data self["skip_source_errors"] = skip_source_errors self["fail_on_quota_errors"] = fail_on_quota_errors self["delete_destination_extra"] = delete_destination_extra self["notify_on_succeeded"] = notify_on_succeeded self["notify_on_failed"] = notify_on_failed self["notify_on_inactive"] = notify_on_inactive self["sync_level"] = _parse_sync_level(sync_level) for k, v in self.items(): log.debug("TransferData.%s = %s", k, v) if additional_fields is not None: self.update(additional_fields) for option, value in additional_fields.items(): log.debug( f"TransferData.{option} = {value} (option passed " "in via additional_fields)" ) def add_item( self, source_path: str, destination_path: str, *, recursive: bool | MissingType = MISSING, external_checksum: str | MissingType = MISSING, checksum_algorithm: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: """ Add a file or directory to be transferred. Appends a transfer_item document to the DATA key of the transfer document. .. note:: The full path to the destination file must be provided for file items. Parent directories of files are not allowed. See `task submission documentation `_ for more details. :param source_path: Path to the source directory or file to be transferred :param destination_path: Path to the destination directory or file will be transferred to :param recursive: Set to True if the target at source path is a directory :param external_checksum: A checksum to verify both source file and destination file integrity. The checksum will be verified after the data transfer and a failure will cause the entire task to fail. Cannot be used with directories. Assumed to be an MD5 checksum unless checksum_algorithm is also given. :param checksum_algorithm: Specifies the checksum algorithm to be used when verify_checksum is True, sync_level is "checksum" or 3, or an external_checksum is given. :param additional_fields: additional fields to be added to the transfer item """ item_data: dict[str, t.Any] = { "DATA_TYPE": "transfer_item", "source_path": source_path, "destination_path": destination_path, "recursive": recursive, "external_checksum": external_checksum, "checksum_algorithm": checksum_algorithm, **(additional_fields or {}), } log.debug( 'TransferData[{}, {}].add_item: "{}"->"{}"'.format( self["source_endpoint"], self["destination_endpoint"], source_path, destination_path, ) ) self["DATA"].append(item_data) def add_filter_rule( self, name: str, *, method: t.Literal["include", "exclude"] = "exclude", type: ( # pylint: disable=redefined-builtin t.Literal["file", "dir"] | MissingType ) = MISSING, ) -> None: """ Add a filter rule to the transfer document. These rules specify which items are or are not included when recursively transferring directories. Each item that is found during recursive directory traversal is matched against these rules in the order they are listed. The method of the first filter rule that matches an item is applied (either "include" or "exclude"), and filter rule matching stops. If no rules match, the item is included in the transfer. Notably, this makes "include" filter rules only useful when overriding more general "exclude" filter rules later in the list. :param name: A pattern to match against item names. Wildcards are supported, as are character groups: ``*`` matches everything, ``?`` matches any single character, ``[]`` matches any single character within the brackets, and ``[!]`` matches any single character not within the brackets. :param method: The method to use for filtering. If "exclude" (the default) items matching this rule will not be included in the transfer. If "include" items matching this rule will be included in the transfer. :param type: The types of items on which to apply this filter rule. Either ``"file"`` or ``"dir"``. If unspecified, the rule applies to both. Note that if a ``"dir"`` is excluded then all items within it will also be excluded regardless if they would have matched any include rules. Example Usage: >>> tdata = TransferData(...) >>> tdata.add_filter_rule(method="exclude", "*.tgz", type="file") >>> tdata.add_filter_rule(method="exclude", "*.tar.gz", type="file") ``tdata`` now describes a transfer which will skip any gzipped tar files with the extensions ``.tgz`` or ``.tar.gz`` >>> tdata = TransferData(...) >>> tdata.add_filter_rule(method="include", "*.txt", type="file") >>> tdata.add_filter_rule(method="exclude", "*", type="file") ``tdata`` now describes a transfer which will only transfer files with the ``.txt`` extension. """ if self.get("filter_rules", MISSING) is MISSING: self["filter_rules"] = [] rule = { "DATA_TYPE": "filter_rule", "method": method, "name": name, "type": type, } self["filter_rules"].append(rule) def iter_items(self) -> t.Iterator[dict[str, t.Any]]: """ An iterator of items created by ``add_item``. Each item takes the form of a dictionary. """ yield from iter(self["DATA"]) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/data/tunnel_data.py000066400000000000000000000031311513221403200310670ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t import uuid from globus_sdk._missing import MISSING, MissingType from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) class CreateTunnelData(GlobusPayload): def __init__( self, initiator_stream_access_point: uuid.UUID | str, listener_stream_access_point: uuid.UUID | str, *, label: str | MissingType = MISSING, submission_id: uuid.UUID | str | MissingType = MISSING, lifetime_mins: int | MissingType = MISSING, restartable: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() log.debug("Creating a new TunnelData object") relationships = { "listener": { "data": { "type": "StreamAccessPoint", "id": listener_stream_access_point, } }, "initiator": { "data": { "type": "StreamAccessPoint", "id": initiator_stream_access_point, } }, } attributes = { "label": label, "submission_id": submission_id, "restartable": restartable, "lifetime_mins": lifetime_mins, } if additional_fields is not None: attributes.update(additional_fields) self["data"] = { "type": "Tunnel", "relationships": relationships, "attributes": attributes, } globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/errors.py000066400000000000000000000002521513221403200271750ustar00rootroot00000000000000from __future__ import annotations from globus_sdk import exc class TransferAPIError(exc.GlobusAPIError): """ Error class for the Transfer API client. """ globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/response/000077500000000000000000000000001513221403200271465ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/response/__init__.py000066400000000000000000000001301513221403200312510ustar00rootroot00000000000000from .iterable import IterableTransferResponse __all__ = ("IterableTransferResponse",) globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/response/iterable.py000066400000000000000000000010241513221403200313040ustar00rootroot00000000000000from globus_sdk.response import IterableResponse class IterableTransferResponse(IterableResponse): """ Response class for non-paged list oriented resources. Allows top level fields to be accessed normally via standard item access, and also provides a convenient way to iterate over the sub-item list in a specified key: >>> print("Path:", r["path"]) >>> # Equivalent to: for item in r["DATA"] >>> for item in r: >>> print(item["name"], item["type"]) """ default_iter_key = "DATA" globus-globus-sdk-python-6a080e4/src/globus_sdk/services/transfer/transport.py000066400000000000000000000030271513221403200277200ustar00rootroot00000000000000""" Custom retry check collection for the TransferClient that overrides the default check_transient_error """ from __future__ import annotations from globus_sdk.transport import RetryCheck, RetryCheckResult, RetryContext from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, check_transient_error, ) def check_transfer_transient_error(ctx: RetryContext) -> RetryCheckResult: """ check for transient error status codes which could be resolved by retrying the request. Does not retry ExternalErrors or EndpointErrors as those are unlikely to actually be transient. :param ctx: The context object which describes the state of the request and the retries which may already have been attempted """ retry_config = ctx.caller_info.retry_config if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): try: code = ctx.response.json()["code"] except (ValueError, KeyError): code = "" for non_retry_code in ("ExternalError", "EndpointError"): if non_retry_code in code: return RetryCheckResult.no_decision return RetryCheckResult.do_retry return RetryCheckResult.no_decision # Transfer retry checks are the defaults with the transient error one replaced TRANSFER_DEFAULT_RETRY_CHECKS: tuple[RetryCheck, ...] = tuple( check_transfer_transient_error if check is check_transient_error else check for check in DEFAULT_RETRY_CHECKS ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/000077500000000000000000000000001513221403200233165ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/__init__.py000066400000000000000000000007201513221403200254260ustar00rootroot00000000000000from .helpers import construct_error, get_last_request from .models import RegisteredResponse, ResponseList, ResponseSet from .registry import ( get_response_set, load_response, load_response_set, register_response_set, ) __all__ = ( "get_last_request", "construct_error", "ResponseSet", "ResponseList", "RegisteredResponse", "load_response_set", "load_response", "get_response_set", "register_response_set", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/000077500000000000000000000000001513221403200242275ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/__init__.py000066400000000000000000000000001513221403200263260ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/000077500000000000000000000000001513221403200251705ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/__init__.py000066400000000000000000000000001513221403200272670ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/_common.py000066400000000000000000000023371513221403200271760ustar00rootroot00000000000000import uuid from collections import namedtuple _ErrorData = namedtuple("_ErrorData", ("error_id", "json", "metadata_include")) _error_id = str(uuid.uuid1()) UNAUTHORIZED_AUTH_RESPONSE = _ErrorData( _error_id, { "errors": [ { "status": "401", "id": _error_id, "code": "UNAUTHORIZED", "detail": "Call must be authenticated", "title": "Unauthorized", } ], "error_description": "Unauthorized", "error": "unauthorized", }, { "http_status": 401, "code": "UNAUTHORIZED", "message": "Call must be authenticated", }, ) _error_id = str(uuid.uuid1()) FORBIDDEN_AUTH_RESPONSE = _ErrorData( _error_id, { "errors": [ { "status": "403", "id": _error_id, "code": "FORBIDDEN", "detail": "Call must be authenticated", "title": "Unauthorized", } ], "error_description": "Unauthorized", "error": "unauthorized", }, { "http_status": 403, "code": "FORBIDDEN", "message": "Call must be authenticated", }, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_child_client.py000066400000000000000000000107561513221403200315170ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet _COMMON_RESPONSE_RECORD: dict[str, t.Any] = { "fqdns": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "parent_client": None, "preselect_idp": None, "prompt_for_named_grant": True, "redirect_uris": [], "required_idp": None, "scopes": [], "userinfo_from_effective_identity": True, } PUBLIC_CLIENT_RESPONSE_RECORD: dict[str, t.Any] = { "grant_types": ["authorization_code", "refresh_token"], **_COMMON_RESPONSE_RECORD, } PRIVATE_CLIENT_RESPONSE_RECORD: dict[str, t.Any] = { "grant_types": [ "authorization_code", "client_credentials", "refresh_token", "urn:globus:auth:grant_type:dependent_token", ], **_COMMON_RESPONSE_RECORD, } PUBLIC_CLIENT_REQUEST_ARGS: dict[str, t.Any] = { "name": "FOO", "visibility": "public", } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: request_args = { **PUBLIC_CLIENT_REQUEST_ARGS, **args, } # Default to public_client=True if "public_client" not in args and "client_type" not in args: request_args["public_client"] = True # The request body follows almost directly from the request args. Some name of args # to create_client() have differenlty named fields. request_body: t.Dict[str, t.Any] = {} for arg_name in request_args: if arg_name == "terms_and_conditions" or arg_name == "privacy_policy": request_body["links"] = { arg_name: request_args[arg_name], **request_body.get("links", {}), } else: request_body[arg_name] = request_args[arg_name] client_response_record = {} if ( request_args.get("public_client", False) or request_args.get("client_type") == "public_installed_client" ): client_response_record = { **PUBLIC_CLIENT_RESPONSE_RECORD, **request_body, } if "client_type" not in client_response_record: client_response_record["client_type"] = "public_installed_client" else: client_response_record = { **PRIVATE_CLIENT_RESPONSE_RECORD, **request_body, } if "client_type" not in client_response_record: client_response_record["client_type"] = ( "hybrid_confidential_client_resource_server" ) return RegisteredResponse( service="auth", method="POST", path="/v2/api/clients", json={"client": client_response_record}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": client_response_record, }, match=[json_params_matcher({"client": request_body})], ) RESPONSES = ResponseSet( default=register_response({}), name=register_response({"name": str(uuid.uuid4()).replace("-", "")}), public_client=register_response({"public_client": True}), private_client=register_response({"public_client": False}), publicly_visible=register_response({"visibility": "public"}), not_publicly_visible=register_response({"visibility": "private"}), redirect_uris=register_response({"redirect_uris": ["https://foo.com"]}), links=register_response( { "terms_and_conditions": "https://foo.org", "privacy_policy": "https://boo.org", } ), required_idp=register_response({"required_idp": str(uuid.uuid1())}), preselect_idp=register_response({"preselect_idp": str(uuid.uuid1())}), client_type_confidential_client=register_response( {"client_type": "confidential_client"} ), client_type_public_installed_client=register_response( {"client_type": "public_installed_client"} ), client_type_client_identity=register_response({"client_type": "client_identity"}), client_type_resource_server=register_response({"client_type": "resource_server"}), client_type_globus_connect_server=register_response( {"client_type": "globus_connect_server"} ), client_type_hybrid_confidential_client_resource_server=register_response( {"client_type": "hybrid_confidential_client_resource_server"} ), client_type_public_webapp_client=register_response( {"client_type": "public_webapp_client"} ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_client.py000066400000000000000000000110751513221403200303470ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet _COMMON_RESPONSE_RECORD: dict[str, t.Any] = { "fqdns": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "parent_client": None, "preselect_idp": None, "prompt_for_named_grant": True, "redirect_uris": [], "required_idp": None, "scopes": [], "userinfo_from_effective_identity": True, } PUBLIC_CLIENT_RESPONSE_RECORD: dict[str, t.Any] = { "grant_types": ["authorization_code", "refresh_token"], **_COMMON_RESPONSE_RECORD, } PRIVATE_CLIENT_RESPONSE_RECORD: dict[str, t.Any] = { "grant_types": [ "authorization_code", "client_credentials", "refresh_token", "urn:globus:auth:grant_type:dependent_token", ], **_COMMON_RESPONSE_RECORD, } PUBLIC_CLIENT_REQUEST_ARGS: dict[str, t.Any] = { "name": "FOO", "project": str(uuid.uuid1()), } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: request_args = { **PUBLIC_CLIENT_REQUEST_ARGS, **args, } # Default to public_client=True if "public_client" not in args and "client_type" not in args: request_args["public_client"] = True # The request body follows almost directly from the request args. Some name of args # to create_client() have differenlty named fields. request_body: t.Dict[str, t.Any] = {} for arg_name in request_args.keys(): if arg_name == "terms_and_conditions" or arg_name == "privacy_policy": request_body["links"] = { arg_name: request_args[arg_name], **request_body.get("links", {}), } else: request_body[arg_name] = request_args[arg_name] client_response_record = {} if ( request_args.get("public_client", False) or request_args.get("client_type") == "public_installed_client" ): client_response_record = { **PUBLIC_CLIENT_RESPONSE_RECORD, **request_body, } if "client_type" not in client_response_record: client_response_record["client_type"] = "public_installed_client" else: client_response_record = { **PRIVATE_CLIENT_RESPONSE_RECORD, **request_body, } if "client_type" not in client_response_record: client_response_record["client_type"] = ( "hybrid_confidential_client_resource_server" ) return RegisteredResponse( service="auth", method="POST", path="/v2/api/clients", json={"client": client_response_record}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": client_response_record, }, match=[json_params_matcher({"client": request_body})], ) RESPONSES = ResponseSet( default=register_response({}), name=register_response({"name": str(uuid.uuid4()).replace("-", "")}), public_client=register_response({"public_client": True}), private_client=register_response({"public_client": False}), project_id=register_response({"project": str(uuid.uuid1())}), publicly_visible=register_response({"visibility": "public"}), not_publicly_visible=register_response({"visibility": "private"}), redirect_uris=register_response({"redirect_uris": ["https://foo.com"]}), links=register_response( { "terms_and_conditions": "https://foo.org", "privacy_policy": "https://boo.org", } ), required_idp=register_response({"required_idp": str(uuid.uuid1())}), preselect_idp=register_response({"preselect_idp": str(uuid.uuid1())}), client_type_confidential_client=register_response( {"client_type": "confidential_client"} ), client_type_public_installed_client=register_response( {"client_type": "public_installed_client"} ), client_type_client_identity=register_response({"client_type": "client_identity"}), client_type_resource_server=register_response({"client_type": "resource_server"}), client_type_globus_connect_server=register_response( {"client_type": "globus_connect_server"} ), client_type_hybrid_confidential_client_resource_server=register_response( {"client_type": "hybrid_confidential_client_resource_server"} ), client_type_public_webapp_client=register_response( {"client_type": "public_webapp_client"} ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_client_credential.py000066400000000000000000000021331513221403200325340ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet NEW_CREDENTIAL_NAME = str(uuid.uuid4()).replace("-", "") CREDENTIAL = { "name": "foo", "id": str(uuid.uuid1()), "created": "2023-10-21T22:46:15.845937+00:00", "client": str(uuid.uuid1()), "secret": "abc123", } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", method="POST", path=f"/v2/api/clients/{CREDENTIAL['client']}/credentials", json={"credential": CREDENTIAL}, metadata={ "credential_id": CREDENTIAL["id"], "client_id": CREDENTIAL["client"], "name": CREDENTIAL["name"], }, ), name=RegisteredResponse( service="auth", method="POST", path=f"/v2/api/clients/{CREDENTIAL['client']}/credentials", json={ "credential": { **CREDENTIAL, "name": NEW_CREDENTIAL_NAME, } }, metadata={ "name": NEW_CREDENTIAL_NAME, "client_id": CREDENTIAL["client"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_native_app_instance.py000066400000000000000000000053341513221403200331040ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet APP_REQUEST_ARGS = { "template_id": str(uuid.uuid1()), "name": str(uuid.uuid1()).replace("-", ""), } def make_app_request_body(request_args: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: request_body = request_args.copy() request_body["template_id"] = str(request_args["template_id"]) return request_body def make_app_response_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: return { "client": { "fqdns": [], "name": request_args["name"], "id": "e634cc2a-d528-494e-8dda-92ec54a883c9", "public_client": False, "scopes": [], "required_idp": None, "grant_types": [ "authorization_code", "client_credentials", "refresh_token", ], "userinfo_from_effective_identity": True, "client_type": "confidential_client", "prompt_for_named_grant": False, "links": {"privacy_policy": None, "terms_and_conditions": None}, "visibility": "private", "preselect_idp": None, "parent_client": str(request_args["template_id"]), "project": None, "redirect_uris": [], }, "included": { "client_credential": { "name": "Auto-created at client creation", "id": "b4840855-2de8-4035-b1b4-4e7c8f518943", "client": "e634cc2a-d528-494e-8dda-92ec54a883c9", "secret": "cgK1HG9Y0DcZw79YlQEJpZCF4CMxIbaFf5sohWxjcfY=", } }, } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: request_args = {**APP_REQUEST_ARGS, **args} request_body = make_app_request_body(request_args) response_body = make_app_response_body(request_args) return RegisteredResponse( service="auth", method="POST", path="/v2/api/clients", json={"client": response_body}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": response_body, }, match=[ json_params_matcher( {"client": request_body}, ) ], ) RESPONSES = ResponseSet( default=register_response({}), template_id_str=register_response({"template_id": str(uuid.uuid1())}), template_id_uuid=register_response({"template_id": uuid.uuid1()}), name=register_response({"name": str(uuid.uuid1()).replace("-", "")}), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_policy.py000066400000000000000000000073761513221403200304010ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY_REQUEST_ARGS = { "project_id": str(uuid.uuid1()), "display_name": "Policy of Foo", "description": "Controls access to Foo", } def make_request_body(request_args: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: request_body = request_args.copy() request_body["project_id"] = str(request_args["project_id"]) for domain_constraints in [ "domain_constraints_include", "domain_constraints_exclude", ]: if domain_constraints in request_args: request_body[domain_constraints] = request_args[domain_constraints] return request_body def make_response_body(request_args: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: response_body = request_args.copy() response_body["project_id"] = str(request_args["project_id"]) response_body["id"] = str(uuid.uuid1()) for domain_constraints in [ "domain_constraints_include", "domain_constraints_exclude", ]: if domain_constraints in request_args: response_body[domain_constraints] = request_args.get(domain_constraints) return response_body def register_response( args: t.Mapping[str, t.Any], match: t.Any = None, ) -> RegisteredResponse: request_args = {**POLICY_REQUEST_ARGS, **args} request_body = make_request_body(request_args) response_body = make_response_body(request_args) return RegisteredResponse( service="auth", method="POST", path="/v2/api/policies", json={"policy": response_body}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": response_body, }, match=( [json_params_matcher({"policy": request_body})] if match is None else match ), ) RESPONSES = ResponseSet( default=register_response({}, match=[]), project_id_str=register_response({"project_id": str(uuid.uuid1())}), project_id_uuid=register_response({"project_id": uuid.uuid1()}), high_assurance=register_response( { "high_assurance": True, "authentication_assurance_timeout": 35, "required_mfa": False, } ), not_high_assurance=register_response( {"high_assurance": False, "required_mfa": False} ), required_mfa=register_response( { "high_assurance": True, "authentication_assurance_timeout": 45, "required_mfa": True, } ), authentication_assurance_timeout=register_response( {"authentication_assurance_timeout": 23} ), display_name=register_response( {"display_name": str(uuid.uuid4()).replace("-", "")} ), description=register_response({"description": str(uuid.uuid4()).replace("-", "")}), domain_constraints_include=register_response( { "domain_constraints_include": ["globus.org", "uchicago.edu"], }, ), empty_domain_constraints_include=register_response( { "domain_constraints_include": [], }, ), no_domain_constraints_include=register_response( { "domain_constraints_include": None, }, ), domain_constraints_exclude=register_response( { "domain_constraints_exclude": ["globus.org", "uchicago.edu"], }, ), empty_domain_constraints_exclude=register_response( { "domain_constraints_exclude": [], }, ), no_domain_constraints_exclude=register_response( { "domain_constraints_exclude": None, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_project.py000066400000000000000000000040261513221403200305350ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet project_id = str(uuid.uuid1()) star_lord = { "identity_provider": str(uuid.uuid1()), "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Star Lord", "username": "star.lord@guardians.galaxy", "email": "star.lord2@guardians.galaxy", } guardians_group = { "id": str(uuid.uuid1()), "name": "Guardians of the Galaxy", "description": "A group of heroes", "organization": "Guardians of the Galaxy", } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/projects", method="POST", json={ "project": { "contact_email": "support@globus.org", "id": project_id, "admins": { "identities": [star_lord], "groups": [], }, "project_name": "Guardians of the Galaxy", "admin_ids": [star_lord["id"]], "admin_group_ids": None, "display_name": "Guardians of the Galaxy", } }, metadata={ "id": project_id, "admin_id": star_lord["id"], }, ), admin_group=RegisteredResponse( service="auth", path="/v2/api/projects", method="POST", json={ "project": { "contact_email": "support@globus.org", "id": project_id, "admins": { "identities": [], "groups": [guardians_group], }, "project_name": "Guardians of the Galaxy", "admin_ids": None, "admin_group_ids": [guardians_group["id"]], "display_name": "Guardians of the Galaxy", } }, metadata={ "id": project_id, "admin_group_id": guardians_group["id"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/create_scope.py000066400000000000000000000076311513221403200302050ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk import DependentScopeSpec from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE_REQUEST_ARGS = { "client_id": str(uuid.uuid1()), "name": "Client manage scope", "description": "Manage configuration of this client", "scope_suffix": "manage", } def make_request_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: request_body = { "name": request_args["name"], "description": request_args["description"], "scope_suffix": request_args["scope_suffix"], } if "advertised" in request_args: request_body["advertised"] = request_args["advertised"] if "allows_refresh_token" in request_args: request_body["allows_refresh_token"] = request_args["allows_refresh_token"] if "required_domains" in request_args: request_body["required_domains"] = request_args["required_domains"] if "dependent_scopes" in request_args: request_body["dependent_scopes"] = request_args["dependent_scopes"] return request_body def make_response_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: return { "scope_string": f"https://auth.globus.org/scopes/{request_args['client_id']}/{request_args['scope_suffix']}", # noqa: E501 "allows_refresh_token": request_args.get("allows_refresh_token", True), "id": str(uuid.uuid1()), "advertised": request_args.get("advertised", False), "required_domains": request_args.get("required_domains", []), "name": request_args["name"], "description": request_args["description"], "client": str(request_args["client_id"]), "dependent_scopes": [ { "scope": str(ds["scope"]), "optional": ds["optional"], "requires_refresh_token": ds["requires_refresh_token"], } for ds in request_args.get("dependent_scopes", []) ], } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: request_args = {**SCOPE_REQUEST_ARGS, **args} request_body = make_request_body(request_args) response_body = make_response_body(request_args) return RegisteredResponse( service="auth", method="POST", path=f"/v2/api/clients/{request_args['client_id']}/scopes", json={"scope": response_body}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": response_body, }, match=[json_params_matcher({"scope": request_body})], ) RESPONSES = ResponseSet( default=register_response({}), client_id_str=register_response({"client_id": str(uuid.uuid1())}), client_id_uuid=register_response({"client_id": uuid.uuid1()}), name=register_response({"name": str(uuid.uuid4()).replace("-", "")}), description=register_response({"description": str(uuid.uuid4()).replace("-", "")}), scope_suffix=register_response( {"scope_suffix": str(uuid.uuid4()).replace("-", "")} ), advertised=register_response({"advertised": True}), not_advertised=register_response({"advertised": False}), allows_refresh_token=register_response({"allows_refresh_token": True}), disallows_refresh_token=register_response({"allows_refresh_token": False}), no_required_domains=register_response({"required_domains": []}), required_domains=register_response( {"required_domains": ["globus.org", "uchicago.edu"]} ), no_dependent_scopes=register_response({"dependent_scopes": []}), dependent_scopes=register_response( { "dependent_scopes": [ DependentScopeSpec(str(uuid.uuid1()), True, True), DependentScopeSpec(uuid.uuid1(), False, False), ], } ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/delete_client.py000066400000000000000000000017031513221403200303430ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet CLIENT = { "required_idp": None, "name": "Great client of FOO", "redirect_uris": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "scopes": [], "grant_types": ["authorization_code", "client_credentials", "refresh_token"], "id": str(uuid.uuid1()), "prompt_for_named_grant": False, "fqdns": ["globus.org"], "project": "da84e531-1afb-43cb-8c87-135ab580516a", "client_type": "client_identity", "visibility": "private", "parent_client": None, "userinfo_from_effective_identity": True, "preselect_idp": None, "public_client": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", method="DELETE", path=f"/v2/api/clients/{CLIENT['id']}", json={"client": CLIENT}, metadata={ "client_id": CLIENT["id"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/delete_client_credential.py000066400000000000000000000012021513221403200325270ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL = { "name": "foo", "id": str(uuid.uuid1()), "created": "2023-10-21T22:46:15.845937+00:00", "client": "7dee4432-0297-4989-ad23-a2b672a52b12", "secret": None, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", method="DELETE", path=f"/v2/api/clients/{CREDENTIAL['client']}/credentials/{CREDENTIAL['id']}", json={"credential": CREDENTIAL}, metadata={ "credential_id": CREDENTIAL["id"], "client_id": CREDENTIAL["client"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/delete_policy.py000066400000000000000000000013661513221403200303710ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY = { "high_assurance": False, "domain_constraints_include": ["greenlight.org"], "display_name": "GreenLight domain Only Policy", "description": "Only allow access from @greenlight.org", "id": str(uuid.uuid1()), "domain_constraints_exclude": None, "project_id": "da84e531-1afb-43cb-8c87-135ab580516a", "authentication_assurance_timeout": 35, "required_mfa": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", method="DELETE", path=f"/v2/api/policies/{POLICY['id']}", json={"policy": POLICY}, metadata={ "policy_id": POLICY["id"], }, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/delete_project.py000066400000000000000000000022231513221403200305310ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet project_id = str(uuid.uuid1()) star_lord = { "identity_provider": str(uuid.uuid1()), "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Star Lord", "username": "star.lord@guardians.galaxy", "email": "star.lord2@guardians.galaxy", } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/projects/{project_id}", method="DELETE", json={ "project": { "contact_email": "support@globus.org", "id": project_id, "admins": { "identities": [star_lord], "groups": [], }, "project_name": "Guardians of the Galaxy", "admin_ids": [star_lord["id"]], "admin_group_ids": None, "display_name": "Guardians of the Galaxy", } }, metadata={ "id": project_id, "admin_id": star_lord["id"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/delete_scope.py000066400000000000000000000013711513221403200301770ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE = { "scope_string": "https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manager", # noqa: E501 "allows_refresh_token": False, "id": str(uuid.uuid1()), "advertised": False, "required_domains": [], "name": "Client manage scope", "description": "Manage configuration of this client", "client": "3f33d83f-ec0a-4190-887d-0622e7c4ee9a", "dependent_scopes": [], } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", method="DELETE", path=f"/v2/api/scopes/{SCOPE['id']}", json={"scope": SCOPE}, metadata={ "scope_id": SCOPE["id"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_client.py000066400000000000000000000022231513221403200276560ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet FQDN = "globus.org" CLIENT = { "required_idp": None, "name": "Great client of FOO", "redirect_uris": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "scopes": [], "grant_types": ["authorization_code", "client_credentials", "refresh_token"], "id": str(uuid.uuid1()), "prompt_for_named_grant": False, "fqdns": [FQDN], "project": "da84e531-1afb-43cb-8c87-135ab580516a", "client_type": "client_identity", "visibility": "private", "parent_client": None, "userinfo_from_effective_identity": True, "preselect_idp": None, "public_client": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/clients/{CLIENT['id']}", json={"client": CLIENT}, metadata={ "client_id": CLIENT["id"], }, ), fqdn=RegisteredResponse( service="auth", path="/v2/api/clients", json={"client": CLIENT}, metadata={ "client_id": CLIENT["id"], "fqdn": FQDN, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_client_credentials.py000066400000000000000000000011311513221403200322300ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL = { "name": "foo", "id": str(uuid.uuid1()), "created": "2023-10-21T22:46:15.845937+00:00", "client": "7dee4432-0297-4989-ad23-a2b672a52b12", "secret": None, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/clients/{CREDENTIAL['client']}/credentials", json={"credentials": [CREDENTIAL]}, metadata={ "credential_id": CREDENTIAL["id"], "client_id": CREDENTIAL["client"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_clients.py000066400000000000000000000030571513221403200300470ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet FOO_CLIENT = { "required_idp": None, "name": "Great client of FOO", "redirect_uris": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "scopes": [], "grant_types": ["authorization_code", "client_credentials", "refresh_token"], "id": str(uuid.uuid1()), "prompt_for_named_grant": False, "fqdns": ["foo.net"], "project": "da84e531-1afb-43cb-8c87-135ab580516a", "client_type": "client_identity", "visibility": "private", "parent_client": None, "userinfo_from_effective_identity": True, "preselect_idp": None, "public_client": False, } BAR_CLIENT = { "required_idp": None, "name": "Lessor client of BAR", "redirect_uris": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "scopes": [], "grant_types": ["authorization_code", "client_credentials", "refresh_token"], "id": str(uuid.uuid1()), "prompt_for_named_grant": False, "fqdns": ["bar.org"], "project": "da84e531-1afb-43cb-8c87-135ab580516a", "client_type": "client_identity", "visibility": "private", "parent_client": None, "userinfo_from_effective_identity": True, "preselect_idp": None, "public_client": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/clients", json={"clients": [BAR_CLIENT, FOO_CLIENT]}, metadata={ "client_ids": [FOO_CLIENT["id"], BAR_CLIENT["id"]], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_consents.py000066400000000000000000000040671513221403200302440ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet _DATA_ACCESS = ( "https://auth.globus.org/scopes/542a86fc-1766-450d-841f-065488a2ec01/data_access" ) RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/identities/8ca28797-3541-4a5d-a264-05b00f91e608/consents", json={ "consents": [ { "created": "2022-09-21T17:10:14.270581+00:00", "id": 142632, "status": "approved", "updated": "2022-09-21T17:10:14.270581+00:00", "allows_refresh": True, "dependency_path": [142632], "scope_name": "urn:globus:auth:scope:transfer.api.globus.org:all", "atomically_revocable": False, "effective_identity": "8ca28797-3541-4a5d-a264-05b00f91e608", "auto_approved": False, "last_used": "2024-03-18T17:34:04.719126+00:00", "scope": "89ecabba-4acf-4e2e-a98d-ce592ccc2818", "client": "065db752-2f43-4fe1-a633-2ee68c9da889", }, { "created": "2024-03-18T17:32:51.496893+00:00", "id": 433892, "status": "approved", "updated": "2024-03-18T17:32:51.496893+00:00", "allows_refresh": True, "dependency_path": [142632, 433892], "scope_name": _DATA_ACCESS, "atomically_revocable": True, "effective_identity": "8ca28797-3541-4a5d-a264-05b00f91e608", "auto_approved": False, "last_used": "2024-03-18T17:33:05.178254+00:00", "scope": "fe334c19-4fe6-4d03-ac73-8992beb231b6", "client": "2fbdda78-a599-4cb5-ac3d-1fbcfbc6a754", }, ] }, metadata={ "identity_id": "8ca28797-3541-4a5d-a264-05b00f91e608", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_identities.py000066400000000000000000000056121513221403200305460ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import UNAUTHORIZED_AUTH_RESPONSE _globus_at_globus_data = { "email": None, "id": "46bd0f56-e24f-11e5-a510-131bef46955c", "identity_provider": "7daddf46-70c5-45ee-9f0f-7244fe7c8707", "name": None, "organization": None, "status": "unused", "username": "globus@globus.org", } _sirosen_at_globus_data = { "email": "sirosen@globus.org", "id": "ae341a98-d274-11e5-b888-dbae3a8ba545", "identity_provider": "927d7238-f917-4eb2-9ace-c523fa9ba34e", "name": "Stephen Rosen", "organization": "Globus Team", "status": "used", "username": "sirosen@globus.org", } _globus_at_globusid_data = { "email": "support@globus.org", "id": str(uuid.UUID(int=1)), "identity_provider": "41143743-f3c8-4d60-bbdb-eeecaba85bd9", "identity_type": "login", "name": "Globus Team", "organization": "University of Chicago", "status": "used", "username": "globus@globusid.org", } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/identities", json={"identities": [_globus_at_globus_data]}, metadata={ "id": _globus_at_globus_data["id"], "username": _globus_at_globus_data["username"], }, ), empty=RegisteredResponse( service="auth", path="/v2/api/identities", json={"identities": []}, ), multiple=RegisteredResponse( service="auth", path="/v2/api/identities", json={"identities": [_globus_at_globus_data, _sirosen_at_globus_data]}, metadata={ "ids": [_globus_at_globus_data["id"], _sirosen_at_globus_data["id"]], "usernames": [ _globus_at_globus_data["username"], _sirosen_at_globus_data["username"], ], }, ), globusid=RegisteredResponse( service="auth", path="/v2/api/identities", json={"identities": [_globus_at_globusid_data]}, metadata={ "id": _globus_at_globusid_data["id"], "username": _globus_at_globusid_data["username"], "short_username": _globus_at_globusid_data["username"].partition("@")[0], "org": _globus_at_globusid_data["organization"], }, ), sirosen=RegisteredResponse( service="auth", path="/v2/api/identities", json={"identities": [_sirosen_at_globus_data]}, metadata={ "id": _sirosen_at_globus_data["id"], "username": _sirosen_at_globus_data["username"], "org": _sirosen_at_globus_data["organization"], }, ), unauthorized=RegisteredResponse( service="auth", path="/v2/api/identities", status=401, json=UNAUTHORIZED_AUTH_RESPONSE.json, metadata={"error_id": UNAUTHORIZED_AUTH_RESPONSE.error_id}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_identity_providers.py000066400000000000000000000024661513221403200323370ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet globusid_idp = { "short_name": "globusid", "id": "41143743-f3c8-4d60-bbdb-eeecaba85bd9", "alternative_names": [], "domains": ["globusid.org"], "name": "Globus ID", } globus_staff_idp = { "short_name": "globus.org", "id": "927d7238-f917-4eb2-9ace-c523fa9ba34e", "alternative_names": [], "domains": ["globus.org"], "name": "Globus Staff", } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/identity_providers", json={"identity_providers": [globusid_idp, globus_staff_idp]}, metadata={ "ids": [globusid_idp["id"], globus_staff_idp["id"]], "domains": [globusid_idp["domains"][0], globus_staff_idp["domains"][0]], }, ), globusid=RegisteredResponse( service="auth", path="/v2/api/identity_providers", json={"identity_providers": [globusid_idp]}, metadata={"id": globusid_idp["id"], "domains": globusid_idp["domains"]}, ), globus_staff=RegisteredResponse( service="auth", path="/v2/api/identity_providers", json={"identity_providers": [globus_staff_idp]}, metadata={"id": globus_staff_idp["id"], "domains": globus_staff_idp["domains"]}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_policies.py000066400000000000000000000022721513221403200302130ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet GREEN_LIGHT_POLICY = { "high_assurance": False, "domain_constraints_include": ["greenlight.org"], "display_name": "GreenLight domain Only Policy", "description": "Only allow access from @greenlight.org", "id": str(uuid.uuid1()), "domain_constraints_exclude": None, "project_id": "da84e531-1afb-43cb-8c87-135ab580516a", "authentication_assurance_timeout": 35, "required_mfa": False, } RED_LIGHT_POLICY = { "high_assurance": True, "domain_constraints_include": None, "display_name": "No RedLight domain Policy", "description": "Disallow access from @redlight.org", "id": str(uuid.uuid1()), "domain_constraints_exclude": ["redlight.org"], "project_id": "da84e531-1afb-43cb-8c87-135ab580516a", "authentication_assurance_timeout": 35, "required_mfa": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/policies", json={"policies": [GREEN_LIGHT_POLICY, RED_LIGHT_POLICY]}, metadata={ "policy_ids": [GREEN_LIGHT_POLICY["id"], RED_LIGHT_POLICY["id"]], }, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_policy.py000066400000000000000000000013351513221403200277020ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY = { "high_assurance": False, "domain_constraints_include": ["greenlight.org"], "display_name": "GreenLight domain Only Policy", "description": "Only allow access from @greenlight.org", "id": str(uuid.uuid1()), "domain_constraints_exclude": None, "project_id": "da84e531-1afb-43cb-8c87-135ab580516a", "authentication_assurance_timeout": 35, "required_mfa": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/policies/{POLICY['id']}", json={"policy": POLICY}, metadata={ "policy_id": POLICY["id"], }, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_project.py000066400000000000000000000030361513221403200300510ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet GUARDIANS_IDP_ID = str(uuid.uuid1()) STAR_LORD = { "identity_provider": GUARDIANS_IDP_ID, "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Star Lord", "username": "star.lord@guardians.galaxy", # I thought it would be funny if he didn't get 'star.lord' # because someone else got it first "email": "star.lord2@guardians.galaxy", } ROCKET_RACCOON = { "identity_provider": GUARDIANS_IDP_ID, "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Rocket", "username": "rocket@guardians.galaxy", # and think about it, who else would try to lay claim # to that email address? "email": "star.lord@guardians.galaxy", } GUARDIANS_PROJECT = { "admin_ids": [ROCKET_RACCOON["id"]], "contact_email": "support@guardians.galaxy", "display_name": "Guardians of the Galaxy Portal", "admin_group_ids": None, "id": str(uuid.uuid1()), "project_name": "Guardians of the Galaxy Portal", "admins": { "identities": [STAR_LORD, ROCKET_RACCOON], "groups": [], }, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/projects/{GUARDIANS_PROJECT['id']}", json={"project": GUARDIANS_PROJECT}, metadata={ "project_id": GUARDIANS_PROJECT["id"], }, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_projects.py000066400000000000000000000041501513221403200302320ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet GUARDIANS_IDP_ID = str(uuid.uuid1()) STAR_LORD = { "identity_provider": GUARDIANS_IDP_ID, "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Star Lord", "username": "star.lord@guardians.galaxy", # I thought it would be funny if he didn't get 'star.lord' # because someone else got it first "email": "star.lord2@guardians.galaxy", } ROCKET_RACCOON = { "identity_provider": GUARDIANS_IDP_ID, "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Rocket", "username": "rocket@guardians.galaxy", # and think about it, who else would try to lay claim # to that email address? "email": "star.lord@guardians.galaxy", } EVIL_TEST_PROJECT = { "admin_ids": [ROCKET_RACCOON["id"]], "contact_email": "eviltestproject@guardians.galaxy", "display_name": "Evil Test Project Full of Evil", "admin_group_ids": None, "id": str(uuid.uuid1()), "project_name": "Evil Test Project Full of Evil", "admins": { "identities": [ROCKET_RACCOON], "groups": [], }, } GUARDIANS_PROJECT = { "admin_ids": [ROCKET_RACCOON["id"]], "contact_email": "support@guardians.galaxy", "display_name": "Guardians of the Galaxy Portal", "admin_group_ids": None, "id": str(uuid.uuid1()), "project_name": "Guardians of the Galaxy Portal", "admins": { "identities": [STAR_LORD, ROCKET_RACCOON], "groups": [], }, } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/projects", json={"projects": [EVIL_TEST_PROJECT, GUARDIANS_PROJECT]}, metadata={ "project_ids": [EVIL_TEST_PROJECT["id"], GUARDIANS_PROJECT["id"]], "admin_map": { EVIL_TEST_PROJECT["id"]: [ROCKET_RACCOON["id"]], GUARDIANS_PROJECT["id"]: [STAR_LORD["id"], ROCKET_RACCOON["id"]], }, }, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_scope.py000066400000000000000000000013401513221403200275100ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE = { "scope_string": "https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manager", # noqa: E501 "allows_refresh_token": False, "id": str(uuid.uuid1()), "advertised": False, "required_domains": [], "name": "Client manage scope", "description": "Manage configuration of this client", "client": "3f33d83f-ec0a-4190-887d-0622e7c4ee9a", "dependent_scopes": [], } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/scopes/{SCOPE['id']}", json={"scope": SCOPE}, metadata={ "scope_id": SCOPE["id"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/get_scopes.py000066400000000000000000000033531513221403200277010ustar00rootroot00000000000000import uuid from responses.matchers import query_param_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE1 = { "scope_string": "https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manage", # noqa: E501 "allows_refresh_token": False, "id": str(uuid.uuid1()), "advertised": False, "required_domains": [], "name": "Client manage scope", "description": "Manage configuration of this client", "client": "3f33d83f-ec0a-4190-887d-0622e7c4ee9a", "dependent_scopes": [], } SCOPE2 = { "scope_string": "https://auth.globus.org/scopes/dfc9a6d3-3373-4a6d-b0a1-b7026d1559d6/view", # noqa: E501 "allows_refresh_token": False, "id": str(uuid.uuid1()), "advertised": False, "required_domains": [], "name": "Client view scope", "description": "View configuration of this client", "client": "dfc9a6d3-3373-4a6d-b0a1-b7026d1559d6", "dependent_scopes": [], } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/api/scopes", json={"scopes": [SCOPE1, SCOPE2]}, metadata={ "scope_ids": [SCOPE1["id"], SCOPE2["id"]], }, ), id=RegisteredResponse( service="auth", path="/v2/api/scopes", json={"scopes": [SCOPE1]}, match=[query_param_matcher(params={"ids": SCOPE1["id"]})], metadata={ "scope_id": SCOPE1["id"], }, ), string=RegisteredResponse( service="auth", path="/v2/api/scopes", json={"scopes": [SCOPE2]}, match=[query_param_matcher(params={"scope_strings": SCOPE2["scope_string"]})], metadata={ "scope_string": SCOPE2["scope_string"], }, ), ) oauth2_client_credentials_tokens.py000066400000000000000000000023121513221403200341610ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/authfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet _token = "DUMMY_TRANSFER_TOKEN_FROM_THE_INTERTUBES" _scope = "urn:globus:auth:scope:transfer.api.globus.org:all" RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", status=200, json={ "access_token": _token, "scope": _scope, "expires_in": 172800, "token_type": "Bearer", "resource_server": "transfer.api.globus.org", "other_tokens": [], }, metadata={ "service": "transfer", "resource_server": "transfer.api.globus.org", "access_token": _token, "scope": _scope, }, ), openid=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", status=200, json={ "access_token": "auth_access_token", "scope": "openid", "expires_in": 172800, "token_type": "Bearer", "resource_server": "auth.globus.org", "id_token": "openid_token", "other_tokens": [], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/oauth2_exchange_code_for_tokens.py000066400000000000000000000022651513221403200340360ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", status=200, json={ "access_token": "transfer_access_token", "scope": "urn:globus:auth:scope:transfer.api.globus.org:all", "expires_in": 172800, "token_type": "Bearer", "resource_server": "transfer.api.globus.org", "state": "_default", "other_tokens": [], }, ), invalid_grant=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", status=401, json={"error": "invalid_grant"}, ), openid=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", status=200, json={ "access_token": "auth_access_token", "scope": "openid", "expires_in": 172800, "token_type": "Bearer", "resource_server": "auth.globus.org", "id_token": "openid_token", "other_tokens": [], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/oauth2_get_dependent_tokens.py000066400000000000000000000033611513221403200332170ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet RESPONSES = ResponseSet( groups=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", json=[ { "scope": "urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships", # noqa: E501 "access_token": "groupsToken", "token_type": "bearer", "expires_in": 120, "resource_server": "groups.api.globus.org", } ], metadata={ "rs_data": { "groups.api.globus.org": { "access_token": "groupsToken", "scope": "urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships", # noqa: E501 } } }, ), groups_with_refresh_token=RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", json=[ { "scope": "urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships", # noqa: E501 "access_token": "groupsToken", "refresh_token": "groupsRefreshToken", "token_type": "bearer", "expires_in": 120, "resource_server": "groups.api.globus.org", } ], metadata={ "rs_data": { "groups.api.globus.org": { "access_token": "groupsToken", "refresh_token": "groupsRefreshToken", "scope": "urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships", # noqa: E501 } } }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/oauth2_revoke_token.py000066400000000000000000000003771513221403200315260ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/oauth2/token/revoke", method="POST", json={"active": False}, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/oauth2_token_introspect.py000066400000000000000000000022471513221403200324230ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet _kingfish = { "username": "kingfish@globus.org", "name": "Christone Ingram", "id": str(uuid.uuid1()), "email": "kingfish@globus.org", } _client_id = str(uuid.uuid1()) _scope = "urn:globus:auth:scope:auth.globus.org:view_identity_set profile email openid" RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path="/v2/oauth2/token/introspect", method="POST", json={ "active": True, "token_type": "Bearer", "scope": _scope, "client_id": _client_id, "username": _kingfish["username"], "name": _kingfish["name"], "email": _kingfish["email"], "exp": 1715289767, "iat": 1715116967, "nbf": 1715116967, "sub": _kingfish["id"], "aud": [ "auth.globus.org", _client_id, ], "iss": "https://auth.globus.org", }, metadata={ "client_id": _client_id, "scope": _scope, **_kingfish, }, ) ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/update_client.py000066400000000000000000000050051513221403200303620ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet _COMMON_RESPONSE_RECORD: dict[str, t.Any] = { "fqdns": [], "links": {"privacy_policy": None, "terms_and_conditions": None}, "parent_client": None, "preselect_idp": None, "prompt_for_named_grant": True, "redirect_uris": [], "required_idp": None, "scopes": [], "userinfo_from_effective_identity": True, } PUBLIC_CLIENT_RESPONSE_RECORD: dict[str, t.Any] = { "client_type": "public_installed_client", "grant_types": ["authorization_code", "refresh_token"], **_COMMON_RESPONSE_RECORD, } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: # Some name of args to create_client() have differenlty named fields. body_fields: t.Dict[str, t.Any] = {} for arg_name in args: if arg_name == "terms_and_conditions" or arg_name == "privacy_policy": body_fields["links"] = { arg_name: args[arg_name], **body_fields.get("links", {}), } else: body_fields[arg_name] = args[arg_name] client_id = str(uuid.uuid1()) # Default to a public client response unless arg says otherwise client_response_record = { **PUBLIC_CLIENT_RESPONSE_RECORD, **body_fields, "id": client_id, } return RegisteredResponse( service="auth", method="PUT", path=f"/v2/api/clients/{client_id}", json={"client": client_response_record}, metadata={ # Test functions use 'args' to form request "args": {**args, "client_id": client_id}, # Test functions use 'response' to verify response "response": body_fields, }, match=[json_params_matcher({"client": body_fields})], ) RESPONSES = ResponseSet( default=register_response({}), name=register_response({"name": str(uuid.uuid4()).replace("-", "")}), publicly_visible=register_response({"visibility": "public"}), not_publicly_visible=register_response({"visibility": "private"}), redirect_uris=register_response({"redirect_uris": ["https://foo.com"]}), links=register_response( { "terms_and_conditions": "https://foo.org", "privacy_policy": "https://boo.org", } ), required_idp=register_response({"required_idp": str(uuid.uuid1())}), preselect_idp=register_response({"preselect_idp": str(uuid.uuid1())}), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/update_policy.py000066400000000000000000000070371513221403200304120ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY_REQUEST_ARGS = { "policy_id": str(uuid.uuid1()), } def make_request_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: request_body = {} for field in [ "authentication_assurance_timeout", "required_mfa", "display_name", "description", "domain_constraints_include", "domain_constraints_exclude", ]: if field in request_args: request_body[field] = request_args[field] if "project_id" in request_args: request_body["project_id"] = str(request_args["project_id"]) return request_body def make_response_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: return { "project_id": str(request_args.get("project_id", uuid.uuid1())), "high_assurance": request_args.get("high_assurance", True), "required_mfa": request_args.get("required_mfa", False), "authentication_assurance_timeout": request_args.get( "authentication_assurance_timeout", 25 ), "display_name": request_args.get( "display_name", str(uuid.uuid4()).replace("-", "") ), "description": request_args.get( "description", str(uuid.uuid4()).replace("-", "") ), "domain_constraints_include": request_args.get("domain_constraints_include"), "domain_constraints_exclude": request_args.get("domain_constraints_exclude"), } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: request_args = {**POLICY_REQUEST_ARGS, **args} request_body = make_request_body(request_args) response_body = make_response_body(request_args) return RegisteredResponse( service="auth", method="PUT", path=f"/v2/api/policies/{request_args['policy_id']}", json={"policy": response_body}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": response_body, }, match=[json_params_matcher({"policy": request_body})], ) RESPONSES = ResponseSet( default=register_response({}), project_id_str=register_response({"project_id": str(uuid.uuid1())}), project_id_uuid=register_response({"project_id": uuid.uuid1()}), authentication_assurance_timeout=register_response( {"authentication_assurance_timeout": 9100} ), required_mfa=register_response({"required_mfa": True}), not_required_mfa=register_response({"required_mfa": False}), display_name=register_response( {"display_name": str(uuid.uuid4()).replace("-", "")} ), description=register_response({"description": str(uuid.uuid4()).replace("-", "")}), no_domain_constrants_include=register_response( {"domain_constraints_include": None} ), empty_domain_constrants_include=register_response( {"domain_constraints_include": []} ), domain_constrants_include=register_response( {"domain_constraints_include": ["globus.org", "uchicago.edu"]} ), no_domain_constrants_exclude=register_response( {"domain_constraints_exclude": None} ), empty_domain_constrants_exclude=register_response( {"domain_constraints_exclude": []} ), domain_constrants_exclude=register_response( {"domain_constraints_exclude": ["globus.org", "uchicago.edu"]} ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/update_project.py000066400000000000000000000040601513221403200305520ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet project_id = str(uuid.uuid1()) star_lord = { "identity_provider": str(uuid.uuid1()), "identity_type": "login", "organization": "Guardians of the Galaxy", "status": "used", "id": str(uuid.uuid1()), "name": "Star Lord", "username": "star.lord@guardians.galaxy", "email": "star.lord2@guardians.galaxy", } guardians_group = { "id": str(uuid.uuid1()), "name": "Guardians of the Galaxy", "description": "A group of heroes", "organization": "Guardians of the Galaxy", } RESPONSES = ResponseSet( default=RegisteredResponse( service="auth", path=f"/v2/api/projects/{project_id}", method="PUT", json={ "project": { "contact_email": "support@globus.org", "id": project_id, "admins": { "identities": [star_lord], "groups": [], }, "project_name": "Guardians of the Galaxy", "admin_ids": [star_lord["id"]], "admin_group_ids": None, "display_name": "Guardians of the Galaxy", } }, metadata={ "id": project_id, "admin_id": star_lord["id"], }, ), admin_group=RegisteredResponse( service="auth", path=f"/v2/api/projects/{project_id}", method="PUT", json={ "project": { "contact_email": "support@globus.org", "id": project_id, "admins": { "identities": [], "groups": [guardians_group], }, "project_name": "Guardians of the Galaxy", "admin_ids": None, "admin_group_ids": [guardians_group["id"]], "display_name": "Guardians of the Galaxy", } }, metadata={ "id": project_id, "admin_group_id": guardians_group["id"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/update_scope.py000066400000000000000000000072061513221403200302220ustar00rootroot00000000000000import typing as t import uuid from responses.matchers import json_params_matcher from globus_sdk import DependentScopeSpec from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE_REQUEST_ARGS = { "scope_id": str(uuid.uuid1()), } def make_request_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: request_body = {} for field in [ "name", "description", "scope_suffix", "advertised", "allows_refresh_token", "required_domains", ]: if field in request_args and request_args[field] is not None: request_body[field] = request_args[field] if "dependent_scopes" in request_args: request_body["dependent_scopes"] = request_args["dependent_scopes"] return request_body def make_response_body(request_args: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: client_id = str(uuid.uuid1()) scope_suffix = request_args.get("scope_suffix", str(uuid.uuid4()).replace("-", "")) return { "scope_string": f"https://auth.globus.org/scopes/{client_id}/{scope_suffix}", "allows_refresh_token": request_args.get("allows_refresh_token", True), "id": request_args["scope_id"], "advertised": request_args.get("advertised", False), "required_domains": request_args.get("required_domains", []), "name": request_args.get("name", str(uuid.uuid4()).replace("-", "")), "description": request_args.get( "description", str(uuid.uuid4()).replace("-", "") ), "client": str(request_args.get("client_id", uuid.uuid1())), "dependent_scopes": [ { "scope": str(ds["scope"]), "optional": ds["optional"], "requires_refresh_token": ds["requires_refresh_token"], } for ds in request_args.get("dependent_scopes", []) ], } def register_response( args: t.Mapping[str, t.Any], ) -> RegisteredResponse: request_args = {**SCOPE_REQUEST_ARGS, **args} request_body = make_request_body(request_args) response_body = make_response_body(request_args) return RegisteredResponse( service="auth", method="PUT", path=f"/v2/api/scopes/{request_args['scope_id']}", json={"scope": response_body}, metadata={ # Test functions use 'args' to form request "args": request_args, # Test functions use 'response' to verify response "response": response_body, }, match=[json_params_matcher({"scope": request_body})], ) RESPONSES = ResponseSet( default=register_response({}), name=register_response({"name": str(uuid.uuid4()).replace("-", "")}), description=register_response({"description": str(uuid.uuid4()).replace("-", "")}), scope_suffix=register_response( {"scope_suffix": str(uuid.uuid4()).replace("-", "")} ), no_required_domains=register_response({"required_domains": []}), required_domains=register_response( {"required_domains": ["globus.org", "uchicago.edu"]} ), no_dependent_scopes=register_response({"dependent_scopes": []}), dependent_scopes=register_response( { "dependent_scopes": [ DependentScopeSpec(str(uuid.uuid1()), True, True), DependentScopeSpec(uuid.uuid1(), False, False), ], } ), advertised=register_response({"advertised": True}), not_advertised=register_response({"advertised": False}), allows_refresh_token=register_response({"allows_refresh_token": True}), disallows_refresh_token=register_response({"allows_refresh_token": False}), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/auth/userinfo.py000066400000000000000000000014401513221403200273730ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import FORBIDDEN_AUTH_RESPONSE, UNAUTHORIZED_AUTH_RESPONSE RESPONSES = ResponseSet( unauthorized=RegisteredResponse( service="auth", path="/v2/oauth2/userinfo", status=401, json=UNAUTHORIZED_AUTH_RESPONSE.json, metadata={ "error_id": UNAUTHORIZED_AUTH_RESPONSE.error_id, **UNAUTHORIZED_AUTH_RESPONSE.metadata_include, }, ), forbidden=RegisteredResponse( service="auth", path="/v2/oauth2/userinfo", status=403, json=FORBIDDEN_AUTH_RESPONSE.json, metadata={ "error_id": FORBIDDEN_AUTH_RESPONSE.error_id, **FORBIDDEN_AUTH_RESPONSE.metadata_include, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/000077500000000000000000000000001513221403200257035ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/__init__.py000066400000000000000000000000001513221403200300020ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/_common.py000066400000000000000000000016511513221403200277070ustar00rootroot00000000000000import uuid USER_ID = str(uuid.uuid1()) NON_USER_ID = str(uuid.uuid1()) SUBSCRIPTION_ID = str(uuid.uuid1()) ENDPOINT_ID = str(uuid.uuid1()) ENDPOINT_ID_2 = str(uuid.uuid1()) ENDPOINT_ID_3 = str(uuid.uuid1()) FUNCTION_ID = str(uuid.uuid1()) FUNCTION_ID_2 = str(uuid.uuid1()) FUNCTION_NAME = "howdy_world" FUNCTION_CODE = "410\n10\n04\n:gASVQAAAAAAAAACMC2hvd2R5X3dvc ..." TASK_GROUP_ID = str(uuid.uuid1()) TASK_ID = str(uuid.uuid1()) TASK_ID_2 = str(uuid.uuid1()) TASK_DOC = { "task_id": TASK_ID, "status": "success", "result": "10000", "completion_t": "1677183605.212898", "details": { "os": "Linux-5.19.0-1025-aws-x86_64-with-glibc2.35", "python_version": "3.10.4", "dill_version": "0.3.5.1", "globus_compute_sdk_version": "2.3.2", "task_transitions": { "execution-start": 1692742841.843334, "execution-end": 1692742846.123456, }, }, } globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/000077500000000000000000000000001513221403200262325ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/__init__.py000066400000000000000000000000001513221403200303310ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/delete_endpoint.py000066400000000000000000000005261513221403200317510ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v2/endpoints/{ENDPOINT_ID}", method="DELETE", json={"result": 302}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/delete_function.py000066400000000000000000000005261513221403200317560ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_ID RESPONSES = ResponseSet( metadata={"function_id": FUNCTION_ID}, default=RegisteredResponse( service="compute", path=f"/v2/functions/{FUNCTION_ID}", method="DELETE", json={"result": 302}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_endpoint.py000066400000000000000000000017061513221403200312670ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, SUBSCRIPTION_ID ENDPOINT_CONFIG = """ display_name: My Endpoint engine: type: GlobusComputeEngine """ DEFAULT_RESPONSE_DOC = { "uuid": ENDPOINT_ID, "name": "my-endpoint", "display_name": "My Endpoint", "multi_user": False, "public": False, "endpoint_config": ENDPOINT_CONFIG.strip(), "user_config_template": "", "user_config_schema": {}, "description": "My endpoint description", "hostname": "my-hostname", "local_user": "user1", "ip_address": "140.221.112.13", "endpoint_version": "2.31.0", "sdk_version": "2.31.0", "subscription_uuid": SUBSCRIPTION_ID, } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v2/endpoints/{ENDPOINT_ID}", method="GET", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_endpoint_status.py000066400000000000000000000010651513221403200326700ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID DEFAULT_RESPONSE_DOC = { "status": "online", "details": { "total_workers": 1, "idle_workers": 0, "pending_tasks": 0, "outstanding_tasks": 0, "managers": 1, }, } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v2/endpoints/{ENDPOINT_ID}/status", method="GET", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_endpoints.py000066400000000000000000000027621513221403200314550ustar00rootroot00000000000000from responses.matchers import query_param_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, ENDPOINT_ID_2, ENDPOINT_ID_3, NON_USER_ID, USER_ID DEFAULT_RESPONSE_DOC = [ { "uuid": ENDPOINT_ID, "name": "my-endpoint", "display_name": "My Endpoint", "owner": USER_ID, }, { "uuid": ENDPOINT_ID_2, "name": "my-second-endpoint", "display_name": "My Second Endpoint", "owner": USER_ID, }, ] ANY_RESPONSE_DOC = [ { "uuid": ENDPOINT_ID, "name": "my-endpoint", "display_name": "My Endpoint", "owner": USER_ID, }, { "uuid": ENDPOINT_ID_2, "name": "my-second-endpoint", "display_name": "My Second Endpoint", "owner": USER_ID, }, { "uuid": ENDPOINT_ID_3, "name": "public_endpoint", "display_name": "Public Endpoint", "owner": NON_USER_ID, }, ] RESPONSES = ResponseSet( metadata={ "endpoint_id": ENDPOINT_ID, "endpoint_id_2": ENDPOINT_ID_2, "endpoint_id_3": ENDPOINT_ID_3, }, default=RegisteredResponse( service="compute", path="/v2/endpoints", method="GET", json=DEFAULT_RESPONSE_DOC, ), any=RegisteredResponse( service="compute", path="/v2/endpoints", method="GET", json=ANY_RESPONSE_DOC, match=[query_param_matcher(params={"role": "any"})], ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_function.py000066400000000000000000000013041513221403200312660ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_CODE, FUNCTION_ID, FUNCTION_NAME FUNCTION_DOC = { "function_uuid": FUNCTION_ID, "function_name": FUNCTION_NAME, "function_code": FUNCTION_CODE, "description": "I just wanted to say hello.", "metadata": {"python_version": "3.12.6", "sdk_version": "2.28.1"}, } RESPONSES = ResponseSet( metadata={ "function_id": FUNCTION_ID, "function_name": FUNCTION_NAME, "function_code": FUNCTION_CODE, }, default=RegisteredResponse( service="compute", path=f"/v2/functions/{FUNCTION_ID}", method="GET", json=FUNCTION_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_result_amqp_url.py000066400000000000000000000006101513221403200326560ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet DEFAULT_RESPONSE_DOC = { "queue_prefix": "some_prefix", "connection_url": "amqps://user:password@amqp.fqdn", } RESPONSES = ResponseSet( default=RegisteredResponse( service="compute", path="/v2/get_amqp_result_connection_url", method="GET", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_task.py000066400000000000000000000005021513221403200304020ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_DOC, TASK_ID RESPONSES = ResponseSet( metadata={"task_id": TASK_ID}, default=RegisteredResponse( service="compute", path=f"/v2/tasks/{TASK_ID}", method="GET", json=TASK_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_task_batch.py000066400000000000000000000010561513221403200315500ustar00rootroot00000000000000from responses.matchers import json_params_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_DOC, TASK_ID TASK_BATCH_DOC = { "response": "batch", "results": {TASK_ID: TASK_DOC}, } RESPONSES = ResponseSet( metadata={"task_id": TASK_ID}, default=RegisteredResponse( service="compute", path="/v2/batch_status", method="POST", json=TASK_BATCH_DOC, # Ensure task_ids is a list match=[json_params_matcher({"task_ids": [TASK_ID]})], ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_task_group.py000066400000000000000000000012711513221403200316220ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_GROUP_ID, TASK_ID, TASK_ID_2 TASK_BATCH_DOC = { "taskgroup_id": TASK_GROUP_ID, "create_websockets_queue": True, "tasks": [ {"id": TASK_ID, "created_at": "2021-05-05T15:00:00.000000"}, {"id": TASK_ID_2, "created_at": "2021-05-05T15:01:00.000000"}, ], } RESPONSES = ResponseSet( metadata={ "task_group_id": TASK_GROUP_ID, "task_id": TASK_ID, "task_id_2": TASK_ID_2, }, default=RegisteredResponse( service="compute", path=f"/v2/taskgroup/{TASK_GROUP_ID}", method="GET", json=TASK_BATCH_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/get_version.py000066400000000000000000000014031513221403200311260ustar00rootroot00000000000000from responses.matchers import query_param_matcher from globus_sdk.testing.models import RegisteredResponse, ResponseSet API_VERSION = "1.23.0" ALL_RESPONSE_DOC = { "api": API_VERSION, "min_sdk_version": "1.0.0a6", "min_endpoint_version": "1.0.0a0", "git_sha": "80b2ef87bc546b3b386cf2e1d372f4be50f10bc4", } RESPONSES = ResponseSet( metadata={"api_version": API_VERSION}, default=RegisteredResponse( service="compute", path="/v2/version", method="GET", json=API_VERSION, # type: ignore[arg-type] ), all=RegisteredResponse( service="compute", path="/v2/version", method="GET", json=ALL_RESPONSE_DOC, match=[query_param_matcher(params={"service": "all"})], ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/lock_endpoint.py000066400000000000000000000007311513221403200314350ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID DEFAULT_RESPONSE_DOC = { "endpoint_id": ENDPOINT_ID, "lock_expiration_timestamp": "2021-07-01T00:00:00.000000", } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v2/endpoints/{ENDPOINT_ID}/lock", method="POST", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/register_endpoint.py000066400000000000000000000013761513221403200323370ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID DEFAULT_RESPONSE_DOC = { "endpoint_id": ENDPOINT_ID, "task_queue_info": { "connection_url": "amqps://user:password@mq.fqdn", "exchange": "some_exchange", "queue": "some_queue", }, "result_queue_info": { "connection_url": "amqps://user:password@mq.fqdn", "exchange": "some_exchange", "queue": "some_queue", "queue_publish_kwargs": {}, }, "warnings": [], } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path="/v2/endpoints", method="POST", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/register_function.py000066400000000000000000000007211513221403200323350ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_CODE, FUNCTION_ID, FUNCTION_NAME RESPONSES = ResponseSet( metadata={ "function_id": FUNCTION_ID, "function_name": FUNCTION_NAME, "function_code": FUNCTION_CODE, }, default=RegisteredResponse( service="compute", path="/v2/functions", method="POST", json={"function_uuid": FUNCTION_ID}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v2/submit.py000066400000000000000000000015111513221403200301050ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_GROUP_ID, TASK_ID, TASK_ID_2 SUBMIT_RESPONSE = { "response": "success", "task_group_id": TASK_GROUP_ID, "results": [ { "status": "success", "task_uuid": TASK_ID, "http_status_code": 200, "reason": None, }, { "status": "success", "task_uuid": TASK_ID_2, "http_status_code": 200, "reason": None, }, ], } RESPONSES = ResponseSet( metadata={ "task_group_id": TASK_GROUP_ID, "task_id": TASK_ID, "task_id_2": TASK_ID_2, }, default=RegisteredResponse( service="compute", path="/v2/submit", method="POST", json=SUBMIT_RESPONSE, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/000077500000000000000000000000001513221403200262335ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/__init__.py000066400000000000000000000000001513221403200303320ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/get_endpoint_allowlist.py000066400000000000000000000010111513221403200333470ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, FUNCTION_ID, FUNCTION_ID_2 DEFAULT_RESPONSE_DOC = { "endpoint_id": ENDPOINT_ID, "restricted": True, "functions": [FUNCTION_ID, FUNCTION_ID_2], } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v3/endpoints/{ENDPOINT_ID}/allowed_functions", method="GET", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/lock_endpoint.py000066400000000000000000000007311513221403200314360ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID DEFAULT_RESPONSE_DOC = { "endpoint_id": ENDPOINT_ID, "lock_expiration_timestamp": "2021-07-01T00:00:00.000000", } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v3/endpoints/{ENDPOINT_ID}/lock", method="POST", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/register_endpoint.py000066400000000000000000000012771513221403200323400ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID DEFAULT_RESPONSE_DOC = { "endpoint_id": ENDPOINT_ID, "task_queue_info": { "connection_url": "amqps://user:password@mq.fqdn", "exchange": "some_exchange", "queue": "some_queue", }, "result_queue_info": { "connection_url": "amqps://user:password@mq.fqdn", "exchange": "some_exchange", "queue": "some_queue", "queue_publish_kwargs": {}, }, } RESPONSES = ResponseSet( default=RegisteredResponse( service="compute", path="/v3/endpoints", method="POST", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/register_function.py000066400000000000000000000010051513221403200323320ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_CODE, FUNCTION_ID, FUNCTION_NAME DEFAULT_RESPONSE_DOC = { "function_uuid": FUNCTION_ID, } RESPONSES = ResponseSet( metadata={ "function_id": FUNCTION_ID, "function_name": FUNCTION_NAME, "function_code": FUNCTION_CODE, }, default=RegisteredResponse( service="compute", path="/v3/functions", method="POST", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/submit.py000066400000000000000000000014631513221403200301140ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, FUNCTION_ID, TASK_GROUP_ID, TASK_ID, TASK_ID_2 REQUEST_ID = "5158de19-10b5-4deb-9d87-a86c1dec3460" SUBMIT_RESPONSE = { "request_id": REQUEST_ID, "task_group_id": TASK_GROUP_ID, "endpoint_id": ENDPOINT_ID, "tasks": { FUNCTION_ID: [TASK_ID, TASK_ID_2], }, } RESPONSES = ResponseSet( metadata={ "endpoint_id": ENDPOINT_ID, "function_id": FUNCTION_ID, "task_id": TASK_ID, "task_id_2": TASK_ID_2, "task_group_id": TASK_GROUP_ID, "request_id": REQUEST_ID, }, default=RegisteredResponse( service="compute", path=f"/v3/endpoints/{ENDPOINT_ID}/submit", method="POST", json=SUBMIT_RESPONSE, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/compute/v3/update_endpoint.py000066400000000000000000000013701513221403200317700ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID DEFAULT_RESPONSE_DOC = { "endpoint_id": ENDPOINT_ID, "task_queue_info": { "connection_url": "amqps://user:password@mq.fqdn", "exchange": "some_exchange", "queue": "some_queue", }, "result_queue_info": { "connection_url": "amqps://user:password@mq.fqdn", "exchange": "some_exchange", "queue": "some_queue", "queue_publish_kwargs": {}, }, } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="compute", path=f"/v3/endpoints/{ENDPOINT_ID}", method="PUT", json=DEFAULT_RESPONSE_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/000077500000000000000000000000001513221403200253615ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/__init__.py000066400000000000000000000000001513221403200274600ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/_common.py000066400000000000000000000200771513221403200273700ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid TWO_HOP_TRANSFER_FLOW_ID = "24bc4997-b483-4c25-a19c-64b0afc00743" TWO_HOP_TRANSFER_FLOW_OWNER_ID = "b44bddda-d274-11e5-978a-9f15789a8150" FLOW_ID = str(uuid.uuid1()) RUN_ID = str(uuid.uuid1()) USER1 = f"urn:globus:auth:identity:{uuid.uuid1()}" USER2 = f"urn:globus:auth:identity:{uuid.uuid1()}" GROUP = f"urn:globus:groups:id:{uuid.uuid1()}" RUN_MANAGER_PRINCIPAL = f"urn:globus:auth:identity:{str(uuid.uuid1())}" RUN_MONITOR_PRINCIPAL = f"urn:globus:auth:identity:{str(uuid.uuid1())}" TWO_HOP_TRANSFER_FLOW_USER_SCOPE = ( "https://auth.globus.org/scopes/" + TWO_HOP_TRANSFER_FLOW_ID + "/flow_" + TWO_HOP_TRANSFER_FLOW_ID.replace("-", "_") + "_user" ) TWO_HOP_TRANSFER_FLOW_DEFINITION = { "States": { "Transfer1": { "Next": "Transfer2", "Type": "Action", "Comment": "Initial Transfer from Campus to DMZ", "ActionUrl": "https://actions.globus.org/transfer/transfer", "Parameters": { "transfer_items": [ { "recursive": True, "source_path.$": "$.source_path", "destination_path.$": "$.staging_path", } ], "source_endpoint_id.$": "$.source_endpoint_id", "destination_endpoint_id.$": "$.staging_endpoint_id", }, "ResultPath": "$.Transfer1Result", "ActionScope": ( "https://auth.globus.org/scopes/actions.globus.org/transfer/transfer" ), }, "Transfer2": { "End": True, "Type": "Action", "Comment": "Transfer from DMZ to dataset repository", "ActionUrl": "https://actions.globus.org/transfer/transfer", "Parameters": { "transfer_items": [ { "recursive": True, "source_path.$": "$.staging_path", "destination_path.$": "$.destination_path", } ], "source_endpoint_id.$": "$.staging_endpoint_id", "destination_endpoint_id.$": "$.destination_endpoint_id", }, "ResultPath": "$.Transfer2Result", "ActionScope": ( "https://auth.globus.org/scopes/actions.globus.org/transfer/transfer" ), }, }, "Comment": "Two step transfer", "StartAt": "Transfer1", } TWO_HOP_TRANSFER_FLOW_DOC = { "id": TWO_HOP_TRANSFER_FLOW_ID, "definition": TWO_HOP_TRANSFER_FLOW_DEFINITION, "input_schema": { "type": "object", "required": [ "source_endpoint_id", "source_path", "staging_endpoint_id", "staging_path", "destination_endpoint_id", "destination_path", ], "properties": { "source_path": {"type": "string"}, "staging_path": {"type": "string"}, "destination_path": {"type": "string"}, "source_endpoint_id": {"type": "string"}, "staging_endpoint_id": {"type": "string"}, "destination_endpoint_id": {"type": "string"}, }, "additionalProperties": False, }, "globus_auth_scope": TWO_HOP_TRANSFER_FLOW_USER_SCOPE, "synchronous": False, "log_supported": True, "types": ["Action"], "api_version": "1.0", "title": "Multi Step Transfer", "subtitle": "", "description": "", "keywords": ["two", "hop", "transfer"], "principal_urn": f"urn:globus:auth:identity:{TWO_HOP_TRANSFER_FLOW_ID}", "globus_auth_username": f"{TWO_HOP_TRANSFER_FLOW_ID}@clients.auth.globus.org", "created_at": "2020-09-01T17:59:20.711845+00:00", "updated_at": "2020-09-01T17:59:20.711845+00:00", "user_role": "flow_starter", "created_by": f"urn:globus:auth:identity:{TWO_HOP_TRANSFER_FLOW_OWNER_ID}", "visible_to": [], "runnable_by": [], "administered_by": [], "action_url": f"https://flows.globus.org/flows/{TWO_HOP_TRANSFER_FLOW_ID}", "flow_url": f"https://flows.globus.org/flows/{TWO_HOP_TRANSFER_FLOW_ID}", "flow_owner": f"urn:globus:auth:identity:{TWO_HOP_TRANSFER_FLOW_OWNER_ID}", "flow_viewers": [ "public", "urn:globus:auth:identity:51abb9ce-6e05-4ab1-9a09-9c524313827c", ], "flow_starters": [ "all_authenticated_users", "urn:globus:auth:identity:d0a15d1b-28f5-42de-9463-b8b6540421b6", ], "flow_administrators": [ "urn:globus:auth:identity:05d29dab-bd26-4510-9290-468972e8ac01" ], "run_managers": [RUN_MANAGER_PRINCIPAL], "run_monitors": [RUN_MONITOR_PRINCIPAL], } TWO_HOP_TRANSFER_RUN_ID = "36ad9f9a-ad29-488f-beb4-c22ab729643a" TWO_HOP_TRANSFER_RUN: dict[str, t.Any] = { "run_id": TWO_HOP_TRANSFER_RUN_ID, "flow_id": TWO_HOP_TRANSFER_FLOW_ID, "flow_title": TWO_HOP_TRANSFER_FLOW_DOC["title"], "flow_last_updated": "2020-09-01T17:59:20.711845+00:00", "start_time": "2020-09-12T15:00:20.711845+00:00", "status": "ACTIVE", "display_status": "ACTIVE", "details": { "code": "FlowStarting", "description": "The Flow is starting execution", "details": { "input": { "source_endpoint_id": "7e1b8ec7-a606-4c23-96c7-a2d930a3a55f", "source_path": "/path/to/the/source/dir", "staging_endpoint_id": "d5049dd6-ce9c-4f9e-853f-c25069f369f8", "staging_path": "/path/to/the/staging/dir", "destination_endpoint_id": "f3bd0daf-be5a-4df8-b53f-76b932113b7c", "destination_path": "/path/to/the/dest/dir", } }, }, "run_owner": TWO_HOP_TRANSFER_FLOW_DOC["flow_owner"], "run_managers": ["urn:globus:auth:identity:7d6064ef-5368-473a-b15b-e99c3561aa9b"], "run_monitors": [ "urn:globus:auth:identity:58cf49f4-06ea-4b76-934c-d5c9f6c3ea9d", "urn:globus:auth:identity:57088a17-d5cb-4cfa-871a-c5cce48f2aec", ], "user_role": "run_owner", "label": "Transfer all of these files!", "tags": [ "my-transfer-run", "jazz-fans", ], "search": {"task_id": "20ba91a8-eb90-470a-9477-2ad68808b276"}, } FLOW_SCOPE_SUFFIX = f'flow_{FLOW_ID.replace("-", "_")}_user' FLOW_SCOPE = f"https://auth.globus.org/scopes/{FLOW_ID}/{FLOW_SCOPE_SUFFIX}" FLOW_DESCRIPTION = { "created_at": "2023-04-11T20:00:06.524930+00:00", "flow_owner": USER1, "created_by": USER1, "description": "This flow does some pretty cool stuff", "globus_auth_scope": FLOW_SCOPE, "id": FLOW_ID, "keywords": ["cool"], "subtitle": "My Cool Subtitle", "title": "My Cool Flow", "updated_at": "2023-04-11T20:00:06.524930+00:00", } RUN_DETAILS = { "code": "FlowSucceeded", "description": "The Flow run reached a successful completion state", "output": { "HelloResult": { "action_id": "6RxDm1JOQnG2", "completion_time": "2023-04-11T20:01:22.340594+00:00", "creator_id": USER1, "details": {"Hello": "World", "hello": "foo"}, "display_status": "SUCCEEDED", "label": "My Cool Run", "manage_by": [USER2], "monitor_by": [GROUP], "release_after": None, "start_time": "2023-04-11T20:01:19.660251+00:00", "state_name": "RunHelloWorld", "status": "SUCCEEDED", }, "input": {"echo_string": "foo", "sleep": 2}, }, } RUN = { "run_id": RUN_ID, "action_id": RUN_ID, "completion_time": "2023-04-11T20:01:22.917000+00:00", "created_by": USER1, "details": RUN_DETAILS, "display_status": "SUCCEEDED", "flow_id": FLOW_ID, "flow_last_updated": "2023-04-11T20:00:06.524930+00:00", "flow_title": "My Cool Flow", "label": "My Cool Run", "manage_by": [USER2], "monitor_by": [GROUP], "run_managers": [USER2], "run_monitors": [GROUP], "run_owner": USER1, "start_time": "2023-04-11T20:01:18.040416+00:00", "status": "SUCCEEDED", "tags": ["cool", "my"], "user_role": "run_owner", } globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/cancel_run.py000066400000000000000000000013701513221403200300450ustar00rootroot00000000000000import copy from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN RUN_ID = TWO_HOP_TRANSFER_RUN["run_id"] SUCCESS_RESPONSE = copy.deepcopy(TWO_HOP_TRANSFER_RUN) SUCCESS_RESPONSE.update( { "status": "FAILED", "display_status": "FAILED", "details": { "time": "2023-06-12T23:04:42.121000+00:00", "code": "FlowCanceled", "description": "The Flow Instance was canceled by the user", "details": {}, }, } ) RESPONSES = ResponseSet( metadata={"run_id": RUN_ID}, default=RegisteredResponse( service="flows", method="POST", path=f"/runs/{RUN_ID}/cancel", json=SUCCESS_RESPONSE, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/create_flow.py000066400000000000000000000040371513221403200302310ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC _two_hop_transfer_create_request = { k: TWO_HOP_TRANSFER_FLOW_DOC[k] for k in [ "title", "definition", "input_schema", "subtitle", "description", "flow_viewers", "flow_starters", "flow_administrators", "run_managers", "run_monitors", "keywords", ] } RESPONSES = ResponseSet( metadata={ "params": _two_hop_transfer_create_request, }, default=RegisteredResponse( service="flows", path="/flows", method="POST", json=TWO_HOP_TRANSFER_FLOW_DOC, ), bad_admin_principal_error=RegisteredResponse( service="flows", path="/flows", method="POST", status=422, json={ "error": { "code": "UNPROCESSABLE_ENTITY", "detail": [ { "loc": ["flow_administrators", 0], "msg": ( "Unrecognized principal string: " '"ae341a98-d274-11e5-b888-dbae3a8ba545". ' 'Allowed principal types in role "FlowAdministrator": ' "[, ]" ), "type": "value_error", }, { "loc": ["flow_administrators", 1], "msg": ( "Unrecognized principal string: " '"4fab4345-6d20-43a0-9a25-16b2e3bbe765". ' 'Allowed principal types in role "FlowAdministrator": ' "[, ]" ), "type": "value_error", }, ], }, "debug_id": "cf71b1d1-ab7e-48b1-8c54-764201d28ded", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/delete_flow.py000066400000000000000000000026541513221403200302330ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID _DELETED_DOC = { "DELETED": True, "administered_by": [], "api_version": "1.0", "created_at": TWO_HOP_TRANSFER_FLOW_DOC["created_at"], "created_by": TWO_HOP_TRANSFER_FLOW_DOC["created_by"], "definition": TWO_HOP_TRANSFER_FLOW_DOC["definition"], "deleted_at": "2022-10-20T16:44:59.126641+00:00", "description": "", "flow_administrators": [], "flow_owner": TWO_HOP_TRANSFER_FLOW_DOC["flow_owner"], "flow_starters": [], "flow_viewers": [], "globus_auth_scope": TWO_HOP_TRANSFER_FLOW_DOC["globus_auth_scope"], "globus_auth_username": TWO_HOP_TRANSFER_FLOW_DOC["globus_auth_username"], "id": TWO_HOP_TRANSFER_FLOW_ID, "input_schema": {}, "keywords": [], "log_supported": True, "principal_urn": TWO_HOP_TRANSFER_FLOW_DOC["principal_urn"], "runnable_by": [], "subtitle": "", "synchronous": False, "title": TWO_HOP_TRANSFER_FLOW_DOC["title"], "types": ["Action"], "updated_at": "2022-10-20T16:44:59.021201+00:00", "user_role": "flow_owner", "visible_to": [], } RESPONSES = ResponseSet( metadata={"flow_id": TWO_HOP_TRANSFER_FLOW_ID}, default=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}", method="DELETE", json=_DELETED_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/delete_run.py000066400000000000000000000023351513221403200300640ustar00rootroot00000000000000import copy from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN RUN_ID = TWO_HOP_TRANSFER_RUN["run_id"] SUCCESSFUL_DELETE_RESPONSE = copy.deepcopy(TWO_HOP_TRANSFER_RUN) SUCCESSFUL_DELETE_RESPONSE.update( { "status": "SUCCEEDED", "display_status": "SUCCEEDED", "details": { "code": "FlowSucceeded", "output": {}, "description": "The Flow run reached a successful completion state", }, } ) CONFLICT_RESPONSE = { "error": { "code": "STATE_CONFLICT", "detail": ( f"Run {RUN_ID} has status 'ACTIVE' but must have status" f" in {{'ENDED', 'SUCCEEDED', 'FAILED'}} for requested operation" ), }, "debug_id": "80d920b6-66cf-4254-bcbd-7d3efe814e1a", } RESPONSES = ResponseSet( metadata={"run_id": RUN_ID}, default=RegisteredResponse( service="flows", method="POST", path=f"/runs/{RUN_ID}/release", json=SUCCESSFUL_DELETE_RESPONSE, ), conflict=RegisteredResponse( service="flows", method="POST", path=f"/runs/{RUN_ID}/release", status=409, json=CONFLICT_RESPONSE, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/get_flow.py000066400000000000000000000006771513221403200275530ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID RESPONSES = ResponseSet( metadata={ "flow_id": TWO_HOP_TRANSFER_FLOW_ID, "title": TWO_HOP_TRANSFER_FLOW_DOC["title"], }, default=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}", json=TWO_HOP_TRANSFER_FLOW_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/get_run.py000066400000000000000000000021311513221403200273730ustar00rootroot00000000000000from copy import deepcopy from responses.matchers import query_param_matcher from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet from ._common import FLOW_DESCRIPTION, RUN, RUN_ID RUN_WITH_FLOW_DESCRIPTION = deepcopy(RUN) RUN_WITH_FLOW_DESCRIPTION["flow_description"] = FLOW_DESCRIPTION RESPONSES = ResponseSet( metadata={"run_id": RUN_ID}, default=ResponseList( RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}", json=RUN, match=[query_param_matcher(params={})], ), RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}", json=RUN, match=[query_param_matcher(params={"include_flow_description": "False"})], ), RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}", json=RUN_WITH_FLOW_DESCRIPTION, match=[query_param_matcher(params={"include_flow_description": "True"})], ), ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/get_run_definition.py000066400000000000000000000011171513221403200316060ustar00rootroot00000000000000import uuid from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet from ._common import RUN_ID GET_RUN_DEFINITION = { "flow_id": str(uuid.uuid4()), "definition": { "States": {"no-op": {"End": True, "Type": "Pass"}}, "StartAt": "no-op", }, "input_schema": {}, } RESPONSES = ResponseSet( metadata={"run_id": RUN_ID}, default=ResponseList( RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}/definition", json=GET_RUN_DEFINITION, ), ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/get_run_logs.py000066400000000000000000000225241513221403200304270ustar00rootroot00000000000000from responses.matchers import query_param_matcher from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet RUN_ID = "cfdaf0a4-0931-40af-b974-b619ce69f401" OWNER_URN = "urn:globus:auth:identity:944cfbe8-60f8-474d-a634-a0c1ad543a54" RUN_LOGS_RESPONSE = { "limit": 10, "has_next_page": False, "entries": [ { "time": "2023-04-25T18:54:30.683000+00:00", "code": "FlowStarted", "description": "The Flow Instance started execution", "details": {"input": {}}, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "ActionStarted", "description": "State SyncHelloWorld of type Action started", "details": { "state_name": "SyncHelloWorld", "state_type": "Action", "input": {"echo_string": "sync!"}, }, }, { "time": "2023-04-25T18:54:31.850000+00:00", "code": "ActionCompleted", "description": "State SyncHelloWorld of type Action completed", "details": { "state_name": "SyncHelloWorld", "state_type": "Action", "output": { "action_id": "19NqhOnDlt2Y3", "completion_time": "2023-04-25T18:54:31.341170+00:00", "creator_id": OWNER_URN, "details": {"Hello": "World", "hello": "sync!"}, "display_status": "SUCCEEDED", "label": None, "manage_by": [], "monitor_by": [], "release_after": None, "start_time": "2023-04-25T18:54:31.340484+00:00", "status": "SUCCEEDED", "state_name": "SyncHelloWorld", }, }, }, { "time": "2023-04-25T18:54:31.913000+00:00", "code": "FlowSucceeded", "description": "The Flow Instance completed successfully", "details": { "output": { "action_id": "19NqhOnDlt2Y3", "completion_time": "2023-04-25T18:54:31.341170+00:00", "creator_id": OWNER_URN, "details": {"Hello": "World", "hello": "sync!"}, "display_status": "SUCCEEDED", "label": None, "manage_by": [], "monitor_by": [], "release_after": None, "start_time": "2023-04-25T18:54:31.340484+00:00", "status": "SUCCEEDED", "state_name": "SyncHelloWorld", } }, }, ], } PAGINATED_RUN_LOG_RESPONSES = [ { "limit": 10, "has_next_page": True, "marker": "fake_run_logs_marker", "entries": [ { "time": "2023-04-25T18:54:30.683000+00:00", "code": "FlowStarted", "description": "The Flow Instance started execution", "details": {"input": {}}, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassStarted", "description": "State PassState of type Pass started", "details": { "state_name": "PassState", "state_type": "Pass", "input": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassCompleted", "description": "State PassState of type Pass completed", "details": { "state_name": "PassState", "state_type": "Pass", "output": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassStarted", "description": "State PassState2 of type Pass started", "details": { "state_name": "PassState2", "state_type": "Pass", "input": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassCompleted", "description": "State PassState2 of type Pass completed", "details": { "state_name": "PassState2", "state_type": "Pass", "output": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassStarted", "description": "State PassState3 of type Pass started", "details": { "state_name": "PassState3", "state_type": "Pass", "input": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassCompleted", "description": "State PassState3 of type Pass completed", "details": { "state_name": "PassState3", "state_type": "Pass", "output": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassStarted", "description": "State PassState4 of type Pass started", "details": { "state_name": "PassState4", "state_type": "Pass", "input": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "PassCompleted", "description": "State PassState4 of type Pass completed", "details": { "state_name": "PassState4", "state_type": "Pass", "output": {}, }, }, { "time": "2023-04-25T18:54:30.715000+00:00", "code": "ActionStarted", "description": "State SyncHelloWorld of type Action started", "details": { "state_name": "SyncHelloWorld", "state_type": "Action", "input": {"echo_string": "sync!"}, }, }, ], }, { "limit": 10, "has_next_page": False, "entries": [ { "time": "2023-04-25T18:54:31.850000+00:00", "code": "ActionCompleted", "description": "State SyncHelloWorld of type Action completed", "details": { "state_name": "SyncHelloWorld", "state_type": "Action", "output": { "action_id": "19NqhOnDlt2Y3", "completion_time": "2023-04-25T18:54:31.341170+00:00", "creator_id": OWNER_URN, "details": {"Hello": "World", "hello": "sync!"}, "display_status": "SUCCEEDED", "label": None, "manage_by": [], "monitor_by": [], "release_after": None, "start_time": "2023-04-25T18:54:31.340484+00:00", "status": "SUCCEEDED", "state_name": "SyncHelloWorld", }, }, }, { "time": "2023-04-25T18:54:31.913000+00:00", "code": "FlowSucceeded", "description": "The Flow Instance completed successfully", "details": { "output": { "action_id": "19NqhOnDlt2Y3", "completion_time": "2023-04-25T18:54:31.341170+00:00", "creator_id": OWNER_URN, "details": {"Hello": "World", "hello": "sync!"}, "display_status": "SUCCEEDED", "label": None, "manage_by": [], "monitor_by": [], "release_after": None, "start_time": "2023-04-25T18:54:31.340484+00:00", "status": "SUCCEEDED", "state_name": "SyncHelloWorld", } }, }, ], }, ] RESPONSES = ResponseSet( metadata={"run_id": RUN_ID}, default=RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}/log", json=RUN_LOGS_RESPONSE, ), paginated=ResponseList( RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}/log", json=PAGINATED_RUN_LOG_RESPONSES[0], match=[query_param_matcher(params={})], ), RegisteredResponse( service="flows", method="GET", path=f"/runs/{RUN_ID}/log", json=PAGINATED_RUN_LOG_RESPONSES[1], match=[ query_param_matcher( params={"marker": PAGINATED_RUN_LOG_RESPONSES[0]["marker"]} ) ], ), ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/list_flows.py000066400000000000000000000104771513221403200301310ustar00rootroot00000000000000from __future__ import annotations import datetime import typing as t import uuid from responses import matchers from globus_sdk.testing.models import RegisteredResponse, ResponseList, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID OWNER_ID = "e061df5a-b7b9-4578-a73b-6d4a4edfd66e" def generate_hello_world_example_flow(n: int) -> dict[str, t.Any]: flow_id = str(uuid.UUID(int=n)) base_time = datetime.datetime.fromisoformat("2021-10-18T19:19:35.967289+00:00") updated_at = created_at = base_time + datetime.timedelta(days=n) flow_user_scope = ( f"https://auth.globus.org/scopes/{flow_id}/" f"flow_{flow_id.replace('-', '_')}_user" ) return { "action_url": f"https://flows.automate.globus.org/flows/{flow_id}", "administered_by": [], "api_version": "1.0", "created_at": created_at.isoformat() + "+00:00", "created_by": f"urn:globus:auth:identity:{OWNER_ID}", "definition": { "StartAt": "HelloWorld", "States": { "HelloWorld": { "ActionScope": ( "https://auth.globus.org/scopes/actions.globus.org/hello_world" ), "ActionUrl": "https://actions.globus.org/hello_world", "End": True, "Parameters": {"echo_string": "Hello, World."}, "ResultPath": "$.Result", "Type": "Action", } }, }, "description": "A simple Flow...", "flow_administrators": [], "flow_owner": f"urn:globus:auth:identity:{OWNER_ID}", "flow_starters": [], "flow_url": f"https://flows.automate.globus.org/flows/{flow_id}", "flow_viewers": [], "globus_auth_scope": flow_user_scope, "globus_auth_username": f"{flow_id}@clients.auth.globus.org", "id": str(flow_id), "input_schema": { "additionalProperties": False, "properties": { "echo_string": {"description": "The string to echo", "type": "string"}, "sleep_time": {"type": "integer"}, }, "required": ["echo_string", "sleep_time"], "type": "object", }, "keywords": [], "log_supported": True, "principal_urn": f"urn:globus:auth:identity:{flow_id}", "runnable_by": [], "subtitle": "", "synchronous": False, "title": f"Hello, World (Example {n})", "types": ["Action"], "updated_at": updated_at.isoformat() + "+00:00", "user_role": "flow_viewer", "visible_to": [], } RESPONSES = ResponseSet( metadata={"first_flow_id": TWO_HOP_TRANSFER_FLOW_ID}, default=RegisteredResponse( service="flows", path="/flows", json={ "flows": [TWO_HOP_TRANSFER_FLOW_DOC], "limit": 20, "has_next_page": False, "marker": None, }, ), paginated=ResponseList( RegisteredResponse( service="flows", path="/flows", json={ "flows": [generate_hello_world_example_flow(i) for i in range(20)], "limit": 20, "has_next_page": True, "marker": "fake_marker_0", }, ), RegisteredResponse( service="flows", path="/flows", json={ "flows": [generate_hello_world_example_flow(i) for i in range(20, 40)], "limit": 20, "has_next_page": True, "marker": "fake_marker_1", }, match=[matchers.query_param_matcher({"marker": "fake_marker_0"})], ), RegisteredResponse( service="flows", path="/flows", json={ "flows": [generate_hello_world_example_flow(i) for i in range(40, 60)], "limit": 20, "has_next_page": False, "marker": None, }, match=[matchers.query_param_matcher({"marker": "fake_marker_1"})], ), metadata={ "owner_id": OWNER_ID, "num_pages": 3, "expect_markers": ["fake_marker_0", "fake_marker_1", None], "total_items": 60, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/list_runs.py000066400000000000000000000107561513221403200277660ustar00rootroot00000000000000from __future__ import annotations import copy import datetime import typing as t import uuid from responses import matchers from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet from ._common import RUN, RUN_ID, USER1 FLOW_ID_ALPHA = str(uuid.uuid1()) FLOW_ID_BETA = str(uuid.uuid1()) def generate_example_run(n: int, flow_id: str | None = None) -> dict[str, t.Any]: run_id = str(uuid.UUID(int=n)) base_time = datetime.datetime.fromisoformat("2021-10-18T19:19:35.967289+00:00") start_time = base_time + datetime.timedelta(days=n) completion_time = base_time + datetime.timedelta(days=n + 1) run_doc = copy.deepcopy(RUN) run_doc["completion_time"] = completion_time.isoformat() run_doc["label"] = f"Run {n}" run_doc["start_time"] = start_time.isoformat() run_doc["run_id"] = run_id run_doc["action_id"] = run_id if flow_id is not None: run_doc["flow_id"] = flow_id return run_doc _combined_filter_output = { "runs": [generate_example_run(i, flow_id=FLOW_ID_ALPHA) for i in range(5)] + [generate_example_run(i, flow_id=FLOW_ID_BETA) for i in range(5)], "limit": 10, "has_next_page": False, } RESPONSES = ResponseSet( default=RegisteredResponse( service="flows", path="/runs", json={ "runs": [RUN], "limit": 20, "has_next_page": False, "marker": None, }, metadata={"first_run_id": RUN_ID}, ), filter_flow_id=ResponseList( RegisteredResponse( service="flows", path="/runs", json={ "runs": [ generate_example_run(i, flow_id=FLOW_ID_ALPHA) for i in range(5) ], "limit": 5, "has_next_page": False, }, match=[matchers.query_param_matcher({"filter_flow_id": FLOW_ID_ALPHA})], ), RegisteredResponse( service="flows", path="/runs", json={ "runs": [ generate_example_run(i, flow_id=FLOW_ID_BETA) for i in range(5) ], "limit": 5, "has_next_page": False, }, match=[matchers.query_param_matcher({"filter_flow_id": FLOW_ID_BETA})], ), # register this twice to make the matching order insensitive RegisteredResponse( service="flows", path="/runs", json=_combined_filter_output, match=[ matchers.query_param_matcher( {"filter_flow_id": f"{FLOW_ID_ALPHA},{FLOW_ID_BETA}"} ) ], ), RegisteredResponse( service="flows", path="/runs", json=_combined_filter_output, match=[ matchers.query_param_matcher( {"filter_flow_id": f"{FLOW_ID_BETA},{FLOW_ID_ALPHA}"} ) ], ), metadata={ "by_flow_id": { FLOW_ID_ALPHA: { "num": 5, }, FLOW_ID_BETA: { "num": 5, }, } }, ), paginated=ResponseList( RegisteredResponse( service="flows", path="/runs", json={ "runs": [generate_example_run(i) for i in range(20)], "limit": 20, "has_next_page": True, "marker": "fake_marker_0", }, ), RegisteredResponse( service="flows", path="/runs", json={ "runs": [generate_example_run(i) for i in range(20, 40)], "limit": 20, "has_next_page": True, "marker": "fake_marker_1", }, match=[matchers.query_param_matcher({"marker": "fake_marker_0"})], ), RegisteredResponse( service="flows", path="/runs", json={ "runs": [generate_example_run(i) for i in range(40, 60)], "limit": 20, "has_next_page": False, "marker": None, }, match=[matchers.query_param_matcher({"marker": "fake_marker_1"})], ), metadata={ "owner_id": USER1, "num_pages": 3, "expect_markers": ["fake_marker_0", "fake_marker_1", None], "total_items": 60, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/resume_run.py000066400000000000000000000006561513221403200301260ustar00rootroot00000000000000from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN FLOW_ID = TWO_HOP_TRANSFER_RUN["flow_id"] RUN_ID = TWO_HOP_TRANSFER_RUN["run_id"] RESPONSES = ResponseSet( metadata={"run_id": RUN_ID, "flow_id": FLOW_ID}, default=RegisteredResponse( service="flows", method="POST", path=f"/runs/{RUN_ID}/resume", json=TWO_HOP_TRANSFER_RUN, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/run_flow.py000066400000000000000000000021511513221403200275650ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_ID, TWO_HOP_TRANSFER_RUN _request_params = { "body": TWO_HOP_TRANSFER_RUN["details"]["details"]["input"], "tags": TWO_HOP_TRANSFER_RUN["tags"], "label": TWO_HOP_TRANSFER_RUN["label"], "run_monitors": TWO_HOP_TRANSFER_RUN["run_monitors"], "run_managers": TWO_HOP_TRANSFER_RUN["run_managers"], } RESPONSES = ResponseSet( metadata={"flow_id": TWO_HOP_TRANSFER_FLOW_ID, "request_params": _request_params}, default=RegisteredResponse( service="flows", method="POST", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}/run", json=TWO_HOP_TRANSFER_RUN, ), missing_scope_error=RegisteredResponse( service="flows", method="POST", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}/run", status=403, json={ "error": { "code": "MISSING_SCOPE", "detail": ( "This action requires the following scope: frobulate[demuddle]" ), } }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/update_flow.py000066400000000000000000000015701513221403200302470ustar00rootroot00000000000000from copy import deepcopy from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID _two_hop_transfer_update_request = { "subtitle": "Specifically, in two steps", "description": "Transfer from source to destination, stopping off at staging", "subscription_id": "00000000-3ba7-456e-9df7-fc40028f3331", } _updated_two_hop_transfer_flow_doc = deepcopy(TWO_HOP_TRANSFER_FLOW_DOC) _updated_two_hop_transfer_flow_doc.update(_two_hop_transfer_update_request) RESPONSES = ResponseSet( metadata={ "flow_id": TWO_HOP_TRANSFER_FLOW_ID, "params": _two_hop_transfer_update_request, }, default=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}", method="PUT", json=_updated_two_hop_transfer_flow_doc, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/update_run.py000066400000000000000000000006461513221403200301070ustar00rootroot00000000000000from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN FLOW_ID = TWO_HOP_TRANSFER_RUN["flow_id"] RUN_ID = TWO_HOP_TRANSFER_RUN["run_id"] RESPONSES = ResponseSet( metadata={"run_id": RUN_ID, "flow_id": FLOW_ID}, default=RegisteredResponse( service="flows", method="PUT", path=f"/runs/{RUN_ID}", json=TWO_HOP_TRANSFER_RUN, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/validate_flow.py000066400000000000000000000066051513221403200305620ustar00rootroot00000000000000from responses import matchers from globus_sdk.testing.models import RegisteredResponse, ResponseSet VALIDATE_SIMPLE_FLOW_DEFINITION = { "Comment": "Simple flow", "StartAt": "Step1", "States": { "Step1": { "Type": "Action", "ActionUrl": "https://transfer.actions.globus.org/transfer", "Parameters": { "source_endpoint.$": "$.source_endpoint_id", "destination_endpoint.$": "$.destination_endpoint_id", "DATA": [ { "source_path.$": "$.source_path", "destination_path.$": "$.destination_path", } ], }, "ResultPath": "$.TransferResult", "End": True, } }, } VALIDATE_SIMPLE_SUCCESS_RESPONSE = { "scopes": {"User": ["urn:globus:auth:scope:transfer.api.globus.org:all"]} } VALIDATE_INVALID_FLOW_DEFINITION = { "Comment": "Simple flow", "StartAt": "Step1", "States": { "Step1": { "Type": "Action", "ActionUrl": "https://transfer.actions.globus.org/transfer", "Parameters": { "source_endpoint.$": "$.source_endpoint_id", "destination_endpoint.$": "$.destination_endpoint_id", "DATA": [ { "source_path.$": "$.source_path", "destination_path.$": "$.destination_path", } ], }, "ResultPath": "$.TransferResult", } }, } VALIDATE_INVALID_RESPONSE = { "error": { "code": "UNPROCESSABLE_ENTITY", "detail": [ { "loc": ["definition", "States", "Step1"], "msg": ( "A state of type 'Action' must be defined as either terminal " '("End": true) or transitional ("Next": "NextStateId")' ), "type": "value_error", } ], "message": ( "1 validation error in body. $.definition.States.Step1: A state of " "type 'Action' must be defined as either terminal (\"End\": true) " 'or transitional ("Next": "NextStateId")' ), }, "debug_id": "41267e70-6788-4316-8b67-df7160166466", } _validate_simple_flow_request = { "definition": VALIDATE_SIMPLE_FLOW_DEFINITION, } _validate_invalid_flow_request = { "definition": VALIDATE_INVALID_FLOW_DEFINITION, } RESPONSES = ResponseSet( metadata={ "success": VALIDATE_SIMPLE_FLOW_DEFINITION, "invalid": VALIDATE_INVALID_FLOW_DEFINITION, }, default=RegisteredResponse( service="flows", path="/flows/validate", method="POST", status=200, json=VALIDATE_SIMPLE_SUCCESS_RESPONSE, match=[ matchers.json_params_matcher( params={"definition": VALIDATE_SIMPLE_FLOW_DEFINITION}, strict_match=False, ) ], ), definition_error=RegisteredResponse( service="flows", path="/flows/validate", method="POST", status=422, json=VALIDATE_INVALID_RESPONSE, match=[ matchers.json_params_matcher( params={"definition": VALIDATE_INVALID_FLOW_DEFINITION}, strict_match=False, ) ], ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/flows/validate_run.py000066400000000000000000000050161513221403200304120ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_ID VALIDATE_RUN_SIMPLE_INPUT_BODY = {"param": "value"} VALIDATE_INVALID_SIMPLE_INPUT_BODY = {"foo": "bar"} VALIDATE_RUN_SIMPLE_SUCCESS_RESPONSE = {"message": "validation successful"} validate_simple_input_request = { "body": VALIDATE_RUN_SIMPLE_INPUT_BODY, } validate_invalid_simple_input_request = { "body": VALIDATE_INVALID_SIMPLE_INPUT_BODY, } RESPONSES = ResponseSet( metadata={ "flow_id": TWO_HOP_TRANSFER_FLOW_ID, "request_body": VALIDATE_RUN_SIMPLE_INPUT_BODY, }, default=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}/validate_run", method="POST", status=200, json=VALIDATE_RUN_SIMPLE_SUCCESS_RESPONSE, ), invalid_input_payload=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}/validate_run", method="POST", status=400, json={ "error": { "code": "FLOW_INPUT_ERROR", "detail": [ { "loc": ["$.bool"], "type": "InvalidActionInput", "msg": "'not-a-boolean' is not of type 'boolean'", } ], "message": "Input failed schema validation with 1 error.", }, "debug_id": "00000000-2572-411b-9fa9-c72fbed2b0bb", }, ), invalid_token=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}/validate_run", method="POST", status=401, json={ "error": { "code": "AUTHENTICATION_ERROR", "detail": "Expired or invalid Bearer token", }, "debug_id": "00000000-1ca9-477d-9937-a26c9d9384b9", }, ), not_a_flow_starter=RegisteredResponse( service="flows", path=f"/flows/{TWO_HOP_TRANSFER_FLOW_ID}/validate_run", method="POST", status=403, json={ "error": { "code": "FORBIDDEN", "detail": ( "You do not have the necessary permissions to perform this action" " on the flow with id value 00000000-0a9d-4036-a9d7-a77c19515594." " Missing permissions: RUN." ), }, "debug_id": "00000000-16c3-4872-8bb5-47db18227cb0", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_server/000077500000000000000000000000001513221403200306215ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_server/__init__.py000066400000000000000000000000001513221403200327200ustar00rootroot00000000000000create_storage_gateway.py000066400000000000000000000042561513221403200356330ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", "display_name": "example gateway 1", } RESPONSES = ResponseSet( metadata=metadata, default=RegisteredResponse( service="gcs", method="POST", path="/storage_gateways", json={ "DATA_TYPE": "result#1.0.0", "http_response_code": 200, "detail": "success", "message": "Operation successful", "code": "success", "data": [ { "DATA_TYPE": "storage_gateway#1.0.0", "id": metadata["id"], "display_name": metadata["display_name"], "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, "allowed_domains": ["example.edu"], "mapping": "username_without_domain", "require_high_assurance": False, "restrict_paths": { "DATA_TYPE": "path_restrictions#1.0.0", "read": ["/"], }, "policies": { "DATA_TYPE": "posix_storage_gateway#1.0.0", "groups_allow": ["globus"], "groups_deny": ["nonglobus"], }, "users_allow": ["user1"], "users_deny": ["user2"], } ], }, ), validation_error=RegisteredResponse( path="/storage_gateways", service="gcs", method="POST", status=422, json={ "DATA_TYPE": "result#1.0.0", "code": "unprocessable_entity", "detail": "", "http_response_code": 422, "message": "Data Validation Error", }, metadata={ "http_status": 422, "code": "unprocessable_entity", "message": "Data Validation Error", }, ), ) create_user_credential.py000066400000000000000000000023571513221403200356160ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" RESPONSES = ResponseSet( metadata={"id": CREDENTIAL_ID}, default=RegisteredResponse( service="gcs", path="/user_credentials", method="POST", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "user_credential#1.0.0", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "display_name": "updated_posix_credential", "id": CREDENTIAL_ID, "identity_id": "948847d4-ffcc-4ae0-ba3a-a4c88d480159", "invalid": False, "policies": {"DATA_TYPE": "posix_user_credential_policies#1.0.0"}, "provisioned": False, "storage_gateway_id": "82247cc9-3208-4d71-bd7f-1b8798c95e6b", "username": "testuser", }, ], "detail": "created", "has_next_page": False, "http_response_code": 201, "message": f"Created User Credential {CREDENTIAL_ID}", }, ), ) delete_storage_gateway.py000066400000000000000000000021651513221403200356270ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", "display_name": "example gateway 1", } RESPONSES = ResponseSet( metadata=metadata, default=RegisteredResponse( service="gcs", method="DELETE", path=f"/storage_gateways/{metadata['id']}", json={ "DATA_TYPE": "result#1.0.0", "http_response_code": 200, "detail": "success", "message": "Operation successful", "code": "success", "data": [{}], }, ), permission_denied_error=RegisteredResponse( service="gcs", method="DELETE", path=f"/storage_gateways/{metadata['id']}", status=403, json={ "DATA_TYPE": "result#1.0.0", "code": "permission_denied", "detail": "", "http_response_code": 403, "message": None, }, metadata={ "http_status": 403, "code": "permission_denied", "message": "", **metadata, }, ), ) delete_user_credential.py000066400000000000000000000011611513221403200356050ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" RESPONSES = ResponseSet( metadata={"id": CREDENTIAL_ID}, default=RegisteredResponse( service="gcs", path=f"/user_credentials/{CREDENTIAL_ID}", method="DELETE", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [], "detail": "success", "has_next_page": False, "http_response_code": 200, "message": f"Deleted User Credential {CREDENTIAL_ID}", }, ), ) get_collection_list.py000066400000000000000000000046311513221403200351450ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverimport uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet identity_id = str(uuid.uuid4()) collection_ids = [str(uuid.uuid4()), str(uuid.uuid4())] gateway_ids = [str(uuid.uuid4()), str(uuid.uuid4())] display_names = ["Happy Fun Collection Name 1", "Happy Fun Collection Name 2"] RESPONSES = ResponseSet( metadata={ "identity_id": identity_id, "collection_ids": collection_ids, "gateway_ids": gateway_ids, "display_names": display_names, }, default=RegisteredResponse( service="gcs", path="/collections", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "detail": "success", "http_response_code": 200, "data": [ { "DATA_TYPE": "collection#1.0.0", "public": True, "id": collection_ids[0], "display_name": display_names[0], "identity_id": identity_id, "collection_type": "mapped", "storage_gateway_id": gateway_ids[0], "require_high_assurance": False, "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, }, { "DATA_TYPE": "collection#1.0.0", "public": True, "id": collection_ids[1], "display_name": display_names[1], "identity_id": identity_id, "collection_type": "mapped", "storage_gateway_id": gateway_ids[1], "require_high_assurance": False, "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, }, ], }, ), forbidden=RegisteredResponse( service="gcs", path="/collections", status=403, json={ "code": "permission_denied", "http_response_code": 403, "DATA_TYPE": "result#1.0.0", "detail": None, "message": "Could not list collections. Insufficient permissions", "data": [], "has_next_page": False, "marker": "", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_server/get_endpoint.py000066400000000000000000000023551513221403200336570ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet endpoint_id = str(uuid.uuid4()) gcs_manager_url = RegisteredResponse._url_map["gcs"] display_name = "Happy Fun Endpoint" RESPONSES = ResponseSet( metadata={ "endpoint_id": endpoint_id, "display_name": display_name, "gcs_manager_url": gcs_manager_url, }, default=RegisteredResponse( service="gcs", path="/endpoint", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "endpoint#1.2.0", "allow_udt": False, "contact_email": "user@globus.org", "display_name": display_name, "gcs_manager_url": gcs_manager_url, "gridftp_control_channel_port": 443, "id": endpoint_id, "network_use": "normal", "organization": "Globus", "public": True, "subscription_id": None, } ], "detail": "success", "has_next_page": False, "http_response_code": 200, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_server/get_gcs_info.py000066400000000000000000000022361513221403200336240ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet endpoint_client_id = str(uuid.uuid4()) domain_name = "abc.xyz.data.globus.org" gcs_manager_url = RegisteredResponse._url_map["gcs"] RESPONSES = ResponseSet( metadata={ "endpoint_client_id": endpoint_client_id, "domain_name": domain_name, }, default=RegisteredResponse( service="gcs", path="/info", json={ "DATA_TYPE": "result#1.1.0", "code": "success", "data": [ { "DATA_TYPE": "info#1.0.0", "api_version": "1.29.0", "client_id": endpoint_client_id, "domain_name": domain_name, "endpoint_id": endpoint_client_id, "manager_version": "5.4.76-rc3", }, { "DATA_TYPE": "connector#1.1.0", "display_name": "POSIX", "id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "is_baa": False, "is_ha": False, }, ], }, ), ) get_storage_gateway.py000066400000000000000000000032331513221403200351410ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", "display_name": "example gateway 1", } RESPONSES = ResponseSet( metadata=metadata, default=RegisteredResponse( service="gcs", path=f"/storage_gateways/{metadata['id']}", json={ "DATA_TYPE": "result#1.0.0", "http_response_code": 200, "detail": "success", "message": "Operation successful", "code": "success", "data": [ { "DATA_TYPE": "storage_gateway#1.0.0", "id": metadata["id"], "display_name": metadata["display_name"], "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, "allowed_domains": ["example.edu"], "mapping": "username_without_domain", "require_high_assurance": False, "restrict_paths": { "DATA_TYPE": "path_restrictions#1.0.0", "read": ["/"], }, "policies": { "DATA_TYPE": "posix_storage_gateway#1.0.0", "groups_allow": ["globus"], "groups_deny": ["nonglobus"], }, "users_allow": ["user1"], "users_deny": ["user2"], } ], }, ), ) get_storage_gateway_list.py000066400000000000000000000047361513221403200362050ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet GATEWAY_IDS = [ "a0cbde58-0183-11ea-92bd-9cb6d0d9fd63", "6840c8ba-eb98-11e9-b89c-9cb6d0d9fd63", ] RESPONSES = ResponseSet( metadata={"ids": GATEWAY_IDS}, default=RegisteredResponse( service="gcs", path="/storage_gateways", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "detail": "success", "http_response_code": 200, "data": [ { "DATA_TYPE": "storage_gateway#1.0.0", "id": GATEWAY_IDS[0], "display_name": "example gateway 1", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, "allowed_domains": ["example.edu"], "mapping": "username_without_domain", "require_high_assurance": False, "restrict_paths": { "DATA_TYPE": "path_restrictions#1.0.0", "read": ["/"], }, "policies": { "DATA_TYPE": "posix_storage_gateway#1.0.0", "groups_allow": ["globus"], "groups_deny": ["nonglobus"], }, "users_allow": ["user1"], "users_deny": ["user2"], }, { "DATA_TYPE": "storage_gateway#1.0.0", "id": GATEWAY_IDS[1], "display_name": "example gateway 2", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, "allowed_domains": ["example.edu"], "mapping": "username_without_domain", "require_high_assurance": False, "policies": { "DATA_TYPE": "posix_storage_gateway#1.0.0", "groups_allow": [], "groups_deny": [], }, "users_allow": [], "users_deny": [], }, ], }, ), ) get_user_credential.py000066400000000000000000000022641513221403200351270ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" RESPONSES = ResponseSet( metadata={"id": CREDENTIAL_ID}, default=RegisteredResponse( service="gcs", path=f"/user_credentials/{CREDENTIAL_ID}", method="GET", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "user_credential#1.0.0", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "display_name": "posix_credential", "id": CREDENTIAL_ID, "identity_id": "948847d4-ffcc-4ae0-ba3a-a4c88d480159", "invalid": False, "policies": {"DATA_TYPE": "posix_user_credential_policies#1.0.0"}, "provisioned": False, "storage_gateway_id": "82247cc9-3208-4d71-bd7f-1b8798c95e6b", "username": "testuser", }, ], "detail": "success", "has_next_page": False, "http_response_code": 200, }, ), ) get_user_credential_list.py000066400000000000000000000040271513221403200361610ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_IDS = [ "af43d884-64a1-4414-897a-680c32374439", "c96b8f70-1448-46db-89af-292623c93ee4", ] RESPONSES = ResponseSet( metadata={"ids": CREDENTIAL_IDS}, default=RegisteredResponse( service="gcs", path="/user_credentials", method="GET", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "user_credential#1.0.0", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "display_name": "posix_credential", "id": CREDENTIAL_IDS[0], "identity_id": "948847d4-ffcc-4ae0-ba3a-a4c88d480159", "invalid": False, "policies": {"DATA_TYPE": "posix_user_credential_policies#1.0.0"}, "provisioned": False, "storage_gateway_id": "82247cc9-3208-4d71-bd7f-1b8798c95e6b", "username": "testuser", }, { "DATA_TYPE": "user_credential#1.0.0", "connector_id": "7643e831-5f6c-4b47-a07f-8ee90f401d23", "display_name": "s3_credential", "id": CREDENTIAL_IDS[1], "identity_id": "948847d4-ffcc-4ae0-ba3a-a4c88d480159", "invalid": False, "policies": { "DATA_TYPE": "s3_user_credential_policies#1.0.0", "s3_key_id": "key_id", "s3_secret_key": "key_secret", }, "provisioned": True, "storage_gateway_id": "99aab7ac-8fde-40e2-b6db-44de8e59597a", "username": "testuser", }, ], "detail": "success", "has_next_page": False, "http_response_code": 200, }, metadata={"ids": CREDENTIAL_IDS}, ), ) update_endpoint.py000066400000000000000000000033301513221403200342750ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverimport uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet endpoint_id = str(uuid.uuid4()) gcs_manager_url = RegisteredResponse._url_map["gcs"] display_name = "Happy Fun Endpoint" RESPONSES = ResponseSet( metadata={ "endpoint_id": endpoint_id, "display_name": display_name, "gcs_manager_url": gcs_manager_url, }, default=RegisteredResponse( service="gcs", method="PATCH", path="/endpoint", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [], "detail": "success", "has_next_page": False, "http_response_code": 200, "message": f"Updated endpoint {endpoint_id}", }, ), include_endpoint=RegisteredResponse( service="gcs", method="PATCH", path="/endpoint", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "endpoint#1.2.0", "allow_udt": False, "contact_email": "user@globus.org", "display_name": display_name, "gcs_manager_url": gcs_manager_url, "gridftp_control_channel_port": 443, "id": endpoint_id, "network_use": "normal", "organization": "Globus", "public": True, "subscription_id": None, } ], "detail": "success", "has_next_page": False, "http_response_code": 200, "message": f"Updated endpoint {endpoint_id}", }, ), ) update_storage_gateway.py000066400000000000000000000032631513221403200356470ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", "display_name": "example gateway 1", } RESPONSES = ResponseSet( metadata=metadata, default=RegisteredResponse( service="gcs", method="PATCH", path=f"/storage_gateways/{metadata['id']}", json={ "DATA_TYPE": "result#1.0.0", "http_response_code": 200, "detail": "success", "message": "Operation successful", "code": "success", "data": [ { "DATA_TYPE": "storage_gateway#1.0.0", "id": metadata["id"], "display_name": metadata["display_name"], "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "require_high_assurance": False, "high_assurance": False, "authentication_assurance_timeout": 15840, "authentication_timeout_mins": 15840, "allowed_domains": ["example.edu"], "mapping": "username_without_domain", "restrict_paths": { "DATA_TYPE": "path_restrictions#1.0.0", "read": ["/"], }, "policies": { "DATA_TYPE": "posix_storage_gateway#1.0.0", "groups_allow": ["globus"], "groups_deny": ["nonglobus"], }, "users_allow": ["user1"], "users_deny": ["user2"], } ], }, ), ) update_user_credential.py000066400000000000000000000024011513221403200356230ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/globus_connect_serverfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" RESPONSES = ResponseSet( metadata={"id": CREDENTIAL_ID}, default=RegisteredResponse( service="gcs", path=f"/user_credentials/{CREDENTIAL_ID}", method="PATCH", json={ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "user_credential#1.0.0", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "display_name": "updated_posix_credential", "id": CREDENTIAL_ID, "identity_id": "948847d4-ffcc-4ae0-ba3a-a4c88d480159", "invalid": False, "policies": {"DATA_TYPE": "posix_user_credential_policies#1.0.0"}, "provisioned": False, "storage_gateway_id": "82247cc9-3208-4d71-bd7f-1b8798c95e6b", "username": "testuser", }, ], "detail": "success", "has_next_page": False, "http_response_code": 200, "message": f"Updated User Credential {CREDENTIAL_ID}", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/000077500000000000000000000000001513221403200255465ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/__init__.py000066400000000000000000000000001513221403200276450ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/_common.py000066400000000000000000000056011513221403200275510ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid GROUP_ID = str(uuid.uuid1()) USER_ID = str(uuid.uuid1()) SUBSCRIPTION_ID = str(uuid.uuid1()) SUBSCRIPTION_GROUP_ID = str(uuid.uuid1()) SUBSCRIPTION_INFO = { "name": "Golems R Us", "subscriber_name": "University of Chicago Golem Lab", "is_high_assurance": True, "is_baa": False, "connectors": { "052be037-7dda-4d20-b163-3077314dc3e6": {"is_ha": True, "is_baa": False}, "1b6374b0-f6a4-4cf7-a26f-f262d9c6ca72": {"is_ha": True, "is_baa": False}, "28ef55da-1f97-11eb-bdfd-12704e0d6a4d": {"is_ha": True, "is_baa": False}, "49b00fd6-63f1-48ae-b27f-d8af4589f876": {"is_ha": True, "is_baa": False}, "56366b96-ac98-11e9-abac-9cb6d0d9fd63": {"is_ha": True, "is_baa": False}, "7251f6c8-93c9-11eb-95ba-12704e0d6a4d": {"is_ha": True, "is_baa": False}, "7643e831-5f6c-4b47-a07f-8ee90f401d23": {"is_ha": True, "is_baa": False}, "7c100eae-40fe-11e9-95a3-9cb6d0d9fd63": {"is_ha": True, "is_baa": False}, "7e3f3f5e-350c-4717-891a-2f451c24b0d4": {"is_ha": True, "is_baa": False}, "9436da0c-a444-11eb-af93-12704e0d6a4d": {"is_ha": True, "is_baa": False}, "976cf0cf-78c3-4aab-82d2-7c16adbcc281": {"is_ha": True, "is_baa": False}, "e47b6920-ff57-11ea-8aaa-000c297ab3c2": {"is_ha": True, "is_baa": False}, "fb656a17-0f69-4e59-95ff-d0a62ca7bdf5": {"is_ha": True, "is_baa": False}, }, } BASE_GROUP_DOC: dict[str, t.Any] = { "name": "Claptrap's Rough Riders", "description": "No stairs allowed.", "parent_id": None, "id": GROUP_ID, "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": GROUP_ID, "identity_id": USER_ID, "username": "claptrap@globus.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "private", "group_members_visibility": "managers", }, "subscription_id": None, "subscription_info": None, } SUBSCRIPTION_GROUP_DOC: dict[str, t.Any] = { "name": "University of Chicago, Department of Magical Automata", "description": "The finest in machines that go 'ping!' and 'whomp!' and 'bzzt!'", "parent_id": None, "id": SUBSCRIPTION_GROUP_ID, "group_type": "plus", "enforce_session": True, "session_limit": 1800, "session_timeouts": {}, "my_memberships": [ { "group_id": SUBSCRIPTION_GROUP_ID, "identity_id": USER_ID, "username": "claptrap@globus.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "private", "group_members_visibility": "managers", }, "subscription_id": SUBSCRIPTION_ID, "subscription_info": SUBSCRIPTION_INFO, } globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/create_group.py000066400000000000000000000005061513221403200306000ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import BASE_GROUP_DOC, GROUP_ID RESPONSES = ResponseSet( metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", path="/v2/groups", method="POST", json=BASE_GROUP_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/delete_group.py000066400000000000000000000005241513221403200305770ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import BASE_GROUP_DOC, GROUP_ID RESPONSES = ResponseSet( metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", path=f"/v2/groups/{GROUP_ID}", method="DELETE", json=BASE_GROUP_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/get_group.py000066400000000000000000000012601513221403200301120ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ( BASE_GROUP_DOC, GROUP_ID, SUBSCRIPTION_GROUP_DOC, SUBSCRIPTION_GROUP_ID, SUBSCRIPTION_ID, ) RESPONSES = ResponseSet( metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", path=f"/v2/groups/{GROUP_ID}", json=BASE_GROUP_DOC, ), subscription=RegisteredResponse( service="groups", path=f"/v2/groups/{SUBSCRIPTION_GROUP_ID}", json=SUBSCRIPTION_GROUP_DOC, metadata={ "group_id": SUBSCRIPTION_GROUP_ID, "subscription_id": SUBSCRIPTION_ID, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/get_group_by_subscription_id.py000066400000000000000000000013721513221403200340700ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBSCRIPTION_GROUP_ID, SUBSCRIPTION_ID, SUBSCRIPTION_INFO RESPONSES = ResponseSet( metadata={"group_id": SUBSCRIPTION_GROUP_ID, "subscription_id": SUBSCRIPTION_ID}, default=RegisteredResponse( service="groups", path=f"/v2/subscription_info/{SUBSCRIPTION_ID}", json={ "group_id": SUBSCRIPTION_GROUP_ID, "subscription_id": SUBSCRIPTION_ID, # this API returns restricted subscription_info "subscription_info": { k: v for k, v in SUBSCRIPTION_INFO.items() if k in ("is_high_assurance", "is_baa", "connectors") }, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/get_my_groups.py000066400000000000000000000131751513221403200310120ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk.testing.models import RegisteredResponse, ResponseSet raw_data: list[dict[str, t.Any]] = [ { "name": "Claptrap's Rough Riders", "parent_id": None, "id": "d3974728-6458-11e4-b72d-123139141556", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "d3974728-6458-11e4-b72d-123139141556", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "private", "group_members_visibility": "managers", }, }, { "name": "duke", "parent_id": "fdb38a24-03c1-11e3-86f7-12313809f035", "id": "7c580b9a-4592-11e3-a2a0-12313d2d6e7f", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "7c580b9a-4592-11e3-a2a0-12313d2d6e7f", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "authenticated", "group_members_visibility": "members", }, }, { "name": "kbase_users", "parent_id": None, "id": "99d2a548-7218-11e2-adc0-12313d2d6e7f", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "99d2a548-7218-11e2-adc0-12313d2d6e7f", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "authenticated", "group_members_visibility": "members", }, }, { "name": "connect", "parent_id": None, "id": "fdb38a24-03c1-11e3-86f7-12313809f035", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "fdb38a24-03c1-11e3-86f7-12313809f035", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "authenticated", "group_members_visibility": "members", }, }, { "name": "sirosen's Email Testing Group", "parent_id": None, "id": "b0d168b0-6398-11e4-ac82-12313b077182", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "b0d168b0-6398-11e4-ac82-12313b077182", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "admin", "status": "active", } ], "policies": { "group_visibility": "private", "group_members_visibility": "managers", }, }, { "name": "osg", "parent_id": "fdb38a24-03c1-11e3-86f7-12313809f035", "id": "80321e42-41a3-11e3-bef1-12313d2d6e7f", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "80321e42-41a3-11e3-bef1-12313d2d6e7f", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "member", "status": "active", } ], "policies": { "group_visibility": "authenticated", "group_members_visibility": "members", }, }, { "name": "Search Examples: Cookery", "parent_id": None, "id": "0a4dea26-44cd-11e8-847f-0e6e723ad808", "group_type": "regular", "enforce_session": False, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "0a4dea26-44cd-11e8-847f-0e6e723ad808", "identity_id": "ae341a98-d274-11e5-b888-dbae3a8ba545", "username": "sirosen@globus.org@accounts.google.com", "role": "admin", "status": "active", } ], "policies": { "group_visibility": "authenticated", "group_members_visibility": "managers", }, }, ] group_ids = [x["id"] for x in raw_data] group_names = [x["name"] for x in raw_data] member_ids = { group["id"]: [m["identity_id"] for m in group["my_memberships"]] for group in raw_data } RESPONSES = ResponseSet( metadata={ "group_ids": group_ids, "group_names": group_names, "member_ids": member_ids, }, default=RegisteredResponse( service="groups", path="/v2/groups/my_groups", json=raw_data, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groups/set_group_policies.py000066400000000000000000000011241513221403200320140ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import GROUP_ID RESPONSES = ResponseSet( metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", path=f"/v2/groups/{GROUP_ID}/policies", method="PUT", json={ "is_high_assurance": False, "authentication_assurance_timeout": 28800, "group_visibility": "private", "group_members_visibility": "managers", "join_requests": False, "signup_fields": ["address1"], }, ), ) set_subscription_admin_verified.py000066400000000000000000000007601513221403200344700ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/groupsfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import GROUP_ID, SUBSCRIPTION_ID RESPONSES = ResponseSet( metadata={"group_id": GROUP_ID, "subscription_id": SUBSCRIPTION_ID}, default=RegisteredResponse( service="groups", path=f"/v2/groups/{GROUP_ID}/subscription_admin_verified", method="PUT", json={ "group_id": GROUP_ID, "subscription_admin_verified_id": SUBSCRIPTION_ID, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/000077500000000000000000000000001513221403200254745ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/__init__.py000066400000000000000000000000001513221403200275730ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/batch_delete_by_subject.py000066400000000000000000000006341513221403200326650ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid1()) TASK_ID = str(uuid.uuid1()) RESPONSES = ResponseSet( metadata={"index_id": INDEX_ID, "task_id": TASK_ID}, default=RegisteredResponse( service="search", path=f"/v1/index/{INDEX_ID}/batch_delete_by_subject", method="POST", json={"task_id": TASK_ID}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/create_index.py000066400000000000000000000032741513221403200305060ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="search", method="POST", path="/v1/index", json={ "@datatype": "GSearchIndex", "@version": "2017-09-01", "creation_date": "2021-04-05 15:05:18", "display_name": "Awesome Index of Awesomeness", "description": "An index so awesome that it simply cannot be described", "id": INDEX_ID, "is_trial": True, "subscription_id": None, "max_size_in_mb": 1, "num_entries": 0, "num_subjects": 0, "size_in_mb": 0, "status": "open", }, metadata={"index_id": INDEX_ID}, ), trial_limit=RegisteredResponse( service="search", method="POST", path="/v1/index", status=409, json={ "@datatype": "GError", "request_id": "38186e960f3a64c9d530d48ba2271285", "status": 409, "error_data": { "cause": ( "When creating an index, an 'owner' role is created " "automatically. If this would exceed ownership limits, this error " "is raised instead." ), "constraint": ( "Cannot create more ownership roles on trial indices " "than the limit (3)" ), }, "@version": "2017-09-01", "message": "Role limit exceeded", "code": "Conflict.LimitExceeded", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/create_role.py000066400000000000000000000015771513221403200303440ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" IDENTITY_ID = "46bd0f56-e24f-11e5-a510-131bef46955c" ROLE_ID = "MDQ1MzAy" RESPONSES = ResponseSet( metadata={"index_id": INDEX_ID, "identity_id": IDENTITY_ID, "role_id": ROLE_ID}, default=RegisteredResponse( service="search", path=f"/v1/index/{INDEX_ID}/role", method="POST", json={ "creation_date": "2022-01-26 21:53:06", "id": ROLE_ID, "index_id": INDEX_ID, "principal": f"urn:globus:auth:identity:{IDENTITY_ID}", "principal_type": "identity", "role_name": "writer", }, metadata={ "index_id": INDEX_ID, "identity_id": IDENTITY_ID, "role_id": ROLE_ID, "role_name": "writer", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/delete_index.py000066400000000000000000000017571513221403200305110ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="search", method="DELETE", path=f"/v1/index/{INDEX_ID}", json={ "index_id": INDEX_ID, "acknowledged": True, }, metadata={"index_id": INDEX_ID}, ), delete_pending=RegisteredResponse( service="search", method="DELETE", path=f"/v1/index/{INDEX_ID}", status=409, json={ "@datatype": "GError", "request_id": "3430ce9a5f9d929ef7682e4c58363dee", "status": 409, "@version": "2017-09-01", "message": ( "Index status (delete_pending) did not match required status " "for this operation: open" ), "code": "Conflict.IncompatibleIndexStatus", }, metadata={"index_id": INDEX_ID}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/delete_role.py000066400000000000000000000014751513221403200303400ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" IDENTITY_ID = "46bd0f56-e24f-11e5-a510-131bef46955c" ROLE_ID = "MDMwMjM5" RESPONSES = ResponseSet( metadata={"index_id": INDEX_ID, "identity_id": IDENTITY_ID, "role_id": ROLE_ID}, default=RegisteredResponse( service="search", path=f"/v1/index/{INDEX_ID}/role/{ROLE_ID}", method="DELETE", json={ "deleted": { "creation_date": "2022-01-26 21:53:06", "id": ROLE_ID, "index_id": INDEX_ID, "principal": f"urn:globus:auth:identity:{IDENTITY_ID}", "principal_type": "identity", "role_name": "writer", }, "success": True, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/get_role_list.py000066400000000000000000000023571513221403200307100ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" IDENTITY_IDS = [ "ae332d86-d274-11e5-b885-b31714a110e9", "c699d42e-d274-11e5-bf75-1fc5bf53bb24", ] ROLE_IDS = ["MDMwMjM5", "MDQ0ODYz"] RESPONSES = ResponseSet( metadata={"index_id": INDEX_ID, "identity_ids": IDENTITY_IDS, "role_ids": ROLE_IDS}, default=RegisteredResponse( service="search", path=f"/v1/index/{INDEX_ID}/role_list", json={ "role_list": [ { "creation_date": "2021-11-09 20:26:45", "id": ROLE_IDS[0], "index_id": INDEX_ID, "principal": "urn:globus:auth:identity:" + IDENTITY_IDS[0], "principal_type": "identity", "role_name": "owner", }, { "creation_date": "2022-01-24 15:33:41", "id": ROLE_IDS[1], "index_id": INDEX_ID, "principal": "urn:globus:auth:identity:" + IDENTITY_IDS[1], "principal_type": "identity", "role_name": "writer", }, ] }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/index_list.py000066400000000000000000000031551513221403200302140ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_IDS = [str(uuid.uuid1()), str(uuid.uuid1())] INDEX_ATTRIBUTES: dict[str, dict[str, t.Any]] = { INDEX_IDS[0]: { "subscription_id": None, "creation_date": "2038-07-17 16:48:24", "display_name": "Index of Indexed Awesomeness", "description": "Turbo Awesome", "max_size_in_mb": 1, "size_in_mb": 0, "num_subjects": 0, "num_entries": 0, "permissions": ["owner"], }, INDEX_IDS[1]: { "subscription_id": str(uuid.uuid1()), "creation_date": "2470-10-11 20:09:40", "display_name": "Catalog of encyclopediae", "description": "Encyclopediae from Britannica to Wikipedia", "max_size_in_mb": 100, "size_in_mb": 23, "num_subjects": 1822, "num_entries": 3644, "permissions": ["writer"], }, } def _make_doc(index_id: str) -> dict[str, t.Any]: attrs = INDEX_ATTRIBUTES[index_id] return { "@datatype": "GSearchIndex", "@version": "2017-09-01", "id": index_id, "is_trial": False if attrs["subscription_id"] else True, "status": "open", **attrs, } RESPONSES = ResponseSet( metadata={"index_ids": INDEX_IDS, "index_attributes": INDEX_ATTRIBUTES}, default=RegisteredResponse( service="search", path="/v1/index_list", json={ "index_list": [ _make_doc(INDEX_IDS[0]), _make_doc(INDEX_IDS[1]), ] }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/post_search.py000066400000000000000000000017641513221403200303700ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" RESPONSES = ResponseSet( metadata={"index_id": INDEX_ID}, default=RegisteredResponse( service="search", path=f"/v1/index/{INDEX_ID}/search", method="POST", json={ "@datatype": "GSearchResult", "@version": "2017-09-01", "count": 1, "gmeta": [ { "@datatype": "GMetaResult", "@version": "2019-08-27", "entries": [ { "content": {"foo": "bar"}, "entry_id": None, "matched_principal_sets": [], } ], "subject": "foo-bar", } ], "has_next_page": True, "offset": 0, "total": 10, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/reopen_index.py000066400000000000000000000017671513221403200305400ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="search", method="POST", path=f"/v1/index/{INDEX_ID}/reopen", json={ "index_id": INDEX_ID, "acknowledged": True, }, metadata={"index_id": INDEX_ID}, ), already_open=RegisteredResponse( service="search", method="POST", path=f"/v1/index/{INDEX_ID}/reopen", status=409, json={ "code": "Conflict.IncompatibleIndexStatus", "request_id": "e1ad6822156dea372027eee48c16e150", "@datatype": "GError", "message": ( "Index status (open) did not match required status for " "this operation: delete_pending" ), "@version": "2017-09-01", "status": 409, }, metadata={"index_id": INDEX_ID}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/search.py000066400000000000000000000017351513221403200273210ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" RESPONSES = ResponseSet( metadata={"index_id": INDEX_ID}, default=RegisteredResponse( service="search", path=f"/v1/index/{INDEX_ID}/search", json={ "@datatype": "GSearchResult", "@version": "2017-09-01", "count": 1, "gmeta": [ { "@datatype": "GMetaResult", "@version": "2019-08-27", "entries": [ { "content": {"foo": "bar"}, "entry_id": None, "matched_principal_sets": [], } ], "subject": "foo-bar", } ], "has_next_page": True, "offset": 0, "total": 10, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/search/update_index.py000066400000000000000000000036341513221403200305250ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) _default_display_name = "Awesome Index of Awesomeness" _default_description = "An index so awesome that it simply cannot be described" RESPONSES = ResponseSet( default=RegisteredResponse( service="search", method="PATCH", path=f"/v1/index/{INDEX_ID}", json={ "@datatype": "GSearchIndex", "@version": "2017-09-01", "creation_date": "2021-04-05 15:05:18", "display_name": _default_display_name, "description": _default_description, "id": INDEX_ID, "is_trial": True, "subscription_id": None, "max_size_in_mb": 1, "num_entries": 0, "num_subjects": 0, "size_in_mb": 0, "status": "open", }, metadata={"index_id": INDEX_ID, "display_name": _default_display_name}, ), forbidden=RegisteredResponse( service="search", method="PATCH", path=f"/v1/index/{INDEX_ID}", status=403, json={ "@datatype": "GError", "@version": "2017-09-01", "status": 403, "code": "Forbidden.Generic", "message": "index_update request denied by service", "request_id": "0e73b6a61e53468684f86c7993336a72", "error_data": { "cause": ( "You do not have the proper roles " "to perform the index_update operation." ), "recommended_resolution": ( "Ensure you are making a call authenticated with " "a valid Search token and that you have been granted " "the required roles for this operation" ), }, }, metadata={"index_id": INDEX_ID}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/000077500000000000000000000000001513221403200253475ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/__init__.py000066400000000000000000000000001513221403200274460ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/_common.py000066400000000000000000000141241513221403200273520ustar00rootroot00000000000000import typing as t import uuid TIMER_ID = str(uuid.uuid1()) DEST_EP_ID = str(uuid.uuid1()) SOURCE_EP_ID = str(uuid.uuid1()) V2_TRANSFER_TIMER = { "body": { "DATA": [ { "DATA_TYPE": "transfer_item", "destination_path": "/~/dst.txt", "source_path": "/share/godata/file1.txt", } ], "DATA_TYPE": "transfer", "delete_destination_extra": False, "destination_endpoint": DEST_EP_ID, "encrypt_data": False, "fail_on_quota_errors": False, "notify_on_failed": True, "notify_on_inactive": True, "notify_on_succeeded": True, "preserve_timestamp": False, "skip_source_errors": False, "source_endpoint": SOURCE_EP_ID, "store_base_path_info": False, "verify_checksum": True, }, "inactive_reason": None, "job_id": TIMER_ID, "last_ran_at": None, "name": "Very Cool Timer", "next_run": "2023-10-27T05:00:00+00:00", "number_of_errors": 0, "number_of_runs": 0, "schedule": { "type": "recurring", "end": {"count": 2}, "interval_seconds": 604800, "start": "2023-10-27T05:00:00+00:00", }, "status": "new", "submitted_at": "2023-10-26T20:31:09+00:00", "timer_type": "transfer", } FLOW_ID = str(uuid.uuid4()) V2_FLOW_TIMER: dict[str, t.Any] = { "callback_body": { "body": {"input_key": "input_value"}, "run_managers": [f"urn:globus:auth:identity:{uuid.uuid4()}"], }, "callback_url": f"https://flows.automate.globus.org/flows/{FLOW_ID}/run", "inactive_reason": None, "interval": None, "job_id": str(uuid.uuid4()), "last_ran_at": None, "n_errors": 0, "n_runs": 0, "name": "Very Cool Timer", "next_run": "2025-10-27T05:00:00+00:00", "results": [], "schedule": { "datetime": "2025-10-27T05:00:00+00:00", "type": "once", }, "scope": ( f"https://auth.globus.org/scopes/{FLOW_ID}" f"/flow_{FLOW_ID.replace('-', '_')}_user" ), "status": "new", "stop_after": { "date": None, "n_runs": 1, }, "submitted_at": "2025-08-01T20:31:09+00:00", "timer_type": "flow", } # V1 API data _transfer_data: dict[str, t.Any] = { "data": { "action_id": "15jfdBESgveZQ", "completion_time": "2022-04-01T19:30:05.973261+00:00", "creator_id": "urn:globus:auth:identity:5276fa05-eedf-46c5-919f-ad2d0160d1a9", "details": { "DATA_TYPE": "task", "bytes_checksummed": 0, "bytes_transferred": 0, "canceled_by_admin": None, "canceled_by_admin_message": None, "command": "API 0.10", "completion_time": None, "deadline": "2022-04-02T19:30:07+00:00", "delete_destination_extra": False, "destination_endpoint": "globus#dest_ep", "destination_endpoint_display_name": "Some Dest Endpoint", "destination_endpoint_id": DEST_EP_ID, "directories": 0, "effective_bytes_per_second": 0, "encrypt_data": False, "event_list": [], "fail_on_quota_errors": False, "fatal_error": None, "faults": 0, "files": 0, "files_skipped": 0, "files_transferred": 0, "filter_rules": None, "history_deleted": False, "is_ok": True, "is_paused": False, "label": "example timer, run 1", "nice_status": "Queued", "nice_status_details": None, "nice_status_expires_in": -1, "nice_status_short_description": "Queued", "owner_id": "5276fa05-eedf-46c5-919f-ad2d0160d1a9", "preserve_timestamp": False, "recursive_symlinks": "ignore", "request_time": "2022-04-01T19:30:07+00:00", "skip_source_errors": True, "source_endpoint": "globus#src_ep", "source_endpoint_display_name": "Some Source Endpoint", "source_endpoint_id": SOURCE_EP_ID, "status": "ACTIVE", "subtasks_canceled": 0, "subtasks_expired": 0, "subtasks_failed": 0, "subtasks_pending": 1, "subtasks_retrying": 0, "subtasks_skipped_errors": 0, "subtasks_succeeded": 0, "subtasks_total": 1, "symlinks": 0, "sync_level": 3, "task_id": "22f0148c-b1f2-11ec-b87e-3912f602f346", "type": "TRANSFER", "username": "u_kj3pubpo35dmlem7vuwqcygrve", "verify_checksum": True, }, "display_status": "ACTIVE", "label": None, "manage_by": [], "monitor_by": [], "release_after": "P30D", "start_time": "2022-04-01T19:30:05.973232+00:00", "status": "ACTIVE", }, "errors": None, "status": 202, "ran_at": "2022-04-01T19:30:07.103090", } V1_TIMER = { "name": "example timer", "start": "2022-04-01T19:30:00+00:00", "stop_after": None, "interval": 864000.0, "callback_url": "https://actions.automate.globus.org/transfer/transfer/run", "callback_body": { "body": { "label": "example timer", "skip_source_errors": True, "sync_level": 3, "verify_checksum": True, "source_endpoint_id": "aa752cea-8222-5bc8-acd9-555b090c0ccb", "destination_endpoint_id": "313ce13e-b597-5858-ae13-29e46fea26e6", "transfer_items": [ { "source_path": "/share/godata/file1.txt", "destination_path": "/~/file1.txt", "recursive": False, } ], } }, "inactive_reason": None, "scope": None, "job_id": TIMER_ID, "status": "loaded", "submitted_at": "2022-04-01T19:29:55.942546+00:00", "last_ran_at": "2022-04-01T19:30:07.103090+00:00", "next_run": "2022-04-11T19:30:00+00:00", "n_runs": 1, "n_errors": 0, "results": {"data": [_transfer_data], "page_next": None}, } globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/create_job.py000066400000000000000000000021201513221403200300110ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER RESPONSES = ResponseSet( metadata={"job_id": TIMER_ID}, default=RegisteredResponse( service="timer", path="/jobs/", method="POST", json=V1_TIMER, status=201, ), validation_error=RegisteredResponse( service="timer", path="/jobs/", method="POST", json={ "detail": [ { "loc": ["body", "start"], "msg": "field required", "type": "value_error.missing", }, { "loc": ["body", "callback_url"], "msg": "field required", "type": "value_error.missing", }, ] }, metadata={ "job_id": TIMER_ID, "expect_messages": [ "field required: body.start", "field required: body.callback_url", ], }, status=422, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/create_timer.py000066400000000000000000000017641513221403200303740ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ( DEST_EP_ID, FLOW_ID, SOURCE_EP_ID, TIMER_ID, V2_FLOW_TIMER, V2_TRANSFER_TIMER, ) RESPONSES = ResponseSet( default=RegisteredResponse( service="timer", path="/v2/timer", method="POST", json={ "timer": V2_TRANSFER_TIMER, }, status=201, metadata={ "timer_id": TIMER_ID, "source_endpoint": SOURCE_EP_ID, "destination_endpoint": DEST_EP_ID, }, ), flow_timer_success=RegisteredResponse( service="timer", path="/v2/timer", method="POST", json={ "timer": V2_FLOW_TIMER, }, status=201, metadata={ "timer_id": V2_FLOW_TIMER["job_id"], "flow_id": FLOW_ID, "callback_body": V2_FLOW_TIMER["callback_body"], "schedule": V2_FLOW_TIMER["schedule"], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/delete_job.py000066400000000000000000000005001513221403200300100ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER RESPONSES = ResponseSet( metadata={"job_id": TIMER_ID}, default=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}", method="DELETE", json=V1_TIMER, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/get_job.py000066400000000000000000000035251513221403200273370ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER TIMER_INACTIVE_USER = { **V1_TIMER, "status": "inactive", "inactive_reason": {"cause": "user", "detail": None}, } TIMER_INACTIVE_GARE = { **V1_TIMER, "status": "inactive", "inactive_reason": { "cause": "globus_auth_requirements", "detail": { "code": "ConsentRequired", "authorization_parameters": { "session_message": "Missing required data_access consent", "required_scopes": [ ( "https://auth.globus.org/scopes/actions.globus.org/" "transfer/transfer" "[urn:globus:auth:scope:transfer.api.globus.org:all" "[*https://auth.globus.org/scopes/" "543aade1-db97-4a4b-9bdf-0b58e78dfa69/data_access]]" ) ], }, }, }, } RESPONSES = ResponseSet( metadata={"job_id": TIMER_ID}, default=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}", method="GET", json=V1_TIMER, ), inactive_gare=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}", method="GET", json=TIMER_INACTIVE_GARE, ), inactive_user=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}", method="GET", json=TIMER_INACTIVE_USER, ), simple_500_error=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}", method="GET", status=500, json={ "error": { "code": "ERROR", "detail": "Request failed terribly", "status": 500, } }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/list_jobs.py000066400000000000000000000005011513221403200277050ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER RESPONSES = ResponseSet( metadata={"job_ids": [TIMER_ID]}, default=RegisteredResponse( service="timer", path="/jobs/", method="GET", json={"jobs": [V1_TIMER]}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/pause_job.py000066400000000000000000000005451513221403200276740ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID RESPONSES = ResponseSet( metadata={"job_id": TIMER_ID}, default=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}/pause", method="POST", json={"message": f"Successfully paused job {TIMER_ID}."}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/resume_job.py000066400000000000000000000005471513221403200300610ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID RESPONSES = ResponseSet( metadata={"job_id": TIMER_ID}, default=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}/resume", method="POST", json={"message": f"Successfully resumed job {TIMER_ID}."}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/timer/update_job.py000066400000000000000000000007471513221403200300450ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER UPDATED_NAME = "updated name" UPDATED_TIMER = dict(V1_TIMER) UPDATED_TIMER["name"] = UPDATED_NAME # mypy complains if this is onelinerized RESPONSES = ResponseSet( metadata={"job_id": TIMER_ID, "name": UPDATED_NAME}, default=RegisteredResponse( service="timer", path=f"/jobs/{TIMER_ID}", method="PATCH", json=UPDATED_TIMER, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/000077500000000000000000000000001513221403200260535ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/__init__.py000066400000000000000000000000001513221403200301520ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/_common.py000066400000000000000000000003631513221403200300560ustar00rootroot00000000000000import uuid def _as_uuid(s: str) -> str: return str(uuid.UUID(int=int(s, 36))) SUBMISSION_ID = _as_uuid("submission_id") ENDPOINT_ID = _as_uuid("endpoint_id") TASK_ID = _as_uuid("task_id") SUBSCRIPTION_ID = _as_uuid("subscription_id") globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/cancel_task.py000066400000000000000000000010621513221403200306730ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet TASK_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( metadata={"task_id": TASK_ID}, default=RegisteredResponse( service="transfer", path=f"/v0.10/task/{TASK_ID}/cancel", method="POST", json={ "DATA_TYPE": "result", "code": "Canceled", "message": "The task has been cancelled successfully.", "resource": f"/task/{TASK_ID}/cancel", "request_id": "ABCdef789", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/create_tunnel.py000066400000000000000000000036761513221403200312710ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet TUNNEL_ID = str(uuid.uuid4()) _initiator_ap = str(uuid.uuid4()) _listener_ap = str(uuid.uuid4()) _default_display_name = "Test Tunnel" RESPONSES = ResponseSet( default=RegisteredResponse( service="transfer", method="POST", path="/v2/tunnels", json={ "data": { "attributes": { "created_time": "2025-12-12T21:49:22.183977", "initiator_ip_address": None, "initiator_port": None, "label": _default_display_name, "lifetime_mins": 10, "listener_ip_address": None, "listener_port": None, "restartable": False, "state": "AWAITING_LISTENER", "status": "The tunnel is waiting for listening.", "submission_id": "6ab42cda-d7a4-11f0-ad34-0affc202d2e9", }, "id": "34d97133-f17e-4f90-ad42-56ff5f3c2550", "relationships": { "initiator": { "data": {"id": _initiator_ap, "type": "StreamAccessPoint"} }, "listener": { "data": {"id": _listener_ap, "type": "StreamAccessPoint"} }, "owner": { "data": { "id": "4d443580-012d-4954-816f-e0592bd356e1", "type": "Identity", } }, }, "type": "Tunnel", }, "meta": {"request_id": "e6KkKkNmw"}, }, metadata={ "tunnel_id": TUNNEL_ID, "display_name": _default_display_name, "initiator_ap": _initiator_ap, "listener_ap": _listener_ap, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/delete_tunnel.py000066400000000000000000000006301513221403200312530ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet TUNNEL_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="transfer", method="DELETE", path=f"/v2/tunnels/{TUNNEL_ID}", json={"data": None, "meta": {"request_id": "ofayi2B4R"}}, metadata={ "tunnel_id": TUNNEL_ID, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/endpoint_manager_task_list.py000066400000000000000000000105271513221403200340210ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID as SRC_ENDPOINT_ID from ._common import TASK_ID DEST_ENDPOINT_ID = str(uuid.uuid4()) OWNER_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="transfer", method="GET", path="/v0.10/endpoint_manager/task_list", metadata={ "task_id": TASK_ID, "source": SRC_ENDPOINT_ID, "destination": DEST_ENDPOINT_ID, "owner_id": OWNER_ID, }, json={ "DATA": [ { "DATA_TYPE": "task", "bytes_checksummed": 0, "bytes_transferred": 14, "canceled_by_admin": None, "canceled_by_admin_message": None, "command": "API 0.10", "completion_time": "2024-02-06T06:59:02+00:00", "deadline": "2024-02-07T06:58:59+00:00", "delete_destination_extra": False, "destination_base_path": None, "destination_endpoint": f"pliny_the_elder#{DEST_ENDPOINT_ID}", "destination_endpoint_display_name": "Ercolano", "destination_endpoint_id": DEST_ENDPOINT_ID, "destination_host_endpoint": None, "destination_host_endpoint_display_name": None, "destination_host_endpoint_id": None, "destination_host_path": None, "destination_local_user": "pliny", "destination_local_user_status": "OK", "destination_mapped_collection_display_name": None, "destination_mapped_collection_id": None, "directories": 2, "effective_bytes_per_second": 5, "encrypt_data": False, "fail_on_quota_errors": False, "fatal_error": None, "faults": 0, "files": 3, "files_skipped": 0, "files_transferred": 3, "filter_rules": None, "history_deleted": False, "is_ok": None, "is_paused": False, "label": None, "nice_status": None, "nice_status_details": None, "nice_status_expires_in": None, "nice_status_short_description": None, "owner_id": OWNER_ID, "owner_string": "pliny-the-elder@globus.org", "preserve_timestamp": False, "recursive_symlinks": "ignore", "request_time": "2024-02-06T06:58:59+00:00", "skip_source_errors": False, "source_base_path": None, "source_endpoint": f"pliny#{SRC_ENDPOINT_ID}", "source_endpoint_display_name": "Pompeii", "source_endpoint_id": SRC_ENDPOINT_ID, "source_host_endpoint": None, "source_host_endpoint_display_name": None, "source_host_endpoint_id": None, "source_host_path": None, "source_local_user": None, "source_local_user_status": "NO_PERMISSION", "source_mapped_collection_display_name": None, "source_mapped_collection_id": None, "status": "SUCCEEDED", "subtasks_canceled": 0, "subtasks_expired": 0, "subtasks_failed": 0, "subtasks_pending": 0, "subtasks_retrying": 0, "subtasks_skipped_errors": 0, "subtasks_succeeded": 6, "subtasks_total": 6, "symlinks": 0, "sync_level": None, "task_id": TASK_ID, "type": "TRANSFER", "username": "pliny", "verify_checksum": True, } ], "DATA_TYPE": "task_list", "has_next_page": False, "last_key": "complete,2024-02-06T06:59:02.291996", }, ), ) endpoint_manager_task_successful_transfers.py000066400000000000000000000013061513221403200372300ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transferfrom globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TASK_ID RESPONSES = ResponseSet( metadata={"task_id": TASK_ID}, default=RegisteredResponse( service="transfer", method="GET", path=f"/v0.10/endpoint_manager/task/{TASK_ID}/successful_transfers", json={ "DATA_TYPE": "successful_transfers", "marker": 0, "next_marker": 93979, "DATA": [ { "destination_path": "/path/to/destination", "source_path": "/path/to/source", "DATA_TYPE": "successful_transfer", } ], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/get_endpoint.py000066400000000000000000000033261513221403200311100ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID ENDPOINT_DOC = { "DATA_TYPE": "endpoint", "id": ENDPOINT_ID, "display_name": "myserver", "organization": "My Org", "username": "auser", "description": "Example gridftp endpoint.", "entity_type": "GCSv4_host", "public": False, "french_english_bilingual": False, "is_globus_connect": False, "globus_connect_setup_key": None, "gcp_connected": None, "gcp_paused": None, "s3_url": None, "s3_owner_activated": False, "host_endpoint_id": None, "host_path": None, "disable_verify": False, "disable_anonymous_writes": False, "force_verify": False, "force_encryption": False, "mfa_required": False, "myproxy_server": None, "myproxy_dn": None, "non_functional": False, "non_functional_endpoint_display_name": None, "non_functional_endpoint_id": None, "oauth_server": None, "default_directory": None, "activated": False, "expires_in": 0, "expire_time": "2000-01-02 03:45:06+00:00", "shareable": True, "acl_available": False, "acl_editable": False, "in_use": False, "DATA": [ { "DATA_TYPE": "server", "hostname": "gridftp.example.org", "uri": "gsiftp://gridftp.example.org:2811", "port": 2811, "scheme": "gsiftp", "id": 985, "subject": "/O=Grid/OU=Example/CN=host/gridftp.example.org", } ], } RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", path=f"/v0.10/endpoint/{ENDPOINT_ID}", json=ENDPOINT_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/get_stream_access_point.py000066400000000000000000000027361513221403200333210ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet ACCESS_POINT_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="transfer", method="GET", path=f"/v2/stream_access_points/{ACCESS_POINT_ID}", json={ "data": { "attributes": { "advertised_owner": "john@globus.org", "contact_email": None, "contact_info": None, "department": None, "description": None, "display_name": "Buzz Dev Listener", "info_link": None, "keywords": None, "organization": None, "tlsftp_server": ( "tlsftp://s-463c7.e7d5e.8540." "test3.zones.dnsteam.globuscs.info:443" ), }, "id": ACCESS_POINT_ID, "relationships": { "host_endpoint": { "data": { "id": "d6428474-c308-4a2d-8a86-d377915d978b", "type": "Endpoint", } } }, "type": "StreamAccessPoint", }, "meta": {"request_id": "55QRq2iBa"}, }, metadata={ "access_point_id": ACCESS_POINT_ID, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/get_submission_id.py000066400000000000000000000005041513221403200321320ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBMISSION_ID RESPONSES = ResponseSet( metadata={"submission_id": SUBMISSION_ID}, default=RegisteredResponse( service="transfer", path="/v0.10/submission_id", json={"value": SUBMISSION_ID}, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/get_tunnel.py000066400000000000000000000035131513221403200305730ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet TUNNEL_ID = str(uuid.uuid4()) _initiator_ap = str(uuid.uuid4()) _listener_ap = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="transfer", method="GET", path=f"/v2/tunnels/{TUNNEL_ID}", json={ "data": { "attributes": { "created_time": "2025-12-12T21:11:50.525278", "initiator_ip_address": None, "initiator_port": None, "label": "Buzz Tester", "lifetime_mins": 360, "listener_ip_address": None, "listener_port": None, "restartable": False, "state": "AWAITING_LISTENER", "status": "The tunnel is waiting for listening", "submission_id": "292b0054-7084-46eb-83d6-7a6821b1f77e", }, "id": TUNNEL_ID, "relationships": { "initiator": { "data": {"id": _initiator_ap, "type": "StreamAccessPoint"} }, "listener": { "data": {"id": _listener_ap, "type": "StreamAccessPoint"} }, "owner": { "data": { "id": "4d443580-012d-4954-816f-e0592bd356e1", "type": "Identity", } }, }, "type": "Tunnel", }, "meta": {"request_id": "M6kFaS949"}, }, metadata={ "tunnel_id": TUNNEL_ID, "initiator_ap": _initiator_ap, "listener_ap": _listener_ap, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/list_tunnels.py000066400000000000000000000061641513221403200311570ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet TUNNEL_LIST_DOC = { "data": [ { "attributes": { "created_time": "2025-12-12T21:11:50.525278", "initiator_ip_address": None, "initiator_port": None, "label": "Buzz Tester", "lifetime_mins": 360, "listener_ip_address": None, "listener_port": None, "restartable": False, "state": "AWAITING_LISTENER", "status": "The tunnel is waiting for listening contact detail setup.", "submission_id": "292b0054-7084-46eb-83d6-7a6821b1f77e", }, "id": "1c1be52d-2d4d-4200-b4ad-d75d43eb0d9c", "relationships": { "initiator": { "data": { "id": "80583f05-75f3-4825-b8a5-6c3edf0bbc5c", "type": "StreamAccessPoint", } }, "listener": { "data": { "id": "dd5fa993-749f-48fb-86cf-f07ad5797d7e", "type": "StreamAccessPoint", } }, "owner": { "data": { "id": "4d443580-012d-4954-816f-e0592bd356e1", "type": "Identity", } }, }, "type": "Tunnel", }, { "attributes": { "created_time": "2025-12-12T21:22:11.018233", "initiator_ip_address": None, "initiator_port": None, "label": "part 2", "lifetime_mins": 360, "listener_ip_address": None, "listener_port": None, "restartable": False, "state": "AWAITING_LISTENER", "status": "The tunnel is waiting for listening contact detail setup.", "submission_id": "fb3b1220-1d5f-4dcf-92f5-e7056a514319", }, "id": "bf1b0d16-7d93-44eb-8773-9066a750c13e", "relationships": { "initiator": { "data": { "id": "34c6e671-c011-4bf8-bc30-5ccebada8f3b", "type": "StreamAccessPoint", } }, "listener": { "data": { "id": "dd5fa993-749f-48fb-86cf-f07ad5797d7e", "type": "StreamAccessPoint", } }, "owner": { "data": { "id": "4d443580-012d-4954-816f-e0592bd356e1", "type": "Identity", } }, }, "type": "Tunnel", }, ], "links": None, "meta": {"request_id": "fAAfpnino"}, } RESPONSES = ResponseSet( metadata={}, default=RegisteredResponse( service="transfer", path="/v2/tunnels", json=TUNNEL_LIST_DOC, method="GET", ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/operation_mkdir.py000066400000000000000000000011451513221403200316140ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", method="POST", path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/mkdir", json={ "DATA_TYPE": "mkdir_result", "code": "DirectoryCreated", "message": "The directory was created successfully", "request_id": "ShbIUzrWT", "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/mkdir", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/operation_rename.py000066400000000000000000000011341513221403200317530ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", method="POST", path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/rename", json={ "DATA_TYPE": "result", "code": "FileRenamed", "message": "File or directory renamed successfully", "request_id": "ShbIUzrWT", "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/rename", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/operation_stat.py000066400000000000000000000042171513221403200314640ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", method="GET", path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", json={ "DATA_TYPE": "file", "group": "tutorial", "last_modified": "2023-12-18 16:52:50+00:00", "link_group": None, "link_last_modified": None, "link_size": None, "link_target": None, "link_user": None, "name": "file1.txt", "permissions": "0644", "size": 4, "type": "file", "user": "tutorial", }, ), not_found=RegisteredResponse( service="transfer", method="GET", path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", status=404, json={ "code": "NotFound", "message": f"Path not found, Error (list)\nEndpoint: Globus Tutorial Collection 1 ({ENDPOINT_ID})\nServer: 100.26.231.26:443\nMessage: No such file or directory\n---\nDetails: Error: '~/foo' not found\\r\\n550-GlobusError: v=1 c=PATH_NOT_FOUND\\r\\n550-GridFTP-Errno: 2\\r\\n550-GridFTP-Reason: System error in stat\\r\\n550-GridFTP-Error-String: No such file or directory\\r\\n550 End.\\r\\n\n", # noqa 501 "request_id": "aaabbbccc", "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", }, ), permission_denied=RegisteredResponse( service="transfer", method="GET", path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", status=403, json={ "code": "EndpointPermissionDenied", "message": f"Denied by endpoint, Error (list)\nEndpoint: Globus Tutorial Collection 1 ({ENDPOINT_ID})\nServer: 100.26.231.26:443\nCommand: MLST /foo\nMessage: Fatal FTP Response\n---\nDetails: 500 Command failed : Path not allowed.\\r\\n\n", # noqa 501 "request_id": "aaabbbccc", "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", }, ), ) set_subscription_admin_verified.py000066400000000000000000000062251513221403200347770ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transferimport uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID, SUBSCRIPTION_ID NO_ADMIN_ROLE_ENDPOINT_ID = str(uuid.UUID(int=10)) NON_SUBSCRIBED_ENDPOINT_ID = str(uuid.UUID(int=11)) NO_IDENTITIES_IN_SESSION_ENDPOINT_ID = str(uuid.UUID(int=12)) def _format_verify_route(epid: str) -> str: return f"/v0.10/endpoint/{epid}/subscription_admin_verified" RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", method="PUT", path=_format_verify_route(ENDPOINT_ID), status=200, json={ "DATA_TYPE": "result", "code": "Updated", "message": "Endpoint updated successfully", "request_id": "SKWMqNWyv", "resource": _format_verify_route(ENDPOINT_ID), }, ), no_admin_role=RegisteredResponse( metadata={"endpoint_id": NO_ADMIN_ROLE_ENDPOINT_ID}, service="transfer", method="PUT", path=_format_verify_route(NO_ADMIN_ROLE_ENDPOINT_ID), status=403, json={ "code": "PermissionDenied", "message": ( "User does not have an admin role on the collection's " "subscription to set subscription_admin_verified" ), "request_id": "BHI2BHt8N", "resource": _format_verify_route(NO_ADMIN_ROLE_ENDPOINT_ID), }, ), non_valid_verified_status=RegisteredResponse( service="transfer", method="PUT", path=_format_verify_route(ENDPOINT_ID), status=400, json={ "code": "BadRequest", "message": ( "Could not parse JSON: Expecting value: line 1 column 33 (char 32)" ), "request_id": "NPjnXpSD6", "resource": _format_verify_route(ENDPOINT_ID), }, ), non_subscribed_endpoint=RegisteredResponse( metadata={"endpoint_id": NON_SUBSCRIBED_ENDPOINT_ID}, service="transfer", method="PUT", path=_format_verify_route(NON_SUBSCRIBED_ENDPOINT_ID), status=400, json={ "code": "BadRequest", "message": ( "The collection must be associated with a subscription to " "set subscription_admin_verified" ), "request_id": "NPjnXpSD6", "resource": _format_verify_route(NON_SUBSCRIBED_ENDPOINT_ID), }, ), no_identities_in_session=RegisteredResponse( metadata={ "endpoint_id": NO_IDENTITIES_IN_SESSION_ENDPOINT_ID, "subscription_id": SUBSCRIPTION_ID, }, service="transfer", method="PUT", path=_format_verify_route(NO_IDENTITIES_IN_SESSION_ENDPOINT_ID), status=400, json={ "code": "BadRequest", "message": ( "No manager or admin identities in session for high-assurance " f"subscription {SUBSCRIPTION_ID}" ), "request_id": "NPjnXpSD6", "resource": _format_verify_route(NO_IDENTITIES_IN_SESSION_ENDPOINT_ID), }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/set_subscription_id.py000066400000000000000000000033771513221403200325120ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID, SUBSCRIPTION_ID OTHER_SUBSCRIPTION_ID = str(uuid.UUID(int=10)) RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", method="PUT", path=f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", json={ "DATA_TYPE": "result", "code": "Updated", "message": "Endpoint updated successfully", "request_id": "dWTZZe17L", "resource": f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", }, ), not_found=RegisteredResponse( service="transfer", method="PUT", path=f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", status=404, json={ "code": "EndpointNotFound", "message": f"No such endpoint '{ENDPOINT_ID}'", "request_id": "BHI2BHt8N", "resource": f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", }, ), multi_subscriber_cannot_use_default=RegisteredResponse( service="transfer", method="PUT", path=f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", status=400, json={ "code": "BadRequest", "message": ( "Please specify the subscription ID to use. " "You currently have access to: " f"{SUBSCRIPTION_ID}, {OTHER_SUBSCRIPTION_ID}" ), "request_id": "H1dFNg6QB", "resource": f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", }, metadata={ "endpoint_id": ENDPOINT_ID, "subscription_ids": [SUBSCRIPTION_ID, OTHER_SUBSCRIPTION_ID], }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/submit_delete.py000066400000000000000000000017451513221403200312610ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBMISSION_ID, TASK_ID RESPONSES = ResponseSet( metadata={"submission_id": SUBMISSION_ID, "task_id": TASK_ID}, default=RegisteredResponse( service="transfer", method="POST", path="/v0.10/delete", json={ "DATA_TYPE": "delete_result", "code": "Accepted", "message": ( "The delete has been accepted and a task has been created " "and queued for execution" ), "request_id": "NS2QXhLZ7", "resource": "/v0.10/delete", "submission_id": SUBMISSION_ID, "task_id": TASK_ID, "task_link": { "DATA_TYPE": "link", "href": f"/v0.10/task/{TASK_ID}?format=json", "rel": "related", "resource": "task", "title": "related task", }, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/submit_transfer.py000066400000000000000000000026521513221403200316410ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBMISSION_ID, TASK_ID RESPONSES = ResponseSet( metadata={"submission_id": SUBMISSION_ID, "task_id": TASK_ID}, default=RegisteredResponse( service="transfer", method="POST", path="/v0.10/transfer", json={ "DATA_TYPE": "transfer_result", "code": "Accepted", "message": ( "The transfer has been accepted and a task has been created " "and queued for execution" ), "request_id": "7HgMVYazI", "resource": "/v0.10/transfer", "submission_id": SUBMISSION_ID, "task_id": TASK_ID, "task_link": { "DATA_TYPE": "link", "href": f"/v0.10/task/{TASK_ID}?format=json", "rel": "related", "resource": "task", "title": "related task", }, }, ), failure=RegisteredResponse( service="transfer", method="POST", path="/v0.10/transfer", json={ "code": "ClientError.BadRequest.NoTransferItems", "message": "A transfer requires at least one item", "request_id": "oUAA6Sq2P", "resource": "/v0.10/transfer", }, status=400, metadata={ "request_id": "oUAA6Sq2P", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/task_list.py000066400000000000000000000054151513221403200304270ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet source_id = str(uuid.uuid4()) destination_id = str(uuid.uuid4()) owner_id = str(uuid.uuid4()) task_id = str(uuid.uuid4()) TASK_LIST_DOC = { "DATA": [ { "bytes_checksummed": 0, "bytes_transferred": 4, "canceled_by_admin": None, "canceled_by_admin_message": None, "command": "API 0.10", "completion_time": "2021-09-02T18:04:49+00:00", "deadline": "2021-09-03T18:04:47+00:00", "delete_destination_extra": False, "destination_endpoint": "mydest", "destination_endpoint_display_name": "Destination Endpoint", "destination_endpoint_id": destination_id, "directories": 0, "effective_bytes_per_second": 3, "encrypt_data": False, "fail_on_quota_errors": False, "fatal_error": None, "faults": 0, "files": 1, "files_skipped": 0, "files_transferred": 1, "filter_rules": None, "history_deleted": False, "is_ok": None, "is_paused": False, "label": None, "nice_status": None, "nice_status_details": None, "nice_status_expires_in": None, "nice_status_short_description": None, "owner_id": owner_id, "preserve_timestamp": False, "recursive_symlinks": "ignore", "request_time": "2021-09-02T18:04:47+00:00", "skip_source_errors": False, "source_endpoint": "mysrc", "source_endpoint_display_name": "Source Endpoint", "source_endpoint_id": source_id, "status": "SUCCEEDED", "subtasks_canceled": 0, "subtasks_expired": 0, "subtasks_failed": 0, "subtasks_pending": 0, "subtasks_retrying": 0, "subtasks_skipped_errors": 0, "subtasks_succeeded": 2, "subtasks_total": 2, "symlinks": 0, "sync_level": None, "task_id": task_id, "type": "TRANSFER", "username": "u_XrtivK6z9w2MZwr65os6nZX0wv", "verify_checksum": True, } ], "DATA_TYPE": "task_list", "length": 1, "limit": 1000, "offset": 0, "total": 1, } RESPONSES = ResponseSet( metadata={ "tasks": [ { "source_id": source_id, "destination_id": destination_id, "owner_id": owner_id, "task_id": task_id, } ], }, default=RegisteredResponse( service="transfer", path="/v0.10/task_list", json=TASK_LIST_DOC, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/update_endpoint.py000066400000000000000000000010541513221403200316070ustar00rootroot00000000000000from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID RESPONSES = ResponseSet( metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", method="PUT", path=f"/v0.10/endpoint/{ENDPOINT_ID}", json={ "DATA_TYPE": "result", "code": "Updated", "message": "Endpoint updated successfully", "request_id": "6aZjzldyM", "resource": f"/v0.10/endpoint/{ENDPOINT_ID}", }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/data/transfer/update_tunnel.py000066400000000000000000000036521513221403200313020ustar00rootroot00000000000000import uuid from globus_sdk.testing.models import RegisteredResponse, ResponseSet TUNNEL_ID = str(uuid.uuid4()) RESPONSES = ResponseSet( default=RegisteredResponse( service="transfer", method="PATCH", path=f"/v2/tunnels/{TUNNEL_ID}", json={ "data": { "attributes": { "created_time": "2025-12-12T21:11:50.525278", "initiator_ip_address": None, "initiator_port": None, "label": "Buzz Tester", "lifetime_mins": 360, "listener_ip_address": None, "listener_port": None, "restartable": False, "state": "STOPPING", "status": "A request to stop tunnel has been received.", "submission_id": "292b0054-7084-46eb-83d6-7a6821b1f77e", }, "id": "1c1be52d-2d4d-4200-b4ad-d75d43eb0d9c", "relationships": { "initiator": { "data": { "id": "80583f05-75f3-4825-b8a5-6c3edf0bbc5c", "type": "StreamAccessPoint", } }, "listener": { "data": { "id": "dd5fa993-749f-48fb-86cf-f07ad5797d7e", "type": "StreamAccessPoint", } }, "owner": { "data": { "id": "4d443580-012d-4954-816f-e0592bd356e1", "type": "Identity", } }, }, "type": "Tunnel", }, "meta": {"request_id": "pN0Aact40"}, }, metadata={ "tunnel_id": TUNNEL_ID, }, ), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/helpers.py000066400000000000000000000064341513221403200253410ustar00rootroot00000000000000from __future__ import annotations import http.client import json import typing as t import requests import responses from globus_sdk.exc import GlobusAPIError E = t.TypeVar("E", bound=GlobusAPIError) def get_last_request( *, requests_mock: responses.RequestsMock | None = None ) -> requests.PreparedRequest | None: """ Get the last request which was received, or None if there were no requests. :param requests_mock: A non-default ``RequestsMock`` object to use. """ calls = requests_mock.calls if requests_mock is not None else responses.calls try: last_call = calls[-1] except IndexError: return None return last_call.request @t.overload def construct_error( *, http_status: int, body: bytes | str | t.Dict[str, t.Any], method: str = "GET", response_headers: t.Dict[str, str] | None = None, request_headers: t.Dict[str, str] | None = None, response_encoding: str = "utf-8", url: str = "https://bogus-url/", ) -> GlobusAPIError: ... @t.overload def construct_error( *, http_status: int, error_class: type[E], body: bytes | str | t.Dict[str, t.Any], method: str = "GET", response_headers: t.Dict[str, str] | None = None, request_headers: t.Dict[str, str] | None = None, response_encoding: str = "utf-8", url: str = "https://bogus-url/", ) -> E: ... def construct_error( *, http_status: int, body: bytes | str | t.Dict[str, t.Any], error_class: type[E] | type[GlobusAPIError] = GlobusAPIError, method: str = "GET", response_headers: t.Dict[str, str] | None = None, request_headers: t.Dict[str, str] | None = None, response_encoding: str = "utf-8", url: str = "https://bogus-url/", ) -> E | GlobusAPIError: """ Given parameters for an HTTP response, construct a GlobusAPIError and return it. :param error_class: The class of the error to construct. Defaults to GlobusAPIError. :param http_status: The HTTP status code to use in the response. :param body: The body of the response. If a dict, will be JSON-encoded. :param method: The HTTP method to set on the underlying request. :param response_headers: The headers of the response. :param request_headers: The headers of the request. :param response_encoding: The encoding to use for the response body. :param url: The URL to set on the underlying request. """ raw_response = requests.Response() raw_response.status_code = http_status raw_response.reason = http.client.responses.get(http_status, "Unknown") raw_response.url = url raw_response.encoding = response_encoding raw_response.request = requests.Request( method=method, url=url, headers=request_headers or {} ).prepare() raw_response.headers.update(response_headers or {}) if isinstance(body, dict) and "Content-Type" not in raw_response.headers: raw_response.headers["Content-Type"] = "application/json" raw_response._content = _encode_body(body, response_encoding) return error_class(raw_response) def _encode_body(body: bytes | str | t.Dict[str, t.Any], encoding: str) -> bytes: if isinstance(body, bytes): return body elif isinstance(body, str): return body.encode(encoding) else: return json.dumps(body).encode(encoding) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/models.py000066400000000000000000000250321513221403200251550ustar00rootroot00000000000000from __future__ import annotations import types import typing as t import responses from globus_sdk._internal.utils import slash_join class RegisteredResponse: """ A mock response along with descriptive metadata to let a fixture "pass data forward" to the consuming test cases. (e.g. a ``GET Task`` fixture which shares the ``task_id`` it uses with consumers via ``.metadata["task_id"]``) When initializing a ``RegisteredResponse``, you can use ``path`` and ``service`` to describe a path on a Globus service rather than a full URL. The ``metadata`` data container is also globus-sdk specific. Most other parameters are wrappers over ``responses`` response characteristics. :param path: Path on the target service or full URL if service is null :param service: A known service name like ``"transfer"`` or ``"compute"``. This will be used to deduce the base URL onto which ``path`` should be joined :param method: A string HTTP Method :param headers: HTTP headers for the response :param json: A dict or list structure for a JSON response (mutex with ``body``) :param body: A string response body (mutex with ``json``) :param status: The HTTP status code for the response :param content_type: A Content-Type header value for the response :param match: A tuple or list of ``responses`` matchers :param metadata: A dict of data to store on the response, which allows the usage site which declares the response to pass information forward to the site which activates and tests against the response. """ _url_map = { "auth": "https://auth.globus.org/", "nexus": "https://nexus.api.globusonline.org/", "transfer": "https://transfer.api.globus.org/", "search": "https://search.api.globus.org/", "gcs": "https://abc.xyz.data.globus.org/api/", "groups": "https://groups.api.globus.org/", "timer": "https://timer.automate.globus.org/", "flows": "https://flows.automate.globus.org/", "compute": "https://compute.api.globus.org/", } def __init__( self, *, # path and service are glbous-sdk specific # in `responses`, these are just `url` path: str, service: ( t.Literal[ "auth", "nexus", "transfer", "search", "gcs", "groups", "timer", "flows", "compute", ] | None ) = None, # method will be passed through to `responses.Response`, so we # support all of the values which it supports method: t.Literal[ "GET", "PUT", "POST", "PATCH", "HEAD", "DELETE", "OPTIONS", "CONNECT", "TRACE", ] = "GET", # these parameters are passed through to `response.Response` (or omitted) body: str | None = None, content_type: str | None = None, headers: dict[str, str] | None = None, json: None | list[t.Any] | dict[str, t.Any] = None, status: int = 200, stream: bool | None = None, match: t.Sequence[t.Callable[..., tuple[bool, str]]] | None = None, # metadata is globus-sdk specific metadata: dict[str, t.Any] | None = None, # the following are known parameters to `responses.Response` which # `RegisteredResponse` does not support: # - url: calculated from (path, service) # - auto_calculate_content_length: a bool setting, usually not needed and can # be achieved in user code via `headers` # - passthrough: bool setting allowing calls to be emitted to the services # (undesirable in any ordinary cases) # - match_querystring: legacy param which has been replaced with `match` ) -> None: self.service = service if service: self.full_url = slash_join(self._url_map[service], path) else: self.full_url = path self.path = path # convert the method to uppercase so that specifying `method="post"` will match # correctly -- method matching is case sensitive but we don't need to expose the # possibility of a non-uppercase method self.method = method.upper() self.body = body self.content_type = content_type self.headers = headers self.json = json self.status = status self.stream = stream self.match = match self._metadata = metadata self.parent: ResponseSet | ResponseList | None = None @property def metadata(self) -> dict[str, t.Any]: if self._metadata is not None: return self._metadata if self.parent is not None: return self.parent.metadata return {} def _add_or_replace( self, method: t.Literal["add", "replace"], *, requests_mock: responses.RequestsMock | None = None, ) -> RegisteredResponse: kwargs: dict[str, t.Any] = { "headers": self.headers, "status": self.status, "stream": self.stream, "match_querystring": None, } if self.json is not None: kwargs["json"] = self.json if self.body is not None: kwargs["body"] = self.body if self.content_type is not None: kwargs["content_type"] = self.content_type if self.match is not None: kwargs["match"] = self.match if requests_mock is None: use_requests_mock: responses.RequestsMock | types.ModuleType = responses else: use_requests_mock = requests_mock if method == "add": use_requests_mock.add(self.method, self.full_url, **kwargs) else: use_requests_mock.replace(self.method, self.full_url, **kwargs) return self def add( self, *, requests_mock: responses.RequestsMock | None = None ) -> RegisteredResponse: """ Activate the response, adding it to a mocked requests object. :param requests_mock: The mocked requests object to use. Defaults to the default provided by the ``responses`` library """ return self._add_or_replace("add", requests_mock=requests_mock) def replace( self, *, requests_mock: responses.RequestsMock | None = None ) -> RegisteredResponse: """ Activate the response, adding it to a mocked requests object and replacing any existing response for the particular path and method. :param requests_mock: The mocked requests object to use. Defaults to the default provided by the ``responses`` library """ return self._add_or_replace("replace", requests_mock=requests_mock) class ResponseList: """ A series of unnamed responses, meant to be used and referred to as a single case within a ResponseSet. This can be stored in a ``ResponseSet`` as a case, describing a series of responses registered to a specific name (e.g. to describe a paginated API). """ def __init__( self, *data: RegisteredResponse, metadata: dict[str, t.Any] | None = None, ) -> None: self.responses = list(data) self._metadata = metadata self.parent: ResponseSet | None = None for r in data: r.parent = self @property def metadata(self) -> dict[str, t.Any]: if self._metadata is not None: return self._metadata if self.parent is not None: return self.parent.metadata return {} def add( self, *, requests_mock: responses.RequestsMock | None = None ) -> ResponseList: for r in self.responses: r.add(requests_mock=requests_mock) return self class ResponseSet: """ A collection of mock responses, potentially all meant to be activated together (``.activate_all()``), or to be individually selected as options/alternatives (``.activate("case_foo")``). On init, this implicitly sets the parent of any response objects to this response set. On register() it does not do so automatically. """ def __init__( self, metadata: dict[str, t.Any] | None = None, **kwargs: RegisteredResponse | ResponseList, ) -> None: self.metadata = metadata or {} self._data: dict[str, RegisteredResponse | ResponseList] = {**kwargs} for res in self._data.values(): res.parent = self def register(self, case: str, value: RegisteredResponse) -> None: self._data[case] = value def lookup(self, case: str) -> RegisteredResponse | ResponseList: try: return self._data[case] except KeyError as e: raise LookupError("did not find a matching registered response") from e def __bool__(self) -> bool: return bool(self._data) def __iter__( self, ) -> t.Iterator[RegisteredResponse | ResponseList]: return iter(self._data.values()) def cases(self) -> t.Iterator[str]: return iter(self._data) def activate( self, case: str, *, requests_mock: responses.RequestsMock | None = None, ) -> RegisteredResponse | ResponseList: return self.lookup(case).add(requests_mock=requests_mock) def activate_all( self, *, requests_mock: responses.RequestsMock | None = None ) -> ResponseSet: for x in self: x.add(requests_mock=requests_mock) return self @classmethod def from_dict( cls, data: t.Mapping[ str, (dict[str, t.Any] | list[dict[str, t.Any]]), ], metadata: dict[str, t.Any] | None = None, **kwargs: dict[str, dict[str, t.Any]], ) -> ResponseSet: # constructor which expects native dicts and converts them to RegisteredResponse # objects, then puts them into the ResponseSet def handle_value( v: dict[str, t.Any] | list[dict[str, t.Any]], ) -> RegisteredResponse | ResponseList: if isinstance(v, dict): return RegisteredResponse(**v) else: return ResponseList(*(RegisteredResponse(**subv) for subv in v)) reassembled_data: dict[str, RegisteredResponse | ResponseList] = { k: handle_value(v) for k, v in data.items() } return cls(metadata=metadata, **reassembled_data) globus-globus-sdk-python-6a080e4/src/globus_sdk/testing/registry.py000066400000000000000000000124351513221403200255450ustar00rootroot00000000000000from __future__ import annotations import importlib import re import typing as t import responses import globus_sdk from .models import RegisteredResponse, ResponseList, ResponseSet # matches "V2", "V11", etc as a string suffix # see usage in _resolve_qualname for details _SUFFIX_VERSION_MATCH_PATTERN = re.compile(r"V\d+$") _RESPONSE_SET_REGISTRY: dict[t.Any, ResponseSet] = {} def register_response_set( set_id: t.Any, rset: ResponseSet | dict[str, dict[str, t.Any]], metadata: dict[str, t.Any] | None = None, ) -> ResponseSet: """ Register a new ``ResponseSet`` object. The response set may be specified as a dict or a ResponseSet object. :param set_id: The ID used to retrieve the response set later :param rset: The response set to register :param metadata: Metadata dict to assign to the response set when it is specified as a dict. If the response set is an object, this argument is ignored. """ if isinstance(rset, dict): rset = ResponseSet.from_dict(rset, metadata=metadata) _RESPONSE_SET_REGISTRY[set_id] = rset return rset def _resolve_qualname(name: str) -> str: if "." not in name: return name prefix, suffix = name.split(".", 1) if not hasattr(globus_sdk, prefix): return name # something from globus_sdk, could be a client class maybe_client = getattr(globus_sdk, prefix) # there are a dozen ways of writing this check, but the point is # "if it's not a client class" if not ( isinstance(maybe_client, type) and issubclass(maybe_client, globus_sdk.BaseClient) ): return name assert issubclass(maybe_client, globus_sdk.BaseClient) service_name = maybe_client.service_name # TODO: Consider alternative strategies for mapping versioned clients # to subdirs. For now, we do it by name matching. # # 'prefix' is the client name, and it may end in `V2`, `V3`, etc. # in which case we want to map it to a subdir suffix_version_match = _SUFFIX_VERSION_MATCH_PATTERN.search(prefix) if suffix_version_match: suffix = f"{suffix_version_match.group(0).lower()}.{suffix}" return f"{service_name}.{suffix}" def get_response_set(set_id: t.Any) -> ResponseSet: """ Lookup a ``ResponseSet`` as in ``load_response_set``, but without activating it. :param set_id: The ID used to retrieve the response set. Typically a string, but could be any key used to register a response set. """ # first priority: check the explicit registry if set_id in _RESPONSE_SET_REGISTRY: return _RESPONSE_SET_REGISTRY[set_id] # if ID is a string, it's the (optionally dotted) name of a module if isinstance(set_id, str): module_name = f"globus_sdk.testing.data.{set_id}" else: assert hasattr( set_id, "__qualname__" ), f"cannot load response set from {type(set_id)}" # support modules like # globus_sdk/testing/data/auth/get_identities.py # for lookups like # get_response_set(AuthClient.get_identities) module_name = ( f"globus_sdk.testing.data.{_resolve_qualname(set_id.__qualname__)}" ) # after that, check the built-in "registry" built from modules try: module = importlib.import_module(module_name) except ModuleNotFoundError as e: raise ValueError(f"no fixtures defined for {module_name}") from e assert isinstance(module.RESPONSES, ResponseSet) return module.RESPONSES def load_response_set( set_id: t.Any, *, requests_mock: responses.RequestsMock | None = None ) -> ResponseSet: """ Optionally lookup a response set and activate all of its responses. If passed a ``ResponseSet``, activate it, otherwise the first argument is an ID used for lookup. :param set_id: The ID used to retrieve the response set. Typically a string, but could be any key used to register a response set. :param requests_mock: A ``responses`` library mock to use for response mocking, defaults to the ``responses`` default """ if isinstance(set_id, ResponseSet): return set_id.activate_all(requests_mock=requests_mock) ret = get_response_set(set_id) ret.activate_all(requests_mock=requests_mock) return ret def load_response( set_id: t.Any, *, case: str = "default", requests_mock: responses.RequestsMock | None = None, ) -> RegisteredResponse | ResponseList: """ Optionally lookup and activate an individual response. If given a ``RegisteredResponse``, activate it, otherwise the first argument is an ID of a ``ResponseSet`` used for lookup. By default, looks for the response registered under ``case="default"``. :param set_id: The ID used to retrieve the response set. Typically a string, but could be any key used to register a response set. :param case: The name of a case within the response set to load, ignoring all other registered mocks in the response set :param requests_mock: A ``responses`` library mock to use for response mocking, defaults to the ``responses`` default """ if isinstance(set_id, RegisteredResponse): return set_id.add(requests_mock=requests_mock) rset = get_response_set(set_id) return rset.activate(case, requests_mock=requests_mock) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/000077500000000000000000000000001513221403200245055ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/__init__.py000066400000000000000000000016261513221403200266230ustar00rootroot00000000000000from .base import FileTokenStorage, TokenStorage from .json import JSONTokenStorage from .memory import MemoryTokenStorage from .sqlite import SQLiteTokenStorage from .token_data import TokenStorageData from .validating_token_storage import ( HasRefreshTokensValidator, NotExpiredValidator, ScopeRequirementsValidator, TokenDataValidator, TokenValidationContext, TokenValidationError, UnchangingIdentityIDValidator, ValidatingTokenStorage, ) __all__ = ( "TokenStorage", "TokenStorageData", "FileTokenStorage", "JSONTokenStorage", "SQLiteTokenStorage", "MemoryTokenStorage", # TokenValidationStorage constructs "ValidatingTokenStorage", "TokenValidationContext", "TokenDataValidator", "TokenValidationError", "HasRefreshTokensValidator", "NotExpiredValidator", "ScopeRequirementsValidator", "UnchangingIdentityIDValidator", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/base.py000066400000000000000000000252321513221403200257750ustar00rootroot00000000000000from __future__ import annotations import abc import contextlib import os import pathlib import re import sys import typing as t import uuid import globus_sdk from .token_data import TokenStorageData if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusAppConfig class TokenStorage(metaclass=abc.ABCMeta): """ The interface for interacting with a store of :class:`TokenStorageData` objects. Implementations must partition their token data objects by ``namespace``. Within a namespace, token data must be indexed by ``resource_server``. :param namespace: A unique string for partitioning token data (Default: "DEFAULT"). :ivar globus_sdk.IDTokenDecoder | None id_token_decoder: A decoder to use when decoding ``id_token`` JWTs from Globus Auth. By default, a new decoder is used each time decoding is performed. """ def __init__(self, namespace: str = "DEFAULT") -> None: self.namespace = namespace self.id_token_decoder: globus_sdk.IDTokenDecoder | None = None @abc.abstractmethod def store_token_data_by_resource_server( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData] ) -> None: """ Store token data for one or more resource server in the current namespace. :param token_data_by_resource_server: mapping of resource server to token data. """ @abc.abstractmethod def get_token_data_by_resource_server(self) -> dict[str, TokenStorageData]: """ Retrieve all token data stored in the current namespace. :returns: a dict of ``TokenStorageData`` objects indexed by their resource server. """ def get_token_data(self, resource_server: str) -> TokenStorageData | None: """ Retrieve token data for a particular resource server in the current namespace. :param resource_server: The resource_server string to get token data for. :returns: token data if found or else None. """ return self.get_token_data_by_resource_server().get(resource_server) @abc.abstractmethod def remove_token_data(self, resource_server: str) -> bool: """ Remove token data for a resource server in the current namespace. :param resource_server: The resource server string to remove token data for. :returns: True if token data was deleted, False if none was found to delete. """ def store_token_response( self, token_response: globus_sdk.OAuthTokenResponse ) -> None: """ Store token data from an :class:`OAuthTokenResponse` in the current namespace. :param token_response: A token response object from an authentication flow. """ token_data_by_resource_server = {} identity_id = self._extract_identity_id(token_response) for resource_server, token_dict in token_response.by_resource_server.items(): token_data_by_resource_server[resource_server] = TokenStorageData( resource_server=token_dict["resource_server"], identity_id=identity_id, scope=token_dict["scope"], access_token=token_dict["access_token"], refresh_token=token_dict.get("refresh_token"), expires_at_seconds=token_dict["expires_at_seconds"], token_type=token_dict.get("token_type"), ) self.store_token_data_by_resource_server(token_data_by_resource_server) def _extract_identity_id( self, token_response: globus_sdk.OAuthTokenResponse ) -> str | None: """ Get identity_id from id_token if available. .. note:: This method is private, but is used in ValidatingTokenStorage to override the extraction of ``identity_id`` information. Generalizing customization of ``identity_id`` extraction will require implementation of a user-facing mechanism for controlling calls to ``decode_id_token()``. """ # dependent token responses cannot contain an `id_token` field, as the # top-level data is an array if isinstance(token_response, globus_sdk.OAuthDependentTokenResponse): return None if id_token := token_response.get("id_token"): if self.id_token_decoder: decoded_id_token = self.id_token_decoder.decode(id_token) else: decoded_id_token = token_response.decode_id_token() return decoded_id_token["sub"] # type: ignore[no-any-return] else: return None def close(self) -> None: # noqa: B027 """ Close any resources associated with this token storage. By default, this does nothing, but subclasses may override it. """ class FileTokenStorage(TokenStorage, metaclass=abc.ABCMeta): """ A base class for token storages which store tokens in a local file. Common functionality for file-based token storages like file creation and class instantiation for a GlobusApp is defined here. :cvar file_format: The file format suffix associated with files of this type. This is used when constructing the file path for a GlobusApp. :param filepath: The path to a file where token data should be stored. :param namespace: A unique string for partitioning token data (Default: "DEFAULT"). """ # File suffix associated with files of this type (e.g., "csv") file_format: str = "_UNSET_" # must be overridden by subclasses def __init__( self, filepath: pathlib.Path | str, *, namespace: str = "DEFAULT" ) -> None: """ :param filepath: the name of the file to write to and read from :param namespace: A user-supplied namespace for partitioning token data """ self.filepath = str(filepath) try: self._ensure_containing_dir_exists() except OSError as e: msg = ( "Encountered an error while initializing the token storage file " f"'{self.filepath}'" ) raise ValueError(msg) from e super().__init__(namespace=namespace) def __init_subclass__(cls, **kwargs: t.Any): if cls.file_format == "_UNSET_": raise TypeError(f"{cls.__name__} must set a 'file_format' class attribute") @classmethod def for_globus_app( cls, client_id: uuid.UUID | str, app_name: str, config: GlobusAppConfig, namespace: str, ) -> TokenStorage: """ Initialize a TokenStorage instance for a GlobusApp, using the supplied info to determine the file location. :param client_id: The client ID of the Globus App. :param app_name: The name of the Globus App. :param config: The GlobusAppConfig object for the Globus App. :param namespace: A user-supplied namespace for partitioning token data. """ filepath = _default_globus_app_filepath(client_id, app_name, config.environment) return cls(filepath=f"{filepath}.{cls.file_format}", namespace=namespace) def _ensure_containing_dir_exists(self) -> None: """ Ensure that the directory containing the given filepath exists. """ os.makedirs(os.path.dirname(self.filepath), exist_ok=True) def file_exists(self) -> bool: """ Check if the file used by this file storage adapter exists. """ return os.path.exists(self.filepath) @contextlib.contextmanager def user_only_umask(self) -> t.Iterator[None]: """ A context manager to deny rwx to Group and World, x to User This does not create a file, but ensures that if a file is created while in the context manager, its permissions will be correct on unix systems. .. note:: On Windows, this has no effect. To control the permissions on files used for token storage, use ``%LOCALAPPDATA%`` or ``%APPDATA%``. These directories should only be accessible to the current user. """ old_umask = os.umask(0o177) try: yield finally: os.umask(old_umask) def _default_globus_app_filepath( client_id: uuid.UUID | str, app_name: str, environment: str ) -> str: r""" Construct a default TokenStorage filepath for a GlobusApp. For flexibility, the filepath will omit the file format suffix. On Windows, this will be: ``~\AppData\Local\globus\app\{client_id}\{app_name}\tokens`` On Linux and macOS, we use: ``~/.globus/app/{client_id}/{app_name}/tokens`` """ environment_prefix = f"{environment}-" if environment == "production": environment_prefix = "" filename = f"{environment_prefix}tokens" app_name = _slugify_app_name(app_name) if sys.platform == "win32": # try to get the app data dir, preferring the local appdata datadir = os.getenv("LOCALAPPDATA", os.getenv("APPDATA")) if not datadir: home = os.path.expanduser("~") datadir = os.path.join(home, "AppData", "Local") return os.path.join( datadir, "globus", "app", str(client_id), app_name, filename ) else: return os.path.expanduser( f"~/.globus/app/{str(client_id)}/{app_name}/{filename}" ) # https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions _RESERVED_FS_CHARS = re.compile(r'[<>:"/\\|?*]') # https://stackoverflow.com/a/31976060 _RESERVED_FS_NAMES = re.compile(r"con|prn|aux|nul|com\d|lpt\d") def _slugify_app_name(app_name: str) -> str: """ Slugify a globus app name for use in a file path. * Reserved filesystem characters are replaced with a '+'. ('a?' -> 'a+') * Periods and Spaces are replaced with a '-'. ('a. b' -> 'a--b') * Control characters are removed. ('a\0b' -> 'ab') * The string is lowercased. ('AB' -> 'ab') :raises: GlobusSDKUsageError if the app name is empty after slugification. :raises: GlobusSDKUsageError if the app name is a reserved filesystem name (after slugification). """ app_name = _RESERVED_FS_CHARS.sub("+", app_name) app_name = app_name.replace(".", "-").replace(" ", "-") app_name = "".join(c for c in app_name if c.isprintable()) app_name = app_name.lower() if _RESERVED_FS_NAMES.fullmatch(app_name): msg = ( f'App name results in a reserved filename ("{app_name}"). ' "Please choose a different name." ) raise globus_sdk.GlobusSDKUsageError(msg) if not app_name: msg = "App name results in the empty string. Please choose a different name." raise globus_sdk.GlobusSDKUsageError(msg) return app_name globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/json.py000066400000000000000000000163541513221403200260410ustar00rootroot00000000000000from __future__ import annotations import json import typing as t from globus_sdk import __version__ from .base import FileTokenStorage from .token_data import TokenStorageData # use the non-annotation form of TypedDict to apply a non-identifier key _JSONFileData_0 = t.TypedDict("_JSONFileData_0", {"globus-sdk.version": str}) # then inherit from that TypedDict to build the "real" TypedDict with the advantages of # the annotation-based syntax class _JSONFileData(_JSONFileData_0): # pylint: disable=inherit-non-class data: dict[str, dict[str, t.Any]] format_version: str class JSONTokenStorage(FileTokenStorage): """ A token storage which stores token data on disk in a JSON file. This class defines a `format_version` which determines what the specific data shape. Any data in a `supported_version` format which is not the primary `format_version` will be automatically rewritten. See :class:`TokenStorage` for common interface details. :cvar "2.0" format_version: The data format version used when writing data. :cvar ("1.0", "2.0") supported_versions: The list of data format versions which can be read. :param filepath: The path to a JSON file where token data should be stored. :param namespace: A unique string for partitioning token data (Default: "DEFAULT"). """ format_version = "2.0" supported_versions = ("1.0", "2.0") file_format = "json" def _invalid(self, msg: str) -> t.NoReturn: raise ValueError( f"{msg} while loading from '{self.filepath}' for JSON Token Storage" ) def _raw_load(self) -> dict[str, t.Any]: """ Load the file contents as JSON and return the resulting dict object. If a dict is not found, raises an error. """ with open(self.filepath, encoding="utf-8") as f: val = json.load(f) if not isinstance(val, dict): self._invalid("Found non-dict root data") return val def _handle_formats(self, read_data: dict[str, t.Any]) -> _JSONFileData: """Handle older data formats supported by this class if the data is not in a known/recognized format, this will error otherwise, reshape the data to the current supported format and return it """ format_version = read_data.get("format_version") if format_version not in self.supported_versions: raise ValueError( f"cannot store data using SimpleJSONTokenStorage({self.filepath}) " "existing data file is in an unknown format " f"(format_version={format_version})" ) # 1.0 data was stored under a "by_rs" key without namespaces, to upgrade we # move everything under the "DEFAULT" key and remove the "by_rs" key. if format_version == "1.0": if "by_rs" not in read_data: self._invalid("Invalid v1.0 data (missing 'by_rs')") read_data = { "data": { "DEFAULT": read_data["by_rs"], }, "format_version": self.format_version, "globus-sdk.version": __version__, } if not isinstance(read_data.get("data"), dict): raise ValueError( f"cannot store data using SimpleJSONTokenStorage({self.filepath}) " "existing data file is malformed" ) if any( k not in read_data for k in ("data", "format_version", "globus-sdk.version") ): self._invalid("Missing required keys") if not isinstance(data_dict := read_data["data"], dict) or any( not isinstance(k, str) for k in data_dict ): self._invalid("Invalid 'data'") if not isinstance(read_data["format_version"], str): self._invalid("Invalid 'format_version'") if not isinstance(read_data["globus-sdk.version"], str): self._invalid("Invalid 'globus-sdk.version'") return read_data # type: ignore[return-value] def _load(self) -> _JSONFileData: """ Load data from the file and ensure that the data is in a modern format which can be handled by the rest of the adapter. If the file is missing, this will return a "skeleton" for new data. """ try: data = self._raw_load() except FileNotFoundError: return { "data": {}, "format_version": self.format_version, "globus-sdk.version": __version__, } return self._handle_formats(data) def store_token_data_by_resource_server( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData] ) -> None: """ Store token data for one or more resource server in the current namespace. Token data, alongside Globus SDK version info, is serialized to JSON before being written to the file at ``self.filepath``. Under the assumption that this may be running on a system with multiple local users, this sets the umask such that only the owner of the resulting file can read or write it. :param token_data_by_resource_server: A mapping of resource servers to token data. """ to_write = self._load() # create the namespace if it does not exist if self.namespace not in to_write["data"]: to_write["data"][self.namespace] = {} # add token data by resource server to namespaced data for resource_server, token_data in token_data_by_resource_server.items(): to_write["data"][self.namespace][resource_server] = token_data.to_dict() # update globus-sdk version to_write["globus-sdk.version"] = __version__ # write the file, denying rwx to Group and World, exec to User with self.user_only_umask(): with open(self.filepath, "w", encoding="utf-8") as f: json.dump(to_write, f) def get_token_data_by_resource_server(self) -> dict[str, TokenStorageData]: """ Lookup all token data under the current namespace from the JSON file. Returns a dict of ``TokenStorageData`` objects indexed by their resource server. """ ret = {} dicts_by_resource_server = self._load()["data"].get(self.namespace, {}) for resource_server, token_data_dict in dicts_by_resource_server.items(): ret[resource_server] = TokenStorageData.from_dict(token_data_dict) return ret def remove_token_data(self, resource_server: str) -> bool: """ Remove all tokens for a resource server from the JSON data, then overwrite ``self.filepath``. Returns True if token data was removed, False if none was found to remove. :param resource_server: The resource server string to remove tokens for """ to_write = self._load() # pop the token data out if it exists popped = to_write["data"].get(self.namespace, {}).pop(resource_server, None) # overwrite the file, denying rwx to Group and World, exec to User with self.user_only_umask(): with open(self.filepath, "w", encoding="utf-8") as f: json.dump(to_write, f) return popped is not None globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/legacy/000077500000000000000000000000001513221403200257515ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/legacy/__init__.py000066400000000000000000000004621513221403200300640ustar00rootroot00000000000000from .base import FileAdapter, StorageAdapter from .file_adapters import SimpleJSONFileAdapter from .memory_adapter import MemoryAdapter from .sqlite_adapter import SQLiteAdapter __all__ = ( "StorageAdapter", "FileAdapter", "SimpleJSONFileAdapter", "SQLiteAdapter", "MemoryAdapter", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/legacy/base.py000066400000000000000000000045571513221403200272500ustar00rootroot00000000000000from __future__ import annotations import abc import contextlib import os import typing as t import globus_sdk class StorageAdapter(metaclass=abc.ABCMeta): @abc.abstractmethod def store(self, token_response: globus_sdk.OAuthTokenResponse) -> None: """ Store an `OAuthTokenResponse` in the underlying storage for this adapter. :param token_response: The token response to store """ @abc.abstractmethod def get_token_data(self, resource_server: str) -> dict[str, t.Any] | None: """ Lookup token data for a resource server Either returns a dict with the access token, refresh token (optional), and expiration time, or returns ``None``, indicating that there was no data for that resource server. :param resource_server: The resource_server string which uniquely identifies the token data to retriever from storage """ def on_refresh(self, token_response: globus_sdk.OAuthTokenResponse) -> None: """ By default, the on_refresh handler for a token storage adapter simply stores the token response. :param token_response: The token response received from the refresh """ self.store(token_response) class FileAdapter(StorageAdapter, metaclass=abc.ABCMeta): """ File adapters are for single-user cases, where we can assume that there's a simple file-per-user and users are only ever attempting to read their own files. """ filename: str def file_exists(self) -> bool: """ Check if the file used by this file storage adapter exists. """ return os.path.exists(self.filename) @contextlib.contextmanager def user_only_umask(self) -> t.Iterator[None]: """ A context manager to deny rwx to Group and World, x to User This does not create a file, but ensures that if a file is created while in the context manager, its permissions will be correct on unix systems. .. note:: On Windows, this has no effect. To control the permissions on files used for token storage, use ``%LOCALAPPDATA%`` or ``%APPDATA%``. These directories should only be accessible to the current user. """ old_umask = os.umask(0o177) try: yield finally: os.umask(old_umask) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/legacy/file_adapters.py000066400000000000000000000131061513221403200311260ustar00rootroot00000000000000from __future__ import annotations import json import pathlib import typing as t import globus_sdk from globus_sdk import __version__ from .base import FileAdapter # use the non-annotation form of TypedDict to apply a non-identifier key _JSONFileData_0 = t.TypedDict("_JSONFileData_0", {"globus-sdk.version": str}) # then inherit from that TypedDict to build the "real" TypedDict with the advantages of # the annotation-based syntax class _JSONFileData(_JSONFileData_0): # pylint: disable=inherit-non-class by_rs: dict[str, t.Any] format_version: str class SimpleJSONFileAdapter(FileAdapter): """ :param filename: the name of the file to write to and read from A storage adapter for storing tokens in JSON files. """ # the version for the current data format used by the file adapter # # if the format needs to be changed in the future, the adapter can dispatch on # the declared format version in the file to decide how to handle the data format_version = "1.0" # the supported versions (data not in these versions causes an error) supported_versions = ("1.0",) def __init__(self, filename: pathlib.Path | str) -> None: self.filename = str(filename) def _invalid(self, msg: str) -> t.NoReturn: raise ValueError( f"{msg} while loading from '{self.filename}' for JSON File Adapter" ) def _raw_load(self) -> dict[str, t.Any]: """ Load the file contents as JSON and return the resulting dict object. If a dict is not found, raises an error. """ with open(self.filename, encoding="utf-8") as f: val = json.load(f) if not isinstance(val, dict): self._invalid("Found non-dict root data") return val def _handle_formats(self, read_data: dict[str, t.Any]) -> _JSONFileData: """Handle older data formats supported by globus_sdk.token_storage if the data is not in a known/recognized format, this will error otherwise, reshape the data to the current supported format and return it """ format_version = read_data.get("format_version") if format_version not in self.supported_versions: raise ValueError( f"cannot store data using SimpleJSONFileAdapter({self.filename} " "existing data file is in an unknown format " f"(format_version={format_version})" ) if not isinstance(read_data.get("by_rs"), dict): raise ValueError( f"cannot store data using SimpleJSONFileAdapter({self.filename} " "existing data file is malformed" ) if any( k not in read_data for k in ("by_rs", "format_version", "globus-sdk.version") ): self._invalid("Missing required keys") if not isinstance(by_rs_dict := read_data["by_rs"], dict) or any( not isinstance(k, str) for k in by_rs_dict ): self._invalid("Invalid 'by_rs'") if not isinstance(read_data["format_version"], str): self._invalid("Invalid 'format_version'") if not isinstance(read_data["globus-sdk.version"], str): self._invalid("Invalid 'globus-sdk.version'") return read_data # type: ignore[return-value] def _load(self) -> _JSONFileData: """ Load data from the file and ensure that the data is in a modern format which can be handled by the rest of the adapter. If the file is missing, this will return a "skeleton" for new data. """ try: data = self._raw_load() except FileNotFoundError: return { "by_rs": {}, "format_version": self.format_version, "globus-sdk.version": __version__, } return self._handle_formats(data) def store(self, token_response: globus_sdk.OAuthTokenResponse) -> None: """ By default, ``self.on_refresh`` is just an alias for this function. Given a token response, extract all the token data and write it to ``self.filename`` as JSON data. Additionally will write the version of ``globus_sdk.token_storage`` which was in use. Under the assumption that this may be running on a system with multiple local users, this sets the umask such that only the owner of the resulting file can read or write it. :param token_response: The token data received from the refresh """ to_write = self._load() # copy the data from the by_resource_server attribute # # if the file did not exist and we're handling the initial token response, this # is a full copy of all of the token data # # if the file already exists and we're handling a token refresh, we only modify # newly received tokens to_write["by_rs"].update(token_response.by_resource_server) # deny rwx to Group and World, exec to User with self.user_only_umask(): with open(self.filename, "w", encoding="utf-8") as f: json.dump(to_write, f) def get_by_resource_server(self) -> dict[str, t.Any]: """ Read only the by_resource_server formatted data from the file, discarding any other keys. This returns a dict in the same format as ``OAuthTokenResponse.by_resource_server`` """ return self._load()["by_rs"] def get_token_data(self, resource_server: str) -> dict[str, t.Any] | None: return self.get_by_resource_server().get(resource_server) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/legacy/memory_adapter.py000066400000000000000000000011701513221403200313320ustar00rootroot00000000000000from __future__ import annotations import typing as t import globus_sdk from .base import StorageAdapter class MemoryAdapter(StorageAdapter): """ A token storage adapter which stores tokens in process memory. Tokens are lost when the process exits. """ def __init__(self) -> None: self._tokens: dict[str, dict[str, t.Any]] = {} def store(self, token_response: globus_sdk.OAuthTokenResponse) -> None: self._tokens.update(token_response.by_resource_server) def get_token_data(self, resource_server: str) -> dict[str, t.Any] | None: return self._tokens.get(resource_server) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/legacy/sqlite_adapter.py000066400000000000000000000250431513221403200313300ustar00rootroot00000000000000from __future__ import annotations import json import pathlib import sqlite3 import typing as t import globus_sdk from globus_sdk import __version__ from .base import FileAdapter class SQLiteAdapter(FileAdapter): """ :param dbname: The name of the DB file to write to and read from. If the string ":memory:" is used, an in-memory database will be used instead. :param namespace: A "namespace" to use within the database. All operations will be performed indexed under this string, so that multiple distinct sets of tokens may be stored in the database. You might use usernames as the namespace to implement a multi-user system, or profile names to allow multiple Globus accounts to be used by a single user. :param connect_params: A pass-through dictionary for fine-tuning the SQLite connection. A storage adapter for storing tokens in sqlite databases. SQLite adapters are for more complex cases, where there may be multiple users or "profiles" in play, and additionally a dynamic set of resource servers which need to be stored in an extensible way. The ``namespace`` is a user-supplied way of partitioning data, and any token responses passed to the storage adapter are broken apart and stored indexed by *resource_server*. If you have a more complex use-case in which this scheme will be insufficient, you should encode that in your choice of ``namespace`` values. The ``connect_params`` is an optional dictionary whose elements are passed directly to the underlying ``sqlite3.connect()`` method, enabling developers to fine-tune the connection to the SQLite database. Refer to the ``sqlite3.connect()`` documentation for SQLite-specific parameters. """ def __init__( self, dbname: pathlib.Path | str, *, namespace: str = "DEFAULT", connect_params: dict[str, t.Any] | None = None, ) -> None: self.filename = self.dbname = str(dbname) self.namespace = namespace self._connection = self._init_and_connect(connect_params) def _is_memory_db(self) -> bool: return self.dbname == ":memory:" def _init_and_connect( self, connect_params: dict[str, t.Any] | None, ) -> sqlite3.Connection: init_tables = self._is_memory_db() or not self.file_exists() connect_params = connect_params or {} if init_tables and not self._is_memory_db(): # real file needs to be created with self.user_only_umask(): conn: sqlite3.Connection = sqlite3.connect( self.dbname, **connect_params ) else: conn = sqlite3.connect(self.dbname, **connect_params) if init_tables: conn.executescript( """ CREATE TABLE config_storage ( namespace VARCHAR NOT NULL, config_name VARCHAR NOT NULL, config_data_json VARCHAR NOT NULL, PRIMARY KEY (namespace, config_name) ); CREATE TABLE token_storage ( namespace VARCHAR NOT NULL, resource_server VARCHAR NOT NULL, token_data_json VARCHAR NOT NULL, PRIMARY KEY (namespace, resource_server) ); CREATE TABLE sdk_storage_adapter_internal ( attribute VARCHAR NOT NULL, value VARCHAR NOT NULL, PRIMARY KEY (attribute) ); """ ) # mark the version which was used to create the DB # also mark the "database schema version" in case we ever need to handle # graceful upgrades conn.executemany( "INSERT INTO sdk_storage_adapter_internal(attribute, value) " "VALUES (?, ?)", [ ("globus-sdk.version", __version__), ("globus-sdk.database_schema_version", "1"), ], ) conn.commit() return conn def close(self) -> None: """ Close the underlying database connection. """ self._connection.close() def store_config( self, config_name: str, config_dict: t.Mapping[str, t.Any] ) -> None: """ :param config_name: A string name for the configuration value :param config_dict: A dict of config which will be stored serialized as JSON Store a config dict under the current namespace in the config table. Allows arbitrary configuration data to be namespaced under the namespace, so that application config may be associated with the stored tokens. Uses sqlite "REPLACE" to perform the operation. """ self._connection.execute( "REPLACE INTO config_storage(namespace, config_name, config_data_json) " "VALUES (?, ?, ?)", (self.namespace, config_name, json.dumps(config_dict)), ) self._connection.commit() def read_config(self, config_name: str) -> dict[str, t.Any] | None: """ :param config_name: A string name for the configuration value Load a config dict under the current namespace in the config table. If no value is found, returns None """ row = self._connection.execute( "SELECT config_data_json FROM config_storage " "WHERE namespace=? AND config_name=?", (self.namespace, config_name), ).fetchone() if row is None: return None config_data_json = row[0] val = json.loads(config_data_json) if not isinstance(val, dict): raise ValueError("reading config data and got non-dict result") return val def remove_config(self, config_name: str) -> bool: """ :param config_name: A string name for the configuration value Delete a previously stored configuration value. Returns True if data was deleted, False if none was found to delete. """ rowcount = self._connection.execute( "DELETE FROM config_storage WHERE namespace=? AND config_name=?", (self.namespace, config_name), ).rowcount self._connection.commit() return rowcount != 0 def store(self, token_response: globus_sdk.OAuthTokenResponse) -> None: """ :param token_response: a globus_sdk.OAuthTokenResponse object containing token data to store By default, ``self.on_refresh`` is just an alias for this function. Given a token response, extract the token data for the resource servers and write it to ``self.dbname``, stored under the adapter's namespace """ pairs = [] for rs_name, token_data in token_response.by_resource_server.items(): pairs.append((rs_name, token_data)) self._connection.executemany( "REPLACE INTO token_storage(namespace, resource_server, token_data_json) " "VALUES(?, ?, ?)", [ (self.namespace, rs_name, json.dumps(token_data)) for (rs_name, token_data) in pairs ], ) self._connection.commit() def get_token_data(self, resource_server: str) -> dict[str, t.Any] | None: """ Load the token data JSON for a specific resource server. In the event that the server cannot be found in the DB, return None. :param resource_server: The name of a resource server to lookup in the DB, as one would use as a key in OAuthTokenResponse.by_resource_server """ for row in self._connection.execute( "SELECT token_data_json FROM token_storage " "WHERE namespace=? AND resource_server=?", (self.namespace, resource_server), ): (token_data_json,) = row val = json.loads(token_data_json) if not isinstance(val, dict): raise ValueError("data error: token data was not saved as a dict") return val return None def get_by_resource_server(self) -> dict[str, t.Any]: """ Load the token data JSON and return the resulting dict objects, indexed by resource server. This should look identical to an OAuthTokenResponse.by_resource_server in format and content. (But it is not attached to a token response object.) """ data = {} for row in self._connection.execute( "SELECT resource_server, token_data_json " "FROM token_storage WHERE namespace=?", (self.namespace,), ): resource_server, token_data_json = row data[resource_server] = json.loads(token_data_json) return data def remove_tokens_for_resource_server(self, resource_server: str) -> bool: """ Given a resource server to target, delete tokens for that resource server from the database (limited to the current namespace). You can use this as part of a logout command implementation, loading token data as a dict, and then deleting the data for each resource server. Returns True if token data was deleted, False if none was found to delete. :param resource_server: The name of the resource server to remove from the DB, as one would use as a key in OAuthTokenResponse.by_resource_server """ rowcount = self._connection.execute( "DELETE FROM token_storage WHERE namespace=? AND resource_server=?", (self.namespace, resource_server), ).rowcount self._connection.commit() return rowcount != 0 def iter_namespaces( self, *, include_config_namespaces: bool = False ) -> t.Iterator[str]: """ Iterate over the namespaces which are in use in this storage adapter's database. The presence of tokens for a namespace does not indicate that those tokens are valid, only that they have been stored and have not been removed. :param include_config_namespaces: Include namespaces which appear only in the configuration storage section of the sqlite database. By default, only namespaces which were used for token storage will be returned """ seen: set[str] = set() for row in self._connection.execute( "SELECT DISTINCT namespace FROM token_storage;" ): namespace = row[0] seen.add(namespace) yield namespace if include_config_namespaces: for row in self._connection.execute( "SELECT DISTINCT namespace FROM config_storage;" ): namespace = row[0] if namespace not in seen: yield namespace globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/memory.py000066400000000000000000000036211513221403200263710ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from .base import TokenStorage from .token_data import TokenStorageData if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusAppConfig class MemoryTokenStorage(TokenStorage): """ A token storage which holds tokens in-memory. All token data is lost when the process exits. See :class:`TokenStorage` for common interface details. :param namespace: A unique string for partitioning token data (Default: "DEFAULT"). """ def __init__(self, *, namespace: str = "DEFAULT") -> None: self._tokens: dict[str, dict[str, t.Any]] = {} super().__init__(namespace=namespace) @classmethod def for_globus_app( cls, # pylint: disable=unused-argument client_id: uuid.UUID | str, app_name: str, config: GlobusAppConfig, # pylint: enable=unused-argument namespace: str, ) -> MemoryTokenStorage: return cls(namespace=namespace) def store_token_data_by_resource_server( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData] ) -> None: if self.namespace not in self._tokens: self._tokens[self.namespace] = {} for resource_server, token_data in token_data_by_resource_server.items(): self._tokens[self.namespace][resource_server] = token_data.to_dict() def get_token_data_by_resource_server(self) -> dict[str, TokenStorageData]: ret = {} dicts_by_resource_server = self._tokens.get(self.namespace, {}) for resource_server, token_data_dict in dicts_by_resource_server.items(): ret[resource_server] = TokenStorageData.from_dict(token_data_dict) return ret def remove_token_data(self, resource_server: str) -> bool: popped = self._tokens.get(self.namespace, {}).pop(resource_server, None) return popped is not None globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/sqlite.py000066400000000000000000000146021513221403200263630ustar00rootroot00000000000000from __future__ import annotations import json import pathlib import sqlite3 import textwrap import typing as t from globus_sdk import __version__, exc from .base import FileTokenStorage from .token_data import TokenStorageData class SQLiteTokenStorage(FileTokenStorage): """ A token storage which stores token data on disk in a SQLite database. See :class:`TokenStorage` for common interface details. :param filepath: The path on disk to a SQLite database file. :param connect_params: A dictionary of parameters to pass to ``sqlite3.connect()``. :param namespace: A unique string for partitioning token data (Default: "DEFAULT"). :raises GlobusSDKUsageError: If the filepath is ":memory:". This usage-mode is not supported in this class; use :class:`MemoryTokenStorage` instead if in-memory token storage is desired. """ file_format = "db" def __init__( self, filepath: pathlib.Path | str, *, connect_params: dict[str, t.Any] | None = None, namespace: str = "DEFAULT", ) -> None: if filepath == ":memory:": raise exc.GlobusSDKUsageError( "SQLiteTokenStorage cannot be used with a ':memory:' database. " "If you want to store tokens in memory, use MemoryTokenStorage instead." ) super().__init__(filepath, namespace=namespace) self._connection = self._init_and_connect(connect_params) def _init_and_connect( self, connect_params: dict[str, t.Any] | None, ) -> sqlite3.Connection: connect_params = connect_params or {} if not self.file_exists(): with self.user_only_umask(): conn: sqlite3.Connection = sqlite3.connect( self.filepath, **connect_params ) conn.executescript( textwrap.dedent( """ CREATE TABLE token_storage ( namespace VARCHAR NOT NULL, resource_server VARCHAR NOT NULL, token_data_json VARCHAR NOT NULL, PRIMARY KEY (namespace, resource_server) ); CREATE TABLE sdk_storage_adapter_internal ( attribute VARCHAR NOT NULL, value VARCHAR NOT NULL, PRIMARY KEY (attribute) ); """ ) ) # mark the version which was used to create the DB # also mark the "database schema version" in case we ever need to handle # graceful upgrades conn.executemany( "INSERT INTO sdk_storage_adapter_internal(attribute, value) " "VALUES (?, ?)", [ ("globus-sdk.version", __version__), # schema_version=1 indicates a schema built with the original # SQLiteAdapter # schema_version=2 indicates one built with SQLiteTokenStorage # # a schema_version of 1 therefore indicates that there should be # a 'config_storage' table present ("globus-sdk.database_schema_version", "2"), ], ) conn.commit() else: conn = sqlite3.connect(self.filepath, **connect_params) return conn def close(self) -> None: """ Close the underlying database connection. """ self._connection.close() def store_token_data_by_resource_server( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData] ) -> None: """ Store token data for one or more resource servers in the current namespace. Token data is JSON-serialized before being inserted into the database. :param token_data_by_resource_server: A mapping of resource servers to token data. """ pairs = [] for resource_server, token_data in token_data_by_resource_server.items(): pairs.append((resource_server, token_data.to_dict())) self._connection.executemany( "REPLACE INTO token_storage(namespace, resource_server, token_data_json) " "VALUES(?, ?, ?)", [ (self.namespace, rs_name, json.dumps(token_data_dict)) for (rs_name, token_data_dict) in pairs ], ) self._connection.commit() def get_token_data_by_resource_server(self) -> dict[str, TokenStorageData]: """ Lookup all token data under the current namespace from the database. :returns: A dict of ``TokenStorageData`` objects indexed by their resource server. """ ret: dict[str, TokenStorageData] = {} for row in self._connection.execute( "SELECT resource_server, token_data_json " "FROM token_storage WHERE namespace=?", (self.namespace,), ): resource_server, token_data_json = row token_data_dict = json.loads(token_data_json) ret[resource_server] = TokenStorageData.from_dict(token_data_dict) return ret def remove_token_data(self, resource_server: str) -> bool: """ Given a resource server to target, delete token data for that resource server from the database (limited to the current namespace). You can use this as part of a logout command implementation, loading token data as a dict, and then deleting the data for each resource server. :param resource_server: The name of the resource server to remove from the DB :returns: True if token data was deleted, False if none was found to delete. """ rowcount = self._connection.execute( "DELETE FROM token_storage WHERE namespace=? AND resource_server=?", (self.namespace, resource_server), ).rowcount self._connection.commit() return rowcount != 0 def iter_namespaces(self) -> t.Iterator[str]: """Iterate over all distinct namespaces in the SQLite database.""" seen: set[str] = set() for row in self._connection.execute( "SELECT DISTINCT namespace FROM token_storage;" ): namespace = row[0] seen.add(namespace) yield namespace globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/token_data.py000066400000000000000000000050241513221403200271710ustar00rootroot00000000000000from __future__ import annotations import typing as t from globus_sdk._internal.guards import validators from globus_sdk._internal.serializable import Serializable class TokenStorageData(Serializable): """ Data class for tokens and token metadata issued by a globus auth token grant. For storage and retrieval of these objects, see :class:`TokenStorage`. Tokens are scoped to a specific user/client (`identity_id`) performing specific operations (`scope`) with a specific service (`resource_server`). :ivar str resource_server: The resource server for which this token data was granted. :ivar str identity_id: The primary identity id of the user or client which requested this token. This will be None if an identity id was not extractable from the token grant response. :ivar str scope: A space separated list of scopes that this token data provides access to. :ivar str access_token: A Globus Auth-issued OAuth2 access token. Used for authentication when interacting with service APIs. :ivar str | None refresh_token: A Globus Auth-issued OAuth2 refresh token. Used to obtain new access tokens when the current one expires. This value will be None if the original token grant did not request refresh tokens. :ivar int expires_at_seconds: An epoch seconds timestamp for when the associated access_token expires. :ivar str | None token_type: The token type of access_token, currently this will always be "Bearer" if present. :param extra: An optional dictionary of additional fields to include. Included for forward/backward compatibility. """ def __init__( self, resource_server: str, identity_id: str | None, scope: str, access_token: str, refresh_token: str | None, expires_at_seconds: int, token_type: str | None, extra: dict[str, t.Any] | None = None, ) -> None: self.resource_server = validators.str_("resource_server", resource_server) self.identity_id = validators.opt_str("identity_id", identity_id) self.scope = validators.str_("scope", scope) self.access_token = validators.str_("access_token", access_token) self.refresh_token = validators.opt_str("refresh_token", refresh_token) self.expires_at_seconds = validators.int_( "expires_at_seconds", expires_at_seconds ) self.token_type = validators.opt_str("token_type", token_type) self.extra = extra or {} globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/validating_token_storage/000077500000000000000000000000001513221403200315535ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/validating_token_storage/__init__.py000066400000000000000000000016301513221403200336640ustar00rootroot00000000000000from .context import TokenValidationContext from .errors import ( ExpiredTokenError, IdentityMismatchError, IdentityValidationError, MissingIdentityError, MissingTokenError, TokenValidationError, UnmetScopeRequirementsError, ) from .storage import ValidatingTokenStorage from .validators import ( HasRefreshTokensValidator, NotExpiredValidator, ScopeRequirementsValidator, TokenDataValidator, UnchangingIdentityIDValidator, ) __all__ = ( "TokenDataValidator", "TokenValidationContext", "ValidatingTokenStorage", "HasRefreshTokensValidator", "NotExpiredValidator", "ScopeRequirementsValidator", "UnchangingIdentityIDValidator", # errors "TokenValidationError", "IdentityValidationError", "IdentityMismatchError", "MissingIdentityError", "MissingTokenError", "ExpiredTokenError", "UnmetScopeRequirementsError", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/validating_token_storage/context.py000066400000000000000000000011261513221403200336110ustar00rootroot00000000000000from __future__ import annotations import dataclasses @dataclasses.dataclass class TokenValidationContext: """ A data object of information available to :class:`TokenDataValidators ` during validation. :ivar str | None prior_identity_id: The identity ID associated with the :class:`ValidatingTokenStorage` before the operation began, if there was one. :ivar str | None token_data_identity_id: The identity ID extracted from the token data being validated. """ prior_identity_id: str | None token_data_identity_id: str | None globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/validating_token_storage/errors.py000066400000000000000000000037211513221403200334440ustar00rootroot00000000000000from __future__ import annotations import uuid from datetime import datetime from globus_sdk import GlobusError, Scope class TokenValidationError(GlobusError): """The class of errors raised when a token fails validation.""" class IdentityValidationError(TokenValidationError): """ The class of errors raised when a token response's identity is indeterminate or incorrect. """ class MissingIdentityError(IdentityValidationError, LookupError): """No identity info contained in a token response.""" class IdentityMismatchError(IdentityValidationError, ValueError): """The identity in a token response did not match the expected identity.""" def __init__( self, message: str, stored_id: uuid.UUID | str, new_id: uuid.UUID | str ) -> None: super().__init__(message) self.stored_id = stored_id self.new_id = new_id class MissingTokenError(TokenValidationError, LookupError): """No token stored for a given resource server.""" def __init__(self, message: str, resource_server: str) -> None: super().__init__(message) self.resource_server = resource_server class ExpiredTokenError(TokenValidationError, ValueError): """The token stored for a given resource server has expired.""" def __init__(self, expires_at_seconds: int) -> None: expiration = datetime.fromtimestamp(expires_at_seconds) super().__init__(f"Token expired at {expiration.isoformat()}") self.expiration = expiration class UnmetScopeRequirementsError(TokenValidationError, ValueError): """The token stored for a given resource server is missing required scopes.""" def __init__( self, message: str, scope_requirements: dict[str, list[Scope]] ) -> None: super().__init__(message) # The full set of scope requirements which were evaluated. # Notably this is not exclusively the unmet scope requirements. self.scope_requirements = scope_requirements globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/validating_token_storage/storage.py000066400000000000000000000134131513221403200335730ustar00rootroot00000000000000from __future__ import annotations import typing as t import globus_sdk from ..base import TokenStorage from ..token_data import TokenStorageData from .context import TokenValidationContext from .errors import MissingTokenError from .validators import TokenDataValidator class ValidatingTokenStorage(TokenStorage): """ A special token storage which provides token data validation hooks. See :class:`TokenDataValidator` for details on hook specifics. See :class:`TokenStorage` for common interface details. :param token_storage: A proxy token storage for this class to pass through store, get, and remove requests to. :param validators: A collection of validation hooks to call. :ivar str | None identity_id: The primary identity ID of the entity which granted tokens, if known. """ def __init__( self, token_storage: TokenStorage, *, validators: t.Iterable[TokenDataValidator] = (), ) -> None: self.token_storage = token_storage self.validators: list[TokenDataValidator] = list(validators) self.identity_id = _identity_id_from_token_data( token_storage.get_token_data_by_resource_server() ) super().__init__(namespace=token_storage.namespace) def close(self) -> None: """Closing a validating storage closes the storage it contains.""" self.token_storage.close() def _make_context( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData] ) -> TokenValidationContext: """ Build a TokenValidationContext object and potentially update the stored ``identity_id`` information in this ValidatingTokenStorage. Importantly, this records ``self.identity_id`` into the ``prior_identity_id`` slot before applying this update. """ context = TokenValidationContext( prior_identity_id=self.identity_id, token_data_identity_id=_identity_id_from_token_data( token_data_by_resource_server ), ) if self.identity_id is None: self.identity_id = context.token_data_identity_id return context def store_token_data_by_resource_server( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData] ) -> None: """ :param token_data_by_resource_server: A dict of TokenStorageData objects indexed by their resource server """ context = self._make_context(token_data_by_resource_server) for validator in self.validators: validator.before_store(token_data_by_resource_server, context) self.token_storage.store_token_data_by_resource_server( token_data_by_resource_server ) def get_token_data(self, resource_server: str) -> TokenStorageData: """ :param resource_server: A resource server with cached token data. :returns: The token data for the given resource server. :raises: :exc:`MissingTokenError` if the underlying ``TokenStorage`` does not have any token data for the given resource server. """ token_data = self.token_storage.get_token_data(resource_server) if token_data is None: msg = f"No token data for {resource_server}" raise MissingTokenError(msg, resource_server=resource_server) token_data_by_resource_server = {token_data.resource_server: token_data} context = self._make_context(token_data_by_resource_server) for validator in self.validators: validator.after_retrieve(token_data_by_resource_server, context) return token_data def get_token_data_by_resource_server(self) -> dict[str, TokenStorageData]: token_data_by_resource_server = ( self.token_storage.get_token_data_by_resource_server() ) context = self._make_context(token_data_by_resource_server) for validator in self.validators: validator.after_retrieve(token_data_by_resource_server, context) return token_data_by_resource_server def remove_token_data(self, resource_server: str) -> bool: """ :param resource_server: The resource server string to remove token data for """ return self.token_storage.remove_token_data(resource_server) def _extract_identity_id( self, token_response: globus_sdk.OAuthTokenResponse ) -> str | None: """ Override determination of the identity_id for a token response. When handling a refresh token, use the stored identity ID if possible. Otherwise, call the inner token storage's method of lookup. """ if isinstance(token_response, globus_sdk.OAuthRefreshTokenResponse): return self.identity_id else: return self.token_storage._extract_identity_id(token_response) def _identity_id_from_token_data( token_data_by_resource_server: t.Mapping[str, TokenStorageData], ) -> str | None: """ Read token data by resource server and return the ``identity_id`` value which was produced. :param token_data_by_resource_server: The token data to read for identity_id. :raises ValueError: if there is inconsistent ``identity_id`` information """ token_data_identity_ids: set[str] = { token_data.identity_id for token_data in token_data_by_resource_server.values() if token_data.identity_id is not None } if len(token_data_identity_ids) == 0: return None elif len(token_data_identity_ids) == 1: return token_data_identity_ids.pop() else: raise ValueError( "token_data_by_resource_server contained TokenStorageData objects with " f"different identity_id values: {token_data_identity_ids}" ) globus-globus-sdk-python-6a080e4/src/globus_sdk/token_storage/validating_token_storage/validators.py000066400000000000000000000241311513221403200342760ustar00rootroot00000000000000from __future__ import annotations import abc import time import typing as t import globus_sdk from globus_sdk.scopes.consents import ConsentForest from ..token_data import TokenStorageData from .context import TokenValidationContext from .errors import ( ExpiredTokenError, IdentityMismatchError, MissingIdentityError, MissingTokenError, UnmetScopeRequirementsError, ) class TokenDataValidator(abc.ABC): """ The abstract base class for custom token validation logic. Implementations should raise a :class:`TokenValidationError` if a validation error is encountered. """ @abc.abstractmethod def before_store( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, ) -> None: """ Validate token data against this validator's constraints before it is written to token storage. :param token_data_by_resource_server: The data to validate. :param context: The validation context object, containing state of the system at the time of validation. :raises TokenValidationError: On failure. """ @abc.abstractmethod def after_retrieve( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, ) -> None: """ Validate token data against this validator's constraints after it is retrieved from token storage. :param token_data_by_resource_server: The data to validate. :param context: The validation context object, containing state of the system at the time of validation. :raises TokenValidationError: On failure. """ class _OnlyBeforeValidator(TokenDataValidator, abc.ABC): def after_retrieve( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, ) -> None: return None class _OnlyAfterValidator(TokenDataValidator, abc.ABC): def before_store( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, ) -> None: return None class HasRefreshTokensValidator(_OnlyAfterValidator): """ A validator to validate that token data contains refresh tokens. * This validator only runs `after_retrieve`. :raises MissingTokenError: If any token data does not contain a refresh token. """ def after_retrieve( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, # pylint: disable=unused-argument ) -> None: for token_data in token_data_by_resource_server.values(): if token_data.refresh_token is None: msg = f"No refresh_token for {token_data.resource_server}" raise MissingTokenError(msg, resource_server=token_data.resource_server) class NotExpiredValidator(_OnlyAfterValidator): """ A validator to validate that token data has not expired. * This validator only runs `after_retrieve`. :raises ExpiredTokenError: If any token data shows has expired. """ def after_retrieve( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, # pylint: disable=unused-argument ) -> None: for token_data in token_data_by_resource_server.values(): if token_data.expires_at_seconds < time.time(): raise ExpiredTokenError(token_data.expires_at_seconds) class UnchangingIdentityIDValidator(_OnlyBeforeValidator): """ A validator to validate that user identity does not change across token grants. * This validator only runs `before_store`. :raises IdentityMismatchError: If identity info changes across token storage operations. :raises MissingIdentityError: If the token data did not have any identity information. """ def before_store( self, token_data_by_resource_server: t.Mapping[ # pylint: disable=unused-argument str, TokenStorageData ], context: TokenValidationContext, ) -> None: if context.token_data_identity_id is None: raise MissingIdentityError( "Token grant response doesn't contain an id_token. This normally " "occurs if the auth flow didn't include 'openid' alongside other " "scopes." ) # no prior ID means we cannot validate the content further if context.prior_identity_id is None: return if context.token_data_identity_id != context.prior_identity_id: raise IdentityMismatchError( "Detected a change in identity associated with the token data.", stored_id=context.prior_identity_id, new_id=context.token_data_identity_id, ) class ScopeRequirementsValidator(TokenDataValidator): """ A validator to validate that token data meets scope requirements. A scope requirement, i.e., "transfer:all[collection:data_access]", can be broken into two parts: the **root scope**, "transfer:all", and one or more **dependent scopes**, "collection:data_access". * On `before_store`, this validator only evaluates **root** scope requirements. * On `after_retrieve`, this validator evaluates both **root** and **dependent** scope requirements. .. note:: Dependent scopes are only evaluated if an identity ID is extractable from token grants. If no identity ID is available, dependent scope evaluation is silently skipped. :param scope_requirements: A mapping of resource servers to required scopes. :param consent_client: An AuthClient to fetch consents with. This auth client must have (or have access to) any valid Globus Auth scoped token. :raises UnmetScopeRequirementsError: If any scope requirements are not met. """ def __init__( self, scope_requirements: t.Mapping[str, t.Sequence[globus_sdk.Scope]], consent_client: globus_sdk.AuthClient, ) -> None: self.scope_requirements = scope_requirements self.consent_client: globus_sdk.AuthClient = consent_client self._cached_consent_forest: ConsentForest | None = None def before_store( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, ) -> None: identity_id = context.token_data_identity_id or context.prior_identity_id for resource_server, token_data in token_data_by_resource_server.items(): self._validate_token_data_meets_scope_requirements( resource_server=resource_server, token_data=token_data, identity_id=identity_id, eval_dependent=False, ) def after_retrieve( self, token_data_by_resource_server: t.Mapping[str, TokenStorageData], context: TokenValidationContext, ) -> None: identity_id = context.token_data_identity_id or context.prior_identity_id for token_data in token_data_by_resource_server.values(): self._validate_token_data_meets_scope_requirements( resource_server=token_data.resource_server, token_data=token_data, identity_id=identity_id, ) def _validate_token_data_meets_scope_requirements( self, *, resource_server: str, token_data: TokenStorageData, identity_id: str | None, eval_dependent: bool = True, ) -> None: """ Evaluate whether the scope requirements for a given resource server are met. :param resource_server: A resource server to access scope requirements. :param token_data: A token data object to validate. :param identity_id: The identity ID of the user, from the surrounding context. :param eval_dependent: Whether to evaluate dependent scope requirements. :raises UnmetScopeRequirements: If token/consent data does not meet the attached root or dependent scope requirements for the resource server. """ required_scopes = self.scope_requirements.get(resource_server) # Short circuit - No scope requirements are, by definition, met. if required_scopes is None: return # 1. Does the token meet root scope requirements? root_scopes = token_data.scope.split() if not all(scope.scope_string in root_scopes for scope in required_scopes): raise UnmetScopeRequirementsError( "Unmet scope requirements", scope_requirements={ k: list(v) for k, v in self.scope_requirements.items() }, ) # Short circuit - No dependent scopes; don't validate them. if not eval_dependent or not any( scope.dependencies for scope in required_scopes ): return # 2. Does the consent forest meet all dependent scope requirements? # 2a. Try with the cached consent forest first. forest = self._cached_consent_forest if forest is not None and forest.meets_scope_requirements(required_scopes): return # Identity id is required to fetch consents. # if we cannot fetch consents, we cannot do any further validation if identity_id is None: return # 2b. Poll for fresh consents and try again. forest = self._poll_and_cache_consents(identity_id) if not forest.meets_scope_requirements(required_scopes): raise UnmetScopeRequirementsError( "Unmet dependent scope requirements", scope_requirements={ k: list(v) for k, v in self.scope_requirements.items() }, ) def _poll_and_cache_consents(self, identity_id: str) -> ConsentForest: forest = self.consent_client.get_consents(identity_id).to_forest() self._cached_consent_forest = forest return forest globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/000077500000000000000000000000001513221403200236755ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/__init__.py000066400000000000000000000014261513221403200260110ustar00rootroot00000000000000from ._clientinfo import GlobusClientInfo from .caller_info import RequestCallerInfo from .encoders import FormRequestEncoder, JSONRequestEncoder, RequestEncoder from .requests import RequestsTransport from .retry import ( RetryCheck, RetryCheckCollection, RetryCheckFlags, RetryCheckResult, RetryContext, set_retry_check_flags, ) from .retry_check_runner import RetryCheckRunner from .retry_config import RetryConfig __all__ = ( "RequestsTransport", "RequestCallerInfo", "RetryCheck", "RetryCheckCollection", "RetryCheckFlags", "RetryCheckResult", "RetryCheckRunner", "set_retry_check_flags", "RetryContext", "RetryConfig", "RequestEncoder", "JSONRequestEncoder", "FormRequestEncoder", "GlobusClientInfo", ) globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/_clientinfo.py000066400000000000000000000104721513221403200265440ustar00rootroot00000000000000""" This module models a read-write object representation of the X-Globus-Client-Info header. The spec for X-Globus-Client-Info is documented, in brief, in the GlobusClientInfo class docstring. """ from __future__ import annotations import typing as t from globus_sdk import __version__, exc _RESERVED_CHARS = ";,=" class GlobusClientInfo: """ An implementation of X-Globus-Client-Info as an object. This header encodes a mapping of multiple products to versions and potentially other information. Values can be added to a clientinfo object via the ``add()`` method. The object always initializes itself to start with product=python-sdk,version=... using the current package version information. .. rubric:: ``X-Globus-Client-Info`` Specification Header Name: ``X-Globus-Client-Info`` Header Value: - A semicolon (``;``) separated list of client information. - Client information is a comma-separated list of ``=`` delimited key-value pairs. Well-known values for client-information are: - ``product``: A unique identifier of the product. - ``version``: Relevant version information for the product. - Based on the above, the characters ``;,=`` should be considered reserved and should NOT be included in client information values to ensure proper parsing. .. rubric:: Example Headers .. code-block:: none X-Globus-Client-Info: product=python-sdk,version=3.32.1 X-Globus-Client-Info: product=python-sdk,version=3.32.1;product=cli,version=4.0.0a1 .. note:: The ``GlobusClientInfo`` object is not guaranteed to reject all invalid usages. For example, ``product`` is required to be unique per header, and users are expected to enforce this in their usage. :param update_callback: A callback function to be invoked each time the content of the GlobusClientInfo changes via ``add()`` or ``clear()``. """ # noqa: E501 def __init__( self, *, update_callback: t.Callable[[GlobusClientInfo], None] | None = None ) -> None: self.infos: list[str] = [] # set `update_callback` to `None` at first so that `add()` can run without # triggering it during `__init__` self.update_callback = None self.add({"product": "python-sdk", "version": __version__}) self.update_callback = update_callback def __bool__(self) -> bool: """Check if there are any values present.""" return bool(self.infos) def format(self) -> str: """Format as a header value.""" return ";".join(self.infos) def add(self, value: str | dict[str, str]) -> None: """ Add an item to the clientinfo. The item is either already formatted as a string, or is a dict containing values to format. :param value: The element to add to the client-info. If it is a dict, it may not contain reserved characters in any keys or values. If it is a string, it cannot contain the ``;`` separator. """ if not isinstance(value, str): value = ",".join(_format_items(value)) elif ";" in value: raise exc.GlobusSDKUsageError( "GlobusClientInfo.add() cannot be used to add multiple items in " "an already-joined string. Add items separately instead. " f"Bad usage: '{value}'" ) self.infos.append(value) if self.update_callback is not None: self.update_callback(self) def clear(self) -> None: """Empty the list of info strings and trigger the update callback.""" self.infos = [] if self.update_callback is not None: self.update_callback(self) def _format_items(info: dict[str, str]) -> t.Iterable[str]: """Format the items in a dict, yielding the contents as an iterable.""" for key, value in info.items(): _check_reserved_chars(key, value) yield f"{key}={value}" def _check_reserved_chars(key: str, value: str) -> None: """Check a key-value pair to see if it uses reserved chars.""" if any(c in x for c in _RESERVED_CHARS for x in (key, value)): raise exc.GlobusSDKUsageError( "X-Globus-Client-Info reserved characters cannot be used in keys or " f"values. Bad usage: '{key}: {value}'" ) globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/caller_info.py000066400000000000000000000014351513221403200265270ustar00rootroot00000000000000from __future__ import annotations from globus_sdk.authorizers import GlobusAuthorizer from .retry_config import RetryConfig class RequestCallerInfo: """ Data object that holds contextual information about the caller of a request. :param retry_config: The configuration of retry checks for the call :param resource_server: The resource server the request is being made to. :param authorizer: The authorizer object from the client making the request """ def __init__( self, *, retry_config: RetryConfig, resource_server: str | None = None, authorizer: GlobusAuthorizer | None = None, ) -> None: self.resource_server = resource_server self.retry_config = retry_config self.authorizer = authorizer globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/default_retry_checks.py000066400000000000000000000067621513221403200304530ustar00rootroot00000000000000from __future__ import annotations import requests from .retry import ( RetryCheck, RetryCheckFlags, RetryCheckResult, RetryContext, set_retry_check_flags, ) def check_request_exception(ctx: RetryContext) -> RetryCheckResult: """ Check if a network error was encountered :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ if ctx.exception and isinstance(ctx.exception, requests.RequestException): return RetryCheckResult.do_retry return RetryCheckResult.no_decision def check_retry_after_header(ctx: RetryContext) -> RetryCheckResult: """ Check for a retry-after header if the response had a matching status :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ retry_config = ctx.caller_info.retry_config if ctx.response is None or ( ctx.response.status_code not in retry_config.retry_after_status_codes ): return RetryCheckResult.no_decision retry_after = _parse_retry_after(ctx.response) if retry_after: ctx.backoff = float(retry_after) return RetryCheckResult.do_retry def check_transient_error(ctx: RetryContext) -> RetryCheckResult: """ Check for transient error status codes which could be resolved by retrying the request :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ retry_config = ctx.caller_info.retry_config if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): return RetryCheckResult.do_retry return RetryCheckResult.no_decision @set_retry_check_flags(RetryCheckFlags.RUN_ONCE) def check_expired_authorization(ctx: RetryContext) -> RetryCheckResult: """ This check evaluates whether or not there is invalid or expired authorization information which could be updated with some action -- most typically a token refresh for an expired access token. The check is flagged to only run once per request. :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ retry_config = ctx.caller_info.retry_config if ( # is the current check applicable? ctx.response is None or ctx.caller_info is None or ctx.caller_info.authorizer is None or ( ctx.response.status_code not in retry_config.expired_authorization_status_codes ) ): return RetryCheckResult.no_decision # run the authorizer's handler, and 'do_retry' if the handler indicated # that it was able to make a change which should make the request retryable if ctx.caller_info.authorizer.handle_missing_authorization(): return RetryCheckResult.do_retry return RetryCheckResult.no_decision def _parse_retry_after(response: requests.Response) -> int | None: """ Get the 'Retry-After' header as an int. :param response: The response to parse. """ val = response.headers.get("Retry-After") if not val: return None try: return int(val) except ValueError: return None DEFAULT_RETRY_CHECKS: tuple[RetryCheck, ...] = ( check_expired_authorization, check_request_exception, check_retry_after_header, check_transient_error, ) globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/encoders.py000066400000000000000000000105501513221403200260520ustar00rootroot00000000000000from __future__ import annotations import enum import typing as t import uuid import requests from globus_sdk._missing import MISSING, filter_missing class RequestEncoder: """ A RequestEncoder takes input parameters and outputs a requests.Requests object. The default encoder requires that the data is text and is a no-op. It can also be referred to as the ``"text"`` encoder. """ def encode( self, method: str, url: str, params: dict[str, t.Any] | None, data: t.Any, headers: dict[str, str], ) -> requests.Request: if not isinstance(data, (str, bytes)): raise TypeError( "Cannot encode non-text in a text request. " "Either manually encode the data or use `encoding=form|json` to " "correctly format this data." ) return requests.Request( method, url, data=self._prepare_data(data), params=self._prepare_params(params), headers=self._prepare_headers(headers), ) def _format_primitive(self, value: t.Any) -> t.Any: """ Transformations for primitive values (e.g. stringifiable items) for query params, headers, and body elements. Transforms data as follows: x: UUID -> str(x) x: Enum -> x.value x: _ -> x """ if isinstance(value, uuid.UUID): return str(value) if isinstance(value, enum.Enum): return value.value return value def _prepare_params( self, params: dict[str, t.Any] | None ) -> dict[str, t.Any] | None: """ Prepare the query params for a request. Filters out MISSING and formats primitives. """ if params is None: return None return filter_missing({k: self._format_primitive(v) for k, v in params.items()}) def _prepare_headers( self, headers: dict[str, t.Any] | None ) -> dict[str, t.Any] | None: """ Prepare the headers for a request. Filters out MISSING and formats primitives. """ if headers is None: return None return filter_missing( {k: self._format_primitive(v) for k, v in headers.items()} ) def _prepare_data(self, data: t.Any) -> t.Any: """ Prepare the data (body) for a request. If the body is a dict, list, or tuple, it will be recursively processed to filter out MISSING and format primitives. Otherwise, it is returned as-is. """ if isinstance(data, dict): return filter_missing({k: self._prepare_data(v) for k, v in data.items()}) elif isinstance(data, (list, tuple)): return [self._prepare_data(x) for x in data if x is not MISSING] else: return self._format_primitive(data) class JSONRequestEncoder(RequestEncoder): """ This encoder prepares the data as JSON. It also ensures that content-type is set, so that APIs requiring a content-type of "application/json" are able to read the data. """ def encode( self, method: str, url: str, params: dict[str, t.Any] | None, data: t.Any, headers: dict[str, str], ) -> requests.Request: if data is not None: headers = {"Content-Type": "application/json", **headers} return requests.Request( method, url, json=self._prepare_data(data), params=self._prepare_params(params), headers=self._prepare_headers(headers), ) class FormRequestEncoder(RequestEncoder): """ This encoder formats data as a form-encoded body. It requires that the input data is a dict -- any other datatype will result in errors. """ def encode( self, method: str, url: str, params: dict[str, t.Any] | None, data: t.Any, headers: dict[str, str], ) -> requests.Request: if not isinstance(data, dict): raise TypeError("FormRequestEncoder cannot encode non-dict data") return requests.Request( method, url, data=self._prepare_data(data), params=self._prepare_params(params), headers=self._prepare_headers(headers), ) globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/requests.py000066400000000000000000000262201513221403200261240ustar00rootroot00000000000000from __future__ import annotations import contextlib import logging import pathlib import time import typing as t import requests from globus_sdk import __version__, config, exc from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.transport.encoders import ( FormRequestEncoder, JSONRequestEncoder, RequestEncoder, ) from ._clientinfo import GlobusClientInfo from .caller_info import RequestCallerInfo from .retry import RetryContext from .retry_check_runner import RetryCheckRunner from .retry_config import RetryConfig log = logging.getLogger(__name__) class RequestsTransport: """ The RequestsTransport handles HTTP request sending and retries. It receives raw request information from a client class, and then performs the following steps - encode the data in a prepared request - repeatedly send the request until no retry is requested by the configured hooks - return the last response or reraise the last exception If the maximum number of retries is reached, the final response or exception will be returned or raised. :param verify_ssl: Explicitly enable or disable SSL verification, or configure the path to a CA certificate bundle to use for SSL verification :param http_timeout: Explicitly set an HTTP timeout value in seconds. This parameter defaults to 60s but can be set via the ``GLOBUS_SDK_HTTP_TIMEOUT`` environment variable. Any value set via this parameter takes precedence over the environment variable. :ivar dict[str, str] headers: The headers which are sent on every request. These may be augmented by the transport when sending requests. """ #: default maximum number of retries DEFAULT_MAX_RETRIES = 5 #: the encoders are a mapping of encoding names to encoder objects encoders: dict[str, RequestEncoder] = { "text": RequestEncoder(), "json": JSONRequestEncoder(), "form": FormRequestEncoder(), } BASE_USER_AGENT = f"globus-sdk-py-{__version__}" def __init__( self, verify_ssl: bool | str | pathlib.Path | None = None, http_timeout: float | None = None, ) -> None: self.session = requests.Session() self.verify_ssl = config.get_ssl_verify(verify_ssl) self.http_timeout = config.get_http_timeout(http_timeout) self._user_agent = self.BASE_USER_AGENT self.globus_client_info: GlobusClientInfo = GlobusClientInfo( update_callback=self._handle_clientinfo_update ) self.headers: dict[str, str] = { "Accept": "application/json", "User-Agent": self.user_agent, "X-Globus-Client-Info": self.globus_client_info.format(), } def close(self) -> None: """ Closes all resources owned by the transport, primarily the underlying network session. """ self.session.close() @property def user_agent(self) -> str: return self._user_agent @user_agent.setter def user_agent(self, value: str) -> None: """ Set the ``user_agent`` and update the ``User-Agent`` header in ``headers``. :param value: The new user-agent string to set (after the base user-agent) """ self._user_agent = f"{self.BASE_USER_AGENT}/{value}" self.headers["User-Agent"] = self._user_agent def _handle_clientinfo_update( self, info: GlobusClientInfo, # pylint: disable=unused-argument ) -> None: """ When the attached ``GlobusClientInfo`` is updated, write it back into ``headers``. If the client info is cleared, it will be removed from the headers. """ formatted = self.globus_client_info.format() if formatted: self.headers["X-Globus-Client-Info"] = formatted else: # discard the element, so that this can be invoked multiple times self.headers.pop("X-Globus-Client-Info", None) @contextlib.contextmanager def tune( self, *, verify_ssl: bool | str | pathlib.Path | None = None, http_timeout: float | None = None, ) -> t.Iterator[None]: """ Temporarily adjust some of the request sending settings of the transport. This method works as a context manager, and will reset settings to their original values after it exits. :param verify_ssl: Explicitly enable or disable SSL verification, or configure the path to a CA certificate bundle to use for SSL verification :param http_timeout: Explicitly set an HTTP timeout value in seconds **Example Usage** This can be used with any client class to temporarily set values in the context of one or more HTTP requests. To increase the HTTP request timeout from the default of 60 to 120 seconds, >>> client = ... # any client class >>> with client.transport.tune(http_timeout=120): >>> foo = client.get_foo() See also: :meth:`RetryConfig.tune`. """ saved_settings = ( self.verify_ssl, self.http_timeout, ) if verify_ssl is not None: if isinstance(verify_ssl, bool): self.verify_ssl = verify_ssl else: self.verify_ssl = str(verify_ssl) if http_timeout is not None: self.http_timeout = http_timeout yield ( self.verify_ssl, self.http_timeout, ) = saved_settings def _encode( self, method: str, url: str, query_params: dict[str, t.Any] | None = None, data: dict[str, t.Any] | list[t.Any] | str | bytes | None = None, headers: dict[str, str] | None = None, encoding: str | None = None, ) -> requests.Request: if headers: headers = {**self.headers, **headers} else: headers = self.headers if encoding is None: if isinstance(data, (bytes, str)): encoding = "text" else: encoding = "json" if encoding not in self.encoders: raise ValueError( f"Unknown encoding '{encoding}' is not supported by this transport." ) return self.encoders[encoding].encode(method, url, query_params, data, headers) def _set_authz_header( self, authorizer: GlobusAuthorizer | None, req: requests.Request ) -> None: if authorizer: authz_header = authorizer.get_authorization_header() if authz_header: req.headers["Authorization"] = authz_header else: req.headers.pop("Authorization", None) # remove any possible value def _retry_sleep(self, retry_config: RetryConfig, ctx: RetryContext) -> None: """ Given a retry context, compute the amount of time to sleep and sleep that much This is always the minimum of the backoff (run on the context) and the ``max_sleep``. :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ sleep_period = min(retry_config.backoff(ctx), retry_config.max_sleep) log.debug( "request retry_sleep(%s) [max=%s]", sleep_period, retry_config.max_sleep, ) time.sleep(sleep_period) def request( self, method: str, url: str, *, caller_info: RequestCallerInfo, query_params: dict[str, t.Any] | None = None, data: dict[str, t.Any] | list[t.Any] | str | bytes | None = None, headers: dict[str, str] | None = None, encoding: str | None = None, allow_redirects: bool = True, stream: bool = False, ) -> requests.Response: """ Send an HTTP request :param url: URL for the request :param method: HTTP request method, as an all caps string :param caller_info: Contextual information about the caller of the request, including the authorizer and retry configuration. :param query_params: Parameters to be encoded as a query string :param headers: HTTP headers to add to the request :param data: Data to send as the request body. May pass through encoding. :param encoding: A way to encode request data. "json", "form", and "text" are all valid values. Custom encodings can be used only if they are registered with the transport. By default, strings get "text" behavior and all other objects get "json". :param allow_redirects: Follow Location headers on redirect response automatically. Defaults to ``True`` :param stream: Do not immediately download the response content. Defaults to ``False`` :return: ``requests.Response`` object """ log.debug("starting request for %s", url) resp: requests.Response | None = None req = self._encode(method, url, query_params, data, headers, encoding) retry_config = caller_info.retry_config checker = RetryCheckRunner(caller_info.retry_config.checks) log.debug("transport request state initialized") for attempt in range(retry_config.max_retries + 1): log.debug("transport request retry cycle. attempt=%d", attempt) # add Authorization header, or (if it's a NullAuthorizer) possibly # explicitly remove the Authorization header # done fresh for each request, to handle potential for refreshed credentials self._set_authz_header(caller_info.authorizer, req) ctx = RetryContext(attempt, caller_info=caller_info) try: log.debug("request about to send") resp = ctx.response = self.session.send( req.prepare(), timeout=self.http_timeout, verify=self.verify_ssl, allow_redirects=allow_redirects, stream=stream, ) except requests.RequestException as err: log.debug("request hit error (RequestException)") ctx.exception = err if attempt >= retry_config.max_retries or not checker.should_retry(ctx): log.warning("request done (fail, error)") raise exc.convert_request_exception(err) log.debug("request may retry (should-retry=true)") else: log.debug("request success, still check should-retry") if not checker.should_retry(ctx): log.debug("request done (success)") return resp log.debug("request may retry, will check attempts") # the request will be retried, so sleep... if attempt < retry_config.max_retries: log.debug("under attempt limit, will sleep") self._retry_sleep(retry_config, ctx) if resp is None: raise ValueError("Somehow, retries ended without a response") log.warning("request reached max retries, done (fail, response)") return resp globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/retry.py000066400000000000000000000103311513221403200254120ustar00rootroot00000000000000from __future__ import annotations import enum import typing as t import requests if t.TYPE_CHECKING: from .caller_info import RequestCallerInfo C = t.TypeVar("C", bound=t.Callable[..., t.Any]) # alias useful for declaring retry-related types RetryCheck = t.Callable[["RetryContext"], "RetryCheckResult"] class RetryContext: """ The RetryContext is an object passed to retry checks in order to determine whether or not a request should be retried. The context is constructed after each request, regardless of success or failure. If an exception was raised, the context will contain that exception object. Otherwise, the context will contain a response object. Exactly one of ``response`` or ``exception`` will be present. :param attempt: The request attempt number, starting at 0. :param caller_info: Contextual information about the caller, including authorizer :param response: The response on a successful request :param exception: The error raised when trying to send the request """ def __init__( self, attempt: int, *, caller_info: RequestCallerInfo, response: requests.Response | None = None, exception: Exception | None = None, ) -> None: # retry attempt number self.attempt = attempt # caller info provides contextual information about the request self.caller_info = caller_info # the response or exception from a request # we expect exactly one of these to be non-null self.response = response self.exception = exception # the retry delay or "backoff" before retrying self.backoff: float | None = None class RetryCheckResult(enum.Enum): #: yes, retry the request do_retry = enum.auto() #: no, do not retry the request do_not_retry = enum.auto() #: "I don't know", ask other checks for an answer no_decision = enum.auto() class RetryCheckFlags(enum.Flag): #: no flags (default) NONE = enum.auto() #: only run this check once per request RUN_ONCE = enum.auto() # stub for mypy class _RetryCheckFunc: _retry_check_flags: RetryCheckFlags def set_retry_check_flags(flag: RetryCheckFlags) -> t.Callable[[C], C]: """ A decorator for setting retry check flags on a retry check function. Usage: >>> @set_retry_check_flags(RetryCheckFlags.RUN_ONCE) >>> def foo(ctx): ... :param flag: The flag to set on the check """ def decorator(func: C) -> C: as_check = t.cast(_RetryCheckFunc, func) as_check._retry_check_flags = flag return func return decorator class RetryCheckCollection: """ A RetryCheckCollection is an ordered collection of retry checks which are used to determine whether or not a request should be retried. Checks are stored in registration order. Notably, the collection does not decide - how many times a request should retry - how or how long the call should wait between attempts (except via the backoff which may be set) - what kinds of request parameters (e.g., timeouts) are used It *only* contains ``RetryCheck`` functions which can look at a response or error and decide whether or not to retry. """ def __init__(self) -> None: self._data: list[RetryCheck] = [] def register_check(self, func: RetryCheck) -> RetryCheck: """ Register a retry check with this policy. A retry checker is a callable responsible for implementing `check(RetryContext) -> RetryCheckResult` `check` should *not* perform any sleeps or delays. Multiple checks should be chainable and callable in any order. :param func: The function or other callable to register as a retry check """ self._data.append(func) return func def register_many_checks(self, funcs: t.Iterable[RetryCheck]) -> None: """ Register all checks in a collection of checks. :param funcs: An iterable collection of retry check callables """ for f in funcs: self.register_check(f) def __iter__(self) -> t.Iterator[RetryCheck]: yield from self._data def __len__(self) -> int: return len(self._data) globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/retry_check_runner.py000066400000000000000000000044121513221403200301430ustar00rootroot00000000000000from __future__ import annotations import logging import typing as t from .retry import RetryCheck, RetryCheckFlags, RetryCheckResult, RetryContext log = logging.getLogger(__name__) class RetryCheckRunner: """ A RetryCheckRunner is an object responsible for running retry checks over the lifetime of a request. Unlike the checks or the retry context, the runner persists between retries. It can therefore implement special logic for checks like "only try this check once". Its primary responsibility is to answer the question "should_retry(context)?" with a boolean. It takes as its input a list of checks. Checks may be paired with flags to indicate their configuration options. When not paired with flags, the flags are taken to be "NONE". Supported flags: ``RUN_ONCE`` The check will run at most once for a given request. Once it has run, it is recorded as "has_run" and will not be run again on that request. """ # check configs: a list of pairs, (check, flags) # a check without flags is assumed to have flags=NONE def __init__(self, checks: t.Iterable[RetryCheck]) -> None: self._checks: list[RetryCheck] = [] self._check_data: dict[RetryCheck, dict[str, t.Any]] = {} for check in checks: self._checks.append(check) self._check_data[check] = {} def should_retry(self, context: RetryContext) -> bool: for check in self._checks: flags = getattr(check, "_retry_check_flags", RetryCheckFlags.NONE) if flags & RetryCheckFlags.RUN_ONCE: if self._check_data[check].get("has_run"): continue else: self._check_data[check]["has_run"] = True result = check(context) log.debug( # try to get name but don't fail if it's not a function... "ran retry check (%s) => %s", getattr(check, "__name__", check), result ) if result is RetryCheckResult.no_decision: continue elif result is RetryCheckResult.do_not_retry: return False else: return True # fallthrough: don't retry any request which isn't marked for retry return False globus-globus-sdk-python-6a080e4/src/globus_sdk/transport/retry_config.py000066400000000000000000000072611513221403200267470ustar00rootroot00000000000000from __future__ import annotations import contextlib import dataclasses import random import typing as t from .retry import RetryCheckCollection, RetryContext def _exponential_backoff(ctx: RetryContext) -> float: # respect any explicit backoff set on the context if ctx.backoff is not None: return ctx.backoff # exponential backoff with jitter return t.cast(float, (0.25 + 0.5 * random.random()) * (2**ctx.attempt)) @dataclasses.dataclass class RetryConfig: """ Configuration for a client which is going to retry requests. :param max_retries: The maximum number of retries allowed. :param max_sleep: The maximum sleep time between retries (in seconds). If the computed sleep time or the backoff requested by a retry check exceeds this value, this amount of time will be used instead. :param backoff: A function which determines how long to sleep between calls based on the RetryContext. Defaults to exponential backoff with jitter based on the context ``attempt`` number. :param retry_after_status_codes: HTTP status codes for responses which may have a Retry-After header. :param transient_error_status_codes: HTTP status codes for error responses which should generally be retried. :param expired_authorization_status_codes: HTTP status codes indicating that authorization info was missing or expired. :param checks: The check callbacks which will run in order to evaluate responses and exceptions, as a ``RetryCheckCollection``. """ max_retries: int = 5 max_sleep: float | int = 10 backoff: t.Callable[[RetryContext], float] = _exponential_backoff retry_after_status_codes: tuple[int, ...] = (429, 503) transient_error_status_codes: tuple[int, ...] = (429, 500, 502, 503, 504) expired_authorization_status_codes: tuple[int, ...] = (401,) checks: RetryCheckCollection = dataclasses.field( default_factory=RetryCheckCollection ) @contextlib.contextmanager def tune( self, *, backoff: t.Callable[[RetryContext], float] | None = None, max_sleep: float | int | None = None, max_retries: int | None = None, ) -> t.Iterator[None]: """ Temporarily adjust some of the request retry settings. This method works as a context manager, and will reset settings to their original values after it exits. :param backoff: A function which determines how long to sleep between calls based on the RetryContext :param max_sleep: The maximum sleep time between retries (in seconds). If the computed sleep time or the backoff requested by a retry check exceeds this value, this amount of time will be used instead :param max_retries: The maximum number of retries allowed by this transport **Example Usage** This can be used with any client class to temporarily set values in the context of one or more HTTP requests. For example, to disable retries: >>> client = ... # any client class >>> with client.retry_config.tune(max_retries=0): >>> foo = client.get_foo() See also: :meth:`RequestsTransport.tune`. """ saved_settings = ( self.backoff, self.max_sleep, self.max_retries, ) if backoff is not None: self.backoff = backoff if max_sleep is not None: self.max_sleep = max_sleep if max_retries is not None: self.max_retries = max_retries yield ( self.backoff, self.max_sleep, self.max_retries, ) = saved_settings globus-globus-sdk-python-6a080e4/tests/000077500000000000000000000000001513221403200200605ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/README000066400000000000000000000004461513221403200207440ustar00rootroot00000000000000= Test Categories - unit Simple tests that touch a single component or method and verify that it is functioning correctly *in isolation* from the rest of the SDK. - functional a.k.a. "integration" tests, which ensure that a full, user-like interaction with the SDK works as expected. globus-globus-sdk-python-6a080e4/tests/__init__.py000066400000000000000000000000001513221403200221570ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/benchmark/000077500000000000000000000000001513221403200220125ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/benchmark/__init__.py000066400000000000000000000000001513221403200241110ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/benchmark/test_scope_parser.py000066400000000000000000000014321513221403200261100ustar00rootroot00000000000000import pytest from globus_sdk.scopes import ScopeParser def _make_deep_scope(depth): big_scope = "" for i in range(depth): big_scope += f"foo{i}[" big_scope += "bar" for _ in range(depth): big_scope += "]" return big_scope def _make_wide_scope(width): big_scope = "" for i in range(width): big_scope += f"foo{i} " return big_scope @pytest.mark.parametrize("depth", (10, 100, 1000, 2000, 3000, 4000, 5000)) def test_deep_scope_parsing(benchmark, depth): scope_string = _make_deep_scope(depth) benchmark(ScopeParser.parse, scope_string) @pytest.mark.parametrize("width", (5000, 10000)) def test_wide_scope_parsing(benchmark, width): scope_string = _make_wide_scope(width) benchmark(ScopeParser.parse, scope_string) globus-globus-sdk-python-6a080e4/tests/common/000077500000000000000000000000001513221403200213505ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/common/__init__.py000066400000000000000000000011261513221403200234610ustar00rootroot00000000000000""" Common use helpers and utilities for all tests to leverage. Not so disorganized as a "utils" module and not so refined as a public package. """ from .consents import ConsentTest, ScopeRepr, make_consent_forest from .constants import GO_EP1_ID, GO_EP2_ID from .globus_responses import register_api_route, register_api_route_fixture_file from .response_mock import PickleableMockResponse __all__ = [ "ConsentTest", "GO_EP1_ID", "GO_EP2_ID", "make_consent_forest", "PickleableMockResponse", "register_api_route", "register_api_route_fixture_file", "ScopeRepr", ] globus-globus-sdk-python-6a080e4/tests/common/consents.py000066400000000000000000000064411513221403200235630ustar00rootroot00000000000000from __future__ import annotations import uuid from collections import defaultdict, namedtuple from dataclasses import dataclass, field from datetime import datetime, timedelta from globus_sdk.scopes import Scope, ScopeParser from globus_sdk.scopes.consents import Consent, ConsentForest ScopeRepr = namedtuple("Scope", ["id", "name"]) @dataclass class ConsentTest(Consent): """ A convenience Consent data subclass with default values for most fields to make test case definition less verbose. Required fields: client, scope, scope_name """ client: uuid.UUID | str scope: uuid.UUID | str scope_name: str id: int = field(default_factory=lambda: uuid.uuid1().int) effective_identity: uuid.UUID | str = str(uuid.uuid4()) dependency_path: list[int] = field(default_factory=list) created: datetime = field( default_factory=lambda: datetime.now() - timedelta(days=1) ) updated: datetime = field( default_factory=lambda: datetime.now() - timedelta(days=1) ) last_used: datetime = field(default_factory=datetime.now) status: str = "approved" allows_refresh: bool = True auto_approved: bool = False atomically_revocable: bool = False def __post_init__(self): # Append self to the dependency path if it's not already there if not self.dependency_path or self.dependency_path[-1] != self.id: self.dependency_path.append(self.id) @classmethod def of( cls, client: str, scope: ScopeRepr, *, parent: ConsentTest | None = None, **kwargs, ) -> ConsentTest: return cls( client=client, scope=scope.id, scope_name=scope.name, dependency_path=list(parent.dependency_path) if parent else [], **kwargs, ) def make_consent_forest(scopes: list[str | Scope] | str | Scope) -> ConsentForest: """ Creates a consent forest from a list of scope strings or scope objects. Client and Scope IDs are generated at random. """ _scopes = _normalize_scopes(scopes) scope_id_mapping: dict[str, str] = defaultdict(lambda: str(uuid.uuid4())) consents = [] for scope in _scopes: consents.extend(_generate_consents(scope, scope_id_mapping)) return ConsentForest(consents) def _normalize_scopes(scopes: list[str | Scope] | str | Scope) -> list[Scope]: if isinstance(scopes, Scope): return [scopes] elif isinstance(scopes, str): return ScopeParser.parse(scopes) else: to_return = [] for scope in scopes: to_return.extend(_normalize_scopes(scope)) return to_return def _generate_consents( scope: Scope, scope_id_mapping: dict[str, str], parent: ConsentTest | None = None ) -> list[Consent]: """Generates a list of consents for a scope and its children.""" consents = [] client_id = str(uuid.uuid4()) scope_string = scope.scope_string scope_id = scope_id_mapping[scope_string] consent = ConsentTest.of( client_id, ScopeRepr(scope_id, scope_string), parent=parent ) consents.append(consent) for dependent_scope in scope.dependencies: consents.extend( _generate_consents(dependent_scope, scope_id_mapping, parent=consent) ) return consents globus-globus-sdk-python-6a080e4/tests/common/constants.py000066400000000000000000000001461513221403200237370ustar00rootroot00000000000000GO_EP1_ID = "aa752cea-8222-5bc8-acd9-555b090c0ccb" GO_EP2_ID = "313ce13e-b597-5858-ae13-29e46fea26e6" globus-globus-sdk-python-6a080e4/tests/common/globus_responses.py000066400000000000000000000041451513221403200253220ustar00rootroot00000000000000import inspect import os import responses from globus_sdk._internal.utils import slash_join def register_api_route_fixture_file(service, path, filename, **kwargs): """ register an API route to serve the contents of a file, given the name of that file in a `fixture_data` directory, adjacent to the current (calling) module i.e. in a dir like this: path/to/tests ├── test_mod.py └── fixture_data └── dat.txt you can call >>> register_api_route_fixture_file('transfer', '/foo', 'dat.txt') in `test_mod.py` it will "do the right thing" and find the abspath to dat.txt , and load the contents of that file as the response for '/foo' """ # get calling frame frm = inspect.stack()[1] # get filename from frame, make it absolute modpath = os.path.abspath(frm[1]) abspath = os.path.join(os.path.dirname(modpath), "fixture_data", filename) with open(abspath, "rb") as f: body = f.read() register_api_route(service, path, body=body, **kwargs) def register_api_route( service, path, method=responses.GET, adding_headers=None, replace=False, **kwargs ): """ Handy wrapper for adding URIs to the response mock state. """ base_url_map = { "auth": "https://auth.globus.org/", "nexus": "https://nexus.api.globusonline.org/", "groups": "https://groups.api.globus.org/", "transfer": "https://transfer.api.globus.org/v0.10", "search": "https://search.api.globus.org/", "gcs": "https://abc.xyz.data.globus.org/api/", } assert service in base_url_map base_url = base_url_map.get(service) full_url = slash_join(base_url, path) # can set it to `{}` explicitly to clear the default if adding_headers is None: adding_headers = {"Content-Type": "application/json"} if replace: responses.replace( method, full_url, headers=adding_headers, match_querystring=None, **kwargs ) else: responses.add( method, full_url, headers=adding_headers, match_querystring=None, **kwargs ) globus-globus-sdk-python-6a080e4/tests/common/response_mock.py000066400000000000000000000043121513221403200245710ustar00rootroot00000000000000import json from unittest import mock import requests class PickleableMockResponse(mock.NonCallableMock): """ Custom Mock class which implements __setstate__ and __getstate__ so that it can be pickled and unpickled correctly -- thus avoiding issues with the tests which pickle/unpickle clients and responses (and thereby, their inner objects). The only attributes which survive the pickle/unpickle process are those with defined treatment in the __getstate__ and __setstate__ methods. NOTE: It also has to set `__class__` explicitly, which can break some mock functionality. I've tried various workarounds using copyreg to put in a custom serializer for things with a __class__ of requests.Response which checks to see if type(obj) is PickleableMockResponse and *all kinds of stuff*. None of it seems to work, so this is the best thing I could figure out for now. - Stephen (2018-09-07) """ def __init__( self, status_code, json_body=None, text=None, headers=None, *args, **kwargs ) -> None: kwargs["spec"] = requests.Response super().__init__(*args, **kwargs) self.__class__ = PickleableMockResponse # after mock initialization, setup various explicit attributes self.status_code = status_code self.headers = headers or {"Content-Type": "application/json"} self._json_body = json_body self.text = text or (json.dumps(json_body) if json_body else "") def json(self): if self._json_body is not None: return self._json_body else: raise ValueError("globus sdk mock value error") def __getstate__(self): """Custom getstate discards most of the magical mock stuff""" keys = ["headers", "text", "_json_body", "status_code"] return {k: self.__dict__[k] for k in keys} def __setstate__(self, state): self.__dict__.update(state) def __reduce__(self): return ( _unpickle_pickleable_mock_response, (self.status_code, self.__getstate__()), ) def _unpickle_pickleable_mock_response(status, state): x = PickleableMockResponse(status) x.__setstate__(state) return x globus-globus-sdk-python-6a080e4/tests/conftest.py000066400000000000000000000024321513221403200222600ustar00rootroot00000000000000from unittest import mock import pytest import responses import globus_sdk @pytest.fixture(autouse=True) def mocksleep(): with mock.patch("time.sleep") as m: yield m @pytest.fixture(autouse=True) def mocked_responses(): """ All tests enable `responses` patching of the `requests` package, replacing all HTTP calls. """ responses.start() yield responses.stop() responses.reset() @pytest.fixture def make_response(): def _make_response( response_class=None, status=200, headers=None, json_body=None, text=None, client=None, ): """ Construct and return an SDK response object with a mocked requests.Response Unlike mocking of an API route, this is meant for unit testing in which we want to directly create the response. """ from tests.common import PickleableMockResponse r = PickleableMockResponse( status, headers=headers, json_body=json_body, text=text ) http_res = globus_sdk.GlobusHTTPResponse( r, client=client if client is not None else mock.Mock() ) if response_class is not None: return response_class(http_res) return http_res return _make_response globus-globus-sdk-python-6a080e4/tests/functional/000077500000000000000000000000001513221403200222225ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/__init__.py000066400000000000000000000000001513221403200243210ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/base_client/000077500000000000000000000000001513221403200244725ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/base_client/conftest.py000066400000000000000000000003621513221403200266720ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def client_class(): class CustomClient(globus_sdk.BaseClient): service_name = "foo" return CustomClient @pytest.fixture def client(client_class): return client_class() globus-globus-sdk-python-6a080e4/tests/functional/base_client/test_advanced_http_options.py000066400000000000000000000027571513221403200324750ustar00rootroot00000000000000from globus_sdk.testing import RegisteredResponse, load_response def test_allow_redirects_false(client): # based on a real response from 'GET https://auth.globus.org/' load_response( RegisteredResponse( path="https://foo.api.globus.org/bar", status=302, headers={ "Date": "Fri, 15 Apr 2022 15:35:44 GMT", "Content-Type": "text/html", "Connection": "keep-alive", "Server": "nginx", "Location": "https://www.globus.org/", }, body="""\ 302 Found

302 Found


nginx
""", ) ) # NOTE: this test isn't very "real" because of where `responses` intercepts the # request/response action # even without `allow_redirects=False`, this test would pass # if we find a better way of testing redirect behavior, consider removing this test res = client.request("GET", "/bar", allow_redirects=False) assert res.http_status == 302 def test_stream_true(client): load_response( RegisteredResponse( path="https://foo.api.globus.org/bar", json={"foo": "bar"}, ) ) res = client.request("GET", "/bar", stream=True) assert res.http_status == 200 # forcing JSON evaluation still works as expected (this must force the download / # evaluation of content) assert res["foo"] == "bar" globus-globus-sdk-python-6a080e4/tests/functional/base_client/test_default_headers.py000066400000000000000000000016661513221403200312330ustar00rootroot00000000000000from globus_sdk import __version__ from globus_sdk.testing import RegisteredResponse, get_last_request def test_clientinfo_header_default(client): RegisteredResponse( path="https://foo.api.globus.org/bar", json={"foo": "bar"}, ).add() res = client.request("GET", "/bar") assert res.http_status == 200 req = get_last_request() assert "X-Globus-Client-Info" in req.headers assert ( req.headers["X-Globus-Client-Info"] == f"product=python-sdk,version={__version__}" ) def test_clientinfo_header_can_be_supressed(client): RegisteredResponse( path="https://foo.api.globus.org/bar", json={"foo": "bar"}, ).add() # clear the X-Globus-Client-Info header client.transport.globus_client_info.clear() res = client.request("GET", "/bar") assert res.http_status == 200 req = get_last_request() assert "X-Globus-Client-Info" not in req.headers globus-globus-sdk-python-6a080e4/tests/functional/base_client/test_encodings.py000066400000000000000000000032361513221403200300600ustar00rootroot00000000000000import pytest import responses def test_cannot_encode_dict_as_text(client): with pytest.raises(TypeError): client.post("/bar", data={"baz": 1}, encoding="text") def test_cannot_encode_with_unknown_encoding(client): with pytest.raises(ValueError): client.post("/bar", data={"baz": 1}, encoding="some-random-string") def test_cannot_form_encode_bad_types(client): with pytest.raises(TypeError): client.post("/bar", data=["baz", "buzz"], encoding="form") with pytest.raises(TypeError): client.post("/bar", data=1, encoding="form") def test_form_encoding_works(client): responses.add(responses.POST, "https://foo.api.globus.org/bar", body="hi") client.post("/bar", data={"baz": 1}, encoding="form") last_req = responses.calls[-1].request assert last_req.body == "baz=1" def test_text_encoding_sends_ascii_string(client): responses.add(responses.POST, "https://foo.api.globus.org/bar", body="hi") client.post("/bar", data="baz", encoding="text") last_req = responses.calls[-1].request assert last_req.body == "baz" def test_text_encoding_can_send_non_ascii_utf8_bytes(client): # this test is a reproducer for an issue in which attempting to send these bytes # in the form of a (decoded) string would fail, as urllib3 tried to encode them as # latin-1 bytes incorrectly # passing the bytes already UTF-8 encoded should work responses.add(responses.POST, "https://foo.api.globus.org/bar", body="hi") client.post("/bar", data='{"field“: "value“}'.encode(), encoding="text") last_req = responses.calls[-1].request assert last_req.body == '{"field“: "value“}'.encode() globus-globus-sdk-python-6a080e4/tests/functional/base_client/test_filter_missing.py000066400000000000000000000030121513221403200311150ustar00rootroot00000000000000import json import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import RegisteredResponse, get_last_request, load_response @pytest.fixture(autouse=True) def setup_mock_responses(): load_response( RegisteredResponse( path="https://foo.api.globus.org/bar", json={"foo": "bar"}, ) ) load_response( RegisteredResponse( path="https://foo.api.globus.org/bar", method="POST", json={"foo": "bar"}, ) ) def test_query_params_can_filter_missing(client): res = client.get("/bar", query_params={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() assert req.params == {"foo": "bar"} def test_headers_can_filter_missing(client): res = client.get("/bar", headers={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() assert req.headers["foo"] == "bar" assert "baz" not in req.headers def test_json_body_can_filter_missing(client): res = client.post("/bar", data={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() sent = json.loads(req.body) assert sent == {"foo": "bar"} def test_form_body_can_filter_missing(client): res = client.post("/bar", data={"foo": "bar", "baz": MISSING}, encoding="form") assert res.http_status == 200 req = get_last_request() sent = urllib.parse.parse_qs(req.body) assert sent == {"foo": ["bar"]} globus-globus-sdk-python-6a080e4/tests/functional/base_client/test_retry_behavior.py000066400000000000000000000244121513221403200311320ustar00rootroot00000000000000import pytest import requests import globus_sdk from globus_sdk.testing import RegisteredResponse from globus_sdk.transport import RequestCallerInfo, RetryConfig @pytest.mark.parametrize("error_status", [500, 429, 502, 503, 504]) def test_retry_on_transient_error(client, mocksleep, error_status): RegisteredResponse( path="https://foo.api.globus.org/bar", status=error_status, body="Uh-oh!" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # no sign of an error in the client res = client.get("/bar") assert res.http_status == 200 assert res["baz"] == 1 # there was a sleep (retry was triggered) mocksleep.assert_called_once() def test_retry_disabled_via_tune(client, mocksleep): RegisteredResponse( path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # the error is seen by the client (automatic retry does not hide it) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: with client.retry_config.tune(max_retries=0): client.get("/bar") assert excinfo.value.http_status == 500 # there was no sleep (retry was not triggered) mocksleep.assert_not_called() def test_retry_disabled_via_init_param(client_class, mocksleep): RegisteredResponse( path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() client = client_class(retry_config=RetryConfig(max_retries=0)) # the error is seen by the client (automatic retry does not hide it) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: client.get("/bar") assert excinfo.value.http_status == 500 # there was no sleep (retry was not triggered) mocksleep.assert_not_called() def test_retry_disabled_via_init_param_but_enabled_via_tune(client_class, mocksleep): RegisteredResponse( path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() client = client_class(retry_config=RetryConfig(max_retries=0)) # no sign of an error in the client if we "turn it back on" with client.retry_config.tune(max_retries=1): res = client.get("/bar") assert res.http_status == 200 assert res["baz"] == 1 # there was a sleep (retry was triggered) mocksleep.assert_called_once() def test_retry_on_network_error(client, mocksleep): # set the response to be a requests NetworkError -- responses will raise the # exception when the call is made RegisteredResponse( path="https://foo.api.globus.org/bar", body=requests.ConnectionError("foo-err"), ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # no sign of an error in the client res = client.get("/bar") assert res.http_status == 200 assert res["baz"] == 1 # there was a sleep (retry was triggered) mocksleep.assert_called_once() @pytest.mark.parametrize("num_errors,expect_err", [(5, False), (6, True), (7, True)]) def test_retry_limit(client, mocksleep, num_errors, expect_err): # N errors followed by a success for _i in range(num_errors): RegisteredResponse( path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() if expect_err: with pytest.raises(globus_sdk.GlobusAPIError): client.get("/bar") else: # no sign of an error in the client res = client.get("/bar") assert res.http_status == 200 assert res["baz"] == 1 # default num retries = 5 assert mocksleep.call_count == min(num_errors, 5) def test_transport_retry_limit(client, mocksleep): # this limit is a safety to protect against a bad policy causing infinite retries client.retry_config.max_retries = 2 for _i in range(3): RegisteredResponse( path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() with pytest.raises(globus_sdk.GlobusAPIError): client.get("/bar") assert mocksleep.call_count == 2 def test_bad_max_retries_causes_error(client): # this test exploits the fact that we loop to (max_retries + 1) in order # to ensure that no requests are ever sent # the transport should throw an error in this case, since it doesn't have a response # value to return client.retry_config.max_retries = -1 with pytest.raises(ValueError): client.get("/bar") def test_persistent_connection_error(client): for _i in range(6): RegisteredResponse( path="https://foo.api.globus.org/bar", body=requests.ConnectionError("foo-err"), ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() with pytest.raises(globus_sdk.GlobusConnectionError): client.get("/bar") def test_no_retry_401_no_authorizer(client): RegisteredResponse( path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # error gets raised in client (no retry) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: client.get("/bar") assert excinfo.value.http_status == 401 def test_retry_with_authorizer(client): RegisteredResponse( path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # an authorizer class which does nothing but claims to support handling of # unauthorized errors dummy_authz_calls = [] class DummyAuthorizer(globus_sdk.authorizers.GlobusAuthorizer): def get_authorization_header(self): dummy_authz_calls.append("set_authz") return "foo" def handle_missing_authorization(self): dummy_authz_calls.append("handle_missing") return True authorizer = DummyAuthorizer() client.authorizer = authorizer # no sign of an error in the client res = client.get("/bar") assert res.http_status == 200 assert res["baz"] == 1 # ensure that setting authz was called twice (once for each request) # and that between the two calls, handle_missing_authorization was called once assert dummy_authz_calls == ["set_authz", "handle_missing", "set_authz"] def test_no_retry_with_authorizer_no_handler(client): RegisteredResponse( path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # an authorizer class which does nothing and does not claim to handle # unauthorized errors dummy_authz_calls = [] class DummyAuthorizer(globus_sdk.authorizers.GlobusAuthorizer): def get_authorization_header(self): dummy_authz_calls.append("set_authz") return "foo" def handle_missing_authorization(self): dummy_authz_calls.append("handle_missing") return False authorizer = DummyAuthorizer() client.authorizer = authorizer # error gets raised in client (no retry) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: client.get("/bar") assert excinfo.value.http_status == 401 # only two calls, single setting of authz and a call to ask about handling the error assert dummy_authz_calls == ["set_authz", "handle_missing"] def test_retry_with_authorizer_persistent_401(client): RegisteredResponse( path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" ).add() RegisteredResponse( path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # an authorizer class which does nothing but claims to support handling of # unauthorized errors dummy_authz_calls = [] class DummyAuthorizer(globus_sdk.authorizers.GlobusAuthorizer): def get_authorization_header(self): dummy_authz_calls.append("set_authz") return "foo" def handle_missing_authorization(self): dummy_authz_calls.append("handle_missing") return True authorizer = DummyAuthorizer() client.authorizer = authorizer # the error gets raised in this case because it persists -- the authorizer only gets # one chance to resolve the issue with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: client.get("/bar") assert excinfo.value.http_status == 401 # ensure that setting authz was called twice (once for each request) # and that between the two calls, handle_missing_authorization was called once # but the handler should not be called a second time because the 401 repeated assert dummy_authz_calls == ["set_authz", "handle_missing", "set_authz"] def test_transport_caller_info_with_retry(client): RegisteredResponse( path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" ).add() RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() dummy_authz_calls = [] class DummyAuthorizer(globus_sdk.authorizers.GlobusAuthorizer): def get_authorization_header(self): dummy_authz_calls.append("set_authz") return "foo" def handle_missing_authorization(self): dummy_authz_calls.append("handle_missing") return True authorizer = DummyAuthorizer() caller_info = RequestCallerInfo( retry_config=client.retry_config, authorizer=authorizer ) # Test direct transport usage with caller_info response = client.transport.request( "GET", "https://foo.api.globus.org/bar", caller_info=caller_info ) assert response.status_code == 200 # Verify that the authorizer was used for both authorization and retry handling assert dummy_authz_calls == ["set_authz", "handle_missing", "set_authz"] globus-globus-sdk-python-6a080e4/tests/functional/globus_app/000077500000000000000000000000001513221403200243555ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/globus_app/test_globus_app_token_handling.py000066400000000000000000000233001513221403200331630ustar00rootroot00000000000000import sys import pytest import responses import globus_sdk import globus_sdk.token_storage from globus_sdk.testing import RegisteredResponse, load_response # the JWT will have a client ID in its audience claim # make sure to use that value when trying to decode it CLIENT_ID_FROM_JWT = "7fb58e00-839d-44e3-8047-10a502612dca" class InfiniteLeewayDecoder(globus_sdk.IDTokenDecoder): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.jwt_leeway = sys.maxsize @pytest.fixture(autouse=True) def _mock_input(capsys, monkeypatch): def _fake_input(s): print(s) return "mock_input" monkeypatch.setattr("builtins.input", _fake_input) @pytest.fixture def oidc_and_jwk_responses(): RegisteredResponse( service="auth", path="/.well-known/openid-configuration", method="GET", json={ "issuer": "https://auth.globus.org", "authorization_endpoint": "https://auth.globus.org/v2/oauth2/authorize", "userinfo_endpoint": "https://auth.globus.org/v2/oauth2/userinfo", "token_endpoint": "https://auth.globus.org/v2/oauth2/token", "revocation_endpoint": "https://auth.globus.org/v2/oauth2/token/revoke", "jwks_uri": "https://auth.globus.org/jwk.json", "response_types_supported": [ "code", "token", "token id_token", "id_token", ], "id_token_signing_alg_values_supported": ["RS512"], "scopes_supported": ["openid", "email", "profile"], "token_endpoint_auth_methods_supported": ["client_secret_basic"], "claims_supported": [ "at_hash", "aud", "email", "exp", "name", "nonce", "preferred_username", "iat", "iss", "sub", ], "subject_types_supported": ["public"], }, ).add() RegisteredResponse( service="auth", path="/jwk.json", method="GET", json={ "keys": [ { "alg": "RS512", "e": "AQAB", "kty": "RSA", "n": "73l27Yp7WT2c0Ve7EoGJ13AuKzg-GHU7Mpgx0JKa_hO04gAXSVXRadQy7gmdLLtAK8uBVcV0fHGgsBl4J92t-I7hayiJSLbgbX-sZhI_OfegeOLcSNB9poPS9w60XGqR9buYOW2x-KXXitsmyHXNmg_-1u0uqfKHu9pmST8dcjUYXTM5F3oJpQKeJlSH8daMlDks4xb9Y83EEFRv-ppY965-WTm2NW4pwLlbgGTWFvZ6YS6GTb-mfGwGuzStI0lKZ7dOFx9ryYQ4wSoUVHtIrypT-gbuaT90Z2SkwOH-GaEZJkudctBeGpieOsyC7P40UXpwgGNFy3xoWL4vHpnHmQ", # noqa: E501 "use": "sig", } ] }, ).add() @pytest.fixture def token_response(): RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", status=200, json={ "access_token": "auth_access_token", "scope": "openid", "expires_in": 172800, "token_type": "Bearer", "resource_server": "auth.globus.org", # this is a real ID token # and since the JWK is real as well, it should decode correctly # but it will have a bad expiration time because it's a very old token "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJzdWIiOiJjOGFhZDQzZS1kMjc0LTExZTUtYmY5OC04YjAyODk2Y2Y3ODIiLCJvcmdhbml6YXRpb24iOiJHbG9idXMiLCJuYW1lIjoiU3RlcGhlbiBSb3NlbiIsInByZWZlcnJlZF91c2VybmFtZSI6InNpcm9zZW4yQGdsb2J1c2lkLm9yZyIsImlkZW50aXR5X3Byb3ZpZGVyIjoiNDExNDM3NDMtZjNjOC00ZDYwLWJiZGItZWVlY2FiYTg1YmQ5IiwiaWRlbnRpdHlfcHJvdmlkZXJfZGlzcGxheV9uYW1lIjoiR2xvYnVzIElEIiwiZW1haWwiOiJzaXJvc2VuQHVjaGljYWdvLmVkdSIsImxhc3RfYXV0aGVudGljYXRpb24iOjE2MjE0ODEwMDYsImlkZW50aXR5X3NldCI6W3sic3ViIjoiYzhhYWQ0M2UtZDI3NC0xMWU1LWJmOTgtOGIwMjg5NmNmNzgyIiwib3JnYW5pemF0aW9uIjoiR2xvYnVzIiwibmFtZSI6IlN0ZXBoZW4gUm9zZW4iLCJ1c2VybmFtZSI6InNpcm9zZW4yQGdsb2J1c2lkLm9yZyIsImlkZW50aXR5X3Byb3ZpZGVyIjoiNDExNDM3NDMtZjNjOC00ZDYwLWJiZGItZWVlY2FiYTg1YmQ5IiwiaWRlbnRpdHlfcHJvdmlkZXJfZGlzcGxheV9uYW1lIjoiR2xvYnVzIElEIiwiZW1haWwiOiJzaXJvc2VuQHVjaGljYWdvLmVkdSIsImxhc3RfYXV0aGVudGljYXRpb24iOjE2MjE0ODEwMDZ9LHsic3ViIjoiYjZlMjI3ZTgtZGI1Mi0xMWU1LWI2ZmYtYzNiMWNjMjU5ZTBkIiwibmFtZSI6bnVsbCwidXNlcm5hbWUiOiJzaXJvc2VuK2JhZGVtYWlsQGdsb2J1cy5vcmciLCJpZGVudGl0eV9wcm92aWRlciI6IjkyN2Q3MjM4LWY5MTctNGViMi05YWNlLWM1MjNmYTliYTM0ZSIsImlkZW50aXR5X3Byb3ZpZGVyX2Rpc3BsYXlfbmFtZSI6Ikdsb2J1cyBTdGFmZiIsImVtYWlsIjoic2lyb3NlbitiYWRlbWFpbEBnbG9idXMub3JnIiwibGFzdF9hdXRoZW50aWNhdGlvbiI6bnVsbH0seyJzdWIiOiJmN2Y4OWQwYS1kYzllLTExZTUtYWRkMC1hM2NiZDFhNTU5YjMiLCJuYW1lIjpudWxsLCJ1c2VybmFtZSI6InNpcm9zZW4rYmFkZW1haWwyQGdsb2J1cy5vcmciLCJpZGVudGl0eV9wcm92aWRlciI6IjkyN2Q3MjM4LWY5MTctNGViMi05YWNlLWM1MjNmYTliYTM0ZSIsImlkZW50aXR5X3Byb3ZpZGVyX2Rpc3BsYXlfbmFtZSI6Ikdsb2J1cyBTdGFmZiIsImVtYWlsIjoic2lyb3NlbitiYWRlbWFpbDJAZ2xvYnVzLm9yZyIsImxhc3RfYXV0aGVudGljYXRpb24iOm51bGx9XSwiaXNzIjoiaHR0cHM6Ly9hdXRoLmdsb2J1cy5vcmciLCJhdWQiOiI3ZmI1OGUwMC04MzlkLTQ0ZTMtODA0Ny0xMGE1MDI2MTJkY2EiLCJleHAiOjE2MjE2NTM4MTEsImlhdCI6MTYyMTQ4MTAxMSwiYXRfaGFzaCI6IjFQdlVhbmNFdUxfc2cxV1BsNWx1TUVGR2tjTDZQaDh1cWdpVUZzejhkZUEifQ.CtfnFtfM32ICo0euHv9GnpVHFL1jWz0NriPTXAv6w08Ylk9JBJtmB3oMKNSO-1TGoWUPFDp9TFFk6N32VyF0hsVDtT5DT3t5oq0qfqbPrZA3R04HARW0xtcK_ejNDHBmj6wysey3EzjT764XTvcGOe63CKQ_RJm97ulVaseIT0Aet7AYo5tQuOiSOQ70xzL7Oax3W6TrWi3FIAA-PIMSrAJKbsG7imGOVkaIObG9a-X5yTOcrB4IG4Wat-pN_QiCiiOw_LDCF-r455PwalmnSGUugMYfsdL2k3UxqwOMLIppHnx5-UVAzj3mygj8eZTp6imjqxNMdakS3vhG8dtxbw", # noqa: E501 "other_tokens": [], }, ).add() def test_globus_app_only_gets_oidc_data_once( oidc_and_jwk_responses, token_response, monkeypatch ): # needed for logout later load_response(globus_sdk.NativeAppAuthClient.oauth2_revoke_token) def _count_oidc_calls(): calls = [c.request for c in responses.calls] calls = [ c for c in calls if c.url == "https://auth.globus.org/.well-known/openid-configuration" ] return len(calls) def _count_jwk_calls(): calls = [c.request for c in responses.calls] calls = [c for c in calls if c.url == "https://auth.globus.org/jwk.json"] return len(calls) memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=InfiniteLeewayDecoder, ) user_app = globus_sdk.UserApp( "test-app", config=config, client_id=CLIENT_ID_FROM_JWT ) # start: we haven't made any calls yet assert _count_oidc_calls() == 0 assert _count_jwk_calls() == 0 # after login: we made one call for each user_app.login() assert _count_oidc_calls() == 1 assert _count_jwk_calls() == 1 # logout and confirm that it was effective user_app.logout() assert user_app.login_required() is True # second login: call counts did not increase user_app.login() assert _count_oidc_calls() == 1 assert _count_jwk_calls() == 1 def test_globus_app_can_set_custom_id_token_decoder_via_config_provider( oidc_and_jwk_responses, token_response, ): init_counter = 0 class CustomDecoder(InfiniteLeewayDecoder): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) nonlocal init_counter init_counter += 1 memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=CustomDecoder, ) user_app = globus_sdk.UserApp( "test-app", config=config, client_id=CLIENT_ID_FROM_JWT ) # confirm that our custom init got called during app init assert init_counter == 1 # login, and confirm no failure (the default would fail on the stale id_token used) user_app.login() def test_globus_app_can_set_custom_id_token_decoder_via_config_instance( oidc_and_jwk_responses, token_response, ): # needed for logout later load_response(globus_sdk.NativeAppAuthClient.oauth2_revoke_token) call_counter = 0 class CustomDecoder(InfiniteLeewayDecoder): def decode(self, *args, **kwargs) -> None: nonlocal call_counter call_counter += 1 return super().decode(*args, **kwargs) login_client = globus_sdk.NativeAppAuthClient(client_id=CLIENT_ID_FROM_JWT) memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=CustomDecoder(login_client) ) user_app = globus_sdk.UserApp("test-app", config=config, login_client=login_client) assert call_counter == 0 # login, and confirm no failure (the default would fail on the stale id_token used) user_app.login() # confirm that our custom decode got called assert call_counter == 1 # logout and log back in; did it get called again? good user_app.logout() user_app.login() assert call_counter == 2 def test_globus_app_custom_id_token_decoder_instance_can_overload_jwt_leeway( oidc_and_jwk_responses, token_response, ): # needed for logout later load_response(globus_sdk.NativeAppAuthClient.oauth2_revoke_token) login_client = globus_sdk.NativeAppAuthClient(client_id=CLIENT_ID_FROM_JWT) memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=globus_sdk.IDTokenDecoder( login_client, jwt_leeway=sys.maxsize ), ) user_app = globus_sdk.UserApp("test-app", config=config, login_client=login_client) # login, and confirm no failure (the default would fail on the stale id_token used) user_app.login() globus-globus-sdk-python-6a080e4/tests/functional/local_endpoint/000077500000000000000000000000001513221403200252145ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/local_endpoint/__init__.py000066400000000000000000000000001513221403200273130ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/local_endpoint/test_personal.py000066400000000000000000000216141513221403200304540ustar00rootroot00000000000000import base64 import os import shutil import uuid import pytest import globus_sdk from globus_sdk.testing import load_response _IS_WINDOWS = os.name == "nt" ID_ZERO = uuid.UUID(int=0) def _compute_base32_id(value): """ Create a base32-encoded UUID in the format used by legacy Globus systems to represent Identity IDs as unix-friendly usernames. GCP CN values still match this format. """ if isinstance(value, str): value = uuid.UUID(value) encoded_bytes = base64.b32encode(value.bytes) result = encoded_bytes.decode() result = result.rstrip("=").lower() return f"u_{result}" def _compute_confdir(homedir, alt=False): if alt: return os.path.join(homedir, "alt-conf-dir/lta") if _IS_WINDOWS: return os.path.join(homedir, "Globus Connect") else: return os.path.join(homedir, ".globusonline/lta") def normalize_config_dir_argument(config_dir): return config_dir if _IS_WINDOWS else os.path.dirname(config_dir) @pytest.fixture def mocked_confdir(tmp_path): confdir = _compute_confdir(tmp_path) os.makedirs(confdir) return confdir @pytest.fixture def mocked_alternate_confdir(tmp_path): altconfdir = _compute_confdir(tmp_path, alt=True) os.makedirs(altconfdir) return altconfdir @pytest.fixture(autouse=True) def mocked_homedir(monkeypatch, tmp_path, mocked_confdir, mocked_alternate_confdir): def mock_expanduser(path): return str(tmp_path / path.replace("~/", "")) if _IS_WINDOWS: monkeypatch.setitem(os.environ, "LOCALAPPDATA", str(tmp_path)) else: monkeypatch.setattr(os.path, "expanduser", mock_expanduser) @pytest.fixture def write_gcp_id_file(mocked_confdir, mocked_alternate_confdir): def _func_fixture(epid, alternate=False): fpath = os.path.join( mocked_alternate_confdir if alternate else mocked_confdir, "client-id.txt" ) with open(fpath, "w") as f: f.write(epid) f.write("\n") return _func_fixture @pytest.fixture def write_gridmap(mocked_confdir): def _func_fixture(data, alternate=False): fpath = os.path.join( mocked_alternate_confdir if alternate else mocked_confdir, "gridmap" ) with open(fpath, "w") as f: f.write(data) return _func_fixture @pytest.fixture def local_gcp(): return globus_sdk.LocalGlobusConnectPersonal() @pytest.fixture def auth_client(): return globus_sdk.AuthClient() @pytest.mark.skipif(not _IS_WINDOWS, reason="test requires Windows") def test_localep_localappdata_notset(local_gcp, monkeypatch): monkeypatch.delitem(os.environ, "LOCALAPPDATA") with pytest.raises(globus_sdk.GlobusSDKUsageError): local_gcp.endpoint_id def test_localep_load_id(local_gcp, write_gcp_id_file): assert local_gcp.endpoint_id is None write_gcp_id_file("foobar") assert local_gcp.endpoint_id == "foobar" write_gcp_id_file("xyz") assert local_gcp.endpoint_id == "foobar" del local_gcp.endpoint_id assert local_gcp.endpoint_id == "xyz" def test_localep_load_id_alternate_conf_dir( mocked_alternate_confdir, write_gcp_id_file ): gcp = globus_sdk.LocalGlobusConnectPersonal( config_dir=normalize_config_dir_argument(mocked_alternate_confdir) ) assert gcp.endpoint_id is None write_gcp_id_file("foobar", alternate=True) assert gcp.endpoint_id == "foobar" write_gcp_id_file("xyz", alternate=True) assert gcp.endpoint_id == "foobar" del gcp.endpoint_id assert gcp.endpoint_id == "xyz" def test_load_id_no_confdir(local_gcp, mocked_confdir, mocked_alternate_confdir): shutil.rmtree(mocked_confdir) shutil.rmtree(mocked_alternate_confdir) alt_gcp = globus_sdk.LocalGlobusConnectPersonal(config_dir=mocked_alternate_confdir) assert local_gcp.endpoint_id is None assert alt_gcp.endpoint_id is None def test_get_owner_info(local_gcp, write_gridmap, auth_client): meta = load_response(auth_client.get_identities, case="globusid").metadata local_username = meta["short_username"] write_gridmap( '"/C=US/O=Globus Consortium/OU=Globus Connect User/' f'CN={local_username}" {local_username}\n' ) info = local_gcp.get_owner_info() assert isinstance(info, globus_sdk.GlobusConnectPersonalOwnerInfo) assert info.username == meta["username"] assert info.id is None assert str(info) == f"GlobusConnectPersonalOwnerInfo(username={info.username})" data = local_gcp.get_owner_info(auth_client) assert isinstance(data, dict) assert data["id"] == meta["id"] def test_get_owner_info_b32_mode(local_gcp, write_gridmap, auth_client): meta = load_response(auth_client.get_identities).metadata base32_id = _compute_base32_id(meta["id"]) local_username = meta["username"].partition("@")[0] write_gridmap( '"/C=US/O=Globus Consortium/OU=Globus Connect User/' f'CN={base32_id}" {local_username}\n' ) info = local_gcp.get_owner_info() assert isinstance(info, globus_sdk.GlobusConnectPersonalOwnerInfo) assert info.username is None assert info.id == meta["id"] data = local_gcp.get_owner_info(auth_client) assert isinstance(data, dict) assert data["id"] == meta["id"] assert data["username"] == meta["username"] # these things are close to the right thing, but each is somehow wrong @pytest.mark.parametrize( "cn", [ # no 'u_' _compute_base32_id(ID_ZERO)[2:], # short one char _compute_base32_id(ID_ZERO)[:-1], # invalid b32 char included _compute_base32_id(ID_ZERO)[:-1] + "/", ], ) def test_get_owner_info_b32_mode_invalid_data( local_gcp, write_gridmap, cn, auth_client ): write_gridmap( f'"/C=US/O=Globus Consortium/OU=Globus Connect User/CN={cn}" javert\n' ) info = local_gcp.get_owner_info() assert isinstance(info, globus_sdk.GlobusConnectPersonalOwnerInfo) assert info.username == f"{cn}@globusid.org" @pytest.mark.parametrize( "bad_cn_line", [ '"/C=US/O=Globus Consortium/OU=Globus Connect User/CN=koala"', '"/C=US/O=Globus Consortium/OU=Globus Connect User/CN=koala" panda fossa', "", '"" koala', ], ) def test_get_owner_info_malformed_entry(local_gcp, write_gridmap, bad_cn_line): write_gridmap(bad_cn_line + "\n") assert local_gcp.get_owner_info() is None def test_get_owner_info_no_conf(local_gcp): assert local_gcp.get_owner_info() is None assert local_gcp.get_owner_info(auth_client) is None def test_get_owner_info_no_confdir(local_gcp, mocked_confdir, auth_client): shutil.rmtree(mocked_confdir) assert local_gcp.get_owner_info() is None assert local_gcp.get_owner_info(auth_client) is None def test_get_owner_info_multiline_data(local_gcp, write_gridmap, auth_client): meta = load_response(auth_client.get_identities, case="globusid").metadata local_username = meta["short_username"] write_gridmap( "\n".join( [ ( '"/C=US/O=Globus Consortium/OU=Globus Connect User/' f'CN={local_username}{x}" {local_username}{x}' ) for x in ["", "2", "3"] ] ) + "\n" ) info = local_gcp.get_owner_info() assert isinstance(info, globus_sdk.GlobusConnectPersonalOwnerInfo) assert info.username == meta["username"] data = local_gcp.get_owner_info(auth_client) assert isinstance(data, dict) assert data["id"] == meta["id"] def test_get_owner_info_no_auth_data(local_gcp, write_gridmap, auth_client): load_response(auth_client.get_identities, case="empty") write_gridmap( '"/C=US/O=Globus Consortium/OU=Globus Connect User/CN=azathoth" azathoth\n' ) info = local_gcp.get_owner_info() assert isinstance(info, globus_sdk.GlobusConnectPersonalOwnerInfo) assert info.username == "azathoth@globusid.org" data = local_gcp.get_owner_info(auth_client) assert data is None def test_get_owner_info_gridmap_permission_denied(local_gcp, mocked_confdir): fpath = os.path.join(mocked_confdir, "gridmap") if not _IS_WINDOWS: with open(fpath, "w"): # "touch" pass os.chmod(fpath, 0o000) else: # on windows, trying to read a directory gets a permission error # this is just an easy way for tests to simulate bad permissions os.makedirs(fpath) with pytest.raises(PermissionError): local_gcp.get_owner_info() def test_get_endpoint_id_permission_denied(local_gcp, mocked_confdir): fpath = os.path.join(mocked_confdir, "client-id.txt") if not _IS_WINDOWS: with open(fpath, "w"): # "touch" pass os.chmod(fpath, 0o000) else: # on windows, trying to read a directory gets a permission error # this is just an easy way for tests to simulate bad permissions os.makedirs(fpath) with pytest.raises(PermissionError): local_gcp.endpoint_id globus-globus-sdk-python-6a080e4/tests/functional/local_endpoint/test_server.py000066400000000000000000000055111513221403200301350ustar00rootroot00000000000000from globus_sdk import LocalGlobusConnectServer def test_info_dict_from_nonexistent_file_is_none(tmp_path): info_path = tmp_path / "info.json" gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is None def test_info_dict_from_non_json_file_is_none(tmp_path): info_path = tmp_path / "info.json" info_path.write_text("{") gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is None def test_info_dict_from_non_json_object_file_is_none(tmp_path): info_path = tmp_path / "info.json" info_path.write_text("[]") gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is None def test_info_dict_from_non_unicode_file_is_none(tmp_path): info_path = tmp_path / "info.json" info_path.write_bytes(b'{"foo":"{' + bytes.fromhex("1BAD DEC0DE") + b'}"}') gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is None def test_info_dict_from_empty_json_file_is_okay_but_has_no_properties(tmp_path): info_path = tmp_path / "info.json" info_path.write_text("{}") gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is not None assert gcs.endpoint_id is None assert gcs.domain_name is None def test_info_dict_can_load_endpoint_id(tmp_path): info_path = tmp_path / "info.json" info_path.write_text('{"endpoint_id": "foo"}') gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is not None assert gcs.endpoint_id == "foo" def test_info_dict_can_load_domain(tmp_path): info_path = tmp_path / "info.json" info_path.write_text('{"domain_name": "foo"}') gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is not None assert gcs.domain_name == "foo" def test_endpoint_id_property_ignores_non_str_value(tmp_path): info_path = tmp_path / "info.json" info_path.write_text('{"endpoint_id": 1}') gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is not None assert gcs.endpoint_id is None def test_domain_name_property_ignores_non_str_value(tmp_path): info_path = tmp_path / "info.json" info_path.write_text('{"domain_name": ["foo"]}') gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict is not None assert gcs.domain_name is None def test_info_dict_can_reload_with_deleter(tmp_path): info_path = tmp_path / "info.json" info_path.write_text('{"foo": "bar"}') # initial load okay gcs = LocalGlobusConnectServer(info_path=info_path) assert gcs.info_dict == {"foo": "bar"} # update the data on disk info_path.write_text('{"bar": "baz"}') # data unchanged on the instance assert gcs.info_dict == {"foo": "bar"} # clear and reload updates the data del gcs.info_dict assert gcs.info_dict == {"bar": "baz"} globus-globus-sdk-python-6a080e4/tests/functional/login_flows/000077500000000000000000000000001513221403200245445ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/login_flows/__init__.py000066400000000000000000000000001513221403200266430ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/login_flows/test_login_flow_manager.py000066400000000000000000000151041513221403200320070ustar00rootroot00000000000000from unittest.mock import Mock, patch import pytest from globus_sdk import ConfidentialAppAuthClient, NativeAppAuthClient from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows import ( CommandLineLoginFlowManager, LocalServerLoginFlowManager, ) from globus_sdk.login_flows.command_line_login_flow_manager import ( CommandLineLoginFlowEOFError, ) from globus_sdk.testing import load_response def _mock_input(s): print(s) return "mock_input" def test_command_line_login_flower_manager_native(monkeypatch, capsys): """ test CommandLineLoginFlowManager with a NativeAppAuthClient """ login_client = NativeAppAuthClient("mock_client_id") load_response(login_client.oauth2_exchange_code_for_tokens) monkeypatch.setattr("builtins.input", _mock_input) login_flow_manager = CommandLineLoginFlowManager(login_client) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"], session_required_identities=["user@org.edu"], ) token_res = login_flow_manager.run_login_flow(auth_params) assert ( token_res.by_resource_server["transfer.api.globus.org"]["access_token"] == "transfer_access_token" ) captured_output = capsys.readouterr().out assert "https://auth.globus.org/v2/oauth2/authorize" in captured_output assert "client_id=mock_client_id" in captured_output assert "&session_required_identities=user%40org.edu" in captured_output def test_command_line_login_flower_manager_with_custom_prompts(capsys): """ test CommandLineLoginFlowManager with a NativeAppAuthClient """ login_client = NativeAppAuthClient("mock_client_id") load_response(login_client.oauth2_exchange_code_for_tokens) class CustomizedLoginFlowManager(CommandLineLoginFlowManager): def print_authorize_url(self, authorize_url): print("MyCustomLoginPrompt:", authorize_url) def prompt_for_code(self): return "mock_code_input" login_flow_manager = CustomizedLoginFlowManager(login_client) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"] ) login_flow_manager.run_login_flow(auth_params) captured_output = capsys.readouterr().out assert "MyCustomLoginPrompt" in captured_output assert "https://auth.globus.org/v2/oauth2/authorize" in captured_output def test_command_line_login_flower_manager_confidential(monkeypatch, capsys): """ test CommandLineLoginFlowManager with a ConfidentialAppAuthClient """ login_client = ConfidentialAppAuthClient( client_id="mock_client_id", client_secret="mock_client_secret" ) load_response(login_client.oauth2_exchange_code_for_tokens) monkeypatch.setattr("builtins.input", _mock_input) redirect_uri = "https://example.com/callback" login_flow_manager = CommandLineLoginFlowManager( login_client, redirect_uri=redirect_uri ) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"], session_required_single_domain=["org.edu"], ) token_res = login_flow_manager.run_login_flow(auth_params) assert ( token_res.by_resource_server["transfer.api.globus.org"]["access_token"] == "transfer_access_token" ) captured_output = capsys.readouterr().out assert "Please authenticate with Globus here:" in captured_output assert "Enter the resulting Authorization Code here:" in captured_output assert "https://auth.globus.org/v2/oauth2/authorize" in captured_output assert "client_id=mock_client_id" in captured_output assert "&session_required_single_domain=org.edu" in captured_output def test_command_line_login_flow_manager_eof_error(monkeypatch, capsys): """ test CommandLineLoginFlowManager with a NativeAppAuthClient """ login_client = NativeAppAuthClient("mock_client_id") load_response(login_client.oauth2_exchange_code_for_tokens) def broken_input(_s): raise EOFError() monkeypatch.setattr("builtins.input", broken_input) login_flow_manager = CommandLineLoginFlowManager(login_client) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"], session_required_identities=["user@org.edu"], ) with pytest.raises(CommandLineLoginFlowEOFError): login_flow_manager.run_login_flow(auth_params) class MockRedirectServer: def __init__(self, *args, **kwargs) -> None: self.socket = Mock() self.socket.getsockname.return_value = ("", 0) def serve_forever(self): pass def shutdown(self): pass def wait_for_code(self): return "auth_code" _LOCAL_SERVER_MODULE = ( "globus_sdk.login_flows.local_server_login_flow_manager." "local_server_login_flow_manager" ) @patch(f"{_LOCAL_SERVER_MODULE}._open_webbrowser", new=lambda url: None) @patch(f"{_LOCAL_SERVER_MODULE}.RedirectHTTPServer", new=MockRedirectServer) def test_local_server_login_flower_manager_native(): """ test LocalServerLoginManager with a NativeAppAuthClient """ login_client = NativeAppAuthClient("mock_client_id") load_response(login_client.oauth2_exchange_code_for_tokens) login_flow_manager = LocalServerLoginFlowManager( login_client, ) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"], ) token_res = login_flow_manager.run_login_flow(auth_params) assert ( token_res.by_resource_server["transfer.api.globus.org"]["access_token"] == "transfer_access_token" ) @patch(f"{_LOCAL_SERVER_MODULE}._open_webbrowser", new=lambda url: None) @patch(f"{_LOCAL_SERVER_MODULE}.RedirectHTTPServer", new=MockRedirectServer) def test_local_server_login_flower_manager_confidential(): """ test LocalServerLoginManager with a ConfidentialAppAuthClient """ login_client = ConfidentialAppAuthClient( client_id="mock_client_id", client_secret="mock_client_secret" ) load_response(login_client.oauth2_exchange_code_for_tokens) login_flow_manager = LocalServerLoginFlowManager( login_client, ) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"], ) token_res = login_flow_manager.run_login_flow(auth_params) assert ( token_res.by_resource_server["transfer.api.globus.org"]["access_token"] == "transfer_access_token" ) globus-globus-sdk-python-6a080e4/tests/functional/scopes/000077500000000000000000000000001513221403200235165ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/scopes/test_scope_data_behaviors.py000066400000000000000000000033761513221403200313040ustar00rootroot00000000000000import uuid import pytest from globus_sdk.scopes import ( AuthScopes, ComputeScopes, FlowsScopes, GCSCollectionScopes, GCSEndpointScopes, GroupsScopes, NexusScopes, Scope, SearchScopes, SpecificFlowScopes, TimersScopes, TransferScopes, ) @pytest.mark.parametrize( "collection, expect_resource_server", ( (AuthScopes, "auth.globus.org"), (ComputeScopes, "funcx_service"), (FlowsScopes, "flows.globus.org"), (GroupsScopes, "groups.api.globus.org"), (NexusScopes, "nexus.api.globus.org"), (SearchScopes, "search.api.globus.org"), (TimersScopes, "524230d7-ea86-4a52-8312-86065a9e0417"), (TransferScopes, "transfer.api.globus.org"), ), ) def test_static_resource_server_attributes(collection, expect_resource_server): assert collection.resource_server == expect_resource_server @pytest.mark.parametrize( "collection_cls", (GCSEndpointScopes, GCSCollectionScopes, SpecificFlowScopes) ) def test_dynamic_resource_server_attributes(collection_cls): some_id = str(uuid.UUID(int=1)) coll = collection_cls(some_id) assert coll.resource_server == some_id def test_oidc_scope_formatting(): assert str(AuthScopes.openid) == "openid" assert str(AuthScopes.email) == "email" assert str(AuthScopes.profile) == "profile" def test_non_oidc_auth_scope_formatting(): non_oidc_scopes = set(AuthScopes).difference( (AuthScopes.openid, AuthScopes.email, AuthScopes.profile) ) assert len(non_oidc_scopes) > 0 assert all(isinstance(x, Scope) for x in non_oidc_scopes) scope_strs = [str(s) for s in non_oidc_scopes] assert all( s.startswith("urn:globus:auth:scope:auth.globus.org:") for s in scope_strs ) globus-globus-sdk-python-6a080e4/tests/functional/services/000077500000000000000000000000001513221403200240455ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/000077500000000000000000000000001513221403200250065ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/__init__.py000066400000000000000000000000001513221403200271050ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/base/000077500000000000000000000000001513221403200257205ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/base/__init__.py000066400000000000000000000000001513221403200300170ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/base/test_oauth2_refresh_token.py000066400000000000000000000001421513221403200334460ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_oauth2_refresh_token(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/auth/base/test_oauth2_revoke_token.py000066400000000000000000000024711513221403200333120ustar00rootroot00000000000000import urllib.parse import uuid import pytest import globus_sdk from globus_sdk.testing import get_last_request, load_response @pytest.fixture def client_id(): return str(uuid.uuid4()) @pytest.fixture def base_client(client_id): return globus_sdk.AuthLoginClient(client_id=client_id) @pytest.fixture def confidential_client(client_id): return globus_sdk.ConfidentialAppAuthClient( client_id=client_id, client_secret="somesecret" ) def test_oauth2_revoke_token_works(client_id, base_client): load_response(base_client.oauth2_revoke_token) response = base_client.oauth2_revoke_token("sometoken") assert response["active"] is False lastreq = get_last_request() body = lastreq.body assert body != "" parsed_body = urllib.parse.parse_qs(body) assert parsed_body == {"token": ["sometoken"], "client_id": [client_id]} def test_oauth2_revoke_token_does_not_send_client_id_when_authenticated( client_id, confidential_client, ): load_response(confidential_client.oauth2_revoke_token) response = confidential_client.oauth2_revoke_token("sometoken") assert response["active"] is False lastreq = get_last_request() body = lastreq.body assert body != "" parsed_body = urllib.parse.parse_qs(body) assert parsed_body == {"token": ["sometoken"]} globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_client/000077500000000000000000000000001513221403200310035ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_client/__init__.py000066400000000000000000000000001513221403200331020ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_client/conftest.py000066400000000000000000000003671513221403200332100ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def auth_client(): client = globus_sdk.ConfidentialAppAuthClient( "dummy_client_id", "dummy_client_secret" ) with client.retry_config.tune(max_retries=0): yield client test_create_child_client.py000066400000000000000000000041011513221403200362750ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_clientfrom __future__ import annotations import pytest from globus_sdk import GlobusSDKUsageError from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "name", "public_client", "private_client", "publicly_visible", "not_publicly_visible", "redirect_uris", "links", "required_idp", "preselect_idp", "client_type_confidential_client", "client_type_public_installed_client", "client_type_client_identity", "client_type_resource_server", "client_type_globus_connect_server", "client_type_hybrid_confidential_client_resource_server", "client_type_public_webapp_client", ), ) def test_create_child_client_args( auth_client, case_name: str, ): meta = load_response(auth_client.create_child_client, case=case_name).metadata res = auth_client.create_child_client(**meta["args"]) for k, v in meta["response"].items(): assert res["client"][k] == v def test_links_requirement(auth_client): """ Verify that terms_and_conditions and privacy_policy must be used together. """ with pytest.raises(GlobusSDKUsageError): auth_client.create_child_client( "FOO", visibility="public", terms_and_conditions="https://foo.net", ) with pytest.raises(GlobusSDKUsageError): auth_client.create_child_client( "FOO", visibility="public", privacy_policy="https://foo.net", ) def test_public_client_and_client_type_requirement(auth_client): """ Verify that exactly one of ``public_client` and ``client_type`` are expected. """ # Neither public_client nor client_type with pytest.raises(GlobusSDKUsageError): auth_client.create_child_client( "FOO", ) # Both public_client and client_type with pytest.raises(GlobusSDKUsageError): auth_client.create_child_client( "FOO", public_client=True, client_type="GCS", ) test_oauth2_client_credentials_tokens.py000066400000000000000000000020171513221403200410350ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_clientimport urllib.parse from globus_sdk.scopes import Scope from globus_sdk.testing import get_last_request, load_response def test_oauth2_client_credentials_tokens(auth_client): meta = load_response(auth_client.oauth2_client_credentials_tokens).metadata response = auth_client.oauth2_client_credentials_tokens(meta["scope"]) assert ( response.by_resource_server[meta["resource_server"]]["access_token"] == meta["access_token"] ) def test_oauth2_client_credentials_tokens_can_accept_scope_object(auth_client): meta = load_response(auth_client.oauth2_client_credentials_tokens).metadata response = auth_client.oauth2_client_credentials_tokens(Scope(meta["scope"])) assert ( response.by_resource_server[meta["resource_server"]]["access_token"] == meta["access_token"] ) last_req = get_last_request() assert last_req.body body = last_req.body assert body != "" parsed_body = urllib.parse.parse_qs(body) assert parsed_body["scope"] == [meta["scope"]] test_oauth2_get_dependent_tokens.py000066400000000000000000000051011513221403200400040ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_clientimport urllib.parse import pytest from globus_sdk.testing import get_last_request, load_response def test_oauth2_get_dependent_tokens(auth_client): meta = load_response( auth_client.oauth2_get_dependent_tokens, case="groups" ).metadata response = auth_client.oauth2_get_dependent_tokens("dummy_token") full_response_by_rs = response.by_resource_server # data matches fully in the by_resource_server layout expected_data = meta["rs_data"] assert set(expected_data) == set(full_response_by_rs) for rs_name, values in expected_data.items(): assert full_response_by_rs[rs_name]["access_token"] == values["access_token"] assert full_response_by_rs[rs_name]["scope"] == values["scope"] def test_oauth2_get_dependent_tokens_with_refresh_token(auth_client): meta = load_response( auth_client.oauth2_get_dependent_tokens, case="groups_with_refresh_token" ).metadata response = auth_client.oauth2_get_dependent_tokens( "dummy_token", refresh_tokens=True ) full_response_by_rs = response.by_resource_server # data matches fully in the by_resource_server layout expected_data = meta["rs_data"] assert set(expected_data) == set(full_response_by_rs) for rs_name, values in expected_data.items(): assert full_response_by_rs[rs_name]["access_token"] == values["access_token"] assert full_response_by_rs[rs_name]["refresh_token"] == values["refresh_token"] assert full_response_by_rs[rs_name]["scope"] == values["scope"] # parse sent request and ensure that refresh_tokens translated correctly # to `access_type=offline` last_req = get_last_request() assert last_req.body body = last_req.body assert body != "" parsed_body = urllib.parse.parse_qs(body) assert parsed_body["access_type"] == ["offline"] @pytest.mark.parametrize( "scope_arg, expect_value", [(None, None), ("scope1", "scope1"), (("scope1", "scope2"), "scope1 scope2")], ) def test_oauth2_get_dependent_tokens_scope_string_param( auth_client, scope_arg, expect_value ): load_response(auth_client.oauth2_get_dependent_tokens, case="groups") add_args = {} if scope_arg is not None: add_args["scope"] = scope_arg auth_client.oauth2_get_dependent_tokens("dummy_token", **add_args) last_req = get_last_request() assert last_req.body body = last_req.body assert body != "" parsed_body = urllib.parse.parse_qs(body) if expect_value is None: assert "scope" not in parsed_body else: assert parsed_body["scope"] == [expect_value] test_oauth2_start_flow.py000066400000000000000000000001371513221403200360040ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_clientimport pytest @pytest.mark.xfail def test_oauth2_start_flow(): raise NotImplementedError test_oauth2_token_introspect.py000066400000000000000000000015321513221403200372120ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/confidential_clientfrom globus_sdk.testing import get_last_request, load_response def test_oauth2_token_introspect(auth_client): meta = load_response(auth_client.oauth2_token_introspect).metadata response = auth_client.oauth2_token_introspect("some_very_cool_token") assert response["username"] == meta["username"] assert response["sub"] == meta["id"] last_req = get_last_request() assert len(last_req.params) == 0 def test_oauth2_token_introspect_allows_added_query_params(auth_client): meta = load_response(auth_client.oauth2_token_introspect).metadata response = auth_client.oauth2_token_introspect( "some_very_cool_token", query_params={"foo": "bar"} ) assert response["username"] == meta["username"] assert response["sub"] == meta["id"] last_req = get_last_request() assert last_req.params["foo"] == "bar" globus-globus-sdk-python-6a080e4/tests/functional/services/auth/conftest.py000066400000000000000000000005141513221403200272050ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def login_client(): client = globus_sdk.AuthLoginClient() with client.retry_config.tune(max_retries=0): yield client @pytest.fixture def service_client(): client = globus_sdk.AuthClient() with client.retry_config.tune(max_retries=0): yield client globus-globus-sdk-python-6a080e4/tests/functional/services/auth/native_client/000077500000000000000000000000001513221403200276325ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/native_client/__init__.py000066400000000000000000000000001513221403200317310ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/native_client/conftest.py000066400000000000000000000003141513221403200320270ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def auth_client(): client = globus_sdk.NativeAppAuthClient("dummy_client_id") with client.retry_config.tune(max_retries=0): yield client test_create_native_app_instance.py000066400000000000000000000010511513221403200365160ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/native_clientfrom __future__ import annotations import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "template_id_str", "template_id_uuid", "name", ), ) def test_create_native_app_instance( auth_client, case_name: str, ): meta = load_response( auth_client.create_native_app_instance, case=case_name ).metadata res = auth_client.create_native_app_instance(**meta["args"]) for k, v in meta["response"].items(): assert res["client"][k] == v test_oauth2_refresh_token.py000066400000000000000000000001421513221403200353010ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/native_clientimport pytest @pytest.mark.xfail def test_oauth2_refresh_token(): raise NotImplementedError test_oauth2_start_flow.py000066400000000000000000000001371513221403200346330ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/native_clientimport pytest @pytest.mark.xfail def test_oauth2_start_flow(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/000077500000000000000000000000001513221403200300045ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_create_client.py000066400000000000000000000042751513221403200342260ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk import GlobusSDKUsageError from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "name", "public_client", "private_client", "project_id", "publicly_visible", "not_publicly_visible", "redirect_uris", "links", "required_idp", "preselect_idp", "client_type_confidential_client", "client_type_public_installed_client", "client_type_client_identity", "client_type_resource_server", "client_type_globus_connect_server", "client_type_hybrid_confidential_client_resource_server", "client_type_public_webapp_client", ), ) def test_create_client_args( service_client, case_name: str, ): meta = load_response(service_client.create_client, case=case_name).metadata res = service_client.create_client(**meta["args"]) for k, v in meta["response"].items(): assert res["client"][k] == v def test_links_requirement(service_client): """ Verify that terms_and_conditions and privacy_policy must be used together. """ with pytest.raises(GlobusSDKUsageError): service_client.create_client( "FOO", uuid.uuid1(), visibility="public", terms_and_conditions="https://foo.net", ) with pytest.raises(GlobusSDKUsageError): service_client.create_client( "FOO", uuid.uuid1(), visibility="public", privacy_policy="https://foo.net", ) def test_public_client_and_client_type_requirement(service_client): """ Verify that exactly one of ``public_client` and ``client_type`` are expected. """ # Neither public_client nor client_type with pytest.raises(GlobusSDKUsageError): service_client.create_client( "FOO", uuid.uuid1(), ) # Both public_client and client_type with pytest.raises(GlobusSDKUsageError): service_client.create_client( "FOO", uuid.uuid1(), public_client=True, client_type="GCS", ) test_create_client_credential.py000066400000000000000000000015171513221403200363350ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientfrom __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize("uuid_type", (str, uuid.UUID)) def test_create_credential( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.create_client_credential).metadata res = service_client.create_client_credential( meta["client_id"] if uuid_type is str else uuid.UUID(meta["client_id"]), meta["name"], ) assert res["credential"]["id"] == meta["credential_id"] def test_create_credential_set_name( service_client, ): meta = load_response(service_client.create_client_credential, case="name").metadata res = service_client.create_client_credential(meta["client_id"], meta["name"]) assert res["credential"]["name"] == meta["name"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_create_policy.py000066400000000000000000000016141513221403200342410ustar00rootroot00000000000000from __future__ import annotations import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "project_id_str", "project_id_uuid", "high_assurance", "not_high_assurance", "required_mfa", "authentication_assurance_timeout", "display_name", "description", "domain_constraints_include", "empty_domain_constraints_include", "no_domain_constraints_include", "domain_constraints_exclude", "empty_domain_constraints_exclude", "no_domain_constraints_exclude", ), ) def test_create_policy( service_client, case_name: str, ): meta = load_response(service_client.create_policy, case=case_name).metadata res = service_client.create_policy(**meta["args"]) for k, v in meta["response"].items(): assert res["policy"][k] == v test_create_project.py000066400000000000000000000052611513221403200343330ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientimport json import uuid import pytest from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( "admin_id_style", ("string", "list", "set", "uuid", "uuid_list") ) def test_create_project_admin_id_styles(service_client, admin_id_style): meta = load_response(service_client.create_project).metadata if admin_id_style == "string": admin_ids = meta["admin_id"] elif admin_id_style == "list": admin_ids = [meta["admin_id"]] elif admin_id_style == "set": admin_ids = {meta["admin_id"]} elif admin_id_style == "uuid": admin_ids = uuid.UUID(meta["admin_id"]) elif admin_id_style == "uuid_list": admin_ids = [uuid.UUID(meta["admin_id"])] else: raise NotImplementedError(f"unknown admin_id_style {admin_id_style}") res = service_client.create_project( "My Project", "support@globus.org", admin_ids=admin_ids ) assert res["project"]["id"] == meta["id"] last_req = get_last_request() data = json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key assert data["project"]["display_name"] == "My Project", data assert data["project"]["contact_email"] == "support@globus.org", data assert data["project"]["admin_ids"] == [meta["admin_id"]], data assert "admin_group_ids" not in data["project"], data @pytest.mark.parametrize( "group_id_style", ("string", "list", "set", "uuid", "uuid_list") ) def test_create_project_group_id_styles(service_client, group_id_style): meta = load_response(service_client.create_project, case="admin_group").metadata if group_id_style == "string": group_ids = meta["admin_group_id"] elif group_id_style == "list": group_ids = [meta["admin_group_id"]] elif group_id_style == "set": group_ids = {meta["admin_group_id"]} elif group_id_style == "uuid": group_ids = uuid.UUID(meta["admin_group_id"]) elif group_id_style == "uuid_list": group_ids = [uuid.UUID(meta["admin_group_id"])] else: raise NotImplementedError(f"unknown group_id_style {group_id_style}") res = service_client.create_project( "My Project", "support@globus.org", admin_group_ids=group_ids ) assert res["project"]["id"] == meta["id"] last_req = get_last_request() data = json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key assert data["project"]["display_name"] == "My Project", data assert data["project"]["contact_email"] == "support@globus.org", data assert data["project"]["admin_group_ids"] == [meta["admin_group_id"]], data assert "admin_ids" not in data["project"], data globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_create_scope.py000066400000000000000000000014151513221403200340520ustar00rootroot00000000000000from __future__ import annotations import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "client_id_str", "client_id_uuid", "name", "description", "scope_suffix", "no_required_domains", "required_domains", "no_dependent_scopes", "dependent_scopes", "advertised", "not_advertised", "allows_refresh_token", "disallows_refresh_token", ), ) def test_create_scope( service_client, case_name: str, ): meta = load_response(service_client.create_scope, case=case_name).metadata res = service_client.create_scope(**meta["args"]) for k, v in meta["response"].items(): assert res["scope"][k] == v globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_delete_client.py000066400000000000000000000011011513221403200342060ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_delete_client( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.delete_client).metadata if uuid_type is str: res = service_client.delete_client(client_id=meta["client_id"]) else: res = service_client.delete_client(client_id=uuid.UUID(meta["client_id"])) assert res["client"]["id"] == meta["client_id"] test_delete_client_credential.py000066400000000000000000000014111513221403200363250ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientfrom __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_delete_credential( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.delete_client_credential).metadata if uuid_type is str: res = service_client.delete_client_credential( client_id=meta["client_id"], credential_id=meta["credential_id"], ) else: res = service_client.delete_client_credential( client_id=uuid.UUID(meta["client_id"]), credential_id=uuid.UUID(meta["credential_id"]), ) assert res["credential"]["id"] == meta["credential_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_delete_policy.py000066400000000000000000000010551513221403200342370ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_delete_policy( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.delete_policy).metadata if uuid_type is str: res = service_client.delete_policy(meta["policy_id"]) else: res = service_client.delete_policy(uuid.UUID(meta["policy_id"])) assert res["policy"]["id"] == meta["policy_id"] test_delete_project.py000066400000000000000000000004311513221403200343240ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientfrom globus_sdk.testing import load_response def test_delete_project(service_client): meta = load_response(service_client.delete_project).metadata project_id = meta["id"] res = service_client.delete_project(project_id) assert res["project"]["id"] == meta["id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_delete_scope.py000066400000000000000000000010451513221403200340500ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_delete_scope( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.delete_scope).metadata if uuid_type is str: res = service_client.delete_scope(meta["scope_id"]) else: res = service_client.delete_scope(uuid.UUID(meta["scope_id"])) assert res["scope"]["id"] == meta["scope_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_client.py000066400000000000000000000022431513221403200335330ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk import GlobusSDKUsageError from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_get_client_by_id( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.get_client).metadata if uuid_type is str: res = service_client.get_client(client_id=meta["client_id"]) else: res = service_client.get_client(client_id=uuid.UUID(meta["client_id"])) assert res["client"]["id"] == meta["client_id"] def test_get_client_by_fqdn( service_client, ): meta = load_response(service_client.get_client, case="fqdn").metadata res = service_client.get_client(fqdn=meta["fqdn"]) assert res["client"]["id"] == meta["client_id"] def test_get_client_exactly_one_of_id_or_fqdn( service_client, ): with pytest.raises(GlobusSDKUsageError): service_client.get_client() with pytest.raises(GlobusSDKUsageError): service_client.get_client( client_id="1b72b72e-5251-454d-af67-0be35911d174", fqdn="globus.org", ) test_get_client_credentials.py000066400000000000000000000011571513221403200360340ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientfrom __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_get_client_credentials( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.get_client_credentials).metadata if uuid_type is str: res = service_client.get_client_credentials(meta["client_id"]) else: res = service_client.get_client_credentials(uuid.UUID(meta["client_id"])) assert {cred["id"] for cred in res["credentials"]} == {meta["credential_id"]} globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_clients.py000066400000000000000000000004151513221403200337150ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_get_clients(service_client): meta = load_response(service_client.get_clients).metadata res = service_client.get_clients() assert {client["id"] for client in res["clients"]} == set(meta["client_ids"]) globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_consents.py000066400000000000000000000011511513221403200341060ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_get_consents(service_client): meta = load_response(service_client.get_consents).metadata res = service_client.get_consents(meta["identity_id"]) forest = res.to_forest() assert len(forest.nodes) == 2 assert len(forest.trees) == 1 tree = forest.trees[0] assert forest.trees[0].max_depth == 2 assert tree.root.scope_name.endswith("transfer.api.globus.org:all") children = [tree.get_node(child_id) for child_id in tree.edges[tree.root.id]] assert len(children) == 1 assert children[0].scope_name.endswith("data_access") test_get_identities.py000066400000000000000000000074101513221403200343400ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientimport uuid import pytest import globus_sdk from globus_sdk.testing import get_last_request, load_response class StringWrapper: """Simple test object to be a non-string obj wrapping a string""" def __init__(self, s) -> None: self.s = s def __str__(self): return self.s def test_get_identities_unauthorized(service_client): data = load_response(service_client.get_identities, case="unauthorized") with pytest.raises(globus_sdk.AuthAPIError) as excinfo: service_client.get_identities(usernames="foobar@example.com") err = excinfo.value assert err.code == "UNAUTHORIZED" assert data.metadata["error_id"] in err.text assert err.raw_json == data.json @pytest.mark.parametrize( "usernames", [ "globus@globus.org", StringWrapper("globus@globus.org"), ["globus@globus.org"], ("globus@globus.org",), ], ) def test_get_identities_success(usernames, service_client): data = load_response(service_client.get_identities) res = service_client.get_identities(usernames=usernames) assert [x["id"] for x in res] == [data.metadata["id"]] lastreq = get_last_request() assert lastreq.params == { "usernames": "globus@globus.org", "provision": "false", # provision defaults to false } def test_get_identities_provision_flag_defaults_to_false(service_client): load_response(service_client.get_identities) service_client.get_identities(usernames="globus@globus.org") lastreq = get_last_request() assert "provision" in lastreq.params assert lastreq.params["provision"] == "false" @pytest.mark.parametrize("inval, outval", [(True, "true"), (False, "false")]) def test_get_identities_provision_flag_formatting(inval, outval, service_client): load_response(service_client.get_identities) service_client.get_identities(usernames="globus@globus.org", provision=inval) lastreq = get_last_request() assert "provision" in lastreq.params assert lastreq.params["provision"] == outval @pytest.mark.parametrize( "usernames", [ "globus@globus.org,sirosen@globus.org", (StringWrapper("sirosen@globus.org"), StringWrapper("globus@globus.org")), ["globus@globus.org", "sirosen@globus.org"], ], ) def test_get_identities_multiple_usernames_success(usernames, service_client): data = load_response(service_client.get_identities, case="multiple") if isinstance(usernames, str): expect_param = usernames else: expect_param = ",".join([str(x) for x in usernames]) res = service_client.get_identities(usernames=usernames) assert [x["username"] for x in res] == data.metadata["usernames"] assert [x["id"] for x in res] == data.metadata["ids"] lastreq = get_last_request() assert "usernames" in lastreq.params assert lastreq.params["usernames"] == expect_param @pytest.mark.parametrize( "ids", [ # two uuids, already comma delimited ",".join( [ "46bd0f56-e24f-11e5-a510-131bef46955c", "ae341a98-d274-11e5-b888-dbae3a8ba545", ] ), # a list of two UUID objects [ uuid.UUID("46bd0f56-e24f-11e5-a510-131bef46955c"), uuid.UUID("ae341a98-d274-11e5-b888-dbae3a8ba545"), ], ], ) def test_get_identities_multiple_ids_success(ids, service_client): data = load_response(service_client.get_identities, case="multiple") expect_param = ",".join(data.metadata["ids"]) res = service_client.get_identities(ids=ids) assert [x["id"] for x in res] == data.metadata["ids"] assert [x["username"] for x in res] == data.metadata["usernames"] lastreq = get_last_request() assert "ids" in lastreq.params assert lastreq.params["ids"] == expect_param test_get_identity_providers.py000066400000000000000000000033401513221403200361230ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientimport pytest import globus_sdk from globus_sdk.testing import get_last_request, load_response def test_get_identity_providers_by_domains(service_client): meta = load_response(service_client.get_identity_providers).metadata res = service_client.get_identity_providers(domains=meta["domains"]) assert [x["id"] for x in res] == meta["ids"] lastreq = get_last_request() assert lastreq.params == {"domains": ",".join(meta["domains"])} def test_get_identity_providers_by_ids(service_client): meta = load_response(service_client.get_identity_providers).metadata res = service_client.get_identity_providers(ids=meta["ids"]) assert [x["id"] for x in res] == meta["ids"] assert [x for y in res for x in y["domains"]] == meta["domains"] lastreq = get_last_request() assert lastreq.params == {"ids": ",".join(meta["ids"])} def test_get_identity_providers_mutex_args(service_client): with pytest.raises(globus_sdk.GlobusSDKUsageError, match="mutually exclusive"): service_client.get_identity_providers(ids="foo", domains="bar") def test_get_identity_providers_allows_query_params_with_no_args(service_client): # this test confirms that the request won't be rejected for passing arguments # without specifying either 'ids' or 'domains' -- the supposition being that some # other parameter is supported but unknown to the SDK meta = load_response(service_client.get_identity_providers).metadata res = service_client.get_identity_providers(query_params={"foo": "bar,baz,snork"}) assert [x["id"] for x in res] == meta["ids"] assert [x for y in res for x in y["domains"]] == meta["domains"] lastreq = get_last_request() assert lastreq.params == {"foo": "bar,baz,snork"} globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_policies.py000066400000000000000000000004211513221403200340600ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_get_policies(service_client): meta = load_response(service_client.get_policies).metadata res = service_client.get_policies() assert {policy["id"] for policy in res["policies"]} == set(meta["policy_ids"]) globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_policy.py000066400000000000000000000010411513221403200335470ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_get_policy( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.get_policy).metadata if uuid_type is str: res = service_client.get_policy(meta["policy_id"]) else: res = service_client.get_policy(uuid.UUID(meta["policy_id"])) assert res["policy"]["id"] == meta["policy_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_project.py000066400000000000000000000010511513221403200337170ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_get_project( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.get_project).metadata if uuid_type is str: res = service_client.get_project(meta["project_id"]) else: res = service_client.get_project(uuid.UUID(meta["project_id"])) assert res["project"]["id"] == meta["project_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_projects.py000066400000000000000000000003671513221403200341130ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_get_projects(service_client): meta = load_response(service_client.get_projects).metadata res = service_client.get_projects() assert [x["id"] for x in res] == meta["project_ids"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_scope.py000066400000000000000000000010531513221403200333640ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "uuid_type", (str, uuid.UUID), ) def test_get_scope( service_client, uuid_type: type[str] | type[uuid.UUID], ): meta = load_response(service_client.get_scope).metadata if uuid_type is str: res = service_client.get_scope(scope_id=meta["scope_id"]) else: res = service_client.get_scope(scope_id=uuid.UUID(meta["scope_id"])) assert res["scope"]["id"] == meta["scope_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_get_scopes.py000066400000000000000000000020621513221403200335500ustar00rootroot00000000000000import pytest from globus_sdk import GlobusSDKUsageError from globus_sdk.testing import load_response def test_get_scopes(service_client): meta = load_response(service_client.get_scopes).metadata res = service_client.get_scopes() assert {scope["id"] for scope in res["scopes"]} == set(meta["scope_ids"]) def test_get_scopes_by_ids(service_client): meta = load_response(service_client.get_scopes, case="id").metadata res = service_client.get_scopes(ids=[meta["scope_id"]]) assert res["scopes"][0]["id"] == meta["scope_id"] def test_get_scopes_by_strings(service_client): meta = load_response(service_client.get_scopes, case="string").metadata res = service_client.get_scopes(scope_strings=[meta["scope_string"]]) assert res["scopes"][0]["scope_string"] == meta["scope_string"] def test_get_scopes_id_strings_mutually_exclusive(service_client): with pytest.raises(GlobusSDKUsageError): service_client.get_scopes( scope_strings=["foo"], ids=["18a8cd00-700a-4fcb-b6da-6efca558c369"], ) globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_update_client.py000066400000000000000000000023401513221403200342340ustar00rootroot00000000000000from __future__ import annotations import uuid import pytest from globus_sdk import GlobusSDKUsageError from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "name", "publicly_visible", "not_publicly_visible", "redirect_uris", "links", "required_idp", "preselect_idp", ), ) def test_update_client_args( service_client, case_name: str, ): meta = load_response(service_client.update_client, case=case_name).metadata res = service_client.update_client(**meta["args"]) for k, v in meta["response"].items(): assert res["client"][k] == v def test_links_requirement(service_client): """ Verify that terms_and_conditions and privacy_policy must be used together. """ with pytest.raises(GlobusSDKUsageError): service_client.create_client( "FOO", uuid.uuid1(), visibility="public", terms_and_conditions="https://foo.net", ) with pytest.raises(GlobusSDKUsageError): service_client.create_client( "FOO", uuid.uuid1(), visibility="public", privacy_policy="https://foo.net", ) globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_update_policy.py000066400000000000000000000015521513221403200342610ustar00rootroot00000000000000from __future__ import annotations import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "project_id_str", "project_id_uuid", "authentication_assurance_timeout", "required_mfa", "not_required_mfa", "display_name", "description", "no_domain_constrants_include", "empty_domain_constrants_include", "domain_constrants_include", "no_domain_constrants_exclude", "empty_domain_constrants_exclude", "domain_constrants_exclude", ), ) def test_update_policy( service_client, case_name: str, ): meta = load_response(service_client.update_policy, case=case_name).metadata res = service_client.update_policy(**meta["args"]) for k, v in meta["response"].items(): assert res["policy"][k] == v test_update_project.py000066400000000000000000000053631513221403200343550ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_clientimport json import uuid import pytest from globus_sdk._missing import MISSING, filter_missing from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( "admin_id_style", ("missing", "string", "list", "set", "uuid", "uuid_list") ) def test_update_project_admin_id_styles(service_client, admin_id_style): meta = load_response(service_client.update_project).metadata if admin_id_style == "missing": admin_ids = MISSING elif admin_id_style == "string": admin_ids = meta["admin_id"] elif admin_id_style == "list": admin_ids = [meta["admin_id"]] elif admin_id_style == "set": admin_ids = {meta["admin_id"]} elif admin_id_style == "uuid": admin_ids = uuid.UUID(meta["admin_id"]) elif admin_id_style == "uuid_list": admin_ids = [uuid.UUID(meta["admin_id"])] else: raise NotImplementedError(f"unknown admin_id_style {admin_id_style}") project_id = meta["id"] res = service_client.update_project( project_id, display_name="My Project", admin_ids=admin_ids ) assert res["project"]["id"] == meta["id"] last_req = get_last_request() data = json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key if admin_id_style == "missing": assert filter_missing(data["project"]) == {"display_name": "My Project"} else: assert data["project"] == { "display_name": "My Project", "admin_ids": [meta["admin_id"]], } @pytest.mark.parametrize( "group_id_style", ("string", "list", "set", "uuid", "uuid_list") ) def test_update_project_group_id_styles(service_client, group_id_style): meta = load_response(service_client.update_project, case="admin_group").metadata if group_id_style == "string": group_ids = meta["admin_group_id"] elif group_id_style == "list": group_ids = [meta["admin_group_id"]] elif group_id_style == "set": group_ids = {meta["admin_group_id"]} elif group_id_style == "uuid": group_ids = uuid.UUID(meta["admin_group_id"]) elif group_id_style == "uuid_list": group_ids = [uuid.UUID(meta["admin_group_id"])] else: raise NotImplementedError(f"unknown group_id_style {group_id_style}") project_id = meta["id"] res = service_client.update_project( project_id, contact_email="support@globus.org", admin_group_ids=group_ids ) assert res["project"]["id"] == meta["id"] last_req = get_last_request() data = json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key assert data["project"] == { "contact_email": "support@globus.org", "admin_group_ids": [meta["admin_group_id"]], } globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_update_scope.py000066400000000000000000000013321513221403200340670ustar00rootroot00000000000000from __future__ import annotations import pytest from globus_sdk.testing import load_response @pytest.mark.parametrize( "case_name", ( "name", "description", "scope_suffix", "no_required_domains", "required_domains", "no_dependent_scopes", "dependent_scopes", "advertised", "not_advertised", "allows_refresh_token", "disallows_refresh_token", ), ) def test_update_scope( service_client, case_name: str, ): meta = load_response(service_client.update_scope, case=case_name).metadata res = service_client.update_scope(**meta["args"]) for k, v in meta["response"].items(): assert res["scope"][k] == v globus-globus-sdk-python-6a080e4/tests/functional/services/auth/service_client/test_userinfo.py000066400000000000000000000012201513221403200332420ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import load_response # TODO: add data for the success case and test it @pytest.mark.xfail def test_userinfo(): raise NotImplementedError @pytest.mark.parametrize("casename", ("unauthorized", "forbidden")) def test_userinfo_error_handling(service_client, casename): meta = load_response(service_client.userinfo, case=casename).metadata with pytest.raises(globus_sdk.AuthAPIError) as excinfo: service_client.userinfo() err = excinfo.value assert err.http_status == meta["http_status"] assert err.code == meta["code"] assert err.request_id == meta["error_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/auth/test_auth_client_flow.py000066400000000000000000000260471513221403200317560ustar00rootroot00000000000000import urllib.parse import uuid import pytest import globus_sdk from globus_sdk._missing import MISSING from globus_sdk.scopes import TransferScopes from globus_sdk.services.auth.flow_managers.native_app import _make_native_app_challenge from globus_sdk.testing import load_response CLIENT_ID = "d0f1d9b0-bd81-4108-be74-ea981664453a" @pytest.fixture def native_client(): client = globus_sdk.NativeAppAuthClient(client_id=CLIENT_ID) with client.retry_config.tune(max_retries=0): yield client @pytest.fixture def confidential_client(): client = globus_sdk.ConfidentialAppAuthClient( client_id=CLIENT_ID, client_secret="SECRET_SECRET_HES_GOT_A_SECRET" ) with client.retry_config.tune(max_retries=0): yield client # build a nearly-diagonal matrix over # # domain: str | list[str] # identities: uuid | str | list[uuid] | list[str] | list[str | uuid] # policies: uuid | str | list[uuid] | list[str] | list[str | uuid] # mfa: True | False # message: str | None # prompt: Literal["prompt"] | None # # The order of these options is consequential. # They are declared in the same order they tuple-unpack in the parametrized test. # `None` values, though valid for each parameter, are omitted; # they will be tested exactly once by a single all-`None` option test. domain_options = ("example.edu", ["example.edu", "example.org"]) identity_options = ( uuid.UUID(int=0), "foo-id", [uuid.UUID(int=0), uuid.UUID(int=1)], ["foo-id", "bar-id"], ["foo-id", uuid.UUID(int=2)], ) policy_options = ( uuid.UUID(int=3), "baz-id", [uuid.UUID(int=3), uuid.UUID(int=4)], ["baz-id", "quux-id"], ["baz-id", uuid.UUID(int=5)], ) message_options = ("Test message",) mfa_options = (True, False) prompt_options = ("login",) # Seed an all-`None` option test, then use a loop to fill in the rest. # The number of parameters here must match the test parameters: _ALL_SESSION_PARAM_COMBINATIONS = [(MISSING,) * 6] for idx, options in enumerate( ( domain_options, identity_options, policy_options, mfa_options, message_options, prompt_options, ) ): for option in options: parameters = [MISSING] * 6 parameters[idx] = option _ALL_SESSION_PARAM_COMBINATIONS.append(tuple(parameters)) @pytest.mark.parametrize("flow_type", ("native_app", "confidential_app")) # parametrize over both what is and what *is not* passed as a parameter @pytest.mark.parametrize( [ "domain_option", "identity_option", "policy_option", "mfa_option", "message_option", "prompt_option", ], _ALL_SESSION_PARAM_COMBINATIONS, ) def test_oauth2_get_authorize_url_supports_session_params( native_client, confidential_client, flow_type, domain_option, identity_option, policy_option, mfa_option, message_option, prompt_option, ): if flow_type == "native_app": client = native_client elif flow_type == "confidential_app": client = confidential_client else: raise NotImplementedError # get the url... client.oauth2_start_flow(redirect_uri="https://example.com", requested_scopes="foo") url_res = client.oauth2_get_authorize_url( session_required_single_domain=domain_option, session_required_identities=identity_option, session_required_policies=policy_option, session_required_mfa=mfa_option, session_message=message_option, prompt=prompt_option, ) # parse the result... parsed_url = urllib.parse.urlparse(url_res) parsed_params = urllib.parse.parse_qs(parsed_url.query) # prepare some helper data... expected_params_keys = { "session_required_single_domain" if domain_option else None, "session_required_identities" if identity_option else None, "session_required_policies" if policy_option else None, "session_required_mfa" if mfa_option is not MISSING else None, "prompt" if prompt_option else None, } expected_params_keys.discard(None) unexpected_query_params = { "session_required_single_domain", "session_required_identities", "session_required_policies", "session_required_mfa", "prompt", } - expected_params_keys parsed_params_keys = set(parsed_params.keys()) # ...and validate! assert expected_params_keys <= parsed_params_keys assert (unexpected_query_params - parsed_params_keys) == unexpected_query_params if domain_option is not MISSING: strized_option = ( ",".join(str(x) for x in domain_option) if isinstance(domain_option, list) else str(domain_option) ) assert parsed_params["session_required_single_domain"] == [strized_option] if identity_option is not MISSING: strized_option = ( ",".join(str(x) for x in identity_option) if isinstance(identity_option, list) else str(identity_option) ) assert parsed_params["session_required_identities"] == [strized_option] if policy_option is not MISSING: strized_option = ( ",".join(str(x) for x in policy_option) if isinstance(policy_option, list) else str(policy_option) ) assert parsed_params["session_required_policies"] == [strized_option] if mfa_option is not MISSING: strized_option = "True" if mfa_option else "False" assert parsed_params["session_required_mfa"] == [strized_option] if prompt_option is not MISSING: assert parsed_params["prompt"] == [prompt_option] def test_oauth2_get_authorize_url_native_defaults(native_client): flow_manager = globus_sdk.services.auth.GlobusNativeAppFlowManager( native_client, TransferScopes.all, ) native_client.current_oauth2_flow_manager = flow_manager # get url and validate results url_res = native_client.oauth2_get_authorize_url() parsed_url = urllib.parse.urlparse(url_res) assert f"https://{parsed_url.netloc}/" == native_client.base_url assert parsed_url.path == "/v2/oauth2/authorize" parsed_params = urllib.parse.parse_qs(parsed_url.query) assert parsed_params == { "client_id": [native_client.client_id], "redirect_uri": [native_client.base_url + "v2/web/auth-code"], "scope": [str(TransferScopes.all)], "state": ["_default"], "response_type": ["code"], "code_challenge": [flow_manager.challenge], "code_challenge_method": ["S256"], "access_type": ["online"], } def test_oauth2_get_authorize_url_native_custom_params(native_client): # starting flow with custom parameters, should not warn because a scope is specified flow_manager = globus_sdk.services.auth.GlobusNativeAppFlowManager( native_client, requested_scopes="scopes", redirect_uri="uri", state="state", verifier=("a" * 43), refresh_tokens=True, ) native_client.current_oauth2_flow_manager = flow_manager # get url_and validate results url_res = native_client.oauth2_get_authorize_url() verifier, remade_challenge = _make_native_app_challenge("a" * 43) parsed_url = urllib.parse.urlparse(url_res) assert f"https://{parsed_url.netloc}/" == native_client.base_url assert parsed_url.path == "/v2/oauth2/authorize" parsed_params = urllib.parse.parse_qs(parsed_url.query) assert parsed_params == { "client_id": [native_client.client_id], "redirect_uri": ["uri"], "scope": ["scopes"], "state": ["state"], "response_type": ["code"], "code_challenge": [urllib.parse.quote_plus(remade_challenge)], "code_challenge_method": ["S256"], "access_type": ["offline"], } def test_oauth2_get_authorize_url_confidential_defaults(confidential_client): flow_manager = globus_sdk.services.auth.GlobusAuthorizationCodeFlowManager( confidential_client, "uri", TransferScopes.all ) confidential_client.current_oauth2_flow_manager = flow_manager # get url_and validate results url_res = confidential_client.oauth2_get_authorize_url() parsed_url = urllib.parse.urlparse(url_res) assert f"https://{parsed_url.netloc}/" == confidential_client.base_url assert parsed_url.path == "/v2/oauth2/authorize" parsed_params = urllib.parse.parse_qs(parsed_url.query) assert parsed_params == { "client_id": [confidential_client.client_id], "redirect_uri": ["uri"], "scope": [str(TransferScopes.all)], "state": ["_default"], "response_type": ["code"], "access_type": ["online"], } def test_oauth2_get_authorize_url_confidential_custom_params(confidential_client): # starting flow with specified parameters flow_manager = globus_sdk.services.auth.GlobusAuthorizationCodeFlowManager( confidential_client, requested_scopes="scopes", redirect_uri="uri", state="state", refresh_tokens=True, ) confidential_client.current_oauth2_flow_manager = flow_manager # get url_and validate results url_res = confidential_client.oauth2_get_authorize_url() parsed_url = urllib.parse.urlparse(url_res) assert f"https://{parsed_url.netloc}/" == confidential_client.base_url assert parsed_url.path == "/v2/oauth2/authorize" parsed_params = urllib.parse.parse_qs(parsed_url.query) assert parsed_params == { "client_id": [confidential_client.client_id], "redirect_uri": ["uri"], "scope": ["scopes"], "state": ["state"], "response_type": ["code"], "access_type": ["offline"], } def test_oauth2_exchange_code_for_tokens_native(native_client): """ Starts a NativeAppFlowManager, Confirms invalid code raises 401 Further testing cannot be done without user login credentials """ load_response(native_client.oauth2_exchange_code_for_tokens, case="invalid_grant") flow_manager = globus_sdk.services.auth.GlobusNativeAppFlowManager( native_client, requested_scopes=TransferScopes.all ) native_client.current_oauth2_flow_manager = flow_manager with pytest.raises(globus_sdk.AuthAPIError) as excinfo: native_client.oauth2_exchange_code_for_tokens("invalid_code") assert excinfo.value.http_status == 401 assert excinfo.value.code is None def test_oauth2_exchange_code_for_tokens_confidential(confidential_client): """ Starts an AuthorizationCodeFlowManager, Confirms bad code raises 401 Further testing cannot be done without user login credentials """ load_response( confidential_client.oauth2_exchange_code_for_tokens, case="invalid_grant" ) flow_manager = globus_sdk.services.auth.GlobusAuthorizationCodeFlowManager( confidential_client, "uri", requested_scopes=TransferScopes.all ) confidential_client.current_oauth2_flow_manager = flow_manager with pytest.raises(globus_sdk.AuthAPIError) as excinfo: confidential_client.oauth2_exchange_code_for_tokens("invalid_code") assert excinfo.value.http_status == 401 assert excinfo.value.code is None globus-globus-sdk-python-6a080e4/tests/functional/services/auth/test_id_token.py000066400000000000000000000162111513221403200302140ustar00rootroot00000000000000import json import time import jwt import pytest import globus_sdk from tests.common import register_api_route OIDC_CONFIG = { "issuer": "https://auth.globus.org", "authorization_endpoint": "https://auth.globus.org/v2/oauth2/authorize", "userinfo_endpoint": "https://auth.globus.org/v2/oauth2/userinfo", "token_endpoint": "https://auth.globus.org/v2/oauth2/token", "revocation_endpoint": "https://auth.globus.org/v2/oauth2/token/revoke", "jwks_uri": "https://auth.globus.org/jwk.json", "response_types_supported": ["code", "token", "token id_token", "id_token"], "id_token_signing_alg_values_supported": ["RS512"], "scopes_supported": ["openid", "email", "profile"], "token_endpoint_auth_methods_supported": ["client_secret_basic"], "claims_supported": [ "at_hash", "aud", "email", "exp", "name", "nonce", "preferred_username", "iat", "iss", "sub", ], "subject_types_supported": ["public"], } JWK = { "keys": [ { "alg": "RS512", "e": "AQAB", "kty": "RSA", "n": "73l27Yp7WT2c0Ve7EoGJ13AuKzg-GHU7Mpgx0JKa_hO04gAXSVXRadQy7gmdLLtAK8uBVcV0fHGgsBl4J92t-I7hayiJSLbgbX-sZhI_OfegeOLcSNB9poPS9w60XGqR9buYOW2x-KXXitsmyHXNmg_-1u0uqfKHu9pmST8dcjUYXTM5F3oJpQKeJlSH8daMlDks4xb9Y83EEFRv-ppY965-WTm2NW4pwLlbgGTWFvZ6YS6GTb-mfGwGuzStI0lKZ7dOFx9ryYQ4wSoUVHtIrypT-gbuaT90Z2SkwOH-GaEZJkudctBeGpieOsyC7P40UXpwgGNFy3xoWL4vHpnHmQ", # noqa: E501 "use": "sig", } ] } JWK_PEM = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(JWK["keys"][0])) TOKEN_PAYLOAD = { "access_token": "auth_access_token", "scope": "profile email openid", "expires_in": 172800, "token_type": "Bearer", "resource_server": "auth.globus.org", "state": "_default", "refresh_token": "auth_refresh_token", "other_tokens": [ { "access_token": "transfer_access_token", "scope": "urn:globus:auth:scope:transfer.api.globus.org:all", "expires_in": 172800, "token_type": "Bearer", "resource_server": "transfer.api.globus.org", "state": "_default", "refresh_token": "transfer_refresh", } ], # this is a real ID token # and since the JWK is real as well, it should decode correctly "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJzdWIiOiJjOGFhZDQzZS1kMjc0LTExZTUtYmY5OC04YjAyODk2Y2Y3ODIiLCJvcmdhbml6YXRpb24iOiJHbG9idXMiLCJuYW1lIjoiU3RlcGhlbiBSb3NlbiIsInByZWZlcnJlZF91c2VybmFtZSI6InNpcm9zZW4yQGdsb2J1c2lkLm9yZyIsImlkZW50aXR5X3Byb3ZpZGVyIjoiNDExNDM3NDMtZjNjOC00ZDYwLWJiZGItZWVlY2FiYTg1YmQ5IiwiaWRlbnRpdHlfcHJvdmlkZXJfZGlzcGxheV9uYW1lIjoiR2xvYnVzIElEIiwiZW1haWwiOiJzaXJvc2VuQHVjaGljYWdvLmVkdSIsImxhc3RfYXV0aGVudGljYXRpb24iOjE2MjE0ODEwMDYsImlkZW50aXR5X3NldCI6W3sic3ViIjoiYzhhYWQ0M2UtZDI3NC0xMWU1LWJmOTgtOGIwMjg5NmNmNzgyIiwib3JnYW5pemF0aW9uIjoiR2xvYnVzIiwibmFtZSI6IlN0ZXBoZW4gUm9zZW4iLCJ1c2VybmFtZSI6InNpcm9zZW4yQGdsb2J1c2lkLm9yZyIsImlkZW50aXR5X3Byb3ZpZGVyIjoiNDExNDM3NDMtZjNjOC00ZDYwLWJiZGItZWVlY2FiYTg1YmQ5IiwiaWRlbnRpdHlfcHJvdmlkZXJfZGlzcGxheV9uYW1lIjoiR2xvYnVzIElEIiwiZW1haWwiOiJzaXJvc2VuQHVjaGljYWdvLmVkdSIsImxhc3RfYXV0aGVudGljYXRpb24iOjE2MjE0ODEwMDZ9LHsic3ViIjoiYjZlMjI3ZTgtZGI1Mi0xMWU1LWI2ZmYtYzNiMWNjMjU5ZTBkIiwibmFtZSI6bnVsbCwidXNlcm5hbWUiOiJzaXJvc2VuK2JhZGVtYWlsQGdsb2J1cy5vcmciLCJpZGVudGl0eV9wcm92aWRlciI6IjkyN2Q3MjM4LWY5MTctNGViMi05YWNlLWM1MjNmYTliYTM0ZSIsImlkZW50aXR5X3Byb3ZpZGVyX2Rpc3BsYXlfbmFtZSI6Ikdsb2J1cyBTdGFmZiIsImVtYWlsIjoic2lyb3NlbitiYWRlbWFpbEBnbG9idXMub3JnIiwibGFzdF9hdXRoZW50aWNhdGlvbiI6bnVsbH0seyJzdWIiOiJmN2Y4OWQwYS1kYzllLTExZTUtYWRkMC1hM2NiZDFhNTU5YjMiLCJuYW1lIjpudWxsLCJ1c2VybmFtZSI6InNpcm9zZW4rYmFkZW1haWwyQGdsb2J1cy5vcmciLCJpZGVudGl0eV9wcm92aWRlciI6IjkyN2Q3MjM4LWY5MTctNGViMi05YWNlLWM1MjNmYTliYTM0ZSIsImlkZW50aXR5X3Byb3ZpZGVyX2Rpc3BsYXlfbmFtZSI6Ikdsb2J1cyBTdGFmZiIsImVtYWlsIjoic2lyb3NlbitiYWRlbWFpbDJAZ2xvYnVzLm9yZyIsImxhc3RfYXV0aGVudGljYXRpb24iOm51bGx9XSwiaXNzIjoiaHR0cHM6Ly9hdXRoLmdsb2J1cy5vcmciLCJhdWQiOiI3ZmI1OGUwMC04MzlkLTQ0ZTMtODA0Ny0xMGE1MDI2MTJkY2EiLCJleHAiOjE2MjE2NTM4MTEsImlhdCI6MTYyMTQ4MTAxMSwiYXRfaGFzaCI6IjFQdlVhbmNFdUxfc2cxV1BsNWx1TUVGR2tjTDZQaDh1cWdpVUZzejhkZUEifQ.CtfnFtfM32ICo0euHv9GnpVHFL1jWz0NriPTXAv6w08Ylk9JBJtmB3oMKNSO-1TGoWUPFDp9TFFk6N32VyF0hsVDtT5DT3t5oq0qfqbPrZA3R04HARW0xtcK_ejNDHBmj6wysey3EzjT764XTvcGOe63CKQ_RJm97ulVaseIT0Aet7AYo5tQuOiSOQ70xzL7Oax3W6TrWi3FIAA-PIMSrAJKbsG7imGOVkaIObG9a-X5yTOcrB4IG4Wat-pN_QiCiiOw_LDCF-r455PwalmnSGUugMYfsdL2k3UxqwOMLIppHnx5-UVAzj3mygj8eZTp6imjqxNMdakS3vhG8dtxbw", # noqa: E501 } # this is the 'exp' value encoded above FIXED_JWT_EXPIRATION_TIME = 1621653811 @pytest.fixture def client(): # this client ID is the audience for the above id_token # the client_id must match the audience in order for the decode to work (we pass it # as the audience during decoding) return globus_sdk.AuthLoginClient(client_id="7fb58e00-839d-44e3-8047-10a502612dca") @pytest.fixture(autouse=True) def register_token_response(): register_api_route( "auth", "/v2/oauth2/token", method="POST", body=json.dumps(TOKEN_PAYLOAD) ) @pytest.fixture def token_response(register_token_response, client): return client.oauth2_token( { "grant_type": "authorization_code", "code": "foo", "redirect_uri": "https://bar.example.org/", } ) def test_decode_id_token(token_response): register_api_route( "auth", "/.well-known/openid-configuration", method="GET", body=json.dumps(OIDC_CONFIG), ) register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK)) decoded = token_response.decode_id_token(jwt_params={"verify_exp": False}) assert decoded["preferred_username"] == "sirosen2@globusid.org" def test_decode_id_token_with_saved_oidc_config(token_response): register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK)) decoded = token_response.decode_id_token( openid_configuration=OIDC_CONFIG, jwt_params={"verify_exp": False} ) assert decoded["preferred_username"] == "sirosen2@globusid.org" def test_decode_id_token_with_saved_oidc_config_and_jwk(token_response): decoded = token_response.decode_id_token( openid_configuration=OIDC_CONFIG, jwk=JWK_PEM, jwt_params={"verify_exp": False}, ) assert decoded["preferred_username"] == "sirosen2@globusid.org" def test_invalid_decode_id_token_usage(token_response): with pytest.raises(globus_sdk.exc.GlobusSDKUsageError): token_response.decode_id_token(jwk=JWK_PEM, jwt_params={"verify_exp": False}) def test_decode_id_token_with_leeway(token_response): register_api_route( "auth", "/.well-known/openid-configuration", method="GET", body=json.dumps(OIDC_CONFIG), ) register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK)) # do a decode with a leeway parameter set high enough that the ancient # expiration time will be tolerated expiration_delta = time.time() - FIXED_JWT_EXPIRATION_TIME decoded = token_response.decode_id_token( jwt_params={"leeway": expiration_delta + 1} ) assert decoded["preferred_username"] == "sirosen2@globusid.org" globus-globus-sdk-python-6a080e4/tests/functional/services/auth/test_identity_map.py000066400000000000000000000231101513221403200311020ustar00rootroot00000000000000import pytest import responses import globus_sdk from globus_sdk.testing import get_last_request, load_response IDENTITIES_MULTIPLE_RESPONSE = { "identities": [ { "email": None, "id": "46bd0f56-e24f-11e5-a510-131bef46955c", "identity_provider": "927d7238-f917-4eb2-9ace-c523fa9ba34e", "name": None, "organization": None, "status": "unused", "username": "globus@globus.org", }, { "email": "sirosen@globus.org", "id": "ae341a98-d274-11e5-b888-dbae3a8ba545", "identity_provider": "927d7238-f917-4eb2-9ace-c523fa9ba34e", "name": "Stephen Rosen", "organization": "Globus Team", "status": "used", "username": "sirosen@globus.org", }, ] } def test_identity_map(service_client): meta = load_response(service_client.get_identities, case="sirosen").metadata idmap = globus_sdk.IdentityMap(service_client, [meta["username"]]) assert idmap[meta["username"]]["organization"] == meta["org"] # lookup by ID also works assert idmap[meta["id"]]["organization"] == meta["org"] # the last (only) API call was the one by username last_req = get_last_request() assert "ids" not in last_req.params assert last_req.params == {"usernames": meta["username"], "provision": "false"} def test_identity_map_initialization_no_values(service_client): idmap = globus_sdk.IdentityMap(service_client) assert idmap.unresolved_ids == set() assert idmap.unresolved_usernames == set() def test_identity_map_initialization_mixed_and_duplicate_values(service_client): # splits things up and deduplicates values into sets idmap = globus_sdk.IdentityMap( service_client, [ "sirosen@globus.org", "ae341a98-d274-11e5-b888-dbae3a8ba545", "globus@globus.org", "sirosen@globus.org", "globus@globus.org", "ae341a98-d274-11e5-b888-dbae3a8ba545", "sirosen@globus.org", "ae341a98-d274-11e5-b888-dbae3a8ba545", ], ) assert idmap.unresolved_ids == {"ae341a98-d274-11e5-b888-dbae3a8ba545"} assert idmap.unresolved_usernames == {"sirosen@globus.org", "globus@globus.org"} def test_identity_map_initialization_batch_size(service_client): idmap = globus_sdk.IdentityMap(service_client, id_batch_size=10) assert idmap.unresolved_ids == set() assert idmap.unresolved_usernames == set() assert idmap.id_batch_size == 10 def test_identity_map_add(service_client): idmap = globus_sdk.IdentityMap(service_client) assert idmap.add("sirosen@globus.org") is True assert idmap.add("sirosen@globus.org") is False assert idmap.add("46bd0f56-e24f-11e5-a510-131bef46955c") is True assert idmap.add("46bd0f56-e24f-11e5-a510-131bef46955c") is False def test_identity_map_add_after_lookup(service_client): meta = load_response(service_client.get_identities, case="sirosen").metadata idmap = globus_sdk.IdentityMap(service_client) x = idmap[meta["username"]]["id"] # this is the key: adding it will indicate that we've already seen this ID, perhaps # "unintuitively", and that's part of the value of `add()` returning a boolean value assert idmap.add(x) is False assert idmap[x] == idmap[meta["username"]] def test_identity_map_multiple(service_client): meta = load_response(service_client.get_identities, case="multiple").metadata idmap = globus_sdk.IdentityMap( service_client, ["sirosen@globus.org", "globus@globus.org"] ) assert idmap["sirosen@globus.org"]["organization"] == "Globus Team" assert idmap["globus@globus.org"]["organization"] is None last_req = get_last_request() # order doesn't matter, but it should be just these two # if IdentityMap doesn't deduplicate correctly, it could send # `sirosen@globus.org,globus@globus.org,sirosen@globus.org` on the first lookup assert last_req.params["usernames"].split(",") in [ meta["usernames"], meta["usernames"][::-1], ] assert last_req.params["provision"] == "false" def test_identity_map_keyerror(service_client): load_response(service_client.get_identities, case="sirosen") idmap = globus_sdk.IdentityMap(service_client) # a name which doesn't come back, indicating that it was not found, will KeyError with pytest.raises(KeyError): idmap["sirosen2@globus.org"] last_req = get_last_request() assert last_req.params == {"usernames": "sirosen2@globus.org", "provision": "false"} def test_identity_map_get_with_default(service_client): load_response(service_client.get_identities, case="sirosen") magic = object() # sentinel value idmap = globus_sdk.IdentityMap(service_client) # a name which doesn't come back, if looked up with `get()` should return the # default assert idmap.get("sirosen2@globus.org", magic) is magic def test_identity_map_del(service_client): meta = load_response(service_client.get_identities).metadata idmap = globus_sdk.IdentityMap(service_client) identity_id = idmap[meta["username"]]["id"] del idmap[identity_id] assert idmap.get(meta["username"])["id"] == identity_id # we've only made one request so far assert len(responses.calls) == 1 # but a lookup by ID after a del is going to trigger another request because we've # invalidated the cached ID data and are asking the IDMap to look it up again assert idmap.get(identity_id)["username"] == meta["username"] assert len(responses.calls) == 2 @pytest.mark.parametrize( "lookup1,lookup2", [ ("username", "username"), ("username", "id"), ("id", "username"), ], ) @pytest.mark.parametrize("initial_add", [True, False]) def test_identity_map_shared_cache_match(service_client, initial_add, lookup1, lookup2): meta = load_response(service_client.get_identities, case="sirosen").metadata lookup1, lookup2 = meta[lookup1], meta[lookup2] cache = {} idmap1 = globus_sdk.IdentityMap(service_client, cache=cache) idmap2 = globus_sdk.IdentityMap(service_client, cache=cache) if initial_add: idmap1.add(lookup1) idmap2.add(lookup2) # no requests yet... assert len(responses.calls) == 0 # do the first lookup, it should make one request assert idmap1[lookup1]["organization"] == meta["org"] assert len(responses.calls) == 1 # lookup more values and make sure that "everything matches" assert idmap2[lookup2]["organization"] == meta["org"] assert idmap1[lookup1]["id"] == idmap2[lookup2]["id"] assert idmap1[lookup2]["id"] == idmap2[lookup1]["id"] # we've only made one request, because the shared cache captured this info on the # very first call assert len(responses.calls) == 1 @pytest.mark.parametrize( "lookup_style", ["usernames", "ids"], ) def test_identity_map_shared_cache_mismatch(service_client, lookup_style): meta = load_response(service_client.get_identities, case="multiple").metadata lookup1, lookup2 = meta[lookup_style] cache = {} idmap1 = globus_sdk.IdentityMap(service_client, [lookup1, lookup2], cache=cache) idmap2 = globus_sdk.IdentityMap(service_client, cache=cache) # no requests yet... assert len(responses.calls) == 0 # do the first lookup, it should make one request assert lookup1 in (idmap1[lookup1]["id"], idmap1[lookup1]["username"]) assert len(responses.calls) == 1 # lookup more values and make sure that "everything matches" assert lookup2 in (idmap2[lookup2]["id"], idmap2[lookup2]["username"]) assert idmap1[lookup1]["id"] == idmap2[lookup1]["id"] assert idmap1[lookup2]["id"] == idmap2[lookup2]["id"] # we've only made one request, because the shared cache captured this info on the # very first call assert len(responses.calls) == 1 def test_identity_map_prepopulated_cache(service_client): meta = load_response(service_client.get_identities).metadata # populate the cache, even with nulls it should stop any lookups from happening cache = {meta["id"]: None, meta["username"]: None} idmap = globus_sdk.IdentityMap(service_client, cache=cache) # no requests yet... assert len(responses.calls) == 0 # do the lookups assert idmap[meta["id"]] is None assert idmap[meta["username"]] is None # still no calls made assert len(responses.calls) == 0 def test_identity_map_batch_limit(service_client): meta1 = load_response(service_client.get_identities).metadata meta2 = load_response(service_client.get_identities, case="sirosen").metadata # setup the ID map with a size limit of 1 idmap = globus_sdk.IdentityMap(service_client, id_batch_size=1) idmap.add(meta2["id"]) idmap.add(meta1["id"]) # no requests yet... assert len(responses.calls) == 0 # do the first lookup, using the second ID to be added # only one call should be made assert idmap[meta1["id"]]["username"] == meta1["username"] assert len(responses.calls) == 1 # 1 ID left unresolved assert len(idmap.unresolved_ids) == 1 # the last (only) API call was by ID with one ID last_req = get_last_request() assert "usernames" not in last_req.params assert last_req.params == {"ids": meta1["id"]} # second lookup works as well assert idmap[meta2["id"]]["username"] == meta2["username"] assert len(responses.calls) == 2 # no IDs left unresolved assert len(idmap.unresolved_ids) == 0 # the last API call was by ID with one ID last_req = get_last_request() assert "usernames" not in last_req.params assert last_req.params == {"ids": meta2["id"]} globus-globus-sdk-python-6a080e4/tests/functional/services/compute/000077500000000000000000000000001513221403200255215ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/compute/conftest.py000066400000000000000000000005311513221403200277170ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def compute_client_v2(): client = globus_sdk.ComputeClientV2() with client.retry_config.tune(max_retries=0): yield client @pytest.fixture def compute_client_v3(): client = globus_sdk.ComputeClientV3() with client.retry_config.tune(max_retries=0): yield client globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/000077500000000000000000000000001513221403200260505ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/__init__.py000066400000000000000000000000001513221403200301470ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_delete_endpoint.py000066400000000000000000000005451513221403200326270ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_delete_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.delete_endpoint).metadata res = compute_client_v2.delete_endpoint(endpoint_id=meta["endpoint_id"]) assert res.http_status == 200 assert res.data == {"result": 302} globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_delete_function.py000066400000000000000000000005451513221403200326340ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_delete_function(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.delete_function).metadata res = compute_client_v2.delete_function(function_id=meta["function_id"]) assert res.http_status == 200 assert res.data == {"result": 302} globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_endpoint.py000066400000000000000000000005501513221403200321400ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_endpoint).metadata res = compute_client_v2.get_endpoint(endpoint_id=meta["endpoint_id"]) assert res.http_status == 200 assert res.data["uuid"] == meta["endpoint_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_endpoint_status.py000066400000000000000000000005641513221403200335500ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_endpoint_status(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_endpoint_status).metadata res = compute_client_v2.get_endpoint_status(endpoint_id=meta["endpoint_id"]) assert res.http_status == 200 assert res.data["status"] == "online" globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_endpoints.py000066400000000000000000000023471513221403200323310ustar00rootroot00000000000000import urllib.parse import globus_sdk from globus_sdk.testing import get_last_request, load_response def test_get_endpoints(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_endpoints).metadata res = compute_client_v2.get_endpoints() assert res.http_status == 200 assert res.data[0]["uuid"] == meta["endpoint_id"] assert res.data[1]["uuid"] == meta["endpoint_id_2"] # confirm that the 'role' param was not sent last_req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(last_req.url).query) assert "role" not in parsed_qs def test_get_endpoints_any(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_endpoints, case="any").metadata res = compute_client_v2.get_endpoints(role="any") last_req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(last_req.url).query) assert parsed_qs["role"] == ["any"] assert res.http_status == 200 assert res.data[0]["uuid"] == meta["endpoint_id"] assert res.data[1]["uuid"] == meta["endpoint_id_2"] assert res.data[2]["uuid"] == meta["endpoint_id_3"] assert res.data[1]["owner"] != res.data[2]["owner"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_function.py000066400000000000000000000007551513221403200321540ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_function(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_function).metadata res = compute_client_v2.get_function(function_id=meta["function_id"]) assert res.http_status == 200 assert res.data["function_uuid"] == meta["function_id"] assert res.data["function_name"] == meta["function_name"] assert res.data["function_code"] == meta["function_code"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_result_amqp_url.py000066400000000000000000000005031513221403200335340ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_result_amqp_url(compute_client_v2: globus_sdk.ComputeClientV2): load_response(compute_client_v2.get_result_amqp_url) res = compute_client_v2.get_result_amqp_url() assert res.http_status == 200 assert "connection_url" in res.data globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_task_batch.py000066400000000000000000000014301513221403200324210ustar00rootroot00000000000000import typing as t import uuid import pytest import globus_sdk from globus_sdk.testing import load_response @pytest.mark.parametrize( "transform", ( pytest.param(lambda x: x, id="string"), pytest.param(lambda x: [x], id="list"), pytest.param(lambda x: {x}, id="set"), pytest.param(lambda x: uuid.UUID(x), id="uuid"), pytest.param(lambda x: [uuid.UUID(x)], id="uuid_list"), ), ) def test_get_task_batch( compute_client_v2: globus_sdk.ComputeClientV2, transform: t.Callable ): meta = load_response(compute_client_v2.get_task_batch).metadata task_ids = transform(meta["task_id"]) res = compute_client_v2.get_task_batch(task_ids=task_ids) assert res.http_status == 200 assert meta["task_id"] in res.data["results"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_task_group.py000066400000000000000000000007601513221403200325010ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_task_group(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_task_group).metadata res = compute_client_v2.get_task_group(task_group_id=meta["task_group_id"]) assert res.http_status == 200 assert meta["task_group_id"] == res.data["taskgroup_id"] assert meta["task_id"] == res.data["tasks"][0]["id"] assert meta["task_id_2"] == res.data["tasks"][1]["id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_task_info.py000066400000000000000000000005231513221403200322750ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_task(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_task).metadata res = compute_client_v2.get_task(task_id=meta["task_id"]) assert res.http_status == 200 assert res.data["task_id"] == meta["task_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_get_version.py000066400000000000000000000011411513221403200320020ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_version(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_version).metadata res = compute_client_v2.get_version() assert res.http_status == 200 assert res.data == meta["api_version"] def test_get_version_all(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.get_version, case="all").metadata res = compute_client_v2.get_version(service="all") assert res.http_status == 200 assert res.data["api"] == meta["api_version"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_lock_endpoint.py000066400000000000000000000005641513221403200323160ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_lock_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.lock_endpoint).metadata res = compute_client_v2.lock_endpoint(endpoint_id=meta["endpoint_id"]) assert res.http_status == 200 assert res.data["endpoint_id"] == meta["endpoint_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_register_endpoint.py000066400000000000000000000022621513221403200332070ustar00rootroot00000000000000import uuid import globus_sdk from globus_sdk.testing import load_response ENDPOINT_CONFIG = """ display_name: My Endpoint engine: type: GlobusComputeEngine """ def test_register_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.register_endpoint).metadata register_doc = { "endpoint_uuid": meta["endpoint_id"], "endpoint_name": "my-endpoint", "display_name": "My Endpoint", "version": "2.31.0", "multi_user": False, "allowed_functions": [str(uuid.uuid1())], "authentication_policy": str(uuid.uuid1()), "metadata": { "endpoint_config": ENDPOINT_CONFIG.strip(), "user_config_template": "", "user_config_schema": {}, "description": "My endpoint description", "ip_address": "140.221.112.13", "hostname": "my-hostname", "local_user": "user1", "sdk_version": "2.31.0", "endpoint_version": "2.31.0", }, } res = compute_client_v2.register_endpoint(data=register_doc) assert res.http_status == 200 assert res.data["endpoint_id"] == meta["endpoint_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_register_function.py000066400000000000000000000007651513221403200332220ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_register_function(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.register_function).metadata registration_doc = { "function_name": meta["function_name"], "function_code": meta["function_code"], } res = compute_client_v2.register_function(data=registration_doc) assert res.http_status == 200 assert res.data["function_uuid"] == meta["function_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v2/test_submit.py000066400000000000000000000015241513221403200307660ustar00rootroot00000000000000import uuid import globus_sdk from globus_sdk.testing import load_response def test_submit(compute_client_v2: globus_sdk.ComputeClientV2): meta = load_response(compute_client_v2.submit).metadata ep_id, func_id = uuid.uuid1(), uuid.uuid1() submit_doc = { "task_group_id": meta["task_group_id"], "create_websocket_queue": False, "tasks": [ [func_id, ep_id, "36\n00\ngASVDAAAAAAAAACMBlJvZG5leZSFlC4=\n12 ..."], [func_id, ep_id, "36\n00\ngASVCwAAAAAAAACMBUJvYmJ5lIWULg==\n12 ..."], ], } res = compute_client_v2.submit(data=submit_doc) assert res.http_status == 200 assert res.data["task_group_id"] == meta["task_group_id"] results = res.data["results"] assert results[0]["task_uuid"] == meta["task_id"] assert results[1]["task_uuid"] == meta["task_id_2"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/000077500000000000000000000000001513221403200260515ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/__init__.py000066400000000000000000000000001513221403200301500ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/test_get_endpoint_allowlist.py000066400000000000000000000006171513221403200342370ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_endpoint_allowlist(compute_client_v3: globus_sdk.ComputeClientV3): meta = load_response(compute_client_v3.get_endpoint_allowlist).metadata res = compute_client_v3.get_endpoint_allowlist(endpoint_id=meta["endpoint_id"]) assert res.http_status == 200 assert res.data["endpoint_id"] == meta["endpoint_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/test_lock_endpoint.py000066400000000000000000000005641513221403200323170ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_lock_endpoint(compute_client_v3: globus_sdk.ComputeClientV3): meta = load_response(compute_client_v3.lock_endpoint).metadata res = compute_client_v3.lock_endpoint(endpoint_id=meta["endpoint_id"]) assert res.http_status == 200 assert res.data["endpoint_id"] == meta["endpoint_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/test_register_endpoint.py000066400000000000000000000022151513221403200332060ustar00rootroot00000000000000import uuid import globus_sdk from globus_sdk.testing import load_response ENDPOINT_CONFIG = """ display_name: My Endpoint engine: type: GlobusComputeEngine """ def test_register_endpoint(compute_client_v3: globus_sdk.ComputeClientV3): load_response(compute_client_v3.register_endpoint) request_doc = { "endpoint_name": "my_endpoint", "display_name": "My Endpoint", "version": "2.31.0", "multi_user": False, "allowed_functions": [str(uuid.uuid1())], "authentication_policy": str(uuid.uuid1()), "subscription_uuid": str(uuid.uuid1()), "metadata": { "endpoint_config": ENDPOINT_CONFIG.strip(), "user_config_template": "", "user_config_schema": {}, "description": "My endpoint description", "ip_address": "140.221.112.13", "hostname": "my-hostname", "local_user": "user1", "sdk_version": "2.31.0", "endpoint_version": "2.31.0", }, } res = compute_client_v3.register_endpoint(data=request_doc) assert res.http_status == 200 assert "endpoint_id" in res.data globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/test_register_function.py000066400000000000000000000007651513221403200332230ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_register_function(compute_client_v3: globus_sdk.ComputeClientV3): meta = load_response(compute_client_v3.register_function).metadata registration_doc = { "function_name": meta["function_name"], "function_code": meta["function_code"], } res = compute_client_v3.register_function(data=registration_doc) assert res.http_status == 200 assert res.data["function_uuid"] == meta["function_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/test_submit.py000066400000000000000000000020741513221403200307700ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_submit(compute_client_v3: globus_sdk.ComputeClientV3): meta = load_response(compute_client_v3.submit).metadata submit_doc = { "tasks": { meta["function_id"]: [ "36\n00\ngASVDAAAAAAAAACMBlJvZG5leZSFlC4=\n12 ...", "36\n00\ngASVCwAAAAAAAACMBUJvYmJ5lIWULg==\n12 ...", ], }, "task_group_id": meta["task_group_id"], "create_queue": True, "user_runtime": { "globus_compute_sdk_version": "2.29.0", "globus_sdk_version": "3.46.0", "python_version": "3.11.9", }, } res = compute_client_v3.submit(endpoint_id=meta["endpoint_id"], data=submit_doc) assert res.http_status == 200 assert res.data["request_id"] == meta["request_id"] assert res.data["task_group_id"] == meta["task_group_id"] assert res.data["endpoint_id"] == meta["endpoint_id"] assert res.data["tasks"] == { meta["function_id"]: [meta["task_id"], meta["task_id_2"]] } globus-globus-sdk-python-6a080e4/tests/functional/services/compute/v3/test_update_endpoint.py000066400000000000000000000023431513221403200326460ustar00rootroot00000000000000import uuid import globus_sdk from globus_sdk.testing import load_response ENDPOINT_CONFIG = """ display_name: My Endpoint engine: type: GlobusComputeEngine """ def test_update_endpoint(compute_client_v3: globus_sdk.ComputeClientV3): meta = load_response(compute_client_v3.update_endpoint).metadata request_doc = { "endpoint_name": "my_endpoint", "display_name": "My Compute Endpoint", "version": "2.31.0", "multi_user": False, "allowed_functions": [str(uuid.uuid1())], "authentication_policy": str(uuid.uuid1()), "subscription_uuid": str(uuid.uuid1()), "metadata": { "endpoint_config": ENDPOINT_CONFIG.strip(), "user_config_template": "", "user_config_schema": {}, "description": "My endpoint description", "ip_address": "140.221.112.13", "hostname": "my-hostname", "local_user": "user1", "sdk_version": "2.31.0", "endpoint_version": "2.31.0", }, } res = compute_client_v3.update_endpoint( endpoint_id=meta["endpoint_id"], data=request_doc ) assert res.http_status == 200 assert res.data["endpoint_id"] == meta["endpoint_id"] globus-globus-sdk-python-6a080e4/tests/functional/services/flows/000077500000000000000000000000001513221403200251775ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/flows/conftest.py000066400000000000000000000010201513221403200273670ustar00rootroot00000000000000import typing as t import pytest import globus_sdk @pytest.fixture def flows_client(): client = globus_sdk.FlowsClient() with client.retry_config.tune(max_retries=0): yield client @pytest.fixture def specific_flow_client_class() -> t.Type[globus_sdk.SpecificFlowClient]: class CustomSpecificFlowClient(globus_sdk.SpecificFlowClient): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.retry_config.max_retries = 0 return CustomSpecificFlowClient globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_flow_crud.py000066400000000000000000000102271513221403200305760ustar00rootroot00000000000000import json import pytest from responses import matchers from globus_sdk import MISSING, FlowsAPIError from globus_sdk.testing import get_last_request, load_response from globus_sdk.testing.models import RegisteredResponse @pytest.mark.parametrize("subscription_id", [MISSING, None, "dummy_subscription_id"]) def test_create_flow(flows_client, subscription_id): metadata = load_response(flows_client.create_flow).metadata resp = flows_client.create_flow( **metadata["params"], subscription_id=subscription_id ) assert resp.data["title"] == "Multi Step Transfer" last_req = get_last_request() req_body = json.loads(last_req.body) if subscription_id is not MISSING: assert req_body["subscription_id"] == subscription_id else: assert "subscription_id" not in req_body @pytest.mark.parametrize("value", [MISSING, [], ["dummy_value"]]) @pytest.mark.parametrize("key", ["run_managers", "run_monitors"]) def test_create_flow_run_role_serialization(flows_client, key, value): request_body = { "title": "Multi Step Transfer", "definition": { "StartAt": "Transfer1", "States": { "Transfer1": {"Type": "Pass", "End": True}, }, }, "input_schema": {}, } if value is not MISSING: request_body[key] = value load_response( RegisteredResponse( service="flows", path="/flows", method="POST", json=request_body, match=[ matchers.json_params_matcher( params={ "title": request_body["title"], "definition": request_body["definition"], "input_schema": request_body["input_schema"], }, strict_match=False, ) ], ) ) flows_client.create_flow(**request_body) last_req = get_last_request() req_body = json.loads(last_req.body) if value is MISSING: assert key not in req_body else: assert req_body[key] == value def test_create_flow_error_parsing(flows_client): metadata = load_response( flows_client.create_flow, case="bad_admin_principal_error" ).metadata with pytest.raises(FlowsAPIError) as excinfo: flows_client.create_flow(**metadata["params"]) err = excinfo.value assert err.code == "UNPROCESSABLE_ENTITY" assert err.messages == [ ( 'Unrecognized principal string: "ae341a98-d274-11e5-b888-dbae3a8ba545". ' 'Allowed principal types in role "FlowAdministrator": ' "[, ]" ), ( 'Unrecognized principal string: "4fab4345-6d20-43a0-9a25-16b2e3bbe765". ' 'Allowed principal types in role "FlowAdministrator": ' "[, ]" ), ] def test_get_flow(flows_client): meta = load_response(flows_client.get_flow).metadata resp = flows_client.get_flow(meta["flow_id"]) assert resp.data["title"] == meta["title"] def test_update_flow(flows_client): meta = load_response(flows_client.update_flow).metadata resp = flows_client.update_flow(meta["flow_id"], **meta["params"]) for k, v in meta["params"].items(): assert k in resp assert resp[k] == v @pytest.mark.parametrize("value", [MISSING, [], ["dummy_value"]]) @pytest.mark.parametrize("key", ["run_managers", "run_monitors"]) def test_update_flow_run_role_serialization(flows_client, key, value): metadata = load_response(flows_client.update_flow).metadata params = {**metadata["params"], key: value} flows_client.update_flow(metadata["flow_id"], **params) last_req = get_last_request() req_body = json.loads(last_req.body) if value is MISSING: assert key not in req_body else: assert req_body[key] == value def test_delete_flow(flows_client): metadata = load_response(flows_client.delete_flow).metadata resp = flows_client.delete_flow(metadata["flow_id"]) assert resp.data["title"] == "Multi Step Transfer" assert resp.data["DELETED"] is True globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_flow_validate.py000066400000000000000000000026121513221403200314310ustar00rootroot00000000000000import json import pytest from globus_sdk import MISSING, FlowsAPIError from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize("input_schema", [MISSING, {}]) def test_validate_flow(flows_client, input_schema): metadata = load_response(flows_client.validate_flow).metadata # Prepare the payload payload = {"definition": metadata["success"]} if input_schema is not MISSING: payload["input_schema"] = input_schema resp = flows_client.validate_flow(**payload) assert resp.data["scopes"] == { "User": ["urn:globus:auth:scope:transfer.api.globus.org:all"] } # Check what was actually sent last_req = get_last_request() req_body = json.loads(last_req.body) # Ensure the input schema is not sent if omitted assert req_body == payload def test_validate_flow_error_parsing(flows_client): metadata = load_response( flows_client.validate_flow, case="definition_error" ).metadata # Make sure we get an error response with pytest.raises(FlowsAPIError) as excinfo: flows_client.validate_flow(definition=metadata["invalid"]) err = excinfo.value assert err.code == "UNPROCESSABLE_ENTITY" assert err.messages == [ ( "A state of type 'Action' must be defined as either terminal " '("End": true) or transitional ("Next": "NextStateId")' ), ] globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_get_run.py000066400000000000000000000017201513221403200302530ustar00rootroot00000000000000import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize("include_flow_description", (MISSING, False, True)) def test_get_run(flows_client, include_flow_description): metadata = load_response(flows_client.get_run).metadata response = flows_client.get_run( metadata["run_id"], include_flow_description=include_flow_description, ) assert response.http_status == 200 request = get_last_request() if include_flow_description is MISSING: assert "flow_description" not in response assert "include_flow_description" not in request.url elif include_flow_description is False: assert "flow_description" not in response assert "include_flow_description=False" in request.url else: # include_flow_description is True assert "flow_description" in response assert "include_flow_description=True" in request.url globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_get_run_logs.py000066400000000000000000000021661513221403200313040ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_get_run_logs(flows_client): metadata = load_response(flows_client.get_run_logs).metadata resp = flows_client.get_run_logs(metadata["run_id"]) assert resp.http_status == 200 def test_get_run_logs_paginated(flows_client): metadata = load_response(flows_client.get_run_logs, case="paginated").metadata paginator = flows_client.paginated.get_run_logs(metadata["run_id"]) responses = list(paginator.pages()) assert len(responses) == 2 assert len(responses[0]["entries"]) == 10 assert len(responses[1]["entries"]) == 2 def test_get_run_logs_manually_paginated(flows_client): metadata = load_response(flows_client.get_run_logs, case="paginated").metadata resp = flows_client.get_run_logs(metadata["run_id"]) assert resp.http_status == 200 assert resp["has_next_page"] assert "marker" in resp.data assert len(resp["entries"]) == 10 resp2 = flows_client.get_run_logs(metadata["run_id"], marker=resp["marker"]) assert resp2.http_status == 200 assert not resp2["has_next_page"] assert len(resp2["entries"]) == 2 globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_list_flows.py000066400000000000000000000120761513221403200310030ustar00rootroot00000000000000import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize("filter_fulltext", [MISSING, "foo"]) @pytest.mark.parametrize("filter_roles", [MISSING, "bar"]) @pytest.mark.parametrize("orderby", [MISSING, "created_at ASC"]) def test_list_flows_simple(flows_client, filter_fulltext, filter_roles, orderby): meta = load_response(flows_client.list_flows).metadata add_kwargs = {} if filter_fulltext: add_kwargs["filter_fulltext"] = filter_fulltext if filter_roles: add_kwargs["filter_roles"] = filter_roles if orderby: add_kwargs["orderby"] = orderby res = flows_client.list_flows(**add_kwargs) assert res.http_status == 200 # dict-like indexing assert meta["first_flow_id"] == res["flows"][0]["id"] # list conversion (using __iter__) and indexing assert meta["first_flow_id"] == list(res)[0]["id"] req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) expect_query_params = { k: [v] for k, v in ( ("filter_fulltext", filter_fulltext), ("filter_roles", filter_roles), ("orderby", orderby), ) if v is not MISSING } assert parsed_qs == expect_query_params @pytest.mark.parametrize("by_pages", [True, False]) def test_list_flows_paginated(flows_client, by_pages): meta = load_response(flows_client.list_flows, case="paginated").metadata total_items = meta["total_items"] num_pages = meta["num_pages"] expect_markers = meta["expect_markers"] res = flows_client.paginated.list_flows() if by_pages: pages = list(res) assert len(pages) == num_pages for i, page in enumerate(pages): assert page["marker"] == expect_markers[i] if i < num_pages - 1: assert page["has_next_page"] is True else: assert page["has_next_page"] is False else: items = list(res.items()) assert len(items) == total_items @pytest.mark.parametrize( "orderby_style, orderby_value", [ # single str -> single param ("str", "created_at ASC"), # sequence of strs (tuple, list) -> multi param ("seq", ["created_at ASC", "updated_at DESC"]), ("seq", ("created_at ASC", "updated_at DESC")), # more complex cases to handle within the test: generators and sets ("generator", ("created_at ASC", "updated_at DESC")), ("set", ("created_at ASC", "updated_at DESC")), ], ) def test_list_flows_orderby_multi(flows_client, orderby_style, orderby_value): meta = load_response(flows_client.list_flows).metadata if orderby_style == "str": orderby = orderby_value expected_orderby_value = [orderby_value] elif orderby_style == "seq": orderby = orderby_value expected_orderby_value = list(orderby_value) elif orderby_style == "generator": orderby = (x for x in orderby_value) expected_orderby_value = list(orderby_value) elif orderby_style == "set": orderby = set(orderby_value) expected_orderby_value = set(orderby_value) else: raise NotImplementedError res = flows_client.list_flows(orderby=orderby) assert res.http_status == 200 # check result correctness assert meta["first_flow_id"] == res["flows"][0]["id"] req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) # for the set case, handle arbitrary ordering by converting the value seen # back to a set if isinstance(expected_orderby_value, set): assert set(parsed_qs["orderby"]) == expected_orderby_value else: assert parsed_qs["orderby"] == expected_orderby_value @pytest.mark.parametrize( "filter_roles, expected_filter_roles", [ # empty list/tuple, list/tuple with empty string, and None do not send the param ((), None), ([], None), ([""], None), (("",), None), (MISSING, None), # single role as string ("foo", ["foo"]), # single role as list/tuple (["foo"], ["foo"]), (("foo",), ["foo"]), # multiple roles as comma-separated string ("foo,bar", ["foo,bar"]), # multiple roles as list/tuple (["foo", "bar"], ["foo,bar"]), (("foo", "bar"), ["foo,bar"]), ], ) def test_list_flows_with_filter_roles_parameter( flows_client, filter_roles, expected_filter_roles ): load_response(flows_client.list_flows).metadata res = flows_client.list_flows(filter_roles=filter_roles) assert res.http_status == 200 req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) # Only check filter_roles in parsed_qs if we expect a non-empty value if expected_filter_roles: assert parsed_qs["filter_roles"] == expected_filter_roles else: assert "filter_roles" not in parsed_qs globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_list_runs.py000066400000000000000000000071761513221403200306450ustar00rootroot00000000000000import urllib.parse import uuid import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response def test_list_runs_simple(flows_client): meta = load_response(flows_client.list_runs).metadata res = flows_client.list_runs() assert res.http_status == 200 # dict-like indexing assert meta["first_run_id"] == res["runs"][0]["run_id"] # list conversion (using __iter__) and indexing assert meta["first_run_id"] == list(res)[0]["run_id"] @pytest.mark.parametrize("by_pages", [True, False]) def test_list_runs_paginated(flows_client, by_pages): meta = load_response(flows_client.list_runs, case="paginated").metadata total_items = meta["total_items"] num_pages = meta["num_pages"] expect_markers = meta["expect_markers"] res = flows_client.paginated.list_runs() if by_pages: pages = list(res) assert len(pages) == num_pages for i, page in enumerate(pages): assert page["marker"] == expect_markers[i] if i < num_pages - 1: assert page["has_next_page"] is True else: assert page["has_next_page"] is False else: items = list(res.items()) assert len(items) == total_items @pytest.mark.parametrize("pass_as_uuids", [True, False]) def test_list_runs_filter_flow_id(flows_client, pass_as_uuids): meta = load_response(flows_client.list_runs, case="filter_flow_id").metadata # sanity check that the underlying test data hasn't changed too much assert len(meta["by_flow_id"]) == 2 flow_id_one, flow_id_two = tuple(meta["by_flow_id"].keys()) if pass_as_uuids: flow_id_one = uuid.UUID(flow_id_one) flow_id_two = uuid.UUID(flow_id_two) res_one = list(flows_client.list_runs(filter_flow_id=flow_id_one)) assert len(res_one) == meta["by_flow_id"][str(flow_id_one)]["num"] for run in res_one: assert run["flow_id"] == str(flow_id_one) res_two = list(flows_client.list_runs(filter_flow_id=flow_id_two)) assert len(res_two) == meta["by_flow_id"][str(flow_id_two)]["num"] for run in res_two: assert run["flow_id"] == str(flow_id_two) res_combined = list( flows_client.list_runs(filter_flow_id=[flow_id_one, flow_id_two]) ) assert len(res_combined) == ( meta["by_flow_id"][str(flow_id_one)]["num"] + meta["by_flow_id"][str(flow_id_two)]["num"] ) for run in res_combined: assert run["flow_id"] in {str(flow_id_one), str(flow_id_two)} @pytest.mark.parametrize( "filter_roles, expected_filter_roles", [ # empty list/tuple, list/tuple with empty string, and None do not send the param ([], None), ((), None), ([""], None), (("",), None), (MISSING, None), # single role as string ("foo", ["foo"]), # single role as list/tuple (["foo"], ["foo"]), (("foo",), ["foo"]), # multiple roles as comma-separated string ("foo,bar", ["foo,bar"]), # multiple roles as list/tuple (["foo", "bar"], ["foo,bar"]), (("foo", "bar"), ["foo,bar"]), ], ) def test_list_runs_filter_roles(flows_client, filter_roles, expected_filter_roles): load_response(flows_client.list_runs).metadata res = flows_client.list_runs(filter_roles=filter_roles) assert res.http_status == 200 req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) if expected_filter_roles: assert parsed_qs["filter_roles"] == expected_filter_roles else: assert "filter_roles" not in parsed_qs globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_resume_run.py000066400000000000000000000006441513221403200310000ustar00rootroot00000000000000import typing as t from globus_sdk import SpecificFlowClient from globus_sdk.testing import load_response def test_resume_run(specific_flow_client_class: t.Type[SpecificFlowClient]): metadata = load_response(SpecificFlowClient.resume_run).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) resp = flow_client.resume_run(metadata["run_id"]) assert resp.http_status == 200 globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_run_crud.py000066400000000000000000000060411513221403200304320ustar00rootroot00000000000000import json import pytest from globus_sdk import FlowsAPIError from globus_sdk.testing import get_last_request, load_response def test_get_run_definition(flows_client): """Validate the HTTP method and route used to get the flow definition for a run.""" run_id = load_response(flows_client.get_run_definition).metadata["run_id"] flows_client.get_run_definition(run_id) request = get_last_request() assert request.method == "GET" assert request.url.endswith(f"/runs/{run_id}/definition") def test_cancel_run(flows_client): """Verify that run cancellation requests meet expectations.""" run_id = load_response(flows_client.cancel_run).metadata["run_id"] flows_client.cancel_run(run_id) request = get_last_request() assert request.method == "POST" assert request.url.endswith(f"/runs/{run_id}/cancel") @pytest.mark.parametrize( "values", ( {}, {"label": "x"}, {"run_monitors": []}, {"run_monitors": ["me", "you"]}, {"run_managers": []}, {"run_managers": ["me", "you"]}, {"tags": []}, {"tags": ["x"]}, ), ) def test_update_run(flows_client, values): metadata = load_response(flows_client.update_run).metadata flows_client.update_run(metadata["run_id"], **values) request = get_last_request() assert request.method == "PUT" assert request.url.endswith(f"/runs/{metadata['run_id']}") assert json.loads(request.body) == values # Ensure deprecated routes are not used. assert f"/flows/{metadata['flow_id']}" not in request.url def test_update_run_additional_fields(flows_client): """*addition_fields* must override all other parameters.""" metadata = load_response(flows_client.update_run).metadata additional_fields = { "label": "x", "run_monitors": ["x"], "run_managers": ["x"], "tags": ["x"], } flows_client.update_run( metadata["run_id"], label="a", run_managers=["a"], run_monitors=["a"], tags=["a"], additional_fields=additional_fields, ) request = get_last_request() assert request.method == "PUT" assert request.url.endswith(f"/runs/{metadata['run_id']}") assert json.loads(request.body) == additional_fields def test_delete_run_success(flows_client): """Verify `.delete_run()` requests match expectations.""" metadata = load_response(flows_client.delete_run).metadata flows_client.delete_run(metadata["run_id"]) request = get_last_request() assert request.method == "POST" assert request.url.endswith(f"/runs/{metadata['run_id']}/release") # Ensure no deprecated routes are used. assert "/flows/" not in request.url def test_delete_run_conflict(flows_client): """Verify the `.delete_run()` HTTP 409 CONFLICT test case matches expectations.""" metadata = load_response(flows_client.delete_run, case="conflict").metadata with pytest.raises(FlowsAPIError) as error: flows_client.delete_run(metadata["run_id"]) assert error.value.http_status == 409 globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_run_flow.py000066400000000000000000000052641513221403200304520ustar00rootroot00000000000000from __future__ import annotations import json import pytest import globus_sdk from globus_sdk import FlowsAPIError, SpecificFlowClient from globus_sdk.testing import get_last_request, load_response def test_run_flow(specific_flow_client_class: type[SpecificFlowClient]): metadata = load_response(SpecificFlowClient.run_flow).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) resp = flow_client.run_flow(**metadata["request_params"]) assert resp.http_status == 200 def test_run_flow_missing_scope(specific_flow_client_class: type[SpecificFlowClient]): metadata = load_response( SpecificFlowClient.run_flow, case="missing_scope_error" ).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) with pytest.raises(FlowsAPIError) as excinfo: flow_client.run_flow(**metadata["request_params"]) err = excinfo.value assert err.http_status == 403 assert err.code == "MISSING_SCOPE" assert ( err.message == "This action requires the following scope: frobulate[demuddle]" ) def test_run_flow_without_activity_notification_policy( specific_flow_client_class, ): metadata = load_response(SpecificFlowClient.run_flow).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) resp = flow_client.run_flow({}) assert resp.http_status == 200 last_req = get_last_request() sent_payload = json.loads(last_req.body) assert "activity_notification_policy" not in sent_payload def test_run_flow_with_empty_activity_notification_policy( specific_flow_client_class, ): metadata = load_response(SpecificFlowClient.run_flow).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) policy = globus_sdk.RunActivityNotificationPolicy() flow_client.run_flow({}, activity_notification_policy=policy) last_req = get_last_request() sent_payload = json.loads(last_req.body) assert "activity_notification_policy" in sent_payload assert sent_payload["activity_notification_policy"] == {} def test_run_flow_with_activity_notification_policy( specific_flow_client_class, ): metadata = load_response(SpecificFlowClient.run_flow).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) policy = globus_sdk.RunActivityNotificationPolicy(status=["FAILED", "INACTIVE"]) flow_client.run_flow({}, activity_notification_policy=policy) last_req = get_last_request() sent_payload = json.loads(last_req.body) assert "activity_notification_policy" in sent_payload assert sent_payload["activity_notification_policy"] == { "status": ["FAILED", "INACTIVE"] } globus-globus-sdk-python-6a080e4/tests/functional/services/flows/test_validate_run.py000066400000000000000000000026071513221403200312720ustar00rootroot00000000000000from __future__ import annotations import pytest from globus_sdk import FlowsAPIError, SpecificFlowClient from globus_sdk.testing import load_response def test_validate_run(specific_flow_client_class: type[SpecificFlowClient]): metadata = load_response(SpecificFlowClient.validate_run).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) resp = flow_client.validate_run(body=metadata["request_body"]) assert resp.http_status == 200 def test_validate_run_returns_error_for_invalid_payload( specific_flow_client_class: type[SpecificFlowClient], ): metadata = load_response( SpecificFlowClient.validate_run, case="invalid_input_payload" ).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) with pytest.raises(FlowsAPIError) as error: flow_client.validate_run(body=metadata["request_body"]) assert error.value.http_status == 400 def test_validate_run_returns_error_for_lacking_run_permission( specific_flow_client_class: type[SpecificFlowClient], ): metadata = load_response( SpecificFlowClient.validate_run, case="not_a_flow_starter" ).metadata flow_client = specific_flow_client_class(flow_id=metadata["flow_id"]) with pytest.raises(FlowsAPIError) as error: flow_client.validate_run(body=metadata["request_body"]) assert error.value.http_status == 403 globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/000077500000000000000000000000001513221403200246215ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/conftest.py000066400000000000000000000003571513221403200270250ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def client(): # default fqdn for GCS client testing client = globus_sdk.GCSClient("abc.xyz.data.globus.org") with client.retry_config.tune(max_retries=0): yield client globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/000077500000000000000000000000001513221403200273005ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/collection_list.json000066400000000000000000000000001513221403200333470ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/create_collection.json000066400000000000000000000034441513221403200336560ustar00rootroot00000000000000{ "code": "success", "data": [ { "id": "18d367d5-45cf-4724-a53e-5a685e45c942", "manager_url": "https://gcs.data.globus.org/", "domain_name": "i-f3c83.123.globus.org", "high_assurance": true, "https_url": "https://i-f3c83.123.globus.org", "tlsftp_url": "tlsftp://i-f3c83.123.globus.org", "collection_type": "mapped", "display_name": "Project Foo Research Data", "connector_id": "c8b7ab5c-595c-43c9-8e43-9e8a3debfe4c", "identity_id": "c8b7ab5c-595c-43c9-8e43-9e8a3debfe4c", "storage_gateway_id": "fc1f3ba0-1fa4-42b2-8bb3-53983774fa5f", "root_path": "/", "collection_base_path": "/", "default_directory": "/projects", "public": true, "force_encryption": false, "disable_verify": false, "organization": "University of Example", "department": "Data Science", "keywords": [ "Project Foo", "Data Intensive Science" ], "description": "Information related to the \"Foo\" project.", "contact_email": "project-foo@example.edu", "contact_info": "+1 (555) 555-1234", "info_link": "https://project-foo.example.edu/info", "authentication_timeout_mins": 30, "policies": { "DATA_TYPE": "blackpearl_collection_policies#1.0.0" }, "DATA_TYPE": "collection#1.0.0", "allow_guest_collections": true, "sharing_restrict_paths": { "DATA_TYPE": "path_restrictions#1.0.0", "read": [ "/public" ], "read_write": [ "/home", "/projects" ], "none": [ "/private" ] } } ], "DATA_TYPE": "result#1.0.0", "http_response_code": 200, "detail": null, "message": "Operation successful", "has_next_page": false, "marker": "string" } globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/empty_success.json000066400000000000000000000001711513221403200330600ustar00rootroot00000000000000{ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [], "detail": "success", "http_response_code": 200 } globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/get_collection/000077500000000000000000000000001513221403200322725ustar00rootroot00000000000000bad_version.json000066400000000000000000000003761513221403200354070ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/get_collection{ "DATA_TYPE": "result#1.0.0", "code": "success", "detail": "success", "http_response_code": 200, "data": [ { "DATA_TYPE": "collection99.145.10", "foo": 1 }, { "DATA_TYPE": "collection", "foo": 2 } ] } includes_other.json000066400000000000000000000011601513221403200361130ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/get_collection{ "DATA_TYPE": "result#1.0.0", "code": "success", "detail": "success", "http_response_code": 200, "data": [ { "DATA_TYPE": "woof#1.0.0", "animal": "dog" }, { "DATA_TYPE": "collection#1.0.0", "public": true, "id": "{collection_id}", "display_name": "Happy Fun Collection Name", "identity_id": "e926d510-cb98-11e5-a6ac-0b0216052512", "collection_type": "mapped", "storage_gateway_id": "{storage_gateway_id}", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b" }, { "DATA_TYPE": "meow#1.0.0", "animal": "cat" } ] } invalid_datatype_type.json000066400000000000000000000007041513221403200374710ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/get_collection{ "DATA_TYPE": "result#1.0.0", "code": "success", "detail": "success", "http_response_code": 200, "data": [ {}, { "DATA_TYPE": 0, "public": true, "id": "{collection_id}", "display_name": "Happy Fun Collection Name" }, { "DATA_TYPE": [ "foo#1.0", "bar#2.0" ], "public": true, "id": "{collection_id}", "display_name": "Happy Fun Collection Name" } ] } normal.json000066400000000000000000000007501513221403200344000ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/get_collection{ "DATA_TYPE": "result#1.0.0", "code": "success", "detail": "success", "http_response_code": 200, "data": [ { "DATA_TYPE": "collection#1.0.0", "public": true, "id": "{collection_id}", "display_name": "Happy Fun Collection Name", "identity_id": "e926d510-cb98-11e5-a6ac-0b0216052512", "collection_type": "mapped", "storage_gateway_id": "{storage_gateway_id}", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b" } ] } unexpectedly_flat.json000066400000000000000000000005041513221403200366240ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/get_collection{ "DATA_TYPE": "collection#1.0.0", "public": true, "id": "{collection_id}", "display_name": "Happy Fun Collection Name", "identity_id": "e926d510-cb98-11e5-a6ac-0b0216052512", "collection_type": "mapped", "storage_gateway_id": "{storage_gateway_id}", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b" } globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/role_document.json000066400000000000000000000005751513221403200330410ustar00rootroot00000000000000{ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "role#1.0.0", "id": "{role_id_1}", "collection": null, "principal": "urn:globus:auth:identity:{user_id_1}", "role": "owner" } ], "http_response_code": 200, "detail": "success", "has_next_page": false } globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/role_list.json000066400000000000000000000011671513221403200321740ustar00rootroot00000000000000{ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "role#1.0.0", "id": "{role_id_1}", "collection": null, "principal": "urn:globus:auth:identity:{user_id_1}", "role": "owner" }, { "DATA_TYPE": "role#1.0.0", "id": "{role_id_2}", "collection": "{collection_id_1}", "principal": "urn:globus:groups:id:{group_id_1}", "role": "administrator" } ], "http_response_code": 200, "detail": {}, "has_next_page": false, "marker": "" } globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data/update_collection.json000066400000000000000000000031751513221403200336760ustar00rootroot00000000000000{ "code": "success", "data": [ { "id": "18d367d5-45cf-4724-a53e-5a685e45c942", "manager_url": "https://gcs.data.globus.org/", "domain_name": "i-f3c83.123.globus.org", "high_assurance": true, "https_url": "https://i-f3c83.123.globus.org", "tlsftp_url": "tlsftp://i-f3c83.123.globus.org", "collection_type": "guest", "display_name": "Project Foo Research Data", "connector_id": "c8b7ab5c-595c-43c9-8e43-9e8a3debfe4c", "identity_id": "c8b7ab5c-595c-43c9-8e43-9e8a3debfe4c", "storage_gateway_id": "fc1f3ba0-1fa4-42b2-8bb3-53983774fa5f", "root_path": "/", "collection_base_path": "/", "default_directory": "/projects", "public": true, "force_encryption": false, "disable_verify": false, "organization": "University of Example", "department": "Data Science", "keywords": [ "Project Foo", "Data Intensive Science" ], "description": "Information related to the \"Foo\" project.", "contact_email": "project-foo@example.edu", "contact_info": "+1 (555) 555-1234", "info_link": "https://project-foo.example.edu/info", "authentication_timeout_mins": 30, "policies": { "DATA_TYPE": "blackpearl_collection_policies#1.0.0" }, "DATA_TYPE": "collection#1.0.0", "user_credential_id": "1ce95432-73c7-4060-8fb2-5d61d627f164", "mapped_collection_id": "14326300-5a33-4387-9bb0-7f85c3dc3185" } ], "DATA_TYPE": "result#1.0.0", "http_response_code": 200, "detail": null, "message": "Operation successful", "has_next_page": false, "marker": "string" } user_credential_list.json000066400000000000000000000012151513221403200343160ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/fixture_data{ "DATA_TYPE": "result#1.0.0", "code": "success", "data": [ { "DATA_TYPE": "user_credential#1.0.0", "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", "display_name": "aaschaer", "id": "58f2d95a-11ce-512e-b90a-a523ed6c37d4", "identity_id": "6e661986-4d49-4b88-982f-6873f842ca6e", "invalid": false, "policies": { "DATA_TYPE": "posix_user_credential_policies#1.0.0" }, "provisioned": false, "storage_gateway_id": "0af12dc0-eb8c-43d9-bf90-50bc5b69879b", "username": "aaschaer" } ], "detail": "success", "has_next_page": false, "http_response_code": 200 } globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_collections.py000066400000000000000000000115061513221403200305530ustar00rootroot00000000000000from globus_sdk import GuestCollectionDocument, MappedCollectionDocument from tests.common import register_api_route_fixture_file def test_get_collection(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "get_collection/normal.json" ) res = client.get_collection("COLLECTION_ID") assert res["DATA_TYPE"] == "collection#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert "detail" in res.full_data assert "data" in res.full_data assert res.full_data["detail"] == "success" assert "detail" not in res.data assert res["display_name"] == "Happy Fun Collection Name" def test_get_collection_flat(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "get_collection/unexpectedly_flat.json" ) res = client.get_collection("COLLECTION_ID") assert res["DATA_TYPE"] == "collection#1.0.0" assert res.full_data["DATA_TYPE"] == "collection#1.0.0" assert "detail" not in res.full_data assert "data" not in res.full_data assert res["display_name"] == "Happy Fun Collection Name" def test_get_collection_bad_version(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "get_collection/bad_version.json" ) res = client.get_collection("COLLECTION_ID") assert res["DATA_TYPE"] == "result#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert "detail" in res.full_data assert "data" in res.full_data assert res.full_data["detail"] == "success" assert "detail" in res.data assert "foo" not in res.data for x in res.full_data["data"]: assert "foo" in x def test_get_collection_includes_sideloaded_data(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "get_collection/includes_other.json" ) res = client.get_collection("COLLECTION_ID") assert res["DATA_TYPE"] == "collection#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert "detail" in res.full_data assert "data" in res.full_data assert res.full_data["detail"] == "success" assert "detail" not in res.data assert res["display_name"] == "Happy Fun Collection Name" def test_get_collection_invalid_datatype_type(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "get_collection/invalid_datatype_type.json" ) res = client.get_collection("COLLECTION_ID") assert res["DATA_TYPE"] == "result#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert "detail" in res.full_data assert "detail" in res.data assert "data" in res.full_data assert res.full_data["detail"] == "success" def test_delete_collection(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "empty_success.json", method="DELETE" ) res = client.delete_collection("COLLECTION_ID") assert res["DATA_TYPE"] == "result#1.0.0" assert "detail" in res.data assert res.data["detail"] == "success" def test_create_mapped_collection(client): register_api_route_fixture_file( "gcs", "/collections", "create_collection.json", method="POST" ) collection = MappedCollectionDocument( domain_name="i-f3c83.123.globus.org", display_name="Project Foo Research Data", identity_id="c8b7ab5c-595c-43c9-8e43-9e8a3debfe4c", storage_gateway_id="fc1f3ba0-1fa4-42b2-8bb3-53983774fa5f", collection_base_path="/", default_directory="/projects", public=True, force_encryption=False, disable_verify=False, organization="University of Example", department="Data Science", keywords=["Project Foo", "Data Intensive Science"], description='Information related to the "Foo" project.', contact_email="project-foo@example.edu", contact_info="+1 (555) 555-1234", info_link="https://project-foo.example.edu/info", policies={"DATA_TYPE": "blackpearl_collection_policies#1.0.0"}, allow_guest_collections=True, sharing_restrict_paths={ "DATA_TYPE": "path_restrictions#1.0.0", "read": ["/public"], "read_write": ["/home", "/projects"], "none": ["/private"], }, ) res = client.create_collection(collection) assert res["DATA_TYPE"] == "collection#1.0.0" assert res["display_name"] == "Project Foo Research Data" def test_update_guest_collection(client): register_api_route_fixture_file( "gcs", "/collections/COLLECTION_ID", "update_collection.json", method="PATCH" ) collection = GuestCollectionDocument(display_name="Project Foo Research Data") res = client.update_collection("COLLECTION_ID", collection) assert res["DATA_TYPE"] == "collection#1.0.0" assert res["display_name"] == "Project Foo Research Data" globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_endpoints.py000066400000000000000000000035041513221403200302370ustar00rootroot00000000000000import globus_sdk from globus_sdk.testing import load_response def test_get_endpoint(client): meta = load_response(client.get_endpoint).metadata get_response = client.get_endpoint() assert get_response["DATA_TYPE"] == "endpoint#1.2.0" assert get_response["display_name"] == meta["display_name"] assert get_response["id"] == meta["endpoint_id"] def test_update_endpoint(client): meta = load_response(client.update_endpoint).metadata update_doc = globus_sdk.EndpointDocument(display_name=meta["display_name"]) update_response = client.update_endpoint(update_doc) assert update_response["DATA_TYPE"] == "result#1.0.0" assert update_response["message"] == f"Updated endpoint {meta['endpoint_id']}" def test_update_endpoint_including_endpoint_in_response(client): meta = load_response(client.update_endpoint, case="include_endpoint").metadata update_doc = globus_sdk.EndpointDocument(display_name=meta["display_name"]) update_response = client.update_endpoint(update_doc, include=["endpoint"]) assert update_response["DATA_TYPE"] == "endpoint#1.2.0" assert update_response["display_name"] == meta["display_name"] assert update_response["id"] == meta["endpoint_id"] def test_endpoint_document_infers_data_type(): doc = globus_sdk.EndpointDocument(display_name="My Endpoint") assert doc["DATA_TYPE"] == "endpoint#1.0.0" def test_endpoint_document_infers_data_type_when_control_port_specified(): doc = globus_sdk.EndpointDocument( display_name="My Endpoint", gridftp_control_channel_port=2811 ) assert doc["DATA_TYPE"] == "endpoint#1.1.0" def test_endpoint_document_respects_explicit_data_type(): doc = globus_sdk.EndpointDocument( data_type="endpoint#0.15.x", display_name="My Endpoint" ) assert doc["DATA_TYPE"] == "endpoint#0.15.x" globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_get_collection_list.py000066400000000000000000000050071513221403200322610ustar00rootroot00000000000000import pytest from globus_sdk import MISSING, GCSAPIError from globus_sdk.testing import get_last_request, load_response def test_get_collection_list(client): meta = load_response(client.get_collection_list).metadata res = client.get_collection_list() # check the results against response metadata to ensure integrity and that # response parsing works assert len(list(res)) == len(meta["collection_ids"]) assert res["DATA_TYPE"] == "result#1.0.0" for index, item in enumerate(res): assert item["DATA_TYPE"] == "collection#1.0.0" assert item["id"] == meta["collection_ids"][index] assert item["storage_gateway_id"] == meta["gateway_ids"][index] assert item["display_name"] == meta["display_names"][index] @pytest.mark.parametrize( "include_param, expected", ( (MISSING, None), ("foo", "foo"), ("foo,bar", "foo,bar"), (("foo", "bar"), "foo,bar"), ), ) def test_get_collection_list_include_param(client, include_param, expected): load_response(client.get_collection_list) client.get_collection_list(include=include_param) req = get_last_request() if include_param is not MISSING: assert "include" in req.params assert req.params["include"] == expected else: assert "include" not in req.params def test_get_collection_list_mapped_collection_id_param(client): load_response(client.get_collection_list) client.get_collection_list(mapped_collection_id="MAPPED_COLLECTION") assert get_last_request().params.get("mapped_collection_id") == "MAPPED_COLLECTION" @pytest.mark.parametrize( "filter_param, expected", ( (["mapped_collections", "created_by_me"], "mapped_collections,created_by_me"), ("created_by_me", "created_by_me"), ), ) def test_get_collection_list_filter_param(client, filter_param, expected): load_response(client.get_collection_list) client.get_collection_list(filter=filter_param) assert get_last_request().params.get("filter") == expected def test_error_parsing_forbidden(client): """ This test is more focused on error parsing than it is on the actual collection list call. """ load_response(client.get_collection_list, case="forbidden") with pytest.raises(GCSAPIError) as excinfo: client.get_collection_list() err = excinfo.value assert err.detail is None assert err.detail_data_type is None assert err.message.startswith("Could not list collections") assert err.code == "permission_denied" globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_get_gcs_info.py000066400000000000000000000011741513221403200306630ustar00rootroot00000000000000from globus_sdk.authorizers import AccessTokenAuthorizer from globus_sdk.testing import get_last_request, load_response def test_get_gcs_info(client): meta = load_response(client.get_gcs_info).metadata endpoint_client_id = meta["endpoint_client_id"] # set an authorizer client.authorizer = AccessTokenAuthorizer("access_token") res = client.get_gcs_info() assert res["endpoint_id"] == endpoint_client_id assert res["client_id"] == endpoint_client_id # confirm request was unauthenticated despite client having an authorizer req = get_last_request() assert "Authorization" not in req.headers globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_roles.py000066400000000000000000000063421513221403200273630ustar00rootroot00000000000000import json from globus_sdk import GCSRoleDocument from globus_sdk.testing import get_last_request from tests.common import register_api_route_fixture_file def test_get_role_list(client): register_api_route_fixture_file("gcs", "/roles", "role_list.json") res = client.get_role_list() assert len(list(res)) == 2 # sanity check some fields assert res["DATA_TYPE"] == "result#1.0.0" for item in res: assert item["DATA_TYPE"] == "role#1.0.0" assert "id" in item assert item["id"] in ("{role_id_1}", "{role_id_2}") assert "principal" in item assert "role" in item def test_get_role_list_params(client): """ confirms include, collection_id, and arbitrary query_params arguments to get_role_list are assembled correctly """ register_api_route_fixture_file("gcs", "/roles", "role_list.json") # no args res = client.get_role_list() assert res["code"] == "success" params = get_last_request().params assert params == {} # collection_id res = client.get_role_list(collection_id="{collection_id_1}") assert res["code"] == "success" params = get_last_request().params assert params == {"collection_id": "{collection_id_1}"} # include res = client.get_role_list(include="all_roles") assert res["code"] == "success" params = get_last_request().params assert params == {"include": "all_roles"} # query_params res = client.get_role_list(query_params={"foo": "bar"}) assert res["code"] == "success" params = get_last_request().params assert params == {"foo": "bar"} # everything together res = client.get_role_list( collection_id="{collection_id_1}", include="all_roles", query_params={"foo": "bar"}, ) assert res["code"] == "success" params = get_last_request().params assert params == { "collection_id": "{collection_id_1}", "include": "all_roles", "foo": "bar", } def test_create_role(client): register_api_route_fixture_file( "gcs", "/roles", "role_document.json", method="POST" ) data = GCSRoleDocument( collection="{collection_id_1}", principal="urn:globus:auth:identity:{user_id_1}", role="owner", ) res = client.create_role(data) assert res["id"] == "{role_id_1}" json_body = json.loads(get_last_request().body) assert json_body["collection"] in (None, "{collection_id_1}") assert json_body["principal"] == "urn:globus:auth:identity:{user_id_1}" assert json_body["role"] in ("owner", "administrator") def test_get_role(client): register_api_route_fixture_file("gcs", "/roles/ROLE_ID", "role_document.json") res = client.get_role("ROLE_ID") assert res["DATA_TYPE"] == "role#1.0.0" assert res["id"] == "{role_id_1}" assert res["collection"] is None assert res["principal"] == "urn:globus:auth:identity:{user_id_1}" assert res["role"] == "owner" def test_delete_role(client): register_api_route_fixture_file( "gcs", "/roles/ROLE_ID", "empty_success.json", method="DELETE" ) res = client.delete_role("ROLE_ID") assert res["DATA_TYPE"] == "result#1.0.0" assert "detail" in res.data assert res.data["detail"] == "success" globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_scope_helpers.py000066400000000000000000000020271513221403200310660ustar00rootroot00000000000000import uuid zero_id = uuid.UUID(int=0) zero_id_s = str(zero_id) def test_manage_collections_scope_helper(client): sc = client.get_gcs_endpoint_scopes(zero_id) assert ( str(sc.manage_collections) == f"urn:globus:auth:scope:{zero_id_s}:manage_collections" ) # data_access is separated from endpoint scopes assert not hasattr(sc, "data_access") def test_data_access_scope_helper(client): sc = client.get_gcs_collection_scopes(zero_id) assert ( str(sc.data_access) == f"https://auth.globus.org/scopes/{zero_id_s}/data_access" ) assert str(sc.https) == f"https://auth.globus.org/scopes/{zero_id_s}/https" # manage_collections is separated from collection scopes assert not hasattr(sc, "manage_collections") def test_contains_scope_properties(client): ep_sc = client.get_gcs_endpoint_scopes(zero_id) assert ep_sc.manage_collections in list(ep_sc) collection_sc = client.get_gcs_collection_scopes(zero_id) assert collection_sc.data_access in list(collection_sc) globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_storage_gateways.py000066400000000000000000000101531513221403200316020ustar00rootroot00000000000000import urllib.parse import pytest import globus_sdk from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( "include_param", [MISSING, "private_policies", "private_policies,foo", ("private_policies", "foo")], ) def test_get_storage_gateway_list(client, include_param): meta = load_response(client.get_storage_gateway_list).metadata expect_ids = meta["ids"] res = client.get_storage_gateway_list(include=include_param) assert res.http_status == 200 # confirm iterable and sanity check some fields assert len(list(res)) > 0 for sg in res: assert sg["DATA_TYPE"] == "storage_gateway#1.0.0" assert "id" in sg assert "display_name" in sg assert [sg["id"] for sg in res] == expect_ids req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) if include_param is MISSING: assert parsed_qs == {} elif isinstance(include_param, str): assert parsed_qs == {"include": [include_param]} else: assert parsed_qs == {"include": [",".join(include_param)]} def test_create_storage_gateway(client): meta = load_response(client.create_storage_gateway).metadata # the SDK does not validate the create document, so an empty document is fine, if # unrealistic, with a mocked response res = client.create_storage_gateway({}) assert res.http_status == 200 # confirm top level access to storage gateway data assert res["id"] == meta["id"] assert res["display_name"] == meta["display_name"] def test_create_storage_gateway_validation_error(client): meta = load_response( client.create_storage_gateway, case="validation_error" ).metadata with pytest.raises(globus_sdk.GCSAPIError) as excinfo: client.create_storage_gateway({}) error = excinfo.value assert error.http_status == meta["http_status"] assert error.code == meta["code"] assert error.message == meta["message"] @pytest.mark.parametrize( "include_param", [MISSING, "private_policies", "private_policies,foo", ("private_policies", "foo")], ) def test_get_storage_gateway(client, include_param): meta = load_response(client.get_storage_gateway).metadata res = client.get_storage_gateway(meta["id"], include=include_param) assert res.http_status == 200 # confirm top level access to storage gateway data assert res["id"] == meta["id"] assert res["display_name"] == meta["display_name"] req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) if include_param is MISSING: assert parsed_qs == {} elif isinstance(include_param, str): assert parsed_qs == {"include": [include_param]} else: assert parsed_qs == {"include": [",".join(include_param)]} def test_update_storage_gateway(client): meta = load_response(client.update_storage_gateway).metadata # as in the create test, an empty update document is not very realistic # but because there's no request validation, this is fine res = client.update_storage_gateway(meta["id"], {}) assert res.http_status == 200 # confirm top level access to response data assert res["code"] == "success" assert res["message"] == "Operation successful" def test_delete_storage_gateway(client): meta = load_response(client.delete_storage_gateway).metadata res = client.delete_storage_gateway(meta["id"]) assert res.http_status == 200 # confirm top level access to response data assert res["code"] == "success" assert res["message"] == "Operation successful" def test_delete_storage_gateway_permission_denied(client): meta = load_response( client.delete_storage_gateway, case="permission_denied_error" ).metadata with pytest.raises(globus_sdk.GCSAPIError) as excinfo: client.delete_storage_gateway(meta["id"]) error = excinfo.value assert error.http_status == meta["http_status"] assert error.code == meta["code"] assert error.message == meta["message"] globus-globus-sdk-python-6a080e4/tests/functional/services/gcs/test_user_credential.py000066400000000000000000000057361513221403200314150ustar00rootroot00000000000000import json from globus_sdk import ConnectorTable, UserCredentialDocument from globus_sdk.testing import get_last_request, load_response def test_get_user_credential_list(client): metadata = load_response(client.get_user_credential_list).metadata res = client.get_user_credential_list() assert len(list(res)) == 2 # sanity check some fields assert res["DATA_TYPE"] == "result#1.0.0" for item in res: assert item["DATA_TYPE"] == "user_credential#1.0.0" assert item["id"] in metadata["ids"] assert "identity_id" in item assert "username" in item def test_get_user_credential(client): metadata = load_response(client.get_user_credential).metadata uc_id = metadata["id"] res = client.get_user_credential(uc_id) assert res["DATA_TYPE"] == "user_credential#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert res["id"] == uc_id assert res["display_name"] == "posix_credential" connector = ConnectorTable.lookup(res["connector_id"]) assert connector is not None assert connector.name == "POSIX" def test_create_user_credential(client): metadata = load_response(client.create_user_credential).metadata uc_id = metadata["id"] data = UserCredentialDocument( storage_gateway_id="82247cc9-3208-4d71-bd7f-1b8798c95e6b", identity_id="948847d4-ffcc-4ae0-ba3a-a4c88d480159", username="testuser", display_name="s3_credential", policies={ "DATA_TYPE": "s3_user_credential_policies#1.0.0", "s3_key_id": "foo", "s3_secret_key": "bar", }, ) res = client.create_user_credential(data) assert res["DATA_TYPE"] == "user_credential#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert res.full_data["message"] == f"Created User Credential {uc_id}" req_body = req_body = json.loads(get_last_request().body) assert req_body["DATA_TYPE"] == "user_credential#1.0.0" assert req_body["policies"]["DATA_TYPE"] == "s3_user_credential_policies#1.0.0" for key, value in req_body.items(): assert data[key] == value def test_update_user_credential(client): metadata = load_response(client.update_user_credential).metadata uc_id = metadata["id"] data = UserCredentialDocument( display_name="updated_posix_credential", ) res = client.update_user_credential(uc_id, data) assert res["DATA_TYPE"] == "user_credential#1.0.0" assert res.full_data["DATA_TYPE"] == "result#1.0.0" assert res.full_data["message"] == f"Updated User Credential {uc_id}" req_body = json.loads(get_last_request().body) assert req_body["display_name"] == "updated_posix_credential" def test_delete_user_credential(client): metadata = load_response(client.delete_user_credential).metadata uc_id = metadata["id"] res = client.delete_user_credential(uc_id) assert res["DATA_TYPE"] == "result#1.0.0" assert res["message"] == f"Deleted User Credential {uc_id}" globus-globus-sdk-python-6a080e4/tests/functional/services/groups/000077500000000000000000000000001513221403200253645ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groups/conftest.py000066400000000000000000000004361513221403200275660ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def groups_client(): client = globus_sdk.GroupsClient() with client.retry_config.tune(max_retries=0): yield client @pytest.fixture def groups_manager(groups_client): return globus_sdk.GroupsManager(groups_client) globus-globus-sdk-python-6a080e4/tests/functional/services/groups/fixture_data/000077500000000000000000000000001513221403200300435ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groups/fixture_data/add_member.json000066400000000000000000000003601513221403200330140ustar00rootroot00000000000000{ "add": [ { "group_id": "d3974728-6458-11e4-b72d-123139141556", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "admin", "status": "active" } ] } globus-globus-sdk-python-6a080e4/tests/functional/services/groups/fixture_data/approve_pending.json000066400000000000000000000003651513221403200341220ustar00rootroot00000000000000{ "approve": [ { "group_id": "d3974728-6458-11e4-b72d-123139141556", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "member", "status": "active" } ] } get_group_policies.json000066400000000000000000000003121513221403200345350ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groups/fixture_data{ "is_high_assurance": false, "authentication_assurance_timeout": 28800, "group_visibility": "private", "group_members_visibility": "managers", "join_requests": false, "signup_fields": [] } globus-globus-sdk-python-6a080e4/tests/functional/services/groups/fixture_data/updated_group.json000066400000000000000000000011441513221403200336000ustar00rootroot00000000000000{ "name": "Claptrap's Rough Riders", "description": "Stairs strongly discouraged.", "parent_id": null, "id": "d3974728-6458-11e4-b72d-123139141556", "group_type": "regular", "enforce_session": false, "session_limit": 28800, "session_timeouts": {}, "my_memberships": [ { "group_id": "d3974728-6458-11e4-b72d-123139141556", "identity_id": "ae332d86-d274-11e5-b885-b31714a110e9", "username": "sirosen@globusid.org", "role": "admin", "status": "active" } ], "policies": { "group_visibility": "private", "group_members_visibility": "managers" } } globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/000077500000000000000000000000001513221403200267765ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_accept_invite.py000066400000000000000000000001331513221403200332210ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_accept_invite(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_decline_invite.py000066400000000000000000000001341513221403200333660ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_decline_invite(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_invite_member.py000066400000000000000000000001331513221403200332310ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_invite_member(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_join.py000066400000000000000000000001221513221403200313410ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_join(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_leave.py000066400000000000000000000001231513221403200314770ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_leave(): raise NotImplementedError test_reject_join_request.py000066400000000000000000000001411513221403200343670ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groups/managerimport pytest @pytest.mark.xfail def test_reject_join_request(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_remove_member.py000066400000000000000000000001331513221403200332300ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_remove_member(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/manager/test_request_join.py000066400000000000000000000001321513221403200331120ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_request_join(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_create_group.py000066400000000000000000000021571513221403200314610ustar00rootroot00000000000000import json from globus_sdk.testing import get_last_request, load_response def test_create_group(groups_client): meta = load_response(groups_client.create_group).metadata res = groups_client.create_group( {"name": "Claptrap's Rough Riders", "description": "No stairs allowed."} ) assert res.http_status == 200 assert "Claptrap" in res["name"] assert "No stairs allowed." in res["description"] assert res["id"] == meta["group_id"] req = get_last_request() req_body = json.loads(req.body) assert req_body["description"] == "No stairs allowed." def test_create_group_via_manager(groups_manager, groups_client): meta = load_response(groups_client.create_group).metadata res = groups_manager.create_group( name="Claptrap's Rough Riders", description="No stairs allowed." ) assert res.http_status == 200 assert "Claptrap" in res["name"] assert "No stairs allowed." in res["description"] assert res["id"] == meta["group_id"] req = get_last_request() req_body = json.loads(req.body) assert req_body["description"] == "No stairs allowed." globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_delete_group.py000066400000000000000000000004331513221403200314530ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_delete_group(groups_client): meta = load_response(groups_client.delete_group).metadata res = groups_client.delete_group(group_id=meta["group_id"]) assert res.http_status == 200 assert "Claptrap" in res["name"] globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_get_group.py000066400000000000000000000020671513221403200307750ustar00rootroot00000000000000import urllib.parse import pytest from globus_sdk.testing import get_last_request, load_response def test_get_group(groups_client): meta = load_response(groups_client.get_group).metadata res = groups_client.get_group(group_id=meta["group_id"]) assert res.http_status == 200 assert "Claptrap" in res["name"] @pytest.mark.parametrize( "include_param", ["policies", "policies,memberships", ["memberships", "policies", "child_ids"]], ) def test_get_group_include(groups_client, include_param): meta = load_response(groups_client.get_group).metadata expect_param = ( ",".join(include_param) if not isinstance(include_param, str) else include_param ) res = groups_client.get_group(group_id=meta["group_id"], include=include_param) assert res.http_status == 200 assert "Claptrap" in res["name"] req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert len(parsed_qs["include"]) == 1 assert parsed_qs["include"][0] == expect_param test_get_group_by_subscription_id.py000066400000000000000000000017521513221403200346700ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groupsfrom globus_sdk.testing import load_response def test_get_group_by_subscription_id(groups_client): meta = load_response(groups_client.get_group_by_subscription_id).metadata res = groups_client.get_group_by_subscription_id(meta["subscription_id"]) assert res.http_status == 200 assert res["group_id"] == meta["group_id"] assert "description" not in res assert "name" not in res def test_two_step_get_group_by_subscription_id(groups_client): meta = load_response(groups_client.get_group_by_subscription_id).metadata load_response(groups_client.get_group, case="subscription").metadata res = groups_client.get_group_by_subscription_id(meta["subscription_id"]) assert res.http_status == 200 assert res["group_id"] == meta["group_id"] assert "description" not in res assert "name" not in res res2 = groups_client.get_group(meta["group_id"]) assert res2.http_status == 200 assert res2["id"] == meta["group_id"] assert "name" in res2 globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_get_group_policies.py000066400000000000000000000012151513221403200326560ustar00rootroot00000000000000from tests.common import register_api_route_fixture_file def test_get_group_policies(groups_client): register_api_route_fixture_file( "groups", "/v2/groups/d3974728-6458-11e4-b72d-123139141556/policies", "get_group_policies.json", ) resp = groups_client.get_group_policies("d3974728-6458-11e4-b72d-123139141556") assert resp.http_status == 200 assert resp.data == { "is_high_assurance": False, "authentication_assurance_timeout": 28800, "group_visibility": "private", "group_members_visibility": "managers", "join_requests": False, "signup_fields": [], } globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_get_identity_preferences.py000066400000000000000000000001461513221403200340470ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_identity_preferences(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_get_membership_fields.py000066400000000000000000000001431513221403200333130ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_membership_fields(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_get_my_groups.py000066400000000000000000000011601513221403200316560ustar00rootroot00000000000000import urllib.parse from globus_sdk.response import ArrayResponse from globus_sdk.testing import get_last_request, load_response def test_get_my_groups(groups_client): meta = load_response(groups_client.get_my_groups).metadata res = groups_client.get_my_groups(statuses="active") assert res.http_status == 200 req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert parsed_qs["statuses"] == ["active"] assert isinstance(res, ArrayResponse) assert isinstance(res.data, list) assert set(meta["group_names"]) == {g["name"] for g in res} globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_group_memberships.py000066400000000000000000000067621513221403200325420ustar00rootroot00000000000000import json import uuid import pytest from globus_sdk import BatchMembershipActions, GroupRole from globus_sdk.testing import RegisteredResponse, get_last_request, load_response from tests.common import register_api_route_fixture_file def test_approve_pending(groups_manager): register_api_route_fixture_file( "groups", "/v2/groups/d3974728-6458-11e4-b72d-123139141556", "approve_pending.json", method="POST", ) res = groups_manager.approve_pending( "d3974728-6458-11e4-b72d-123139141556", "ae332d86-d274-11e5-b885-b31714a110e9" ) assert res.http_status == 200 data = res.data assert isinstance(data, dict) assert "approve" in data assert data["approve"][0]["status"] == "active" @pytest.mark.parametrize("role", (GroupRole.admin, GroupRole.member, "member", "admin")) def test_add_member(groups_manager, role): rolestr = role if isinstance(role, str) else role.value register_api_route_fixture_file( "groups", "/v2/groups/d3974728-6458-11e4-b72d-123139141556", "add_member.json", method="POST", ) res = groups_manager.add_member( "d3974728-6458-11e4-b72d-123139141556", "ae332d86-d274-11e5-b885-b31714a110e9", role=role, ) assert res.http_status == 200 data = res.data assert isinstance(data, dict) assert "add" in data assert data["add"][0]["status"] == "active" # FIXME: this should be the line # assert data["add"][0]["role"] == rolestr # but the response is fixed right now assert data["add"][0]["role"] == "admin" req = get_last_request() req_body = json.loads(req.body) assert req_body["add"][0]["role"] == rolestr @pytest.mark.parametrize("role", (GroupRole.admin, GroupRole.member, "member", "admin")) def test_batch_action_payload(groups_client, role): group_id = str(uuid.uuid1()) load_response( RegisteredResponse( service="groups", method="POST", path=f"/v2/groups/{group_id}", json={} ) ) rolestr = role if isinstance(role, str) else role.value batch_action = ( BatchMembershipActions() .accept_invites(uuid.uuid1()) .add_members( [uuid.uuid1(), uuid.uuid1()], role=role, ) .change_roles("admin", [uuid.uuid1(), uuid.uuid1()]) .invite_members([uuid.uuid1(), uuid.uuid1()]) .join([uuid.uuid1(), uuid.uuid1()]) ) assert "add" in batch_action assert len(batch_action["add"]) == 2 assert all(member["role"] == role for member in batch_action["add"]) assert "accept" in batch_action assert len(batch_action["accept"]) == 1 assert "change_role" in batch_action assert len(batch_action["change_role"]) == 2 for change_role in batch_action["change_role"]: assert change_role["role"] == "admin" assert "invite" in batch_action assert len(batch_action["invite"]) == 2 assert "join" in batch_action assert len(batch_action["invite"]) == 2 # send the request and confirm that the data is serialized correctly groups_client.batch_membership_action(group_id, batch_action) req = get_last_request() req_body = json.loads(req.body) # role should be stringified if it was an enum member assert all(member["role"] == rolestr for member in req_body["add"]) # UUIDs should have been stringified for action in ["add", "accept", "invite", "join"]: assert all(isinstance(value["identity_id"], str) for value in req_body[action]) globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_set_group_policies.py000066400000000000000000000112631513221403200326760ustar00rootroot00000000000000import json import pytest from globus_sdk import ( MISSING, GroupMemberVisibility, GroupPolicies, GroupRequiredSignupFields, GroupVisibility, ) from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( "group_vis, group_member_vis, signup_fields, signup_fields_str", ( ( GroupVisibility.private, GroupMemberVisibility.members, [GroupRequiredSignupFields.address1], ["address1"], ), ( GroupVisibility.authenticated, GroupMemberVisibility.managers, ["address1"], ["address1"], ), ( "private", "members", [GroupRequiredSignupFields.address1, "address2"], ["address1", "address2"], ), ("authenticated", "managers", ["address1"], ["address1"]), ), ) def test_set_group_policies( groups_manager, groups_client, group_vis, group_member_vis, signup_fields, signup_fields_str, ): group_vis_str = group_vis if isinstance(group_vis, str) else group_vis.value group_member_vis_str = ( group_member_vis if isinstance(group_member_vis, str) else group_member_vis.value ) meta = load_response(groups_client.set_group_policies).metadata resp = groups_manager.set_group_policies( meta["group_id"], is_high_assurance=False, group_visibility=group_vis, group_members_visibility=group_member_vis, join_requests=False, signup_fields=signup_fields, authentication_assurance_timeout=28800, ) assert resp.http_status == 200 assert "address1" in resp.data["signup_fields"] # ensure enums were stringified correctly req = get_last_request() req_body = json.loads(req.body) assert req_body["group_visibility"] == group_vis_str assert req_body["group_members_visibility"] == group_member_vis_str assert req_body["signup_fields"] == signup_fields_str @pytest.mark.parametrize( "group_vis, group_member_vis, signup_fields, signup_fields_str, auth_timeout", ( ( GroupVisibility.private, GroupMemberVisibility.members, [GroupRequiredSignupFields.address1], ["address1"], 28800, ), ( GroupVisibility.authenticated, GroupMemberVisibility.managers, ["address1"], ["address1"], MISSING, ), ( "private", "members", [GroupRequiredSignupFields.address1, "address2"], ["address1", "address2"], 0, ), ("authenticated", "managers", ["address1"], ["address1"], None), ), ) @pytest.mark.parametrize("setter_usage", (False, "enum", "str")) def test_set_group_policies_explicit_payload( groups_client, group_vis, group_member_vis, signup_fields, signup_fields_str, auth_timeout, setter_usage, ): group_vis_str = group_vis if isinstance(group_vis, str) else group_vis.value group_member_vis_str = ( group_member_vis if isinstance(group_member_vis, str) else group_member_vis.value ) meta = load_response(groups_client.set_group_policies).metadata # same payload as the above test, but formulated without GroupsManager payload = GroupPolicies( is_high_assurance=False, group_visibility=group_vis, group_members_visibility=group_member_vis, join_requests=False, signup_fields=signup_fields, authentication_assurance_timeout=auth_timeout, ) if setter_usage: # set a string in the payload directly # this will pass through GroupPolicies.__setitem__ if setter_usage == "enum": payload["group_visibility"] = group_vis elif setter_usage == "str": payload["group_visibility"] = group_vis_str else: raise NotImplementedError # now send it... (but ignore the response) groups_client.set_group_policies(meta["group_id"], payload) # ensure enums were stringified correctly, but also that the raw string came through req = get_last_request() req_body = json.loads(req.body) assert req_body["group_visibility"] == group_vis_str assert req_body["group_members_visibility"] == group_member_vis_str assert req_body["signup_fields"] == signup_fields_str # check the authentication_assurance_timeout # it should be omitted if it's MISSING if auth_timeout is MISSING: assert "authentication_assurance_timeout" not in req_body else: assert req_body["authentication_assurance_timeout"] == auth_timeout globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_set_identity_preferences.py000066400000000000000000000001461513221403200340630ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_set_identity_preferences(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_set_membership_fields.py000066400000000000000000000001431513221403200333270ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_set_membership_fields(): raise NotImplementedError test_set_subscription_admin_verified.py000066400000000000000000000012361513221403200353440ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/groupsimport json from globus_sdk.testing import get_last_request, load_response def test_set_subscription_admin_verified(groups_client): meta = load_response(groups_client.set_subscription_admin_verified).metadata res = groups_client.set_subscription_admin_verified( group_id=meta["group_id"], subscription_id=meta["subscription_id"], ) assert res.http_status == 200 assert res.data["group_id"] == meta["group_id"] assert res.data["subscription_admin_verified_id"] == meta["subscription_id"] req = get_last_request() req = json.loads(req.body) assert req == {"subscription_admin_verified_id": meta["subscription_id"]} globus-globus-sdk-python-6a080e4/tests/functional/services/groups/test_update_group.py000066400000000000000000000012031513221403200314670ustar00rootroot00000000000000from tests.common import register_api_route_fixture_file def test_update_group(groups_client): register_api_route_fixture_file( "groups", "/v2/groups/592e0566-5201-4207-b5e1-7cd6c516e9a0", "updated_group.json", method="PUT", ) data = { "name": "Claptrap's Rough Riders", "description": "Stairs strongly discouraged.", } res = groups_client.update_group( group_id="592e0566-5201-4207-b5e1-7cd6c516e9a0", data=data ) assert res.http_status == 200 assert "Claptrap" in res.data["name"] assert "Stairs strongly discouraged." in res.data["description"] globus-globus-sdk-python-6a080e4/tests/functional/services/search/000077500000000000000000000000001513221403200253125ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/search/conftest.py000066400000000000000000000002571513221403200275150ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def client(): client = globus_sdk.SearchClient() with client.retry_config.tune(max_retries=0): yield client globus-globus-sdk-python-6a080e4/tests/functional/services/search/fixture_data/000077500000000000000000000000001513221403200277715ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/search/fixture_data/scroll_result_1.json000066400000000000000000000006231513221403200340010ustar00rootroot00000000000000{ "gmeta": [ { "@datatype": "GMetaResult", "@version": "2019-08-27", "entries": [ { "content": { "foo": "bar" }, "entry_id": null, "matched_principal_sets": [] } ], "subject": "foo-bar" } ], "count": 1, "total": 2, "has_next_page": true, "marker": "3d34900e3e4211ebb0a806b2af333354" } globus-globus-sdk-python-6a080e4/tests/functional/services/search/fixture_data/scroll_result_2.json000066400000000000000000000005661513221403200340100ustar00rootroot00000000000000{ "gmeta": [ { "@datatype": "GMetaResult", "@version": "2019-08-27", "entries": [ { "content": { "foo": "baz" }, "entry_id": null, "matched_principal_sets": [] } ], "subject": "foo-baz" } ], "count": 1, "total": 2, "has_next_page": false, "marker": null } globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_batch_delete_by_subject.py000066400000000000000000000035531513221403200335450ustar00rootroot00000000000000import json from globus_sdk.testing import get_last_request, load_response def test_batch_delete_by_subject(client): meta = load_response(client.batch_delete_by_subject).metadata input_subjects = [ "very-cool-document", "less-cool-document", "document-wearing-sunglasses", ] res = client.batch_delete_by_subject(meta["index_id"], subjects=input_subjects) assert res.http_status == 200 assert res["task_id"] == meta["task_id"] req = get_last_request() sent_data = json.loads(req.body) assert sent_data == {"subjects": input_subjects} def test_batch_delete_by_subject_accepts_string(client): """ Test the handling for a single string. We want to ensure that subjects="mydoc" parses the same as subjects=["mydoc"] *not* as subjects=["m", "y", "d", "o", "c"] """ meta = load_response(client.batch_delete_by_subject).metadata input_subject = "very-cool-document" res = client.batch_delete_by_subject(meta["index_id"], subjects=input_subject) assert res.http_status == 200 assert res["task_id"] == meta["task_id"] req = get_last_request() sent_data = json.loads(req.body) assert sent_data == {"subjects": [input_subject]} def test_batch_delete_by_subject_allows_additional_params(client): meta = load_response(client.batch_delete_by_subject).metadata input_subjects = [ "very-cool-document", "less-cool-document", "document-wearing-sunglasses", ] res = client.batch_delete_by_subject( meta["index_id"], subjects=input_subjects, additional_params={"foo": "snork"}, ) assert res.http_status == 200 assert res["task_id"] == meta["task_id"] req = get_last_request() sent_data = json.loads(req.body) assert sent_data == {"subjects": input_subjects, "foo": "snork"} globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_create_index.py000066400000000000000000000012021513221403200313500ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import load_response def test_create_index(client): meta = load_response(client.create_index).metadata res = client.create_index("Foo Title", "bar description") assert res.http_status == 200 assert res["id"] == meta["index_id"] def test_create_index_limit_exceeded(client): load_response(client.create_index, case="trial_limit") with pytest.raises(globus_sdk.SearchAPIError) as excinfo: client.create_index("Foo Title", "bar description") err = excinfo.value assert err.http_status == 409 assert err.code == "Conflict.LimitExceeded" globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_delete_by_query.py000066400000000000000000000001351513221403200321030ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_by_query(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_delete_entry.py000066400000000000000000000001321513221403200314020ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_entry(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_delete_index.py000066400000000000000000000012111513221403200313470ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import load_response def test_delete_index(client): meta = load_response(client.delete_index).metadata res = client.delete_index(meta["index_id"]) assert res.http_status == 200 assert res["acknowledged"] is True def test_delete_index_delete_already_pending(client): meta = load_response(client.delete_index, case="delete_pending").metadata with pytest.raises(globus_sdk.SearchAPIError) as excinfo: client.delete_index(meta["index_id"]) err = excinfo.value assert err.http_status == 409 assert err.code == "Conflict.IncompatibleIndexStatus" globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_delete_subject.py000066400000000000000000000001341513221403200317020ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_subject(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_get_entry.py000066400000000000000000000001271513221403200307230ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_entry(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_get_index.py000066400000000000000000000001271513221403200306710ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_index(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_get_subject.py000066400000000000000000000001311513221403200312140ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_subject(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_get_task.py000066400000000000000000000001261513221403200305230ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_task(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_get_task_list.py000066400000000000000000000001331513221403200315540ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_task_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_index_list.py000066400000000000000000000013431513221403200310660ustar00rootroot00000000000000from globus_sdk.testing import load_response def test_search_index_list(client): meta = load_response(client.index_list).metadata index_ids = meta["index_ids"] res = client.index_list() assert res.http_status == 200 index_list = res["index_list"] assert isinstance(index_list, list) assert len(index_list) == len(index_ids) assert [i["id"] for i in index_list] == index_ids def test_search_index_list_is_iterable(client): meta = load_response(client.index_list).metadata index_ids = meta["index_ids"] res = client.index_list() assert res.http_status == 200 index_list = list(res) assert len(index_list) == len(index_ids) assert [i["id"] for i in index_list] == index_ids globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_ingest.py000066400000000000000000000001241513221403200302110ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_ingest(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_reopen_index.py000066400000000000000000000011751513221403200314060ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import load_response def test_reopen_index(client): meta = load_response(client.reopen_index).metadata res = client.reopen_index(meta["index_id"]) assert res.http_status == 200 assert res["acknowledged"] is True def test_reopen_index_already_open(client): meta = load_response(client.reopen_index, case="already_open").metadata with pytest.raises(globus_sdk.SearchAPIError) as excinfo: client.reopen_index(meta["index_id"]) err = excinfo.value assert err.http_status == 409 assert err.code == "Conflict.IncompatibleIndexStatus" globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_search.py000066400000000000000000000103361513221403200301730ustar00rootroot00000000000000import json import urllib.parse import uuid import pytest import responses import globus_sdk from globus_sdk._missing import filter_missing from globus_sdk.testing import get_last_request, load_response from tests.common import register_api_route_fixture_file @pytest.fixture def search_client(): client = globus_sdk.SearchClient() with client.retry_config.tune(max_retries=0): yield client def test_search_query_simple(search_client): meta = load_response(search_client.search).metadata res = search_client.search(meta["index_id"], q="foo") assert res.http_status == 200 data = res.data assert isinstance(data, dict) assert data["gmeta"][0]["entries"][0]["content"]["foo"] == "bar" req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert parsed_qs == {"q": ["foo"]} @pytest.mark.parametrize("query_doc", [{"q": "foo"}, {"q": "foo", "limit": 10}]) def test_search_post_query_simple(search_client, query_doc): meta = load_response(search_client.post_search).metadata res = search_client.post_search(meta["index_id"], query_doc) assert res.http_status == 200 data = res.data assert isinstance(data, dict) assert data["gmeta"][0]["entries"][0]["content"]["foo"] == "bar" req = get_last_request() assert req.body is not None req_body = json.loads(req.body) assert req_body == dict(query_doc) def test_search_post_query_simple_with_v1_helper(search_client): query_doc = globus_sdk.SearchQueryV1(q="foo") meta = load_response(search_client.post_search).metadata res = search_client.post_search(meta["index_id"], query_doc) assert res.http_status == 200 data = res.data assert isinstance(data, dict) assert data["gmeta"][0]["entries"][0]["content"]["foo"] == "bar" req = get_last_request() assert req.body is not None req_body = json.loads(req.body) assert req_body == {"@version": "query#1.0.0", "q": "foo"} @pytest.mark.parametrize("doc_type", ("dict", "helper")) def test_search_post_query_arg_overrides(search_client, doc_type): meta = load_response(search_client.post_search).metadata if doc_type == "dict": query_doc = {"q": "foo", "limit": 10, "offset": 0} elif doc_type == "helper": query_doc = globus_sdk.SearchQueryV1(q="foo", limit=10, offset=0) else: raise NotImplementedError(doc_type) res = search_client.post_search(meta["index_id"], query_doc, limit=100, offset=150) assert res.http_status == 200 data = res.data assert isinstance(data, dict) assert data["gmeta"][0]["entries"][0]["content"]["foo"] == "bar" req = get_last_request() assert req.body is not None req_body = json.loads(req.body) assert req_body != dict(query_doc) assert req_body["q"] == query_doc["q"] assert req_body["limit"] == 100 assert req_body["offset"] == 150 # important! these should be unchanged (no side-effects) assert query_doc["limit"] == 10 assert query_doc["offset"] == 0 @pytest.mark.parametrize( "query_doc", [ {"q": "foo"}, globus_sdk.SearchScrollQuery("foo"), ], ) def test_search_paginated_scroll_query(search_client, query_doc): index_id = str(uuid.uuid1()) register_api_route_fixture_file( "search", f"/v1/index/{index_id}/scroll", "scroll_result_1.json", method="POST", match=[responses.matchers.json_params_matcher({"q": "foo"})], ) register_api_route_fixture_file( "search", f"/v1/index/{index_id}/scroll", "scroll_result_2.json", method="POST", match=[ responses.matchers.json_params_matcher( {"q": "foo", "marker": "3d34900e3e4211ebb0a806b2af333354"} ) ], ) data = list(search_client.paginated.scroll(index_id, query_doc).items()) assert len(responses.calls) == 2 assert len(data) == 2 assert isinstance(data[0], dict) assert data[0]["entries"][0]["content"]["foo"] == "bar" assert isinstance(data[1], dict) assert data[1]["entries"][0]["content"]["foo"] == "baz" # confirm that pagination was not side-effecting assert "marker" not in filter_missing(query_doc) globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_search_roles.py000066400000000000000000000026461513221403200314040ustar00rootroot00000000000000import json import pytest import globus_sdk from globus_sdk.testing import get_last_request, load_response @pytest.fixture def search_client(): client = globus_sdk.SearchClient() with client.retry_config.tune(max_retries=0): yield client def test_search_role_create(search_client): meta = load_response(search_client.create_role).metadata send_data = { "role_name": meta["role_name"], "principal": "urn:globus:auth:identity:" + meta["identity_id"], } res = search_client.create_role(meta["index_id"], send_data) assert res.http_status == 200 assert res["index_id"] == meta["index_id"] assert res["role_name"] == "writer" last_req = get_last_request() sent = json.loads(last_req.body) assert sent == send_data def test_search_role_delete(search_client): meta = load_response(search_client.delete_role).metadata res = search_client.delete_role(meta["index_id"], meta["role_id"]) assert res.http_status == 200 assert res["success"] is True assert res["deleted"]["index_id"] == meta["index_id"] assert res["deleted"]["id"] == meta["role_id"] def test_search_role_list(search_client): meta = load_response(search_client.get_role_list).metadata res = search_client.get_role_list(meta["index_id"]) assert res.http_status == 200 role_list = res["role_list"] assert isinstance(role_list, list) assert len(role_list) == 2 globus-globus-sdk-python-6a080e4/tests/functional/services/search/test_update_index.py000066400000000000000000000012221513221403200313710ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import load_response def test_update_index(client): meta = load_response(client.update_index).metadata res = client.update_index(meta["index_id"], display_name="foo") assert res.http_status == 200 assert res["display_name"] == meta["display_name"] def test_update_index_forbidden_error(client): meta = load_response(client.update_index, case="forbidden").metadata with pytest.raises(globus_sdk.SearchAPIError) as excinfo: client.update_index(meta["index_id"]) err = excinfo.value assert err.http_status == 403 assert err.code == "Forbidden.Generic" globus-globus-sdk-python-6a080e4/tests/functional/services/timers/000077500000000000000000000000001513221403200253505ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/timers/conftest.py000066400000000000000000000001461513221403200275500ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def client(): return globus_sdk.TimersClient() globus-globus-sdk-python-6a080e4/tests/functional/services/timers/test_create_timer.py000066400000000000000000000043641513221403200314330ustar00rootroot00000000000000import json import globus_sdk from globus_sdk._missing import filter_missing from globus_sdk.testing import get_last_request, load_response def test_dummy_timer_creation(client): # create a timer with a dummy payload and validate how it gets wrapped by the # sending method meta = load_response(client.create_timer).metadata timer = client.create_timer(timer={"foo": "bar"}) assert timer["timer"]["job_id"] == meta["timer_id"] req = get_last_request() sent = json.loads(req.body) assert sent == {"timer": {"foo": "bar"}} def test_transfer_timer_creation(client): # create a timer using the payload helpers and confirm that it is serialized as # desired meta = load_response(client.create_timer).metadata body = globus_sdk.TransferData( source_endpoint=meta["source_endpoint"], destination_endpoint=meta["destination_endpoint"], ) body.add_item("/share/godata/file1.txt", "/~/file1.txt") schedule = globus_sdk.RecurringTimerSchedule( interval_seconds=60, end={"condition": "iterations", "iterations": 3} ) timer = client.create_timer( timer=globus_sdk.TransferTimer(body=body, schedule=schedule) ) assert timer["timer"]["job_id"] == meta["timer_id"] req = get_last_request() sent = json.loads(req.body) assert sent["timer"]["schedule"] == { "type": "recurring", "interval_seconds": 60, "end": {"condition": "iterations", "iterations": 3}, } assert sent["timer"]["body"] == { k: [filter_missing(data_val) for data_val in v] if k == "DATA" else v for k, v in filter_missing(body).items() if k != "skip_activation_check" } def test_flow_timer_creation(client): # Setup meta = load_response(client.create_timer, case="flow_timer_success").metadata # Act client.create_timer( timer=globus_sdk.FlowTimer( flow_id=meta["flow_id"], body=meta["callback_body"], schedule=meta["schedule"], ) ) # Verify req = get_last_request() sent = json.loads(req.body) assert sent["timer"]["flow_id"] == meta["flow_id"] assert sent["timer"]["body"] == meta["callback_body"] assert sent["timer"]["schedule"] == meta["schedule"] globus-globus-sdk-python-6a080e4/tests/functional/services/timers/test_jobs.py000066400000000000000000000072421513221403200277230ustar00rootroot00000000000000import datetime import json import pytest from globus_sdk import TimerJob, TimersAPIError from globus_sdk.testing import get_last_request, load_response def test_list_jobs(client): meta = load_response(client.list_jobs).metadata response = client.list_jobs() assert response.http_status == 200 assert set(meta["job_ids"]) == {job["job_id"] for job in response.data["jobs"]} def test_get_job(client): meta = load_response(client.get_job).metadata response = client.get_job(meta["job_id"]) assert response.http_status == 200 assert response.data.get("job_id") == meta["job_id"] def test_get_job_errors(client): meta = load_response(client.get_job, case="simple_500_error").metadata with pytest.raises(TimersAPIError) as excinfo: client.get_job(meta["job_id"]) err = excinfo.value assert err.http_status == 500 assert err.code == "ERROR" assert err.message == "Request failed terribly" @pytest.mark.parametrize("start", [datetime.datetime.now(), "2022-04-05T06:00:00"]) @pytest.mark.parametrize( "interval", [datetime.timedelta(days=1), datetime.timedelta(minutes=60), 600, None] ) def test_create_job(client, start, interval): meta = load_response(client.create_job).metadata timer_job = TimerJob( "https://example.bogus/bogus-callback", {"bogus": "bogus_body"}, start, interval ) response = client.create_job(timer_job) assert response.http_status == 201 assert response.data["job_id"] == meta["job_id"] req_body = json.loads(get_last_request().body) if isinstance(start, datetime.datetime): assert req_body["start"] == start.isoformat() else: assert req_body["start"] == start if isinstance(interval, datetime.timedelta): assert req_body["interval"] == interval.total_seconds() else: assert req_body["interval"] == interval assert req_body["callback_url"] == "https://example.bogus/bogus-callback" def test_create_job_validation_error(client): meta = load_response(client.create_job, case="validation_error").metadata timer_job = TimerJob( "https://example.bogus/bogus-callback", {"bogus": "bogus_body"}, "2022-04-05T06:00:00", 1800, ) with pytest.raises(TimersAPIError) as excinfo: client.create_job(timer_job) err = excinfo.value assert err.http_status == 422 assert err.code is None assert err.messages == meta["expect_messages"] def test_update_job(client): meta = load_response(client.update_job).metadata response = client.update_job(meta["job_id"], {"name": meta["name"]}) assert response.http_status == 200 assert response.data["job_id"] == meta["job_id"] assert response.data["name"] == meta["name"] def test_delete_job(client): meta = load_response(client.delete_job).metadata response = client.delete_job(meta["job_id"]) assert response.http_status == 200 assert response.data["job_id"] == meta["job_id"] def test_pause_job(client): meta = load_response(client.pause_job).metadata response = client.pause_job(meta["job_id"]) assert response.http_status == 200 assert "Successfully paused" in response.data["message"] @pytest.mark.parametrize("update_credentials", [True, False, None]) def test_resume_job(update_credentials, client): meta = load_response(client.resume_job).metadata kwargs = {} if update_credentials is not None: kwargs["update_credentials"] = update_credentials response = client.resume_job(meta["job_id"], **kwargs) assert response.http_status == 200 assert json.loads(response._raw_response.request.body) == kwargs assert "Successfully resumed" in response.data["message"] globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/000077500000000000000000000000001513221403200256715ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/__init__.py000066400000000000000000000000001513221403200277700ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/conftest.py000066400000000000000000000002611513221403200300670ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def client(): client = globus_sdk.TransferClient() with client.retry_config.tune(max_retries=0): yield client globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_manager/000077500000000000000000000000001513221403200312035ustar00rootroot00000000000000test_endpoint_manager_acl_list.py000066400000000000000000000001471513221403200377230ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_acl_list(): raise NotImplementedError test_endpoint_manager_cancel_status.py000066400000000000000000000001541513221403200407570ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_cancel_status(): raise NotImplementedError test_endpoint_manager_cancel_tasks.py000066400000000000000000000001531513221403200405600ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_cancel_tasks(): raise NotImplementedError test_endpoint_manager_create_pause_rule.py000066400000000000000000000001601513221403200416130ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_create_pause_rule(): raise NotImplementedError test_endpoint_manager_delete_pause_rule.py000066400000000000000000000001601513221403200416120ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_delete_pause_rule(): raise NotImplementedError test_endpoint_manager_get_endpoint.py000066400000000000000000000001531513221403200406050ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_get_endpoint(): raise NotImplementedError test_endpoint_manager_get_pause_rule.py000066400000000000000000000001551513221403200411330ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_get_pause_rule(): raise NotImplementedError test_endpoint_manager_get_task.py000066400000000000000000000001471513221403200377320ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_get_task(): raise NotImplementedError test_endpoint_manager_hosted_endpoint_list.py000066400000000000000000000001631513221403200423500ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_hosted_endpoint_list(): raise NotImplementedError test_endpoint_manager_monitored_endpoints.py000066400000000000000000000001621513221403200422110ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_monitored_endpoints(): raise NotImplementedError test_endpoint_manager_pause_rule_list.py000066400000000000000000000001561513221403200413300ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_pause_rule_list(): raise NotImplementedError test_endpoint_manager_pause_tasks.py000066400000000000000000000001521513221403200404470ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_pause_tasks(): raise NotImplementedError test_endpoint_manager_resume_tasks.py000066400000000000000000000001531513221403200406330ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_resume_tasks(): raise NotImplementedError test_endpoint_manager_task_pause_info.py000066400000000000000000000001561513221403200413030ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_task_pause_info(): raise NotImplementedError test_endpoint_manager_task_skipped_errors.py000066400000000000000000000001621513221403200422030ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_task_skipped_errors(): raise NotImplementedError test_endpoint_manager_task_successful_transfers.py000066400000000000000000000005671513221403200434270ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerfrom globus_sdk.testing import load_response def test_endpoint_manager_task_successful_transfers(client): meta = load_response(client.endpoint_manager_task_successful_transfers).metadata response = client.endpoint_manager_task_successful_transfers(meta["task_id"]) assert response.http_status == 200 assert response["DATA_TYPE"] == "successful_transfers" test_endpoint_manager_update_pause_rule.py000066400000000000000000000001601513221403200416320ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport pytest @pytest.mark.xfail def test_endpoint_manager_update_pause_rule(): raise NotImplementedError test_task_event_list.py000066400000000000000000000021111513221403200357260ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport uuid import pytest from globus_sdk.testing import get_last_request from tests.common import register_api_route ZERO_ID = uuid.UUID(int=0) def get_last_params(): return get_last_request().params @pytest.fixture def task_id(): return uuid.uuid1() # stub in empty data, this can be explicitly replaced if a test wants specific data @pytest.fixture(autouse=True) def empty_response(task_id): register_api_route( "transfer", f"/endpoint_manager/task/{task_id}/event_list", json={"DATA": []} ) # although int values are not supported based on the type annotations, users may already # be passing ints -- it's good to support this usage and test it even if it's not # documented and desirable @pytest.mark.parametrize( "paramvalue, paramstr", [(True, "1"), (False, "0"), (1, "1"), (0, "0")] ) def test_filter_is_error(client, task_id, paramvalue, paramstr): client.endpoint_manager_task_event_list(task_id, filter_is_error=paramvalue) params = get_last_params() assert "filter_is_error" in params assert params["filter_is_error"] == paramstr test_task_list.py000066400000000000000000000072201513221403200345330ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/endpoint_managerimport datetime import uuid import pytest import globus_sdk from globus_sdk.testing import get_last_request, load_response ZERO_ID = uuid.UUID(int=0) def get_last_params(): return get_last_request().params @pytest.mark.parametrize( "paramname, paramvalue", [ ("filter_task_id", ZERO_ID), ("filter_task_id", "foo"), ("filter_owner_id", ZERO_ID), ("filter_owner_id", "foo"), ("filter_endpoint", ZERO_ID), ("filter_endpoint", "foo"), ("filter_is_paused", True), ("filter_is_paused", False), ("filter_min_faults", 0), ("filter_min_faults", 10), ("filter_local_user", "foouser"), ("filter_status", "ACTIVE"), ("filter_completion_time", "2020-08-25T00:00:00,2021-08-25T16:05:28"), ], ) def test_strsafe_params(client, paramname, paramvalue): load_response(client.endpoint_manager_task_list) paramstr = str(paramvalue) client.endpoint_manager_task_list(**{paramname: paramvalue}) params = get_last_params() assert paramname in params assert params[paramname] == paramstr def test_filter_status_list(client): load_response(client.endpoint_manager_task_list) client.endpoint_manager_task_list(filter_status=["ACTIVE", "INACTIVE"]) params = get_last_params() assert "filter_status" in params assert params["filter_status"] == "ACTIVE,INACTIVE" def test_filter_task_id_list(client): load_response(client.endpoint_manager_task_list) # mixed list of str and UUID client.endpoint_manager_task_list(filter_task_id=["foo", ZERO_ID, "bar"]) params = get_last_params() assert "filter_task_id" in params assert params["filter_task_id"] == f"foo,{str(ZERO_ID)},bar" def test_filter_completion_time_datetime_tuple(client): load_response(client.endpoint_manager_task_list) dt1 = datetime.datetime.fromisoformat("2020-08-25T00:00:00") dt2 = datetime.datetime.fromisoformat("2021-08-25T16:05:28") client.endpoint_manager_task_list(filter_completion_time=(dt1, dt2)) params = get_last_params() assert "filter_completion_time" in params assert params["filter_completion_time"] == "2020-08-25T00:00:00,2021-08-25T16:05:28" # mixed tuples work, important for passing `""` client.endpoint_manager_task_list(filter_completion_time=(dt1, "")) params = get_last_params() assert "filter_completion_time" in params assert params["filter_completion_time"] == "2020-08-25T00:00:00," client.endpoint_manager_task_list(filter_completion_time=("", dt1)) params = get_last_params() assert "filter_completion_time" in params assert params["filter_completion_time"] == ",2020-08-25T00:00:00" @pytest.mark.parametrize("ep_use", ("source", "destination")) def test_filter_by_endpoint_use(client, ep_use): meta = load_response(client.endpoint_manager_task_list).metadata if ep_use == "source": ep_id = meta["source"] else: ep_id = meta["destination"] client.endpoint_manager_task_list(filter_endpoint=ep_id, filter_endpoint_use=ep_use) params = get_last_params() assert "filter_endpoint" in params assert params["filter_endpoint"] == str(ep_id) assert "filter_endpoint_use" in params assert params["filter_endpoint_use"] == ep_use @pytest.mark.parametrize("ep_use", ("source", "destination")) def test_usage_error_on_filter_endpoint_use_without_endpoint(client, ep_use): with pytest.raises( globus_sdk.GlobusSDKUsageError, match=( "`filter_endpoint_use` is only valid when `filter_endpoint` is " r"also supplied\." ), ): client.endpoint_manager_task_list(filter_endpoint_use=ep_use) globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/fixture_data/000077500000000000000000000000001513221403200303505ustar00rootroot00000000000000get_task1_active.json000066400000000000000000000031621513221403200344030ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/fixture_data{ "DATA_TYPE": "task", "bytes_checksummed": 0, "bytes_transferred": 0, "canceled_by_admin": null, "canceled_by_admin_message": null, "command": "API 0.10", "completion_time": null, "deadline": "2022-01-27T19:18:20+00:00", "delete_destination_extra": false, "destination_endpoint": "go#ep2", "destination_endpoint_display_name": "Globus Tutorial Endpoint 2", "destination_endpoint_id": "313ce13e-b597-5858-ae13-29e46fea26e6", "directories": 1, "effective_bytes_per_second": 0, "encrypt_data": false, "fail_on_quota_errors": false, "fatal_error": null, "faults": 0, "files": 0, "files_skipped": 0, "files_transferred": 0, "filter_rules": null, "history_deleted": false, "is_ok": true, "is_paused": false, "label": null, "nice_status": "OK", "nice_status_details": null, "nice_status_expires_in": -1, "nice_status_short_description": "OK", "owner_id": "ae332d86-d274-11e5-b885-b31714a110e9", "preserve_timestamp": false, "recursive_symlinks": "ignore", "request_time": "2022-01-26T19:18:20+00:00", "skip_source_errors": false, "source_endpoint": "go#ep1", "source_endpoint_display_name": "Globus Tutorial Endpoint 1", "source_endpoint_id": "aa752cea-8222-5bc8-acd9-555b090c0ccb", "status": "ACTIVE", "subtasks_canceled": 0, "subtasks_expired": 0, "subtasks_failed": 0, "subtasks_pending": 1, "subtasks_retrying": 0, "subtasks_skipped_errors": 0, "subtasks_succeeded": 1, "subtasks_total": 2, "symlinks": 0, "sync_level": null, "task_id": "b8872740-7edc-11ec-9f33-ed182a728dff", "type": "TRANSFER", "username": "sirosen", "verify_checksum": true } get_task1_succeeded.json000066400000000000000000000032161513221403200350540ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/fixture_data{ "DATA_TYPE": "task", "bytes_checksummed": 0, "bytes_transferred": 0, "canceled_by_admin": null, "canceled_by_admin_message": null, "command": "API 0.10", "completion_time": "2022-01-26T19:18:21+00:00", "deadline": "2022-01-27T19:18:20+00:00", "delete_destination_extra": false, "destination_endpoint": "go#ep2", "destination_endpoint_display_name": "Globus Tutorial Endpoint 2", "destination_endpoint_id": "313ce13e-b597-5858-ae13-29e46fea26e6", "directories": 1, "effective_bytes_per_second": 0, "encrypt_data": false, "fail_on_quota_errors": false, "fatal_error": null, "faults": 0, "files": 0, "files_skipped": 0, "files_transferred": 0, "filter_rules": null, "history_deleted": false, "is_ok": null, "is_paused": false, "label": null, "nice_status": null, "nice_status_details": null, "nice_status_expires_in": null, "nice_status_short_description": null, "owner_id": "ae332d86-d274-11e5-b885-b31714a110e9", "preserve_timestamp": false, "recursive_symlinks": "ignore", "request_time": "2022-01-26T19:18:20+00:00", "skip_source_errors": false, "source_endpoint": "go#ep1", "source_endpoint_display_name": "Globus Tutorial Endpoint 1", "source_endpoint_id": "aa752cea-8222-5bc8-acd9-555b090c0ccb", "status": "SUCCEEDED", "subtasks_canceled": 0, "subtasks_expired": 0, "subtasks_failed": 0, "subtasks_pending": 0, "subtasks_retrying": 0, "subtasks_skipped_errors": 0, "subtasks_succeeded": 2, "subtasks_total": 2, "symlinks": 0, "sync_level": null, "task_id": "b8872740-7edc-11ec-9f33-ed182a728dff", "type": "TRANSFER", "username": "sirosen", "verify_checksum": true } globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_add_endpoint_acl_rule.py000066400000000000000000000001431513221403200335760ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_add_endpoint_acl_rule(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_add_endpoint_role.py000066400000000000000000000001371513221403200327540ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_add_endpoint_role(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_bookmark_list.py000066400000000000000000000001331513221403200321370ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_bookmark_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_cancel_task.py000066400000000000000000000004401513221403200315470ustar00rootroot00000000000000from globus_sdk.testing import get_last_request, load_response def test_cancel_task(client): meta = load_response(client.cancel_task).metadata res = client.cancel_task(meta["task_id"]) assert res.http_status == 200 req = get_last_request() assert req.body is None globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_create_bookmark.py000066400000000000000000000001351513221403200324310ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_create_bookmark(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_create_shared_endpoint.py000066400000000000000000000001441513221403200337720ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_create_shared_endpoint(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_create_tunnel.py000066400000000000000000000036171513221403200321410ustar00rootroot00000000000000import json import uuid import pytest from globus_sdk import exc from globus_sdk.services.transfer import CreateTunnelData from globus_sdk.testing import get_last_request, load_response def test_create_tunnel(client): meta = load_response(client.create_tunnel).metadata submission_id = uuid.uuid4() data = CreateTunnelData( meta["initiator_ap"], meta["listener_ap"], submission_id=submission_id, label=meta["display_name"], ) res = client.create_tunnel(data) assert res.http_status == 200 assert res["data"]["type"] == "Tunnel" req = get_last_request() sent = json.loads(req.body) assert ( sent["data"]["relationships"]["initiator"]["data"]["id"] == meta["initiator_ap"] ) assert ( sent["data"]["relationships"]["listener"]["data"]["id"] == meta["listener_ap"] ) assert sent["data"]["attributes"]["submission_id"] == str(submission_id) assert sent["data"]["attributes"]["label"] == meta["display_name"] def test_create_tunnel_no_submission(client): meta = load_response(client.create_tunnel).metadata load_response(client.get_submission_id) data = CreateTunnelData( meta["initiator_ap"], meta["listener_ap"], label=meta["display_name"] ) res = client.create_tunnel(data) assert res.http_status == 200 req = get_last_request() sent = json.loads(req.body) assert sent["data"]["attributes"]["submission_id"] is not None def test_create_tunnel_bad_input(client): data = { "relationships": { "listener": { "data": { "type": "StreamAccessPoint", } }, "initiator": { "data": { "type": "StreamAccessPoint", } }, } } with pytest.raises(exc.GlobusSDKUsageError): client.create_tunnel(data) globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_custom_retry_behavior.py000066400000000000000000000030671513221403200337260ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import RegisteredResponse def test_transfer_client_will_retry_ordinary_502(client, mocksleep): # turn on retries (fixture defaults off) client.retry_config.max_retries = 1 RegisteredResponse(service="transfer", path="/foo", status=502, body="Uh-oh!").add() RegisteredResponse(service="transfer", path="/foo", json={"status": "ok"}).add() # no sign of an error in the client res = client.get("/foo") assert res.http_status == 200 assert res["status"] == "ok" # there was a sleep (retry was triggered) mocksleep.assert_called_once() def test_transfer_client_will_not_retry_endpoint_error(client, mocksleep): # turn on retries (fixture defaults off) client.retry_config.max_retries = 1 RegisteredResponse( service="transfer", path="/do_a_gcp_thing", status=502, json={ "HTTP status": "502", "code": "ExternalError.DirListingFailed.GCDisconnected", "error_name": "Transfer API Error", "message": "The GCP endpoint is not currently connected to Globus", "request_id": "rhvcR0aHX", }, ).add() RegisteredResponse( service="transfer", path="/do_a_gcp_thing", json={"status": "ok"} ).add() # no sign of an error in the client with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.get("/do_a_gcp_thing") assert excinfo.value.http_status == 502 # there was no sleep (retry was not triggered) mocksleep.assert_not_called() globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_delete_bookmark.py000066400000000000000000000001351513221403200324300ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_bookmark(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_delete_endpoint.py000066400000000000000000000001351513221403200324430ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_endpoint(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_delete_endpoint_acl_rule.py000066400000000000000000000001461513221403200343130ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_endpoint_acl_rule(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_delete_endpoint_role.py000066400000000000000000000001421513221403200334620ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_delete_endpoint_role(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_delete_tunnel.py000066400000000000000000000004501513221403200321300ustar00rootroot00000000000000from globus_sdk.testing import get_last_request, load_response def test_delete_tunnel(client): meta = load_response(client.delete_tunnel).metadata res = client.delete_tunnel(meta["tunnel_id"]) assert res.http_status == 200 req = get_last_request() assert req.body is None globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_endpoint_acl_list.py000066400000000000000000000001371513221403200327750ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_endpoint_acl_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_endpoint_role_list.py000066400000000000000000000001401513221403200331710ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_endpoint_role_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_get_bookmark.py000066400000000000000000000001321513221403200317420ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_bookmark(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_get_endpoint_acl_rule.py000066400000000000000000000001431513221403200336250ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_endpoint_acl_rule(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_get_endpoint_role.py000066400000000000000000000001371513221403200330030ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_endpoint_role(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_get_endpoint_server.py000066400000000000000000000001411513221403200333430ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_get_endpoint_server(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_get_stream_access_point.py000066400000000000000000000006551513221403200341740ustar00rootroot00000000000000from globus_sdk.testing import get_last_request, load_response def test_get_tunnel(client): meta = load_response(client.get_stream_access_point).metadata res = client.get_stream_access_point(meta["access_point_id"]) assert res.http_status == 200 assert res["data"]["type"] == "StreamAccessPoint" assert res["data"]["id"] == meta["access_point_id"] req = get_last_request() assert req.body is None globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_get_tunnel.py000066400000000000000000000005741513221403200314540ustar00rootroot00000000000000from globus_sdk.testing import get_last_request, load_response def test_get_tunnel(client): meta = load_response(client.get_tunnel).metadata res = client.get_tunnel(meta["tunnel_id"]) assert res.http_status == 200 assert res["data"]["type"] == "Tunnel" assert res["data"]["id"] == meta["tunnel_id"] req = get_last_request() assert req.body is None globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_iterable.py000066400000000000000000000024601513221403200310730ustar00rootroot00000000000000""" Tests for IterableTransferResponse responses from TransferClient """ import globus_sdk from tests.common import register_api_route SERVER_LIST_TEXT = """{ "DATA": [ { "DATA_TYPE": "server", "hostname": "ep1.transfer.globus.org", "id": 207976, "incoming_data_port_end": null, "incoming_data_port_start": null, "is_connected": true, "is_paused": false, "outgoing_data_port_end": null, "outgoing_data_port_start": null, "port": 2811, "scheme": "gsiftp", "subject": null, "uri": "gsiftp://ep1.transfer.globus.org:2811" } ], "DATA_TYPE": "endpoint_server_list", "endpoint": "go#ep1" }""" def test_server_list(client): epid = "epid" register_api_route( "transfer", f"/endpoint/{epid}/server_list", body=SERVER_LIST_TEXT ) res = client.endpoint_server_list(epid) # it should still be a subclass of GlobusHTTPResponse assert isinstance(res, globus_sdk.GlobusHTTPResponse) # fetch top-level attrs assert res["DATA_TYPE"] == "endpoint_server_list" assert res["endpoint"] == "go#ep1" # intentionally access twice -- unlike PaginatedResource, this is allowed # and works assert len(list(res)) == 1 assert len(list(res)) == 1 assert list(res)[0]["DATA_TYPE"] == "server" globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_list_tunnel.py000066400000000000000000000004441513221403200316440ustar00rootroot00000000000000from globus_sdk.testing import get_last_request, load_response def test_list_tunnel(client): load_response(client.list_tunnels) res = client.list_tunnels() assert res.http_status == 200 assert len(res["data"]) == 2 req = get_last_request() assert req.body is None test_my_effective_pause_rule_list.py000066400000000000000000000001521513221403200351450ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transferimport pytest @pytest.mark.xfail def test_my_effective_pause_rule_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_my_shared_endpoint_list.py000066400000000000000000000001451513221403200342100ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_my_shared_endpoint_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_operation_ls.py000066400000000000000000000050651513221403200320060ustar00rootroot00000000000000import urllib.parse import pytest from globus_sdk.testing import RegisteredResponse, get_last_request, load_response from tests.common import GO_EP1_ID def _mk_item(*, name, typ, size=0): return { "DATA_TYPE": "file", "group": "tutorial", "last_modified": "2018-04-04 18:30:26+00:00", "link_group": None, "link_last_modified": None, "link_size": None, "link_target": None, "link_user": None, "name": name, "permissions": "0755" if typ == "dir" else "0644", "size": 4096 if typ == "dir" else size, "type": typ, "user": "snork", } def _mk_ls_data(): return { "DATA": [ _mk_item(name="foo", typ="dir"), _mk_item(name="tempdir1", typ="dir"), _mk_item(name=".bashrc", typ="file", size=3771), _mk_item(name=".profile", typ="file", size=807), ] } @pytest.fixture(autouse=True) def _setup_ls_response(): load_response( RegisteredResponse( service="transfer", path=f"/v0.10/operation/endpoint/{GO_EP1_ID}/ls", json=_mk_ls_data(), ), ) def test_operation_ls(client): ls_path = f"https://transfer.api.globus.org/v0.10/operation/endpoint/{GO_EP1_ID}/ls" # load the tutorial endpoint ls doc ls_doc = client.operation_ls(GO_EP1_ID) # check that the result is an iterable of file and dir dict objects for x in ls_doc: assert "DATA_TYPE" in x assert x["DATA_TYPE"] in ("file", "dir") req = get_last_request() assert req.url == ls_path @pytest.mark.parametrize( "kwargs, expected_qs", [ # orderby with a single str ({"orderby": "name"}, {"orderby": ["name"]}), # orderby with a multiple strs ( {"orderby": ["size DESC", "name", "type"]}, {"orderby": ["size DESC,name,type"]}, ), # orderby + filter ( {"orderby": "name", "filter": "name:~*.png"}, {"orderby": ["name"], "filter": ["name:~*.png"]}, ), # local_user ( {"local_user": "my-user"}, {"local_user": ["my-user"]}, ), # limit+offset ( {"limit": 10, "offset": 5}, {"limit": ["10"], "offset": ["5"]}, ), ], ) def test_operation_ls_params(client, kwargs, expected_qs): client.operation_ls(GO_EP1_ID, **kwargs) req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert parsed_qs == expected_qs globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_operation_mkdir.py000066400000000000000000000022671513221403200324770ustar00rootroot00000000000000import json import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response _OMIT = object() @pytest.mark.parametrize("local_user", ("my-user", MISSING, _OMIT)) def test_operation_mkdir(client, local_user): meta = load_response(client.operation_mkdir).metadata endpoint_id = meta["endpoint_id"] if local_user is not _OMIT: res = client.operation_mkdir( endpoint_id=endpoint_id, path="~/dir/", local_user=local_user, query_params={"foo": "bar"}, ) else: res = client.operation_mkdir( endpoint_id=endpoint_id, path="~/dir/", query_params={"foo": "bar"}, ) assert res["DATA_TYPE"] == "mkdir_result" assert res["code"] == "DirectoryCreated" req = get_last_request() body = json.loads(req.body) assert body["path"] == "~/dir/" if local_user not in (_OMIT, MISSING): assert body["local_user"] == local_user else: assert "local_user" not in body query_params = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert query_params["foo"] == ["bar"] globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_operation_rename.py000066400000000000000000000024661513221403200326410ustar00rootroot00000000000000import json import urllib.parse import pytest from globus_sdk import MISSING from globus_sdk.testing import get_last_request, load_response _OMIT = object() @pytest.mark.parametrize("local_user", ("my-user", MISSING, _OMIT)) def test_operation_rename(client, local_user): meta = load_response(client.operation_rename).metadata endpoint_id = meta["endpoint_id"] if local_user is not _OMIT: res = client.operation_rename( endpoint_id=endpoint_id, oldpath="~/old-name", newpath="~/new-name", local_user=local_user, query_params={"foo": "bar"}, ) else: res = client.operation_rename( endpoint_id=endpoint_id, oldpath="~/old-name", newpath="~/new-name", query_params={"foo": "bar"}, ) assert res["DATA_TYPE"] == "result" assert res["code"] == "FileRenamed" req = get_last_request() body = json.loads(req.body) assert body["old_path"] == "~/old-name" assert body["new_path"] == "~/new-name" if local_user not in (_OMIT, MISSING): assert body["local_user"] == local_user else: assert "local_user" not in body query_params = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert query_params["foo"] == ["bar"] globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_operation_stat.py000066400000000000000000000011331513221403200323330ustar00rootroot00000000000000""" Tests for TransferClient.operation_stat """ import urllib.parse from globus_sdk.testing import get_last_request, load_response def test_operation_stat(client): meta = load_response(client.operation_stat).metadata endpoint_id = meta["endpoint_id"] path = "/home/share/godata/file1.txt" res = client.operation_stat(endpoint_id, path) assert res["name"] == "file1.txt" assert res["type"] == "file" assert res["size"] == 4 req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert parsed_qs == {"path": [path]} globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_paginated.py000066400000000000000000000244731513221403200312500ustar00rootroot00000000000000import random import uuid import pytest import responses from globus_sdk.paging import Paginator from tests.common import register_api_route # empty search EMPTY_SEARCH_RESULT = { "DATA_TYPE": "endpoint_list", "offset": 0, "limit": 100, "has_next_page": False, "DATA": [], } # single page of data SINGLE_PAGE_SEARCH_RESULT = { "DATA_TYPE": "endpoint_list", "offset": 0, "limit": 100, "has_next_page": False, "DATA": [ {"DATA_TYPE": "endpoint", "display_name": f"SDK Test Stub {x}"} for x in range(100) ], } # multiple pages of results, very stubby MULTIPAGE_SEARCH_RESULTS = [ { "DATA_TYPE": "endpoint_list", "offset": 0, "limit": 100, "has_next_page": True, "DATA": [ {"DATA_TYPE": "endpoint", "display_name": f"SDK Test Stub {x}"} for x in range(100) ], }, { "DATA_TYPE": "endpoint_list", "offset": 100, "limit": 100, "has_next_page": True, "DATA": [ { "DATA_TYPE": "endpoint", "display_name": f"SDK Test Stub {x + 100}", } for x in range(100, 200) ], }, { "DATA_TYPE": "endpoint_list", "offset": 200, "limit": 100, "has_next_page": False, "DATA": [ { "DATA_TYPE": "endpoint", "display_name": f"SDK Test Stub {x + 200}", } for x in range(100) ], }, ] def _mk_task_doc(idx): return { "DATA_TYPE": "task", "source_endpoint_id": "dc8e1110-b698-11eb-afd7-e1e7a67e00c1", "source_endpoint_display_name": "foreign place", "destination_endpoint_id": "83567b16-478d-4ead-a486-645bab0b07dc", "destination_endpoint_display_name": "my home", "directories": 0, "effective_bytes_per_second": random.randint(0, 10000), "files": 1, "encrypt_data": False, "label": f"autogen transfer {idx}", } MULTIPAGE_OFFSET_TASK_LIST_RESULTS = [ { "DATA_TYPE": "task_list", "offset": 0, "limit": 100, "total": 200, "DATA": [_mk_task_doc(x) for x in range(100)], }, { "DATA_TYPE": "task_list", "offset": 100, "limit": 200, "total": 200, "DATA": [_mk_task_doc(x) for x in range(100, 200)], }, ] MULTIPAGE_LASTKEY_TASK_LIST_RESULTS = [ { "DATA_TYPE": "task_list", "last_key": "abc", "limit": 100, "has_next_page": True, "DATA": [_mk_task_doc(x) for x in range(100)], }, { "DATA_TYPE": "task_list", "last_key": "def", "limit": 100, "has_next_page": False, "DATA": [_mk_task_doc(x) for x in range(100, 200)], }, ] def test_endpoint_search_noresults(client): register_api_route("transfer", "/endpoint_search", json=EMPTY_SEARCH_RESULT) res = client.endpoint_search("search query!") assert res["DATA"] == [] def test_endpoint_search_one_page(client): register_api_route("transfer", "/endpoint_search", json=SINGLE_PAGE_SEARCH_RESULT) # without calling the paginated version, we only get one page res = client.endpoint_search("search query!") assert len(list(res)) == 100 assert res["DATA_TYPE"] == "endpoint_list" for res_obj in res: assert res_obj["DATA_TYPE"] == "endpoint" @pytest.mark.parametrize("method", ("__iter__", "pages")) @pytest.mark.parametrize( "api_methodname,paged_data", [ ("endpoint_search", MULTIPAGE_SEARCH_RESULTS), ("task_list", MULTIPAGE_OFFSET_TASK_LIST_RESULTS), ("endpoint_manager_task_list", MULTIPAGE_LASTKEY_TASK_LIST_RESULTS), ], ) def test_paginated_method_multipage(client, method, api_methodname, paged_data): if api_methodname == "endpoint_search": route = "/endpoint_search" client_method = client.endpoint_search paginated_method = client.paginated.endpoint_search call_args = ("search_query",) wrapper_type = "endpoint_list" data_type = "endpoint" elif api_methodname == "task_list": route = "/task_list" client_method = client.task_list paginated_method = client.paginated.task_list call_args = () wrapper_type = "task_list" data_type = "task" elif api_methodname == "endpoint_manager_task_list": route = "/endpoint_manager/task_list" client_method = client.endpoint_manager_task_list paginated_method = client.paginated.endpoint_manager_task_list call_args = () wrapper_type = "task_list" data_type = "task" else: raise NotImplementedError # add each page for page in paged_data: register_api_route("transfer", route, json=page) # unpaginated, we'll only get one page res = list(client_method(*call_args)) assert len(res) == 100 # reset and reapply responses responses.reset() for page in paged_data: register_api_route("transfer", route, json=page) # setup the paginator and either point at `pages()` or directly at the paginator's # `__iter__` paginator = paginated_method(*call_args) if method == "pages": iterator = paginator.pages() elif method == "__iter__": iterator = paginator else: raise NotImplementedError # paginated calls gets all pages count_pages = 0 count_objects = 0 for page in iterator: count_pages += 1 assert page["DATA_TYPE"] == wrapper_type for res_obj in page: count_objects += 1 assert res_obj["DATA_TYPE"] == data_type assert count_pages == len(paged_data) assert count_objects == sum(len(x["DATA"]) for x in paged_data) def test_endpoint_search_multipage_iter_items(client): # add each page for page in MULTIPAGE_SEARCH_RESULTS: register_api_route("transfer", "/endpoint_search", json=page) # paginator items() call gets an iterator of individual page items paginator = client.paginated.endpoint_search("search_query") count_objects = 0 for item in paginator.items(): count_objects += 1 assert item["DATA_TYPE"] == "endpoint" assert count_objects == sum(len(x["DATA"]) for x in MULTIPAGE_SEARCH_RESULTS) # multiple pages of results, very stubby SHARED_ENDPOINT_RESULTS = [ { "next_token": "token1", "shared_endpoints": [{"id": "abcd"} for x in range(1000)], }, { "next_token": "token2", "shared_endpoints": [{"id": "abcd"} for x in range(1000)], }, { "next_token": None, "shared_endpoints": [{"id": "abcd"} for x in range(100)], }, ] def test_shared_endpoint_list_non_paginated(client): # add each page for page in SHARED_ENDPOINT_RESULTS: register_api_route( "transfer", "/endpoint/endpoint_id/shared_endpoint_list", json=page ) # without calling the paginated version, we only get one page res = client.get_shared_endpoint_list("endpoint_id") assert len(list(res)) == 1000 for item in res: assert "id" in item @pytest.mark.parametrize("paging_variant", ["attr", "wrap"]) def test_shared_endpoint_list_iter_pages(client, paging_variant): # add each page for page in SHARED_ENDPOINT_RESULTS: register_api_route( "transfer", "/endpoint/endpoint_id/shared_endpoint_list", json=page ) # paginator pages() call gets an iterator of pages if paging_variant == "attr": paginator = client.paginated.get_shared_endpoint_list("endpoint_id") elif paging_variant == "wrap": paginator = Paginator.wrap(client.get_shared_endpoint_list)("endpoint_id") else: raise NotImplementedError count = 0 for item in paginator.pages(): count += 1 assert "shared_endpoints" in item assert count == 3 @pytest.mark.parametrize("paging_variant", ["attr", "wrap"]) def test_shared_endpoint_list_iter_items(client, paging_variant): # add each page for page in SHARED_ENDPOINT_RESULTS: register_api_route( "transfer", "/endpoint/endpoint_id/shared_endpoint_list", json=page ) # paginator items() call gets an iterator of individual page items if paging_variant == "attr": paginator = client.paginated.get_shared_endpoint_list("endpoint_id") elif paging_variant == "wrap": paginator = Paginator.wrap(client.get_shared_endpoint_list)("endpoint_id") else: raise NotImplementedError count = 0 for item in paginator.items(): count += 1 assert "id" in item assert count == 2100 @pytest.mark.parametrize("paging_variant", ["attr", "wrap"]) def test_task_skipped_errors_pagination(client, paging_variant): task_id = str(uuid.uuid1()) # add each page (10 pages) for page_number in range(10): page_data = [] for item_number in range(100): page_data.append( { "DATA_TYPE": "skipped_error", "checksum_algorithm": None, "destination_path": f"/~/{page_number}-{item_number}.txt", "error_code": "PERMISSION_DENIED", "error_details": "Error bad stuff happened", "error_time": "2022-02-18T19:06:05+00:00", "external_checksum": None, "is_delete_destination_extra": False, "is_directory": False, "is_symlink": False, "source_path": f"/~/{page_number}-{item_number}.txt", } ) register_api_route( "transfer", f"/task/{task_id}/skipped_errors", json={ "DATA_TYPE": "skipped_errors", "next_marker": f"mark{page_number}" if page_number < 9 else None, "DATA": page_data, }, ) # paginator items() call gets an iterator of individual page items if paging_variant == "attr": paginator = client.paginated.task_skipped_errors(task_id) elif paging_variant == "wrap": paginator = Paginator.wrap(client.task_skipped_errors)(task_id) else: raise NotImplementedError count = 0 for item in paginator.items(): count += 1 assert item["DATA_TYPE"] == "skipped_error" assert count == 1000 test_set_subscription_admin_verified.py000066400000000000000000000054501513221403200356530ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transferimport pytest import globus_sdk from globus_sdk.testing import load_response def test_set_subscription_admin_verified(client): meta = load_response(client.set_subscription_admin_verified).metadata epid = meta["endpoint_id"] res = client.set_subscription_admin_verified(epid, True) assert res["code"] == "Updated" assert res["message"] == "Endpoint updated successfully" def test_set_subscription_admin_verified_fails_no_admin_role(client): meta = load_response( client.set_subscription_admin_verified, case="no_admin_role" ).metadata epid = meta["endpoint_id"] with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.set_subscription_admin_verified(epid, True) assert excinfo.value.code == "PermissionDenied" assert len(excinfo.value.messages) == 1 message = excinfo.value.messages[0] assert message == ( "User does not have an admin role on the collection's subscription to set " "subscription_admin_verified" ) def test_set_subscription_admin_verified_fails_non_valid_verified_status(client): meta = load_response( client.set_subscription_admin_verified, case="non_valid_verified_status" ).metadata epid = meta["endpoint_id"] with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.set_subscription_admin_verified(epid, None) assert excinfo.value.code == "BadRequest" assert len(excinfo.value.messages) == 1 message = excinfo.value.messages[0] assert message.startswith("Could not parse JSON: ") def test_set_subscription_admin_verified_fails_non_subscribed_endpoint(client): meta = load_response( client.set_subscription_admin_verified, case="non_subscribed_endpoint" ).metadata epid = meta["endpoint_id"] with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.set_subscription_admin_verified(epid, True) assert excinfo.value.code == "BadRequest" assert len(excinfo.value.messages) == 1 message = excinfo.value.messages[0] assert message == ( "The collection must be associated with a subscription to " "set subscription_admin_verified" ) def test_set_subscription_admin_verified_fails_no_identities_in_session(client): meta = load_response( client.set_subscription_admin_verified, case="no_identities_in_session" ).metadata epid = meta["endpoint_id"] subid = meta["subscription_id"] with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.set_subscription_admin_verified(epid, True) assert excinfo.value.code == "BadRequest" assert len(excinfo.value.messages) == 1 message = excinfo.value.messages[0] assert message == ( "No manager or admin identities in session for high-assurance subscription " f"{subid}" ) globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_set_subscription_id.py000066400000000000000000000024161513221403200333600ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import load_response def test_set_subscription_id(client): meta = load_response(client.set_subscription_id).metadata epid = meta["endpoint_id"] res = client.set_subscription_id(epid, "DEFAULT") assert res["code"] == "Updated" assert res["message"] == "Endpoint updated successfully" def test_set_subscription_id_fails_notfound(client): meta = load_response(client.set_subscription_id, case="not_found").metadata epid = meta["endpoint_id"] with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.set_subscription_id(epid, "DEFAULT") assert excinfo.value.code == "EndpointNotFound" assert excinfo.value.messages == [f"No such endpoint '{epid}'"] def test_set_subscription_id_fails_multi(client): meta = load_response( client.set_subscription_id, case="multi_subscriber_cannot_use_default" ).metadata epid = meta["endpoint_id"] with pytest.raises(globus_sdk.TransferAPIError) as excinfo: client.set_subscription_id(epid, "DEFAULT") assert excinfo.value.code == "BadRequest" assert len(excinfo.value.messages) == 1 message = excinfo.value.messages[0] assert message.startswith("Please specify the subscription ID to use.") globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_simple.py000066400000000000000000000023451513221403200305770ustar00rootroot00000000000000import json import uuid import pytest from globus_sdk.testing import get_last_request, load_response def test_get_endpoint(client): """ Gets endpoint on fixture, validate results """ meta = load_response(client.get_endpoint).metadata epid = meta["endpoint_id"] # load the endpoint document ep_doc = client.get_endpoint(epid) # check that the contents are basically OK assert ep_doc["DATA_TYPE"] == "endpoint" assert ep_doc["id"] == epid assert "display_name" in ep_doc @pytest.mark.parametrize("epid_type", [uuid.UUID, str]) def test_update_endpoint(epid_type, client): meta = load_response(client.update_endpoint).metadata epid = meta["endpoint_id"] # NOTE: pass epid as UUID or str # requires that TransferClient correctly translates UUID update_data = {"display_name": "Updated Name", "description": "Updated description"} update_doc = client.update_endpoint(epid_type(epid), update_data) # make sure response is a successful update assert update_doc["DATA_TYPE"] == "result" assert update_doc["code"] == "Updated" assert update_doc["message"] == "Endpoint updated successfully" req = get_last_request() assert json.loads(req.body) == update_data globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_task_event_list.py000066400000000000000000000001351513221403200324770ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_task_event_list(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_task_list.py000066400000000000000000000034641513221403200313060ustar00rootroot00000000000000import urllib.parse import pytest from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( "client_kwargs, qs", [ ({}, {}), ({"query_params": {"foo": "bar"}}, {"foo": "bar"}), ({"filter": "foo"}, {"filter": "foo"}), ({"limit": 10, "offset": 100}, {"limit": "10", "offset": "100"}), ({"limit": 10, "query_params": {"limit": 100}}, {"limit": "100"}), ({"filter": "foo:bar:baz"}, {"filter": "foo:bar:baz"}), ({"filter": {"foo": "bar", "bar": "baz"}}, {"filter": "foo:bar/bar:baz"}), ({"filter": {"foo": ["bar", "baz"]}}, {"filter": "foo:bar,baz"}), ], ) def test_task_list(client, client_kwargs, qs): load_response(client.task_list) client.task_list(**client_kwargs) req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) # parsed_qs will have each value as a list (because query-params are a multidict) # so transform the test data to match before comparison assert parsed_qs == {k: [v] for k, v in qs.items()} @pytest.mark.parametrize( "orderby_value, expected_orderby_param", [ ("foo", "foo"), (["foo"], "foo"), ("foo,bar", "foo,bar"), ("foo ASC,bar", "foo ASC,bar"), (["foo ASC", "bar"], "foo ASC,bar"), (["foo ASC", "bar DESC"], "foo ASC,bar DESC"), ], ) def test_task_list_orderby_parameter(client, orderby_value, expected_orderby_param): load_response(client.task_list) client.task_list(orderby=orderby_value) req = get_last_request() parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) assert "orderby" in parsed_qs assert len(parsed_qs["orderby"]) == 1 orderby_param = parsed_qs["orderby"][0] assert orderby_param == expected_orderby_param globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_task_pause_info.py000066400000000000000000000001351513221403200324530ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_task_pause_info(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_task_submit.py000066400000000000000000000066531513221403200316410ustar00rootroot00000000000000""" Tests for submitting Transfer and Delete tasks """ import json import pytest from globus_sdk import DeleteData, TransferAPIError, TransferData from globus_sdk.testing import get_last_request, load_response from tests.common import GO_EP1_ID, GO_EP2_ID def test_transfer_submit_failure(client): load_response(client.get_submission_id) meta = load_response(client.submit_transfer, case="failure").metadata with pytest.raises(TransferAPIError) as excinfo: client.submit_transfer(TransferData(GO_EP1_ID, GO_EP2_ID)) assert excinfo.value.http_status == 400 assert excinfo.value.request_id == meta["request_id"] assert excinfo.value.code == "ClientError.BadRequest.NoTransferItems" def test_transfer_submit_success(client): load_response(client.get_submission_id) meta = load_response(client.submit_transfer).metadata tdata = TransferData( GO_EP1_ID, GO_EP2_ID, label="mytask", sync_level="exists", deadline="2018-06-01", source_local_user="my-source-user", destination_local_user="my-dest-user", additional_fields={"custom_param": "foo"}, ) assert tdata["custom_param"] == "foo" assert tdata["sync_level"] == 0 tdata.add_item("/path/to/foo", "/path/to/bar") res = client.submit_transfer(tdata) assert res assert res["submission_id"] == meta["submission_id"] assert res["task_id"] == meta["task_id"] req_body = json.loads(get_last_request().body) assert req_body["source_local_user"] == "my-source-user" assert req_body["destination_local_user"] == "my-dest-user" def test_delete_submit_success(client): load_response(client.get_submission_id) meta = load_response(client.submit_delete).metadata ddata = DeleteData( endpoint=GO_EP1_ID, label="mytask", deadline="2018-06-01", local_user="my-user", additional_fields={"custom_param": "foo"}, ) assert ddata["custom_param"] == "foo" ddata.add_item("/path/to/foo") res = client.submit_delete(ddata) assert res assert res["submission_id"] == meta["submission_id"] assert res["task_id"] == meta["task_id"] req_body = json.loads(get_last_request().body) assert req_body["local_user"] == "my-user" @pytest.mark.parametrize("datatype", ("transfer", "delete")) def test_submit_adds_missing_submission_id_to_data(client, datatype): data = {} meta = load_response(client.get_submission_id).metadata if datatype == "transfer": load_response(client.submit_transfer) client.submit_transfer(data) else: load_response(client.submit_delete) client.submit_delete(data) assert "submission_id" in data assert data["submission_id"] == meta["submission_id"] req_body = json.loads(get_last_request().body) assert req_body == data @pytest.mark.parametrize("datatype", ("transfer", "delete")) def test_submit_does_not_overwrite_existing_submission_id(client, datatype): data = {"submission_id": "foo"} meta = load_response(client.get_submission_id).metadata if datatype == "transfer": load_response(client.submit_transfer) client.submit_transfer(data) else: load_response(client.submit_delete) client.submit_delete(data) assert data["submission_id"] == "foo" assert data["submission_id"] != meta["submission_id"] req_body = json.loads(get_last_request().body) assert req_body == data test_task_successful_transfers.py000066400000000000000000000001471513221403200345150ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/services/transferimport pytest @pytest.mark.xfail def test_task_successful_transfers(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_task_wait.py000066400000000000000000000042101513221403200312650ustar00rootroot00000000000000from unittest import mock import pytest import responses import globus_sdk from globus_sdk.testing import get_last_request from tests.common import register_api_route_fixture_file TASK1_ID = "b8872740-7edc-11ec-9f33-ed182a728dff" @pytest.mark.parametrize( "add_kwargs", [ {"timeout": 0, "polling_interval": 0}, {"timeout": 0.5, "polling_interval": 0.5}, {"timeout": 5, "polling_interval": 0.5}, {"timeout": -5, "polling_interval": 5}, ], ) def test_task_wait_bad_args_min_wait(client, mocksleep, add_kwargs): # register task mock data even though it should not be needed register_api_route_fixture_file( "transfer", f"/task/{TASK1_ID}", "get_task1_active.json" ) with pytest.raises(globus_sdk.GlobusSDKUsageError): client.task_wait(TASK1_ID, **add_kwargs) # no requests sent, no sleep done assert get_last_request() is None mocksleep.assert_not_called() def test_task_wait_success_case(client, mocksleep): # first the task will show as active, then as succeeded register_api_route_fixture_file( "transfer", f"/task/{TASK1_ID}", "get_task1_active.json" ) register_api_route_fixture_file( "transfer", f"/task/{TASK1_ID}", "get_task1_succeeded.json" ) # do the task wait, it should return true (the task completed in time) result = client.task_wait(TASK1_ID, timeout=5, polling_interval=1) assert result is True # one sleep, two network calls mocksleep.assert_called_once_with(1) assert len(responses.calls) == 2 def test_task_wait_unfinished_case(client, mocksleep): # the task is in the active state no matter how many times we ask register_api_route_fixture_file( "transfer", f"/task/{TASK1_ID}", "get_task1_active.json" ) # do the task wait, it should return false (the task didn't complete) result = client.task_wait(TASK1_ID, timeout=5, polling_interval=1) assert result is False # a number of sleeps equal to timeout/polling_interval # a number of requests equal to sleeps+1 mocksleep.assert_has_calls([mock.call(1) for x in range(5)]) assert len(responses.calls) == 6 globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_update_bookmark.py000066400000000000000000000001351513221403200324500ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_update_bookmark(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_update_endpoint_acl_rule.py000066400000000000000000000001461513221403200343330ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_update_endpoint_acl_rule(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_update_task.py000066400000000000000000000001311513221403200316010ustar00rootroot00000000000000import pytest @pytest.mark.xfail def test_update_task(): raise NotImplementedError globus-globus-sdk-python-6a080e4/tests/functional/services/transfer/test_update_tunnel.py000066400000000000000000000011421513221403200321470ustar00rootroot00000000000000import json from globus_sdk.testing import get_last_request, load_response def test_update_tunnel(client): meta = load_response(client.update_tunnel).metadata label = "New Name" update_doc = { "data": { "type": "Tunnel", "attributes": { "label": "New Name", }, } } res = client.update_tunnel(meta["tunnel_id"], update_doc) assert res.http_status == 200 assert res["data"]["type"] == "Tunnel" req = get_last_request() sent = json.loads(req.body) assert sent["data"]["attributes"]["label"] == label globus-globus-sdk-python-6a080e4/tests/functional/testing/000077500000000000000000000000001513221403200236775ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/testing/test_non_default_mock.py000066400000000000000000000023471513221403200306250ustar00rootroot00000000000000""" Test that globus_sdk.testing can accept a non-default requests mock """ import pytest import requests import responses from globus_sdk import GlobusHTTPResponse, GroupsClient from globus_sdk.testing import get_last_request, load_response @pytest.fixture def custom_requests_mock(): responses.stop() with responses.RequestsMock() as m: yield m def test_get_last_request_on_empty_custom_mock_returns_none(custom_requests_mock): assert get_last_request(requests_mock=custom_requests_mock) is None def test_get_last_request_on_custom_mock_populated_via_manual_load( custom_requests_mock, ): custom_requests_mock.add("GET", "https://example.org/", json={"foo": "bar"}) r = requests.get("https://example.org/") assert r.json() == {"foo": "bar"} req = get_last_request(requests_mock=custom_requests_mock) assert req is not None assert req.body is None # "example" assertion about a request def test_register_client_method_and_call_on_custom_mock(custom_requests_mock): gc = GroupsClient() loaded = load_response(gc.get_group, requests_mock=custom_requests_mock) group_id = loaded.metadata["group_id"] data = gc.get_group(group_id) assert isinstance(data, GlobusHTTPResponse) globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/000077500000000000000000000000001513221403200247275ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v1/000077500000000000000000000000001513221403200252555ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v1/conftest.py000066400000000000000000000024561513221403200274630ustar00rootroot00000000000000import time from unittest import mock import pytest @pytest.fixture def mock_response(): res = mock.Mock() expiration_time = int(time.time()) + 3600 res.by_resource_server = { "resource_server_1": { "access_token": "access_token_1", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "bearer", }, "resource_server_2": { "access_token": "access_token_2", "expires_in": expiration_time, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "bearer", }, } return res @pytest.fixture def mock_refresh_response(): res = mock.Mock() expiration_time = int(time.time()) + 3600 res.by_resource_server = { "resource_server_2": { "access_token": "access_token_2_refreshed", "expires_in": expiration_time, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "bearer", } } return res globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v1/test_simplejson_file.py000066400000000000000000000046141513221403200320550ustar00rootroot00000000000000import json import os import pytest from globus_sdk import __version__ from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter IS_WINDOWS = os.name == "nt" @pytest.fixture def json_file(tmp_path): return tmp_path / "mydata.json" def test_file_does_not_exist(json_file): adapter = SimpleJSONFileAdapter(json_file) assert not adapter.file_exists() def test_file_exists(json_file): json_file.touch() adapter = SimpleJSONFileAdapter(json_file) assert adapter.file_exists() def test_store(json_file, mock_response): adapter = SimpleJSONFileAdapter(json_file) assert not adapter.file_exists() adapter.store(mock_response) data = json.loads(json_file.read_text()) assert data["globus-sdk.version"] == __version__ assert data["by_rs"]["resource_server_1"]["access_token"] == "access_token_1" assert data["by_rs"]["resource_server_2"]["access_token"] == "access_token_2" def test_get_token_data(json_file, mock_response): adapter = SimpleJSONFileAdapter(json_file) assert not adapter.file_exists() adapter.store(mock_response) data = adapter.get_token_data("resource_server_1") assert data["access_token"] == "access_token_1" def test_store_and_refresh(json_file, mock_response, mock_refresh_response): adapter = SimpleJSONFileAdapter(json_file) assert not adapter.file_exists() adapter.store(mock_response) # rs1 and rs2 data was stored correctly data = adapter.get_token_data("resource_server_1") assert data["access_token"] == "access_token_1" data = adapter.get_token_data("resource_server_2") assert data["access_token"] == "access_token_2" # "refresh" happens, this should change rs2 but not rs1 adapter.store(mock_refresh_response) data = adapter.get_token_data("resource_server_1") assert data["access_token"] == "access_token_1" data = adapter.get_token_data("resource_server_2") assert data["access_token"] == "access_token_2_refreshed" @pytest.mark.xfail(IS_WINDOWS, reason="cannot set umask perms on Windows") def test_store_perms(json_file, mock_response): adapter = SimpleJSONFileAdapter(json_file) assert not adapter.file_exists() adapter.store(mock_response) # mode|0600 should be 0600 -- meaning that those are the maximal # permissions given st_mode = json_file.stat().st_mode & 0o777 # & 777 to remove extra bits assert st_mode | 0o600 == 0o600 globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v1/test_sqlite.py000066400000000000000000000140751513221403200301760ustar00rootroot00000000000000import pytest from globus_sdk.token_storage.legacy import SQLiteAdapter @pytest.fixture def db_file(tmp_path): return tmp_path / "test.db" MEMORY_DBNAME = ":memory:" @pytest.fixture def adapters_to_close(): data = set() yield data for x in data: x.close() @pytest.fixture def make_adapter(adapters_to_close): def func(*args, **kwargs): ret = SQLiteAdapter(*args, **kwargs) adapters_to_close.add(ret) return ret return func @pytest.mark.parametrize( "success, use_file, kwargs", [ (False, False, {}), (False, False, {"namespace": "foo"}), (True, False, {"dbname": MEMORY_DBNAME}), (True, False, {"dbname": MEMORY_DBNAME, "namespace": "foo"}), (True, True, {}), (True, True, {"namespace": "foo"}), (False, True, {"dbname": MEMORY_DBNAME}), (False, True, {"dbname": MEMORY_DBNAME, "namespace": "foo"}), ], ) def test_constructor(success, use_file, kwargs, db_file, make_adapter): if success: if use_file: make_adapter(db_file, **kwargs) else: make_adapter(**kwargs) else: with pytest.raises(TypeError): if use_file: make_adapter(db_file, **kwargs) else: make_adapter(**kwargs) def test_store_and_retrieve_simple_config(make_adapter): adapter = make_adapter(MEMORY_DBNAME) store_val = {"val1": True, "val2": None, "val3": 1.4} adapter.store_config("myconf", store_val) read_val = adapter.read_config("myconf") assert read_val == store_val assert read_val is not store_val def test_store_and_retrieve(mock_response, make_adapter): adapter = make_adapter(MEMORY_DBNAME) adapter.store(mock_response) data = adapter.get_by_resource_server() assert data == mock_response.by_resource_server def test_on_refresh_and_retrieve(mock_response, make_adapter): """just confirm that the aliasing of these functions does not change anything""" adapter = make_adapter(MEMORY_DBNAME) adapter.on_refresh(mock_response) data = adapter.get_by_resource_server() assert data == mock_response.by_resource_server def test_multiple_adapters_store_and_retrieve(mock_response, db_file, make_adapter): adapter1 = make_adapter(db_file) adapter2 = make_adapter(db_file) adapter1.store(mock_response) data = adapter2.get_by_resource_server() assert data == mock_response.by_resource_server def test_multiple_adapters_store_and_retrieve_different_namespaces( mock_response, db_file, make_adapter ): adapter1 = make_adapter(db_file, namespace="foo") adapter2 = make_adapter(db_file, namespace="bar") adapter1.store(mock_response) data = adapter2.get_by_resource_server() assert data == {} def test_load_missing_config_data(make_adapter): adapter = make_adapter(MEMORY_DBNAME) assert adapter.read_config("foo") is None def test_load_missing_token_data(make_adapter): adapter = make_adapter(MEMORY_DBNAME) assert adapter.get_by_resource_server() == {} assert adapter.get_token_data("resource_server_1") is None def test_remove_tokens(mock_response, make_adapter): adapter = make_adapter(MEMORY_DBNAME) adapter.store(mock_response) removed = adapter.remove_tokens_for_resource_server("resource_server_1") assert removed data = adapter.get_by_resource_server() assert data == { "resource_server_2": mock_response.by_resource_server["resource_server_2"] } removed = adapter.remove_tokens_for_resource_server("resource_server_1") assert not removed def test_remove_config(make_adapter): adapter = make_adapter(MEMORY_DBNAME) store_val = {"val1": True, "val2": None, "val3": 1.4} adapter.store_config("myconf", store_val) adapter.store_config("myconf2", store_val) removed = adapter.remove_config("myconf") assert removed read_val = adapter.read_config("myconf") assert read_val is None read_val = adapter.read_config("myconf2") assert read_val == store_val removed = adapter.remove_config("myconf") assert not removed def test_store_and_refresh(mock_response, mock_refresh_response, make_adapter): adapter = make_adapter(MEMORY_DBNAME) adapter.store(mock_response) # rs1 and rs2 data was stored correctly data = adapter.get_token_data("resource_server_1") assert data["access_token"] == "access_token_1" data = adapter.get_token_data("resource_server_2") assert data["access_token"] == "access_token_2" # "refresh" happens, this should change rs2 but not rs1 adapter.store(mock_refresh_response) data = adapter.get_token_data("resource_server_1") assert data["access_token"] == "access_token_1" data = adapter.get_token_data("resource_server_2") assert data["access_token"] == "access_token_2_refreshed" def test_iter_namespaces(mock_response, db_file, make_adapter): foo_adapter = make_adapter(db_file, namespace="foo") bar_adapter = make_adapter(db_file, namespace="bar") baz_adapter = make_adapter(db_file, namespace="baz") for adapter in [foo_adapter, bar_adapter, baz_adapter]: assert list(adapter.iter_namespaces()) == [] assert list(adapter.iter_namespaces(include_config_namespaces=True)) == [] foo_adapter.store(mock_response) for adapter in [foo_adapter, bar_adapter, baz_adapter]: assert list(adapter.iter_namespaces()) == ["foo"] assert list(adapter.iter_namespaces(include_config_namespaces=True)) == ["foo"] bar_adapter.store(mock_response) for adapter in [foo_adapter, bar_adapter, baz_adapter]: assert set(adapter.iter_namespaces()) == {"foo", "bar"} assert set(adapter.iter_namespaces(include_config_namespaces=True)) == { "foo", "bar", } baz_adapter.store_config("some_conf", {}) for adapter in [foo_adapter, bar_adapter, baz_adapter]: assert set(adapter.iter_namespaces()) == {"foo", "bar"} assert set(adapter.iter_namespaces(include_config_namespaces=True)) == { "foo", "bar", "baz", } globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v2/000077500000000000000000000000001513221403200252565ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v2/conftest.py000066400000000000000000000120471513221403200274610ustar00rootroot00000000000000import time import uuid from unittest import mock import pytest import globus_sdk from globus_sdk.testing import RegisteredResponse from globus_sdk.token_storage import TokenStorageData @pytest.fixture def id_token_sub(): return str(uuid.UUID(int=1)) @pytest.fixture def cc_auth_client(): client = globus_sdk.ConfidentialAppAuthClient("dummy_id", "dummy_secret") with client.retry_config.tune(max_retries=0): yield client @pytest.fixture def mock_token_data_by_resource_server(): expiration_time = int(time.time()) + 3600 ret = { "resource_server_1": TokenStorageData( resource_server="resource_server_1", identity_id="user_id", scope="scope1", access_token="access_token_1", refresh_token="refresh_token_1", expires_at_seconds=expiration_time, token_type="Bearer", ), "resource_server_2": TokenStorageData( resource_server="resource_server_2", identity_id="user_id", scope="scope2 scope2:0 scope2:1", access_token="access_token_2", refresh_token="refresh_token_2", expires_at_seconds=expiration_time, token_type="Bearer", ), } return ret @pytest.fixture def mock_response(): res = mock.Mock() expiration_time = int(time.time()) + 3600 res.by_resource_server = { "resource_server_1": { "access_token": "access_token_1", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "Bearer", }, "resource_server_2": { "access_token": "access_token_2", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "Bearer", }, } res.decode_id_token.return_value = {"sub": "user_id"} return res @pytest.fixture def dependent_token_response(cc_auth_client): expiration_time = int(time.time()) + 3600 RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", json=[ { "access_token": "access_token_1", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "Bearer", }, { "access_token": "access_token_2", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "Bearer", }, ], ).add() return cc_auth_client.oauth2_get_dependent_tokens("dummy_tok") @pytest.fixture def authorization_code_response(cc_auth_client, id_token_sub): cc_auth_client.oauth2_start_flow("https://example.com/redirect-uri", "dummy-scope") expiration_time = int(time.time()) + 3600 RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", json={ "access_token": "access_token_1", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "Bearer", "id_token": "dummy_id_token", "other_tokens": [ { "access_token": "access_token_2", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "Bearer", }, ], }, ).add() # because it's more difficult to mock the full decode_id_token() interaction in # detail, directly mock the result of it to return the desired subject (identity_id) # value response = cc_auth_client.oauth2_exchange_code_for_tokens("dummy_code") with mock.patch.object(response, "decode_id_token", lambda: {"sub": id_token_sub}): yield response @pytest.fixture def refresh_token_response(cc_auth_client): expiration_time = int(time.time()) + 3600 RegisteredResponse( service="auth", path="/v2/oauth2/token", method="POST", json={ "access_token": "access_token_1", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "Bearer", "other_tokens": [], }, ).add() return cc_auth_client.oauth2_refresh_token("dummy_token") globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v2/test_common_tokenstorage.py000066400000000000000000000062711513221403200327520ustar00rootroot00000000000000import pytest from globus_sdk.token_storage import ( JSONTokenStorage, MemoryTokenStorage, SQLiteTokenStorage, ) @pytest.fixture(params=["json", "sqlite", "memory"]) def storage(request, tmp_path): if request.param == "json": file = tmp_path / "mydata.json" yield JSONTokenStorage(file) elif request.param == "sqlite": file = tmp_path / "mydata.db" store = SQLiteTokenStorage(file) yield store store.close() else: yield MemoryTokenStorage() def test_store_authorization_code_response( storage, authorization_code_response, id_token_sub ): storage.store_token_response(authorization_code_response) tok_by_rs = authorization_code_response.by_resource_server stored_data = storage.get_token_data_by_resource_server() for resource_server in ["resource_server_1", "resource_server_2"]: for fieldname in ( "resource_server", "scope", "access_token", "refresh_token", "expires_at_seconds", "token_type", ): assert tok_by_rs[resource_server][fieldname] == getattr( stored_data[resource_server], fieldname ) assert "identity_id" not in tok_by_rs[resource_server] assert stored_data[resource_server].identity_id == id_token_sub def test_store_dependent_token_response(storage, dependent_token_response): """ If a TokenStorage is asked to store dependent token data, it should work and produce identity_id values of None (because there is no id_token to inspect) """ storage.store_token_response(dependent_token_response) dep_tok_by_rs = dependent_token_response.by_resource_server stored_data = storage.get_token_data_by_resource_server() for resource_server in ["resource_server_1", "resource_server_2"]: for fieldname in ( "resource_server", "scope", "access_token", "refresh_token", "expires_at_seconds", "token_type", ): assert dep_tok_by_rs[resource_server][fieldname] == getattr( stored_data[resource_server], fieldname ) assert stored_data[resource_server].identity_id is None assert "identity_id" not in dep_tok_by_rs[resource_server] def test_store_refresh_token_response(storage, refresh_token_response): """ If a TokenStorage is asked to store refresh token data, it should work and produce identity_id values of None (because there is no id_token to inspect) """ storage.store_token_response(refresh_token_response) refresh_tok_by_rs = refresh_token_response.by_resource_server stored_data = storage.get_token_data_by_resource_server() for fieldname in ( "resource_server", "scope", "access_token", "refresh_token", "expires_at_seconds", "token_type", ): assert refresh_tok_by_rs["resource_server_1"][fieldname] == getattr( stored_data["resource_server_1"], fieldname ) assert stored_data["resource_server_1"].identity_id is None assert "identity_id" not in refresh_tok_by_rs["resource_server_1"] globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v2/test_json_tokenstorage.py000066400000000000000000000067321513221403200324350ustar00rootroot00000000000000import json import os import pytest from globus_sdk import __version__ from globus_sdk.token_storage import JSONTokenStorage from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter IS_WINDOWS = os.name == "nt" @pytest.fixture def json_file(tmp_path): return tmp_path / "mydata.json" def test_file_does_not_exist(json_file): adapter = JSONTokenStorage(json_file) assert not adapter.file_exists() def test_file_exists(json_file): json_file.touch() adapter = JSONTokenStorage(json_file) assert adapter.file_exists() def test_store_and_get_token_data_by_resource_server( json_file, mock_token_data_by_resource_server ): adapter = JSONTokenStorage(json_file) assert not adapter.file_exists() adapter.store_token_data_by_resource_server(mock_token_data_by_resource_server) gotten = adapter.get_token_data_by_resource_server() for resource_server in ["resource_server_1", "resource_server_2"]: assert ( mock_token_data_by_resource_server[resource_server].to_dict() == gotten[resource_server].to_dict() ) def test_store_token_response_with_namespace(json_file, mock_response): adapter = JSONTokenStorage(json_file, namespace="foo") assert not adapter.file_exists() adapter.store_token_response(mock_response) data = json.loads(json_file.read_text()) assert data["globus-sdk.version"] == __version__ assert data["data"]["foo"]["resource_server_1"]["access_token"] == "access_token_1" assert data["data"]["foo"]["resource_server_2"]["access_token"] == "access_token_2" def test_get_token_data(json_file, mock_response): adapter = JSONTokenStorage(json_file) assert not adapter.file_exists() adapter.store_token_response(mock_response) assert adapter.get_token_data("resource_server_1").access_token == "access_token_1" def test_remove_token_data(json_file, mock_response): adapter = JSONTokenStorage(json_file) assert not adapter.file_exists() adapter.store_token_response(mock_response) # remove rs1, confirm only rs2 is still available remove_result = adapter.remove_token_data("resource_server_1") assert remove_result is True assert adapter.get_token_data("resource_server_1") is None assert adapter.get_token_data("resource_server_2").access_token == "access_token_2" # confirm unable to re-remove rs1 remove_result = adapter.remove_token_data("resource_server_1") assert remove_result is False @pytest.mark.xfail(IS_WINDOWS, reason="cannot set umask perms on Windows") def test_store_perms(json_file, mock_response): adapter = JSONTokenStorage(json_file) assert not adapter.file_exists() adapter.store_token_response(mock_response) # mode|0600 should be 0600 -- meaning that those are the maximal # permissions given st_mode = json_file.stat().st_mode & 0o777 # & 777 to remove extra bits assert st_mode | 0o600 == 0o600 def test_migrate_from_v1_adapter(json_file, mock_response): # write with a SimpleJSONFileAdapter old_adapter = SimpleJSONFileAdapter(json_file) old_adapter.store(mock_response) # confirm able to read with JSONTokenStorage new_adapter = JSONTokenStorage(json_file) assert ( new_adapter.get_token_data("resource_server_1").access_token == "access_token_1" ) # confirm version is overwritten on next store new_adapter.store_token_response(mock_response) data = json.loads(json_file.read_text()) assert data["format_version"] == "2.0" globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v2/test_memory_tokenstorage.py000066400000000000000000000034171513221403200327710ustar00rootroot00000000000000from globus_sdk.token_storage import MemoryTokenStorage def test_store_and_get_token_data_by_resource_server( mock_token_data_by_resource_server, ): adapter = MemoryTokenStorage() adapter.store_token_data_by_resource_server(mock_token_data_by_resource_server) gotten = adapter.get_token_data_by_resource_server() for resource_server in ["resource_server_1", "resource_server_2"]: assert ( mock_token_data_by_resource_server[resource_server].to_dict() == gotten[resource_server].to_dict() ) def test_store_token_response_with_namespace(mock_response): adapter = MemoryTokenStorage(namespace="foo") adapter.store_token_response(mock_response) assert ( adapter._tokens["foo"]["resource_server_1"]["access_token"] == "access_token_1" ) assert ( adapter._tokens["foo"]["resource_server_2"]["access_token"] == "access_token_2" ) def test_get_token_data(mock_response): adapter = MemoryTokenStorage() adapter.store_token_response(mock_response) assert adapter.get_token_data("resource_server_1").access_token == "access_token_1" assert adapter.get_token_data("resource_server_2").access_token == "access_token_2" def test_remove_token_data(mock_response): adapter = MemoryTokenStorage() adapter.store_token_response(mock_response) # remove rs1, confirm only rs2 is still available remove_result = adapter.remove_token_data("resource_server_1") assert remove_result is True assert adapter.get_token_data("resource_server_1") is None assert adapter.get_token_data("resource_server_2").access_token == "access_token_2" # confirm unable to re-remove rs1 remove_result = adapter.remove_token_data("resource_server_1") assert remove_result is False globus-globus-sdk-python-6a080e4/tests/functional/tokenstorage/v2/test_sqlite_tokenstorage.py000066400000000000000000000077721513221403200327720ustar00rootroot00000000000000import pytest from globus_sdk import exc from globus_sdk.token_storage import SQLiteTokenStorage from globus_sdk.token_storage.legacy import SQLiteAdapter @pytest.fixture def db_file(tmp_path): return tmp_path / "test.db" @pytest.fixture def adapters_to_close(): data = set() yield data for x in data: x.close() @pytest.fixture def make_adapter(adapters_to_close, db_file): def func(*args, **kwargs): if len(args) == 0 and "filepath" not in kwargs: args = (db_file,) ret = SQLiteTokenStorage(*args, **kwargs) adapters_to_close.add(ret) return ret return func @pytest.mark.parametrize("kwargs", [{}, {"namespace": "foo"}]) def test_constructor(kwargs, db_file, make_adapter): make_adapter(db_file, **kwargs) assert db_file.exists() def test_constructor_rejects_memory_db(make_adapter): with pytest.raises( exc.GlobusSDKUsageError, match="SQLiteTokenStorage cannot be used with a ':memory:' database", ): make_adapter(":memory:") def test_store_and_get_token_data_by_resource_server( mock_token_data_by_resource_server, make_adapter ): adapter = make_adapter() adapter.store_token_data_by_resource_server(mock_token_data_by_resource_server) gotten = adapter.get_token_data_by_resource_server() for resource_server in ["resource_server_1", "resource_server_2"]: assert ( mock_token_data_by_resource_server[resource_server].to_dict() == gotten[resource_server].to_dict() ) def test_multiple_adapters_store_and_retrieve(mock_response, db_file, make_adapter): adapter1 = make_adapter(db_file) adapter2 = make_adapter(db_file) adapter1.store_token_response(mock_response) assert adapter2.get_token_data("resource_server_1").access_token == "access_token_1" assert adapter2.get_token_data("resource_server_2").access_token == "access_token_2" def test_multiple_adapters_store_and_retrieve_different_namespaces( mock_response, db_file, make_adapter ): adapter1 = make_adapter(db_file, namespace="foo") adapter2 = make_adapter(db_file, namespace="bar") adapter1.store_token_response(mock_response) data = adapter2.get_token_data_by_resource_server() assert data == {} def test_load_missing_token_data(make_adapter): adapter = make_adapter() assert adapter.get_token_data_by_resource_server() == {} assert adapter.get_token_data("resource_server_1") is None def test_remove_tokens(mock_response, make_adapter): adapter = make_adapter() adapter.store_token_response(mock_response) removed = adapter.remove_token_data("resource_server_1") assert removed assert adapter.get_token_data("resource_server_1") is None assert adapter.get_token_data("resource_server_2").access_token == "access_token_2" removed = adapter.remove_token_data("resource_server_1") assert not removed def test_iter_namespaces(mock_response, db_file, make_adapter): foo_adapter = make_adapter(db_file, namespace="foo") bar_adapter = make_adapter(db_file, namespace="bar") for adapter in [foo_adapter, bar_adapter]: assert list(adapter.iter_namespaces()) == [] foo_adapter.store_token_response(mock_response) for adapter in [foo_adapter, bar_adapter]: assert list(adapter.iter_namespaces()) == ["foo"] bar_adapter.store_token_response(mock_response) for adapter in [foo_adapter, bar_adapter]: assert set(adapter.iter_namespaces()) == {"foo", "bar"} def test_migrate_from_v1_adapter(mock_response, db_file, make_adapter): # store data with SQLiteAdapter old_adapter = SQLiteAdapter(db_file) old_adapter.store(mock_response) old_adapter.close() # retrieve data with SQLiteTokenStorage using the same file new_adapter = make_adapter(db_file) assert ( new_adapter.get_token_data("resource_server_1").access_token == "access_token_1" ) assert ( new_adapter.get_token_data("resource_server_2").access_token == "access_token_2" ) globus-globus-sdk-python-6a080e4/tests/non-pytest/000077500000000000000000000000001513221403200222005ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/lazy-imports/000077500000000000000000000000001513221403200246525ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/lazy-imports/test_for_import_cycles.py000066400000000000000000000035031513221403200320060ustar00rootroot00000000000000""" Import the modules from the lazy import table in "all" possible orders. This ensures that there cannot be any user-triggerable import cycles. Note that testing all permutations is infeasibly expensive. This is kept in the non-pytest dir because it may be written in pytest but it is not part of the normal testsuite. """ import itertools import os import subprocess import sys import pytest import globus_sdk from globus_sdk._internal.lazy_import import find_source_module PYTHON_BINARY = os.environ.get("GLOBUS_TEST_PY", sys.executable) MODULE_NAMES = sorted( { find_source_module("globus_sdk", "__init__.pyi", attr).lstrip(".") for attr in globus_sdk.__all__ if not attr.startswith("_") } ) @pytest.mark.parametrize( "first_module, second_module", itertools.permutations(MODULE_NAMES, 2) ) def test_import_pairwise(first_module, second_module): command = ( f"from globus_sdk.{first_module} import *; " f"from globus_sdk.{second_module} import *" ) proc = subprocess.Popen( f'{PYTHON_BINARY} -c "{command}"', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) status = proc.wait() assert status == 0, str(proc.communicate()) proc.stdout.close() proc.stderr.close() @pytest.mark.parametrize("first_module", MODULE_NAMES) def test_import_all_each_first(first_module): command = f"from globus_sdk.{first_module} import *; " + "; ".join( f"from globus_sdk.{mod} import *" for mod in MODULE_NAMES if mod != first_module ) proc = subprocess.Popen( f'{PYTHON_BINARY} -c "{command}"', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) status = proc.wait() assert status == 0, str(proc.communicate()) proc.stdout.close() proc.stderr.close() test_modules_do_not_require_requests.py000066400000000000000000000034161513221403200347110ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/lazy-imports""" Test that various modules do not rely on `requests` at import-time. This means that these modules are safe to import in highly latency-sensitive applications like globus-cli. """ import os import subprocess import sys import pytest PYTHON_BINARY = os.environ.get("GLOBUS_TEST_PY", sys.executable) @pytest.mark.parametrize( "module_name", ( # most of the SDK should not pull in 'requests', making the parts which do # not handle request sending easy to use without the perf penalty from # requests/urllib3 "authorizers", "config", "gare", "local_endpoint", "login_flows", "paging", "response", "scopes", "token_storage", # the top-level of the 'exc' subpackage (but not necessarily its contents) # should similarly be standalone, for exception handlers "exc", # internal components and utilities are a special case: # failing to ensure that these avoid 'requests' can make it more difficult # to ensure that the main parts (above) do not transitively pick it up "_internal.classprop", "_internal.guards", "_internal.remarshal", "_internal.serializable", "_internal.utils", "_internal.type_definitions", "_missing", ), ) def test_module_does_not_require_requests(module_name): command = ( f"import globus_sdk.{module_name}; " "import sys; assert 'requests' not in sys.modules" ) proc = subprocess.Popen( f'{PYTHON_BINARY} -c "{command}"', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) status = proc.wait() assert status == 0, str(proc.communicate()) proc.stdout.close() proc.stderr.close() globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/000077500000000000000000000000001513221403200256175ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/README.rst000066400000000000000000000006521513221403200273110ustar00rootroot00000000000000Tests Using ``mypy --warn-unused-ignores`` ========================================== These tests confirm that types are correct using ``mypy --warn-unused-ignores``. This strategy is suggested as a lightweight test in the `Python Typing Quality Documentation `_. These tests are run by ``tox -e mypy`` from the repo root. globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/app_scope_requirements.py000066400000000000000000000030021513221403200327400ustar00rootroot00000000000000from types import MappingProxyType from globus_sdk import ClientApp, UserApp my_user_app = UserApp("...", client_id="...") my_client_app = ClientApp("...", client_id="...", client_secret="...") # declare scope data in the form of a subtype of the # `str | Scope | t.Iterable[str | Scope]` (`list[str]`) indexed in a dict, # this is meant to be a subtype of the requirements data accepted by # `GlobusApp.add_scope_requirements` # # this is a regression test for that being annotated as # `dict[str, str | Scope | t.Iterable[str | Scope]]` which will reject the input # type because `dict` is a mutable container, and therefore invariant scopes: dict[str, list[str]] = {"foo": ["bar"]} my_user_app.add_scope_requirements(scopes) my_client_app.add_scope_requirements(scopes) # a mapping proxy is an immutable mapping (proxy) and should be accepted by apps as well # meaning that any mapping is fine, not just `dict` specifically (or MutableMapping) my_user_app.add_scope_requirements(MappingProxyType(scopes)) my_client_app.add_scope_requirements(MappingProxyType(scopes)) # both of the above tests repeated, but now on init my_user_app = UserApp("...", client_id="...", scope_requirements=scopes) my_user_app = UserApp( "...", client_id="...", scope_requirements=MappingProxyType(scopes) ) my_client_app = ClientApp( "...", client_id="...", client_secret="...", scope_requirements=scopes ) my_client_app = ClientApp( "...", client_id="...", client_secret="...", scope_requirements=MappingProxyType(scopes), ) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/auth_client_create_policy.py000066400000000000000000000007501513221403200333740ustar00rootroot00000000000000import globus_sdk ac = globus_sdk.AuthClient() # create new policy with keyword-only args, as supported ac.create_policy( project_id="foo", display_name="My Policy", description="This is a policy", ) # create using positional args (deprecated/unsupported) ac.create_policy( # type: ignore[misc] "foo", True, # type: ignore[arg-type] 101, # type: ignore[arg-type] "My Policy", # type: ignore[arg-type] "This is a policy", # type: ignore[arg-type] ) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/auth_client_initialization.py000066400000000000000000000016261513221403200336040ustar00rootroot00000000000000import globus_sdk # ok usage ac = globus_sdk.AuthClient() nc = globus_sdk.NativeAppAuthClient("foo_client_id") cc = globus_sdk.ConfidentialAppAuthClient("foo_client_id", "foo_client_secret") # base class allows authorizer authorizer = globus_sdk.AccessTokenAuthorizer("dummytoken") ac = globus_sdk.AuthClient(authorizer=authorizer) # subclasses forbid authorizers nc = globus_sdk.NativeAppAuthClient( # type: ignore[call-arg] "foo_client_id", authorizer=authorizer ) cc = globus_sdk.ConfidentialAppAuthClient( # type: ignore[call-arg] "foo_client_id", "foo_client_secret", authorizer=authorizer ) # the login clients allow a client_id kwarg, but AuthClient does not globus_sdk.NativeAppAuthClient(client_id="foo_client_id") globus_sdk.ConfidentialAppAuthClient( client_id="foo_client_id", client_secret="foo_client_secret" ) globus_sdk.AuthClient(client_id="foo_client_id") # type: ignore[call-arg] globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/auth_client_jwk_methods.py000066400000000000000000000004741513221403200330730ustar00rootroot00000000000000import globus_sdk from globus_sdk.services.auth._common import SupportsJWKMethods # setup clients nc = globus_sdk.NativeAppAuthClient("foo_client_id") cc = globus_sdk.ConfidentialAppAuthClient("foo_client_id", "foo_client_secret") # check that each one supports the JWK methods x: SupportsJWKMethods x = nc x = cc globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/base_client_usage.py000066400000000000000000000010401513221403200316200ustar00rootroot00000000000000from __future__ import annotations import globus_sdk # type is (str | None) s: str | None = globus_sdk.BaseClient.resource_server i: int = globus_sdk.BaseClient.resource_server # type: ignore [assignment] # holds on an instance as well c = globus_sdk.BaseClient() s = c.resource_server i = c.resource_server # type: ignore [assignment] # check that data:list warns, but other types are okay r = c.request("POST", "/foo", data="bar") r = c.request("POST", "/foo", data={}) r = c.request("POST", "/foo", data=[]) # type: ignore [arg-type] globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/custom_transport.py000066400000000000000000000005351513221403200316220ustar00rootroot00000000000000from globus_sdk.transport import RequestsTransport # customize the status code tuples # make sure mypy does not reject these changes for updating the tuple sizes class CustomRetryStatusTransport(RequestsTransport): RETRY_AFTER_STATUS_CODES = (503,) TRANSIENT_ERROR_STATUS_CODES = (500,) EXPIRED_AUTHORIZATION_STATUS_CODES = (401, 403) get_authorize_url_supports_session_params.py000066400000000000000000000032651513221403200367400ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-testsimport typing as t import uuid import globus_sdk ac = globus_sdk.AuthLoginClient("someclientid") # base case url: str = ac.oauth2_get_authorize_url() def generate_strs() -> t.Iterator[str]: yield "foo" yield "bar" # with session_required_{identities,single_domain,policies} as strings url = ac.oauth2_get_authorize_url(session_required_identities="foo") url = ac.oauth2_get_authorize_url(session_required_single_domain="foo") url = ac.oauth2_get_authorize_url(session_required_policies="foo") url = ac.oauth2_get_authorize_url(session_message="foo") # or any iterable of strings url = ac.oauth2_get_authorize_url(session_required_identities=generate_strs()) url = ac.oauth2_get_authorize_url(session_required_single_domain=generate_strs()) url = ac.oauth2_get_authorize_url(session_required_policies=generate_strs()) # these two support UUIDs url = ac.oauth2_get_authorize_url(session_required_identities=uuid.uuid4()) url = ac.oauth2_get_authorize_url(session_required_policies=uuid.uuid4()) # now the negative tests, starting with the fact that domain can't take a UUID url = ac.oauth2_get_authorize_url( session_required_single_domain=uuid.uuid4() # type: ignore[arg-type] ) url = ac.oauth2_get_authorize_url( session_message=uuid.uuid4(), # type: ignore[arg-type] ) # integers and other non-str data are not acceptable url = ac.oauth2_get_authorize_url( session_required_identities=1 # type: ignore[arg-type] ) url = ac.oauth2_get_authorize_url( session_required_single_domain=1 # type: ignore[arg-type] ) url = ac.oauth2_get_authorize_url(session_required_policies=1) # type: ignore[arg-type] url = ac.oauth2_get_authorize_url(session_message=1) # type: ignore[arg-type] globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/get_identities.py000066400000000000000000000013071513221403200311720ustar00rootroot00000000000000import uuid from globus_sdk import AuthClient zero_id = uuid.UUID(int=0) # ok usages ac = AuthClient() ac.get_identities(ids="foo") ac.get_identities(ids=zero_id) ac.get_identities(ids=("foo", "bar")) ac.get_identities(ids=(zero_id,)) ac.get_identities(usernames="foo,bar") ac.get_identities(usernames=("foo", "bar")) ac.get_identities(usernames=("foo", "bar"), provision=True) ac.get_identities(usernames=("foo", "bar"), query_params={"provision": False}) # bad usage ac.get_identities(usernames=zero_id) # type: ignore[arg-type] ac.get_identities(usernames=(zero_id,)) # type: ignore[arg-type] # test the response object is iterable res = ac.get_identities(usernames="foo") for x in res: print(x) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/group_helpers.py000066400000000000000000000043451513221403200310550ustar00rootroot00000000000000from globus_sdk import ( BatchMembershipActions, GroupMemberVisibility, GroupPolicies, GroupRole, GroupVisibility, ) act = BatchMembershipActions() act.add_members(["foo"], role=GroupRole.member) act.add_members(["foo"], role=GroupRole.manager) act.add_members(["foo"], role=GroupRole.admin) act.add_members(["foo"], role="member") act.add_members(["foo"], role="manager") act.add_members(["foo"], role="admin") act.add_members(["foo"], role="unknown") # type: ignore[arg-type] GroupPolicies( is_high_assurance=False, group_members_visibility="members", join_requests=True, signup_fields=[], group_visibility=GroupVisibility.authenticated, ) GroupPolicies( is_high_assurance=False, group_members_visibility="members", join_requests=True, signup_fields=[], group_visibility=GroupVisibility.private, ) GroupPolicies( is_high_assurance=False, group_members_visibility="members", join_requests=True, signup_fields=[], group_visibility="authenticated", ) GroupPolicies( is_high_assurance=False, group_members_visibility="members", join_requests=True, signup_fields=[], group_visibility="private", ) GroupPolicies( is_high_assurance=False, group_members_visibility="members", join_requests=True, signup_fields=[], group_visibility="unknown", # type: ignore[arg-type] ) GroupPolicies( is_high_assurance=False, group_visibility="private", join_requests=True, signup_fields=[], group_members_visibility=GroupMemberVisibility.members, ) GroupPolicies( is_high_assurance=False, group_visibility="private", join_requests=True, signup_fields=[], group_members_visibility=GroupMemberVisibility.managers, ) GroupPolicies( is_high_assurance=False, group_visibility="private", join_requests=True, signup_fields=[], group_members_visibility="members", ) GroupPolicies( is_high_assurance=False, group_visibility="private", join_requests=True, signup_fields=[], group_members_visibility="managers", ) GroupPolicies( is_high_assurance=False, group_visibility="private", join_requests=True, signup_fields=[], group_members_visibility="unknown", # type: ignore[arg-type] ) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/identity_map.py000066400000000000000000000011741513221403200306620ustar00rootroot00000000000000# check IdentityMap usages import globus_sdk # create clients for later usage ac = globus_sdk.AuthClient() nc = globus_sdk.NativeAppAuthClient("foo_client_id") cc = globus_sdk.ConfidentialAppAuthClient("foo_client_id", "foo_client_secret") # check init allows the service client but not the login clients im = globus_sdk.IdentityMap(ac) im = globus_sdk.IdentityMap(cc) # type: ignore[arg-type] im = globus_sdk.IdentityMap(nc) # type: ignore[arg-type] # getitem and delitem work, but setitem and contains do not foo = im["foo"] del im["foo"] im["foo"] = "bar" # type: ignore[index] somebool = "foo" in im # type: ignore[operator] globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/lazy_importer.py000066400000000000000000000003611513221403200310710ustar00rootroot00000000000000# test typing interactions with __getattr__-based lazy imports import globus_sdk # ensure that a valid name is treated as valid globus_sdk.TransferClient # but an invalid name is not! globus_sdk.TRansferClient # type: ignore[attr-defined] globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/missing_type.py000066400000000000000000000020221513221403200306770ustar00rootroot00000000000000import typing as t from globus_sdk import MISSING, MissingType # first, the type of `MISSING` must be `MissingType` t.assert_type(MISSING, MissingType) # second, a variable annotated as `int | MissingType` is assignable with `MISSING` x: int | MissingType = MISSING # and `MissingType` is not the same as None, Ellipsis, False, 0, or other weirdness # these error! y: MissingType y = None # type: ignore[assignment] y = Ellipsis # type: ignore[assignment] y = ... # type: ignore[assignment] y = False # type: ignore[assignment] y = 0 # type: ignore[assignment] # given that x is int|MissingType, `x is not MISSING` should narrow to `int` if x is not MISSING: t.assert_type(x, int) else: # don't do this: # t.assert_type(x, MissingType) # although that looks right, `MissingType` != `Literal[MISSING]`, so it fails # (at least on some mypy versions) # instead, confirm that `not isinstance(x, MissingType)` narrows to a Never if not isinstance(x, MissingType): # noqa: SDK003 t.assert_never(x) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/pagination.py000066400000000000000000000021341513221403200303220ustar00rootroot00000000000000import globus_sdk from globus_sdk.paging import Paginator tc = globus_sdk.TransferClient() # return type should be IterableTransferResponse # confirm that it is not altered by the decorator r: globus_sdk.IterableTransferResponse = tc.task_list() i: int = tc.task_list() # type: ignore [assignment] tc.task_list(limit=10, offset=100, filter="hi") tc.task_list(limit="hi") # type: ignore [arg-type] # too many positional args is 'misc' # we also will trigger arg type because `str` does not match the first kwarg (limit) # of type `int` tc.task_list("foo") # type: ignore [misc,arg-type] # the same basic should hold for a Paginator.wrap'ed variant # but the return type should be a paginator of responses paginated_call = Paginator.wrap(tc.task_list) paged_r: Paginator[globus_sdk.IterableTransferResponse] = paginated_call() i = paginated_call() # type: ignore [assignment] paginated_call(limit=10, offset=100, filter="hi") paginated_call(limit="hi") # type: ignore [arg-type] # see note above on non-paginated case for why these ignores are correct paginated_call("foo") # type: ignore [misc,arg-type] globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/responselike_protocol.py000066400000000000000000000024301513221403200326140ustar00rootroot00000000000000# validate that GlobusAPIError and GlobusHTTPResponse both satisfy the SDK's # ResponseLike protocol import sys import typing as t import requests from globus_sdk import GlobusAPIError, GlobusHTTPResponse from globus_sdk._internal.type_definitions import ResponseLike if sys.version_info < (3, 11): from typing_extensions import assert_type else: from typing import assert_type sample_response = requests.Response() def get_length_of_response(r: ResponseLike) -> int: return len(r.binary_content) # confirm this test function is well-declared get_length_of_response("foo") # type: ignore[arg-type] # check an error object err = GlobusAPIError(sample_response) assert_type(err.http_status, int) assert_type(err.http_reason, str) assert_type(err.headers, t.Mapping[str, str]) assert_type(err.content_type, str | None) assert_type(err.text, str) assert_type(err.binary_content, bytes) assert_type(get_length_of_response(err), int) # check an HTTP response object resp = GlobusHTTPResponse(sample_response) assert_type(resp.http_status, int) assert_type(resp.http_reason, str) assert_type(resp.headers, t.Mapping[str, str]) assert_type(resp.content_type, str | None) assert_type(resp.text, str) assert_type(resp.binary_content, bytes) assert_type(get_length_of_response(resp), int) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/scope_collection_type.py000066400000000000000000000122721513221403200325620ustar00rootroot00000000000000import typing as t import globus_sdk from globus_sdk.scopes import Scope, ScopeParser from globus_sdk.services.auth import ( GlobusAuthorizationCodeFlowManager, GlobusNativeAppFlowManager, ) # in tests below, passing `[None]` inline does not result in an arg-type error # but instead in a list-item error because mypy effectively identifies that your # argument type is "correct" (a list or iterable) and that it contains a # bad item (the None value) # # therefore, define the none_list explicitly to control the error behavior better none_list: list[None] = [None] # setup clients for usage below native_client = globus_sdk.NativeAppAuthClient("dummy_client_id") cc_client = globus_sdk.ConfidentialAppAuthClient( "dummy_client_id", "dummy_client_secret" ) # this function should type-check okay def foo(x: str | Scope | t.Iterable[str | Scope]) -> str: return ScopeParser.serialize(x) foo("somestring") foo(["somestring", "otherstring"]) foo(Scope("bar")) foo((Scope("bar"),)) foo({Scope("bar"), "baz"}) # bad usages foo(1) # type: ignore[arg-type] foo((False,)) # type: ignore[arg-type] # now, verify that we can pass scope collections to flow managers GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes="foo", ) GlobusNativeAppFlowManager( native_client, requested_scopes="foo", ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes=("foo", "bar"), ) GlobusNativeAppFlowManager( native_client, requested_scopes=("foo", "bar"), ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes=Scope("foo"), ) GlobusNativeAppFlowManager( native_client, requested_scopes=Scope("foo"), ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes=[Scope("foo")], ) GlobusNativeAppFlowManager( native_client, requested_scopes=[Scope("foo")], ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes=[Scope("foo"), "bar"], ) GlobusNativeAppFlowManager( native_client, requested_scopes=[Scope("foo"), "bar"], ) # bad usages GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes=1, # type: ignore[arg-type] ) GlobusNativeAppFlowManager( native_client, requested_scopes=1, # type: ignore[arg-type] ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", requested_scopes=none_list, # type: ignore[arg-type] ) GlobusNativeAppFlowManager( native_client, requested_scopes=none_list, # type: ignore[arg-type] ) # furthermore, verify that we can pass these collection types to the client classes # which wrap the flow managers # note that oauth2_start_flow allows the scopes as a positional arg native_client.oauth2_start_flow("foo") cc_client.oauth2_start_flow("https://example.org/redirect-uri", "foo") native_client.oauth2_start_flow(requested_scopes="foo") cc_client.oauth2_start_flow("https://example.org/redirect-uri", requested_scopes="foo") native_client.oauth2_start_flow(("foo", "bar")) cc_client.oauth2_start_flow("https://example.org/redirect-uri", ("foo", "bar")) native_client.oauth2_start_flow(requested_scopes=("foo", "bar")) cc_client.oauth2_start_flow( "https://example.org/redirect-uri", requested_scopes=("foo", "bar") ) native_client.oauth2_start_flow(Scope("foo")) cc_client.oauth2_start_flow("https://example.org/redirect-uri", Scope("foo")) native_client.oauth2_start_flow(requested_scopes=Scope("foo")) cc_client.oauth2_start_flow( "https://example.org/redirect-uri", requested_scopes=Scope("foo") ) native_client.oauth2_start_flow([Scope("foo"), "bar"]) cc_client.oauth2_start_flow("https://example.org/redirect-uri", [Scope("foo"), "bar"]) native_client.oauth2_start_flow(requested_scopes=[Scope("foo"), "bar"]) cc_client.oauth2_start_flow( "https://example.org/redirect-uri", requested_scopes=[Scope("foo"), "bar"] ) # bad usages native_client.oauth2_start_flow(1) # type: ignore[arg-type] cc_client.oauth2_start_flow( "https://example.org/redirect-uri", 1, # type: ignore[arg-type] ) native_client.oauth2_start_flow(requested_scopes=1) # type: ignore[arg-type] cc_client.oauth2_start_flow( "https://example.org/redirect-uri", requested_scopes=1, # type: ignore[arg-type] ) # finally, requested_scopes for oauth2_client_credentials_tokens should follow these # same constraints cc_client.oauth2_client_credentials_tokens("foo") cc_client.oauth2_client_credentials_tokens(requested_scopes="foo") cc_client.oauth2_client_credentials_tokens(("foo", "bar")) cc_client.oauth2_client_credentials_tokens(requested_scopes=("foo", "bar")) cc_client.oauth2_client_credentials_tokens(Scope("foo")) cc_client.oauth2_client_credentials_tokens(requested_scopes=Scope("foo")) cc_client.oauth2_client_credentials_tokens([Scope("foo"), "bar"]) cc_client.oauth2_client_credentials_tokens(requested_scopes=[Scope("foo"), "bar"]) cc_client.oauth2_client_credentials_tokens(1) # type: ignore[arg-type] cc_client.oauth2_client_credentials_tokens( requested_scopes=none_list, # type: ignore[arg-type] ) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/specific_flow_scopes.py000066400000000000000000000010521513221403200323570ustar00rootroot00000000000000import typing as t import globus_sdk # test that a SpecificFlowClient allows assignments of scope strings and resource_server # even though the class-level default is a specialized stub object flow_id = "foo" specific_flow_client = globus_sdk.SpecificFlowClient(flow_id) scopes_object = specific_flow_client.scopes t.assert_type(scopes_object, globus_sdk.scopes.SpecificFlowScopes) scope: globus_sdk.Scope = scopes_object.user x: int = scopes_object.user # type: ignore[assignment] resource_server: str = specific_flow_client.scopes.resource_server globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/test_consents_usage.py000066400000000000000000000017161513221403200322550ustar00rootroot00000000000000import uuid from globus_sdk import AuthClient, Scope from globus_sdk.scopes import ScopeParser # setup: get a consent forest ac = AuthClient() identity_id = uuid.uuid1() consents = ac.get_consents(identity_id) consent_forest = consents.to_forest() # create some variant types xfer_str: str = "urn:globus:auth:scope:transfer.api.globus.org:all" strlist: list[str] = [xfer_str] scopelist: list[Scope] = ScopeParser.parse(xfer_str) scopeobj: Scope = Scope.parse(xfer_str) # all should be allowed b: bool b = consent_forest.meets_scope_requirements(xfer_str) b = consent_forest.meets_scope_requirements((xfer_str,)) b = consent_forest.meets_scope_requirements(strlist) b = consent_forest.meets_scope_requirements(scopelist) # and we really are validating the type, since a bool or list[bool] is not allowed consent_forest.meets_scope_requirements(b) # type: ignore[arg-type] blist: list[bool] = [b] consent_forest.meets_scope_requirements(blist) # type: ignore[arg-type] test_flow_activity_notification_policy.py000066400000000000000000000017371513221403200361710ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-testsimport typing as t import globus_sdk specific_flow_client = globus_sdk.SpecificFlowClient("foo") # running a flow without a notification policy is allowed by the types specific_flow_client.run_flow({}) # passing a dict[str, t.Any] is allowed (even though the type cannot guarantee safety) policy_dict: dict[str, t.Any] = {"status": ["INACTIVE"]} specific_flow_client.run_flow({}, activity_notification_policy=policy_dict) # passing the object representation is allowed as well policy_object = globus_sdk.RunActivityNotificationPolicy(status=["FAILED"]) specific_flow_client.run_flow({}, activity_notification_policy=policy_object) # the object representation does not allow bad values in the status field globus_sdk.RunActivityNotificationPolicy(status=["FAILURE"]) # type: ignore[list-item] # passing something of the wrong type (e.g. the status list) is also not allowed specific_flow_client.run_flow( {}, activity_notification_policy=["SUCCEEDED"] # type: ignore[arg-type] ) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/test_guards.py000066400000000000000000000013371513221403200305210ustar00rootroot00000000000000# test that the internal guards module provides valid and well-formed type-guards import typing as t from globus_sdk._internal import guards def get_any() -> t.Any: return 1 x = get_any() t.assert_type(x, t.Any) # test is_list_of if guards.is_list_of(x, str): t.assert_type(x, list[str]) elif guards.is_list_of(x, int): t.assert_type(x, list[int]) # test is_optional if guards.is_optional(x, float): t.assert_type(x, float | None) elif guards.is_optional(x, bytes): t.assert_type(x, bytes | None) # test is_optional_list_of if guards.is_optional_list_of(x, type(None)): t.assert_type(x, list[None] | None) elif guards.is_optional_list_of(x, dict): t.assert_type(x, list[dict[t.Any, t.Any]] | None) globus-globus-sdk-python-6a080e4/tests/non-pytest/mypy-ignore-tests/transfer_data.py000066400000000000000000000014461513221403200310130ustar00rootroot00000000000000import uuid from globus_sdk import TransferData # simple usage, ok TransferData("srcep", "destep") # can set sync level TransferData("srcep", "destep", sync_level=1) TransferData("srcep", "destep", sync_level="exists") # unknown int values are allowed TransferData("srcep", "destep", sync_level=100) # unknown str values are rejected (Literal) TransferData("srcep", "destep", sync_level="sizes") # type: ignore[arg-type] # TransferData.add_filter_rule tdata = TransferData(uuid.UUID(), uuid.UUID()) tdata.add_filter_rule("*.tgz") tdata.add_filter_rule("*.tgz", method="exclude") tdata.add_filter_rule("*.tgz", type="file") # bad values rejected (Literal) tdata.add_filter_rule("*.tgz", type="files") # type: ignore[arg-type] tdata.add_filter_rule("*.tgz", method="occlude") # type: ignore[arg-type] globus-globus-sdk-python-6a080e4/tests/non-pytest/poetry-lock-test/000077500000000000000000000000001513221403200254255ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/non-pytest/poetry-lock-test/.gitignore000066400000000000000000000000141513221403200274100ustar00rootroot00000000000000poetry.lock globus-globus-sdk-python-6a080e4/tests/non-pytest/poetry-lock-test/README000066400000000000000000000004751513221403200263130ustar00rootroot00000000000000Ensure that `poetry install` of the SDK works correctly. This means that `poetry` can parse the version specifiers for all dependencies, including dev extras, correctly. Inspired by #474 ( https://github.com/globus/globus-sdk-python/pull/474 ) This can be run via the tox package checks. Try `tox -e poetry-check` globus-globus-sdk-python-6a080e4/tests/non-pytest/poetry-lock-test/pyproject.toml000066400000000000000000000004521513221403200303420ustar00rootroot00000000000000[tool.poetry] name = "poetry-install-test" version = "0.1.0" description = "" authors = ["Stephen Rosen "] [tool.poetry.dependencies] python = "^3.9" globus-sdk = { path = "../../.." } [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" globus-globus-sdk-python-6a080e4/tests/unit/000077500000000000000000000000001513221403200210375ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/CA-Bundle.cert000066400000000000000000000002041513221403200234040ustar00rootroot00000000000000This file exists to ensure that custom CA bundle files can be used. You can find references to this file by name in the test suite. globus-globus-sdk-python-6a080e4/tests/unit/__init__.py000066400000000000000000000000001513221403200231360ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/authorizers/000077500000000000000000000000001513221403200234165ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/authorizers/test_access_token_authorizer.py000066400000000000000000000010631513221403200317440ustar00rootroot00000000000000import pytest from globus_sdk.authorizers import AccessTokenAuthorizer TOKEN = "DUMMY_TOKEN" @pytest.fixture def authorizer(): return AccessTokenAuthorizer(TOKEN) def test_get_authorization_header(authorizer): """ Get authorization header, confirms expected value """ assert authorizer.get_authorization_header() == "Bearer " + TOKEN def test_handle_missing_authorization(authorizer): """ Confirms that AccessTokenAuthorizer doesn't handle missing authorization """ assert not authorizer.handle_missing_authorization() globus-globus-sdk-python-6a080e4/tests/unit/authorizers/test_basic_authorizer.py000066400000000000000000000027741513221403200303760ustar00rootroot00000000000000import base64 import pytest from globus_sdk.authorizers import BasicAuthorizer USERNAME = "testUser" PASSWORD = "PASSWORD" @pytest.fixture def authorizer(): return BasicAuthorizer(USERNAME, PASSWORD) def test_get_authorization_header(authorizer): """ Gets authorization header, confirms expected value """ header_val = authorizer.get_authorization_header() assert header_val[:6] == "Basic " assert header_val[6:] == "dGVzdFVzZXI6UEFTU1dPUkQ=" decoded = base64.b64decode(header_val[6:].encode("utf-8")).decode("utf-8") assert decoded == f"{USERNAME}:{PASSWORD}" def test_handle_missing_authorization(authorizer): """ Confirms that BasicAuthorizer doesn't handle missing authorization """ assert not authorizer.handle_missing_authorization() @pytest.mark.parametrize( "username, password, encoded_value", [ ("user", "テスト", "dXNlcjrjg4bjgrnjg4g="), ("дум", "pass", "0LTRg9C8OnBhc3M="), ("テスト", "дум", "44OG44K544OIOtC00YPQvA=="), ], ) def test_unicode_handling(username, password, encoded_value): """ With a unicode string for the password, set and verify the Authorization header. """ authorizer = BasicAuthorizer(username, password) header_val = authorizer.get_authorization_header() assert header_val[:6] == "Basic " assert header_val[6:] == encoded_value decoded = base64.b64decode(header_val[6:].encode("utf-8")).decode("utf-8") assert decoded == f"{username}:{password}" globus-globus-sdk-python-6a080e4/tests/unit/authorizers/test_client_credentials_authorizer.py000066400000000000000000000036441513221403200331450ustar00rootroot00000000000000from unittest import mock import pytest from globus_sdk.authorizers import ClientCredentialsAuthorizer from globus_sdk.scopes import Scope ACCESS_TOKEN = "access_token_1" EXPIRES_AT = -1 SCOPES = "scopes" @pytest.fixture def response(): r = mock.Mock() r.by_resource_server = { "rs1": {"expires_at_seconds": -1, "access_token": "access_token_2"} } return r @pytest.fixture def client(response): c = mock.Mock() c.oauth2_client_credentials_tokens = mock.Mock(return_value=response) return c @pytest.fixture def authorizer(client): return ClientCredentialsAuthorizer( client, SCOPES, access_token=ACCESS_TOKEN, expires_at=EXPIRES_AT ) def test_get_token_response(authorizer, response, client): """ Calls _get_token_response, confirms that the mock ConfidentialAppAuthClient is used and the known data was returned. """ res = authorizer._get_token_response() assert res == response client.oauth2_client_credentials_tokens.assert_called_once_with( requested_scopes=SCOPES ) def test_multiple_resource_servers(authorizer, response): """ Sets the mock ConfidentialAppAuthClient to return multiple resource servers. Confirms GlobusError is raised when _extract_token_data is called. """ response.by_resource_server["rs2"] = { "expires_at_seconds": -1, "access_token": "access_token_3", } with pytest.raises(ValueError) as excinfo: authorizer._extract_token_data(response) assert "didn't return exactly one token" in str(excinfo.value) assert SCOPES in str(excinfo.value) def test_can_create_authorizer_from_scope_objects(client): a1 = ClientCredentialsAuthorizer(client, Scope("foo")) assert a1.scopes == "foo" a2 = ClientCredentialsAuthorizer( client, [Scope("foo"), "bar", Scope("baz").with_dependency(Scope("buzz"))] ) assert a2.scopes == "foo bar baz[buzz]" globus-globus-sdk-python-6a080e4/tests/unit/authorizers/test_null_authorizer.py000066400000000000000000000007241513221403200302600ustar00rootroot00000000000000from globus_sdk.authorizers import NullAuthorizer def test_get_authorization_header(): """ Gets authorization header. Confirms None value. """ authorizer = NullAuthorizer() assert authorizer.get_authorization_header() is None def test_handle_missing_authorization(): """ Confirms that NullAuthorizer doesn't handle missing authorization """ authorizer = NullAuthorizer() assert not authorizer.handle_missing_authorization() globus-globus-sdk-python-6a080e4/tests/unit/authorizers/test_refresh_token_authorizer.py000066400000000000000000000055061513221403200321470ustar00rootroot00000000000000from unittest import mock import pytest import globus_sdk REFRESH_TOKEN = "refresh_token_1" ACCESS_TOKEN = "access_token_1" EXPIRES_AT = -1 @pytest.fixture(params=["simple", "with_new_refresh_token"]) def response(request): r = mock.Mock() r.by_resource_server = { "simple": {"rs1": {"expires_at_seconds": -1, "access_token": "access_token_2"}}, "with_new_refresh_token": { "rs1": { "expires_at_seconds": -1, "access_token": "access_token_2", "refresh_token": "refresh_token_2", } }, }[request.param] return r @pytest.fixture def client(response): c = mock.Mock() c.oauth2_refresh_token = mock.Mock(return_value=response) return c @pytest.fixture def authorizer(client): return globus_sdk.RefreshTokenAuthorizer( REFRESH_TOKEN, client, access_token=ACCESS_TOKEN, expires_at=EXPIRES_AT ) def test_get_token_response(authorizer, client, response): """ Calls _get_token_response, confirms that the mock AuthClient is used and the known data was returned. """ # get new_access_token res = authorizer._get_token_response() assert res == response # confirm mock ConfidentialAppAuthClient was used as expected client.oauth2_refresh_token.assert_called_once_with(REFRESH_TOKEN) def test_multiple_resource_servers(authorizer, response): """ Sets the mock client to return multiple resource servers. Confirms GlobusError is raised when _extract_token_data is called. """ response.by_resource_server["rs2"] = { "expires_at_seconds": -1, "access_token": "access_token_3", } with pytest.raises(ValueError) as excinfo: authorizer._extract_token_data(response) assert "didn't return exactly one token" in str(excinfo.value) def test_conditional_refresh_token_update(authorizer, response): """ Call ensure_valid_token (triggering a refresh) Confirm that the authorizer always updates its access token and only updates refresh_token if one was present in the response """ authorizer.ensure_valid_token() # trigger refresh token_data = response.by_resource_server["rs1"] if "refresh_token" in token_data: # if present, confirm refresh token was updated assert authorizer.access_token == "access_token_2" assert authorizer.refresh_token == "refresh_token_2" else: # otherwise, confirm no change assert authorizer.access_token == "access_token_2" assert authorizer.refresh_token == "refresh_token_1" def test_refresh_token_authorizer_rejects_auth_service_client(): with pytest.raises( globus_sdk.GlobusSDKUsageError, match="RefreshTokenAuthorizer requires an AuthLoginClient", ): globus_sdk.RefreshTokenAuthorizer(REFRESH_TOKEN, globus_sdk.AuthClient()) globus-globus-sdk-python-6a080e4/tests/unit/authorizers/test_renewing_authorizer.py000066400000000000000000000131531513221403200311240ustar00rootroot00000000000000import time from unittest import mock import pytest from globus_sdk import exc from globus_sdk.authorizers.renewing import EXPIRES_ADJUST_SECONDS, RenewingAuthorizer class MockRenewer(RenewingAuthorizer): """ Class that implements RenewingAuthorizer so that _get_token_response and _extract_token_data can return known values for testing """ def __init__(self, token_data, **kwargs) -> None: self.token_data = token_data self.token_response = mock.Mock() super().__init__(**kwargs) def _get_token_response(self): return self.token_response def _extract_token_data(self, res): return self.token_data ACCESS_TOKEN = "access_token_1" @pytest.fixture def expires_at(): return int(time.time()) + EXPIRES_ADJUST_SECONDS + 10 @pytest.fixture def token_data(): return { "expires_at_seconds": int(time.time()) + 1000, "access_token": "access_token_2", } @pytest.fixture def on_refresh(): return mock.Mock() @pytest.fixture def authorizer(on_refresh, token_data, expires_at): return MockRenewer( token_data, access_token=ACCESS_TOKEN, expires_at=expires_at, on_refresh=on_refresh, ) @pytest.fixture def expired_authorizer(on_refresh, token_data, expires_at): return MockRenewer( token_data, access_token=ACCESS_TOKEN, expires_at=expires_at - 11, on_refresh=on_refresh, ) def test_init(token_data, expires_at): """ Creating a MockRenewer with partial data (expires_at, access_token) results in errors. Either complete data or no partial data works. """ authorizer = MockRenewer( token_data, access_token=ACCESS_TOKEN, expires_at=expires_at ) assert authorizer.access_token == ACCESS_TOKEN assert authorizer.access_token != token_data["access_token"] # with no args, an automatic "refresh" is triggered on init authorizer = MockRenewer(token_data) assert authorizer.access_token == token_data["access_token"] assert authorizer.expires_at == token_data["expires_at_seconds"] with pytest.raises(exc.GlobusSDKUsageError): MockRenewer(token_data, access_token=ACCESS_TOKEN) with pytest.raises(exc.GlobusSDKUsageError): MockRenewer(token_data, expires_at=expires_at) def test_get_new_access_token(authorizer, token_data, on_refresh): """ Calls get_new_access token, confirms that the mock _get_token_data is used and that the mock on_refresh function is called. """ # take note of original access_token_hash original_hash = authorizer._access_token_hash # get new_access_token authorizer._get_new_access_token() # confirm side effects assert authorizer.access_token == token_data["access_token"] assert authorizer.expires_at == token_data["expires_at_seconds"] assert authorizer._access_token_hash != original_hash on_refresh.assert_called_once() def test_ensure_valid_token_ok(authorizer): """ Confirms nothing is done before the access_token expires, """ authorizer.ensure_valid_token() assert authorizer.access_token == ACCESS_TOKEN def test_ensure_valid_token_expired(expired_authorizer, token_data): """ Confirms a new access_token is gotten after expiration """ expired_authorizer.ensure_valid_token() assert expired_authorizer.access_token == token_data["access_token"] assert expired_authorizer.expires_at == token_data["expires_at_seconds"] def test_ensure_valid_token_no_token(authorizer, token_data): """ Confirms a new access_token is gotten if the old one is set to None """ authorizer.access_token = None authorizer.ensure_valid_token() assert authorizer.access_token == token_data["access_token"] assert authorizer.expires_at == token_data["expires_at_seconds"] def test_ensure_valid_token_no_expiration(authorizer, token_data): """ Confirms a new access_token is gotten if expires_at is set to None """ authorizer.expires_at = None authorizer.ensure_valid_token() assert authorizer.access_token == token_data["access_token"] assert authorizer.expires_at == token_data["expires_at_seconds"] def test_get_authorization_header(authorizer): """ Gets authorization header, confirms expected value """ assert authorizer.get_authorization_header() == "Bearer " + ACCESS_TOKEN def test_get_authorization_header_expired(expired_authorizer, token_data): """ Sets the access_token to be expired, then gets authorization header Confirms header value uses the new access_token. """ assert expired_authorizer.get_authorization_header() == ( "Bearer " + token_data["access_token"] ) def test_get_authorization_header_no_token(authorizer, token_data): """ Sets the access_token to None, then gets authorization header Confirms header value uses the new access_token. """ authorizer.access_token = None assert authorizer.get_authorization_header() == ( "Bearer " + token_data["access_token"] ) def test_get_authorization_header_no_expires(authorizer, token_data): """ Sets expires_at to None, then gets authorization header Confirms header value uses the new access_token. """ authorizer.expires_at = None assert authorizer.get_authorization_header() == ( "Bearer " + token_data["access_token"] ) def test_handle_missing_authorization(authorizer): """ Confirms that RenewingAuthorizers will attempt to fix 401s by treating their existing access_token as expired """ assert authorizer.handle_missing_authorization() assert authorizer.expires_at is None globus-globus-sdk-python-6a080e4/tests/unit/errors/000077500000000000000000000000001513221403200223535ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/errors/test_auth_errors.py000066400000000000000000000036171513221403200263300ustar00rootroot00000000000000import pytest from globus_sdk import AuthAPIError from globus_sdk.testing import construct_error def test_auth_error_get_args_simple(): err = construct_error( error_class=AuthAPIError, http_status=404, body={"detail": "simple auth error message"}, ) req = err._underlying_response.request assert err._get_args() == [ req.method, req.url, None, 404, None, "simple auth error message", ] def test_nested_auth_error_message_and_code(): err = construct_error( error_class=AuthAPIError, http_status=404, body={ "errors": [ {"detail": "nested auth error message", "code": "Auth Error"}, { "title": "some secondary error", "code": "HiddenError", }, ] }, ) assert err.message == "nested auth error message; some secondary error" assert err.code is None @pytest.mark.parametrize( "error_body, expected_error_id", ( ( { "errors": [ {"id": "foo"}, ] }, "foo", ), ( { "errors": [ {"id": "foo"}, {"id": "bar"}, ] }, None, ), ( { "errors": [ {"id": "foo"}, {"id": "foo"}, ] }, "foo", ), ( {"errors": [{"id": "foo"}, {"id": "foo"}, {}]}, "foo", ), ), ) def test_auth_error_parses_error_id(error_body, expected_error_id): err = construct_error( error_class=AuthAPIError, http_status=404, body=error_body, ) assert err.request_id == expected_error_id globus-globus-sdk-python-6a080e4/tests/unit/errors/test_common_functionality.py000066400000000000000000000571251513221403200302360ustar00rootroot00000000000000import itertools import sys import uuid import pytest import requests from globus_sdk import ErrorSubdocument, GlobusAPIError, exc from globus_sdk.testing import construct_error def _strmatch_any_order(inputstr, prefix, midfixes, suffix, sep=", "): # test for string matching, but without insisting on ordering of middle segments assert inputstr in [ prefix + sep.join(m) + suffix for m in itertools.permutations(midfixes) ] def test_raw_json_property(): data = { "code": "Json Error", "errors": [ { "message": "json error message", "title": "json error title", } ], } err = construct_error(body=data, http_status=400) assert err.raw_json == data @pytest.mark.parametrize( "body, response_headers", ( ("text_data", {}), # plain text ("{", {"Content-Type": "application/json"}), # malformed JSON ), ) def test_raw_json_none_on_nonjson_data(body, response_headers): err = construct_error(body=body, response_headers=response_headers, http_status=400) assert err.raw_json is None def test_text_property(): err = construct_error(body="text_data", http_status=400) assert err.text == "text_data" def test_binary_content_property(): body_text = "some data" err = construct_error(body=body_text, http_status=400) assert err.binary_content == body_text.encode("utf-8") # `add_note()` and `__notes__` are new in Python 3.11 @pytest.mark.skipif( sys.version_info < (3, 11), reason="Exception.add_note() is new in Python 3.11" ) def test_notes_are_populated_with_text(): text_body = f"some error: {uuid.uuid4()}" err = construct_error(body=text_body, http_status=400) assert err.text == text_body assert any(text_body in note for note in err.__notes__) @pytest.mark.parametrize( "body, response_headers, http_status, expect_code, expect_message", ( ("text_data", {}, 401, None, "Unauthorized"), # text # JSON with unrecognized contents ( {"foo": "bar"}, {"Content-Type": "application/json"}, 403, None, "Forbidden", ), # JSON with well-known contents ( {"code": "foo", "message": "bar"}, {"Content-Type": "application/json"}, 403, "foo", "bar", ), # non-dict JSON with well-known contents ( "[]", {"Content-Type": "application/json"}, 403, None, "Forbidden", ), # invalid JSON ( "{", {"Content-Type": "application/json"}, 400, None, "Bad Request", ), ), ) def test_get_args(body, response_headers, http_status, expect_code, expect_message): err = construct_error( body=body, response_headers=response_headers, http_status=http_status ) req = err._underlying_response.request assert err._get_args() == [ req.method, req.url, None, http_status, expect_code, expect_message, ] def test_info_is_falsey_on_non_dict_json(): err = construct_error( body="[]", response_headers={"Content-Type": "application/json"}, http_status=400, ) assert bool(err.info.consent_required) is False assert bool(err.info.authorization_parameters) is False assert str(err.info) == "AuthorizationParameterInfo(:)|ConsentRequiredInfo(:)" @pytest.mark.parametrize( "body, is_detected, required_scopes", ( ( {"code": "ConsentRequired", "required_scopes": ["foo", "bar"]}, True, ["foo", "bar"], ), ( {"code": "ConsentRequired", "required_scope": "foo bar"}, True, ["foo bar"], ), ({"code": "ConsentRequired"}, False, None), ({"code": "ConsentRequired", "required_scopes": []}, False, None), ({"code": "ConsentRequired", "required_scopes": ["foo", 123]}, False, None), ({"code": "ConsentRequired", "required_scope": 1}, False, None), ), ) def test_consent_required_info(body, is_detected, required_scopes): err = construct_error(body=body, http_status=403) if is_detected: assert bool(err.info.consent_required) is True assert err.info.consent_required.required_scopes == required_scopes else: assert bool(err.info.consent_required) is False def test_consent_required_info_str(): info = exc.ConsentRequiredInfo( {"code": "ConsentRequired", "required_scopes": ["foo", "bar"]} ) assert str(info) == "ConsentRequiredInfo(required_scopes=['foo', 'bar'])" info = exc.ConsentRequiredInfo({}) assert str(info) == "ConsentRequiredInfo(:)" def test_authz_params_info_containing_session_message(): body = {"authorization_parameters": {"session_message": "foo"}} err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message == "foo" assert err.info.authorization_parameters.session_required_identities is None assert err.info.authorization_parameters.session_required_single_domain is None assert err.info.authorization_parameters.session_required_policies is None print("derk") print(str(err.info.authorization_parameters)) _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=foo", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) def test_authz_params_info_containing_malformed_session_message(): body = {"authorization_parameters": {"session_message": 100}} err = construct_error(error_class=GlobusAPIError, body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message is None assert err.info.authorization_parameters.session_required_identities is None assert err.info.authorization_parameters.session_required_single_domain is None assert err.info.authorization_parameters.session_required_policies is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) def test_authz_params_info_containing_session_required_identities(): body = {"authorization_parameters": {"session_required_identities": ["foo", "bar"]}} err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message is None assert err.info.authorization_parameters.session_required_identities == [ "foo", "bar", ] assert err.info.authorization_parameters.session_required_single_domain is None assert err.info.authorization_parameters.session_required_policies is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=['foo', 'bar']", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) def test_authz_params_info_containing_malformed_session_required_identities(): body = {"authorization_parameters": {"session_required_identities": "foo,bar"}} err = construct_error(error_class=GlobusAPIError, body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message is None assert err.info.authorization_parameters.session_required_identities is None assert err.info.authorization_parameters.session_required_single_domain is None assert err.info.authorization_parameters.session_required_policies is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) def test_authz_params_info_containing_session_required_single_domain(): body = { "authorization_parameters": {"session_required_single_domain": ["foo", "bar"]} } err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message is None assert err.info.authorization_parameters.session_required_identities is None assert err.info.authorization_parameters.session_required_single_domain == [ "foo", "bar", ] assert err.info.authorization_parameters.session_required_policies is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=['foo', 'bar']", "session_required_policies=None", "session_required_mfa=None", ], ")", ) def test_authz_params_info_containing_malformed_session_required_single_domain(): body = {"authorization_parameters": {"session_required_single_domain": "foo,bar"}} err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message is None assert err.info.authorization_parameters.session_required_identities is None assert err.info.authorization_parameters.session_required_single_domain is None assert err.info.authorization_parameters.session_required_policies is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) @pytest.mark.parametrize("policies_value", ["foo,bar", ["foo", "bar"]]) def test_authz_params_info_containing_session_required_policies(policies_value): body = {"authorization_parameters": {"session_required_policies": policies_value}} err = construct_error(error_class=GlobusAPIError, body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_message is None assert err.info.authorization_parameters.session_required_identities is None assert err.info.authorization_parameters.session_required_single_domain is None assert err.info.authorization_parameters.session_required_policies == ["foo", "bar"] _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=['foo', 'bar']", "session_required_mfa=None", ], ")", ) def test_authz_params_info_containing_malformed_session_required_policies(): # confirm that if `session_required_policies` is not str|list[str], # it will parse as `None` body = {"authorization_parameters": {"session_required_policies": {"foo": "bar"}}} err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_required_policies is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) @pytest.mark.parametrize("mfa_value", [True, False]) def test_authz_params_info_containing_session_required_mfa(mfa_value): body = {"authorization_parameters": {"session_required_mfa": mfa_value}} err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_required_mfa is mfa_value _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", f"session_required_mfa={mfa_value}", ], ")", ) def test_authz_params_info_containing_malformed_session_required_mfa(): body = {"authorization_parameters": {"session_required_mfa": "foobarjohn"}} err = construct_error(body=body, http_status=403) assert bool(err.info.authorization_parameters) is True assert err.info.authorization_parameters.session_required_mfa is None _strmatch_any_order( str(err.info.authorization_parameters), "AuthorizationParameterInfo(", [ "session_message=None", "session_required_identities=None", "session_required_single_domain=None", "session_required_policies=None", "session_required_mfa=None", ], ")", ) @pytest.mark.parametrize( "orig, wrap_class", [ (requests.RequestException("exc_message"), exc.NetworkError), (requests.Timeout("timeout_message"), exc.GlobusTimeoutError), ( requests.ConnectTimeout("connect_timeout_message"), exc.GlobusConnectionTimeoutError, ), (requests.ConnectionError("connection_message"), exc.GlobusConnectionError), ], ) def test_requests_err_wrappers(orig, wrap_class): msg = "dummy message" err = wrap_class(msg, orig) assert err.underlying_exception == orig assert str(err) == msg @pytest.mark.parametrize( "orig, conv_class", [ (requests.RequestException("exc_message"), exc.NetworkError), (requests.Timeout("timeout_message"), exc.GlobusTimeoutError), ( requests.ConnectTimeout("connect_timeout_message"), exc.GlobusConnectionTimeoutError, ), (requests.ConnectionError("connection_message"), exc.GlobusConnectionError), ], ) def test_convert_requests_exception(orig, conv_class): conv = exc.convert_request_exception(orig) assert conv.underlying_exception == orig assert isinstance(conv, conv_class) @pytest.mark.parametrize( "status, expect_reason", [ (400, "Bad Request"), (500, "Internal Server Error"), ], ) def test_http_reason_exposure(status, expect_reason): body = {"errors": [{"message": "json error message", "code": "Json Error"}]} err = construct_error(body=body, http_status=status) assert err.http_reason == expect_reason def test_http_header_exposure(make_response): body = {"errors": [{"message": "json error message", "code": "Json Error"}]} err = construct_error( body=body, http_status=400, response_headers={"Content-Type": "application/json", "Spam": "Eggs"}, ) assert err.headers["Spam"] == "Eggs" assert err.headers["Content-Type"] == "application/json" # do not parametrize each of these independently: it would result in hundreds of tests # which are not meaningfully non-overlapping in what they test # instead, iterate through "full variations" to keep the suite faster @pytest.mark.parametrize( "http_method, http_status, error_code, request_url, error_message, authz_scheme", [ ( "POST", 404, "FooError", "https://bogus.example.com/foo", "got a foo error", "bearer", ), ("PATCH", 500, None, "https://bogus.example.org/bar", "", "unknown-token"), ("PUT", 501, None, "https://bogus.example.org/bar", "Not Implemented", None), ], ) def test_error_repr_has_expected_info( http_method, http_status, authz_scheme, request_url, error_code, error_message, ): http_reason = {404: "Not Found", 500: "Server Error", 501: "Not Implemented"}.get( http_status ) body = {"otherfield": "otherdata"} if error_code is not None: body["code"] = error_code if error_message is not None: body["message"] = error_message headers = {"Content-Type": "application/json", "Spam": "Eggs"} request_headers = {} if authz_scheme is not None: request_headers["Authorization"] = f"{authz_scheme} TOKENINFO" # build the response -> error -> error repr err = construct_error( body=body, http_status=http_status, method=http_method, url=request_url, response_headers=headers, request_headers=request_headers, ) stringified = repr(err) # check using substring -- do not check exact format assert http_method in stringified assert request_url in stringified if authz_scheme in GlobusAPIError.RECOGNIZED_AUTHZ_SCHEMES: assert authz_scheme in stringified # confirm that actual tokens don't get into the repr, regardless of authz scheme assert "TOKENINFO" not in stringified assert str(http_status) in stringified if error_code is not None: assert error_code in stringified else: # several things could be 'None', but at least one of them is 'code' assert "None" in stringified if error_message is None: assert "otherdata" in stringified else: assert "otherdata" not in stringified if error_message: assert error_message in stringified else: assert http_reason in stringified @pytest.mark.parametrize( "content_type", ("application/json", "application/unknown+json", "application/vnd.api+json"), ) def test_loads_jsonapi_error_subdocuments(content_type): body = { "errors": [ { "code": "TooShort", "title": "Password data too short", "detail": "password was only 3 chars long, must be at least 8", }, { "code": "MissingSpecial", "title": "Password data missing special chars", "detail": "password must have non-alphanumeric characters", }, { "code": "ContainsCommonDogName", "title": "Password data has a popular dog name", "detail": "password cannot contain 'spot', 'woofy', or 'clifford'", }, ] } err = construct_error( body=body, http_status=422, response_headers={"Content-Type": content_type} ) # code is not taken from any of the subdocuments (inherently too ambiguous) # this holds regardless of which parsing path was taken assert err.code is None # messages can be extracted, and they prefer detail to title assert err.messages == [ "password was only 3 chars long, must be at least 8", "password must have non-alphanumeric characters", "password cannot contain 'spot', 'woofy', or 'clifford'", ] @pytest.mark.parametrize( "content_type", ("application/json", "application/unknown+json", "application/vnd.api+json"), ) def test_loads_jsonapi_error_subdocuments_with_common_code(content_type): body = { "errors": [ { "code": "MissingClass", "title": "Must contain capital letter", "detail": "password must contain at least one capital letter", }, { "code": "MissingClass", "title": "Must contain special chars", "detail": "password must have non-alphanumeric characters", }, ] } err = construct_error( body=body, http_status=422, response_headers={"Content-Type": content_type} ) # code is taken because all subdocuments have the same code assert err.code == "MissingClass" @pytest.mark.parametrize( "content_type", ("application/json", "application/unknown+json", "application/vnd.api+json"), ) def test_loads_jsonapi_error_messages_from_various_fields(content_type): body = { "errors": [ { "message": "invalid password value", }, { "title": "Must contain capital letter", }, { "detail": "password must have non-alphanumeric characters", }, ] } err = construct_error( body=body, http_status=422, response_headers={"Content-Type": content_type} ) # no code was found assert err.code is None # messages are extracted, and they use whichever field is appropriate for # each sub-error # note that 'message' will *not* be extracted if the Content-Type indicated JSON:API # because JSON:API does not define such a field if content_type.endswith("vnd.api+json"): assert err.messages == [ "Must contain capital letter", "password must have non-alphanumeric characters", ] else: assert err.messages == [ "invalid password value", "Must contain capital letter", "password must have non-alphanumeric characters", ] @pytest.mark.parametrize( "error_doc", ( # Type Zero Error Format {"code": "FooCode", "message": "FooMessage"}, # Undefined Error Format {"message": "FooMessage"}, ), ) def test_non_jsonapi_parsing_uses_root_as_errors_array_by_default(error_doc): err = construct_error(body=error_doc, http_status=422) # errors is the doc root wrapped in a list, but converted to a subdocument error assert len(err.errors) == 1 subdoc = err.errors[0] assert isinstance(subdoc, ErrorSubdocument) assert subdoc.raw == error_doc # note that 'message' is supported for error message extraction # vs 'detail' and 'title' for JSON:API data assert subdoc.message == error_doc["message"] @pytest.mark.parametrize( "error_doc", ( # Type Zero Error Format with sub-error data {"code": "FooCode", "message": "FooMessage", "errors": [{"bar": "baz"}]}, # Type Zero Error Format with *empty* sub-error data {"code": "FooCode", "message": "FooMessage", "errors": []}, # Undefined Error Format with sub-error data {"message": "FooMessage", "errors": [{"bar": "baz"}]}, # Undefined Error Format with *empty* sub-error data {"message": "FooMessage", "errors": []}, ), ) def test_non_jsonapi_parsing_uses_errors_array_if_present(error_doc): err = construct_error(body=error_doc, http_status=422) # errors is the 'errors' list converted to error subdocs # first some sanity checks... assert len(err.errors) == len(error_doc["errors"]) assert all(isinstance(subdoc, ErrorSubdocument) for subdoc in err.errors) # ...and then a true equivalence test assert [e.raw for e in err.errors] == error_doc["errors"] globus-globus-sdk-python-6a080e4/tests/unit/errors/test_timers_errors.py000066400000000000000000000023161513221403200266650ustar00rootroot00000000000000from globus_sdk import TimersAPIError from globus_sdk.testing import construct_error def test_timer_error_load_simple(): err = construct_error( error_class=TimersAPIError, body={"error": {"code": "ERROR", "detail": "Request failed", "status": 500}}, http_status=500, ) assert err.code == "ERROR" assert err.message == "Request failed" def test_timer_error_load_nested(): err = construct_error( error_class=TimersAPIError, body={ "detail": [ { "loc": ["body", "start"], "msg": "field required", "type": "value_error.missing", }, { "loc": ["body", "end"], "msg": "field required", "type": "value_error.missing", }, ] }, http_status=422, ) assert err.code is None assert err.message == "field required: body.start; field required: body.end" def test_timer_error_load_unrecognized_format(): err = construct_error(error_class=TimersAPIError, body={}, http_status=400) assert err.code is None assert err.message is None globus-globus-sdk-python-6a080e4/tests/unit/errors/test_transfer_errors.py000066400000000000000000000011161513221403200272030ustar00rootroot00000000000000from globus_sdk import TransferAPIError from globus_sdk.testing import construct_error def test_transfer_response_get_args(): err = construct_error( error_class=TransferAPIError, body={ "message": "transfer error message", "code": "Transfer Error", "request_id": "123", }, http_status=404, ) req = err._underlying_response.request assert err._get_args() == [ req.method, req.url, None, 404, "Transfer Error", "transfer error message", "123", ] globus-globus-sdk-python-6a080e4/tests/unit/globus_app/000077500000000000000000000000001513221403200231725ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/globus_app/test_authorizer_factory.py000066400000000000000000000227221513221403200305330ustar00rootroot00000000000000import time from unittest import mock import pytest from globus_sdk.globus_app.authorizer_factory import ( AccessTokenAuthorizerFactory, ClientCredentialsAuthorizerFactory, RefreshTokenAuthorizerFactory, ) from globus_sdk.token_storage import ( HasRefreshTokensValidator, MemoryTokenStorage, NotExpiredValidator, ) from globus_sdk.token_storage.validating_token_storage import ( ExpiredTokenError, MissingTokenError, ValidatingTokenStorage, ) def make_mock_token_response(token_number=1): ret = mock.Mock() ret.by_resource_server = { "rs1": { "resource_server": "rs1", "scope": "rs1:all", "access_token": f"rs1_access_token_{token_number}", "refresh_token": f"rs1_refresh_token_{token_number}", "expires_at_seconds": int(time.time()) + 3600, "token_type": "Bearer", } } ret.decode_id_token.return_value = {"sub": "dummy_id"} return ret def _make_mem_token_storage(validators=()): return ValidatingTokenStorage(MemoryTokenStorage(), validators=validators) def test_access_token_authorizer_factory(): initial_response = make_mock_token_response() mock_token_storage = _make_mem_token_storage() mock_token_storage.store_token_response(initial_response) factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) # cache is initially empty assert factory._authorizer_cache == {} # calling get_authorizer once creates a new authorizer from underlying storage authorizer = factory.get_authorizer("rs1") assert authorizer.get_authorization_header() == "Bearer rs1_access_token_1" # calling get_authorizer again gets the same cached authorizer authorizer2 = factory.get_authorizer("rs1") assert authorizer is authorizer2 # calling store_token_response_and_clear_cache then get gets a new authorizer new_data = make_mock_token_response(token_number=2) factory.store_token_response_and_clear_cache(new_data) assert factory._authorizer_cache == {} authorizer = factory.get_authorizer("rs1") assert authorizer.get_authorization_header() == "Bearer rs1_access_token_2" def test_access_token_authorizer_factory_no_tokens(): initial_response = make_mock_token_response() mock_token_storage = _make_mem_token_storage() mock_token_storage.store_token_response(initial_response) factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) with pytest.raises(MissingTokenError) as exc: factory.get_authorizer("rs2") assert str(exc.value) == "No token data for rs2" def test_access_token_authorizer_factory_expired_access_token(): initial_response = make_mock_token_response() initial_response.by_resource_server["rs1"]["expires_at_seconds"] = int( time.time() - 3600 ) mock_token_storage = _make_mem_token_storage(validators=(NotExpiredValidator(),)) mock_token_storage.store_token_response(initial_response) factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) with pytest.raises(ExpiredTokenError): factory.get_authorizer("rs1") def test_refresh_token_authorizer_factory(): initial_response = make_mock_token_response() mock_token_storage = _make_mem_token_storage( validators=(HasRefreshTokensValidator(),) ) mock_token_storage.store_token_response(initial_response) refresh_data = make_mock_token_response(token_number=2) mock_auth_login_client = mock.Mock() mock_refresh = mock.Mock() mock_refresh.return_value = refresh_data mock_auth_login_client.oauth2_refresh_token = mock_refresh factory = RefreshTokenAuthorizerFactory( token_storage=mock_token_storage, auth_login_client=mock_auth_login_client, ) # calling get authorizer creates a new authorizer with existing token data authorizer1 = factory.get_authorizer("rs1") assert authorizer1.get_authorization_header() == "Bearer rs1_access_token_1" assert mock_auth_login_client.oauth2_refresh_token.call_count == 0 # standard refresh doesn't change the authorizer authorizer1._get_new_access_token() authorizer2 = factory.get_authorizer("rs1") assert authorizer2 is authorizer1 assert authorizer2.get_authorization_header() == "Bearer rs1_access_token_2" assert mock_auth_login_client.oauth2_refresh_token.call_count == 1 # calling store_token_response_and_clear_cache then get gets a new authorizer factory.store_token_response_and_clear_cache(initial_response) authorizer3 = factory.get_authorizer("rs1") assert authorizer3 is not authorizer1 assert authorizer3.get_authorization_header() == "Bearer rs1_access_token_1" def test_refresh_token_authorizer_factory_expired_access_token(): initial_response = make_mock_token_response() initial_response.by_resource_server["rs1"]["expires_at_seconds"] = int( time.time() - 3600 ) mock_token_storage = _make_mem_token_storage( validators=(HasRefreshTokensValidator(),) ) mock_token_storage.store_token_response(initial_response) refresh_data = make_mock_token_response(token_number=2) mock_auth_login_client = mock.Mock() mock_refresh = mock.Mock() mock_refresh.return_value = refresh_data mock_auth_login_client.oauth2_refresh_token = mock_refresh factory = RefreshTokenAuthorizerFactory( token_storage=mock_token_storage, auth_login_client=mock_auth_login_client, ) # calling get_authorizer automatically causes a refresh call to get an access token authorizer = factory.get_authorizer("rs1") assert authorizer.get_authorization_header() == "Bearer rs1_access_token_2" assert mock_refresh.call_count == 1 def test_refresh_token_authorizer_factory_no_refresh_token(): initial_response = make_mock_token_response() initial_response.by_resource_server["rs1"]["refresh_token"] = None mock_token_storage = _make_mem_token_storage( validators=(HasRefreshTokensValidator(),) ) mock_token_storage.store_token_response(initial_response) factory = RefreshTokenAuthorizerFactory( token_storage=mock_token_storage, auth_login_client=mock.Mock(), ) with pytest.raises(MissingTokenError) as exc: factory.get_authorizer("rs1") assert str(exc.value) == "No refresh_token for rs1" def test_client_credentials_authorizer_factory(): initial_response = make_mock_token_response() mock_token_storage = _make_mem_token_storage() mock_token_storage.store_token_response(initial_response) client_token_data = make_mock_token_response(token_number=2) mock_confidential_client = mock.Mock() mock_client_credentials_tokens = mock.Mock() mock_client_credentials_tokens.return_value = client_token_data mock_confidential_client.oauth2_client_credentials_tokens = ( mock_client_credentials_tokens ) factory = ClientCredentialsAuthorizerFactory( token_storage=mock_token_storage, confidential_client=mock_confidential_client, scope_requirements={"rs1": ["scope1"]}, ) # calling get_authorizer once creates a new authorizer using existing tokens authorizer1 = factory.get_authorizer("rs1") assert authorizer1.get_authorization_header() == "Bearer rs1_access_token_1" assert mock_confidential_client.oauth2_client_credentials_tokens.call_count == 0 # renewing with existing tokens doesn't change the authorizer authorizer1._get_new_access_token() authorizer2 = factory.get_authorizer("rs1") assert authorizer2 is authorizer1 assert authorizer2.get_authorization_header() == "Bearer rs1_access_token_2" assert mock_confidential_client.oauth2_client_credentials_tokens.call_count == 1 # calling store_token_response_and_clear_cache then get gets a new authorizer factory.store_token_response_and_clear_cache(initial_response) authorizer3 = factory.get_authorizer("rs1") assert authorizer3 is not authorizer1 assert authorizer3.get_authorization_header() == "Bearer rs1_access_token_1" def test_client_credentials_authorizer_factory_no_tokens(): mock_token_storage = _make_mem_token_storage() client_token_data = make_mock_token_response() mock_confidential_client = mock.Mock() mock_client_credentials_tokens = mock.Mock() mock_client_credentials_tokens.return_value = client_token_data mock_confidential_client.oauth2_client_credentials_tokens = ( mock_client_credentials_tokens ) factory = ClientCredentialsAuthorizerFactory( token_storage=mock_token_storage, confidential_client=mock_confidential_client, scope_requirements={"rs1": ["scope1"]}, ) # calling get_authorizer once creates a new authorizer automatically making # a client credentials call to get an access token that is then stored authorizer = factory.get_authorizer("rs1") assert authorizer.get_authorization_header() == "Bearer rs1_access_token_1" assert mock_client_credentials_tokens.call_count == 1 def test_client_credentials_authorizer_factory_no_scopes(): mock_token_storage = _make_mem_token_storage() mock_confidential_client = mock.Mock() factory = ClientCredentialsAuthorizerFactory( token_storage=mock_token_storage, confidential_client=mock_confidential_client, scope_requirements={}, ) with pytest.raises(ValueError) as exc: factory.get_authorizer("rs2") assert ( str(exc.value) == "ValidatingTokenStorage has no scope_requirements for resource_server rs2" ) globus-globus-sdk-python-6a080e4/tests/unit/globus_app/test_auto_gare_redrive.py000066400000000000000000000141741513221403200303000ustar00rootroot00000000000000from __future__ import annotations import typing as t import uuid from unittest.mock import MagicMock, Mock import pytest import globus_sdk from globus_sdk import ( GlobusAppConfig, IDTokenDecoder, NativeAppAuthClient, OAuthTokenResponse, TransferClient, UserApp, ) from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows import LoginFlowManager from globus_sdk.scopes import Scope, TransferScopes from globus_sdk.testing import RegisteredResponse from globus_sdk.token_storage import MemoryTokenStorage, TokenStorageData class GlobusAppConfigurator: def __init__( self, starting_tokens: dict[str, list[Scope]] | None = None, login_tokens: dict[str, list[Scope]] | None = None, ) -> None: self.identity_id = str(uuid.uuid4()) self.token_storage = MemoryTokenStorage() if starting_tokens: self.token_storage.store_token_data_by_resource_server( { resource_server: self._generate_token_data(resource_server, scopes) for resource_server, scopes in starting_tokens.items() } ) self._login_tokens: list[TokenStorageData] = [ self._generate_token_data(resource_server, scopes) for resource_server, scopes in login_tokens.items() or {}.items() ] self.login_flow_manager = _FakeLoginFlowManager(self._login_tokens) def _generate_token_data( self, resource_server: str, scopes: list[Scope] ) -> TokenStorageData: return TokenStorageData( identity_id=self.identity_id, resource_server=resource_server, scope=" ".join(str(s) for s in scopes), access_token="generated-access-token", refresh_token="generated-refresh-token", expires_at_seconds=9999999999, token_type="Bearer", ) def config(self, **kwargs: t.Any) -> GlobusAppConfig: decoder = MagicMock(spec=IDTokenDecoder) decoder.decode.return_value = {"sub": self.identity_id} return GlobusAppConfig( login_flow_manager=self.login_flow_manager, id_token_decoder=decoder, token_storage=self.token_storage, **kwargs, ) class _FakeLoginFlowManager(LoginFlowManager): def __init__(self, starting_tokens: list[TokenStorageData]) -> None: super().__init__(login_client=MagicMock(spec=NativeAppAuthClient)) self._by_resource_server = { token.resource_server: token.to_dict() for token in starting_tokens } self.login_count = 0 def run_login_flow( self, auth_parameters: GlobusAuthorizationParameters ) -> OAuthTokenResponse: self.login_count += 1 response = Mock() response.id_token = "abcdefghjiklmnop" response.by_resource_server = self._by_resource_server return response _GET_TASK_SUCCESS_RESPONSE = RegisteredResponse( service="transfer", path="/v0.10/task/foobar", method="GET", json={"task_id": "foobar"}, ) _GET_TASK_GARE_RESPONSE = RegisteredResponse( service="transfer", path="/v0.10/task/foobar", method="GET", status=403, json={ "code": "AuthorizationRequired", "authorization_parameters": {"required_scopes": ["my-cool-new-scope"]}, }, ) def test_app_can_redrive_gares(): """ When enabled: If an app-registered client encounters a GARE-compatible 403 http response, it should "redrive" it by: 1. Running a login flow with the gare-included authorization 2. Re-attempting the original request with the new token(s) """ transfer_rs = TransferClient.resource_server configurator = GlobusAppConfigurator( # Start with a valid Transfer:all token starting_tokens={transfer_rs: [TransferScopes.all]}, # On login, receive both Transfer:all and the gare-required scope login_tokens={transfer_rs: [TransferScopes.all, Scope("my-cool-new-scope")]}, ) config = configurator.config(auto_redrive_gares=True) app = UserApp(client_id="client_id", config=config) transfer = TransferClient(app=app) # Set up first a GARE response, then a successful response (on retry) _GET_TASK_GARE_RESPONSE.add() _GET_TASK_SUCCESS_RESPONSE.add() assert transfer.get_task("foobar").http_status == 200 assert configurator.login_flow_manager.login_count == 1 def test_app_gare_redriving_is_disabled_by_default(): transfer_rs = TransferClient.resource_server configurator = GlobusAppConfigurator( # Start with a valid Transfer:all token starting_tokens={transfer_rs: [TransferScopes.all]}, # On login, receive both Transfer:all and the gare-required scope login_tokens={transfer_rs: [TransferScopes.all, Scope("my-cool-new-scope")]}, ) # Don't override the `auto_redrive_gares` default of False config = configurator.config() app = UserApp(client_id="client_id", config=config) transfer = TransferClient(app=app) # Set up first a GARE response, then a successful response (on retry) _GET_TASK_GARE_RESPONSE.add() _GET_TASK_SUCCESS_RESPONSE.add() with pytest.raises(globus_sdk.GlobusAPIError): transfer.get_task("foobar") assert configurator.login_flow_manager.login_count == 0 def test_app_gare_redrive_only_occurs_once_per_request(): transfer_rs = TransferClient.resource_server configurator = GlobusAppConfigurator( # Start with a valid Transfer:all token starting_tokens={transfer_rs: [TransferScopes.all]}, # On login, receive Transfer:all and a random scope (not required by the gare). login_tokens={transfer_rs: [TransferScopes.all, Scope("my-stupid-new-scope")]}, ) config = configurator.config(auto_redrive_gares=True) app = UserApp(client_id="client_id", config=config) transfer = TransferClient(app=app) # Set up exclusively a GARE response which will retry indefinitely. _GET_TASK_GARE_RESPONSE.add() with pytest.raises(globus_sdk.GlobusAPIError): transfer.get_task("foobar") assert configurator.login_flow_manager.login_count == 1 globus-globus-sdk-python-6a080e4/tests/unit/globus_app/test_client_integration.py000066400000000000000000000261351513221403200304730ustar00rootroot00000000000000import uuid import pytest import globus_sdk from globus_sdk import GlobusApp, GlobusAppConfig, UserApp from globus_sdk.testing import load_response from globus_sdk.token_storage import MemoryTokenStorage @pytest.fixture def app() -> GlobusApp: config = GlobusAppConfig(token_storage=MemoryTokenStorage()) return UserApp("test-app", client_id="client_id", config=config) def test_client_inherits_environment_from_globus_app(): config = GlobusAppConfig(token_storage=MemoryTokenStorage(), environment="sandbox") app = UserApp("test-app", client_id="client_id", config=config) client = globus_sdk.AuthClient(app=app) assert client.environment == "sandbox" def test_client_environment_does_not_match_the_globus_app_environment(): config = GlobusAppConfig(token_storage=MemoryTokenStorage(), environment="sandbox") app = UserApp("test-app", client_id="client_id", config=config) with pytest.raises(globus_sdk.GlobusSDKUsageError) as exc: globus_sdk.AuthClient(app=app, environment="preview") expected = "[Environment Mismatch] AuthClient's environment (preview) does not match the GlobusApp's configured environment (sandbox)." # noqa: E501 assert str(exc.value) == expected def test_transfer_client_default_scopes(app): globus_sdk.TransferClient(app=app) assert [str(s) for s in app.scope_requirements["transfer.api.globus.org"]] == [ "urn:globus:auth:scope:transfer.api.globus.org:all" ] def test_transfer_client_add_app_data_access_scope(app): client = globus_sdk.TransferClient(app=app) collection_id = str(uuid.UUID(int=0)) client.add_app_data_access_scope(collection_id) str_list = [str(s) for s in app.scope_requirements["transfer.api.globus.org"]] expected = f"urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id}/data_access]" # noqa: E501 assert expected in str_list def test_timers_client_add_app_data_access_scope(app): client = globus_sdk.TimersClient(app=app) collection_id = str(uuid.UUID(int=0)) client.add_app_transfer_data_access_scope(collection_id) str_list = [ str(s) for s in app.scope_requirements[globus_sdk.TimersClient.resource_server] ] expected = f"{globus_sdk.TimersClient.scopes.timer}[urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id}/data_access]]" # noqa: E501 assert expected in str_list def test_timers_client_add_app_flow_user_scope(app): client = globus_sdk.TimersClient(app=app) flow_id = str(uuid.UUID(int=0)) client.add_app_flow_user_scope(flow_id) str_list = [ str(s) for s in app.scope_requirements[globus_sdk.TimersClient.resource_server] ] flow_client = globus_sdk.SpecificFlowClient(flow_id, app=app) expected = f"{globus_sdk.TimersClient.scopes.timer}[{flow_client.scopes.user}]" # noqa: E501 assert expected in str_list def test_specific_flow_client_add_app_data_access_scope(app): flow_id = str(uuid.UUID(int=1)) client = globus_sdk.SpecificFlowClient(flow_id, app=app) collection_id = str(uuid.UUID(int=0)) client.add_app_transfer_data_access_scope(collection_id) str_list = [str(s) for s in app.scope_requirements[client.resource_server]] expected = f"{client.scopes.user}[*urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id}/data_access]]" # noqa: E501 assert expected in str_list def test_transfer_client_add_app_data_access_scope_chaining(app): collection_id_1 = str(uuid.UUID(int=1)) collection_id_2 = str(uuid.UUID(int=2)) ( globus_sdk.TransferClient(app=app) .add_app_data_access_scope(collection_id_1) .add_app_data_access_scope(collection_id_2) ) str_list = [str(s) for s in app.scope_requirements["transfer.api.globus.org"]] expected_1 = f"urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id_1}/data_access]" # noqa: E501 expected_2 = f"urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id_2}/data_access]" # noqa: E501 assert expected_1 in str_list assert expected_2 in str_list def test_timers_client_add_app_flow_user_scope_chaining(app): flow_id_1 = str(uuid.UUID(int=1)) flow_id_2 = str(uuid.UUID(int=2)) ( globus_sdk.TimersClient(app=app) .add_app_flow_user_scope(flow_id_1) .add_app_flow_user_scope(flow_id_2) ) str_list = [ str(s) for s in app.scope_requirements[globus_sdk.TimersClient.resource_server] ] flow_client_1 = globus_sdk.SpecificFlowClient(flow_id_1, app=app) expected_1 = f"{globus_sdk.TimersClient.scopes.timer}[{flow_client_1.scopes.user}]" # noqa: E501 flow_client_2 = globus_sdk.SpecificFlowClient(flow_id_2, app=app) expected_2 = f"{globus_sdk.TimersClient.scopes.timer}[{flow_client_2.scopes.user}]" # noqa: E501 assert expected_1 in str_list assert expected_2 in str_list def test_transfer_client_add_app_data_access_scope_in_iterable(app): collection_id_1 = str(uuid.UUID(int=1)) collection_id_2 = str(uuid.UUID(int=2)) globus_sdk.TransferClient(app=app).add_app_data_access_scope( (collection_id_1, collection_id_2) ) expected_1 = f"https://auth.globus.org/scopes/{collection_id_1}/data_access" expected_2 = f"https://auth.globus.org/scopes/{collection_id_2}/data_access" transfer_dependencies = [] for scope in app.scope_requirements["transfer.api.globus.org"]: if scope.scope_string != str(globus_sdk.TransferClient.scopes.all): continue for dep in scope.dependencies: transfer_dependencies.append((dep.scope_string, dep.optional)) assert (expected_1, True) in transfer_dependencies assert (expected_2, True) in transfer_dependencies def test_timers_client_add_app_data_access_scope_in_iterable(app): collection_id_1 = str(uuid.UUID(int=1)) collection_id_2 = str(uuid.UUID(int=2)) globus_sdk.TimersClient(app=app).add_app_transfer_data_access_scope( (collection_id_1, collection_id_2) ) expected_1 = f"https://auth.globus.org/scopes/{collection_id_1}/data_access" expected_2 = f"https://auth.globus.org/scopes/{collection_id_2}/data_access" transfer_dependencies = [] for scope in app.scope_requirements[globus_sdk.TimersClient.resource_server]: if scope.scope_string != str(globus_sdk.TimersClient.scopes.timer): continue for dep in scope.dependencies: if dep.scope_string != str(globus_sdk.TransferClient.scopes.all): continue for subdep in dep.dependencies: transfer_dependencies.append((subdep.scope_string, subdep.optional)) assert (expected_1, True) in transfer_dependencies assert (expected_2, True) in transfer_dependencies def test_timers_client_add_app_flow_user_scope_in_iterable(app): flow_id_1 = str(uuid.UUID(int=1)) flow_id_2 = str(uuid.UUID(int=2)) globus_sdk.TimersClient(app=app).add_app_flow_user_scope((flow_id_1, flow_id_2)) timer_dependencies = [ scope_dep.scope_string for scope in app.scope_requirements[globus_sdk.TimersClient.resource_server] for scope_dep in scope.dependencies ] flow_client_1 = globus_sdk.SpecificFlowClient(flow_id_1, app=app) flow_client_2 = globus_sdk.SpecificFlowClient(flow_id_2, app=app) assert flow_client_1.scopes.user.scope_string in timer_dependencies assert flow_client_2.scopes.user.scope_string in timer_dependencies def test_transfer_client_add_app_data_access_scope_catches_bad_uuid(app): with pytest.raises(ValueError, match="'collection_ids' must be a valid UUID"): globus_sdk.TransferClient(app=app).add_app_data_access_scope("foo") def test_transfer_client_add_app_data_access_scope_catches_bad_uuid_in_iterable(app): collection_id_1 = str(uuid.UUID(int=1)) with pytest.raises(ValueError, match=r"'collection_ids\[1\]' must be a valid UUID"): globus_sdk.TransferClient(app=app).add_app_data_access_scope( [collection_id_1, "foo"] ) def test_timers_client_add_app_data_access_scope_catches_bad_uuid(app): with pytest.raises(ValueError, match="'collection_ids' must be a valid UUID"): globus_sdk.TimersClient(app=app).add_app_transfer_data_access_scope("foo") def test_timers_client_add_app_flow_user_scope_catches_bad_uuid(app): with pytest.raises(ValueError, match="'flow_ids' must be a valid UUID"): globus_sdk.TimersClient(app=app).add_app_flow_user_scope("foo") def test_timers_client_add_app_data_access_scope_catches_bad_uuid_in_iterable(app): collection_id_1 = str(uuid.UUID(int=1)) with pytest.raises(ValueError, match=r"'collection_ids\[1\]' must be a valid UUID"): globus_sdk.TimersClient(app=app).add_app_transfer_data_access_scope( [collection_id_1, "foo"] ) def test_timers_client_add_app_flow_user_scope_catches_bad_uuid_in_iterable(app): flow_id = str(uuid.UUID(int=1)) with pytest.raises(ValueError, match=r"'flow_ids\[1\]' must be a valid UUID"): globus_sdk.TimersClient(app=app).add_app_flow_user_scope([flow_id, "foo"]) def test_auth_client_default_scopes(app): globus_sdk.AuthClient(app=app) str_list = [str(s) for s in app.scope_requirements["auth.globus.org"]] assert "openid" in str_list assert "profile" in str_list assert "email" in str_list def test_groups_client_default_scopes(app): globus_sdk.GroupsClient(app=app) assert [str(s) for s in app.scope_requirements["groups.api.globus.org"]] == [ "urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships" ] def test_search_client_default_scopes(app): globus_sdk.SearchClient(app=app) assert [str(s) for s in app.scope_requirements["search.api.globus.org"]] == [ "urn:globus:auth:scope:search.api.globus.org:search" ] def test_timer_client_default_scopes(app): globus_sdk.TimersClient(app=app) timer_client_id = "524230d7-ea86-4a52-8312-86065a9e0417" str_list = [str(s) for s in app.scope_requirements[timer_client_id]] assert str_list == [f"https://auth.globus.org/scopes/{timer_client_id}/timer"] def test_flows_client_default_scopes(app): globus_sdk.FlowsClient(app=app) flows_client_id = "eec9b274-0c81-4334-bdc2-54e90e689b9a" str_list = [str(s) for s in app.scope_requirements["flows.globus.org"]] assert len(str_list) == 1 assert str_list == [f"https://auth.globus.org/scopes/{flows_client_id}/all"] def test_specific_flow_client_default_scopes(app): globus_sdk.SpecificFlowClient("flow_id", app=app) assert [str(s) for s in app.scope_requirements["flow_id"]] == [ "https://auth.globus.org/scopes/flow_id/flow_flow_id_user" ] def test_gcs_client_default_scopes(app): meta = load_response(globus_sdk.GCSClient.get_gcs_info).metadata endpoint_client_id = meta["endpoint_client_id"] domain_name = meta["domain_name"] globus_sdk.GCSClient(domain_name, app=app) assert [str(s) for s in app.scope_requirements[endpoint_client_id]] == [ f"urn:globus:auth:scope:{endpoint_client_id}:manage_collections" ] globus-globus-sdk-python-6a080e4/tests/unit/globus_app/test_globus_app.py000066400000000000000000000567601513221403200267540ustar00rootroot00000000000000from __future__ import annotations import logging import os import time from unittest import mock import pytest import globus_sdk from globus_sdk import ( AccessTokenAuthorizer, AuthLoginClient, ClientApp, ClientCredentialsAuthorizer, ConfidentialAppAuthClient, GlobusAppConfig, NativeAppAuthClient, RefreshTokenAuthorizer, TransferClient, UserApp, ) from globus_sdk.exc import GlobusSDKUsageError from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.globus_app.authorizer_factory import ( AccessTokenAuthorizerFactory, ClientCredentialsAuthorizerFactory, RefreshTokenAuthorizerFactory, ) from globus_sdk.login_flows import ( CommandLineLoginFlowManager, LocalServerLoginFlowManager, LoginFlowManager, ) from globus_sdk.scopes import AuthScopes, Scope from globus_sdk.testing import load_response from globus_sdk.token_storage import ( HasRefreshTokensValidator, JSONTokenStorage, MemoryTokenStorage, NotExpiredValidator, SQLiteTokenStorage, TokenStorageData, ) def _mock_token_data_by_rs( resource_server: str = "auth.globus.org", scope: str = "openid", refresh_token: str | None = "mock_refresh_token", expiration_delta: int = 300, ): return { resource_server: TokenStorageData( resource_server=resource_server, identity_id="mock_identity_id", scope=scope, access_token="mock_access_token", refresh_token=refresh_token, expires_at_seconds=int(time.time() + expiration_delta), token_type="Bearer", ) } def _mock_input(s): print(s) return "mock_input" def _mock_decode(*args, **kwargs): return {"sub": "user_id"} def test_user_app_native(): client_id = "mock_client_id" user_app = UserApp("test-app", client_id=client_id) assert user_app.app_name == "test-app" assert isinstance(user_app._login_client, NativeAppAuthClient) assert user_app._login_client.app_name == "test-app" assert isinstance(user_app._authorizer_factory, AccessTokenAuthorizerFactory) assert isinstance(user_app._login_flow_manager, CommandLineLoginFlowManager) def test_user_app_login_client(): mock_client = mock.Mock( spec=NativeAppAuthClient, client_id="mock-client_id", base_url="https://auth.globus.org", environment="production", ) user_app = UserApp("test-app", login_client=mock_client) assert user_app.app_name == "test-app" assert user_app._login_client == mock_client assert user_app.client_id == "mock-client_id" def test_user_app_no_client_or_id(): msg = ( "Could not set up a globus login client. One of client_id or login_client is " "required." ) with pytest.raises(GlobusSDKUsageError, match=msg): UserApp("test-app") def test_user_app_both_client_and_id(): msg = "Mutually exclusive parameters: client_id and login_client." with pytest.raises(GlobusSDKUsageError, match=msg): UserApp("test-app", login_client=mock.Mock(), client_id="client_id") def test_user_app_login_client_environment_mismatch(): mock_client = mock.Mock(environment="sandbox") with pytest.raises(GlobusSDKUsageError) as exc: config = GlobusAppConfig(environment="preview") UserApp("test-app", login_client=mock_client, config=config) expected = "[Environment Mismatch] The login_client's environment (sandbox) does not match the GlobusApp's configured environment (preview)." # noqa assert str(exc.value) == expected def test_user_app_default_token_storage(): client_id = "mock_client_id" user_app = UserApp("test-app", client_id=client_id) token_storage = user_app._authorizer_factory.token_storage.token_storage assert isinstance(token_storage, JSONTokenStorage) if os.name == "nt": # on the windows-latest run this was # C:\Users\runneradmin\AppData\Roaming\globus\app\mock_client_id\test-app\tokens.json expected = "\\globus\\app\\mock_client_id\\test-app\\tokens.json" assert token_storage.filepath.endswith(expected) else: expected = "~/.globus/app/mock_client_id/test-app/tokens.json" assert token_storage.filepath == os.path.expanduser(expected) class CustomMemoryTokenStorage(MemoryTokenStorage): pass @pytest.mark.parametrize( "token_storage_value, token_storage_class", ( # Named token storage types ("json", JSONTokenStorage), ("sqlite", SQLiteTokenStorage), ("memory", MemoryTokenStorage), # Custom token storage class (instantiated or class) (CustomMemoryTokenStorage(), CustomMemoryTokenStorage), (CustomMemoryTokenStorage, CustomMemoryTokenStorage), ), ) def test_user_app_token_storage_configuration(token_storage_value, token_storage_class): client_id = "mock_client_id" config = GlobusAppConfig(token_storage=token_storage_value) user_app = UserApp("test-app", client_id=client_id, config=config) if hasattr(user_app._token_storage, "close"): user_app._token_storage.close() # Prevent a ResourceWarning on Python 3.13 assert isinstance(user_app._token_storage, token_storage_class) def test_user_app_registers_openid_scope_implicitly(): client_id = "mock_client_id" user_app = UserApp("test-app", client_id=client_id) assert "auth.globus.org" in user_app.scope_requirements scopes = user_app.scope_requirements["auth.globus.org"] assert "openid" in [str(s) for s in scopes] def test_user_app_with_refresh_tokens_sets_expected_validators(): client_id = "mock_client_id" config = GlobusAppConfig(request_refresh_tokens=True) user_app = UserApp("test-app", client_id=client_id, config=config) validator_types = {type(x) for x in user_app.token_storage.validators} assert HasRefreshTokensValidator in validator_types assert NotExpiredValidator not in validator_types def test_user_app_without_refresh_tokens_sets_expected_validators(): client_id = "mock_client_id" config = GlobusAppConfig(request_refresh_tokens=False) user_app = UserApp("test-app", client_id=client_id, config=config) validator_types = {type(x) for x in user_app.token_storage.validators} assert HasRefreshTokensValidator not in validator_types assert NotExpiredValidator in validator_types class MockLoginFlowManager(LoginFlowManager): def __init__(self, login_client: AuthLoginClient | None = None) -> None: login_client = login_client or mock.Mock(spec=NativeAppAuthClient) super().__init__(login_client) @classmethod def for_globus_app( cls, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig ) -> MockLoginFlowManager: return cls(login_client) def run_login_flow(self, auth_parameters: GlobusAuthorizationParameters): return mock.Mock() @pytest.mark.parametrize( "value,login_flow_manager_class", ( (None, CommandLineLoginFlowManager), ("command-line", CommandLineLoginFlowManager), ("local-server", LocalServerLoginFlowManager), (MockLoginFlowManager(), MockLoginFlowManager), (MockLoginFlowManager, MockLoginFlowManager), ), ) def test_user_app_login_flow_manager_configuration(value, login_flow_manager_class): client_id = "mock_client_id" config = GlobusAppConfig(login_flow_manager=value) user_app = UserApp("test-app", client_id=client_id, config=config) assert isinstance(user_app._login_flow_manager, login_flow_manager_class) def test_user_app_templated(): client_id = "mock_client_id" client_secret = "mock_client_secret" config = GlobusAppConfig(login_redirect_uri="https://example.com") user_app = UserApp( "test-app", client_id=client_id, client_secret=client_secret, config=config ) assert user_app.app_name == "test-app" assert isinstance(user_app._login_client, ConfidentialAppAuthClient) assert user_app._login_client.app_name == "test-app" assert isinstance(user_app._authorizer_factory, AccessTokenAuthorizerFactory) assert isinstance(user_app._login_flow_manager, CommandLineLoginFlowManager) def test_user_app_refresh(): client_id = "mock_client_id" config = GlobusAppConfig(request_refresh_tokens=True) user_app = UserApp("test-app", client_id=client_id, config=config) assert user_app.app_name == "test-app" assert isinstance(user_app._login_client, NativeAppAuthClient) assert user_app._login_client.app_name == "test-app" assert isinstance(user_app._authorizer_factory, RefreshTokenAuthorizerFactory) def test_client_app(): client_id = "mock_client_id" client_secret = "mock_client_secret" client_app = ClientApp("test-app", client_id=client_id, client_secret=client_secret) assert client_app.app_name == "test-app" assert isinstance(client_app._login_client, ConfidentialAppAuthClient) assert client_app._login_client.app_name == "test-app" assert isinstance( client_app._authorizer_factory, ClientCredentialsAuthorizerFactory ) def test_client_app_no_secret(): client_id = "mock_client_id" msg = "A ClientApp requires a client_secret to initialize its own login client" with pytest.raises(GlobusSDKUsageError, match=msg): ClientApp("test-app", client_id=client_id) def test_add_scope_requirements_and_auth_params_with_required_scopes(): client_id = "mock_client_id" user_app = UserApp("test-app", client_id=client_id) # default without adding requirements is just auth's openid scope params = user_app._auth_params_with_required_scopes() assert params.required_scopes == ["openid"] # re-adding openid alongside other auth scopes, openid shouldn't be duplicated user_app.add_scope_requirements( {"auth.globus.org": [Scope("openid"), Scope("email"), Scope("profile")]} ) params = user_app._auth_params_with_required_scopes() assert sorted(params.required_scopes) == ["email", "openid", "profile"] # adding a requirement with a dependency user_app.add_scope_requirements( {"foo": [Scope("foo:all").with_dependency(Scope("bar:all"))]} ) params = user_app._auth_params_with_required_scopes() assert sorted(params.required_scopes) == [ "email", "foo:all[bar:all]", "openid", "profile", ] # re-adding a requirement with a new dependency, dependencies should be combined user_app.add_scope_requirements( {"foo": [Scope("foo:all").with_dependency(Scope("baz:all"))]} ) params = user_app._auth_params_with_required_scopes() # order of dependencies is not guaranteed assert sorted(params.required_scopes) in ( ["email", "foo:all[bar:all baz:all]", "openid", "profile"], ["email", "foo:all[baz:all bar:all]", "openid", "profile"], ) @pytest.mark.parametrize( "scope_collection", ("email", AuthScopes.email, Scope("email"), [Scope("email")]), ) def test_add_scope_requirements_accepts_different_scope_types(scope_collection): client_id = "mock_client_id" user_app = UserApp("test-app", client_id=client_id) assert _sorted_auth_scope_str(user_app) == "openid" # Add a scope scope string user_app.add_scope_requirements({"auth.globus.org": scope_collection}) assert _sorted_auth_scope_str(user_app) == "email openid" @pytest.mark.parametrize( "scope_collection", ("email", AuthScopes.email, Scope("email"), [Scope("email")]), ) def test_constructor_scope_requirements_accepts_different_scope_types(scope_collection): client_id = "mock_client_id" user_app = UserApp( "test-app", client_id=client_id, scope_requirements={"auth.globus.org": scope_collection}, ) assert _sorted_auth_scope_str(user_app) == "email openid" def test_scope_requirements_returns_copies_scopes(): user_app = UserApp("test-app", client_id="mock_client_id") foo_scope = Scope("foo:all").with_dependency(Scope("bar:all")) user_app.add_scope_requirements({"foo": [foo_scope]}) real_requirements = user_app._scope_requirements real_openid = real_requirements["auth.globus.org"][0] real_foo = real_requirements["foo"][0] copied_requirements = user_app.scope_requirements copied_openid = copied_requirements["auth.globus.org"][0] copied_foo = copied_requirements["foo"][0] assert real_requirements is not copied_requirements # Copied requirements mirror the originals but are distinct objects. assert real_openid is not copied_openid assert real_foo is not copied_foo assert str(real_openid) == str(copied_openid) assert str(real_foo) == str(copied_foo) def _sorted_auth_scope_str(user_app: UserApp) -> str: scope_list = user_app.scope_requirements["auth.globus.org"] return " ".join(sorted(str(scope) for scope in scope_list)) def test_user_app_get_authorizer(): client_id = "mock_client_id" memory_storage = MemoryTokenStorage() memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage) user_app = UserApp("test-app", client_id=client_id, config=config) authorizer = user_app.get_authorizer("auth.globus.org") assert isinstance(authorizer, AccessTokenAuthorizer) assert authorizer.access_token == "mock_access_token" def test_user_app_get_authorizer_clears_cache_when_adding_scope_requirements(): client_id = "mock_client_id" memory_storage = MemoryTokenStorage() memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage) user_app = UserApp("test-app", client_id=client_id, config=config) initial_authorizer = user_app.get_authorizer("auth.globus.org") assert isinstance(initial_authorizer, AccessTokenAuthorizer) assert initial_authorizer.access_token == "mock_access_token" # We should've cached the authorizer from the first call assert user_app.get_authorizer("auth.globus.org") is initial_authorizer user_app.add_scope_requirements({"auth.globus.org": [Scope("openid")]}) # The cache should've been cleared updated_authorizer = user_app.get_authorizer("auth.globus.org") assert initial_authorizer is not updated_authorizer assert isinstance(updated_authorizer, AccessTokenAuthorizer) assert updated_authorizer.access_token == "mock_access_token" assert user_app.get_authorizer("auth.globus.org") is updated_authorizer def test_user_app_get_authorizer_refresh(): client_id = "mock_client_id" memory_storage = MemoryTokenStorage() memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage, request_refresh_tokens=True) user_app = UserApp("test-app", client_id=client_id, config=config) authorizer = user_app.get_authorizer("auth.globus.org") assert isinstance(authorizer, RefreshTokenAuthorizer) assert authorizer.refresh_token == "mock_refresh_token" class CustomExitException(Exception): pass class RaisingLoginFlowManagerCounter(LoginFlowManager): """ A login flow manager which increments a public counter and raises an exception on each login attempt. """ def __init__(self) -> None: super().__init__(mock.Mock(spec=NativeAppAuthClient)) self.counter = 0 def run_login_flow( self, auth_parameters: GlobusAuthorizationParameters ) -> globus_sdk.OAuthTokenResponse: self.counter += 1 raise CustomExitException("mock login attempt") def test_user_app_expired_token_triggers_login(): # Set up token data with an expired access token and no refresh token client_id = "mock_client_id" memory_storage = MemoryTokenStorage() token_data = _mock_token_data_by_rs( refresh_token=None, expiration_delta=-3600, # Expired by 1 hour ) memory_storage.store_token_data_by_resource_server(token_data) login_flow_manager = RaisingLoginFlowManagerCounter() config = GlobusAppConfig( token_storage=memory_storage, login_flow_manager=login_flow_manager ) user_app = UserApp("test-app", client_id=client_id, config=config) with pytest.raises(CustomExitException): user_app.get_authorizer("auth.globus.org") assert login_flow_manager.counter == 1 def test_client_app_expired_token_is_auto_resolved(): """ This test exercises ClientApp token grant behavior. ClientApps may request updated tokens outside the normal token authorization flow. """ client_creds = { "client_id": "mock_client_id", "client_secret": "mock_client_secret", } meta = load_response("auth.oauth2_client_credentials_tokens").metadata memory_storage = MemoryTokenStorage() token_data = _mock_token_data_by_rs( resource_server=meta["resource_server"], scope=meta["scope"], refresh_token=None, expiration_delta=-3600, # Expired by 1 hour ) memory_storage.store_token_data_by_resource_server(token_data) config = GlobusAppConfig(token_storage=memory_storage) client_app = ClientApp("test-app", **client_creds, config=config) transfer = TransferClient(app=client_app, app_scopes=[Scope(meta["scope"])]) load_response(transfer.task_list) starting_token = memory_storage.get_token_data(meta["resource_server"]).access_token assert starting_token == token_data[meta["resource_server"]].access_token transfer.task_list() ending_token = memory_storage.get_token_data(meta["resource_server"]).access_token assert starting_token != ending_token assert ending_token == meta["access_token"] def test_client_app_get_authorizer(): client_id = "mock_client_id" client_secret = "mock_client_secret" memory_storage = MemoryTokenStorage() memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage) client_app = ClientApp( "test-app", client_id=client_id, client_secret=client_secret, config=config ) authorizer = client_app.get_authorizer("auth.globus.org") assert isinstance(authorizer, ClientCredentialsAuthorizer) assert authorizer.confidential_client.client_id == "mock_client_id" @mock.patch.object(globus_sdk.IDTokenDecoder, "decode", _mock_decode) def test_user_app_login_logout(monkeypatch, capsys): monkeypatch.setattr("builtins.input", _mock_input) load_response(NativeAppAuthClient.oauth2_exchange_code_for_tokens, case="openid") load_response(NativeAppAuthClient.oauth2_revoke_token) client_id = "mock_client_id" memory_storage = MemoryTokenStorage() config = GlobusAppConfig(token_storage=memory_storage) user_app = UserApp("test-app", client_id=client_id, config=config) assert memory_storage.get_token_data("auth.globus.org") is None assert user_app.login_required() is True user_app.login() assert memory_storage.get_token_data("auth.globus.org").access_token is not None assert user_app.login_required() is False user_app.logout() assert memory_storage.get_token_data("auth.globus.org") is None assert user_app.login_required() is True @mock.patch.object(globus_sdk.IDTokenDecoder, "decode", _mock_decode) def test_client_app_login_logout(): load_response( ConfidentialAppAuthClient.oauth2_client_credentials_tokens, case="openid" ) load_response(ConfidentialAppAuthClient.oauth2_revoke_token) client_id = "mock_client_id" client_secret = "mock_client_secret" memory_storage = MemoryTokenStorage() config = GlobusAppConfig(token_storage=memory_storage) client_app = ClientApp( "test-app", client_id=client_id, client_secret=client_secret, config=config ) assert memory_storage.get_token_data("auth.globus.org") is None client_app.login() assert memory_storage.get_token_data("auth.globus.org").access_token is not None client_app.logout() assert memory_storage.get_token_data("auth.globus.org") is None @mock.patch.object(globus_sdk.IDTokenDecoder, "decode", _mock_decode) @pytest.mark.parametrize( "login_kwargs,expected_login", ( # No params - no additional login ({}, False), # "force" or "auth_params" - additional login ({"force": True}, True), ( {"auth_params": GlobusAuthorizationParameters(session_required_mfa=True)}, True, ), ), ) def test_app_login_flows_can_be_forced(login_kwargs, expected_login, monkeypatch): monkeypatch.setattr("builtins.input", _mock_input) load_response(NativeAppAuthClient.oauth2_exchange_code_for_tokens, case="openid") config = GlobusAppConfig( token_storage="memory", login_flow_manager=CountingCommandLineLoginFlowManager, ) user_app = UserApp("test-app", client_id="mock_client_id", config=config) user_app.login() assert user_app.login_required() is False assert user_app._login_flow_manager.counter == 1 user_app.login(**login_kwargs) expected_count = 2 if expected_login else 1 assert user_app._login_flow_manager.counter == expected_count class CountingCommandLineLoginFlowManager(CommandLineLoginFlowManager): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.counter = 0 def run_login_flow( self, auth_parameters: GlobusAuthorizationParameters, ) -> globus_sdk.OAuthTokenResponse: self.counter += 1 return super().run_login_flow(auth_parameters) @pytest.mark.parametrize("config", (None, GlobusAppConfig(token_storage="memory"))) def test_closing_app_closes_implicitly_created_token_storage(config): if config is None: user_app = UserApp("test-app", client_id="mock_client_id") else: user_app = UserApp("test-app", client_id="mock_client_id", config=config) with mock.patch.object(user_app.token_storage, "close") as token_storage_close: user_app.close() token_storage_close.assert_called_once() user_app.token_storage.close() # cleanup def test_closing_app_closes_implicitly_created_login_client(): user_app = UserApp("test-app", client_id="mock_client_id") with mock.patch.object(user_app._login_client, "close") as login_client_close: user_app.close() login_client_close.assert_called_once() user_app._login_client.close() # cleanup def test_closing_app_does_not_close_explicitly_passed_token_storage(): user_app = UserApp( "test-app", client_id="mock_client_id", config=GlobusAppConfig(token_storage=MemoryTokenStorage()), ) with mock.patch.object(user_app.token_storage, "close") as token_storage_close: user_app.close() token_storage_close.assert_not_called() def test_app_context_manager_exit_calls_close(): with mock.patch.object(UserApp, "close") as app_close_method: with UserApp("test-app", client_id="mock_client_id"): app_close_method.assert_not_called() app_close_method.assert_called_once() def test_app_close_debug_logs_closure_of_resources(caplog): """Debug logs show both of the internal clients, plus the token storage""" caplog.set_level(logging.DEBUG) user_app = UserApp("test-app", client_id="mock_client_id") user_app.close() # 3 resources at least: consent_client, login_client, token_storage assert ( "closing resource of type AuthClient for UserApp(app_name='test-app')" in caplog.text ) assert ( "closing resource of type NativeAppAuthClient for " "UserApp(app_name='test-app')" ) in caplog.text assert ( "closing resource of type ValidatingTokenStorage for " "UserApp(app_name='test-app')" ) in caplog.text globus-globus-sdk-python-6a080e4/tests/unit/globus_app/test_scope_normalization.py000066400000000000000000000035571513221403200306740ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.scopes import Scope @pytest.fixture def user_app(): client_id = "mock_client_id" return globus_sdk.UserApp("test-app", client_id=client_id) @pytest.mark.parametrize( "scope_collection", ([Scope("scope1")], Scope("scope1"), "scope1", ["scope1"]), ) def test_iter_scopes_simple(user_app, scope_collection): actual_list = list(user_app._iter_scopes(scope_collection)) assert len(actual_list) == 1 assert isinstance(actual_list[0], Scope) assert str(actual_list[0]) == "scope1" @pytest.mark.parametrize( "scope_collection, expect_str", ( (("scope1", "scope2"), "scope1 scope2"), (("scope1", Scope("scope2")), "scope1 scope2"), ((Scope("scope1"), Scope("scope2")), "scope1 scope2"), ((Scope("scope1"), Scope("scope2"), "scope3"), "scope1 scope2 scope3"), ( (Scope("scope1"), Scope("scope2"), "scope3 scope4"), "scope1 scope2 scope3 scope4", ), ([Scope("scope1"), "scope2", "scope3 scope4"], "scope1 scope2 scope3 scope4"), ), ) def test_iter_scopes_handles_mixed_data(user_app, scope_collection, expect_str): actual_list = list(user_app._iter_scopes(scope_collection)) assert all(isinstance(scope, Scope) for scope in actual_list) assert _as_sorted_string(actual_list) == expect_str def test_iter_scopes_handles_dependent_scopes(user_app): scope_collection = "scope1 scope2[scope3 scope4]" actual_list = list(user_app._iter_scopes(scope_collection)) actual_sorted_str = _as_sorted_string(actual_list) # Dependent scope ordering is not guaranteed assert ( actual_sorted_str == "scope1 scope2[scope3 scope4]" or actual_sorted_str == "scope1 scope2[scope4 scope3]" ) def _as_sorted_string(scope_list) -> str: return " ".join(sorted(str(scope) for scope in scope_list)) globus-globus-sdk-python-6a080e4/tests/unit/helpers/000077500000000000000000000000001513221403200225015ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/helpers/__init__.py000066400000000000000000000000001513221403200246000ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/helpers/gcs/000077500000000000000000000000001513221403200232555ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/helpers/gcs/__init__.py000066400000000000000000000000001513221403200253540ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/helpers/gcs/test_collections.py000066400000000000000000000274341513221403200272160ustar00rootroot00000000000000import inspect import sys import types import typing as t import uuid import pytest from globus_sdk import ( CollectionDocument, GoogleCloudStorageCollectionPolicies, GuestCollectionDocument, MappedCollectionDocument, POSIXCollectionPolicies, POSIXStagingCollectionPolicies, ) from globus_sdk._missing import MISSING, MissingType, filter_missing from globus_sdk.transport import JSONRequestEncoder if sys.version_info >= (3, 10): UnionTypes = (t.Union, types.UnionType) else: UnionTypes = (t.Union,) STUB_SG_ID = uuid.uuid1() # storage gateway STUB_MC_ID = uuid.uuid1() # mapped collection STUB_UC_ID = uuid.uuid1() # user credential MappedCollectionSignature = inspect.signature(MappedCollectionDocument.__init__) GuestCollectionSignature = inspect.signature(GuestCollectionDocument.__init__) def test_collection_base_abstract(): with pytest.raises(TypeError): CollectionDocument() def test_collection_type_field(): m = MappedCollectionDocument( storage_gateway_id=STUB_SG_ID, collection_base_path="/" ) g = GuestCollectionDocument( mapped_collection_id=STUB_MC_ID, user_credential_id=STUB_UC_ID, collection_base_path="/", ) assert m["collection_type"] == "mapped" assert g["collection_type"] == "guest" @pytest.mark.parametrize( "use_kwargs,doc_version", [ ({}, "1.0.0"), ({"user_message_link": "https://example.net/"}, "1.1.0"), ({"user_message": "kthxbye"}, "1.1.0"), ({"user_message": ""}, "1.1.0"), ({"enable_https": True}, "1.1.0"), ({"enable_https": False}, "1.1.0"), # a string of length > 64 ({"user_message": "long message..." + "x" * 100}, "1.7.0"), ], ) def test_datatype_version_deduction(use_kwargs, doc_version): m = MappedCollectionDocument(**use_kwargs) assert m["DATA_TYPE"] == f"collection#{doc_version}" g = GuestCollectionDocument(**use_kwargs) assert g["DATA_TYPE"] == f"collection#{doc_version}" @pytest.mark.parametrize( "use_kwargs,doc_version", [ ({"sharing_users_allow": "sirosen"}, "1.2.0"), ({"sharing_users_allow": ["sirosen", "aaschaer"]}, "1.2.0"), ({"sharing_users_deny": "sirosen"}, "1.2.0"), ({"sharing_users_deny": ["sirosen", "aaschaer"]}, "1.2.0"), ({"force_verify": True}, "1.4.0"), ({"force_verify": False}, "1.4.0"), ({"disable_anonymous_writes": True}, "1.5.0"), ({"disable_anonymous_writes": False}, "1.5.0"), ({"guest_auth_policy_id": str(uuid.uuid4())}, "1.6.0"), ({"delete_protected": False}, "1.8.0"), # combining a long user_message (which uses callback-based detection) with # higher and lower bounding fields needs to apply correctly ( {"force_verify": False, "user_message": "long message..." + "x" * 100}, "1.7.0", ), ( {"delete_protected": False, "user_message": "long message..." + "x" * 100}, "1.8.0", ), ], ) def test_datatype_version_deduction_mapped_specific_fields(use_kwargs, doc_version): d = MappedCollectionDocument(**use_kwargs) assert d["DATA_TYPE"] == f"collection#{doc_version}" def test_datatype_version_deduction_add_custom(monkeypatch): custom_field = "foo-made-up-field" monkeypatch.setitem( CollectionDocument.DATATYPE_VERSION_IMPLICATIONS, custom_field, (1, 20, 0) ) m = MappedCollectionDocument( storage_gateway_id=STUB_SG_ID, collection_base_path="/", additional_fields={custom_field: "foo"}, ) assert m["DATA_TYPE"] == "collection#1.20.0" g = GuestCollectionDocument( mapped_collection_id=STUB_MC_ID, user_credential_id=STUB_UC_ID, collection_base_path="/", additional_fields={custom_field: "foo"}, ) assert g["DATA_TYPE"] == "collection#1.20.0" @pytest.mark.parametrize( "policies_type", ( dict, POSIXCollectionPolicies, POSIXStagingCollectionPolicies, GoogleCloudStorageCollectionPolicies, ), ) def test_collection_policies_field_encoded(policies_type): if policies_type is dict: policy_data = {"spam": "eggs"} elif policies_type in (POSIXCollectionPolicies, POSIXStagingCollectionPolicies): policy_data = policies_type( sharing_groups_allow=["foo", "bar"], sharing_groups_deny="baz", additional_fields={"spam": "eggs"}, ) elif policies_type is GoogleCloudStorageCollectionPolicies: policy_data = GoogleCloudStorageCollectionPolicies( project="foo", additional_fields={"spam": "eggs"} ) else: raise NotImplementedError # only Mapped Collections support a policies subdocument doc = MappedCollectionDocument( storage_gateway_id=STUB_SG_ID, collection_base_path="/", policies=policy_data, ) assert "policies" in doc assert isinstance(doc["policies"], policies_type) encoder = JSONRequestEncoder() request_data = encoder.encode("POST", "bogus.url.example", {}, doc, {}).json if policies_type is dict: assert request_data["policies"] == {"spam": "eggs"} elif policies_type is POSIXCollectionPolicies: assert request_data["policies"] == { "DATA_TYPE": "posix_collection_policies#1.0.0", "spam": "eggs", "sharing_groups_allow": ["foo", "bar"], "sharing_groups_deny": ["baz"], } elif policies_type is POSIXStagingCollectionPolicies: assert request_data["policies"] == { "DATA_TYPE": "posix_staging_collection_policies#1.0.0", "spam": "eggs", "sharing_groups_allow": ["foo", "bar"], "sharing_groups_deny": ["baz"], } elif policies_type is GoogleCloudStorageCollectionPolicies: assert request_data["policies"] == { "DATA_TYPE": "google_cloud_storage_collection_policies#1.0.0", "spam": "eggs", "project": "foo", } else: raise NotImplementedError # these test cases enumerate parameters for Guest Collections and Mapped Collections # and ensure that they're defined on one class but not the other @pytest.mark.parametrize( "fieldname", ( "allow_guest_collections", "delete_protected", "disable_anonymous_writes", "domain_name", "guest_auth_policy_id", "policies", "sharing_restrict_paths", "sharing_users_allow", "sharing_users_deny", "storage_gateway_id", ), ) def test_settings_which_are_only_supported_in_mapped_collections(fieldname): assert fieldname in MappedCollectionSignature.parameters assert fieldname not in GuestCollectionSignature.parameters @pytest.mark.parametrize( "fieldname", ( "mapped_collection_id", "user_credential_id", ), ) def test_settings_which_are_only_supported_in_guest_collections(fieldname): assert fieldname in GuestCollectionSignature.parameters assert fieldname not in MappedCollectionSignature.parameters @pytest.mark.parametrize( "fieldname", ( "disable_verify", "enable_https", "force_encryption", "force_verify", "public", "delete_protected", "allow_guest_collections", "disable_anonymous_writes", ), ) @pytest.mark.parametrize("value", (True, False, None)) def test_mapped_collection_opt_bool(fieldname, value): data = {} if value is not None: data[fieldname] = value doc = filter_missing( MappedCollectionDocument( storage_gateway_id=STUB_SG_ID, collection_base_path="/", **data, ) ) if value is not None: assert fieldname in doc assert doc[fieldname] == value else: assert fieldname not in doc common_collection_fields = [ ("collection_base_path", (str, MissingType)), ("contact_email", (str, None, MissingType)), ("contact_info", (str, None, MissingType)), ("default_directory", (str, MissingType)), ("department", (str, None, MissingType)), ("description", (str, None, MissingType)), ("display_name", (str, MissingType)), ("identity_id", (t.Union[uuid.UUID, str], MissingType)), ("info_link", (str, None, MissingType)), ("organization", (str, MissingType)), ("user_message", (str, None, MissingType)), ("user_message_link", (str, None, MissingType)), ("keywords", (t.Iterable[str], MissingType)), ("disable_verify", (bool, MissingType)), ("enable_https", (bool, MissingType)), ("force_encryption", (bool, MissingType)), ("force_verify", (bool, MissingType)), ("public", (bool, MissingType)), ] mapped_collection_fields = [ *common_collection_fields, ("domain_name", (str, MissingType)), ("guest_auth_policy_id", (t.Union[uuid.UUID, str], None, MissingType)), ("disable_anonymous_writes", (bool, MissingType)), ("policies", (t.Dict[str, t.Any], MissingType)), ] guest_collection_fields = [ *common_collection_fields, ("mapped_collection_id", (t.Union[uuid.UUID, str], MissingType)), ("user_credential_id", (t.Union[uuid.UUID, str], MissingType)), ("activity_notification_policy", (t.Dict[str, t.List[str]], MissingType)), ] def expand_collection_fields(fields): """ Expand each collection field into (field, valid_value) """ return [ (param, value) for param, types in fields for _type in types for value in _gen_value(_type) ] def _gen_value(_type): """ Return a list of valid values for type _type. """ if _type is MissingType: return [MISSING] if _type is None: return [None] if _type is str: return ["STRING"] if _type is bool: return [True, False] # Union[UUID, str] | (UUID | str) if t.get_origin(_type) in UnionTypes and set(t.get_args(_type)) == {uuid.UUID, str}: return [str(uuid.uuid1()), uuid.uuid1()] if _type is t.Iterable[str]: return [[], ["a", "b", "c"]] if _type is t.Dict[str, t.Any]: return [{"A": 1}] if _type is t.Dict[str, t.List[str]]: return [{"a": ["b", "c"]}] raise AssertionError(f"Unexpected Type: {_type}") @pytest.mark.parametrize( "fieldname,value", expand_collection_fields(mapped_collection_fields), ) def test_mapped_collection_fields(fieldname, value): """ Verify that each field in the mapped collection document can be set to a valid value. """ data = {} if value != MISSING: data[fieldname] = value doc = MappedCollectionDocument(**data) assert doc[fieldname] == value @pytest.mark.parametrize( "fieldname,value", expand_collection_fields(guest_collection_fields), ) def test_guest_collection_fields(fieldname, value): """ Verify that each field in the guest collection document can be set to a valid value. """ data = {} if value != MISSING: data[fieldname] = value doc = GuestCollectionDocument(**data) assert doc[fieldname] == value # regression test for a typo which caused this to be set improperly to the wrong key @pytest.mark.parametrize("value", ("inbound", "outbound", "all")) @pytest.mark.parametrize("collection_type", ("mapped", "guest")) def test_can_set_restrict_transfers_to_high_assurance(value, collection_type): if collection_type == "mapped": c = MappedCollectionDocument( storage_gateway_id=STUB_SG_ID, collection_base_path="/", restrict_transfers_to_high_assurance=value, ) else: c = GuestCollectionDocument( mapped_collection_id=STUB_MC_ID, user_credential_id=STUB_UC_ID, collection_base_path="/", restrict_transfers_to_high_assurance=value, ) assert c["restrict_transfers_to_high_assurance"] == value globus-globus-sdk-python-6a080e4/tests/unit/helpers/gcs/test_connector_table.py000066400000000000000000000115471513221403200300370ustar00rootroot00000000000000from __future__ import annotations import inspect import sys import uuid import pytest from globus_sdk import ConnectorTable, GlobusConnectServerConnector @pytest.mark.parametrize("connector_data", ConnectorTable._connectors) def test_lookup_by_attribute(connector_data): attrname, connector_name, _ = connector_data connector = getattr(ConnectorTable, attrname) assert connector.name == connector_name @pytest.mark.parametrize("connector_data", ConnectorTable._connectors) @pytest.mark.parametrize("as_uuid", (True, False)) def test_lookup_by_id(connector_data, as_uuid): _, connector_name, connector_id = connector_data if as_uuid: connector_id = uuid.UUID(connector_id) connector = ConnectorTable.lookup(connector_id) assert connector.name == connector_name @pytest.mark.parametrize("connector_data", ConnectorTable._connectors) def test_lookup_by_name(connector_data): _, connector_name, connector_id = connector_data connector = ConnectorTable.lookup(connector_name) assert connector.connector_id == connector_id @pytest.mark.parametrize( "lookup_name, expect_name", ( ("Google Drive", "Google Drive"), ("google drive", "Google Drive"), ("google_drive", "Google Drive"), ("google-drive", "Google Drive"), ("google-----drive", "Google Drive"), ("google-_-drive", "Google Drive"), # moody (" google_-drIVE", "Google Drive"), ("google_-drIVE ", "Google Drive"), (" GOOGLE DRIVE ", "Google Drive"), ), ) def test_lookup_by_name_normalization(lookup_name, expect_name): connector = ConnectorTable.lookup(lookup_name) assert connector.name == expect_name @pytest.mark.parametrize("name", [c.name for c in ConnectorTable.all_connectors()]) def test_all_connector_names_map_to_attributes(name): connector = ConnectorTable.lookup(name) assert connector is not None name = name.replace(" ", "_").upper() assert getattr(ConnectorTable, name) == connector @pytest.mark.skipif( sys.version_info < (3, 10), reason="inspect.get_annotations added in 3.10" ) def test_all_connector_attributes_are_assigned(): # build a list of attribute names annotated with # `t.ClassVar[GlobusConnectServerConnector]` annotated_attributes = [] for attribute, annotation in inspect.get_annotations(ConnectorTable).items(): # get_annotations does not interpret string-ized annotations by default, so we # receive the relevant values as strings, making comparison simple if annotation != "t.ClassVar[GlobusConnectServerConnector]": continue annotated_attributes.append(attribute) # confirm that we got the right number of annotated items assert len(annotated_attributes) == len(ConnectorTable._connectors) # now confirm that all of these are assigned values for attribute in annotated_attributes: instance = getattr(ConnectorTable, attribute) assert isinstance(instance, GlobusConnectServerConnector) def test_table_can_be_extended_with_simple_item(): # don't think too hard about the ethical implications of transporter clones connector_name = "Star Trek Transporter" connector_id = uuid.uuid1() ExtendedTable = ConnectorTable.extend( connector_name=connector_name, connector_id=connector_id ) # we get a new subclass named 'ExtendedConnectorTable' assert issubclass(ExtendedTable, ConnectorTable) assert ExtendedTable.__name__ == "ExtendedConnectorTable" # access via name, attribute, and ID all resolve to the same object data_object = ExtendedTable.lookup(connector_id) assert isinstance(data_object, GlobusConnectServerConnector) assert ExtendedTable.STAR_TREK_TRANSPORTER is data_object assert ExtendedTable.lookup(connector_name) is data_object def test_table_extended_twice(): connector_id1 = uuid.uuid1() connector_id2 = uuid.uuid1() ExtendedTable1 = ConnectorTable.extend( connector_name="Star Trek Transporter", connector_id=connector_id1 ) ExtendedTable2 = ExtendedTable1.extend( connector_name="Battlestar Galactica FTL", connector_id=connector_id2 ) # we get new subclasses named 'ExtendedConnectorTable' assert issubclass(ExtendedTable1, ConnectorTable) assert ExtendedTable1.__name__ == "ExtendedConnectorTable" assert issubclass(ExtendedTable2, ExtendedTable1) assert ExtendedTable2.__name__ == "ExtendedConnectorTable" # both tables get the same object for connector1 # only table2 has connector2 data_object = ExtendedTable1.lookup(connector_id1) assert isinstance(data_object, GlobusConnectServerConnector) assert ExtendedTable2.lookup(connector_id1) is data_object assert ExtendedTable1.lookup(connector_id2) is None data_object2 = ExtendedTable2.lookup(connector_id2) assert isinstance(data_object2, GlobusConnectServerConnector) globus-globus-sdk-python-6a080e4/tests/unit/helpers/gcs/test_role.py000066400000000000000000000005031513221403200256250ustar00rootroot00000000000000from globus_sdk import GCSRoleDocument def test_gcs_role_helper_supports_additional_fields(): r = GCSRoleDocument() assert r["DATA_TYPE"] == "role#1.0.0" assert "foo" not in r r2 = GCSRoleDocument(additional_fields={"foo": "bar"}) assert r2["DATA_TYPE"] == "role#1.0.0" assert r2["foo"] == "bar" globus-globus-sdk-python-6a080e4/tests/unit/helpers/gcs/test_storage_gateway.py000066400000000000000000000055171513221403200300630ustar00rootroot00000000000000import pytest from globus_sdk import ( ActiveScaleStoragePolicies, AzureBlobStoragePolicies, BlackPearlStoragePolicies, BoxStoragePolicies, CephStoragePolicies, GoogleCloudStoragePolicies, GoogleDriveStoragePolicies, HPSSStoragePolicies, IrodsStoragePolicies, OneDriveStoragePolicies, POSIXStagingStoragePolicies, POSIXStoragePolicies, S3StoragePolicies, StorageGatewayDocument, ) from globus_sdk.transport import JSONRequestEncoder @pytest.mark.parametrize( "use_kwargs,doc_version", [ ({}, "1.0.0"), ({"require_mfa": True}, "1.1.0"), ({"require_mfa": False}, "1.1.0"), ], ) def test_datatype_version_deduction(use_kwargs, doc_version): sg = StorageGatewayDocument(**use_kwargs) assert sg["DATA_TYPE"] == f"storage_gateway#{doc_version}" def test_storage_gateway_policy_document_conversion(): policies = POSIXStoragePolicies( groups_allow=["jedi", "wookies"], groups_deny=["sith", "stormtroopers"] ) sg = StorageGatewayDocument(policies=policies) assert "policies" in sg assert isinstance(sg["policies"], POSIXStoragePolicies) encoder = JSONRequestEncoder() request_data = encoder.encode("POST", "bogus.url.example", {}, sg, {}).json assert request_data["policies"] == { "DATA_TYPE": "posix_storage_policies#1.0.0", "groups_allow": ["jedi", "wookies"], "groups_deny": ["sith", "stormtroopers"], } def test_posix_staging_env_vars(): p = POSIXStagingStoragePolicies( groups_allow=("vulcans", "starfleet"), groups_deny=(x for x in ("ferengi", "romulans")), stage_app="/globus/bin/posix-stage-data", environment=({"name": "VOLUME", "value": "/vol/0"},), ) assert isinstance(p["environment"], list) assert dict(p) == { "DATA_TYPE": "posix_staging_storage_policies#1.0.0", "groups_allow": ["vulcans", "starfleet"], "groups_deny": ["ferengi", "romulans"], "stage_app": "/globus/bin/posix-stage-data", "environment": [{"name": "VOLUME", "value": "/vol/0"}], } @pytest.mark.parametrize( "doc_class", [ StorageGatewayDocument, POSIXStagingStoragePolicies, POSIXStoragePolicies, BlackPearlStoragePolicies, BoxStoragePolicies, CephStoragePolicies, GoogleDriveStoragePolicies, GoogleCloudStoragePolicies, OneDriveStoragePolicies, AzureBlobStoragePolicies, S3StoragePolicies, ActiveScaleStoragePolicies, IrodsStoragePolicies, HPSSStoragePolicies, ], ) def test_storage_gateway_documents_support_additional_fields(doc_class): d = doc_class() assert "DATA_TYPE" in d assert "foo" not in d d2 = doc_class(additional_fields={"foo": "bar"}) assert "DATA_TYPE" in d2 assert d2["foo"] == "bar" globus-globus-sdk-python-6a080e4/tests/unit/helpers/test_auth_flow_managers.py000066400000000000000000000045031513221403200277610ustar00rootroot00000000000000import base64 import hashlib import os from unittest import mock import pytest import globus_sdk from globus_sdk.scopes import TransferScopes from globus_sdk.services.auth.flow_managers.authorization_code import ( GlobusAuthorizationCodeFlowManager, ) from globus_sdk.services.auth.flow_managers.native_app import _make_native_app_challenge @pytest.mark.parametrize( "verifier", [ "x" * 20, # too short "x" * 200, # too long ("x" * 40) + "/" + ("y" * 40), # includes invalid characters ], ) def test_invalid_native_app_challenge(verifier): with pytest.raises(globus_sdk.GlobusSDKUsageError): _make_native_app_challenge(verifier) def test_simple_input_native_app_challenge(): verifier = "x" * 80 challenge = ( base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("utf-8")).digest()) .rstrip(b"=") .decode("utf-8") ) res_verifier, res_challenge = _make_native_app_challenge(verifier) assert res_verifier == verifier assert res_challenge == challenge def test_random_native_app_challenge(monkeypatch): b64vals = [] def mock_urandom(n: int): return b"xyz" def mock_b64encode(b: bytes): b64vals.append(b) return b"abc123" monkeypatch.setattr(os, "urandom", mock_urandom) monkeypatch.setattr(base64, "urlsafe_b64encode", mock_b64encode) verifier, challenge = _make_native_app_challenge() assert verifier == "abc123" assert challenge == "abc123" assert len(b64vals) == 2 assert b64vals == [b"xyz", hashlib.sha256(b"abc123").digest()] def test_get_authorize_url_for_authorization_code(): mock_client = mock.Mock() mock_client.client_id = "MOCK_CLIENT_ID" mock_client.base_url = "https://auth.globus.org/" flow_manager = GlobusAuthorizationCodeFlowManager( mock_client, redirect_uri="https://foo.example.org/authenticate", requested_scopes=TransferScopes.all, ) silly_string = "ANANAS_IS_PINEAPPLE_BUT_BANANE_IS_BANANA" authorize_url = flow_manager.get_authorize_url() assert authorize_url.startswith("https://auth.globus.org") assert silly_string not in authorize_url silly_authorize_url = flow_manager.get_authorize_url( query_params={"silly_string": silly_string} ) assert silly_string in silly_authorize_url globus-globus-sdk-python-6a080e4/tests/unit/helpers/test_search.py000066400000000000000000000015471513221403200253660ustar00rootroot00000000000000""" Unit tests for globus_sdk.SearchQueryV1 """ from globus_sdk import MISSING, SearchQueryV1 def test_init_v1(): query = SearchQueryV1() # ensure the version is set to query#1.0.0 assert query["@version"] == "query#1.0.0" # ensure key attributes initialize to empty lists for attribute in ["facets", "filters", "post_facet_filters", "sort", "boosts"]: assert query[attribute] == MISSING # init with supported fields params = {"q": "foo", "limit": 10, "offset": 0, "advanced": False} param_query = SearchQueryV1(**params) for par in params: assert param_query[par] == params[par] # init with additional_fields add_params = {"param1": "value1", "param2": "value2"} param_query = SearchQueryV1(additional_fields=add_params) for par in add_params: assert param_query[par] == add_params[par] globus-globus-sdk-python-6a080e4/tests/unit/helpers/test_timer.py000066400000000000000000000066431513221403200252430ustar00rootroot00000000000000import datetime import pytest from globus_sdk import ( OnceTimerSchedule, RecurringTimerSchedule, TransferData, TransferTimer, ) from globus_sdk._missing import filter_missing from tests.common import GO_EP1_ID, GO_EP2_ID def test_transfer_timer_ok(): tdata = TransferData(GO_EP1_ID, GO_EP2_ID) timer = TransferTimer(body=tdata, name="foo timer", schedule={"type": "once"}) assert timer["name"] == "foo timer" assert timer["schedule"]["type"] == "once" assert {"source_endpoint", "destination_endpoint"} < timer["body"].keys() assert timer["body"]["source_endpoint"] == GO_EP1_ID assert timer["body"]["destination_endpoint"] == GO_EP2_ID def test_transfer_timer_removes_disallowed_fields(): # set transfer-data-like body with disallowed fields tdata = {"submission_id": "foo", "skip_activation_check": False, "foo": "bar"} timer = TransferTimer(body=tdata, name="foo timer", schedule={"type": "once"}) assert timer["name"] == "foo timer" assert timer["schedule"]["type"] == "once" # confirm that disallowed fields are stripped in the timer, # but the original dict is unchanged assert timer["body"] == {"foo": "bar"} assert set(tdata.keys()) == {"submission_id", "skip_activation_check", "foo"} @pytest.mark.parametrize( "input_time, expected", ( # even though this string is "obviously" not a valid datetime, we don't # translate it when we create the schedule ("tomorrow", "tomorrow"), # use a fixed (known) timestamp and check how it's formatted as UTC (datetime.datetime.fromtimestamp(1698385129.7044), "2023-10-27T05:38:49+00:00"), # use a non-UTC datetime and confirm that it is sent as non-UTC ( datetime.datetime.fromisoformat("2023-10-27T05:38:49.999+01:00"), "2023-10-27T05:38:49+01:00", ), ), ) def test_once_timer_schedule_formats_datetime(input_time, expected): schedule = OnceTimerSchedule(datetime=input_time) assert dict(schedule) == {"type": "once", "datetime": expected} def test_recurring_timer_schedule_interval_only(): schedule = RecurringTimerSchedule(interval_seconds=600) assert filter_missing(schedule) == { "type": "recurring", "interval_seconds": 600, } @pytest.mark.parametrize( "input_time, expected", ( # even though this string is "obviously" not a valid datetime, we don't # translate it when we create the schedule ("tomorrow", "tomorrow"), # use a fixed (known) timestamp and check how it's formatted (datetime.datetime.fromtimestamp(1698385129.7044), "2023-10-27T05:38:49+00:00"), ), ) def test_recurring_timer_schedule_formats_start(input_time, expected): schedule = RecurringTimerSchedule(interval_seconds=600, start=input_time) assert filter_missing(schedule) == { "type": "recurring", "interval_seconds": 600, "start": expected, } def test_recurring_timer_schedule_formats_datetime_for_end(): # use a fixed (known) timestamp and check how it's formatted end_time = datetime.datetime.fromtimestamp(1698385129.7044) schedule = RecurringTimerSchedule( interval_seconds=600, end={"condition": "time", "datetime": end_time} ) assert filter_missing(schedule) == { "type": "recurring", "interval_seconds": 600, "end": {"condition": "time", "datetime": "2023-10-27T05:38:49+00:00"}, } globus-globus-sdk-python-6a080e4/tests/unit/helpers/test_transfer.py000066400000000000000000000224021513221403200257360ustar00rootroot00000000000000import pytest from globus_sdk import MISSING, DeleteData, TransferData from globus_sdk.services.transfer.client import _format_filter from tests.common import GO_EP1_ID, GO_EP2_ID def test_transfer_init_no_params(): """ Creates a TransferData object without optional parameters and verifies field initialization. """ # default init tdata = TransferData(GO_EP1_ID, GO_EP2_ID) assert tdata["DATA_TYPE"] == "transfer" assert tdata["source_endpoint"] == GO_EP1_ID assert tdata["destination_endpoint"] == GO_EP2_ID assert tdata["submission_id"] is MISSING assert "DATA" in tdata assert len(tdata["DATA"]) == 0 def test_transfer_init_w_params(): # init with params label = "label" params = {"param1": "value1", "param2": "value2"} tdata = TransferData( GO_EP1_ID, GO_EP2_ID, label=label, sync_level="exists", additional_fields=params, ) assert tdata["label"] == label assert tdata["submission_id"] is MISSING # sync_level of "exists" should be converted to 0 assert tdata["sync_level"] == 0 for par in params: assert tdata[par] == params[par] def test_transfer_add_item(): """ Adds items to TransferData, verifies results """ tdata = TransferData(GO_EP1_ID, GO_EP2_ID) # add item source_path = "source/path/" dest_path = "dest/path/" tdata.add_item(source_path, dest_path) # verify results assert len(tdata["DATA"]) == 1 data = tdata["DATA"][0] assert data["DATA_TYPE"] == "transfer_item" assert data["source_path"] == source_path assert data["destination_path"] == dest_path assert data["recursive"] == MISSING assert data["external_checksum"] == MISSING assert data["checksum_algorithm"] == MISSING # add recursive item tdata.add_item(source_path, dest_path, recursive=True) # verify results assert len(tdata["DATA"]) == 2 r_data = tdata["DATA"][1] assert r_data["DATA_TYPE"] == "transfer_item" assert r_data["source_path"] == source_path assert r_data["destination_path"] == dest_path assert r_data["recursive"] assert data["external_checksum"] == MISSING assert data["checksum_algorithm"] == MISSING # item with checksum checksum = "d577273ff885c3f84dadb8578bb41399" algorithm = "MD5" tdata.add_item( source_path, dest_path, external_checksum=checksum, checksum_algorithm=algorithm ) assert len(tdata["DATA"]) == 3 c_data = tdata["DATA"][2] assert c_data["DATA_TYPE"] == "transfer_item" assert c_data["source_path"] == source_path assert c_data["destination_path"] == dest_path assert c_data["recursive"] == MISSING assert c_data["external_checksum"] == checksum assert c_data["checksum_algorithm"] == algorithm # add an item which uses `additional_fields` addfields = {"foo": "bar", "bar": "baz"} tdata.add_item(source_path, dest_path, additional_fields=addfields) assert len(tdata["DATA"]) == 4 fields_data = tdata["DATA"][3] assert fields_data["DATA_TYPE"] == "transfer_item" assert fields_data["source_path"] == source_path assert fields_data["destination_path"] == dest_path assert fields_data["recursive"] == MISSING assert all(fields_data[k] == v for k, v in addfields.items()) @pytest.mark.parametrize( "args, kwargs", ( ((GO_EP1_ID,), {}), ((), {"endpoint": GO_EP1_ID}), ), ) def test_delete_data_noparams_init(args, kwargs): # the minimal, required argument is the endpoint ID -- less than that results # in a TypeError because the signature is not obeyed ddata = DeleteData(*args, **kwargs) assert ddata["DATA_TYPE"] == "delete" assert ddata["endpoint"] == GO_EP1_ID assert ddata["submission_id"] is MISSING assert "DATA" in ddata assert len(ddata["DATA"]) == 0 @pytest.mark.parametrize( "add_kwargs", ( {"recursive": True}, {"ignore_missing": True}, {"interpret_globs": True}, {"label": "somelabel"}, ), ) def test_delete_init_with_supported_parameters(add_kwargs): ddata = DeleteData(GO_EP1_ID, **add_kwargs) for k, v in add_kwargs.items(): assert ddata[k] == v def test_delete_init_with_additional_fields(): params = {"param1": "value1", "param2": "value2"} ddata = DeleteData(GO_EP1_ID, additional_fields=params) assert ddata["param1"] == "value1" assert ddata["param2"] == "value2" def test_delete_add_item(): """ Adds items to DeleteData, verifies results """ ddata = DeleteData(GO_EP1_ID) # add normal item path = "source/path/" ddata.add_item(path) assert len(ddata["DATA"]) == 1 data = ddata["DATA"][0] assert data["DATA_TYPE"] == "delete_item" assert data["path"] == path # add an item which uses `additional_fields` addfields = {"foo": "bar", "bar": "baz"} ddata.add_item(path, additional_fields=addfields) assert len(ddata["DATA"]) == 2 fields_data = ddata["DATA"][1] assert fields_data["DATA_TYPE"] == "delete_item" assert fields_data["path"] == path assert all(fields_data[k] == v for k, v in addfields.items()) def test_delete_iter_items(): ddata = DeleteData(GO_EP1_ID) # add item ddata.add_item("abc/") ddata.add_item("def/") # order preserved, well-formed as_list = list(ddata.iter_items()) assert len(as_list) == 2 def check_item(x, path): assert isinstance(x, dict) assert x.get("DATA_TYPE") == "delete_item" assert x.get("path") == path check_item(as_list[0], "abc/") check_item(as_list[1], "def/") def test_transfer_iter_items(): tdata = TransferData(GO_EP1_ID, GO_EP2_ID) tdata.add_item("source/abc.txt", "dest/abc.txt") tdata.add_item("source/def/", "dest/def/", recursive=True) # order preserved, well-formed as_list = list(tdata.iter_items()) assert len(as_list) == 2 def check_item(x, src, dst, params=None): params = params or {} assert isinstance(x, dict) assert x.get("DATA_TYPE") == "transfer_item" assert x.get("source_path") == src assert x.get("destination_path") == dst for k, v in params.items(): assert x.get(k) == v check_item(as_list[0], "source/abc.txt", "dest/abc.txt") check_item(as_list[1], "source/def/", "dest/def/", {"recursive": True}) @pytest.mark.parametrize("n_succeeded", [None, True, False]) @pytest.mark.parametrize("n_failed", [None, True, False]) @pytest.mark.parametrize("n_inactive", [None, True, False]) def test_notification_options(n_succeeded, n_failed, n_inactive): notify_kwargs = {} if n_succeeded is not None: notify_kwargs["notify_on_succeeded"] = n_succeeded if n_failed is not None: notify_kwargs["notify_on_failed"] = n_failed if n_inactive is not None: notify_kwargs["notify_on_inactive"] = n_inactive ddata = DeleteData(GO_EP1_ID, **notify_kwargs) tdata = TransferData(GO_EP1_ID, GO_EP2_ID, **notify_kwargs) def _default(x): return x if x is not None else True expect = { "notify_on_succeeded": _default(n_succeeded), "notify_on_failed": _default(n_failed), "notify_on_inactive": _default(n_inactive), } for k, v in expect.items(): if k in notify_kwargs: assert tdata[k] is v assert ddata[k] is v else: assert tdata[k] is MISSING assert ddata[k] is MISSING @pytest.mark.parametrize( "sync_level, result", [ ("exists", 0), (0, 0), ("size", 1), (1, 1), ("mtime", 2), (2, 2), ("checksum", 3), (3, 3), ("EXISTS", ValueError), ("hash", ValueError), (100, 100), ], ) def test_transfer_sync_levels_result(sync_level, result): if isinstance(result, type) and issubclass(result, Exception): with pytest.raises(result): TransferData(GO_EP1_ID, GO_EP2_ID, sync_level=sync_level) else: tdata = TransferData(GO_EP1_ID, GO_EP2_ID, sync_level=sync_level) assert tdata["sync_level"] == result def test_add_filter_rule(): tdata = TransferData(GO_EP1_ID, GO_EP2_ID) assert "filter_rules" not in tdata tdata.add_filter_rule("*.tgz", type="file") assert "filter_rules" in tdata assert isinstance(tdata["filter_rules"], list) assert len(tdata["filter_rules"]) == 1 assert tdata["filter_rules"][0]["DATA_TYPE"] == "filter_rule" assert tdata["filter_rules"][0]["method"] == "exclude" assert tdata["filter_rules"][0]["name"] == "*.tgz" assert tdata["filter_rules"][0]["type"] == "file" tdata.add_filter_rule("tmp") assert len(tdata["filter_rules"]) == 2 assert tdata["filter_rules"][1]["DATA_TYPE"] == "filter_rule" assert tdata["filter_rules"][1]["method"] == "exclude" assert tdata["filter_rules"][1]["name"] == "tmp" assert tdata["filter_rules"][1]["type"] == MISSING @pytest.mark.parametrize( "filter,expected", [ ("foo", "foo"), ({"foo": "bar", "a": ["b", "c"]}, "foo:bar/a:b,c"), ( [ "foo", { "a": ["b", "c"], }, ], ["foo", "a:b,c"], ), ], ) def test_ls_format_filter(filter, expected): assert _format_filter(filter) == expected globus-globus-sdk-python-6a080e4/tests/unit/login_flows/000077500000000000000000000000001513221403200233615ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/login_flows/__init__.py000066400000000000000000000000001513221403200254600ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/login_flows/test_local_server.py000066400000000000000000000033761513221403200274630ustar00rootroot00000000000000from unittest.mock import Mock import pytest from globus_sdk.login_flows import LocalServerLoginError from globus_sdk.login_flows.local_server_login_flow_manager.local_server import ( # noqa: E501 DEFAULT_HTML_TEMPLATE, RedirectHandler, RedirectHTTPServer, ) def test_default_html_template_contains_expected_text(): # basic integrity test assert "

Globus Login Result

" in DEFAULT_HTML_TEMPLATE.substitute( post_login_message="", login_result="Login successful" ) @pytest.mark.parametrize( "url,expected_result", [ (b"localhost?code=abc123", "abc123"), (b"localhost?error=bad_login", LocalServerLoginError("bad_login")), (b"localhost", LocalServerLoginError(None)), ], ) def test_server(url, expected_result): """ Setup a RedirectHTTPServer and pass it mocked HTTP GET requests to have its RedirectHandler handle """ server = RedirectHTTPServer( server_address=("", 0), handler_class=RedirectHandler, html_template=DEFAULT_HTML_TEMPLATE, ) # setup Mocks to look like a connection to a file for reading the HTTP data mock_file = Mock() mock_file.readline.side_effect = [ b"GET " + url + b" HTTP/1.1", b"Host: localhost", b"", ] mock_conn = Mock() mock_conn.makefile.return_value = mock_file # handle the request, then cleanup server.finish_request(mock_conn, ("", 0)) server.server_close() # confirm expected results result = server.wait_for_code() if isinstance(result, str): assert result == expected_result elif isinstance(result, LocalServerLoginError): assert result.args == expected_result.args else: raise AssertionError("unexpected result type") globus-globus-sdk-python-6a080e4/tests/unit/responses/000077500000000000000000000000001513221403200230605ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/responses/__init__.py000066400000000000000000000000001513221403200251570ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/responses/conftest.py000066400000000000000000000056161513221403200252670ustar00rootroot00000000000000import pytest import globus_sdk @pytest.fixture def make_oauth_token_response(make_response): """ response with conveniently formatted names to help with iteration in tests """ def f(client=None): return make_response( response_class=globus_sdk.services.auth.response.OAuthTokenResponse, json_body={ "access_token": "access_token_1", "expires_in": 3600, "id_token": "id_token_value", "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "state": "provided_by_client_to_prevent_replay_attacks", "token_type": "bearer", "other_tokens": [ { "access_token": "access_token_2", "expires_in": 3600, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "bearer", }, { "access_token": "access_token_3", "expires_in": 3600, "refresh_token": "refresh_token_3", "resource_server": "resource_server_3", "scope": "scope3:0 scope3:1", "token_type": "bearer", }, ], }, client=client, ) return f @pytest.fixture def make_oauth_dependent_token_response(make_response): """ response with conveniently formatted names to help with iteration in tests """ def f(client=None): return make_response( response_class=( globus_sdk.services.auth.response.OAuthDependentTokenResponse ), json_body=[ { "access_token": "access_token_4", "expires_in": 3600, "refresh_token": "refresh_token_4", "resource_server": "resource_server_4", "scope": "scope4", "token_type": "bearer", }, { "access_token": "access_token_5", "expires_in": 3600, "refresh_token": "refresh_token_5", "resource_server": "resource_server_5", "scope": "scope5", "token_type": "bearer", }, ], client=client, ) return f @pytest.fixture def oauth_token_response(make_oauth_token_response): return make_oauth_token_response() @pytest.fixture def oauth_dependent_token_response(make_oauth_dependent_token_response): return make_oauth_dependent_token_response() globus-globus-sdk-python-6a080e4/tests/unit/responses/test_oauth_token_response.py000066400000000000000000000062251513221403200307340ustar00rootroot00000000000000import pytest def test_by_resource_server_lookups(oauth_token_response): by_rs = oauth_token_response.by_resource_server # things which should hold for all for n in (1, 2, 3): name = f"resource_server_{n}" for attr in ("access_token", "refresh_token", "resource_server"): assert by_rs[name][attr] == f"{attr}_{n}" assert "expires_in" not in by_rs[name] assert "expires_at_seconds" in by_rs[name] assert by_rs[name]["token_type"] == "bearer" assert by_rs["resource_server_1"]["scope"] == "scope1" assert by_rs["resource_server_2"]["scope"] == "scope2 scope2:0 scope2:1" assert by_rs["resource_server_3"]["scope"] == "scope3:0 scope3:1" @pytest.mark.parametrize( "scopestr, resource_server", [ ("scope1", "resource_server_1"), ("scope2 scope2:0 scope2:1", "resource_server_2"), ("scope3:0 scope3:1", "resource_server_3"), ], ) def test_by_scopes_lookups_simple(scopestr, resource_server, oauth_token_response): by_scopes = oauth_token_response.by_scopes # containment assert scopestr in by_scopes for x in scopestr.split(): assert x in by_scopes # maps correctly assert by_scopes[scopestr]["resource_server"] == resource_server def test_by_scopes_lookups_failures(oauth_token_response): by_scopes = oauth_token_response.by_scopes mixed_scopes = "scope1 scope2" # containment assert mixed_scopes not in by_scopes assert "badscope" not in by_scopes # try to actually do it, ensure KeyErrors have good messages with pytest.raises(KeyError) as excinfo: by_scopes[mixed_scopes] assert "did not match exactly one token" in str(excinfo.value) with pytest.raises(KeyError) as excinfo: by_scopes["badscope"] assert "was not found" in str(excinfo.value) def test_by_scopes_lookups_fancy(oauth_token_response): by_scopes = oauth_token_response.by_scopes # repeated scope assert by_scopes["scope3:0 scope3:0"]["resource_server"] == "resource_server_3" # not matching order from original document assert ( by_scopes["scope2:1 scope2 scope2:0"]["resource_server"] == "resource_server_2" ) def test_stringify(oauth_token_response): data = str(oauth_token_response) # it starts the right way assert data.startswith("OAuthTokenResponse:\n") # it contains the id_token, but heavily truncated and annotated as such assert " id_token: id_token_v... (truncated)\n" in data # it contains the by_resource_server mapping (but we won't assert anything about # that data other than that it starts with a '{') assert " by_resource_server:\n {" in data def test_stringify_dependent_tokens(oauth_dependent_token_response): data = str(oauth_dependent_token_response) # it starts the right way assert data.startswith("OAuthDependentTokenResponse:\n") # it does not contain the id_token, so this is indicated as None assert " id_token: None\n" in data # it contains the by_resource_server mapping (but we won't assert anything about # that data other than that it starts with a '{') assert " by_resource_server:\n {" in data globus-globus-sdk-python-6a080e4/tests/unit/responses/test_response.py000066400000000000000000000250531513221403200263340ustar00rootroot00000000000000import json import re from collections import namedtuple from unittest import mock import pytest import requests from globus_sdk.response import ArrayResponse, GlobusHTTPResponse, IterableResponse _TestResponse = namedtuple("_TestResponse", ("data", "r")) def _response(data=None, encoding="utf-8", headers=None, status: int = 200): r = requests.Response() is_json = isinstance(data, (dict, list)) datastr = json.dumps(data) if is_json else data if datastr is not None: if isinstance(datastr, str): r._content = datastr.encode("utf-8") r.encoding = "utf-8" else: r._content = datastr r.encoding = "ISO-8559-1" if headers: r.headers.update(headers) elif is_json: r.headers["Content-Type"] = "application/json" r.status_code = status r.reason = {200: "OK", 404: "Not Found"}.get(status, "Unknown") return r def _mk_json_response(data): json_response = _response(data) return _TestResponse(data, GlobusHTTPResponse(json_response, client=mock.Mock())) @pytest.fixture def dict_response(): return _mk_json_response({"label1": "value1", "label2": "value2"}) @pytest.fixture def list_response(): return _mk_json_response(["value1", "value2", "value3"]) @pytest.fixture def http_no_content_type_response(): res = _response() assert "Content-Type" not in res.headers return _TestResponse(None, GlobusHTTPResponse(res, client=mock.Mock())) @pytest.fixture def malformed_http_response(): malformed_response = _response(b"{", headers={"Content-Type": "application/json"}) return _TestResponse( "{", GlobusHTTPResponse(malformed_response, client=mock.Mock()) ) @pytest.fixture def text_http_response(): text_data = "text data" text_response = _response( text_data, encoding="utf-8", headers={"Content-Type": "text/plain"} ) return _TestResponse( text_data, GlobusHTTPResponse(text_response, client=mock.Mock()) ) def test_data( dict_response, list_response, malformed_http_response, text_http_response, ): """ Gets the data from the GlobusResponses, confirms results Gets the data from each HTTPResponse, confirms expected data from json and None from malformed or plain text HTTP """ assert dict_response.r.data == dict_response.data assert list_response.r.data == list_response.data assert malformed_http_response.r.data is None assert text_http_response.r.data is None def test_str(dict_response, list_response): """ Confirms that individual values are seen in stringified responses """ for item in dict_response.data: assert item in str(dict_response.r) assert "nonexistent" not in str(dict_response.r) for item in list_response.data: assert item in str(list_response.r) assert "nonexistent" not in str(list_response.r) def test_text_response_repr_and_str_contain_raw_data(): expect_text = """pu-erh is a distinctive aged tea primarily produced in Yunnan depending on the tea used and how it is aged, it can be bright, floral, and fruity or it can take on mushroomy, fermented, and malty notes """ raw = _response( expect_text, encoding="utf-8", headers={"Content-Type": "text/plain"} ) res = GlobusHTTPResponse(raw, client=mock.Mock()) assert expect_text in repr(res) assert expect_text in str(res) def test_getitem(dict_response, list_response): """ Confirms that values can be accessed from the GlobusResponse """ # str indexing for key in dict_response.data: assert dict_response.r[key] == dict_response.data[key] # int indexing for i in range(len(list_response.data)): assert list_response.r[i] == list_response.data[i] # slice indexing assert list_response.r[:-1] == list_response.data[:-1] def test_contains(dict_response, list_response, text_http_response): """ Confirms that individual values are seen in the GlobusResponse """ for item in dict_response.data: assert item in dict_response.r assert "nonexistent" not in dict_response.r for item in list_response.data: assert item in list_response.r assert "nonexistent" not in list_response.r assert "foo" not in text_http_response.r def test_bool(dict_response, list_response): assert bool(dict_response) is True assert bool(list_response) is True empty_dict, empty_list = _mk_json_response({}), _mk_json_response([]) assert bool(empty_dict.r) is False assert bool(empty_list.r) is False null = _mk_json_response(None) assert bool(null.r) is False def test_len_array(list_response): array = ArrayResponse(list_response.r) assert len(array) == len(list_response.data) empty_list = _mk_json_response([]) empty_array = ArrayResponse(empty_list.r) assert len(empty_list.data) == 0 assert len(empty_array) == 0 def test_len_array_bad_data(dict_response): null_array = ArrayResponse(_mk_json_response(None).r) with pytest.raises( TypeError, match=re.escape( "Cannot take len() on ArrayResponse data when type is 'NoneType'" ), ): len(null_array) dict_array = ArrayResponse(dict_response.r) with pytest.raises( TypeError, match=re.escape("Cannot take len() on ArrayResponse data when type is 'dict'"), ): len(dict_array) def test_iter_array_bad_data(dict_response): null_array = ArrayResponse(_mk_json_response(None).r) with pytest.raises( TypeError, match=re.escape("Cannot iterate on ArrayResponse data when type is 'NoneType'"), ): list(null_array) dict_array = ArrayResponse(dict_response.r) with pytest.raises( TypeError, match=re.escape("Cannot iterate on ArrayResponse data when type is 'dict'"), ): list(dict_array) def test_get(dict_response, list_response, text_http_response): """ Gets individual values from dict response, confirms results Confirms list response correctly fails as non indexable """ for item in dict_response.data: assert dict_response.r.get(item) == dict_response.data.get(item) assert list_response.r.get("value1") is None assert list_response.r.get("value1", "foo") == "foo" assert text_http_response.r.get("foo") is None assert text_http_response.r.get("foo", default="bar") == "bar" def test_text(malformed_http_response, text_http_response): """ Gets the text from each HTTPResponse, confirms expected results """ assert malformed_http_response.r.text == "{" assert text_http_response.r.text == text_http_response.data def test_binary_content_property(malformed_http_response, text_http_response): """ Gets the text from each HTTPResponse, confirms expected results """ assert malformed_http_response.r.binary_content == b"{" assert text_http_response.r.binary_content == text_http_response.data.encode( "utf-8" ) def test_no_content_type_header(http_no_content_type_response): """ Response without a Content-Type HTTP header should be okay """ assert http_no_content_type_response.r.content_type is None def test_client_required_with_requests_response(): r = _response({"foo": 1}) GlobusHTTPResponse(r, client=mock.Mock()) # ok with pytest.raises(ValueError): GlobusHTTPResponse(r) # not ok def test_client_forbidden_when_wrapping(): r = _response({"foo": 1}) to_wrap = GlobusHTTPResponse(r, client=mock.Mock()) GlobusHTTPResponse(to_wrap) # ok with pytest.raises(ValueError): GlobusHTTPResponse(to_wrap, client=mock.Mock()) # not ok def test_value_error_indexing_on_non_json_data(): r = _response(b"foo: bar, baz: buzz") res = GlobusHTTPResponse(r, client=mock.Mock()) with pytest.raises(ValueError): res["foo"] def test_cannot_construct_base_iterable_response(): r = _response(b"foo: bar, baz: buzz") with pytest.raises(TypeError): IterableResponse(r, client=mock.Mock()) def test_iterable_response_using_iter_key(): class MyIterableResponse(IterableResponse): default_iter_key = "default_iter" raw = _response({"default_iter": [0, 1], "other_iter": [3, 4]}) default = MyIterableResponse(raw, client=mock.Mock()) assert list(default) == [0, 1] withkey = MyIterableResponse(raw, client=mock.Mock(), iter_key="other_iter") assert list(withkey) == [3, 4] def test_iterable_response_errors_on_non_dict_data(list_response): class MyIterableResponse(IterableResponse): default_iter_key = "default_iter" list_iterable = MyIterableResponse(list_response.r) null_iterable = MyIterableResponse(_mk_json_response(None).r) with pytest.raises( TypeError, match=re.escape("Cannot iterate on IterableResponse data when type is 'list'"), ): list(list_iterable) with pytest.raises( TypeError, match=re.escape( "Cannot iterate on IterableResponse data when type is 'NoneType'" ), ): list(null_iterable) def test_can_iter_array_response(list_response): arr = ArrayResponse(list_response.r) # sorted/reversed are just example stdlib functions which use iter assert sorted(arr) == sorted(list_response.data) assert list(reversed(arr)) == list(reversed(list_response.data)) def test_http_status_code_on_response(): r1 = _response(status=404) assert r1.status_code == 404 r2 = GlobusHTTPResponse(r1, client=mock.Mock()) # handle a Response object assert r2.http_status == 404 r3 = GlobusHTTPResponse(r2) # wrap another response assert r3.http_status == 404 def test_http_reason_on_response(): r1 = _response(status=404) r2 = GlobusHTTPResponse(r1, client=mock.Mock()) # handle a Response object r3 = GlobusHTTPResponse(r2) # wrap another response assert r1.reason == "Not Found" assert r2.http_reason == "Not Found" assert r3.http_reason == "Not Found" r4 = _response(status=200) r5 = GlobusHTTPResponse(r4, client=mock.Mock()) # handle a Response object r6 = GlobusHTTPResponse(r5) # wrap another response assert r4.reason == "OK" assert r5.http_reason == "OK" assert r6.http_reason == "OK" def test_http_headers_from_response(): r1 = _response(headers={"Content-Length": "5"}) assert r1.headers["content-length"] == "5" r2 = GlobusHTTPResponse(r1, client=mock.Mock()) # handle a Response object assert r2.headers["content-length"] == "5" r3 = GlobusHTTPResponse(r2) # wrap another response assert r3.headers["content-length"] == "5" globus-globus-sdk-python-6a080e4/tests/unit/responses/test_token_response_pickleability.py000066400000000000000000000014251513221403200324360ustar00rootroot00000000000000import pickle import pytest import globus_sdk @pytest.fixture def auth_client(): client = globus_sdk.AuthClient() yield client @pytest.fixture def token_response(auth_client, make_oauth_token_response): return make_oauth_token_response(client=auth_client) def test_pickle_and_unpickle_no_usage(token_response): """ Test pickle and unpickle, with no usage of the result, for all pickle protocol versions supported by the current interpreter. """ pickled_versions = [ pickle.dumps(token_response, protocol=n) for n in range(pickle.HIGHEST_PROTOCOL + 1) ] unpickled_versions = [pickle.loads(x) for x in pickled_versions] for x in unpickled_versions: assert x.by_resource_server == token_response.by_resource_server globus-globus-sdk-python-6a080e4/tests/unit/responses/test_unpacking_gcs_response.py000066400000000000000000000027461513221403200312330ustar00rootroot00000000000000import pytest from globus_sdk.services.gcs import UnpackingGCSResponse def test_unpacking_response_with_callback(make_response): def identify_desired_data(d): return "foo" in d base_resp = make_response( json_body={ "data": [ {"x": 1, "y": 2}, {"x": 2, "y": 3, "foo": "bar"}, ] } ) resp = UnpackingGCSResponse(base_resp, identify_desired_data) assert resp["x"] == 2 assert resp["y"] == 3 assert resp["foo"] == "bar" @pytest.mark.parametrize("datatype", ["foo#1.0.1", "foo#1", "foo#0.0.1", "foo#2.0.0.1"]) def test_unpacking_response_matches_datatype(make_response, datatype): base_resp = make_response(json_body={"data": [{"x": 1, "DATA_TYPE": datatype}]}) resp = UnpackingGCSResponse(base_resp, "foo") assert "x" in resp # using some other spec will not match resp_bar = UnpackingGCSResponse(base_resp, "bar") assert "x" not in resp_bar def test_unpacking_response_invalid_spec(make_response): base_resp = make_response(json_body={"data": [{"x": 1, "DATA_TYPE": "foo#1.0.0"}]}) with pytest.raises(ValueError): UnpackingGCSResponse(base_resp, "foo 1.0") def test_unpacking_response_invalid_datatype(make_response): # we'll never return a match if the DATA_TYPE doesn't appear to be valid base_resp = make_response(json_body={"data": [{"x": 1, "DATA_TYPE": "foo"}]}) resp = UnpackingGCSResponse(base_resp, "foo") assert "x" not in resp globus-globus-sdk-python-6a080e4/tests/unit/scopes/000077500000000000000000000000001513221403200223335ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/scopes/test_consents.py000066400000000000000000000117331513221403200256050ustar00rootroot00000000000000from __future__ import annotations from types import SimpleNamespace from uuid import UUID import pytest from globus_sdk.scopes.consents import ConsentForest, ConsentTreeConstructionError from tests.common import ConsentTest, ScopeRepr _zero_uuid = str(UUID(int=0)) def _uuid_of(char: str) -> str: if len(char) != 1: raise ValueError(f"char must be a single character, got {char!r}") return _zero_uuid.replace("0", char) Clients = SimpleNamespace( Zero=_uuid_of("0"), One=_uuid_of("1"), Two=_uuid_of("2"), Three=_uuid_of("3"), ) Scopes = SimpleNamespace( A=ScopeRepr(_uuid_of("A"), "A"), B=ScopeRepr(_uuid_of("B"), "B"), C=ScopeRepr(_uuid_of("C"), "C"), D=ScopeRepr(_uuid_of("D"), "D"), ) def test_consent_forest_creation(): root = ConsentTest.of(Clients.Zero, Scopes.A) node1 = ConsentTest.of(Clients.One, Scopes.B, parent=root) node2 = ConsentTest.of(Clients.Two, Scopes.C, parent=node1) forest = ConsentForest([root, node1, node2]) assert len(forest.trees) == 1 tree = forest.trees[0] assert tree.root == root assert tree.max_depth == 3 assert tree.edges[tree.root.id] == {node1.id} assert tree.edges[node1.id] == {node2.id} assert tree.edges[node2.id] == set() assert tree.get_node(root.id) == root assert tree.get_node(node1.id) == node1 assert tree.get_node(node2.id) == node2 def test_consent_forest_scope_requirement_evaluation(): root = ConsentTest.of(Clients.Zero, Scopes.A) node1 = ConsentTest.of(Clients.One, Scopes.B, parent=root) node2 = ConsentTest.of(Clients.Two, Scopes.C, parent=node1) forest = ConsentForest([root, node1, node2]) assert forest.meets_scope_requirements("A") assert forest.meets_scope_requirements("A[B[C]]") assert not forest.meets_scope_requirements("B") assert not forest.meets_scope_requirements("A[C]") def test_consent_forest_scope_requirement_with_sibling_dependent_scopes(): root = ConsentTest.of(Clients.Zero, Scopes.A) node1 = ConsentTest.of(Clients.One, Scopes.B, parent=root) node2 = ConsentTest.of(Clients.Two, Scopes.C, parent=root) forest = ConsentForest([root, node1, node2]) assert forest.meets_scope_requirements("A") assert forest.meets_scope_requirements("A[B]") assert forest.meets_scope_requirements("A[C]") assert forest.meets_scope_requirements("A[B C]") assert not forest.meets_scope_requirements("A[B[C]]") assert not forest.meets_scope_requirements("A[C[B]]") @pytest.mark.parametrize("atomically_revocable", (True, False)) def test_consent_forest_scope_requirement_with_optional_dependent_scopes( atomically_revocable: bool, ): """ Dependent scope optionality is intentionally ignored for this implementation. In formal terms, the scope "A[*B]" is only satisfied by a tree matching the shape A -> B where B is "atomically revocable". We've decided that this is an auth service concern, not a concern for local clients to be making decisions about; so we intentionally ignore this distinction in order to give standard users a simpler verification mechanism to ask "will my request work with the current set of consents?". """ root = ConsentTest.of(Clients.Zero, Scopes.A) child = ConsentTest.of( Clients.One, Scopes.B, parent=root, atomically_revocable=atomically_revocable ) forest = ConsentForest([root, child]) assert forest.meets_scope_requirements("A[B]") assert forest.meets_scope_requirements("A[*B]") def test_consent_forest_with_disjoint_consents_with_duplicate_scopes(): """ Strange state to reproduce in practice but this test case simulates the forest of Tree 1: A (Client Zero) -> B (Client Zero) Tree 2: B (Client Zero) -> C (Client Zero) In this situation, A[B] and B[C] are both satisfied, but A[B[C]] is not. """ root1 = ConsentTest.of(Clients.Zero, Scopes.A) child1 = ConsentTest.of(Clients.Zero, Scopes.B, parent=root1) root2 = ConsentTest.of(Clients.Zero, Scopes.B) child2 = ConsentTest.of(Clients.Zero, Scopes.C, parent=root2) forest = ConsentForest([root1, child1, root2, child2]) assert forest.meets_scope_requirements("A[B]") assert forest.meets_scope_requirements("B[C]") assert not forest.meets_scope_requirements("A[B[C]]") def test_consent_forest_with_missing_intermediary_nodes(): """ Simulate a situation in which we didn't receive the full list of consents from Auth. So the tree has holes Tree: A -> -> C """ root = ConsentTest.of(Clients.Zero, Scopes.A) node1 = ConsentTest.of(Clients.One, Scopes.B, parent=root) node2 = ConsentTest.of(Clients.Two, Scopes.C, parent=node1) # Only add the first and last node to the forest. # The last node (C) references the middle node (B) and so forest loading should # fail. with pytest.raises( ConsentTreeConstructionError, match=rf"Missing parent node: {node1.id}" ): ConsentForest([root, node2]) globus-globus-sdk-python-6a080e4/tests/unit/scopes/test_merge_scopes.py000066400000000000000000000037361513221403200264300ustar00rootroot00000000000000from globus_sdk.scopes import Scope, ScopeParser def test_base_scope_strings(): s1 = [Scope("foo"), Scope("bar")] s2 = [Scope("foo"), Scope("baz")] merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 3 str_list = [str(s) for s in merged] assert "foo" in str_list assert "bar" in str_list assert "baz" in str_list def test_mixed_optional_dependencies(): s1 = [Scope("foo", optional=True)] s2 = [Scope("foo", optional=False)] merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 2 str_list = [str(s) for s in merged] assert "foo" in str_list assert "*foo" in str_list def test_different_dependencies(): s1 = [Scope("foo").with_dependency(Scope("bar"))] s2 = [Scope("foo").with_dependency(Scope("baz"))] merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 1 assert merged[0].scope_string == "foo" dependency_str_list = [str(s) for s in merged[0].dependencies] assert len(dependency_str_list) == 2 assert "bar" in dependency_str_list assert "baz" in dependency_str_list def test_optional_dependencies(): s1 = [Scope("foo").with_dependency(Scope("bar"))] s2 = [Scope("foo").with_dependency(Scope("bar", optional=True))] merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 1 assert merged[0].scope_string == "foo" dependency_str_list = [str(s) for s in merged[0].dependencies] assert len(dependency_str_list) == 1 assert "bar" in dependency_str_list def test_different_dependencies_on_mixed_optional_base(): s1 = [Scope("foo").with_dependency(Scope("bar"))] s2 = [Scope("foo", optional=True).with_dependency(Scope("baz"))] merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 2 for scope in merged: dependency_str_list = [str(s) for s in scope.dependencies] assert len(dependency_str_list) == 2 assert "bar" in dependency_str_list assert "baz" in dependency_str_list globus-globus-sdk-python-6a080e4/tests/unit/scopes/test_scope_collections.py000066400000000000000000000043231513221403200274550ustar00rootroot00000000000000import uuid from globus_sdk.scopes import ComputeScopes, FlowsScopes, Scope from globus_sdk.scopes.collection import ( DynamicScopeCollection, StaticScopeCollection, _url_scope, _urn_scope, ) def test_url_scope_string(): resource_server = str(uuid.UUID(int=0)) s = _url_scope(resource_server, "data_access") assert isinstance(s, Scope) assert str(s) == ( "https://auth.globus.org/scopes/00000000-0000-0000-0000-000000000000" "/data_access" ) def test_urn_scope_string(): resource_server = "example.globus.org" s = _urn_scope(resource_server, "myscope") assert isinstance(s, Scope) assert str(s) == "urn:globus:auth:scope:example.globus.org:myscope" def test_static_scope_collection_iter_contains_expected_values(): class _MyScopes(StaticScopeCollection): resource_server = str(uuid.UUID(int=0)) foo = _urn_scope(resource_server, "foo") bar = _url_scope(resource_server, "bar") MyScopes = _MyScopes() listified = list(MyScopes) as_set = set(listified) assert len(listified) == len(as_set) assert as_set == {MyScopes.foo, MyScopes.bar} def test_dynamic_scope_collection_contains_expected_values(): class MyScopes(DynamicScopeCollection): _scope_names = ("foo", "bar") @property def foo(self): return _urn_scope(self.resource_server, "foo") @property def bar(self): return _url_scope(self.resource_server, "bar") resource_server = str(uuid.UUID(int=10)) scope_collection = MyScopes(resource_server) assert scope_collection.resource_server == resource_server listified = list(scope_collection) assert scope_collection.foo in listified assert scope_collection.bar in listified def test_flows_scopes_creation(): assert FlowsScopes.resource_server == "flows.globus.org" assert ( str(FlowsScopes.run) == "https://auth.globus.org/scopes/eec9b274-0c81-4334-bdc2-54e90e689b9a/run" ) def test_compute_scopes_creation(): assert ComputeScopes.resource_server == "funcx_service" assert ( str(ComputeScopes.all) == "https://auth.globus.org/scopes/facd7ccc-c5f4-42aa-916b-a0e270e2c2a9/all" ) globus-globus-sdk-python-6a080e4/tests/unit/scopes/test_scope_model.py000066400000000000000000000036221513221403200262400ustar00rootroot00000000000000import uuid import pytest from globus_sdk.scopes import Scope def test_scope_with_dependency_leaves_original_unchanged(): s1 = Scope(uuid.uuid1().hex) s2 = Scope("s2") s3 = s1.with_dependency(s2) assert s1.scope_string == s3.scope_string assert len(s1.dependencies) == 0 assert len(s3.dependencies) == 1 def test_scope_with_dependencies_leaves_original_unchanged(): s1 = Scope(uuid.uuid1().hex) s2 = Scope("s2") s3 = Scope("s3") s4 = s1.with_dependencies((s2, s3)) assert s1.scope_string == s4.scope_string assert len(s1.dependencies) == 0 assert len(s4.dependencies) == 2 def test_scope_with_optional_leaves_original_unchanged(): s1 = Scope(uuid.uuid1().hex) s2 = s1.with_optional(True) s3 = s2.with_optional(False) assert s1.scope_string == s2.scope_string == s3.scope_string assert len(s1.dependencies) == 0 assert len(s2.dependencies) == 0 assert len(s3.dependencies) == 0 assert not s1.optional assert s2.optional assert not s3.optional def test_scope_with_string_dependency_gets_typeerror(): s = Scope("x") with pytest.raises( TypeError, match=r"Scope\.with_dependency\(\) takes a Scope as its input\. Got: 'str'", ): s.with_dependency("y") def test_scope_with_string_dependencies_gets_typeerror(): s = Scope("x") with pytest.raises( TypeError, match=( r"Scope\.with_dependencies\(\) takes an iterable of Scopes as its input\. " "At position 0, got: 'str'" ), ): s.with_dependencies(["y"]) def test_scope_with_mixed_dependencies_gets_typeerror(): s = Scope("x") with pytest.raises( TypeError, match=( r"Scope\.with_dependencies\(\) takes an iterable of Scopes as its input\. " "At position 1, got: 'str'" ), ): s.with_dependencies([Scope("y"), "z"]) globus-globus-sdk-python-6a080e4/tests/unit/scopes/test_scope_parser.py000066400000000000000000000171101513221403200264310ustar00rootroot00000000000000import time import pytest from globus_sdk import exc from globus_sdk.scopes import Scope, ScopeCycleError, ScopeParseError, ScopeParser def test_scope_str_and_repr_simple(): s = Scope("simple") assert str(s) == "simple" assert repr(s) == "Scope('simple')" def test_scope_str_and_repr_optional(): s = Scope("simple", optional=True) assert str(s) == "*simple" assert repr(s) == "Scope('simple', optional=True)" def test_scope_str_and_repr_with_dependencies(): s = Scope("top") s = s.with_dependency(Scope("foo")) assert str(s) == "top[foo]" s = s.with_dependency(Scope("bar")) assert str(s) == "top[foo bar]" assert repr(s) == "Scope('top', dependencies=(Scope('foo'), Scope('bar')))" def test_scope_str_nested(): bottom = Scope("bottom") mid = Scope("mid", dependencies=(bottom,)) top = Scope("top", dependencies=(mid,)) assert str(bottom) == "bottom" assert str(mid) == "mid[bottom]" assert str(top) == "top[mid[bottom]]" def test_scope_with_optional_dependency_stringifies(): s = Scope("top") s = s.with_dependency(Scope("subscope", optional=True)) assert str(s) == "top[*subscope]" subscope_repr = "Scope('subscope', optional=True)" assert repr(s) == f"Scope('top', dependencies=({subscope_repr},))" def test_scope_parsing_allows_empty_string(): scopes = ScopeParser.parse("") assert scopes == [] @pytest.mark.parametrize( "scope_string1,scope_string2", [ ("foo ", "foo"), (" foo", "foo"), ("foo[ bar]", "foo[bar]"), ], ) def test_scope_parsing_ignores_non_semantic_whitespace(scope_string1, scope_string2): list1 = ScopeParser.parse(scope_string1) list2 = ScopeParser.parse(scope_string2) assert len(list1) == len(list2) == 1 s1, s2 = list1[0], list2[0] # Scope.__eq__ is not defined, so equivalence checking is manual (and somewhat error # prone) for now assert s1.scope_string == s2.scope_string assert s1.optional == s2.optional for i in range(len(s1.dependencies)): assert s1.dependencies[i].scope_string == s2.dependencies[i].scope_string assert s1.dependencies[i].optional == s2.dependencies[i].optional @pytest.mark.parametrize( "scopestring", [ # ending in '*' "foo*", "foo *", # '*' followed by '[] ' "foo*[bar]", "foo *[bar]", "foo [bar*]", "foo * ", "* foo", # empty brackets "foo[]", # starting with open bracket "[foo]", # double brackets "foo[[bar]]", # unbalanced open brackets "foo[", "foo[bar", # unbalanced close brackets "foo]", "foo bar]", "foo[bar]]", "foo[bar] baz]", # space before brackets "foo [bar]", # missing space before next scope string after ']' "foo[bar]baz", ], ) def test_scope_parsing_rejects_bad_inputs(scopestring): with pytest.raises(ScopeParseError): ScopeParser.parse(scopestring) @pytest.mark.parametrize( "scopestring", [ "foo[foo]", "foo[*foo]", "foo[bar[foo]]", "foo[bar[baz[bar]]]", "foo[bar[*baz[bar]]]", "foo[bar[*baz[*bar]]]", ], ) def test_scope_parsing_catches_and_rejects_cycles(scopestring): with pytest.raises(ScopeCycleError): ScopeParser.parse(scopestring) @pytest.mark.flaky def test_scope_parsing_catches_and_rejects_very_large_cycles_quickly(): """ WARNING: this test is hardware speed dependent and could fail on slow systems. This test creates a very long cycle and validates that it can be caught in a small timeframe of < 100ms. Observed times on a test system were <20ms, and in CI were <60ms. Although checking the speed in this way is not ideal, we want to avoid high time-complexity in the cycle detection. This test offers good protection against any major performance regression. """ scope_string = "" for i in range(1000): scope_string += f"foo{i}[" scope_string += " foo10" for _ in range(1000): scope_string += "]" t0 = time.time() with pytest.raises(ScopeCycleError): ScopeParser.parse(scope_string) t1 = time.time() assert t1 - t0 < 0.1 @pytest.mark.parametrize( "scopestring", ("foo", "*foo", "foo[bar]", "foo[*bar]", "foo bar", "foo[bar[baz]]"), ) def test_scope_parsing_accepts_valid_inputs(scopestring): # test *only* that parsing does not error and returns a non-empty list of scopes scopes = ScopeParser.parse(scopestring) assert isinstance(scopes, list) assert len(scopes) > 0 assert isinstance(scopes[0], Scope) def test_scope_deserialize_simple(): scope = Scope.parse("foo") assert str(scope) == "foo" def test_scope_deserialize_with_dependencies(): # oh, while we're here, let's also check that our whitespace insensitivity works scope = Scope.parse("foo[ bar *baz ]") assert str(scope) in ("foo[bar *baz]", "foo[*baz bar]") def test_scope_deserialize_fails_on_empty(): with pytest.raises(ValueError): Scope.parse(" ") def test_scope_deserialize_fails_on_multiple_top_level_scopes(): with pytest.raises(ValueError): Scope.parse("foo bar") @pytest.mark.parametrize("scope_str", ("*foo", "foo[bar]", "foo[", "foo]", "foo bar")) def test_scope_init_forbids_special_chars(scope_str): with pytest.raises(ValueError): Scope(scope_str) @pytest.mark.parametrize( "original, reserialized", [ ("foo[bar *bar]", {"foo[bar]"}), ("foo[*bar bar]", {"foo[bar]"}), ("foo[bar[baz]] bar[*baz]", {"foo[bar[baz]]", "bar[baz]"}), ("foo[bar[*baz]] bar[baz]", {"foo[bar[baz]]", "bar[baz]"}), ], ) def test_scope_parsing_normalizes_optionals(original, reserialized): assert {str(s) for s in ScopeParser.parse(original)} == reserialized @pytest.mark.parametrize( "scope_str", ( "foo", "*foo", "foo[bar] baz", " foo ", "foo[bar] bar[foo]", ), ) def test_serialize_of_scope_string_is_exact(scope_str): assert ScopeParser.serialize(scope_str) == scope_str def test_serialize_of_scope_object(): assert ScopeParser.serialize(Scope("scope1")) == "scope1" @pytest.mark.parametrize( "scope_collection", ( ("scope1",), ["scope1"], {"scope1"}, (s for s in ["scope1"]), ), ) def test_serialize_of_simple_collection_of_strings(scope_collection): assert ScopeParser.serialize(scope_collection) == "scope1" @pytest.mark.parametrize( "scope_collection, expect_str", ( (("scope1", Scope("scope2")), "scope1 scope2"), ((Scope("scope1"), Scope("scope2")), "scope1 scope2"), ((Scope("scope1"), Scope("scope2"), "scope3"), "scope1 scope2 scope3"), ( (Scope("scope1"), Scope("scope2"), "scope3 scope4"), "scope1 scope2 scope3 scope4", ), ([Scope("scope1"), "scope2", "scope3 scope4"], "scope1 scope2 scope3 scope4"), ), ) def test_serialize_handles_mixed_data(scope_collection, expect_str): assert ScopeParser.serialize(scope_collection) == expect_str @pytest.mark.parametrize("input_obj", ("", [], set(), ())) def test_serialize_rejects_empty_by_default(input_obj): with pytest.raises( exc.GlobusSDKUsageError, match="'scopes' cannot be the empty string or empty collection", ): ScopeParser.serialize(input_obj) @pytest.mark.parametrize("input_obj", ("", [], set(), ())) def test_serialize_allows_empty_string_with_flag(input_obj): assert ScopeParser.serialize(input_obj, reject_empty=False) == "" globus-globus-sdk-python-6a080e4/tests/unit/scopes/test_scope_parser_intermediate_representations.py000066400000000000000000000021371513221403200344730ustar00rootroot00000000000000from globus_sdk.scopes._graph_parser import ScopeGraph def test_graph_str_single_node(): g = ScopeGraph.parse("foo") clean_str = _blank_lines_removed(str(g)) assert ( clean_str == """\ digraph scopes { rankdir="LR"; foo }""" ) def test_graph_str_single_optional_node(): g = ScopeGraph.parse("*foo") clean_str = _blank_lines_removed(str(g)) assert ( clean_str == """\ digraph scopes { rankdir="LR"; *foo }""" ) def test_graph_str_single_dependency(): g = ScopeGraph.parse("foo[bar]") clean_str = _blank_lines_removed(str(g)) assert ( clean_str == """\ digraph scopes { rankdir="LR"; foo foo -> bar; }""" ) def test_graph_str_optional_dependency(): g = ScopeGraph.parse("foo[bar[*baz]]") clean_str = _blank_lines_removed(str(g)) assert ( clean_str == """\ digraph scopes { rankdir="LR"; foo foo -> bar; bar -> baz [ label = "optional" ]; }""" ) def _blank_lines_removed(s: str) -> str: return "\n".join(line for line in s.split("\n") if line.strip() != "") globus-globus-sdk-python-6a080e4/tests/unit/services/000077500000000000000000000000001513221403200226625ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/services/auth/000077500000000000000000000000001513221403200236235ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/services/auth/test_id_token_decoder.py000066400000000000000000000055061513221403200305230ustar00rootroot00000000000000import uuid from unittest import mock import pytest import requests import globus_sdk class MockDecoder(globus_sdk.IDTokenDecoder): def get_openid_configuration(self): return { "issuer": "https://auth.globus.org", "authorization_endpoint": "https://auth.globus.org/v2/oauth2/authorize", "userinfo_endpoint": "https://auth.globus.org/v2/oauth2/userinfo", "token_endpoint": "https://auth.globus.org/v2/oauth2/token", "revocation_endpoint": "https://auth.globus.org/v2/oauth2/token/revoke", "jwks_uri": "https://auth.globus.org/jwk.json", "response_types_supported": ["code", "token", "token id_token", "id_token"], "id_token_signing_alg_values_supported": ["RS512"], "scopes_supported": ["openid", "email", "profile"], "token_endpoint_auth_methods_supported": ["client_secret_basic"], "claims_supported": [ "at_hash", "aud", "email", "exp", "name", "nonce", "preferred_username", "iat", "iss", "sub", ], "subject_types_supported": ["public"], } def get_jwk(self): return mock.Mock() def test_decoding_defaults_to_client_id_as_audience(): fake_client = mock.Mock() fake_client.client_id = str(uuid.uuid1()) decoder = MockDecoder(fake_client) with mock.patch("jwt.decode") as mock_jwt_decode: decoder.decode("") assert mock_jwt_decode.call_args.kwargs["audience"] == fake_client.client_id @pytest.mark.parametrize("audience_value", (None, "myaud")) def test_decoding_passes_audience(audience_value): class MyDecoder(MockDecoder): def get_jwt_audience(self): return audience_value decoder = MyDecoder(mock.Mock()) with mock.patch("jwt.decode") as mock_jwt_decode: decoder.decode("") assert mock_jwt_decode.call_args.kwargs["audience"] == audience_value def test_setting_oidc_config_on_default_decoder_unpacks_data(): oidc_config = {"x": 1} raw_response = mock.Mock(spec=requests.Response) raw_response.json.return_value = oidc_config response = globus_sdk.GlobusHTTPResponse(raw_response, client=mock.Mock()) decoder = globus_sdk.IDTokenDecoder(mock.Mock()) decoder.store_openid_configuration(response) assert decoder.get_openid_configuration() == oidc_config def test_default_jwt_leeway_can_be_overridden_on_instance(): decoder = MockDecoder(mock.Mock()) default_leeway = decoder.jwt_leeway decoder.jwt_leeway = int(default_leeway * 2) with mock.patch("jwt.decode") as mock_jwt_decode: decoder.decode("") assert mock_jwt_decode.call_args.kwargs["leeway"] == int(default_leeway * 2) globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/000077500000000000000000000000001513221403200230715ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/conftest.py000066400000000000000000000150761513221403200253010ustar00rootroot00000000000000import pathlib import shutil import tempfile import textwrap from xml.etree import ElementTree import pytest import responses HERE = pathlib.Path(__file__).parent REPO_ROOT = HERE.parent.parent.parent # # sphinx's intersphinx plugin loads an inventory from the docs.python.org docs for # linkage each time it runs # we use a cached copy so that: # - tests don't require the network # - we don't put unnecessary load on the python docs site # - tests will be faster # @pytest.fixture(autouse=True) def _mock_intersphinx_object_inventory_response(mocked_responses): objects_inv = HERE / "objects.inv" responses.get( url="https://docs.python.org/3/objects.inv", body=objects_inv.read_bytes() ) class SingleFileSphinxRunner: """ A SingleFileSphinxRunner runs sphinx with the full globus-sdk config loaded, against some target RST source, and returns the parsed XML it produces. For content with sphinx-specific features like `py:meth` and other sphinx domain features, this is the best way of ensuring we get a realistic build with good outputs. Primarily, usage should use ``to_etree`` like so: >>> def test_my_thing(sphinx_runner): >>> etree_element = sphinx_runner.to_etree( >>> ":py:class:`globus_sdk.TimersClient`" >>> ) Because it's often difficult to know the structure of the produced XML, if `debug=True` is passed, this will print the full XML to stdout, for you to see in your test outputs. """ def to_etree(self, content, dedent=True, debug=False): from sphinx.cmd.build import build_main as sphinx_main if dedent: content = textwrap.dedent(content) source_dir = tempfile.TemporaryDirectory() out_dir = tempfile.TemporaryDirectory() with source_dir, out_dir: self._prepare_file(source_dir.name, content, "index.rst") self._prepare_sphinx_config(source_dir.name) sphinx_rc = sphinx_main([source_dir.name, out_dir.name, "-b", "xml"]) assert sphinx_rc == 0 output_xml = pathlib.Path(out_dir.name) / "index.xml" xml_text = output_xml.read_text(encoding="utf-8") if debug: print("--- debug from sphinx runner ---") print() print(xml_text) print() print("--- end debug from sphinx runner ---") return ElementTree.fromstring(xml_text) def ensure_failure(self, content, dedent=True, debug=False): from sphinx.cmd.build import build_main as sphinx_main if dedent: content = textwrap.dedent(content) source_dir = tempfile.TemporaryDirectory() out_dir = tempfile.TemporaryDirectory() with source_dir, out_dir: self._prepare_file(source_dir.name, content, "index.rst") self._prepare_sphinx_config(source_dir.name) sphinx_rc = sphinx_main([source_dir.name, out_dir.name, "-b", "xml"]) assert sphinx_rc != 0 def _prepare_file(self, source_dir, content, filename): source_path = pathlib.Path(source_dir) / filename source_path.write_text(content, encoding="utf-8") def _prepare_sphinx_config(self, source_dir): shutil.copy( REPO_ROOT / "docs" / "conf.py", pathlib.Path(source_dir) / "conf.py" ) class DocutilsRunner: """ A DocutilsRunner is a direct user of docutils, with no special sphinx customizations applied. It can fail on sphinx-specific content, but it lets us directly test underlying docutils behaviors. Primarily, usage should use ``to_etree`` like so: >>> def test_my_thing(docutils_runner): >>> etree_element = docutils_runner.to_etree( >>> ".. note:: a note directive" >>> ) Because it's often difficult to know the structure of the produced XML, if `debug=True` is passed, this will print the full XML to stdout, for you to see in your test outputs. """ def __init__(self) -> None: from docutils.frontend import get_default_settings from docutils.parsers.rst import Parser self.parser = Parser() self.settings = get_default_settings(self.parser) # set the halt_level so that docutils won't "power through" noncritical errors # https://docutils.sourceforge.io/docs/user/config.html#halt-level self.settings.halt_level = 2 def to_etree(self, content, dedent=True, debug=False): if dedent: content = textwrap.dedent(content) doc = self.new_doc() xml_string = self.make_xml(content, doc) if debug: print("--- debug from docutils runner ---") print() print(xml_string) print() print("--- end debug from docutils runner ---") etree = ElementTree.fromstring(xml_string) return etree def new_doc(self, doc_source="TEST"): from docutils.utils import new_document return new_document(doc_source, self.settings.copy()) def make_xml(self, source, doc): # docutils produces 'xml.dom.minidom' # for a nicer API, convert to XML so we can reparse as etree self.parser.parse(source, doc) dom = doc.asdom() xml_string = dom.toxml() # also, unlink the dom object to ensure we don't get cyclic references # which make GC fail to collect these (see xml.dom.minidom docs for detail) dom.unlink() return xml_string @pytest.fixture def sphinx_runner(): pytest.importorskip("sphinx", reason="testing sphinx extension needs sphinx") return SingleFileSphinxRunner() @pytest.fixture def docutils_runner(): pytest.importorskip("docutils", reason="testing sphinx extension needs docutils") return DocutilsRunner() @pytest.fixture def sphinxext(): """ Provide the extension subpackage as a fixture so that we can properly capture skip requirements and avoid awkward import requirements. """ pytest.importorskip("docutils", reason="testing sphinx extension needs docutils") import globus_sdk._internal.extensions.sphinxext return globus_sdk._internal.extensions.sphinxext @pytest.fixture def register_temporary_directive(): pytest.importorskip("docutils", reason="testing sphinx extension needs docutils") from docutils.parsers.rst.directives import _directives, register_directive registered_names = [] def func(name, directive): registered_names.append(name) register_directive(name, directive) yield func for name in registered_names: del _directives[name] globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/objects.inv000066400000000000000000004175011513221403200252500ustar00rootroot00000000000000# Sphinx inventory version 2 # Project: Python # Version: 3.13 # The remainder of this file is compressed using zlib. xÚ̽[sã8š&|¿¿b"vo‡»Ó±S±wJYvªÛ–=¢œ™Õ7 Z‚lvR$‹¤|è_ÿàA€x_À5_DUÚ–øðâ@å}rý¸{Ü®’«õ÷u¼¾ßüÛþÿÈé‰Ôÿö—Ûÿ{Zeÿû•Ô/ÙóKôÒžòÿ¹þ׿ýûÿ¸[í¾%ËÛE³ëÓ}]Ž—7m}Þ·çš4SÀýêç:ÞA ׋x·\ÜÞB0_ýþã~{²Æþ¹¿‚ 6÷‹í Hãrq¼[ìÖKâûb pôðûÝê.¹º¿[¬74õw-eýa„Üý²]üp‚$ËŸ?iYÙhJJZ?+×Þ-þv¿M¾¯¶Cᕯ¯²ô)£%¸ÉÊBE®—Û{$rƒÕ¼ß¬w÷Ûõæ&Y}_mvÉ×íb³ü6 KYdmYgÅó¶rà4WÖñÊaE[Åêçrõ°£±L¾-6W·«+®5-6ÛÇåN—3î,{¼{ð€ß®7>!¥ŸmWñã7‡g¾ÐÏh3´ÝùQì¾mïøQ¾õd»òeˆw÷ÉzGy`¥{»º]-âUrKinAÍÕ€ŒWÛõý¾ZRó¬uJè]‰–åÍ.NãÕ´8íÓ<ŸÀyãš|[ýÈ~,Ö-©“å Ùÿ¢°ã¹Ø·ôš™Ño€¨ iž´8Y„sÄËõúþéŸdßR†ö£"#ð\dûò@Íâ#&­ÙeCZ3 ªŒ.œ"¢’¬ÞSî&ÍQýNŽÓÕÏ 5D'®ÞÜ/WGô«Æ¨+àD™áXÖ§´õâØ«hœC0î;- *&¶Eqø;ùx+ëCƒH—€F'Qt`WÀ'Ü]ã×4Ÿ¡òŠÿ±¨Òý/X¹0À\Õtl§ô= YV„#+ÒñcûžÂš­þz`„•ZöM<¯Aì ›$c¥0© ƒÖ7"h¥·Ï×x 9yvH[ÒA?:ŸHÑ:–¡æ£Øß‘ö¥äÑ•zZìòéŸf@”ž’”uä¦3\nØ‚¼·XìUFbR¡_¯“Åæw6w²[ß<Þ?N'ƒžÎÇ£Úf°¼<ÙÞ»#0®®ï·w‹ûåš¹ÛÅ\c@G­ë- 9î?йsh†Å¦K·«Å•aî5#o:ÀR;jà×C¢¯ïnWî—ï¶ë«Õôz#qOôíz·ø H¬Üó¡,sóPñ‰~kºÞ2ža’ÄÈp]—§Û²xˆè¢; <;Ö¬O<(ïC]îÝnÂõÑÓ1y&m—C®« ¯INÒ† (’ë,Ï—eÑfÏçòÜÄmˆö¨-AŽˆÞÞNMö/s_Wfœq4+W²Só'iÑÎóŸ¡Õ¼¤ÕŸÃf’ýáÔÖűD”+‹è5O\RÇGÞ«²nqYh$=æés”1'EP¾š¤‡²È?‚’²»DBÚö^ ´¤H`Ty‘åÍÜtäÖ~9&Ç]¹Qe$¿!íC™r%rɪ 0Êh›½‡77‚´ž“R=éºAIŠ»aHâ&fLZMÄÛ®LhB9º˜vX±»VgH\¼Èà¹Yƒ)Å@¿+ÑÅA„bÚ'IÚÄ‹h>œxQÅ׉¹©÷þ¼-YÔuú¡ìõD¿NÙ×J¾ ¨dóqÖæF›¥Îèh¼Ôœ"•LoCà=*ÃègÖ]ö67˜éÍcÁ9c³£B›a|(f|–èæ§{¨ècêòtQø¢,ö)Ò‡F)ÒQ'¬¥| HÉ˱îÁycC/˜RwwQby0 3(±ØÚ9§²¡9©F­üÕnV»$^ÿc…ŠÊömZF2ý–4Øüê ¾¾zZjtþkIÑ3LM±0Lz™Ý4æ;o£¹zþŽkG¹Ô‰FNk£¹½6³·V½á–Z ¨·ÐËG3“ÕÆÐNŒ“Kjë0e§þ¹}ù,öò韡¨­=m ]z"r¤qè}˜µ]zzro£™éièôÝöÐÜ© òV¥¶9Íß+’#ÔñÉê… ´IœÏŒuMÉŒrufÔñ­òáw¤—ïžf¾Xíþ¤ö®ª1…N]T­ ¥kÚý2°‹þÞó w1¹û8wŽg̸vŠ-®¦íø”þ5ù\¿W›à±¿ ,“þm“€ƒÝÝ%aºJšÍ:Eqy,noï$l…7_¦,~,ÖÓeºw±8ü~sû;íoA»õÑ ¶ûýa•,ïïø‚ngØu¥·jx5é¸N›ú‘µ/Âú48ÙHs²0`tÊÍË«'ªê”®!ù1$áê´Õ»W⨲–²<œsœÖ/ni·ŠË¹u—ƒËÏíóæZT¹ ™e£Bd’…ž9*Yšçó/Фm©–ÄÇøjëëPÞõ©’—ƒ¸¥´ƒ!­ôš:¾2yÊK©ÁòàløòxxÚ:> a)2bFÃmΚ4vO:èG—{Ò…—"S5©vH²ßÇáXß‹ÔwÞÔí{¦}í»,àS­¨{Hì{‰5÷W7 ŒØ»Rà®ëï5ºòªÔ$ÏõãödÒc¥ŸXz¸–ë£Ò¼|@ÄI#Å ëÞU^±¯ ‚LáB@Zˆiár¤½˜n,z9ÖpÂ9¶ÖìÊÊX %#xMó3Á3€2#FdF,f#€ôÃFßò@V¯tÀ:©w“-Ø¥†:ª½6Y?Òvÿbh½æ@|`þ”Úú…ÅTS¤¬¿ÜÒ9ÌF‡áÏâ’òxlH 4Ò‹j8÷eªrŸö3°dõ8¿¤ ânLg(IqHöe~>ŸBË%$ qÓ¦uûY®;ò@¾ÍwT}Qêlî¨üÏœ¤5¼‰aÑ[÷3‘†|óFD员ͣÐò5­€ ‡¨'ÑÑŸ@ì¸8Ói®kBà©Pp£žH·9ŸØç0=naPɾ§5ëÃÃ0 àF½‘Ž=9UíÀÁ‰ŽYNìC©‰QOGW7-kcŠ2#åð7hèáèƒÃ«úòÒŸpè¾ì“¯ôÚ&O›—-©òtOVu]šëÆÞ""ï{kâ÷rêV.BWdoi4´Þ:½õѶe‰ZC½Þ”M‘W©.xÁsÕˆÈÔ^¡©¾UΜ‚tÐëMÙ™câÂdŽ«FD¦ö ˜9ë碬1u[Âk´$+ûš°Ý\ÒSr§pdœ4>ì*ˆ¢åª)%S¸ 4eG£âˆ©Æß‹ò­XM½ÏÇ@B"“/«_¸oËò×¹âÅäIÀE³}€‰QõÂɦMñ·è ^—§.ô[òœ5-°ô ¨¡Ã”ýKr¼¬ýØ¥§ÄðŒ“])ǹQsO&†— 3/¶Dx–SIˆÛš¤§-I¡m™DVcIÛÄŒh¹\xþG@ÞuÖ¢BؽBØk›˜Ñ!´óâC¨ãÍö-¢Vˆ@x¥d/¤Ei/0¿R¿ÿ¼»]¾¤õ–ñMˆ7“™NêT¥ûö±Û°y2f9©¯û­_ºG«s EP´?&G’²5‚I¿‰´Û¡:žÞ…3:'ï¦çüK-"Y4ËþW}N:Amë ­²Èj™pµx™ÛÀÚèBœ{>1/¾›u1»øÎjEX|×ÌwmÁæ‘¶¼saô$‰«ÄWåù)7ÍΘê±QvJŸq¶u3mçópÌkêsÑt߀,‚QuER™i_ GÛÁ([’ºÈlš…Ôš)ŽÙ³ÚfeEÆž±¯t—³}}_5ëgPOiCòNo UMŽÙ»ùíGcÐêü­`rHšö•<wè-­‹®Ÿ„ïY•¼¤ÍKR}ì›äÔMßAYØÌó<Ÿ“:-žIƒ¡`?Ø]v ž:Ó¯ÏE ‡È+2é‡ó©JhÞ#’ìUj} ì1=çíKZrÝf”³hÚ§i>hWò”ã ’¡Ó ¤à…¶!䀀–'DØ2¾7i3¸hª®#¶iž'Mö\¤yÒg[ƒ!j“SúNkW²ç¬Åqšöf²WLš2O[L~åä9Ý$oYq(ßlûp¢a,Yë’b’Þ½–”ô£°*m_Â$Ýš QYÑb˜ý‹¯lHròÊßÁrÔÙs‚»ëVlÛvlÝÝ.0èö¥ûz¸é!²¡"õ‘¶¼%m…P­Ÿ¶yötÈÍ'¶Á§vŸi.éç ¡è}JïõèÛMõѾ” >mô™þÇ9Ãóú\ЯO§´8àÀÂz|¥‘n²nÒ#áMúR¾±Î¶Ä 'ÙðÊ ü+«’¦<×{’ð¥Ãz-(kÝ=º=Ù«héítw 8øÌFÅkV—ʼn`2€xôk¼ïUååó3¦wwfm3/´u#ì5„8É+©ŸÊ‘÷¬I§wÞAõ(Œ†Ý!ËQÞØDrÂÆU{Ôˆà«Ü­ÕÓ¾Ûëì‡Pæwq§$вÀ¸¦¯ûþÚRåuö5eñ1©ñ¤h=𻔟_‘Ã×­äGÐa{0þÎÇ×øbÒ.º>ØJe³6{”—A[ÏùšÓ#jzV¾i‹GôF¼W/.,쯟ÇîV+¿yÿ%×èö[1ð(lÍdÓ`cëÏÕù¯H ²ÅV€ÐÚØCÓþȤûâ6kZdLeÏØ*ެJüH˜?Chf»ÊJr6Ðé_mVÈòùËj¦²/vå/R @³/a¶}O¦Tf+ Óó!»½ïi …xÆDf@FD±¡Ð›ÞiupÆ^p†@3¯FÎØ_{•É?‰´IizI×!~Ý&G´›ñý]™rf½˜sK|áà¨íœ§Î€–Ø'3µ´1>©±w‰µ~¼©¡Äµž-™3fhÃ’eY}àQ`´oß‘n˜¬J·<×u7·³2î)á„ħduÙòaøä=C¦=Œ0Y‘Ù¶ÂKx]šV¯ÕåôÚÙª0²nÓ €§j#™12ZivW8ÖöÛÂDör'è _‚?ÎiîEbÊW5ÑWiKvÙÉò5;«™=·álÙ¤â'šS:cyqñ2[næ ]ÊÏøùÕb·â[ _ßß^!ŒIxK£n±&[г»Üú¸cxOwÜ‚žýn½ÜÞÇ«åýÆ+„§WÑIkó¸[ù¹e ÞF¹ ½‚DƒÓÇÝ?Ö›ë{ƒ§ÇÞ†¬°ºÝ-ø·W‹ßcŒE‰éP6aàÊ«ŸQ‘È×°dÊ Âs »z§i;ÙøuÎXÿ ¦¿=»€Q@’·)Ô)Ã ÔøÒlúÇ¢8ô‚æºÂ,Ç,Yr]5bÕx)Ïõ§‹œ²âÜ’Ï—¡Ý÷—OWi&>]æü'é|ôÓ ýq]æÿ*Ѫƒ-Å£ìÿÏ T5!’Á**H4Pµ…h«\Ñó‹j  n”d¿4mzªÕ{ÄGÖs—fœ^\H}ßa­Gâ:^ƒì„ñî~³û†tñx?ô„õ÷Õb‹4Ä x?\XâܭF$<ΘlAÏŽœnðžî¦Ó ãW~Ó :O¯Æéá ätƒÂàmt:Ý0~éÑ Á´Æ=Ý 0xzÔM7ÐÏŠc‰W!†›è¡&ûçeA’ÇÝÆL#c§•écôÔ±×´±nÊxì1} 0D¯þ"9eÃt+m|ˆ~”Ùcòï-ézJl¾\|/ `ÑÔ"h VhBæò,‹¡Nz+à ØÈ 9¥ëF !Ïa(›}¬Z¨Rm¤Ø÷Y59ëJÄEü"³ …"{áÛ·ež6ÍxêÐŒN*«1ŇQ ínM!éoHkX—2›ô=“Ö¶£¸ÕV¯®£Å§UGz×Ý1Éì ‘zRt1J0ÖÏ´ly—Zé:­*dVöXGƒº–öÿ<|s€9Û·úõ(ú6úÉC]¾˜–„Ì@¢Õµm¼&âU¹mæX ½бSê±ü3sOGoanç&¬Ð½ÿ4÷n,²s[5™´…. ÿÌô*žI›¿“ß¹»ü Ïnè&#ÓL§C4+ˆ—ý" ƒÔ”© Ãc~Ãe&)ý["È oˆèX«0œú…šÆ„UPá~I&ÿ‹ök×-9{4†ƒÐ„§ Á.™5ëdÎ*#íÿs§G@S4Mxª,[iù¾c(™ &§c«rÕ¤9çm Bpé•pÈ(ʦôº0¢ÊÀõJ£9äØRá‹.!fòYûbÜÛÉŠŒäEÚÌ\…á]+/-Î¥_ï$F†¿“ˆ »¦ÇFü©Ÿ @_¥ÁNBaxòf(_I]gâKÄæÑbòÇh"£¤´9âFN'–4p¥W¿·ÀËa%z3œé+à-‹Ž¢*_ ûËe³$e½Ú 2 _y¡á·•Üû1ˆ¤«}„ WŒ •ŽÝÌßÚDæ^ù-Ÿþ 1"¨êHñža«ÂqÁzwÎñåh«%©³$³£Cfᬂ3‹¬™8bŒq#ÆXëŘ£‘åÕ²38€ܶÅ>#«X?Šñ#+'F\¬&œÙ¿ Rv90-L`ÄÖ¦èÅ > °×#"ö5Ää 4!ÂÍÅN骃iuˆ!!ëðö"*Ç“ÇwÖ:CFIVDz‘‘ã0× òü‡œE?<ò[Ge:½Ó–íÉæ@¹Oâ[‰yJ÷u)ìƒÓNÞöí.ý¾pºšÉ“¯éaQ?ŸO†WõÉûžt;#j¡kö¾|‘æ,õP8‚ó ²0Öô¤ÇºÊš*O?VÃ%¾xëq9S"ƒ‰}ü䎗IpHT<ÊÝÄÄÀ~M uÔf‰ƒ¢ŠïÎj}¤i²Ó©ªt¶‡á &{_Ô‘ª¬O)¸êt¨ˆhŠ¢£‰NVá;ªf<È‹:ÍšÔpžÉ|â.x?[‚Yà;Î×w﨟0z%pJ÷j]ñèÊvCZZÙjdh .HŸÊ+èk‰1Õx†W¡õ¤ßø™ ôÝ‚RliÁõaÈhÿÀ÷¶ %AÝôvì:ÏìÓÀJì¬ulHE,?ìMR×ÒZ;¦xZën1hZödæªÜûı§ðçàÅ&â\'‘rÿéÞÙ9)ïÈ©Ûø–•ì¶‘ÓQ;¾RMPí‹ÆˆE¡}údÛ| Bá~¿g¢ Á}¨3øÈŒƒVïHXÔ–HäiÃ&lkx_d000oIwÆÄñÈ31Žío©.LôÝ_ŸGßÒîÔxþO0¶¬ "° á{yƒð„Ó½3‘áúu*[Ìûyì¡ëîL*ú!4`:Ž(#u v§uc•A…,Ãî+×—x¼£#òEÇ)1>’Sg Á2 ',QòãRú ÙÚ±~^æö®v>%£qV>%ÓQVšÏÉûæ³3¿AúòÿGŸX áf>±Hªf0SP¤Gß$žN숼ø˜(qý='‹2ýµÀ„nÄ¢yQ×Ñzö´<^-ˆÞÙ¬¤wt@r蛯…mŒÚ„Ü/1Ó›‹ÍDÐ8‚¤›°ál>7ž ÒÆ—ÿ6Ÿ³q¯ÑX ¡˜y€ttôIC#·‘Á' ŠÜ„}¥h(P3Œ:+‚ÆšŸ¡j|õbÆ¡€ŽNÍ3ÆœhÀÀŒ-!Ôý鵟@ŸŸöl?¿¨,Ïo ¶fôüsÓU)@îºJÈm­[TŸ+L7gÄÂg®etÔdÏÅù„JžàA ß”¦¹g0ô-”k dèî£g'mÚë Ñ£)£ç“ò)öþ Ì«Œg¬õ xO¤iÒgTÀ„U¼2'6OU¢MßoË}ŠŠ¾„öèÈÈ. ôì|ò¢ürDãà£}™'åñhÛaÈ͢ة"Bl“f£²-Óp„ ¸Ú*éÄÞE*H(Bì©!¼Œw¨ˆ2’gy›¥^U¡Ã6‡z6ŸgªZÆ*ÏöY‹ U‡õ Xï@K.‡óÔÈr7Oì—áVæòpÎ?ƒ¸&ÏYÓ~FâÀ)C˜B¦m&$™ n^&@á› QTü å¼Hª‘Á­j¿ ýŠ¥²] E.ü±“úÞ;Tæ:k ~õ¼lï])87®%‹ýç¬&»—š¤Ó9犧 .jiÄ,‡gˆZÝ‘–ý³,Ú½’úã%{~Ѹ`´ïg4¡ÂUÅ)Ùs^>ɯ"ù2æ´’Pßusí ¥õó¾<-ÖÍÐAšÒ6)÷yÙœkšµ Lx ÇÏ(¥ PÏ‚m`ýõöÉÿõö øõšÑ¯f«¤×µáiÒLÕæ¸èˆ±ÑINÙPMLôp¢el_êò혧ÏA˜oHûõœå­aƒÌšsÂ?Ò¤ó†=ì2d“Ð[ùfRaø[µÌºÃ)€íÎëƒÒ˜‹SºJ»ÁfGõ³´‘*ýÐ#Y³¦­®KÕúr|ë©eyªè®¾¦u«Vù)A´W}‹Q`Kr’6ð^ª„CôRe]–¯#GØp(;¢îH§¯p/1a{U³Q< d¯*ŽÎ6¨³-ò^ tpT ìè”vlu$,Ý‚Ik§5¥‚§ÉÀ„ÍIŒN›-Æ^y¨eïì°¨ŸaË#TpDÞaÓõ í2=7mŠ£½\uúSâ²h ºëð~Özòf±2ÌÞÈàeðâCˆ}ŠZŽàÛLôõÄèi=Êð€Žö И®ÇÞõÃBìS?.øhßúxÓV8@í³‘{Ö¾8Hí‹­µï"}ƒÙ,ÀÖ\òS«®µ]BÞã’’,¢§óñHjÛ±'"^S¹ØÔ90‘b_˜"l­Z€(HÂN¥e÷awë³8 ycÜ|˜Øð5Ó¯Ž3¼ÙAfÂRy±°Õh)جܷ²üH…‚Œ^øÊââF¹ùóó#bƒ#•qý¬QRËhëôbøª0læUy¶(ÍîÒiò ,œ›°5^ly™Nº:²¯””°’Eœ\Ý?~½Õž(<Šª~‘Ù¶H1(›«òlxÖiöу6É ¡ñÄ+£…¹3¯´Ê—S¯„¿Ç^Ùµç¾2¸}Õ}È­„g½Â\‚2¥FÚ\ •¨­á>ÔzÄÏ0¼uj4A1wé;’ Èí´}#"ËRmX:•¥ AòîOòWpþŠŠÀ_',U’w’ßÀø ß&,U’w?ý¾ÐÆä?¸ÚôpЭ)S†è¯ˆdü™Œ¿j˜ª0D¿!’ñ2¿i˜*¢ËãÐîOsÏ?a—Ó£Û»Ç"‹Q^Kn¨([†þ£ájx²‘iUÈħ@ékëÐW'?ÈÉd–çñV=kd•HM‹àlFø3œ‰qyÙøÉ )H¶ú7_m.F ÒÊEX¦5?L·ºé`X/½¨Dy›6mõÁAH L—ds6n52"±n.Ò2±qÉ‚ÕGa­t’á÷ AøžÖSª"“eÜdOM?rB§I=Mi‹P¬€[Yù/RÄÄ2¢oHkÅXzt*¥¡2Žìç<ÌímF„Ñýø…ál8‹ v¦yÖÒæõÉÒí2ØÎ4¿|¢Ï¿©|¯bè‹ôßê13‡§;ƒçQW©ô$zsoÉn«ï3a¬…rühæÄñ93À“ÇmæÔÈÇoØŠ‚¢([þ bƒp)De…ñ§¸P–“eæw=ïlPŸ›:‚ó–Xwïgì öG!sp@ãMú*µ­w8ëkè#¢mIÝDáó;õ/€©‹÷ÔkK´†ÆŒvÓØ[RšŒ7 ‘lîõ Ûdè¿ÎinZý;Ÿ<‘Á'©’«ˆ_Üuþ ¿ØûkH¡Ø·E— ¢TKæì5¶4íqˆ{ÇŒþÞqϾçd·§½Ä¾w& ±ÇÍ@@ç€b³]Kˆ½ï66êï´[Ö{Ãgói¿àí SíöW×2†Ñ›=ñÖ“ñ\°ÚòôÀ›e²,óܰdâyßœ+¶sÕte~M× Zp̺éPl}³×̃é¢è³*L”ÄÖŽ¾dM·P9n ÇXZú—(QÄ0N|&ÅäJsÌWG¥y•€ù%¼qôo–ìÆý`ÝËŸ}dè»iUY'Ë>ù3Q:Výïs}*“¬Ôù€[˜ô7t´Å§°Îö”œ™õ £&bmwQKzÓÖç}K+jcuîeÚd†=óÍ”À°> `\‘û¶ˆ¿%_×»xrÂéKÚ¼h®]ß-nœ¯Ý\»^zwõxûèìâîñv·~¸]¯¶.úIÒ½÷8)/ö«#öuò”ñ–Ѥ>÷f@ !˜ÜøF§ö,ù)¨Ûp’ÍÊ™Ç×ÙizëTqö†DfШê(Í+4] õë ½l K"Õ¯¶ú»8Û^JÎÖpXýðyU‘âÀÞVmÓ'¨)±„ýå.ƒ3É‘•hõêìÙ´ªØÊ`Ûî[Šº™“fWrýnÎɶoÛáä­ýßDïŸø¶Íî犠!…w¬§‘^&LLÕþ4¡?'U¬ úÐ+5^™.1yç½ìËU4LÌPÚÿM²Ÿžâ}§•°ìÍ ™ÂÑf«¿v‘;÷š¾¢ÅÖͰç¸þy§Ҿݹ!1¢²L{—>g{ó‚»'Þ¥Ï$¦'qy¸„)wò£%õë |çé~ @¨ôz2±@¢ÜˆÎ$VÔÞDÈñôvÕýðˆ«wDM±DvIept¬ËSž5­7¹;§Ì=+ôåŸÙºÌ—Þ7ßÌä·ÊÐÌãø ù×9±ÊÈEûž¥4¼Ë‹ƒˆo©™—ðh=',á ‘½ý%C(wÅ0…  ¦ ¹ )tv¹Mù•&ë—G±ë¼ÍN™-ÉË7«*B£Ô—$ÌY‹y7?Þ+º첑 IJ¶RFÊBKÖ&ÝNÝÓÝÌdÅóÿ~:g9ûEµÐ´i±'w¤})fû'þý<Ö’™Â ­áž¦#m ð(;a­4ô7«]rý¸YîÖ÷¤C‘ÂÃ¥äD#cxë`?œµÍXÏ8žÔ*ëŸ:êýÐñkUúï²,ŽÙ³ú@iúT~‰Òg9xnHrJ³")ŸN,i¸¨&W«ëÅãí.¹YßNOºàïlÐØøÛb»º‚ÇÅ)î…¹_JÃÖ­»-OÑc£.Ó·4èké¯HN\ÜÀžæzZÒ"­±µÜ¦©"g´gÊ&‰õÞÞúÊßÕ×ÒsÞèu½Kõ›S8@ 7<'ä{ëõ äfØnòM·¢Í#šO‚.D¿°¬ïk'=ßñzK‡Q„I#ƒ1Ÿô‚¶6 Ýf³O©å¤=“‰ËF³*ò0Õ—Îv6 €í±ÈØ®sæé–8IXT¸du ù9(3ªC§Bá%TߟS¿ñ´åoÍlß:k­´ÎˬªÕF)![ñY-xŸP@ÁºG¢ÜÈf9ÄÈ o˜—œ\|wµÞ¬»%ÏšE—m]*×§UE›±níq£Îe²?hg܉NU’6MÒœŸš}Ušý~YrR<óWyqp¤Ëâþî ʶ¬_«r"ÕÉMq¬z\ô‹|Àm ¢:Â2(¹Óà’ºþþì“FáF­'/CR÷Oî‘©–иTËÌäe`ê{~„mŠ+Ë rÉ„MÀ6ÅD`fma |›²‰ø|ÁÊ^€è;‹Ê Þ·´ù»6· è`¸ˆ÷’º2 ²d‰`ŸäCoºœˆÙæ++v¦´WÊGÿ\ Í‹•Ÿ'å— q2ˆ‚žžUüœjŽÂXéäD*ê ®Ï@y.&Ý]r˜~Ã8èE²Ø«Cûw¨bc¯'öíP¹R¿¥6,ã±Ç‘-æÁ¤pXÏ3|ð=ÍÏ^¤;ÆA/Ø‘ÕÍKš'[’ØYlÝ|?ö>Ë 1áÕ“&:f–µð ᬧ‰ñû|¬Š>Tq Ó™x^¾¶50ÎfûúzHÛÔ×®Ts-Z¶¥¼~RñK·'>%L”ˆ*?ê¬%¬0îJ”Kô¨º°)¼²öè“%HÝÈ[C„éÊŒO¬EŸhKNì*>‡ÈxEÝMÙHi8‚DEÓph¥‚DF#§Û.{Užº—¡¥™ë9•õ‡ Å7‹sA$ËaóÍ!6DDòFîi©»ƒˆhIK!*HNN>•eŽÅ²×S‰V>æeÚz€ËšÈè€Ó®&¯Hp•§{â“k#EáM&WÞ¼dÇÖ—å”¶uöžœÎy›Uù‡7] ž²öe¨Ê7âMR¶­Ö!QÌjÎOmݽåàÅÓÖg¨¾ûä”GB^IFûÕœ@5Æ_g~Ï·ªl2e¯šé]#kÒú• ›r¿šì[ƒÔ\hM—Ó¤ŸWðK¬0Ë®+2z*(²éßq·éQù°vÿB¹HòÅ—¤{/PދÄä‹/‰íd{[ ÆÓíÉûìA:Þ~ÂXá3¾°hIÕÜ6BýË ‹ýWC"Þå¢WÓP}ñ§ºî‡”p <Œ”ÌSÖWCkt0 uM¤_‘®‹ÖÑ‚· "…‚ªòKXÊ›¼Â'yÅÁDù%(%¾ N ðɵ—0¿Òë$q;ôè°øÄ÷Úfâ/¡‰ïø@áîÒÏG¤ZæÀ§^ñ2/ôåÓ„üâá‰yk©7“ß׸ß×ø´Þ×Â/! úñ"qŠO_§l¤ýò9´ÿ˜v+ i1áø8^Xé¿|½Ç-aëyKØšZî­ç-ÁH_†ÿˆôh|ŠG}ù—ðä»úL|º?<>í‚»À—ÏøY"«÷ÏÒ£bÿ,kå—P”ÝÃXÊ(>üë¤$ž~&Î@u.&0±Ã܀⠗æB" ¦ÍD÷Ÿ§ý[tyJåÕGöî›;«ÞÝa 52±É´¸À$MC·¹<€$j€ÁëÄ((°»Ø¨¾µÜFõ¦eЇËóˆùÝ((±Á;ñØÞû¤#Œí¯›ˆþÛÛ'÷éŒk»É>Ýo=!ææîiO{Âè¾õ” Ù›öèF뺸gÝ®üÚmIRŠžd¤“‡^oJV`Âvû½úûú~¸W_O ïÓã:óJ—×}HîãdqlI}]ÖÚ§_͇rê†H–/Y~€Ã¨CÃ*Wî+9–5äØâ6Ý;#®ã‡TÚ Èáê¨J- 8Q¡gXÕ¹egêMŸ½ÓqÅÇKöü2QeÇs³W]‡m¿ÊŒíÜ`8”ç§œ$m™4ƽ°öeÑï'4 Ÿ9 kʤ7ad/ëSÚ&lùò§iT5ÙgÖÍ“¼¬êú²Û^r?“¶Éž]«@wu”9—ÿž¾‡7 ±îêȽ²51€WN«ß*¡N&'?hÝk{Ö®Ö—{h¶Žª*õ¸70Y[{sÑR·?UÐèt¨¨ù X¿×S‰¾ *žY=êj"A"<"Å¡BDWua¢/)ú˜—o öÿø4©& s++¾°J&øâb ÂTÈ)Y[æð¸P}X`Ögz2®¸Nyš wª_oa<ÙNá´Æd<‡Ÿ(é4Nõ\#Š%¦-äèÁÒÜã$}p¢uï€ýu0tØzU=©uCq¥íXaã¦De«öJg¯o£÷H³[¢%´WÚ­%yðH½¿ ôKlê9˜µñho¼Ý+´óô>Õ²#«R_Êñ £¾›C°ºé{|¸;¥p”Ÿ@-®û]}¾÷ŒéÓÒ–{ó©÷ÆÔ•ûˆäÖqL *Né ’“S6öŒäëùxÔ?Ô~âßè“uAZë¬L¡YÙ‰Îwi‘>“ƒéd KßZ…[:Ú *ÿ’þÚ’£¶Iy£ßÑ¡œÙÜŽJµ!Í)D"{Y}\¥m ÍÆHÓ‚3qÐÔ6õ>ßÑŽrÒn;ºQQièMÑ’žL´hÛX{T”Òë ÓPY gŽË|Î|Úúãj|S(Y£å/Cò®[©ÍtL-CE¿È Ô°•ḰyxÁz¤ÍeÜ3ðfà:˜aêJ&Õ“kuüé"܆×ÖSû .zÙ)'âv¦áª öaÄvª¾»Á…–›¥©5Þ7çª*k}@;œ­=ŸàUM™mÝ\gEšÓþØåFÀ£]‰Tö]õhgƒ¾ÌÜ!'Ÿh …ôÓ&ø(Õ09uÞ¦VLenýFÅÚ]yM\¨•„õNátü#ó—!yQÓ#."ï¬BjèdÏEYKk_UŒã|ͤc½(s = 3Þ5le8.Óüá|šøŒÖ‰:å×}\£œÔXjþÞóÕ‘iŽkçD´Wk Ù°h fì5iÎyûyØ&wÊ,ØÚ¦R£çxW¥ ™ c§„ãêþ€Œöyƒp6 XË@œßV‹«É\‚}QÇ$ëÍz¦ ¢ÑèQøò;Èj8Ëp\¨š*a½S8-Â2ù9¼?²öeU×eí“ø‘$P.¦æËOSò M¸ 8™DÂÄþOö Êċʲ)Ûõ©ÊɉÐQüaC&À™RL ë8¹Y§?9Ìzc5O}v’"Y‘µˆÈ †|‚ÌU*ëû@²nB“"ŠôHÓÕZ×µà(}¢¥R6ë¢iÓbO€ÕãÄtnY=gV4m(Òøü´ÏÓ¦§pâR8Êê9¤Î^¥VÉ‹—½nN Á[µ^L`º%Ås mç;\¾›0}ËŠeivS:~@M› úzò2 ãfÝ]¿":Ãy7Y §{Â9ÓªžoΡJhFR<ÈLFàx¨áˆcf&ã êÜ”« DTzól ¦¼õ(xä4\U® Úgx¹È’í_–å©Jkh- m[ ˜|º&Ò/ŸAZe‡ð´_Ký«¼nách¿r}ù—Ï$÷ ©J“üˆ˜B`˜I½QRâó{œ‡{œÛçážÍǾó4¶ÑÏâ-\¯á¸P³_q˜Gƒ±é^ì÷hБ÷5$/âáYŒxOxÅØ‡g®×\òXÚ)0‚HYàÕUMåjµ“Çù.² ×åB ‹ñ¬Ÿõ¹ó~,.gþLø¬SÊï‹-êyÇwúOYï¡o•_`ˆ·ÓM=%îí­YÚ_o¬m¶`8†%5­ðrȾJÊ'OÔ%R µoÞÌÐÿz;dûö“Èý²ÊLŽxÝ]{eÙôò ½gÙwP€¿! ¤÷É;@Öd-þCn}‡ÕÐO,Øøƒÿ+û ˆÂCM–eqä[üMN+Oöü+9¦#Bé(ÛÛ8 ÷%©÷$Ù'Œ$'!8’·´.Dì½ñú¸9×äTpà¬)ó´ÕoêÎÉsºÿHÞ²âP¾5ɱIH±/Ý@NGÇë Ih%yEÁÏLŠ×¬.‹Ñí˜äÂÑC“?dZ÷‹·ö°{0߉T¥”럆wÆÜTôჶ…·w‘&ŒsÉX'XV¤n?†‘‚’[‡Ë®N’Ï-9²×_H­kx —&Ëíj±[é‹„ sµŠwÛûßa ¶Hip§ý,0:È«µ¦¾DX§+ÓÆX?±ŸŸxÎOÔª¶PÄç"Yl/]õû$  ˆm fíÓ¨²æ„® C´zG%gõíó²!–{¼ÕÆê]Ãè"=eŽìZy~‰Çú†¬3`àö žÜúè΋¹ J‹Ï£Q4[ò‰ µ A oÔ|Z4©µeSŠç¼|J󯛇÷­ýiš6­[_hÓìÕ.+ž_‹l «‚Ðà³zÊ…Îî)6Ë'Lˆ&Ìÿ£kRÜ]fi­â,g˜Ð§À™Y=Šž™_§œÈrè]^ NÐeÅÈW$×#º|˜±eCæ[³sbÒ=;äí¶,+XùPÀØR¢z0ÒWŸCŒ¨:†@É·æ¾ê TªO’¸/:Ú Î̉¼ú Z¿"6„I¹-_‚”/7* @œ±wMàãž ZAYGZ…¤ƒ D$²)‰ky½ãf$®BS"*á ަ¢ëÙ7I *8ˆ¿ Ln^c7—wýJµ}y:¥ÅeKX­¦~ˆ.R<€7sÈ|ó[ËÉŒ.pýj5:.TZO…éK›˜ê<˜rå›ßÆœöͱzd¢™Ÿ6ÎÚ“1&Åa;l× ?€lÕ ubò[7ž—#2H:yÄ)Ál/uªpUQ$ÛHûã8:  ¨! 09‘Iÿר~&Åžtëy5ÎÚ#LÔü‘¤M“dÝjd—C”4 û²Ø§­¾M³¢Á3dE•§l¥Š§“§&ñâñŠg>¼j‰Ä£Ü›k_Ó_aÃY«ð‰¤Ì6ä ØÚ^²éTµ„_Å‘8E†yÖsÚ߇Eðž \ž­¯4Zœ‹VÇæ‘´ £e÷ô™” {žg'â¾çÆ2$cœgú-æ“Ç¡Qö¤NÙ@úåHËpœ×iƒ)í 06¸à”« ǕܬvÉz·ºC¦kÄãJ¼lÁÂ^~{¼þÇÊ'í ïéŽ[˜²3Ó1Ö{øêÄ%^Ë63ކÍQEDÜÀqÂX†dÄ6ŠÙ(ŽÊÒ/Ÿ@Z†ãD6ø6cZ™ñ-„†«x`ÃtßTÂ#»¨²ý—ϡߣ dê;|´Wº‡P‡½ ‹Bœý@ÞQé¦8¬&©aCwh5Œæ}f6»«Å‡°³Ïå³ Õ…b0¤‰Líö  ·o©Ö8Ÿr¬ã‹Ñ7éØç&ën©±ÏMÚÊø’{Ûýnû±önûÝögHËOà| ÈiØ`.#ì{Øl ûŒŸìÎUŽñÀq8dÇÖŽ'¦Jó» iUÝ6Yz£úk­{I  ¹¦àÀ–©FÏn‚±r—§û¿Vïé¾…)rX¶‰ØY•î²ìjXT;‘À:èSæÓâ¦+Oý L 5¡¹Êš}Z»—áþz˜ê ¢’€" ’Øç 4Öljhì•éÖôÌJ«ÊUñÇI¶m &’—ÇTmò _8©U£×¢úÐ/\d¿›[~oï¡‘šúÖðèm*–=iÎnV?Ò_ä\]k +yß¾ç|3‡ŽŽæµS­>'fwNÚÈþóÜ´´¯Oÿзüö,[š"£\(YôÚ&fûÓj/bR}oˆ×|×3eJÿhí ¦/Üþøßlj Q".0TqTµœÒÃA /[ó”ÐR5ÏX†eÔ/”tÈVö ‘WÆö+5¼^™;Cûiž}J_‚l¬eVSßÁT|øRx¤Æ„@‹Ž£ôâ0ÜòMi,ªØ‚wL}èŦTðà›© Y`f*=˜Ú´ewÍÃ]y8ëÚ'þ`Æv÷6o *á§’:¾“êKy¨¤]pˆ´ ¢Æ-9•¯ãED"ÜHÂë¹qÞ©»<"uœȦ;ð=k÷°Ýnz$K%•‚q éì##I‹”à åg³v§Ë1„¥ÑĬ?•dÞQÖŠù‰±Ó(Òÿ½nÐÙÕC}‚3¨Ë¬ÈàtHO;Jx6åßPãçþÅöçÜ4ûɪV>§:‡Š¥¦Ö:bÉ ^$Y‘ S»x–î0øš™‡ðQëÀXǶÅFîhËD­Db–·’—ÍgЛŸË9$¼ì…N·øäË@Oµ™œmrfÚôx>Õ::tmÆÜèÀÆo݌؟ÿ‹_ò¿„Hÿ»B€h #ÎùijŸ‡©qè7ù_{°On úöØ«eŒç—IÚÅ–¦+ömvÝÈÑ •Þ§Ù_¿0cÌÜ2Æž-º 5> fòÇ‚ÝúCÏáðѰòâ|ÈÚoe©ŸQùhì èEF*sâ#UOËwnH}eÛÄÑ‘“}âž"vuD^‰mõÐD—KÈDz>¥~æeFçÝ ëvÝzea}ƒÌ… …LvÍ#·:ŠvNÇRRœ0–çnƒ‚ð6˜âÀx#,qó0"컘Oä/JÓÏîäíÆY}Köd³. @~¼Ïó«Û®€•Þ½ãþª¢Ž†æÕŸFÿ0Çš€Õ;2 Ãs•ê5Õ¹:¤-©RËÃGB` Ž‘%8ž”à Ó«Í:k °E0à–@ÔSù Í‘€A{£Ý?Öű4?ge¥ÍNJWADÙ„«hEO¥2.›wÑ- C[¹¬Û½Ô$=ðùñù=º…‹£Œí T¹l¸- ÌKÑ`QÛô6]ªñTU%½"9i ÜL‡Ã»éuõ´Ës]“¢…»Z±ü`»mö qGãS&yP%n"Ud: dx]Ëí.‰è­¡^_¡­¯¼ìPU)¯Â5iI3uÁû¹|hd‹=k³Ènò–¤¯])D4>’U´чÕ_=94¿P;ÔüÙ{‹±ñÞâÃÂ5UJÖïj>Šýê}÷#€#ò¾Gxåmì–3R‘äoi…H0Eá3€k^(阇O˜ÞÆ#ÉžþKÉä•Ïó°¿ÙyÀ¬k­u‰%µŒ‰úVÄNö1ÕË ìϤEú’pS²°ÀÛ 5~Ž'G3ï%˜ۦIŒ§’ÓÉP]•@Âà±$Ƹƒ+ÀpFjß–z¬ ÜáLp9…íÖû08LLáʆL:€m\ 87‚´ÂÜ ‚Ò`ƒÒLƒÂ¸uVb£cÜ”I®ëòt<’dQOÁQ©rÌŽ«5 ä‹â°1ŒIœ ööI%'—ƒ›¡Q¥’E³cÊÍUy~ÒÏ÷룡"#³³©«‰ìÈ 1À9˜‹–L ’XÂ4ûŒåûèc€E/幆ú5§t§¬8·$ aÃó> á94#­'×e~@f@ŽŽ2ÈÓàÀHî“ɳä~Y>Kï—]³ôçÀüw‹Ÿš'Ùú2p·Þ¸_[e[ÙÐ쎘¨öSunu/zSºmú†qAa>F˜êHú@ê#ßiO? f°" NDM%,,2ÐÏŽÓÝÀàƒ7Špu¹-dÿÀ’ß#ÊR‚'ûh$Ù‰–w|h˜°ßµÝ†_¬<Œå)Í ó[ ‰NYO[YöMÆr6òkо¤E‹ ]õ Þ n¢Æp›,··NÏȺ«Áׯ~.W»õýÚ®v[!r»Þ¬Ü¯¾XÞ_®$€-ÊÑï\¢YƯ6ì÷s¢ŒBbصçv 1†üCà:xu -KW©Ü ZÉL»aXÃwÄ@$fÜ£ÿ´ÆS€Յ•‘ 8:*ãVT¶„tÛ0±múÁ.{0)lyùެò¦r_G>^ÏÞw‡éŽÏìø_)°yb€(ùJSŒiàk¿1 U Z1tdˆ^¼-KM‰ª`DàˆL© Û嘣Á¶Ì¦`Ø1§ûSÿ½N‘^hè °÷' ×Fm5> r9Ë@A6ô¿ ~:|`ŠBŸÒªêÖ> àÅ™ƒhËë‡óø§´Á,2Û÷8zŸî_°È<ÇûÕtpäàKôäH\³¯ûqXtƒEw‹ÈpÀñ ŽŒñ1+Ò[‡£¨ÈîA-Hs5mÛÚ[bÁؼyI› 3qÀ&yÆ•v”ºeÊZd΀E·6 î ;&…À"K’áÍq Àq5©pÙRgû—}yªÒg¸ñ¨¬Oem°¢ç§}ž6Øûz[§¯´$â‚õJ–5ú}'·fŸv“6ÅuåÞÒ–vN8,IåÝÞèx08íìµÆܵþ¡Œ¶¿.€"ò'y¤¬Œ,Š©æHiž Ô[˜;ˆL#+ÌŽš'-²ýb°¶0 È?cÝÊeßu6À@ðaQWLëIJ¾V—Qy$fOUïhcÅ€!  ƒ g3²¬õ¤ñˆÙ¾]–¶©È>)w¸(±Ìé”/"™7’¼…IWzTDÍäˆ`¢mèÕdíËW%G\mŒX\žÊòvlj5ä7¤ ´¶0­6¤X„EïÕC IªzRûÞ$@JÓì¶5qlŠÛú2¾ÙÅ8Ñ­Ðýz;„¤CÆH¡3¾™f G Ô;%‘ÈxXµY»;S!~9NyüäœçÿuNóì˜ã’V‹• åkêB1o0i6Öo-‰0#ì*)ôõãJÚ¨ÒÙZw¹´9édM¬èšháeà‚û1äyÜÞ¿˜sÓ½ÙŒ¶Ó銴ð°`2 «pù…±0*Š„q^ÛH†ˆæ"Ï•&DˆtLˆØO¶'éÙVÙ wµ©±Ñ>7ßÅdƒøÈü-m®IÚžkHþ^@ÑqžŒ ©c,}¹Öqr³¤†_“í$. M|~jMÛ®TL”‚”G) Ó“'m}ø-Œ®£ÖHµ%éá ͯëv*#I߬8<Ãå—F•~e¤o‚ÝÈÂÉO?vµ=j4Á,OvLû“Ê<nB°p¹[3<²×_Ìçv%Œ_.aŠB‚›‘ÒQ-/ó‘3¥ä±ÈØ®ÒW„ýË·eN–ÆWJMGüI"RÐäýæÏû1{š;ü):3Çq„”*Õm >OŠþ۔ş"e?Ð!ˆí̬¦eS’&ëN޶Ec6ÅCÛ‡u¢·3£(26óžh°¸ôOŒÂXö=Ó_°UëÒuÇÈuoʬ+UišÓòÜYšÓ Ò¶Ì©Å!jh¬†ÆÖš«¡³:AÊz²¬Çseý¢æè"¢rÅ+W<_ÜV.¿%¹u˜°a2³)BØF7 ;3:"cÓAߺµ<,OoÝÊèæLËÈõ´uQ.À¶.:š@žçê»gëQ‹CÔÐ8X ­5'VCgu‚”õ8dYçÊzˆ[7T0DåŠV®x¾¸¬\:5ý;.çîKm,vuZ49¼{Þ3µ<ØJ©75¯†ËB„új¢ ç}z#^ƒn_LTáìO+ýôl½70…3?©&Et׌Õè8dŽçêX²F»¨…ªqà*;T‰÷^œl š‡­‰±S­[ušÉ_¾þ¾[%W‹ÝB#ÛmY€FýuF“ V~Jý÷õ†íH~J÷uéfé >5_¤æ‹>5_©ù+>5 š¿êSóWdjÍ"^®×q[fjÜÑø4É&dåKZŸÒ mPÂG—·›Áe#68ØDº>ü ?ÿP×âRvbUam\ó¹ᶉܦmVümîcQ²!KÜ}]Æh°;Á‚L¿M߆¡g³O+‚ö©'òñl°&Ë>.ã¿"Ü2X4nÀ¶ÆUõŒ¶-`Œû²úHŠ3G1©G¦(—Ô&2Ô×—W¨w×ù¿èÒ, ½¼ &&ÿñÅÇÞ€ö´7š˜ü†óõ›§¡ß4”ìÅ(à ]C=Ú¾ÑጩÁ™Ý3ÕfŸBò[2ò›¾ˆ¹m¾gÌß0~dÂzo¯]’Fm¯GÂç¨ßš¶þ^t¾ËÞµfµ±²Èß"·`ØaþŸp7—wÊ¥OŒ¯•ÏûvšÇš¹¼d>~<îa´Óᢜ[„™^UÇXgÏ/a)Ù«£>cr=MÔ¨l0§ª+Et½p’,ö)¢XrºpM ºèÛ4Ó<—2ŒšóªI•µ¬y©¥­>X³G«7©1iáѱ.Oc’‡þÄ>Ý@å¥|KNiññ™mù¹ìŸ¥s©çe}ädñ§dè¤èÐÕTÏçQAUBÔԶߤöd:»[jwÁas›½®Ž9“ifÄõM-|˜òd¤ãw^dp¬gÄ:}·Oìf˜ñQ4÷“úÈ`öhÏpÌü3/Dy󣽸 x䜙ü:¾"ÇT>$’{#ÞÇÞÅ„E;™¥§ñªaO.zÂc–랢 cÓ=+`ïÀÈîïYn|lÍÔå>ͱ¥­ûF´s`d÷IµÚ¯ªIAb`)÷’–_Óò޾Yˆ=tDÆžA=CÎÕ Ì>•ØÊ‹¦…–ï>s<ç±(¢}Y4ç9øØýÌhù矫’o~:ëøå¯EF}ØÌk•Æ3&®}ò¥‡Ï9þ™=}´¤¬í²³`¢÷Ë`;·OVÎ0·ŸϦRâ”s–J/Ëù6Í0µåÆYË»¹+µŸ™Yÿñ_®þãKòD-˜è½sÔÂ홃6æösÂáß\8åÜ\á ÙÔ”g-ïrä®Ô~ffý'¾Xý§ÀÿÓÄì^3¯W0M´þUô?CV™ÿœÉòpÆYÉ7?uüò×.ó>óõo&fÏØšy½bi¢õ¯+¿…¬+¿Ídy¸ºâ¬ä›ŸÎ:~ùk“ 0rô?;66î*yä€Q¨{ÅdIÿHŸáÑ—áÿ¬’‰*F,"ØÜuW@/˜•À?¯Sð¾õÏìºïÏND°g&ipH잉×ÑÿqNó]‰ºo Xô:FQßÄOõ<9ö™Ñ”Ù"j¼Ìéø‡ÚQ)DàuR×ñ²,^IÝøº|‹[Ã,Š›ˆkÒÈm•7ww?Â$·Gb;ëIñ Õòf9¼ÇÊ@Ñ‘þ“ìå÷4\­0Ñ !ö™ªž ¹ÊPO†®U:ýN 3ñ.Ñ!«I‡A¸÷ÞèQ‹:õTè`kÉ :õlY§¡C½—4£ý ÎÆäõž‘ѳP˜‰Ñ…ÃLéQH̤^Ù;¥-ëâeˆf?xËl¤ÓÔðU7Œuyê_Ñ7oœeO£Êà±6xêfF ÙC‡êàî׳"ØR5Bñ%᢮gþîáê{[ßܯÈÚd¡þ;­ò´oËNôA%[À£w&P}Ø4~e¨F €ÿÌ)x´0¾ÑP‘/^ ¸-AZO‹ÊN¢eg‡Á«˜Æç®dA¤ÿ[™`O 5¤¢Úm‰ÈN.ª!ü#U¿q,A „.—éî?“å·Å6ù¾¸}\ÝÈp¼/ņ(a8ÜØj‹|JßqSNÃùÁ"2mW x†3Pt;¢Žú\oÂE»cä=®ã©åúšßñ…®U y9DYáHŸðvÒzZ¯TjˆIŠ›¡€ètÂZR|*õ´êFÛãx¢¼ ïl§šÜ[ÖšþDu-l¤Ø™P§GfhH³ý v'‹ÜFDT71—Õçð"7*1QÇUžÁGÌÅ t£ÀÝtªSFÚµ G†.pÂ<+Hƒ ‡F¿©HqhŽ:}·_b5Ô¼À |F$n*ÿ"¬'ENæÏÑâã§%Þ¥Y~2`k ߈ôyÄr‘×S£rfŽ™3³´È{Ñ/:Ç ÄÞððèÇ m=o›>å$<->‚zbíñÐÖÐýØ®wða&G!Aâ” Ù5ÑáB&¶×4?“ tuÖâ&LGd´v‰BåõÔØL˜¡E—s•¸á1Y´«w}§++h¿E‰Ÿ²—Z>ÕÓÑ1ž€t› ¬B.û©ÙÕ{[kšéŠšY³NÍ7­º…œ(ö²ÛË3žý:«›öº&ØOƒ‹µwñ0•0LÒÍ9cSkiýldÛl]fØTÚ=Éó×ÔroFÒb˦’~Úçx޽'#?f9)ÒSxZZÀØÀ¦(C3çésèiMø„öë­,òOª,¸ŸQ >¡00Êб-r¶D>4+[ ý  ïYŸ;àxi™ý„ز}ƒÊæž–Ý…¼r3PX¼ï ª+'Qß;NÔ㾂ô¹ë $ÃÜ“PÒ¾w,¤¨÷ý §ës·Ã)zÞ Q¢aî”(iïû(JõO/À^÷ œ¢ß¥Y•Í[Aòí; D½{(Uß~Ǽhì1ù{O>Äæ|ì?ùàÂŽŸ|г¯^Ó<Ù’?ΤiÙeü’µ"âa3¿s@-[|ɲïûä¡&Õ–ÔiÖvâ+ìô[3ëË4Àƒh-®fåÊ:{þ$¹›eò=k²þ(rmÏýyßœ«ª¬M3 ë¶ÍN¸,V¬"ô>‘?¥Òq=”øòȪ&ôßkJÈ'¿–†ñìCXL<}K”.¶fEoÓ¦ÍB¸çDíwÆæeiw+ˆ}ÊÒ=³eå[ƒ°«îÒ¬¸+gýÞ–çv2äÌýŒCÉÀmY1T¦1°°ŸÔÂé/Ð5«É2'iýƒ¤¿¶äØlÊeßbkï1oô²š ñ˜!doÉïÉiPÈNu.èÍ£ïòž‡é%±¹»¦™­ë'&Ö.ºíîfñéê ý¨>WûÔÇ;vè6k„®ik¥ aE/:¥•©c&q¬ ýÚ¶†!Y4Mö\|'uC‘»T»„ŠEÂa¡È6ëFã¨û”¯Š¶þ@$zÂÑíq”ægü*¥Õ’‹šµx‡T#ìßÄ:ÌË}OëñÝEžÑSÓÖç}{®‰û@Ûª!éÊÂÆC2.ùÆr¯7ëC¦ûºœ×¤°²f儵hÚQýÜ âýlûT—@1ÕÔQ2K[ §kÏU({×C| ¡€¶^„ÑɨªnÛ›l…£¢‚½}„{é4åî¦i>U{Ë–P´¬»ß“e½ ×C]¾àlp(ÞK§ø'dU9`©·žÎ¸j½ù˜¤Þau¹€º°wèó!k¿•å/aü$Š'‚_W7ëM_›vßXVéêï$àl¹]ïÖËÅmÓ¢µ¾ßà‘_œ ·÷Ë¿ƒ\ž³ü`œ2§¥ÄxýÜFYVÖéx>ZÒ°é|Çâ;^ÏWÚ¹–µ‹Ê”äÕ‡äGZ´_ç|NÓAQì³·Úïñßšñåíj±y|HâLJ‡ûín5m¦ùÂ0Ú½hÏŽ/É -$2/øÒ G¡Žà~ï›]²º½]?Äëië&»UÝv÷°û=áM' ï¶(ÜîñávD^/nc(fs¿CvÉúŽÚ»[mtoGÃõvÛG(ä«í½#¤:‘¦±8&ÇOêˆ놗®”B_+§šï)M=gåÅ–inØ[S?‡ËWﮀºü)œÿ›•c³z³Ú­6ßnú7;WNR:Û/ò,mL«Õ^¨BcÁÙO¡˜0¨ªS>ö–BVdÔo0dN[»¨Ÿ÷ yÒ¡Ñ€ó¡Ó¦BÔÕñ½àã3½ëâX:–eþnï)×[ôVhÛyí\”€é^(¦¿$™y$™DU¿–´E{#Œ›ëíj4qQ¨>Ôgºz'ûÚ—ÎÞÝ1©~ouÃÕyÚ²ÔO]>Ó˜]Ÿóh­nä;Éè£})‹o%Ó¯—u|K›—‡’¿C¡C¼Ð¯Íˆ¨²ŒYE¨"4òlÓâPÒ1{ÊàÎ7œõ>ní0à1^/ÕQÄÉî÷í¦mƇ©=Æ¢;… R‡uµÍsQÖdU¼fuYœHѺǻ€©; <ÞÅeLͲgŽÎàÜÕ!û¾Éž×±‹¤§Ð±ƒ–ü†vrF{•ÁÑ Þ“7mÚ9>v9Ý1 ¬f4Ñ;°47#ò‹-Ÿ7ÛI ]Üð¤JÍKnœ1›²€¦ŽAà‰ãB=î>CE.Ê…z†’mÂyp.œ·®c”ÛõÝz·ºJkÍ’¶þ_¸ŽTnIúJð³·ä9ÝüÈè 󭹎WýIrîñq{ÈJgèÝâ§Ó ínu÷uµMâõ?ܔݭ7n×Ý_%7ë[þøú1ÖLë^!Q0ÄÝãínýp»¢wþÝjû°¥CÖmÌ•Íë%€d¢Vƒ§ Jã¾¼æ²|¼üåݸVÑÍ=dÕ↼Á§4eø^n‚ïçFv‘n6©‚É€;Ã2q`çOX"žlÊ8kÝ;›ò±!5ƒ\ñ½ÇKþ:  ®ÐL¬잨^Ø®OUNXǙ߈v ~_‘‚íL`[Ȧ'°îA¦sê¶´qH¶‹n+gè˜w¾o­Ÿ!’ -1%z:±#˜³2hÚ=ÏÑÛ@™ÇÒ{±k¼~¶FXÏñŇ?ò‰þÅ•ƒÚ럧,GÉÿ:gÄ}¨Æ·Þonw_à½]]/7;X‹ÞaÀ-z/ÕSÜv èï¯i÷uñ»{Ün k$5›úëµë#5-ûxù¼4²‡n×ËoËû»‡ÅÖµ Óµë#u)"U½2MÑG #jnWíóÏ‹æHÆG0` …¶ÐivdçЉ¥E¬›$”£k.‪¸z’*XöàsçëGcî£ó³ÅdûŠ7"ð´ë¤á2c:²Àg^"p`â ÙŽöoÖ׿; b̳ dß‘Sn(z#ôQ‡„‰^J˜ƒ‹'Û=\ß.nâäë‚æ\WÚ\Úq–¬~.é üëòvÇP<Á î_ˆƒ¢ÖËZq¯ÿñUûë…ví–äÛ‚ö®×›Å­n¾È{³Ä ¾¯–»ûí’ú‡¢W‹DÑXßÝ=î_o1Åj½[ÝÅÉbÇ–¼¡·ë½·÷›,ön±Yܬ®xñBB¬×ݺçдՃ¢V»o÷Ôí*^n×´`ñÃy­` Üj¼ú¯ÇÕf -Cüu$ln²2‹Å>nÖËû+4ü;m®hmÝÆ´iIv çpAû ˜~ÂåÖ¾K¾Þßߺfv¼­‡\ßÃë|ýÕýã×[Âõíýb¬7 ËY{½Šéö_I4/£[@¿Ç¬×JLüí~ ðÞA£üp»X‚ròZ¸YùÍ—GLf>BƒÜ=œ_‰ú¸Œÿ¢NÀjcW~q¾ò¯ŽWnÀ/P÷¨+×â²ÞÙæñÎù¤;I‡Μ7Q0>|[à¬P Þ SU¯VËõÝâc¦‡bí Ê*ëúf½CÙa@´®ª0²çi_i±ü;ÆÎÆZº¨«Ì÷?V[”%DÛ᪠#-Û«íz‰1ÓC±ve…•?õYt÷z°¥Œ5uQW˜ã‡î¦¶ÄX;ªÂ¸[ïpÑá@¬•NUa||xÀdÄZéTÆäÛúæí–o·÷7‹*> ÖžêDUaS ~>%´MÙ‡ªáçÐßÁÙßî×›Ëw1ÜœB±PUV¼| -±»Gß÷G(.o.Ê*+î¾ßÑf¦÷ýÝ=òÛ±V47ØÝ=ú;B±v´7ØÝ=òöѱf4·Ý=òöѱV¤Ûíž-¿-tS º§ íº;í¥ÅÓùx$59À–%~_ÇúZdØp¾X¦z4Û¹÷"<…ng%"`ZIZS®Wëk³T¤~*÷åE—7J”kéÿéSöÚ}­€~bvø‰Ü^৸¿ÀOÌû!?‘/ˆüßù‰Y,÷¹Z\î'øqwWçÔ‰…îSí¥ý¡™d±ÆÀð„Š4¢Ø^°Ú½äm¨œ0@qÈN0D·k2P“ôÀNსš—´&¾iëì@ èüT i]qû’-š|W‹Yÿ±þâ(;é&;†Æ-wÅvnZVTgMÙ4l—ÃVi!ìý·d²»±æ…ºSyHöÔ?ß4Áa%÷åú¨û‘œŒÇ*¢ƒeSnõø³ »{)¤yç|ŽqcWGìLÌT0ôÄ ½ƒ‘¾;ÝÈ1!§sÞf/”ãÒíÆ ÞЛ¸47¬yM4›uOûsmîI¿êdgoõÚ¦I6«ÕU¬?ÐÀh˜Ïnõϰ¢þQƒp€ËåÉÃ_Ú™lIcXiýľ·`"~±„IpUnJV7ër¬y:~æ;wÒ´œ]Í ŠQŒ£.^æóëdÔx@f×^ËÛó:ªëy È9ùÒËÆì«<¿›Ùó›KBŒK‚oäô²ÃÁY¤½ÊöíC[»í{ CF¶C+d ­°Èj؇=ÍÙIÍ­2þ‘`öåƒSYS!û®?»ÕÁEÚcÎ8ùžÖ:JŸÄ”;vr‘¥™ä'))1³-›D  Né*"á5lÛreÍ«IÿBRܵŽÓá¶)““}rÈŽGýiÄ“ð@G@GÛ9ßTБ8ÔM@ $y§×GÅùSd"AU¾éõ´ÃVÁ™ˆHv,Ôåf™™Ëu$DæJ$œË䜢CöJǦÅ&̵&½w¦"2 ¢ÞSùÖ]ÓVvRçCÖµéäZ¾CFß³W§ Í ~qø,€7oºqƒf¡ßSV¤õ‡ãÅü¬”gÒB.o —óø•µÃÅì˜wGÞçý+;™»ë¦hÆ<ÚYnšÊ´mkW‰îêÒýòn’‡VqÇë3ÚÙsgo§’ú‘Å9Ò²Rèè7+þ8güÅáù@³„äݵä¤p¼’öd¯¤ÒÿÒúÙ1yÝõŽ×$'iC@ù]“Ê5³ëlÿ²?UŽW7ô^à|)¤4° Њ(Ÿ0¡™ãJÍ.e'Ÿ'wnÛ:eÏiˆŽ\[ÀÏò×ñØL@=¤“7kÎ>uÖ¾œhK²ï6ö¬>þßø² EæÙSMN^ äІ¦E?ƒ#i¡ÈžÎ-#¿ÒJsÙ( Œ’dŸž’$Œ"Ü@)Ê‚íìGR”lÈ‹‚å*­MC|z‘hèC“¿~,M•g­OzšóÓóX7Úeý•Ïë{xK!C£ýKʶ\£tòFÛ¼– hÔå/ReMƒiòúž%²fwû‚ô»õâÚÜ~‹W’­‚.ölÊs½Çܲ·lu(¢aéqŸ-«EóQì×l“ih·¡ýÑ+_ ¨ñ½ Ãï¥,¢5#Éi]„5=*C·Ë€iÀD¼Ÿ?ïüh+0SB?š–œyÍaÀ®ï±1 Ü¥O`“;ZiË3¼ßÌß±wº­³ÃjÀôÅëñÃÈ`þKw¢݆†Fûàh½îÆÍÌWhl¿5^“´) 4¼;TŽÞÕiѰM౑G´ laŒ¿z¶‰êwwKýƒÔå[+éµ$ÉÐÙÍÆÛ[‡áB:ò9†Õ¯„þ?#îòuì.ø÷ÿ%!£äš¤ìWF±ÏÓ¦”U†)HKíûâvV­©XÎ’+4z©gÒÞ¥ÅM—|l»Ç­Úyçy5“Qóžg`š{KªDL±[ÕÑ媰jfà>êËÃx5ƒžÒ¬0‡îËiah_Ø 3zP÷ Ñ[Í/~®Ú´ØJPõò Ãn}·ºÜñóÜHDÄ…§_×GöDÀ9›Œ»ð[Ö‘þÈÄ‘ ïÂ8ú ݱ•ÃW´h$lù„â‚‘yŠ”AÃ-ƒq]p¾l˜!⥠@¨/Œ,îQºg«F õHÃ&.öO7{âNÕc¦ÚV»MTõ¥&÷ÓÖ{ÿ+á‹[Ýã%€$&ZÕ ò&ÔYw>ÊXuG‰éj~ÿ¦æ÷_¬~.W|ËÊiµ•ÀS€ÀÂ6qâïC;’Œ× ?¶ë„c¼^äÈ´ÈGí¯þ)6Ä{¡·ƒ¼šýzb ‰Vë@ËŠ·Ö”äßÿGú´×æ*ý|’£ì³Å×¥®0^.\wGh„×òµ Oüê—²ìLÒhD×ä9kZCd&¨ô©akZ.×_®¯#F(UÕeEêöŠcÇŒd{˜&k›÷éþ…$-{^í ;WvŸ“ÓÙ¸¡·¾¿ø™°&i1.R³­ææh˜/fÑ Ó}^*M®¬éGo0m Ë7,;jýbZµ²¾ä:†.ÏýLŽë個ëç*­ûl˜¤´ÿršÚá‹Å~˜Wœ4Vâ‹¶òÜÔJÌ»×å“ó°DQ?ŸÙc”+rLÏyÛ|#yuÍùú\„¤¦ç²Žðì/¬xæë´Òþ#lL4T‰y•ŒðDF4Ï?£üÌv FMï<ü»ºñ$£ƒðWÚ¾³ÄóégÚô&Ã6Úq”áÃñžùfk_k^hÃäUÿz*v›;tUÑ“‰"»:"Þ;eïäŽóWQ¾¡é­)_ˆ2Ò…("öΡ3Ms)iè ÍNØ[8¾–%3Ã\êöÉV: 31ëc¿¦5CúÜîØ²Š¦J÷PùmúvÅÞÊxÒ}P¦í¡(êôÃÐÓ¡ßhº9ìÓ5AÊÕQZUj/ÕÕ½>B+ó±CÙòÒ·´‚âö´8Ö`Û# ¢Y†ɱ.Oݦ )cp9mj0¸~s8(4cËÎà ö¶5ìüd˜ª·@«\¸êÿ·/íRGÚýE—;]Ý“ï¼IpV2Í6j™/>ÂàNc»,;—þõW’ X¶"¤¼çÌtUa=¶P(BKˆž“W GÉ«7PXž ä$O0R’'ɤ„ˆ{?}»ÝÐÔuùÀü08UÕï´(Ëïu(ÿmìn;õg¼•näBž(¼ò–D†ñ“hà€H'Lœ+1€ö é„A!A ×à±nºxÌXxŒõR³Z5é$°ò¤RªE³ÚÅÕkq~ê)É43‰+©ŸÒƒ>è{XrNÝZ+uq„´Ó õÿ1䆨fÊ\WTxÒU¦›ò‡f—³Y·¨%/Z)'Õ"ÜpB‘@W&É™[T3m¹ß¯›8ãByJ#ÝtTS E”í„¢4¿j¥s~é%“îØóe‹E‘ê',‰µ’?EI’éÖNW5=%NÖŽR×V+/ l¥G–±"Pú¶·€Öõ=aû%{¢70¾æšÉ­t³ƒf2ݪ”÷cIÅËÉzÉcÍdâp°ŸWWb5Ò¿ê3¦™l™èþ;áæSàæzƒlîžÂƒñœœ÷ÑKZù Z µÕçŽB´SÓ-C™V¿(;ݵ¯nGìDh'ÍÕ‡ï§P³œÚkE"!Ρú)Dµò)µ=¢ÝÓ$;ê¦ÃU±>¡¢WqÍ £'k{ñ~’~xuYÕº[PP”ªH'í!|÷äÝÝøØäÃtÁòÜDâ‹·)J«VT^õ=žÕÏïaìgòöyk[ Ç=EÏql¼¸ÞÐÆ]Võu /ôã-Éô„>ü$’ï[èòŸ…YíùDsªkŽÖép1(ù‰^´Ór"¶þ4 ýû'¢ÚÞ)?vìñða¢…‰ 3↓0IÖsgëL;m™îÿ䄽ܔS «#®2JÒwÑ9*K¿dI>ûèñÈce¥ØöÙÆQç9á‚%ªMU éýyZ#ºy—õ;”冰ÅFu^¹:šW­¨êt*‰¦ÖàYË MD膪<Ã-+üv‘Oó ·ØeýÌeé–ZÜ ù$jö©mÂÌÛä‘[þa¿*(#²OT§3´ðû¬>ðßmépÄžØ ì¹'­GÃg¥<ìÛŒ×!ÉhµSŽlzS gt%yâ'Qï ®Òô3ˆ³ÁU@:.Z¬¿@pÊ3 ¨ ÊTÆBQ«¾7%äÇ­l Œr=N½q¢Ð™î4™¤}纤çè‚2m ž1!ŸE…-µ¿Ô‘¦å(hàÒ3IOÊÕÞÑ)•\5ÄûÔöóT¢Žô–ú8fy›*À¶Ò-+M^F}Ê +kœBÚª*qŒû4ŠŠ5UG\ƒ†)‰ƒPyª¹·hï%a]ŽžËÁºq’‡‡ ^uÔMÓw?Y—9…^x‡$CL¹SwÌÈÙl„Þ²Œ‚êÓuG,oëØb5œõîh4ŒK›¸O™cg½êÿIRÀ•†„õc×@ó¹g–p,rPšú½O³»5Xw)ñ}ð„¯Ù”awûeräÉ7wTP›¹./C4*iÄ2QÄT^gU†¨×,“/'bs†h@k UT,ž¶CLcI„U3èÁpT+NÖúG¸BQn W'l4 2(NyH©?J°Ž´ê’ ì0\h+ŸÌÀ2Íâòµ°œn(1·…;èÄÓxÜݧÁÐr ”W®Ò”xÚW…|mÔ¸îóðü§ E¯„ý º°ç0!u³"¶P2±ä"Ö3Œº`^Åž‚+ó¡Hp´_3 tÔç¹Bar˜ÎÛë08Æ$Y˜daþ1néºÛÔ2sh®SwÃæ ¶Y%nDÏiÞï6÷ÂÅ€·Ü‹>XãC`/Nò^Ž¿’0Æ£Ïä½÷‚¦‡x©]6n‚_uppv*ò y3hEáuxƒFl…SËpß¼ÓGðTI1ïòF˜V€à–áÂÃ…p’E49üô5]¹Ia“±Ú¥0¤,â¸FÊd RcWb€@ËëààŸQp—¨éÌìr ÃÆÌj°¯Ð@ l­ ÚàºPà­.W¼åY>m4~%aDö‘¹mîÒ쵿s{ûE€Ë5HÏÂúôÆßª¸†ÇŒ&‡¥òh Áa¹¬˜½)£a"ÁBÅÊ;}ØáÑàÒœîækÉ0£v*(wd¢Üœ+þ³˜ŽmÈtýøÉ=nD÷Wž¥¦&n³1 }çŽOôa‡LµÃ$ÞG‰ ¨¾ganÒó%^,FÉsÔ°ÛjºAͦEddÀ§Ð¢œsÐâÓ<“ ÅUª“-Ÿ2[ ¹ç¥¡¼`ãÄ•ŠÝƲKwùoOÄk´ÀkèÆuêâ“ÇÂÀ:#Ñl‘<“×ÐÔјPÄ/aY¤[óžˆüBl²ÊP‘1ɱßÖ»öݽå+PÆ[®’¶áÚSí«AôоÕ0ÁÐôÙÃ0¸ÂÕƒÕÛƒì!ÐÝo¤È|ý</Ÿ_À”N(À;Ô½\ƒÛÁ=hfØšEl4 š‡¥áz¥|޾|ŠÁË+õ-‚|,ƒïLU/Æ!*RÅq˜á´JÅÀÅ€—¾ŽÔ€$y;Ñ7^£<™Ñ…ÃpeúxÌ̯ꕱ» GüI¹Šñn¡Õ€Q8¦õ¼â0:£xC¥ï“kS Å_RV‡¼#qJ€}ùàn˜65ûÌr;§0*{ݥ餪ù<ñfvÖL wfO/¢ä•š’Ô‘ÌMyŠØ“x„C0°Šn}O˜WF PÕ¬Mɱª‰wŠòÄ5nwÂËÀ0=T—Ô:„ìD£ÈœQ£ÓU,²L\nDXjj3ñÊá¡9†Ûà:„x$ùIùnNö÷j{XTsßý Ú¾a¤6®y¼´™Œ‘e{´eT“R…LøWE^­Óƒ[9dFý+ %üõ¬Ù˜Á˜` M¹¢uu\‡î­Ù4@ÓÈ7ŒHnÎÑÃVj')Å{¿hX’ÄvXª 9P3B“ ã’¡\Wöˆ/ZrÉ’û¢V8EåÚ¥¶Ò”2§+gËzº ¶æþ·-Ò$Œs¤…cÊÄšs¦Lƒk š‰nÌ"䟬éÉì)Ö“ð}ºÿˆ‹ó^±L܇¬_¨Õ!K«W'ŽI?¬ÿœ?Vy½³–5/Åúz'VlýaÄFÙEôºÄÁú¼‹‡’çþKq³ý%LSED¾;yƒƈäï ôõ[1zïyV]ª©_zÖGŪ{8Jį"" ›IÑ¢) !uV^}{¼’')p~’…òj§+˜xü$—‚PÛìÕÿTbÓu³ã>½ÿæhаÕÑ$]«Ú®™~t9EhÖª Œª—záYÊÛ`àùp0ùòíögÈ…X„‘ãhlCU±Þïã¸f¯o²A±§P¥ãj‰Ь‡,—Sñ²7)m-°¢þK»qºŒ[=hó”àJ‡BÕ¦Xý»JL˜†ÝOõñÞí©?ô^J»†_á.ñV`òeï]–­ºš£{¢ï8ே+ 8nÿ… +)°¨J ¦’‡©¤Ÿù¿AÁ¼Ó/Dýx£DÕû0`á¬>s?¬Ä§ŽA%¾;ó¹@ï0^D8`O@d³ê:"ƒ(f3éœÒ¥¯,*M½Y„w)XwWï»ø#çNyF:¯”²</¶¶3©“|’†9‰T·{¡ò¢– Ë«uYŸÁh°·êù^ð=%q“=CaÀAYr®4+‡ÁðòaPŒDÜÈGáÒÁàû† Âc˜#pQò†çq?Ó§\æW¤)¢œÊXÇ} è¯‚Á›בx9ÃÎä…ÊÀC`¤ŒÃ¯ôÛûåaô4£‡ð fÅN#Œ´e(­”áTE†’šÌ ?X!†|†“8\f¥¾ß GÎpó²†o$õUQpû€8•&‡o¤:wӇĩ¿ªƒcÝ(¦oU1œEÅàÖ[R nE1ŒÅpÖƒZN a5±4=T2„¥ÄVCXH a1„eÄVCXD a 1¨%ÄÀVÁ lý0”åÃPVÃZ< kí0„¥ÃÀVƒ[8 lÝ0œeÃàV ƒ[4lÏeÉ0œÃFˆÁ­6‚«ŒÕÂFpu²VþþÒ½pô÷—û5#þÛã¿L’3ÖŒ%!ô`'Bì—íÞiêÇ¢‚´Sêƒ ÚÆHÛXØ+|!«sófS0/ŒSõ‘õAŽ". dÇSØ•O•VnäʼnvI̹炃Ի맥”¾ÀÊ&Žžÿ‡¨÷OûPÊ‹˜Jã‚ç%®Áë£Ú‚ßµÂ}›þz¨è ÄÕ;´þ:K𛡷J*-¿Ý)*ŸDâfjÖ ª?*Q£ñz3ëº[u¼†ì¾îÜ- 3i•òv°iÀø¸ã“÷—†€(w‰uxŽ4¿¥º“gáœÄù‰7b¦Ò|Xbùb‡æw;4ÑT½„o™K] *ÞÀö¨Œ™˜%qþ $³ÓL%“Vª™°DSgâ, LQM»w {OÎãf7Þü„åó´™MÇ@Ìóv1G*Ò6tä3&¡žD„:íaóNÕe6+”q‚Ó÷ÝlÕÑlkdV+K™=&¯RV X@ld2U<Êèì0® W š”Á%šE=’h!jÔ{$ Â1ÂÊŠé{Ùó†åi³Œðv׿ÇK¸jü÷nF,bžˆ¿(Ô’`Kßs$Áb¼™<à ½€N‹Õ<É,jñÔe¹ú†˜jW“í rÇÛÝ\)×Yo%twðöÛ>ï6.µs ¼ôµ¡•DØ,¨iBŃ™&:¸ÞTË"p;$˜Nê8‚02Ì$xE“fæ–f˜b|w¦K„Ü·=‚Ž5•¤0‰È~ŸÁò¨ZõQ·Ž¬¤!‹(Iá8ª]f² Ѩ%Þ¬‡— ÏH|¤plšù$ÂÀMÛáÝÃDp’ãŽCgx­Øt±Q½X«wØ?vß›à¿ß¯sÃ|¯Jßß,÷O  §"~é&_îÉÅü\éGýL¸Š?ug"¾Üg"%~¢TØuúÀ”÷]”éaä$†¦el!huªµÎ„ï)„7ŒºÌ<é_ÚiY‹^»$ì Abªòëb˜Dɘü·@UqtÚ”'ÕnÿôD`­Ÿ†ÚÔI¤ž)»™ú&Lgz *€jö+¨8 z#'…NË thì`4)ÓßypuòVºÿ³Tyol+¿ Z(÷“»AITœcÕAM¤z?©ŒÆ¬„ïD£ð½‹a¢ÝôœæÊ›è0 qîŸHÆ@9…q^>ªà¢’Wb¦9s5i‡$¦µDk5Bš4eÓÀ„:Í€EÊ(4.Î9¨™²"6l#%³`ÔËÈ[ï©™k`}¶ùN%ñ÷:Iü8‡®‰¼2=á6iÒ}Væ‚ï ù–?u·¿Eª:¥ç­u9d„åa_Ê ,ò¯\óîYµÁ2à‹ê¨:ˆ…%Eæ›ò°SòÆ>✼ÓzÓÁM^ºW¾¬Ï¥~²ƒCÌ^"ìÇKb•‡Öl‹¶ÂgÊè³Î¡È~\-ºÌŽæ:±÷èÀÒÏéwÛ§ßpÁJ¢xå%w S<ŽBï_úˆ‰øC¥3Téû.´ôâhŒÂͪç~ e˜V9;g±!8àaìgôÌ6•ÉlP•…ÂSõÿ—QÕ³~«Bäò¤€Ä‘•êÓµ ¥Øœ]pzé Mº#F@É‘æ,'¹):dœ>3+Š´iFh‡’£•еiÍiSWŽM3À4[³ ÕQ¿1yJ°Ú/0`Û·Ñßµl:¨—8dv% [Ó Ýo„j¡Á%lgÆkzªî‰yÒ2g0eÜõWé ù«Àp]КxA¸Ö, ÂuÏùX l)²Þ2%ì2%ëÃÊ n(1™aDAà0â%ÉK‘b0^ãê#…e2Hzîv(¯  0u4©Ÿ¸`èç(èàC Üû9ë§=  žìÿa©üÄ#Ù±‰ж`9»Oú…&®hE* ŸFÑkÇz.„¢ÃAÁc–›ä/¯­ux ŠŒÉ{­qb‘£QE2J ûâå-‰£ rq?+CÀq’“½Aî†Ý)à&­G⬠Cš0yëÝ„œÀROþ*HdØž2ð8«¶Ë\°º¥+(À06U.Fv¼[Sý<)×H'å’©Ò°n¿’wA¦¡z#¡¡¿.{ Ž¢ò‘CÕbkó½£’—o“ ãEyï¾À7*ðèLRõv 6‹xPR>x¨ptyRÂ%"7(nª^×ÃŽh$g†¬JÅ"BܼÐC–sÂr)^ŠÚD¬Øç=›\š,9Ÿ"3Š" Ô++níg4˜†~Žêá~$_%ÏÆqaFi’rŸâŒ#Ú1š¡«TƒG}ñ´XæULvTؼ.ÇÅGt!J¸Y1ÈÞTÊ-üÈóÎ!c\ê<gƒµÉª¿{n.'ê—?48Y5Žñq„6NÛ ut},‹úÝ8]‚$ý0ëâ¥jÐ÷ܰ%KÓ–TÇ,Ô&àf®QÎä=¢±™L wǤ nÚ’etJ3¡ÏÍ(’íÊŠÕ´ /Òžc( ,9Ó ËÈ#¬V¥ðÒÜrB^¥ñ«1´öèDU;õí‹Yª"K2ö¡\À“»œÄêCÄÄÚCvÜ«{¿Eqe¯h, ì)bx,{Ec?Â_p,ÁëÛ¬äjÞd,õ‚÷ZîèÒæS½„ê]ÅkÁ¡ïpšCJÔ—z Q93i¼Žã'±_dbvt(ò"£ªQp›¬£ñî’Œçso²Z¬çÎÖ™vžBífUÐ=fÉ wêyOÐé„Äbã•ý‘l`¤í2ÞŒ(*±¶¯Pi8>v*ò y‹í’ûs˜Û |šmÜ­Mé) g½­–基¸$‘€ÏÒILåë§=·0ܾ”rë„Õ3•–8Eå-Òõ¿‹ãäU^ Äq\-°Xb¯I~FÕË÷Œ-W_’–Mà%™'yxøðìIë,~%Q¸Â¶·©à×YâSÆÖIYÔõÛ“8ªg›4<Ó¤ÈmÖž0¯¹ƒ«´L@ŒiÙžÕßj]K%ÎeËVô—f¶Ä.öOpíp)ážk¦²ºš]öÖåã#a¡_^IK"¢Þµ¸å»#šÈ¬›2RŒW«¹3^zîv¼u\µ·cuÉv²qlщ™—]6{º”ˆ¯ík“•gÂÁ<âÕLJb[t‡(!ÖÊÆv¨N\9%©µ®t6ECœ,°#e%mr½‹@oVøzîœÀ‰¼ž¥0[ùÞ k¶«-ðÉc,…·b´)¿¬}ØÍœÌN¿ö]}dšiúÜ>\Ɇ²“´éܲ¥LùŒñr³ƒ6Œƒ+Ž)/ÍÉ´tWŒVÉå¢N½bve SÊÅø‡7[nÍz5‹ oꬷÏÝ˃dee+‘{–÷}Œ È-˜Pœ=GX¸°ÒEËÄN‡,KÃKhÞr¦4òfÁr¾a±a—ÞR¨ÜÝr9^8SOßÊ´n’òÔÈóÄe-Ŷþý;ZHßsž@åUŸ»|£úÓxÏäYYy\nRþ¾ 19*{éŠTÁfNÔ*ޏuÝsi˜Çys7¯V F¤ Ï×%""ù’ªGÄ^•Ê«ˆÍS“¬’âXé{ˆ*§…®4éÅK]Lš¥Å‚•‚[0èúVÿ$©G¢ÈDÖ©—AŽˆÊÆï^àéE ©ªþ}¾h 8‘êۊ쇚TÀFÙã"ŠZ‚'È(¯<Ÿ¹»Å-›Ìœ$)%/Ýö¼ÉÖƒ®ZO‚åÈ΂-8i(º¾Ë¨šDÅwÕ,*¿M.ݧPM ¶ïè˜&CÏZ˜Aïò‹EÏ= M†¬ˆÍ^åM#Žo$3éËo$3î ÁÑ~HŸ§/t†> šc+¶Pí)‘£ÅÌug˯è–(Y’(ð¤hò¼ö<_5Ä Æ¸§¥ëo9*Ýp¯ ÒÍÄðÙjX“¾Î¢³Hw©JS¢uîL Ñ£²Æü[g¥Åïò–~VøyO|†Cš†þKDA¨ðxÊ»]=–“8¿êÂê’J‡õ8p=¸²Û“½š¸ü”%o 0ÊíjeexÖûŽ_/º'T•:y¹ÒÞ÷¤Ð-¶_IߦþÏnµu¼ñ¼ó¥àîÄ‹Ùr¶ËU÷#nÊÔËÝÂÙÌ&Ìv¹ƒÔÁÝnøìïêÜ8T^³T¤•{˜>«qLü©áÿ/£Lqÿ$æ“»êêŽ\Ô – _B·xqq†@ËØV˜LßZávÒà˜PŒšH«³eJ¯Šõ2˜º¼ âˆxr&Q™·8nó{­FÓD!k0LÔ²ì>u´ X´"ßÛ˜Áf»„š‚fÕLí*µôà»Mˆ–sß¶"ÊŸÇ›MùÂYW·Ë$;"†G'æ½»ÒhVÙïbÕ¶i7p2-'ÝäO»ådûsíÀúoEQ¶(!Á²TF ðWšODW2° þ$_túÂÄuy¦sÂ0Èçãîæ[@»ÎËÏóDiYiàFâê—nµ­"Éóˆâ…¯‡Êß*¢0 \¯än4¬CÖ?qr»þN¤y'"®mÇ…í]iêFbú¬ÁŒ<…ÇØÃæÉñqœ“‚p”—ô ¸ïó…Ceõûl‰£ïa “#@¨o2­¢ÐhfSFÞÞÛž°.è˜Ò€‰P„XŠ2`áEÐ+A½Ý¡«öÚð¾7LtÑžzß`˜"%9#ÀaìªGð‰KÔ:Ï@‚RaF$;6v¨«jšeþ‰–û¾(ÞÍâ'(~„Ê mýœ9× å2r n77ÀCºõ/²wT)9ÀtœD„‰ƒí?2 ÌÉ÷öÕÍ&လ‚õॠH¹Â ‚4ÙµÕiuHúò¢|W‚xøŠø%ŠX@}D‡8/vJ2X¯Èõ Ï#!xhP‘,à2YÀ…²€KeË,+J ¸ˆ½&aT`o`-ùW“„g?£$§Õ}‡–]f(âPÑEQˆåGn蔡Áa0ñŒePûn왞ëè4 Pu–@“^ )裲ñ}ÏÍ*~´~> ×ðÌ áE!¡ÖR%ØôEF£C^tYåÁÀÏìÕÏ€™¿ ]dLµ@,?Ý/—?'®÷øèvî3µ€·ˆùjò'²o¦@ˆû-—ë>‚Ð<¶ŽDìæsg ÃLþD4Øt¼Ù¬¾1Î× ´BÓÙx±Z ÷ÕùÏ®{OVyžÏ–À²ÍÇbÉr ÁnލÏ|>Ym–åŠ*µA¡ÀrºDÔh=¦Ÿ/fË AD‡nÀ æþLÿ;0ýÿÓÿ/0ý#TÁrüF@óàÅ#Ày€ëž(\®‡æÕ N}Ô,…;„¬ïPŠh‡RD߀JÜÏ·“çñÆÍ™Þx»ÝÌwUD ]Ô#/ÜŸ Àj™ø†çZOª ³ÿ³َ7ðŒvPÕ\3w o.ØBu1Ö‹²¢\¸åbì(j¹P»Èå¾úÚ™`KÇÛ-.ÜqŸ éÁ†ˆ ·,\¸iáBm n,¸¨©Ù…Ï›.|âtQ“š‹›¢\Äån éÁ“š‹ši\ÄTãbÔù>k¸ˆ9Àݹk¸²;Ÿîn îLÉàÙìÚF¬˜„0þ=O>P™ÄåEj=a~ö ’ T§ƒÊ½ ´-üð±Ïèº8ܤo‰·¸Áá¦}‹eýÀ/XàïXàH ¶§stHÜò?Hàj‹º$pûˆþÀŸž8äXüŠÄ=#»bެßrŒÔnKä°XîÀ ²EÝ·Ââž‘À5‡î9 ݟȉ¦×,êîÿ 9œH¤<{¬®­\\ãË’¨ `/½Ÿg>ÿ‘¨ ƒ'HOĈÀ¬ C¶HübBàdžhÚ Ax s‚cFÒ“ A”¼©o_èœin$iÆFM œ Aé KP¤©Y¾ÊáqOŠ «^€VcïP”¦0„Obq«5>RO¾u Dï3J^€‘W]—Æ@SB‹Éÿðzn@u£zðÒ,9z"\#ÊN4ŠP؈|xI‘§´ÀI‘`™¡þ)"âà-Œ˜Œ°&d"Ö ªs£œó9D„ ‚Â8Þä•AæËN†#s²ï»,§а-pûxcÀ‚q"ÑÑ'ùè£xœŽ£ÕD%'¦‰$Ëá<¡ÀDpÌ 6ŽˆŠQçW¯Æ|(ˆy"«W^ÿ  `ŒâíX‡]€Â(‚ë-q—ž×°Év‡[^Ô}%z& 8}Ç$=Ç\\þY¯!ë¯ Çô-%@Ñà°`ÄÀ¦‹Œá'ð©6NàeûÊ© ËÈ "ÎŽ“Hîåî¡ÓtJâê!fÅ–‚L ÚR(?®åɾºEOéŽIA±§‘*àª6E’çÉÙŒã9 ãlÜœ­xõxqß«µ,Í3C¢“ißæIjF`¥\øš”òé5ÃN=RûX¸J6¥àiJQúF% CÑp— ¦®Pš¬‡åc#XOµc}U‰Î?À ´ˆfg Žcä^J†vÔÖQc—‹cŠÑ$9ÉP.‚ÈJ„™cpÜï²T 0¶H…mùŸûä½3Mož5r$¼™Îg}JtˆE<ða‚?’üD3Ö%•+ªê…ýLâ±2#h'†òmvrDÀwB:Q,‡ uJ HÁ!' õNt„ï1béçÍ?!`(bµ¨š\ÅÛr]¨x~^@¤ˆrÔªŽÀÓø»£¥-%Bgú'ÈP¼àb–g8$ÈGy¢z4c#ʇËz‘û—c€„!3L²€f(à;城SL?¢$Ãâ’2Ënûc¡4‰0Pþ¯Ø/R–›i¨1ÉqÜ Œb4«š€@± T¯(B¡qÏK¸CÈ$è{yª-vo¹¶&.8rOïH(N•4&W­ÑÃgòŽ­%·Z°PÜt¨ OÂ.@àÂÀGé¯0ˆp¸ó™8dŒœ0fX×\(µÅ¡4˱P¤Ñh Ǥ-š'…¢Ž€ÿ çú ö\a@>i¿R” -Áª`¯è™ìüŠÃÅI³¬FŠèÕÕsÙ`l‘gôQ†r¼q3\™R¾·…À¦EŽkâŒy‹ðPl¶Øö½„™Ÿ%Q„¢†šX©ó³úÅ\ 6'q@ã Å ?+ö8}Ä8Q¶<ÿ Åõ ÖÀk9E í‰Åµn€_ÁåÍÈðQÊ$VSäj«",tëûÝúmûÛ“x-ªkéöŽà™%Óx&Oöiïs‚ƒ\~÷VËy×mïAhëu(ÔóÒ„åž<‘ã)Ú}„° TÁÕ@çEª\í„7ÇêîGƒÑYsëϸøgòBÍYú_qWÁ©Ðgª±'?v ¼òÃbüãgwøªkì5h¶„ƒvÛ‰j9¢UŸpíP }>¢ò‰HÅ[ÀÃpî‰ ±~ÓPdPZœˆ&’ï…,ñIÄ Åj“&GÙph†$ ˜Dh¼ø×äçÅ`Úfõçè7J_…ç0?“wlÿŸKÓMâü„÷ãƲ$*òjW“;¤ôhkæ<8:Áörž˜ ùú¨¶M]·2PË¿˜é⊭å_¸uÃÿø;‰)šÂOÎûÐÔ«y þ0èöQÀrtÖ‡¤´ê°y[˜×Ú$&2”ÿ]Ÿ|D È};'2Òžª“6š æâd:; -^„µkhéšX¹X#ÍÄ@37ªLÌ3óÃÀn°`3Ø f¶‚ÉTm>M›MÑFÓ³™f7ÐêfšTü' Q÷Kñ:8á÷1dKü ©ð—‘bXôx)ñÆ¢[ÒØ¨Jžä$òZLP™¨W1€"!`ØYBb…¶çÂŒÆãÇ„WY#Ú½†#‡a£8 .A¨æ.‘ØÆ.ÑȦ.Áà–ÚŸ»7Ÿöçû}'þ[Pœ÷`€üø‡úHlïĉÃXo(’nH’RÕMô;Œþ ãéá9ŒŽe󪎟÷a´»¤ÁÂŒ)Ѫa1}G 2šdG«N©úB# ¨Ó{â®TWì¹;D g1\Ð$¤õ ¹N™Êÿä¦ Ò™ÒÀ´ìWæôwPëVP>o§Ð?{­äÔϤ;úAõí¾XÕï„…þD„jxÏ»z± oã&9§´û ŽsÉŠ9y+†ò0Tqß~Iö Ð'q‡¾jUj.FÖEM` òŒ¤X‚䜒Œ=ctTÒt¶@áÉK“~à‘Zø$º­p 1=*÷µD/"áwFÍêpÅá ™ŒDµ4D„¯ªH(šX¯ š‰ÂsÍÃÒw¤î;œ 2ÏP‹q†ƒ8ÞHÑpÄ$ÆcÅB#¾ê¿LòcŽx¸QÞÅÞ°êÓ,Á#d±£äøÛ?ÐÐ=)‡§Š¡MƯ4ËÍ8’Ì ÿŽ%¨Ö Q@ü¤|c4Ð(×9›Ÿ‹(ÓiIØ3È]âÓÈž'o$CJz©P”Îø \F’óšsÖp|ÝÓ:L7ú« qŽ®tF‚9´2z&a`ËÝÀ¹ñH°ÜèÆNÜŒœ©'¯8#ÄA'¤Zg§ð€TÆìVóI—{k>'b'¹ €¢QFŸ è»II˜¢J2½¸ ÚË †;ê,‡ZL š•u矑}¾X ´R²_Eè¿ôméÙ  Üs´V ùpýõ›–/VX€}UÙI^P)&Èðzûœ2(Rˆ–<ç^J,œ Æ”7ãC7É(VÄá!䣖%ÈR‰ ;‚Còßy“ûÕCU÷ú울I<ÿIöuyJL]²{qR!䯮©‘Ü#ûûtOü`žõU´aˆ‡™¾r­ 'ýˆdG*Ý@2šfXõ”)µ'Œ–ꇬ/ÇA>áƒ×»\Œƒ`)ŸOqÈyç”›#ÙŽ6€ ±n=Ú âjš¤©Ã NÒ„Iç‘Aåëk¸V‘X¦­dÖí2ù&5W˜ €œl¬¨WœÀpúŸKé*¦LäYþC¢œônÕl¡™T„l=ïÕÁuo!ÂÈ^ýre7B<Ç%åE%l“ð"mÚ¸ašƒ•§l9Z©c–k¦¥ï¾fÊCFu ðWuÜG'iF#ݤÅ9ÕL*W5Óêê±$=“T/¡.¥x2×óÕoˆÞrnîxôp ê Õ7˜¼ÈÃHi0–_;ÌÆúf¦IšøÜ¬Ï»s)¿ÝçQý>Y-ÖãÍÌ]-½§ùøkç.P›¢®–[o<™8ë­÷8/ÿ¾?–`»Ù9ÞÓjãýb ûâ¸)âX±` Ä%þSQ·¨ŽIuuev[57$?ñ_vv;A 1ÐwrN#õ£˜ƒb]©"(‚c”ìñÙ_&?ܤäO$ŒŠRwÞ%׃×=`XŠÑ±|¯Î„¢õ(ŽæD=TˆK¤œ°»¹üšd —s‰”“~[çá iÚ£ Ë“Š?P8ÅuAŽ¥  GcEÃýÞ„ø‡Ëø@V¥f’Fœ žÂ÷)c˜Š˜¾§|Þ¦GÛJд…œ½„©i¯±â|&™òX˜ Ï/>¨éÓ™Ïgkw²4œ‹ÊÕ)†«kß;3ø„[ã…¶‹s49ßÕð$müž%EæS4ü ªþ4žÍ½§±»…tñìërµq<燰gÜ:[Ρà‹ñ|ö_Çûþ<Û:îz<™£«"O‹|r¢þ P+_!G¾øÓKäo°±yÍS2È¥lš‰ÝÙÆY¯6ÛÙò+ܲ/¡Þd:{zBà–Hœx·È{šmÜ­'dh·q$;hæîŸ³5$½Ôž”QÎ@BÒ‰W§é9­®xW›EL؆b"J»f>½:Ÿ ²«;8ÐÆu7—ïõ†Ì„¡lí@¸§Ð R‚<–ùP`e¿á;$£Çå4óÊYGDì–ÛE^ãä^™×ægaš—áQÐTX}a.þá•V`‚Â#þSŸÅ€âÔË8½°Ë¤­ƒgô»ÃlÉ/wK?å¯âEï:Bb7®JÑM.<·š°ƒF‰yý¥80䉩u$kÑ> ><ùœ9µIÖ³†b^ð«Y!åY”ÒviK™ç‰q ÅV;^ÑÉMf—ÔV÷„ñM3Úbäõö­ðÝ÷´5Jã2’ ðH•Ò×&iÕΈ¦®˜Dœü‰ó3‰É±Šƒ¡PÚW Uºû:ѤüçâB®ÐäÜš¤²öB/œHDŠHvraŸž‹<UþôI90û9|B¡3ò& õZbyãD!XmÔ½Ôëë(uÅi>WSÄÿ$Ù¿öa¿¸¦Ts‰#<X¡Š“òx€1ѯ"I³G%ôö6µL jèò㣸Ã!½oßÓÝñ<Ë9xJë=Z#¹Ào§Dß³07¦ZPƸ¸×®â±T+­´ÅH&nl¾r!ãhÊ#ÅäožlÒ(Äòò]œ\d_۔ݳã ã0•÷Ù²pƃ¸ÛCc«œo™*”ˆ6ãTý ì4¢e½`¹,´}éŠô- È á¯>>7$ÝÍÜæP€‡Ýz=’Ÿ^dÈ¡×a!iJûÞú$ƒ^l/I G“wébåŒÝK#/ ’” †e¤ý^A«ö \“hYµíÛ'yפClÚ¤å>Gæ±tÈSEq!K#òáõz1¡ODxO .Í>§Ìz: Ã);’ÿK½nœƒ¼Bf“þ‘{V[äBÈÕŒ°2?§´Í›}6b‹VGò¹'‘N©-‹tåôOù ¯Î†[íK}í;{Áå‹ýZm3³ÍœÖj¨½Jݲüüzfû‘ÒÏè’ oÓâ¦ôgV`$4ý§g"â¾ÚÍcJr»]p!lÞXµXܯBÃ[*©ä²mµ”¤Ÿ1Õ•ÌŸd±”=¶i™›øš”·´O¢È^Sßѹ´åôüi9œIêåI£Jlä°˜-œoårŒÕQxÇË ÿW’Y”˜ŽÂø“s¨®,æ±ñ]hNÅ1Ï@dhµì£2ªŒÅj¸|ŠŽèg˜ð̵v³Xþ]uŬÈh`V|®ä‚Mßzy“Fá-_¾{M±z]øÂ.&y@^™­3ej(aqŪ®!ë¹\iîi³*…¢Åꯎø×âBÖ݃W\ý$\«Ë˜ ½:Æ',Bn«Ù¤ h48ñÀu¦2cDc‹lÌzù†¬0[y,!k$yøJm²æ9ñOçþ½S(ik1Ùa&bÑÀ#»Ä5±C×|Χˆ†äÒØ‡pj­óÀ£2.¥Ò4Œ’cAí•ðHm5ž8UÁ‹lb"¶JWî«[¤¬Ž(1›”V%ºÅ¨qË|³zf™½½\c™ºµHc™Ù2mÀÐ6mû´%Ê"ßÅauK”!³?‘sÎs}ÆÂ¥2L&Š+kUÚ*mZG±@¨Œô §’°öÍ@I{ß­ÙW’Юɖf”ÔádìLéM#âS»¶*³?2ë³ûM)hmZq̾šì‰= '{#Ñ žJ,ž­+ÍŠZT\”Aˆòß¿ü="­… 8§ÖâœVgΪ³HgZ¨€3²O)çàõ% £ÖJ˜tØwGPúï`Ni˜X$Ôrºá¤ÃŽ7”SÏù†²ö;à¶'è1ñch5nÄáY™mZm/Ú„]Ï“6ÉAÛ6¡ö¨MØ?ZÓ^ÄPkx×ZÛz­ádÖI?¢„–Y5,g(­æ‚˜v`QÊ7àpCéôáöݵ,¨FbùqRYa=íÐbQ‡npé“T¦ž1W}é@sÿä ö„² Çf™SUë»Â£(¿ùI‘”‹Wvð¿|ùý7O®+÷®r¶HoÙªà6—­9’ª@X–*2ž!Ë!ÉÎ$÷ÚwÍñ4âf£Cý¾;†áHó«‹ãù6œIC”Ç[Ù1 Ð /"/ÿK §2“®&=шûÞ¦¢Õ"C7ɯ"Áצˆáð*î…AL:õ—ŒµÛ¡º®£m2v'³™"ï6G'x‡=/ÀÅ1‰4£)}Þû Wgî¨xÿòXx„ÁYÁ‹¦ÝóDýUí“$çÓQÆ*½æ¸·nÕëB‹³¢¨Å¹£”üG§‚ÜyQ â*©8²*W*1Õ\……qˆ±b/kFÇô Œ¯gƒA=§TU˜ÚLä…|ÏKkÈcá’(ÍKî2&žµ€å&†9<¯$¾vi¬7Z‹Ó˜y|Ù0ÎdúÑdµÜΖ»ÕÎÅd7ZŽÎôò¿[Îþ³s€ÐieŠkVT$çnŸx¬\Ê f·­Ö^4³ÉâÝô®ô‚Áš³Â çïÅÊ{``Ïiÿ^\Ù—U;RêR1ëqO‘wóè[ªÃSõT†ü‰¤¼†$T®ÂÀÅ®&òÇð¿Qî‚—!úØ:á§Ù‚5D(ǧÕf³+¸óog²Å‚ÿtœ5ën73Pγ8Xi<5@&7¼ÇänIMŠ<ÑL*žV&‘W°æî¨T5úéÅÑ $Í’”fù‡>B>®.ÞÛò.Ç õEþR¯ݧ/µÓKÓQÿmuše±âœšørïMÈ_/³¯]+qL+±xÝÕO=n¸5ä: Ärµë~OSú¦Ÿöi¹rwkñü¢>æëx¶ÔO=ßðýÔO¿Û>k'~OÇ› (½JüÙ@Ò?O!©ãÉó p¿B’o@‰× fÙü”Üë ããw1ôSï\}IœŒ—gîè÷Òäy6‡¤Þ,õ{h²Z,‰—Ëñ#ÓÂsÌÆyâÚ Šqý˜r]0ÿ–|5Ü­Pšç?Ì7g£/þÓÕvËÿ§Ÿü?;@jçÇÌÕOý4Þ†Õd|Úþ\ë«Õç•ËåûØq9ýlºÑï¥Ù܈Ël¹Þ¬¾r±wíøÛX²ŸéÏ 3WŒW@òé Pjw9Öoñ?ŸÎõlÐ5²‘.ómõ'2ÿò &ó/K÷çRr›ÿbÿ}àóÙ#7G!ÉùT I2†xúÅø$¹;Ñ—Íù2wŠd·\Œ×kˆ ¬VkíÄ g:Û-@jpñ4›RÏgKýY›~îì¿v>5Ìžõ ³ÛÕj¾ôÂæÅ,g ò–lZáéa¶ @§¡%¨“—«ñrI½L+ËÕãîÉ$Ÿ¸3@êéx;†$w¾R;Ÿ§†è©%Ÿ~RÏÆ(O µËU©F@Pj€G¸\-!Ccµþ½æ-34;7¹¸¾YCJä®!RàBF’ wîOȰێ'ÛÙ7ˆÒØ>Î!B¶Œ×ãGZÚ‚ Gžb9òäÎb½…HÃbkòäî©rZkwì[w·†$‡UYìeê§ÿð Vë²ìú…ù4_}×|_:áÌk#ÖÀ[#V1×3€±ÆÕÏd>[€Òï€ð*3w1Þ Bd´]ÁR#šWÀ@ñúûfõ¤¿€úŸ§Ý\¿Î›ñò«~Q6Îbòü’œë0`rÀ݈E-@ÛožþœAÚf° 7ë XHÝgîÞq•á;˜1/Tõ.¥.H ¸­w³X­¼ƒs·?@ßΰÄÓÕN¿ìÜ0[Œ—?7@Œ¶?¶€¥øÝdîŒõ¥kDˆ|î\ˆl~_íæÓGЪôˆ»ó¢Øä3Úõ9ÖÀ+‰»§ï峨º©Ã®ë >O”“8o¥”·$ÊËYÝ‘ Ú îöh¯>ú$öiäÅ9õòŒøtOüO;Víßsß‘!#{eÀ‚«âØâ1­Íj2¯¤ðõÍ#‚’»Ï ä»¥nò¦Æ]mÔ ˆµ°vÂćå 28hˆúçî3ëÕ·û>«~Ÿ–]Þìërµq”ÇF¯hÚøê⟔£E =§ˆø+ƒâ‚0«r»=š4·ÜÎêwîôÐâ(3¤hªmÂQÄñ–#³RÈìÍ("zÈM°^ĵ¡AGè&Ìhš("PêAyF‘'bUïàayDh¦R…Ǻ-%ب7J“î`â¶‚‘8²bÚaœ¹RU˯ʺüòÄÿ6«ºtY‹àÙDRèM%PüMÜù.bÐõ •ë)I^“¼ Χ<Êo÷ª£ú½¿½Ûà+Ô%GÌ'j£‚æâ¹û¨çàòPÞ4wåOÝÍX½oÈæËSõ·Ni¼ÆwÅýªî#éRp!R^ÜÐæè»¦©K’‰‹FZCU*àìÆõ¼Ó²‘Œ˜P?<“È PÈœ–[‹ZÀjAJ†‰“\>*Ó;¥ß{¹T8"ð#]Eñ4¨@ú¹:ññàÊIǼ¼™×¿4#ñxr þUÈŒAÏU S I’(b²6_ï­íæK暑k|0ðb}ôSˆÓF‰÷BQè(+ðèwŽò£7¼ùOSçq÷Õ›¬æsg²U\B©‘W¹3þS7­;þæŒ;Žv'ߎ·®nâÝTxœN,p3¤IU~|GÃß$î?›y“¸÷ÄãMÚCFéßši$ÛW¯¥õW­|¸Šî¯Á[»OúÉËbC3IöÕ˜ëªY ɪ8ðzÆmG@òü”Qvª^œ†„Ì;p/âÞ¢>B¬Ç¿è§ï? {“œÁº›Á«\Äúb]ÆeM2?öývõböÐwyåz#삾_ÊÓ†ŠE’7Kò††¾ “(ün;¨ƒ=Þ¯§ÞÀ“,<†±46iÞrr¤ˆS)«0Š\$Ô?jÀIÚ}Ö«üt?ù•?•8»T9–m‚’"0—bv‰½— GÑâ[WkÈßys¬ùŸßIWöŽ¢v–6¼•uwA•°‚)£.˜òåÊê[Wåï_—»muî¥>™rk^Qô`GírÜ®ôh“ÄvXR+48–eEèF½zõÑ'!g•­gHÕ÷¤¹>‹•†…ñ!1¦àæL²ÄvêÛ8¤ÜîÃ8 ’3QãT¢ƒVž \Œ¦X$È ÓŠAfÕ’K Û˜è¶Äâð‚–_$]%ÝÛêâÃýÜ(~¤Ì'©Ò"o`Mú: ½Ô!,ùÐÑÔ{Hu¤JÓZÌHzR<¨?Þ7SýaòáG´ßd¼â¸o“4»âÜ>v“,ï~;‚W”‘ªçA,¥ƒM‚s*îäŠs¯J%žÃ!™"±úår 6[ª²´CÀRÑ‚œD­³ôŠ»L¯~·ª;}LÎZéÿV¼Î#>Ü—^üøH‚¯üϧê‚BçØø;¼Éc uòѹãШ®~Ù ì{Ò¤—Rª0CïAõ]UÝ#ÄËtPL’ÒX+õ‰0˜+Î'Xj•&®¾ÝIZý;‰ŽIæ§3W¯$ŒêUÕ›ÅÏ+þX>åä´\#Ó%ØGä…~QÍkC°ÑbüÛξ:îÖSÄDÔ¢øÓù‰Æ¯»Z¢áîxŽ-;õ3o5fØj̬Õ®ÕÄÅ./”åЖòÌÿÅ«Ï$éæ*¡ÊóÑý°KaQ@\YOôoŸvïGöœzUÏÁ?¡Ó7($Ý¿‡/ÞéL|(”ùÙG 6v"¿!0_¾üAýóúý_˜¼~÷peä8\)=l9ÿùÛ‡z¡¨q^"‘ƒU‚½ß¾ü U^PwP’þê¶MÄ—{ˤü•ÿWýzL y ©.€ …ÒÖêÅ ²‡öˆò,G'ìL³#G$ë›à:1ìÌÝ%(JÖiyÏX3q¥cïe…¸ñãób<¹™™;'Ÿ†à©ž˜€¾ÑÖ Ao`x+P½³°Ó7ÿÞCäsêÙ5uƒ¦ï™®¯ó¿t ÿp/8âGçaR¦†Õ)úñ#q(5÷±¾Ôí~knݓݳȿ|ôÀ° â_ÿÄ‚EÑ¿45²ô-|^z I/·§2u×߻›½úö¼]Ì× ÍíŠÇ‹ÞsC Âr¨ü^#’ê¤L–çäè)7c€ŒiÂŒiÊ kžØÇËèÁ]r>Ó8·E'Öõ#[\4òòÅC;lBOXì…4´Å$E×nmëÑ`Ì—QÕ4„¥ˆ_âä-ƈGƒ´dž*ÔcžvèEþãóv»^(Oè^pW7'yÁ4~Ö£µ³Xå÷îÒUßIPf9¯âu­nߑݲLäe—KÚ3%Õ>‚!цþ**ÃÅ$sRÞZUÄZR”&m¯É$ì›ç€L=×s¡\¼íO²™:1ƒ¦YòþáñŸrß‚z–ék^+|i‘Ÿ¶Á–]äØ˜Î&×ÝP<Ñà=T e^ðÐ9Ò0Ñ ÊXuú¥>6-Ù4"(•E`¤z®ëî2ç»ÚÃõUCÄGs3ÇœÉtßs1;dEY jɼ•gð`4gv´SqÞÂBy wž;TL}ƒÆÅ#Ïœ«&s¢;ÚZd®‹ËõOJh¡ÑàÙ¹¼Îy©°‹ñ&ñ,+r"–ɦ’e$Í+‰Â`·™£)„e¿M’y¢>û>ȱLòªa(¾.zNr: ™oª”Z^®M}4 E´‹CÑKbÕƒâXË¢šlÒ]‹(~™òl«(Ž#tŸôÛvw§¿;@RÛÙ 6¼’ä%¤‘¬ÇM­“(<ÕæóDþM­Y®y”£Ö2—ZÕɼAE¬KØŠƒf¬9k‹Ë ãPÿ’Ü*/K©Bj©æô= 3Êì /6._W xßxõé1…Q¡K{"ì3hÅë!²ö²Þ­+SJÊ`7˜ª¸gv˜lK^vð¿üöÿµCƨ_EØ·Áõ)RÌ-”ÂR µŒPM²“ÌxªàòÚUù»7èRhÕa=qT+oÞVeev¨é»¸•Û%=‹+V­‹h›Ï ^òb5M¢Ðÿ0ç[7¹EŸÜÇLŽ/ø¬PñÇìÿG¢½.Î}‘ìÓ³³àögRÄÜ¥à¾]}ês2ª&d›¹‰µH~̈®M>¢„6KwõH—5Òº”ØázÍ–QÞ3¹=>F^\ù÷µÞóFê½´­É³Hþù€7-Zäe}K¼lôHÕZäeÝÙ·F1/)òú±¼žnÒàÒ‘E kå‘+.âÌ[ÿf9Œ®|¬KWRéÊ¢Še‘d¬<•€èüeanaPh/oò˜¶ g¨ÂqõÝÛÑ&±¤sô×ÓUD®ÜGMÊröìû–ß»-‚ꛘ¤ÊS*òäãsùÔ¼²mF=ªQuÂoU!þ”Í„à&A Xxl`.BP—{í^•ƒí‚ËÆžu˜Ç*?Ÿ Iئ•A©E”ÏhqÃ'—1¼òÞÖ¹èP3Ôv³”9œKA÷.žÚÌãìô)íSÞ;ùJ±GÎÅÛûíÿø ö$¦Þàm ½Æ¹Om”½O?"ƒ&¼¬UÖóå¶UÞÏè½Aû ÃY7ótf{ÕŸL_?Xn—ºudÍ*qýZ¹MRq%ㆻÌ\öy‡/ߘ2{µ[h‘¾¶KíöžøúYƒ„}°Ï¢®h?Cç¿ “É×™%Û¿“iäC/3n$Yh #ºÙƒÄ[¯Ü-®=åAøFLÁõ-6K§"5üê +ØGú쌧–Yé;·®å»Þ™¤èžVò[üÛSycåˆèe¸Ìލ.!/´*@¡øv瓇¼Õ”€ò› 3š-Æë?ºªq¼CŒÖ›Õv5YͽoÎÆ­–ªV iÉíZ’¦©êšäRÜ#óÐW- àýUy*/U«ÊC8.²¸:6ï± zµç™`-$Q„ _^9#ûvF†°ïiQÙhuŒ ¯•àÚH ›·)‘¿ŠD:D›%IŽÁG!Ãá”oBë=?#g¯ žˆ`H \¡Y±ÇàÎYx<å ƒçXJ|”4ljâíú\Šk=•†Š˜Êç}ÀÊë¯¸ÚÆ`kW ¡£¸­Š4ˆT¾í=ˆìq>°Œ’ §T±6‘8c‚¡u13ÕÅÌ@³S‘É._ql×ÈI†ÃÉÀFCb« Ýpd’áä·Ø3Ÿ[M(p~Âj˜"ÄÁbü¨áX“ºùá_^ë­@„±ùNòäŒÈÛsÝ9Üñ 1´|Ì@ç_Æk…؇¢YL"±îÿ%/RåûJ…ë)"GEnÃ3ýÒ.€>Áñd OT|êpDSEò´+-×jW·úªÆ<¯ü{ù4`g®H®Ñdï£sæØ‘óNýBd(Nv¯ åËQ¾}Ú:âÙ1 ÅÙIƒ]àå³­D9ÁˆlUJž¬žb7æê;H áéÛiä™ÅâNlnÐgW ܃¥¾Q]Ó•oýÖ¸XRd¾¶yâePr´Âf,W×te5Å»óøÆ3‰ªå‚‘QµlI•­f^p3{ÍÇÛScçšB>R'ï¥Û(Ð(,£Èˆf÷‰¢ Ç*8Ï> êyÃaZÑ[:K5ÝÐr¬Hû5…áÄU“m¨qyJŠQuàˆÙ(Ðz5Q¢7|b½Æ.cMÖÜ8‚3º¦Ñ&0×]î­táuú•IÃ_‘™N‡WdÆjúŠMÞönM³A&V˜2f¤*¶Ûõ>XJ[xîËäYAƒðõæ…z—… ̬ðÔÇ ‰¸ói¥D%aŒ×+m&#ã¿MÔ³â `«DÞþ#ÇN£w\ÊúªZµ3Sùnˆä FD+Oÿ¢“ î~§¹%9sdÓì½rÐ0ŒÆiѧŒ‹á[’½ '»~:¼ð¢=Þ ïãÏ­3YMÏÝ==Í~8®)_FyÏä'£–œ:»¯žíò9?¶ÎR®°FX´1Xòée3uûɬ²~j¼‰6Ì‹_ÈæÆÚ‚ýÌ&†a?³íÒEkA¹6¤çÜEbâ:wòYð;y­6]ijŸ’äŨtYò7µ¡–RW¸U'“ŒdÆ­Õâ&£Ä'}·¸à¬Q£ØmòIŠÚcµ  ZlIC‹m(^kS߇ó±b_N\^y´¡évf˜Ç²>±c<¯ÖÛÙbö_gjÝŽ1X|í"±£Zí.À^xÝÕn3±ÖtåÒƒsí–ÊÒ,Gk´ÔÃkAaÜqZ˜ü:9±«M=´øu§[RîL3‹uMgÅ’V››»Ô6G µÕÑpÃmmDÜðZß¹fMÞØ†C†ß’ºð‘(òXq8„ïÈUš“fô¨aêd}«1u’©¨™hÅ{*VÜ'[ׯ‰Z#cØÖåØX´ÚÐã2ÉŸ’"î’…£^W2lXÐও{åN‹‘Š®ñÒ¦oÌ›åQsªö01g«tªØY¸´¢¥’Š{Éup#s¶ÖÕ04YFë@dP唵—Ì{´S“®G=]ÒŒcÿ” èÜFÕð¹·«´ZŃnœwä O­»¡n–‰þF»Y> x³œ óÀœ´wBñ¼€RƒL´vNÍø5vTM2ÐÚi5È@sÖ =KØ [ví\Ó<4vxñYhîüZÈ@kG’ÏeˆõÎÝšÓe{²±Á§gài’ÝLR6(å|´cRnr[£¬¥Õa­lp•ÃÕj}¯F'ž[ØúœæäïäÂÐ ~t "vÇ|ÙC-Æ_go¹[<:Ôb„dñBùÒ#ÉýŒ)VmV+2b$ÈáZ~‰*¹fì²äìiŽ+S3Ø”åjyÅPµNY%"!¬Ñ+õêùER™ ²4²­M™N„ð¼=ʲµ|µ}gÈwÙ·Ó$Š«ˆ³šaEÊÛÝê¡üv¯ªßÅ*Í8;gªZRj3¨¡"hGôáU±\S¸.UvTïžés\êdBôòf£8,<Æ$«&*š5—ýô»ç‚—?½Éj¹}Ý­v®IF% o³2c±U£§Õf1ÞšQìæscÓy²Õ ³åt¶q&fM²œÁ7U0*‚Éj3ump˜vŒ;[¬çŽÅv3›:SƵ‘4f û}3ÛŽ ÛDÀ&+oìþ\N¼¯ÎÒÙŒ·«MÇ»Ê}h.«Ýv¶t€8l~¼’ÑVèŒ—Ž»-Eú>_MÆsˆkΚq߯›ñæ« Gýéüü^t]¤<Á<‹ hBmPò}Š2tô{À ÍÐû=:™!¾60ñbøÝ/çĉAš°°ÿÀ•ŠcM‰ê$â jtã—õ¨õú’žÓüÃÿªcÂkÂy-D¨Ô°-P£áÏh)C\©ànÛ2ׂ5Ú‡ªèBzX¯7ÚÕ0®÷/øK`zLî•ïEê}Ã$i̠݃f_?k0¤·Ï~OÊø‘Ú‚Ô Ìf• zViQ`g… nV¸à‘³Â…?+ˆg½ã Q®)qE&Ž7S2[B’å4 'è.’‹›dMØGìi,VÌ"‚æ :Ï»WÌ }Zj¯ìˆ)¸åyF) ›0®x^I†Ê¹|Õ Í’"çã Ý_ ¶Ã£‹£úvòú`b@†•µÅ¥lô,•÷ ¹HÓŒäI†né†ÛÒaÌ d `²?ÓóžfHü¨ì¿Ü;ŒJX€3Œ”ðÁ`ÐÈý›Bƒ@ä¨(Ábš—9ddϵñs²RõxdÛ82¼‘0¯íTt_^(†ýú3 ¬:gEUzÙŠo^±úR»IÇ£ 'C¸Q®P“Üsùö!¾ÁJkÆ %$Î<ç·L„úÇÀQ³CÈÐã#o;wè™d/ƃëj÷ÌE–u…‚ŠXt¦>ªó2Dçæv.Nqñ?è‘f^&<œNŸåxÅïºÅ¥¥B"[áÄQq®±$BÔ'ýºAaS1(O`ù$Ý’û3Éè‘[ª/a|œ­úon$mþjW†³•x¨sù£0 D mÅ!Ò^`FÞ”î}?P‡yöÇ».1€b²}ËBÕcݸÍ÷5 3@nÄJÉP_èŒRJ_@5©¹Ãú¹5øwÑÒj•€òýNxF€žšb¶Ò)Y™Rø{™€^€š§“u1úÜh…©ó4ÞÍ·ÞãîéÉÙxîì¿û¦× qUR«±Ê„â8ÕT@¢weö¡­M«ÑîyT÷!:=©Ó â—ËÑ í!Q¥ç~]žBŒÔ»Jˆú^`˜¦’k%V$§QIžqƒŒä„fJ*3ý¶ŠýŒŠµO-雀MåáZí»!oÚ£ªI«=‰\#ˆ^K_ƒt„ J{&på;“ZÚ­N Rë[úžk7î%ñè2Ùh(•NßhlhÌE¥zn–|HŒÁqq)£¤®ØÝ@tõT ¦«wZ]EÒ‚hKj‰ù~YЦ*½Ü9ôJ¡‚vtM‘q…Âc‘ÁË lÌkÏ%›ÔËOYROšUÝŬHe®`•Ò¬ÙëÖðóê»r]þ}J¯oó:¹ØòöÚcs’’ È(SÜ«®¿Þ{¸Í—qùç7¡Ýú]Ýk²k–Ùúõñ¥$wrª‰å¦[¹¬²ÞtyÄñ—#M—о§ï?s¢0}}¯¿¦6¸˜wŒ’}yXÆ”‰+ŠyËÃ[’¤õú )×¹ˆÄ¦,·A–fák_|7ÿS¼~k¥ ¹$B tgòΫIá{Dcc6ÜÂB¡2*n·Ò2 H£Ë7ðÈñ |ôéP­ì‚5UƒæãÙ¼Ü*ÊEa Lo¡ŒKÉò3aÖøxñlÒ™ËæòÒ^஫°£ê7ÏrQ¡ ã¥K¶Ïåµfb“ªÉ¹‹dÔ«Dƒ™•ÏÂLXSÏ„5‘±”¶‰ ÛÇÎü×f3ÿZ\æó_‹ÌtþkQÏ-.ó_Mggþ«ÙL•_‹GüiMAÄÅ¹æ¢æl ŸO#’#{­ÎŠ=o0/9˜¨¤a¦$Ü1ŒUïÀBYŒëdjùÔXòEìù¢¶|ч–/ú‡¡/ú`Í}°ê‹>XôElú¢ö|Ñ‹¾h›‹‰U4k­oaf°êÙ>Øñl,{¶ ó“”z¡yÁXøž'‡¤0/R.öÚcÙëÁÈg°á³?XóÙ,ûìv}öÛ>ûƒÏþ`Óg°î³?XõÙìùì¶|ö[>ûƒ¹Ïþ`Õg°è³?ØôÙìùì}ö6—;Áî ÀƒÕ€K+–W¬®pGš¡“žûjJtÛ}PïC\éÔ}Ñ$‘²7ò×øh4¿õ1J+Ú`"èåÎ’\õb€ fÞ-^û‰ãOÉ$±Ò8õŽŒ¥~¼¡3±YÔ¤&ö‹š•’" žlpO¹åqÌ ?›•6½å¯Ëmoj™Ñ80å|Þn×vÚ²Åd¥Û|g’^­¾šð.¸}Ø©ó—Ñž¢‚ÒÊx¼¦äŽÅ–dGsq¼¡µ7Æ—[ç•ÆbV¶ÓKw|vzêžÖ†Øß³Š%RñÓ„Ï«ÇÊ2úœ¶)ýöjßc65eÿOA jG*ÚTVzîš0þ%þi•³t߬XnW¼iFÅÚ¨•²Î[e4휚‹û}ö³!­zÈ㳨x›5²Ën´¾¥àüŒ~’×ÏsÛ”IjÊXÛ寇~úGA²I¢HD‚ú„òZQ*î–}Öb²]2¡âÝbÿõÍY¥¥l©Æm.;sþÿãíÜÚÅ•tý¼gõšY{æÒiãL¦lpάZ7<$È6hyè_¿%6N#Y¢÷EwU™x?„ÎÇÐ7I1±>MçþZy’ºRÔÞ%æã›m¿edzÝ¿#j§5ù_t²Þé•ÖD¹ôZrÊ\z¥Î4ÓŒ›+/gWqÊ-f ®”]ü:ÝñA8B\g=&¥¡È°áùÆW}Vq×ëµõ)J³×âs´éžÝÔ!ýïÑëW6–®Wà !¼ËîSÉ€CƒÍ¢W’Q É6QÝášIvŒÃ"©ùœö{ñ/7‹³¡È{JÓWTºõ*í&Hã/¢S|䋚’U¢'¤¬KåÔ'ëåQÆ;%GœDۭñ›'PnÞ<±®cB²´&'É*Ëóÿ¾Èdg U¨|,®¢’”Æ‘d™RÅÉ;4* Wö .^8Jù”| ¦ù*F÷f ®ETF¨­h6f©QÌb™Í¦…9S¢xõ!F¾Ù,?}kLÃb’›å ‹ À`Ž"R›c¨lÂ@TF¹pmMœEô›Û¸b ÔP¢*Nx…¾„} 5ÿŠû’Kw]?»?Ã0.ò:Js n‡Ï ؆ü,ÀÐL@q]€‘S³°³„…ÏU, ïuâ:BÝŸ˜ÞP÷§lk“¾~Õàz€#jò}RÖ¸¸‚ó¶,²e,jèv…šUOÃÑ—éG–¾¡ :¯f.š×®±A˘‹áÐ8zé$ÑK§ˆ^Š^Dvw ¿‰]·7±íµu*ë/}‚ËOøAÇ ×}=a{‚Ä$Ê€CV@CdÅq#žt* ËúP„š |ú(n/)]åeÏFVyK}wž§¨¢Ç(“¼@<yAû»âš¤+ô;C¡âÊ$%~ÑàÂÛo®Fú(‰µú8eìÇ(.ä!ê¡+ë#ÀœJ]¾J¬!âu”샙¿V5Àš¤Àº8½B¬Ë/šrAAÆ€hIÈ¡" Ÿ¤d´LÜšU{€fµÖŸ%Èø7õéúæûè•B¬ùÆ4dÃ}VH}`Œ™·ûu‹`]¢}O›“¾õ!:AB~ˆ?~• {³.³^OéyÚES™îùþ[Ñ…F×<‡TÎ)ýRf1 A³”[2`o‡7nïAÑ7/Aæ€ú…9@‚vÉy?/ò‹´£½üúî„ßÔ °NõRÑ RUQ’F9 ºeýü(ÍH¤€zN°Ž¬°±Ú; n¬›UG^â×ÓÓ×m2Ýbö Å—ï)ùл6òb? Cò§ömô.‹òé¡)éµ@DkÄkÍ^º7 õÅÙ6=’Oø÷ŠåÍî^ðóóª2MÒ ,^ÿ@q‰’"Ͼ°Ü“¸dcÚ·|É;AF.m^‹ýž’‡×…|·À°¿YÊ SG‹å&|ÛM·•Ëð#Å5>|£Ûÿáµ+?/‘ ¼šÃ²íÌ–æ'1°,%ÙʦŒæñ7>Ò?½S9?Ù°¿½ÂÍ$çµ€„ÓœµÌ‹¦F/ »9¸ÑåU=­CC( £, Y/ä´?J‡ [«wÖ2—â³ßÆ1ÖJ™ªðÒiʇ¬×X|а"VIHÜý÷¥‰möûôs‚|$þ=¥N˜¶wµ†%l¬û§bãât*òð\M|؛R eÅú#Å'qUìpXšÏc`¹ö $æßòâ#ç_ NpQœù?/ùäëâyùUyÓsÀ0ç$[ :­ñ7óås8ß®ÿË»¦öB^# ׳æKw¹ÛlAÀÊõ~€'¿Ã oq,k @VžeÍCÏÚùó‡5Šƒ@O»Gk;!/[×ö]€l,ïÑ~Žãs˜ã">Æq½ÍèuzrXN¶žX‹@xsgén €µqŸ!_í[¿ï,'°A_ tW«µí@Þµs0©ÿb¯×b¶ ÿùÛƒè›Ï×ö£c-C·µ<æègüÖúׯÝùúÈÂu–~ºlÃ¥åüzñìÀÒG¬ŸÖb@Rd>Í}ßbyÊmø¿$a%~¾x² €õ3°œ%„ð,ßòž/ÙºÛäFzáÙÏ €…É^Û¬@²Ô_²¿?:vc-í¹>è?Í=Hùòƒù⇾yÀê=€5«ó|ËKV0×îK—Eq¸sìŸÿñO=û¾—t30nªÚG­"¤7É(~]è5Š}Öâ%ï)ðcäg$€|´­Ä0•¥9ê'‹õpºP™ÇI È è‡Ô$Ë@ÀG•ÓDÚI)†"³0Œò¼»B€B§{‰8âþ¼°4¿ÍÂgp{T Õ‘,ß¾ÖŸ—„Әܞå§ö£ƒ^±,- .²÷çßÛ¡ñÀàvˆ<|8O’mûíÛnoߨ¬ÁÜÎFücuÒM;“˜µO¤K%P¹Š”…Äó0XªÉCWi‰–óH™E1éâç4eãi.ݦ6}»/¿%ø# 1[xÁòá1ܰî[(»o}¨ AWöx—ü>ùâ,_À¤gm]/ 6c‡õ¯ðÙò|{|D¤¦½ÆÈî¤W üV4QÕ>©=‘õ{§fc9í>½éÜ›éÓkûÁ›{¶å_"Ö™o,6°VöOýoXÿ×îx'] 8k0áA0°s@„» ÂÀe%o5ß­0·ñÜŸ`Ì–,[êc]qc ¬Ä=/.ùb»{XÛ‹Ö¯Àýa²7?QÐýÅù½~îdh|D ð×| Þó~Ñ‘D¥|R]½½åKã?Œ#wö)¸w?lB• 8¡Žt~>EÜÁžÀ5cáP“#² €ßÖduÚ]!Û;j¸é\ÛÜö¾=ŸWU$sL;ª5*ÒÔG~F,†GêlºšQU¥’®§¶FѰÞVâ6¢;1÷’EỞ§Ñ÷-Š>^]F§(ãÂâ²÷âïP. ÓÖs\·"ñûߥ«òO:•z˜æõßáÒ›÷&Ð8bTרšè±ê‡W€“‡“jè/‡1iŒiO[¶Ï²üB²°’Lð(§¤bU|”±ºã@&¬C¹ûê÷¿I»ïTÉ•MÍíFS±Ö±ËËl‰\ÒœN_“´ûŠ¿H¥‘i¸ø…ªë°Íßt Q~¼™|N¾LÜ 7…Òð+MõX¨Âº`’ ©Œwj§?é$9¤›=°AÈ`b] NVᎊwµ‚Y;ªÜÞµQÉn5o,þ-Òû§–6ŸT»ÒÝVÅç×tNÈͰ"¥ôÞ#aZÿ-º1«W/UÍ´Ú,¿÷ƒÁ ”èDhÅdšTóQE’8·¨¨ÀÂö¼?î5ío|Ém-s0ð·½µûíïyéWOZ“/ è䤡î`ý{jå‘Uñ‰_qµ¸=±öyÉ{bݵÔ/­‘ìUí0±öyfrb]ïoŠ‹¿3Ë=OX£uù®û¿A6Ó­a¥²e4å«,Šl’þ3šÍ)ûTPǫ̈Æý.f>?}£ÈWˆ¾&Ö¤MÌŸí›lbaó•#!¹íÛ,]¸Ê,*Ëìk¢ µbaÄ?x*É fŸ.biï¹`"­°É‹Š€I2•ªùN‹Ö„Ë¿uÒd壮 Ã×ÉMƉöHÁ@¬d—[±-ö“ÄMÝä3i3„ø¢zkgXKDT.Î!ªSN5]´š¸‘ñ…¢Á»¶¾BM4nS¼evž²jä¤z™ÚuâßðB~`êóÿ××Ñãéïÿ´ÁX÷ïËÝKf¯ÍþÿÛZ[ówA6š¿Må½sú·5y–æoÓGd¬ðPìç§9Ep¥èE‹è$»@®D§Wä72L*È{5ǪÈY7§9©«x4Lj'7ù¤ýÕáÿßF%ÊÍ; )ÛîáPv-Ý‚–8ш?ÇÜ÷J^n­ž¨WpÑåN!¹˜OA *wb«àNQ\ú tAsÄI\žŽWSì÷Û,—×¥ìu÷ì–iN¯²%Ã™- ¾Çmôº­+t„áks4Ù à.ÎFòLxfýœ ÛyMU”¾Ö‚Oœ¾§‡f +Üb°ÕÖ5 /<‹°H…“ š/^ÿèVf¿›Ž”Ö˜»yý~gºÒ›Ä…KǪ’›kÎuÑÏ´Æ‘)ú9ò3y•„#ùDŠ|eCx(ŽâP±/CòUXHR$È‹Ð{Ê7ø0z³¦B[U"Ù¼ŽÒÓ¬Ï'iöù£"!¤ÄP¸ta¿òôÀ²¤&X–Iù©é;{„BñuîÍÕš¶¦ÞgE„&‹ O²ØÅÁbÎ …þ®F³Ž+€.lŒÍÉGT¨¤>*Óø-#jò¦âÕ)ûjæpÉsŒè¦Ø®XŠíܤF%&Íè1Ýã"‰QêS“!Yd홢ßȧ—‘$²þ m^}°öÍï¤B¢ÈŠ;-‹XdA»8Û§uÕtiûDFnFXŽåõ1LM²Sj–8WSo`xØñ‚Ã6¾ðï¼L¿ÁÑw4xž³}»Ï]>0Y™dˆs³ &¯^0ý‰¨ Y9™› É™”6taC¿1'Xî€ d¼bóÎy²" §IÙ¤&Í)º\kd©f]P”$ˆ5ùŠ ÜX禖:JQ°¼ÏŠ Dʼn¾*†lòÚÝÃÁ®ƒ ÉŸp¦ï;ÁI©7%%Sã>í€Hº•¡y'AaóWŠO€®ãŠÛf˜¸Á½«?†„À0¥®»Ÿ ‘ “¢âã=ZUas Që„`¸æA±ºÛŒôÝmù‰I½Œ`˜óú/F¦{†`°J;ÙÇ[tL¢j#T8s‚aˆõàáë$¡² Qak Ší- *t}À@éÅ V‹Ô%?%™“nŽÌIwæ§×ôÐ uË»]«]É—¥Ô#®L’Rî*‚ï¿%-Û­9}²¢UIþ7ˆŠË4Í¿ø"úô3khwE²‘ ßeúiLjNCâ'óô*+¾«Öz[]SP`ô³[>’Ð1[ZÁ|ñ¤cé¸/s–ª·tuLÝgËÓjÝ·,Fu¦9™¶ÙJËPó«£ú¸N߈Æä`oÊOöQ>É%9Ìû ó>½oY[ÖiÕ¸0\»:50^ÏÿýK×Ô]hË:.ëÀY¥m¾vçKmc~™÷²Ò;p[Íþ37}²Y™Ô3Õ->ÜV3Zs%þâÉZ†ó@§”·Æ+{åêÚÚK®FkëO:uWkìY¾„®®\Oû3=myŸw¼–¶F4û–õ#\ìçªiª•#…å“«ËÜÔ×YgòWáF7Cú<ï.X#aiÚ.mÿ‡íjëet»¶VȬ5£>0†˜ëw[È´[êsk>ˆõÂùƒ¯7š½ ‹¹³°Ö¼Ðhå‚ÀÚ2³õ,¾¸,Û6úÍ\wmYúÁ<ØùšÊöê*ØZÄ%4Zæ¾ýèÌ×à²â­aï¸OsÙÝÝ"ú¢½â.,ývùH#¤åmô­wNàÍZÁÐjº~΃Àk§Ó,]kÏÚ®YtÍ}ûß|Âò§†½V…Ÿ]½èŠ^å—Ò7·>~·M’0É2¾ˆL⺻yHËjJÊû»Õéû’ñqŸEªk¬8PñÝ´»Ü[Ç´* ÍH>_g¤iZEùA×¾È÷´®@ÆÂ7ÕHîœ/ÿ‡•Mx¾HLÃ\Ün§›ª´ÖÌnü ` ‘-!¶áw€)D¶„Øj ëUÞ{@í½ÔÈ{@•¼çAìoÞÔ1W8Ðún[Toú–e­Ù4ŠjŠ×Êšæ4!q·ëKÇZÔkÚÖ}•©c[G5Àô}Ou­õSOød‹të‰ýG”i¦ ßþšñ›v‹pè®C õ£#Ç(OÄ~ý#ažâ&Õu7Éfa ¡$ÛWmcrHõ•Iþ±„¢„bqΡ¿Ÿ]Ÿ Úæü–Üèý°?¤¹¶u ùÖòP•úÆaq•¬P|iÜWqqÒ7')Ì’·(À¢ûÉw¹jZ³©KÍ Ë¯€äØ”²€h¦Í[šeú–¥fÈ@ãÆ ÐMÉÝ~…¤VŸªß ­#Ikíî—0®ÒwBõíO|D°/²æy “ò¦y¯kÊj¼P;×e”Ý×ï¢?ŠJ×ô°á%À8­4#ùDNld³Á˜n—‰u´Ãý¦ûNoût_èÚæº°? .Q9íëéé ¸¥v‡¿Œôf0úŽäØi.þlìH—ø=z½Ó¿ÐCì5¢ç»q!\\œNEŽycGVdŸ~BY…˜À’Ï”ª*1U²^BCI…#ߣ üNqW ÿBÞGr'$§JŒcüô3…C¬fí‚L+8Ô|‚QãO0RÝÜ˨“z®U†±¾)©ÞI%ÿ(Rð§e¸‚ÇZŸSQ‚á05¿ÖÇ¡0Êj0Lãos°¬ºã"áØ8¡ ¬ å×|«9õ²Œlʲ¨j6¬‡P$$ì¬’ŒªèÏãõÖº 1Ü^kÄÀË{î B·ïR¦ºóÀÜRs¢ätMS@P š~†û(yOu×Òz$cÒîõ¶PÛ©ƒ¬Ûõ‘ƒÍI“R E˜¾ëÚ|õ7®Ù•5Uw¿®Œý©»°ðÓÚj ëG·Ôwâº"Ö’*ŒêP5¡õ±Õ¶ö€wEô“½ºTŸ:Æâ.,Mã“öX“Ƭÿ 4¾,ÖßÛ5Kã#IÄ ½ý¼cxŠ>u_vËêÎÍžÙh¿çsp_@ŒA£i2RA¸ó‹îìZX÷LäjœÑo¡ª Û•VÊÞ£ RŠ‹LŠˆLŠŒÌ¯”dšuÊ'êÞáµµFÇ‚‚×ĨÁÅ,pQÈ‚…¬ûPÀº…­â0ó\ß´„CI†BWN(_Û€„²pBa 'ÂbM¶]@C*º~Ä–@ŒK1Dúb .AÆÚÒ™ö„/W5 ÖÔžþç–!ÏÑ­ÃÊ{máÅšý=<Ïê´‚#h˜SýšVõÿò3Ž@öÖO-b`ÖT!_,–HŠ ã£,6Š®nÕŠýg ùöóª*¥J‡¤Jö@r$ØÖëp0Í xê6b!H|–:e©¼ÃÑ ÙPPøÁ¬£R_kàI|n@9Q{#e¢~_¯†u?ýùúŪ€P{“ñ™cãæ°Ý¬ ýž°@½³ÁABè%û"ËŠÞ©œÎk.îç4"M{«"ý¢úsñîÔ03W\%pm[Ç Zu EÜl.¼ÓÓ¹²ŸÅ|ÈvMuÍŠ 5º$«+àFˆžáW²¤'êózŽb^F/Ó5m·­ë/ý¹ÂæQÍB×Tsý²ÌlY‡Qw¿É)`?*³>F•n !;ý òéžã–ÿÔ7ýO}Sݱ4·-!ƼelhX∜þ–wý-á€u À²š$-º®ÒÈNŸóó›Ý>|AÙrÝ3#|DŒUÏWÜw€ï+b5t=>1~¥aS¥ð¼ÿÊ}`¨Ë&08Ü „÷UqBFÏ!+^1˜Ôý¾šc•˜Xcå C'LÞIi;–Û“z8…˜ÝH@æ5ù.ØouÄ2°JßËwéóv"8JY:,«pü|f}A\údØú%“º‚Vsç]²@®ß$Å>rT•ĵۑ î÷î Xd.¸,MƒÁóÒ3˜d-â;ŠDÖÜç•o 7ÜiEq9½+θ¦¢.šøˆà.~ Ø÷¡ïé¡ JK#J ß<ïÃ5AC³(òq ž†bÏðK{ü¼‡÷ö}“eá)ªÁ™ª`í²û;P¨H‰ /p™¸¹ìÚE(ðÍ»ç“pÜ$U·4ܧK6€ÎkSž tý{4oòzóÌÓïŠÅ½¿ŸcDÒ;;äaƒøûHëcˆè!\ñ”N}.Bkt‰æÏQ T@·/ižÌîònž$®¸÷µ’Œy‘Š€_žHæD’‘ùöÛ¶µ¿ÑM®5¹Ý¬j$p‰1¿mnÓšÀ÷HÕ•´ëú)esŠ·–…X®¬äË 7H5š¦}Ähšù^¾ó«H¹ ¤ñ[6~õpûè6Ë´?/­Õ|·T÷ª ä“ýødùr+þx`ÅüK lVEã™E‡%‰$ûb•¼òš<% ÿNq±fÉ{)áyãàhƒqG¤9•ð­fûH~«ì”ßÖÉ·‡çu˜&˜wW$ibR…Å;©ª4'Tš0IµËKpb™ÙžÕïaKÂ{á¹ 9ˆp¤2ÂúÌ5V;É CúO‚°—ÔE‘QEí)žKªÐö+¬Ê7¾£’%šæW‘žäçV%|IdŸËžÜ~èÛ¡©ÓlœhŸÉ˜ÙFüdçíäñM©ÒCŒ|²üœ¨œIQQynJ—¢|ϽhÃ\z=DÔ;q¡åÓÅç×RÝ&˜íæ CÅ ¶”å3T,]ã·èx13î‹ê4ž»‡·y°åIU¤ ¿XöÆ+k¸Š,²âº‘®|+è}EHBè[]”a!¦Nˆü°B'e0*øDïŽd&1Ž8øFTBíûxÐgh´')+]Jñ†±"RçE7e¤}v[Hºß·ö¦| ÑfÖÛy].©—ï)$Üùœ¬hÜ…YÇÎäå$Ç¿[¤ÉË¥ ˜ |1S®?a¬b=Qt¯Ñ¤JRµî¾çÁñÜ.Ìg¡˜½k/U[åQ±¼‹*ŸêT‘ü ¤Pð’»´ µ@f~Þ/Ñ€ß(¤™3†¸a>u,þë0˜F[y™ „šñžÇÆ@EøÜ ‹*‘u•<-*üË)+4eØ;©Ò€ÛBz›Yö¾É‹ì7…k­3ÒÛª=÷Þ˜«<¼]wçOnÂü‘܆™ýÆOe~(6EôØ•}.u;4n/wermÿÕÏsŽÂùñí—\m¿íßÔ ¤ß´nDb;²4Ó¥›n²ú¦‚«ÌOÖ⇵ Ÿæþ“t½.Ë/Jôƒy{áDš;g‚Àt,ùGá8{­$9€?I}ñó¢_Ë¿M¦ öÝ~Ó,©äΔp_‡ƒA~lXîÎA‰¶õűmŒbPå&Zxgÿ¬ ¥M©Øø:N®G43B`ó™Gd‡3Ë7z÷Çú1<*GiTÆ8Óˆ¼qfQÙƒ´/A­Xîp!ùÔF“"–TGìÉMmôgCšñL<·ŸY§®70ÖZ À ±êîÔÖé¾ø½Ü÷¢1Jl;Ïl0 a=#ýçßôÊо‘ ÌA2ù«F¼èO±CÈÞa9„)› øgï/ÑcS'Ý3®Žè[˜9à…>{Õ²{•v>÷ÅZ7,h^¢ Üõ 'áåšo Ñ‹9¨}Å·¹ò¸ÈÙ-¯éв(»Cé·5(4R…ŠŸ•ц ¥ÎE%¤ò‚C%zÛÅ)ÇMl´nb£ûÙ;ƒßKÀ»ú«É^SÉû.͇ܾR"9•vƒÕÛ9ÀQûJêè=ªR¹˜æÅ)2 ¹ J%Caù, TÑé!ɆCù-cƒÀsFѧ²âÀoA}‘‰ÎûÓ5.5DìôGŠa˜|GI} )ÕmàŒF'ù^1C¤×ÓH L&a}©ý8Ö|°Xh²¨‚qMž*6OJ ÷"?¥”PTŽú l@—Á3òa´çFë„;;¤ßL[÷¹²¤j}ØÂŒ«Û! ûi>Ò7ªÈPl6÷¶}ßli=ì5ÌÜ`¾^ß·Óx£ý踞µ˜ûÖ}[7®ÝÅ|­!µÑ0Ù­{m;:jýó›&}Änrßz)ßÙ9¾?j"y2. é,ß8"œAé¿BîInž¤q D¨¾=‹úú*íïæTš'äDAc·" sÊÒBÿÃÏU˜®y?‚Ðã®ÖsañÛ\£ t–âäŽlq^¤µdíOƨ<<ËÖìÈ}FH ‘õ 9é „¼ò’@€×PÂÄ@Þs¾qMh^æ9À^¹§@¸_WùŒ¸ TÏ¥«È=`0TÊv¨v5æ‘ù\e­së;%Ü¿_]ì4L{á.5ßgË{puº?W¿G¥¦í ¶Ó3Nå`¾[k«ûæÓ²©šßw©(4l—2~7m+-Ã\ËRxj“u®Û‡#]ìîA”$á1e=ùêKú²¡Æ5\–ü”kLJ{U>“мFñ[×¼ÉÉR(ÎXêàÎç—_ùµ9Ÿ8–*Õ2]^n-;å×=iœÛß½îM·Ó÷à»n²÷(kãsÎÇ{ò (jö}FU}¡é„ü‰ 7§»É¼¯Š¿HÞ†C)œÏÂá蔼ºPMÜ‹ú(ßVv—6‰±{ó6wðó¡w8Ýâ‘1÷=ì0.êKñMçVœO?…ŠšFACZ4U,:´Gj§î·¶7vÎÇÇCz [¸ž…·;·œs¸òíã‚úã÷µ³pñ³±6kwñÇúâÍ(رHÐ]Ùk$ºõÜ’ pñ‹¬çãÞè[Ïv‘(ßÑŽBýtöõíÇ­å,mç‡sdþõ_æ[øÌJ  mge;vð ˆîüù£>¸Á \<Ùë¥g9(Ø·Ö+00º­DÍÊÎUÇH\ÉéÚîÙmv¿ßÛòxÅAåöC9ÅÊPT‘0IY=ZCi±…dRjP¾3PÊÕÅÉCÕ®¹;èQzåØT÷³ g$®%9€?Éâç­½emþn%Í¡z€%ä½,¤«ejhg…Äß³˜à‚*Žoåæµ,ÖÒý†ìãÊUäÀ]W¶É14''Á%&Á'%A&dÇUÅiŸÀ9xòTâtÒ“„Wu†%Ìëêå5,·´Ì¬÷ç HõŽÜ+7 ÝaÓ¬–O Þañ¯MUsÏj´ÁDÔ¹¯H–ö ¸¹NÖÈëªÈo…×=®àÕ"²Î@Uèà¸t5´ã¤¨¨¢kžJz'üÉCD‰ßýk¼ƒz¥!‡ïæq ¾ªþF¾&P9E¥¡ÊÝ<¦#¢‘tdýO¼ˆV~”-É>j²S–mïi*þ~%ѱž-'%sZä‹g5útk’ÿ!jht ®qt(¶&Ñ‚†xQý _&¬FKCDôB¨±Ì>1—`IY¼þÒ©õ1C.©?G¤¿˜õ¦-n²öçš,_}ö÷ýhÈìZ¾Šl”DÙ«HÖû5Ñ¥úK‡Çº7DF>%ÑÍžŒÄ6ÿµwÌ0ú‚3v±ÿ³)¤Ç×Fs°n£WfÍg¾NbQ^¨ä°pNŒàH±ÇPÂÉnF"™7{Þr }©‘˜(â}*1u qš«û(AZW$:!PõXe8ØåøÌ’çíFøÿ×Öcâ¸lèýz†¢Ò#Ú Ã1¯¼|"ð•-ØïჼòNà?øe8´ŒPÁ½ÀáyË?T¢¨DvR0¸¤—]µFñ³ò`Ñ0>kkW÷¼ JØøX|È›q¤(¿àÄopD±#ôÕõ¼`àIî:EAõnôa«‚ïJRúªÖÓ%oaÄíSï$l}sS¸ëœÒ<ÊBÅ.Þäü®.ÜëS„Ýi4 |ŠÞHÿõ@²€ý,À·ØFŠ\EPãÏ]-3‹Þ‹4¡!ý:±¶þ-dqΡ¨$Gµº £¢ý2™bƒˆd ôìÙ Âòi$©ðÅ£Û ¿ýyxëðÁ³æ?B126 2øN. ÔS”'½›å›À8bó}o¿¤w¥ÿžb¶C϶ì@\žH¥nÿÆIÇ·õßÃŒç^ÖÞˆÌ#v>È~ñ´^¨½ëÀ"iµµ@öO»-ÈÞ^¯aöÀðÿ€¾€ï`¾õø ‚«5ì+ ˜Uw¾÷(ðx±Åˆ$[¶ÈrK½Ð~t`€o›¹ ÖÎ~Kz8Eô R¹ûâe‘Ôa“Žix Æ.íª>VFô:E’&û$¤ü 5êõQŒÛßÒ IÒ6å`p¥” Ìï}°ÎqS†ûŒbÒ’ý!.1ªš²“ðïKüT¼Ü¥¥œãß–ôÎGA$J»›­`]a¢ExÓï2 ÕFkÉÚ{0Ò«e?ZÎüam…;Ÿõå|ÉÊÞ>S[ÏZÙ?-_ÏZˆ?Œû÷’™ë‡%Jþg’Êsù #j«šÜ¹–zä7×½FŠZKáÞxŠäk 7ÖqC뢿^3dÝ.[Ë„„-?ñÚûø©XŽ\¦´«`‘2²»lo¡<9&ãgÞÚG·íS¿ÉùñHBÌæ«p¾—`×ö‹¹²_ÚÏÖøLžyúµµ¼gb;Vþ"Ö¶ólç‹ÀPýno^àoéƒìwŽýø[kBž}ÉtÊ8ñ°œ/—^8w~á0XŸb¹7|Xl`€í»Á†üï?þçŸÿC¼ùK¸Z†+o¾±|8ú¿®í„+{X„Þøáº=–=6HQP>Ëߌ³‚'V°ÒÎ=@„^0I=ÿ Ã=¯!uãÂm8îW[‚<-lᩃU’ŒjÆð5Ÿð|Q &'J²„¼°Y¾rw¬fóýàÉÛ!t®ãX‹ÀZ†þÎçðâƒÜ]`®næ?!*áãÎ^†ž;_.æ~GÞîk×Ý>Ì!•kOnçždSͽ° .æÞNþÛò\µõÜÀ]¸k^¡éƒ¶»Öáó&l×Y ‹v!\Ø€`wˆgAÔ²^‹oû(–·ÀÕÞ£Ú»"[`·Â_lÚ8ø À<±‚ÉVðâ`»áËÚÎ×,’ÃçùÚ‡±}ñ W¬^`Ñ>ê-EÁ{‹gP ³|¸X»ÖOHMPËGÖÓ2Ž+_)S` âÜKhÐ|ëwh.o¹À³`ñ°†™8+L.kŽx AØ€fánlçQâ2LŠ?‚Σ®s> ßñ‹+Õ$ìÐÝPŒS1‡¡êÌãÌå¨þ{8CQŒ˜C”bBx u”$•j=K &íY8¾üV45ßÿ™ä`èXÐúõ‹‰ö·Ì"Qù¯jõZ¡R¦¬Šº@~+/F(Ÿóƒ¡|ö=i¢ŒòÍ‹aZ¾ÿ Î÷”^ÍsÄ”•c]ä0d ¡0$݇âòF‚Ó€/ˆ<“@³iGÖ†ÍùŽåZ ,¯‹‡•¬’½è˜Á`šW$~÷ k:Ê¨â¦Øúì2µ|3o­fQÌ èøŽì=Â1ÅéD5§Z4Õ!»¦'¬—\Ìs”ÜÚ¥¦öÑ)ͤžªï°ŠÃÒJŸHHsVg§uôšœÂkÆþÚm/‚Ó%!UŸçá4ÿ›] Šƒ…§E\g޻䖅S‚üìG‚ˆŠ.WòºñFQe§y] YÞÍ7@M^Ý]/…#±/¦UsLv9Ü}™›ÄnT$udí£ Ë#£×´Š¤FU$5©¦¨I5u+ƒ¹¦Nº£”@´¿7\Ù”Q ·âE¤r™Þ¬â·’µüî!w™ãñ‹%hÝ@’÷݆zB¬Å÷t„|O(UG%T{Ÿæ)Uuˆ ‚Gñç„‚UûÏi?û’æSjÖÊNâ]=ÿ(d¾if×áZ-³/í„»êtkµ5³¬ø+Òð›©&ÉáðÝþ³¾/"á  òútjm ÏS4ÓÈM¾ûM–® ë½Æ<NÄN)ÞªÎgÀͳ ø…Í%ïdŠ´i #Ö¾G5™PqÊ‚×iÞ™PªÝ›g€È¥1qØM6š*ÞïiK'}&Hñ{x¿ó¥+8(ÀæbíVcÓ"Ì÷ûªè4IÇk\lV)ý¡e? eWEÅÇ›ôÓΑÜI‹­QÇ£“Ù-§‘ÉÓÏ>ʦÒó…C(#µVb’|fß8Pi˜ö×"31” ¹ª¾šEfËoºIDNüæXñ+Bw²Ø3ͯ¡©rì•âyÖô 'û0“ï©jÈÝ5´üœIcnðfiMþ9´ÏdÌì!+^G¿cÈ}T«%JŽg F yC`5‘M!*1qÝ)Œ[\mLÑŽË ÆÏì܉£ÂMš† ¿\6v×ÁÙç÷þ3Qˆ|1¤ÏßÃÛ]>ÑáP‘CTS¡¸È²¨OS¡s™7Ôù`#‘âc¹¦¢²1¶žV½¥QÖŸ4ÆhœÏW‡Y%ü&ɩɑO7 Þñ§(ÿ2Õ q•–ø<| u\äûô`¢p¾Y %²~Nå4:g2lmtåGZ´E0TÞ¬%ÅÂ’4'|å8Q^­X­Â«iVDqÝ:6>ÌËpwùû±¨˜Je$SVÅAÌÈ/C´˽1«½#Ãx¦ÆE‘šÅše·)Òº.ê(ã~yóƒÂ×­TèÜ\èwl2÷›+§!ï`úyÕ#ü;éêr \»tÅ"Ú¤+Õµ {VŽ´ =„Ñt‘ãbŽeÒšU’i‚M6£ö,M^cyî¼)/›šŠŠ¦P4u'S¸ï~@Er÷88Ú€šµÿjWâÎA0(âoeú­[ ›õylù…ûŽW,ï<2½¶ç‹_aà;l×áÏ=P$SÑw!SpKR‰^öC¶sÏ·XØ×Žì,´š\Z‹5?Á C+Â/Ýò=iq{ñ˜v{¹åýŸˆ~óöÖRí2ûÙùV-þïk;`ÑóÀ²ÅŠ»c´V–ãÛÏ$žnD~÷Ãårm,±1’è\¡­~X¿¦ ü†ûÃrì³ ®Ýù2´~<º]gÁß·þã2g?>Nò…϶õb"ÓU^s~®= ¸äj+›'¦ëm扚ㆋÛ äêÚõBæY¾ˆSÿ2§zºJ]Ú…Öï[3™/Îã/ž¬ÍÜD‰ß.&ò^Êr0Å×~t\—îôUy·ÏT¹GezžþÐ}W|Èï= 'Å…ªr…óÉÖÍìõÃïv4þÚ»§‡(±Ö4:Ñú+#¿ø¥O¢²–÷¼Ë³ï'B¡ëC¾s§íØQ7ì×dØÔÅ»åɈž¾ "¤à R‰Ï}:âpŸý6_[«å,áÙ[Ñ}š;KÿiþƒÕÇs{½/®4»z刌í°öÁ™¯CËó\OK#c¹`y=pÓÏú-ø{×Ñ ¥0vEøÆ}žõûÎö¬¥ÀbϱÖáƒí,ùoiçödßžî¹G¼;#’ïà’5,1ƒÃº·ÖFü]V¾³OsŸµã[G'€ÜÖZ,Ÿtmë™…Œµ/Üï»Ù°,Á Ú¸~¨¶ã^ÄÇL}ÇÖ6õ×ï¿AŒÿ©k¬ý÷€ŒC ùo0s­oÈ©¨¾lW«Xž­•w×É©¯j )_ù–3Š…è;PØ}Þ}Ðݲ!…¿Ÿ¹ïW+sCX•ºr1œ³Ûßů›u„ýëW-wª?JðAq£°<ÆûÁÏ|¯·–sh]~û¸rÁ*6p÷­­Wä|©Y« _Ìg¢¼Óš_ûÇ#”êõ8†G¿…CÇÀ‹#Hq$jøëœ–Çî–5Û:ß@C¾w‡±ßñF¾²âö7rc$Äþ‘$ñ1’9™¿Çw>BÚJÃvæ‡w¹8+Ú¬ W9EŸé©9 ç_ QyJsS‰œáu*öcðâÒôBQVíÖb =Š[5»Ë À2ƒu,(KIÜð5Ð;»¶” TÌ/áê±3)+ó°ÿœ¶nhï>h R—˨>â„HÌ ‹Îw‚ÂsãX(é[ØÜVï©ÒÐéÁ™è´mÝ;Ðç°<5ãiV¼þÁøÄʃÉj‰îtZ¢Ë^û,:˜ð'h¤Ã?ª¨ _Óœ\; Ã²q1¸Û…f$JZ°"…5‚sšÓ«ä<ÉâŠ|§kí·U)Ð\x×k0È×ô´bó•‚jßféžðSšá1Íkœ¬ 5äTGCÇP¹3©õ¬mX´3~O§²º$& =wða6KŠK_úVÑ¿ftN2qŠZêcF-Ñ䢖JIìÙ4Œ¸'°~N‚fŒ!˜þtñ…¡$·Ë\w}Ð*¹‘ˆhç1#»kš&8šB;î×dëÈ#ÁpÇD ¸ïØÑMÎ[F(ÕO#´½´ëB SÃÖ*8ôEyß Ü:¿Dyí±¢_xÁä¿IUx¤n*àœO°öŸ/Qy·q¸˜Ï6óŸöf· ýÝvëzA»Â«• ‡"¶c.rž\…‚çYL ¿áQý/}¶<{Å÷š­Ý~éÄÏ_!_’ךWîØ…·îW¡Ÿæ¶ƒ"×Ö|—Öj¾[âçýÇÿð{?›ßQ §€ýÀ³àWö[ÊV¶çëÁ¢\õ£ »¥­µßt£ž»æíÂï‘Ö²ÞT¢=yÜÝÀ3þšU}Úka_`o6;é^üQÂaq z«»EðݾÀå;ó­ÿäúÀ°×­Gür‚'+°šDÀÊ„½±´­ kÖªiÛ>ÚKm[Ûqµm7îR?ÀÐçArXúö¿õ¥wÚ±ÁZðÕF7K…¶ì†®QãÕÃú‡¾ñâÉÓ7–\5)1v]€µ½rõ×à Ûx눎Ûó«¯îYúƲ›mÇ­_žôÃÑíÏä}muïqüÊÕqcw|»Ï¸ñÎ×Ï$ÞËÏGˆ± 1Þiû]ÓŽk¿+dú@WÐ@WØô‰¾Äéú•=3¾z_ìô‰®èé}ñÓ'vO~~êw…[;(/"ø)‚üä¥o )°?!þ ôOí`ì`}ifص½égÙÛ(„ïÀéïL/w›-ÀÒ÷f€»ÿ¾Ó¬ßÍ€Àã7rj&ß;×ïcÐ*RiÜ­Uè»eäe •ÎWµGg­ºGNQ¢l™ÒñUãoRx'’#Ú âFñƒ¢ÔP*ÍßCóH$_³¨$é$:ªM1Ú*Å;©2Ù’ž¶Ji·6Q^§æ)=M~¡uBÞÍ#÷=ªXRÇ$Ó_4.dʨEüó_Õ „jööŠ\ÜMJ*0¥À02àü¾/|pô@ iU‡xcTŠÜHâ-!h0¬¢¡H×Päª7«‰ðOnÎÓyzÐÇ1­É¹¬Å°W©¨ïùcI/ñ»>„»ÆèÊ„ºèÈ^MâL ÀþûÍXÅXá·˜+°ÿÌÃa¬ðOSÿ4ø/S™ ü_Sÿ6øCÄ4?&ÈŒtŠÊ¾z0WæÅ&–*àÆ*Eþ³G:5gît<%¹‚å7±Þs)uˆ ¤ °5k‚UÞè¢$Ñ_Ù‚!_±†Ðq”Å}hdzÎEÔëx£Ì·DÔŽ8'>† Ü·h×›×î^óñÒw~|[/æîsF$Üz¶+Ž›,Öóñ½êß4¿‰=Xü”ÞDb ÏšVøÀþø1™ÿ Wž» ÿ×}@Ku§òZÄÒMÏZRŽõÂOÆøîÚLcë¹ Ë÷ÃGÏÝmñJnøb;K÷¡eI¶í¿Ô«êP¡Y|’Ÿê‹µ7ìL§W‰ƒÆJwp`M¾ÐWU“êøoîÜÒ÷’£--LbÖÏ8Mœn*ë:n ¨yª~W4KÓ5|Š.­`¾x²–}­/íKëÙÙ»÷UƒOöã“qn/×–±ÈD­ËÖÞ"jîme/° Τô´Üóa“ŸOûBKK+ò&äÖ¡Ë41ú„²0y»qñne(É“¦‡<2‹QÅp–HsS|}ÒJ°Þô)ͲÔG$[ÈRÓ¬÷²æ§ÉŒK³̽`ůZXîjµ²¬åÃ|üð@È1ÖÙù–ÿä¾`;g•`ÉoåX[ØÈÙm{ßñÀªk@Ï’•Òû¾Òѯ/že&вs|çð»–kÒÓŠeå¼Xw{Ð ?ücññ"î&G«-Ý]€ÉDËnÜÔæCœ‚ílw‘ ¼‘Ä ? ‚Ï5$­×;ë³L«v¨±ˆÁ(ë›Ñë›–Qãx«UL®{nÕbqwEïØäÌR vLùK Áý%µh¼õän"R59<Î 5yÔHæÁØ“‘)°r| Ƙö×é²ÃîF¾{xû†þÁ¢÷8{Óò^¡·ŒX½oûT’•{¹Àª5Ø{{¬Ý`Q‚~±P8dÅk”™ip÷ä†y‘O "\­“ZºB.ã^%×ÔÞƒÚðwŽáï<㊠Ÿ0 äÇó]$QžüŠÄÄ@‚òa›‰BBXp·‘mÞÄ ñÒ§MßžžÚkŸñ ¢DàqÓLÅ%ºb‰W8I¼DEö¤"yŒÊ oÀE[çÞ4K*’„ Ûée®pg¯˜¾ß©š¦*øªï¢AÅ¿?ˆï5Ñàn¾§Hi^r­2,á_œÒ¿Ìt²¢xkJ… ‹U\ñáôlî8np¾$m´_¯/vž2ÔYíœÅDAÚ¸Ë];Ø5âw‘†óµ=÷§ÛνùÆ ,o2Åç¹g÷ž €zÃ~úØc„”,¬™³ßÂðµ"Ñ[Y¤9ËëÅ[ŽM$|] 8•¤´Ì¢/ÒÎ @ˆ”Ÿ°‹ØG¿ÖŽÔÖi0f£E]ë&¯¢”ò?Îøq`ñéy”±6(K7ÂJh^¯·¤>ØT÷ÿ}ž½¡`tÏ{.úXB^›Ã)ÊX‡é|QHNíuÊD~ƒô=f6ø÷½{šîj•ÝÕÙx…ªÉ•·NÜèüÆŸÄE®qÝzFQüõ!æ`鞊s§ÝI­ô`= ‘ô` ­‹rÊÇiÖ<`m‰0Ÿƒk´îå¯è5=ߢs×–WhÜ«3Ëõí÷làû–º!‹’$j’TT¥ÚL™ªî”¿1¯ïZv<ºax(aõ ‰ÃËI¿{/ Àüª1ÓX“f5kÚüØŽµÞöU“¢JÚ±Ú=c>SÊ;­¸(¿ªôp¬u^ãœ5hÕµ‘,ã·EŒo¸1.X3ÑÞ1Íã®_õ¾‡±6é\Óë„éÒÓ€ ç·(sóFz äÖZ¿PiW;ÂPdY~DU®º‰|œ 6˜yWß&ÁÆ3 @ M_–ô¯óQ€@zÈùݤ$O«"?ÅeRœ–ÝM[ Š{>ødE¸ /G`}§JÒ"ëç2X^„4­ œj¨¸£Œö£| ög“hjˆ³~ü& ×ÔûÿÆä{ÖD¿Jñâ|¾~aØ}ÓÔ(¢Z»'}±æçí/™‘’¦¨8žÑSÄJòµ¬P!±ðÿÁº-%–Æ¢iŽÄLÂËh$ZEIŠŠáªhò„ÉŠ”¬î¨¿ôT>×ѯ<>°ïh6Å\vÁ‚Èú‡$,XgŒŸkbõë+b¬Ì•õ"5^»kÒb¨Ì+èWö'èX3XiÂç;u5É®VŽ 9™ñtçî‰&Åýp²¿ÖäÔº?§i `anïd…¼®¬ þFQñëù8,cX ÷gRš?½Tì!ÀGZÇGÑ…x ù„ H,ð=¿,yûA¹ê˜ÕÝÍU ´: èê¶sgãY”X¹¯'í*ꊿ½B:q6=E –ï”ú6˜`æQŽ (! 6b>ÒÐ!;’OÀÄGÊO7ðξôôý ‘ëwžzÛÿò°$]# ý-g¾ïì!G g¶‚À†âòæ°ÝÍÅ'Bé±ó£§«Å*ÝÌ÷(¤9wî¨×ô}>$¼Œt²ˆŠ‰q$¶¢6ìï×#º5C-㳟—{Ö,{ô5«†iW'kY³>Åy\t×XL«Q=Ó<­ ¹¢+“Ñ­Áã¥íˆèŒllß·G¼ßPÖE‘uû$™MC‰wY×-WvN4uÄ•ÉÔøç™©TäR~šO‘¾*ŽëÊ a“| %ÓéLí 5ÉU|êNðë–qnwYiYwû½ªËBå],‹jî+A×–=LR­Éyý™Ü’þCÏì7-³/ññ€™dŠÊÒ醲Ôh(KQƒC lQì`‹bB8ºœ)¸›ìý¹: Cö ¼”u9‘pϲ]ìÖîòÌg|ÚB»g8•×À«@èÚþ¹ãvÎ/÷¾ïz‡nÂF$©v„óÉ`ÀÊ5J¿k-`ÆE¾O¥Ý²öéX¬{Šýæbk*ß©@Y­§Ü,2¼Vj§äø_ÃcÈëõ-¡÷¨2W@M?šk#«Âõòè0Ž“¾ÍDz—Z«€‡=ÿ-ƒ¢u¾êcqµµ£Ò†|;y¾Ã!ÁH¿ çâRÈ imÁUüçµûÎ×Öø Šò;²¿ûKMl=ûFq9@³(á:0b9·6`ÆzØ=ÂkcyPÄó`À*ØÂ€þ8„ðƒù¸Ë9ôÃò€¼žïœÅÓ¹‹ùú?Ì?ÌoæŸæ?Ì!˜!˜ÿ d¶Àü¼™ÛÀœæ,­õü±x9p¬`]渽° ÌËZºˆ(ØŠ³ü@ÆOoŒ1UÌoü_>ûÆì| øý»ÝXÛ¾Ì=G:%:ÅYAI×ï:ŒQ|v ±Ñ7ûãÑ7 wî«h0uôšGyþ5ÚµéÞtnÎþ§]Øs”p­p…Š%I(X?‹<ûI7Ú_ëâHÏ(øÁn&—VýÜÎH´Šg#±Úþ>÷¬G~ÒfôEôŠy¥E&ܘäoJÿw¶lÀ‚SxXÿ€{ñä× ÀLq*»ëèpŸÖ;¦\qßfäÕKü…–³p—ãµ’œùäs=5îëøÕ×Ð@®Ò¬&ò}vÆ~맂Ʀ´¤ÎNœF[»Î£äž^-Ö™o, ëoçž%ùç‰D 6ž×¸@òúÃmj~£Ö’К»Cš©t¶óŸˆˆCT¢‹k\ý_èËü’Äi”±²@ï¬+plUhp•ާ¿Çðƒ>£ÇuÀsc †E ‡ŠÝzç-† êé,ÑÖËÈ÷·p$qȨËãã®àóõ{Qg£ãá@ê9½Jü@èãÍŸ§Qô ™‚óYJ‘9 g)€#{?¨p²Œ>ãhu(&¹í.š ÕgØ€à­s(…y%?šsŠþ(*#47àžá_›=8ÖÏðpÐ8Èý¾ÞgU+’w锾fo¸@§4>VX´»³…¦è·îSD5pf±©›Ò,GÇqEX”~phÆ:’F¹JuÜè>¬:~—6 w±ßSùÁ,M<½H¬Æ5¸òVÑ»°êR”ûðùò>^è:¸ßµŠzocPÿ6¨·ãŽg##n7èÏN‚IÐ}“e_a]5Ü»R#¥á ¡ƒ}Ÿ°?¡á$YNøf»ñÉ»þéíô9•ò9¿î¡”š9, $üJÄ‚+Eô\I]iøeQd#*7}1˜Ä¬*²¬x—uÞåbg•eZ‘¸îœyBs+0ã.ar‰C+˜’²Ði(áÒˆ&Øß»–}E×½™8âõ§·„ÿR,ˆãÉó½JªSZPI™ÏFŠ|û{°ðçKon;£o°ß¡Õzç?A!gÔ¿½©ã¤ŠRiu+ÇöYëúJ5ôÇøaPVzQàGš+Îé)X~9…ðÌ‚A‘᥈ðÒZ’-i=’'ÙlœP!0Ú”|ë;ŽšÍ×/ó_~hý>š;i}ýEgìiþl…Kwáží<ú0ØvËs¬ äw[Œ{ÛWÐë¹÷hùPÈu‘¯sÝ-¿ãGoæ?ÃíWHyÖ k Û èˆÞDu|wã}ŸšøŸŠkÀu%dó~rܱž-œ'·.°·VÈSÈ·ÿmÑ/é–7åZ4UL–$Oå×(ü'×C3_¯ÁåÆwYÎÇE‘½_Ü!Dp@ŠóWE΀C ½Ãù¦KÖ‹*¢Gô,à#×árÌÃ¥íÁbN OîÆÂ¢A° wÞú»í–g1ø‹Ù?VQš¡²u”üÁ†´ú‡€TRyÆiÉý–íYpšŠ 4ª¶nþXFàËTç‹b¯é!J’J¸ï[w°Â‰œlC¿ø‚íÐÈv¾²Â#ÉºË Œo*³‡îß<Ã,"J€ÅêžÜŒ)‘ª¶ó‡§/HÉ×|Süïç8RÊ_uz;ˆŸ:?d¼‰ß:\¼þ¡ì¹*áwº¬qøá|. /  iñEScxèr&+¼çØO¥%\„Ümcˆ¥yŽÈØøŽUK´æ]dqœ<ÚóªßÍ´¤^îjqGaÂFWi†• _yÍý[õû"J5*¸;×Òïi„Ñì³I\‡Ü'ê)¥¢Ç‹ér˜8ºÕºÀ¬ð*‡ÎîÓyÀ ¯£HtB4’‡8Œ‹,ëY~†ù<‡ˆÂÛãËQ†®)Ú£f‚«)JO{ðݬ¾Ò˜-ø¯}Bôy¯u–iEÝÜÿ¢Ûî Zi_TRÆMÿ¯=÷*ÓižHr1~ä2lÝg‡åWl,×:J 3öSÈQR›‹5yVD B…_’$¼ÏÁF'ŒûCÔñ`¬ê†ìwœ„+Dø—²Ê!~cõCÈŸ êG>*bc€f|û»‚K)åþââĽõUáµë`X òâŽÇiË׿ÊúèÄ(Ôé)œthVP³jóÌϬ‹_ßç¨zl¢*VxJ­Y·¥ÒóW 6¹‘äÊ×™û Û†eÈ«í ÃEÀŸ?[ËÅË>ß±rÌhãXèdvÎÒZ¸K‰ƒ)˜’8¿0‰’Í„Ð*q”ó‹¥øF$ƒ’)d>•‹6:"¬GÍ*ßø#1RaÛš5§²þ q]勨> Uþ7õ4(¿Š"æ—°!ÉiªðC§¥(º ¯¬½ÚÅTuR­¯k ÔÝíXú––lèžñgdáœqväëØ¦™QhƲÐhGµTX¬ "·Ä”ËŠOîâíPÿûõn0Vd!¶OÆàòêž;è»ã¢J̇¬½à7±\‘?›´"O†¶eYáÿý¯ÿ4xýë7:áwEñYU“¯àÓh¯4¹ãEGéþUDZ*¥¥.\U‘„Og©äÍçQ’ýuŠ ðSOü˜å,ñW·Ÿˆ7ÜQWH›×v°ƒSøHëcë.’ÔÁáöV1³1Ö•F·öÒû!B/ð©D‹7S½áÈôœ¾Ýüb¢ÿ–fYZS)Ñß+ß“è´?M"Åê°É‚ŵ¦ / ].áWìñÚ:1Õ¤eô‘ãS“{j#'•?J5­å™R%ÁûÅé>|­¸W‡ðÔduÚ9zàóNܵè‘e{Ô´8Í)¹ rýšñ1­~†3{ûü¯Ðrøà8íp­Ãêó$ìç× >ªjòô3l7ÕÛ·z¬O;Mø†Ã¦©ÃZWñá>^Üû€R¬ê$Ŭ:ÑÖ‹Ç Ê:Y'*î"aŬõÛž&|B"&˜„j›òâeT@¾Ët‹æ—Ÿ ÖÌo´ÿ(¸“añëjb 6¸mÕP®õ>Þåô ŽB¹Œs£ø¥çW *4Û:ée°:UáH‰ðv-<¨Ý´ƒáºÁ7•ÙK÷oO íÁûu¿Ëµû)òâ²v…ž-—î¶jL¬Úÿj®×]¶h ÈG<åW{´&þYTQ)ÉíÑ\Ñ=Ø_^Ø_JYâdd{_-ê±ÒùÁ2’üô)DŽuv_å±sÙe —TE~YÃ'öBª‘Ï2bý‰:z5 ×>•9øÐåYíź!$«ÄIŸå ‚”æiFëØ$Š›B5Åø<%¿¦Æ4Pâ@2¿ŽGá‡DO©;Ü<]f Í+eí¶/8A”ñ\¥:C­§¢¼YJW£¯S™3!‰j–AÊõF]"ÆÑ#¿¯#‡ƒ}”èQ}e¼bîŸÞÖÌç'QU¥’jùåfÑk8ë& uðv`-ÍE:yÈ;=ŠK\µTJÖ»M•ˆŽëƒ¸àŸ ù $ñÉ)*ÝQXJŠ$è¡öÛ¥’Y¬mèB œÉY‹éDx$\$ò¢N÷_æ ¡Ô/–žJEب†} .?\óᾨàÖ{WçÒQPâ=ö•âú9Lji×ëîŽP4qŸÓÍï†! FçLõ¹žÙ÷zfaÆW‹gö‹:üÝYH~Fql"@‰ž'’@ßÝb³$"§×0w R; à7·|ªìéêÐåù#p! ¬HßñŸÀ'ôа҈Ï·K… ÇA"UƒÿV&5ÌBbÎA§'DXP|C_L=‡°›IæcµG¨ e½½¢U½åNÅÇMUñá¥zòY¥@òæÄOY|þt -îŠWÒîÁW%! ¾jï­ºOÉŠMàlzŠî.2¨pjô¹Z¼³špG q=kqS²êPÔݹ¼¬³·ãtþãbͽ<¸nÀ{cUÃýÆ<ÙOžåCë¸ëØ zó4η[Ïý á™ýÂòýp±Ýñ¸ G¯(QÑ+ˆ½gÍ×Ð$æ6Èœ¥×|‰üžÝº–€¦Ú…%Y”Õ9Ñ4¦q_TÆ Ö-óA¿8Q±kSA!_ÆÿæàRÜ )æ…°%ÑW–޵^*‰;EÐzÿÕzo9œ`¡M 9½틼¨‹<(AJRíÛžšü ´š‚½®¿—C öºîFY–3â²yCÞ¯ºÅŦ(€y]íaƈL4q}Ž¿›Î–’˜Õ§eöb¿—ŽÂîòÇ¢©ðtJZãñ« è47€ ˜’}ô—ÝMŸâè¾Õ¤»Â +T5ß‚‘×ï_Ôõó+:¶T¾ûîÆ<­¥ýû´í᳟åSì»ý,jê¢âgÿÆçTh{Έ ¼áhEJÕpn9š\×sÝÇŽ$FÐK õ™Ku˜·´oÁoÓº}v›ØÝïA,]‰’WÈøÌ»Ü|6<ƒ5Z(ØSDU÷±(Èú LÅEVTñ±(èèJãöÊhqQ•EÝ­¨T-¢oâßwÒîžâéÄ—è"Ù•Ïí#Å÷]øÙò,óýó†*wp¾Wác´XÊD’ö`€4¡Øsiøù³ež<]œÉRæ¬"Ǖӭ™}š§­‡`#™"g–~ ÜTI¹ftOŠýžg¿yuT€ÏÏá3è…ž)³Ë2×€ç>ÐáY|T Ë)ÜUÈ)ÊPvLÒŠŸ±l„ç´~Á| MJ¸§¨É4ù¯“´>¤|t;]ñé‰Ëõ ˆµ,+à@½b‘âm²Øÿ³I§¡SÆ9"Î×E”×$×"<Þ ³©Û]7‚ ÷þ;§°ql\‹L¬k• ‹‘&KO…{bR¬NÁDú!â$Bt%3î+:¢Æñs‘ÁÄP‘×ÊÞ#7÷.øÃw=º0r#pC® ÉÆ6=”/´7‘º¿yO">ȶ©é ,' %Ê/#Kÿ¦"†lS¬†Ì×ãË”÷Y‡_kµÆ±ž»™;8tÒ,•¯OÝWPU$šx]ô%¬ÁÞM£y->•øb&-Æ“ùƒëŠøü®¦’ñ¬Àûe?²äµÌsga­Íu,Ïs=s™©>ËvV®¹Ê¦ý«ª.…)Ég!€zÎ_çþ˜Bcª ôûÎòÛuÌ•Dù˜Hfª¯{™{Ží<š ýßé™" ™©bˆwÿÞ.3]Šj[_ðφPÕ±u° ðb1q ¿Í‹iÕ¦ !¯±”Ýárªmpµ;G˜5iÌog$IÝ_.kó‡†ÒVÿÊÈïþtÒ²fäVYKr¶¯T¨ Åß_£ÊP›»E7˜fòˆu€|öZ$_ ü¨HS×EÞwQJ¬¬î³Bºz§«Á=H× uU.“DêvLZZØsiÂòg‹âôZtq(+g ÜïêVvªî«Ü®ÞW X§¨YµT¼¡¢¡‡gQ’˜½~Öº-äîÁ¹»½;ãÿûrÿ"Ç4!†bÓ¼ô´¤¾LžOc J*Ó(igŠ EêèÕ\â$¶Uqàײu 8ËxÕÙ˜ )§P)*~™æØŠ°cñ5X/€®Àüú+#¸°s²›ê»7ÑvG„• î—1l|O¡tž…2*Äs#©,úê£X¡mJ…Sd„×G$O«cœ8­ŒØkÞùz1Sj(ò«‚Š÷”|  PÏ^e}Q}‰¸ÈšSn(’°L_c‘:І"ä3í®i1Ùqcª!Ž ¶©H~Pð*f=¢™p’ŒsVëjÍ©ä*r¸·U ¢vgöVC ßhàû‘‘Îy¥Ä©x7-êy?å‚—(£Ê<¿”y7”à-Ûõ%ÄXAµ—¡¢—#Z™ Û\äнÖ­º82ó ™h²£ŽÂgï2†½ô+­cdÚ¨~ö]&/¼ÆKštã-p—­E á^„ß!‹ínuP…Þ•Ùí´r;á'~o¶Öèêð…ùflý¾ß»0Jú– á…í-v›ÕÚú '`/r×ãKŸRc üf3‡[Žv”.­%ÄÚ˜îÖ–¿žûO?Ì=8z‘µ^Û[ßöµíùEp’…ãqûåfîýÐ/lÖï°ðƒŒùžÀýaiçlëç|„ ƒ_[K?ž~.ÖóÍ\¶` YùÇb6dQE6ör9~7ŸŠâ™E;ó?z««< 9(ylRpmßjIŒ-²ŽŽë°0ml§ “>ˆ¡ÖÞ|¡&kkøOö* (f5„±…‰oõ««µÿûƒ®-‹zP8˜ýNû+ùF;‘ëeÍ¢m®bÇ…%£„îjå[Ú¹ÅÙmô ¯ÓÖxÚqènu-YGj(ã9(n¶kýÄç¶ qoΚmkPàÙO°:àBÀ>PN=@9õ­­m éEÁûO¾» ÂÖ¯×ÓnL!½-p?«mru­{½ÔÎ7¼_ûÈ‚‘ïmCžô£‡Û‚¢‡ý?”¹±ïœùØÃñQð¿¨ý-_i\Á ©ÅÉ»<.Å/¹ÀäÂ]_(~¡p¢”{7—SÃ8ƒ‘MŽ`OYw3áHúˆg#ÉÓþ¸›õ’°Ø%w’h 3ä³Bî_OÅPm¨÷~wûiüÉ퇉_Å;Köñm²zô Ù—r³¦L¤s#:üG•²¼W]B¡'ô­ha=ýDîùT‰Äõ'‚âé­½Fñ›<ÝùÓñ´OV|S¤ßœNÑø¡ìorx6<ý5º R[‰ßÞ1J»YØLýM>÷‰Ú!<#ŸüA-ÏZ*û¢:EÓˆ„b/mH/g"Y§0K)"hAÿ7kxë(¢o%faG %­×]p² 9ßõ;¥d^Ô„N)Èoƒâû³¦,ɓР8J‹ý¾[@šDñ3믒L­ÒºšP“\6HM¡hTié!ë©Ôå>Űȳ¯i”y…suO£±ä´¹ûDSIM[H„“°I"L¸Jž*Xýa°–¸›¤m̤ýlß5Ìáùk°õ+†¾3Z}a)Dã\H‘B}7‰£Ó¡ãqÉpå: ã¡•`žÚ€NMht¼·8.Úë×pôHWEö¤â·þ>]ÃF=$“à<ÊR|´Ò×çó‘jK“þˆ²7|¬ é—ëAÇGšíóñ±f÷lYðÛVgo@£=õ¡ŽJ`–ˆ(+~m­4³†vw» ä ¾ª ¿}àÒÂ4f:•¾s–L…TÆzSűNoK©Ò×ð„â¤^§ù¾ŒáWøyTÒcQ£>¤‡¹ûG¾)4¬ y·NK(iN¥¡DçîM<£†Zýt«ßíÅZE©Gm¡s…ÊZŒSZ§øìC8þÃpY§§gçÛ…ÌB1SÞ=«/s5j¦µL[ÿìøÈá SEÐE+LR…ãx˜à4±~–š2hæ)©^¸Kš6 ­ˆQ ·ÅD¯€‹Á W^Tꢎ²0W»b¸'׎Ú/• ¬«Òà'Š×?øšéUcµFêx#©$<‘“Ü÷¡¾PûoCµ” 1ÅÂò=qrX’ËJïÅ÷á¢Ä²uôFB:èmi‹Ô_㣥úëÿÑövË­ãȺཟB±/ª§‹®ßîÞ»&Îî%z-uÉ’J’×ZÕ'N0h’X¦HIÙV]õCÌÅœˆ™—ë'd⇠ERHGt¯²H|@ü$@"ór•DŸy»#ÍÊ{J[jI@˘Ô}í  Í —t¥?%õ—›³Ü‡­ðLMšæaÃi5¾º¬ öxí¾®êý¤ª¸2`+3ëˆÙx)ÑÊD¸‚™¸Ñ‹›énBtÓƒ ÐÛã¨ñ2W=Z»"@»:>ïû©Fzú=™¨ˆºNVjÙ͈W7õ5QdDàÂ84Á%qó†hnßT  ”4Gªj€èÖÞY³¼ õB]Œˆ® ÉdÕæî11Åeº@ŽF¢ß;‡z0Ÿìifº ÐÕìÞ>ÕóÁçš&J»æüæÉº ðªÙàZ_.J_*· #/ t…ÞNSÜÁô:€µè‡  8Õ—Ç Úê×zë‡IÆêɉ] –èŠ$Š1‘dƒoÓ/a¡ˆiÀâ<ÖC(Î4@OLŠwˆºMIdú²"È^‚,xÒý˜ì4 'yºý'ÔÌ6GÂ8Öœ7õç¤èt ¢ä¤Y:#1G~êîu1ÏälA3+œHic‚Ȩ*[ã³ÕƒÍY@[´&`¯=­Q”þX§ ¶ à “æ€JâL´Y4 NzéS*ìÝH³hà|bÌ9+AÜ\2€˜LµH›V_”¥"ˆ²D3=ûýžGkâ 0™Ò)N»Ï2ŒA·¥À¼O§ÝNW4ц¼Æiè{qœÒŠqs¢ÿoÚÍÙÇ`l1½R¿O,O©€ƒ],]_ï\ˆý£ Œ_M4Æ,!DsúÉtǦþ6CN§í$Ö` i 3åAh€p£½n]çvóä1,5‡îÈkööŸÌ¤=B@6é#˜4#QžºÕ•hÅ•®î˜×À×K^аœÑŸ 9Ô$S¸nfqüØcÀýÀÓ,í›§+ÏκŸ*&Z‡*çúÓ‘¡zbC“Þ:ŽE1œÕÓw—÷CÚÁ]‰B¡Ê ‘±gÜi–3C-3t;E“xâ=3 yZÓÏË,ïcjXh4ú3Bþ~rCs4Z\yŽ„ö“ìô„Ú´Î^䝯;?‰›ºG£ÒÓ6ÓË2«ïÅqke¸g·ãìyØuï8ÝrP½Ú^9¯ÅŸw':çÑ=—Ðü.àÄM2Zþmƒ¤ªÇ0ÔÊHYѦ„®¢‚D³b&´]ôJ§ñ‰ÖÑÉez¦C+ð°´cÙ«æ‡:; ƒ$ 2LÑVU `Ð;Œz#‚o Gîõ |Cr³Vž»Ç'ßÕA<¸IBu…U¿µúGØ ±¼Ñã}'4ø¿ÍzàŸÉ¹üÅ OD‡€ŸHjÖÆ}¤€¾pcˆÏ)ýb¢— u­.µˆõ¤Í"Îgù¹kúS‡òÙø)3@ÁâÆ7‰aKåÍy/'kâf"ø–6Î$·5]è´s,üÑžüÜຸÖZ¼9¹–þ*Õ‚ŽÅÑ›ã!¹ÜíîžBL³ê§TTiŒæ–*Ië ›&n-±|8ñ‹ Ýb¦ÙpŸÜT³É(ŽØa0}Ã{qÛrA”gýX >¶›n”÷âéÏaª A´#©ÃJâ‘DÆCà†ú‡ë.ZTXÿJ4}*€qô¨]…\b@²Õ¢§YF ˆb¾šèÏ”R%<î ÁÄ”¿þLÌŃ.žâtQÂoÿvKÿ 5w0À$ÍH¬ÝëBZ¼ª;Ðkqâ×€@gàM½Õ[|=*+ùjª[O°Û¡Ý!p&ÖKz(v‚f€3«¼gÈ^à?㉮uôP[9À\ª‡ 2™¹6òôÛ ë‘QÌJ‹vS= T8{±Ü`íN"ÚI•¾æ@Ké ¦bJ„¦'¼ÝľÔm¹ÏWWj©ÍUj|Šˆ<²®î”UyqÔª¾{ |*ä½b3«&ï ®* Ní›É\ÇÇǧ j¾;r•€v¯àh^~èÇ+WJ®“PQ”›‚ •òŽ›QU²Õvù*ê¸e ņ IÇϦáWÑÇ¢¡˜ç.ü(aÅ·œŽâÌ~ò|çç{çÛ=ä:8 à¿î Õª>MyNx¬„1€/ëd{!óè ¿ZÕ†Ê,%ø,‹C0¼@‚ –ÜÀ0S ªÒ𻺽 -üÞüqXÆ-Õ»¦üjè¤`f•ª¦$CÃEm‡]§:7iå´MñpZ¨6ÏA²å·.xR Ô°«©} Í7 ûV™§7¬—l°•òó}S¬ŸN\¸Ð‡Ç8Ë‘°'Zi A‹Á!x†à¸wÃŒôäø@—ÚÜ|¥?Ë5‹úd½ fQáʧÑ‚{÷éI’Á1@YæÜ½COŠ!º ¡ Âïû~ÒÃ)̃y‘!ʳˆ(míáD'%ˆ¦÷ÐŽ¡'ÚKìÉ[Ošµd$„dâ À!ަ‡huªžA³MO}۷؇(Ëg7²!8úµúÍ¿ÌËã“§Ó¾O+à7 xiΫjlÖ‹§'.XXiÌ,¦ž$;7{O))…Ô\'I®À7.FGû:ó»{Ò§G÷mÚâ½ E|ʹ S†6ý¬ÅÉ|&ÌLüeôD\1£8Ä©¼hØg®‘²j6#8=õÂç†;Æ:]¿þî@' øõâ³å§ °ÏiÚ›9Ǟșh!ÂüÎIàgvŸÆGvp(¶ß’Šk°ÏT÷€ ù2:&á'»x™=fü¼M³SÙ±ÍyrYªUJvÁ[6¨{njjZ¶BYÑw []ÓSꚊ¿‚EÆ\ïAñ–/'þ=›êû‘ BÛ¡=Ì%»Jây†ÉcDxýö§+<¶wYp‚bqDÿë7ú‰ÁQî=Ù@eÝqs‘~,ýËBuSèoxqÈnG¼~ÝkGÂZÿ™R%ëóJcôÓ‹d{HY¨‘áX„6ãlgöòqÛ«æĘ̈¼Á¡PΣ='‚Fþ¾"\Ìñm‹Íkx&… 5,Å:î)…Ì0- Œ¸94T'’D8®êEpÛrÿNƒåF“IHú3±hçýy`G¹gÏɈÛ÷ÁÐ`-!ÂLûlz{-)9Æ/Äxƒ‡ÁM3Ïø¡>j¶3Ã=Fas¨ºlÒ—õ{QøêrŠ=&ò|·‡ ZÑÌe”oãx¦7òȸö¦Ék':P¼rÒ¢ ^§ý¸Ý®úMrÜz<YßÂÜî’Ah *i9'Õá¢ÓBÖb‰§CE÷ày\Ïû¶˜ èóQ‰›rÛ—†1‚ šÆ{9%»ÔÝ·u\0µR4º³êÀ³‚{•DaèSMy¯‚( } ‚ÿ:¿7ŠÛî ¡9Å笠1¨DW¸³Kã£ótnŽ×™) O=8NQÏÏáÃÃÉ㾕rŠ^S7éOCÇo/ ˆ¤ê“^¿ÅÍ']jGXXrY¹éD+…¬qÁ2(N/†SÔ¿§È¸à ¨ñ†y)IÓ\#^ ÷þwnxãS~PôæùXbÖ ¤€lfkÂ|3ê+lª|þ¡¿|neÿþ]ÙxWö¿½+ûÍžòߎº×у3LçÛ¨_Ÿõžt–¨WOñK{ie"‡âƒ‡)žr8 _ßÚÃø³ªêµ|eýïo½Ë;¤ÂZO8t‰û—s]Œá~ ¬cqwÅô³T¶S$D ’Z8î±`T»S:×ÏG»ÒA²JÙ÷Ko¸z6Î1˜åW6Æä4ÇÚÖ勳| F-ªl2æÅf^gÀ"2Ú›##¡œ6(_Ɯɿ‡(›ðZ>@¹NÑ Iƒ] <™÷ bK°Ø ì;}—™¾=> cJh%‘2 ›âºOí³ì?–‰;Wx:Á-•¢ƒ4ŸJ^c¢BFRpíý¿¬9H™7?€+óïù´gÎD~/Øz3yí—y;ó´™Ôv&QG ‘²9hz,Í(@3 ¢]ÌÛJ{œ\°t1ÃÓ¡£=\0ê·Z:cmì’­›‰Ÿ#üw¨ÒÑ!Ÿ†,\Õ8~Šs4±hµlU’5v\%Éþ†c©•dnêÀUânŒ·ž9;ÂmàÛÄ”ÝJ˜çžâWã¼³à˜„„U€>þ0C9ä- ¯ñvIü…4t¬RÓ©èÃÖ—L¦ÿLÿqÄ‚÷²ï5§o¹…Ð ÃMºc5yKï9Úà]J³m\~_CÝôÙÈ÷þ´±‘wp£ˆ„†hüb3,]É»G3hæ¢U ÿ˜:%¯Aäëã¨,¢S>.' P¯iƒÇÛV€ÁPc¸Ìp¸H´Ùx‘p³¾[À úô^‰5ë¾ nÖ'nñ_ãïUЩûÚ• åxª’š¸Ï)ÙÕOEìÝålÄŸ Hhµ´å=±²TâPÁý@™jMv$…¸dºÙ}¦ÿý™œ!r'ÄgÁb/Æwgôí39Ó ½ æAš›hå¾!¹6æxñêñÅü-zÓÿê]É@Öó pK Ë#Ç,Ï[úïKãŽKxNÞ‚Üïp"hXö„g=(ÔùŸÒ§_ظÏt¡ÒüGÄ¥UçNÿgýáÉõž›=•Ö<=¥ñkÓaeñºF@ÊWíJ{…¢‚×:)DíHePøú‚wei´XÑ£p"ò:“»O&T{ÒÜO[mãu¤ørs´ø`}á§KDUß{ñÕeÏe' •Ò¹«)/¬¢bªzI”4ÕãÆŒ~žf¿é§$$ùI˜Y~Ÿ„L `’E•0¿¤í2ÛQo‚ÓÑdð¹ö[âF¾½iI”o¨ÄkÙIªg¹OÙA»È¶u&óñfcoœõrYwrÚ }\¯íÅÖ™,÷³†àǽքN]8Óñv¬ ›/'ã¹ó0ž|œ-lMìÊ^ß/×㜴ëg ¹éŽÈx>wÆ“‰½ÑÄMÖöxk;óÙâg#àæñΡ?õ°öâñÁ^+pÍBÛ_ìÉãÖÖ-–ÛÙ½fAy´×¿:ŸÆóGÍÌhåLõ{k’Ñçåç¿þèüð½v0à_Ô®g:U±]´E \Í2éN=«3Œ96©×+[­¸iËr¤è8€¦iv K×PzÐ_èBó<‹v±vÅ"²Ç‰xƒÉ®Àé¶éÚþàÜÍãµFßÌôór=5€ÐÌ>P©6&èùl»ÛFö—Õx1u6ÿÔƒÝ?Òùbmo–k˜¢ìÍd=[m—k=½YóíL»´‹¥Î ˆ_ôò—¾M!«s>Ûl ¡kû—ÇÙÚ~ ªÎÆ€G§b7‚¿!¹Á@(aœÅ§ÈoZœá˺å{qGHÒœ“.¡îœÕMÇ›­½žm:ö°ý…*Èãíl¹0¤ƒÙH5•Q®KÝeáæÍªxºçhX=è†J¹ñ|6Þèa›_}Øýlnƒ]ª>r¾\®ôQöòiÂjÃ-–ÍÁ–®B7[“¢.–ŸÇ3ƒüVëë3Û'/ìÝåøÏÙU´¬åM>ÌZ.È•¨9†"¸uv°l¯WùôˆäilO"¸±îíÑú5€Ž9 xŠýsãf®ß•ûG&”ñ)OܚеßAÒc„ë™w Ç!ÚÉrZ{épdA4ú”¸b«ªÇÆ{§8„è_B~k=#éKshrMÊŒ®qão¾¦¨¯z,‰üFcO]*¨,'‹w`!G†ùJfN©T\ÏB‚?^‚geapl9ÇÔ"…—h2뀻îd¨ƒ´," s74m~t±Ò>)Ãõ™Qö²g³M_Š ÚYö¢€jU;m®ÜHRLxúÚÃÞ~,8º¾Œ½æ§3LMá%I˜­«"µ«¢ÄpûyóaÆ}`˜4ì52üH#!Õ‰Ùd¿JÌ:k_Òl¡•ÉHXOI’aŠ„·J{²ùä !\f8ïJOÖ!Ê…5˜v÷G$3ÐØäTµšˆ¡áíçbBÓDÏ"ªdç½ÉÝ¢Àq·þŒ% ¦³Uœßƒ=›Ù‘oNI§¹ÙS»Áb¶{XO•»éÞ`õ¿9G¹ûfòE°ÁcöŸÓ8Ú‹uºIÖ_>næf‹ë/óÅÆj¤ír±)Ú°ãßÈq ¢àÚÞƒ¸M·R©ë̺½UŽØúö[å8i"Š-9¶yÐ(OÌyQ¯jÓ°Œ©!ªL:|‰©Vø.5± Þ‹µÉб71U@òá™÷äHÙÊ;ð6ÆÁëK›¾ëût3`~ŸÞðLÎïP¿`ÛH®'“³‡áy³÷¹»é›»Aø>ļ› M,T‡!§Mü¬a ª«iXÆŒ ¯RÁrãÚˆ6éà„â¨Ä‘¶@C²çñðKpöëV,°k†‡Žyí™FrÆwµ§%´¼OÛü¡%®ø·ÕuºIë:L) "ÅÃpÅ»]Ë%“\L`OÚBšu`‘SxCxŒ¤gS!>ëÙó š–ØëõríŒï–ë­=íñI%¶‡Õ|v?› ë;g>{˜m»µ=ž|*pâæØ‹ílûëP”Ûízv÷¸µûËÖ^/ÆsÎï¬íûò¸OÉÇñzHJtl:|Q'cpÎýq¼ø`;÷öb*;Kð`½¯7³Å‡²™>Òž2çϲþbæ52µ'óñÚž:³…³Œ»ÒCÀQã|¸JõÍýnx5Ý5:ïg‹Ùæã`ƒ}¶˜,éx·iVÁÀ¹^Û“-­áÉr:\ÕΟÆteëŒ×Á¯êÀ´ÛåÏöb Î<.~vÆ÷´§9Ó%vs{À?Ì6«ùxBÇ<[Í¢],·ÎfKGÃx^ï¥×˜ôq³²ÓÁ:íbéÜ=Þß׆0äã ´ޱѨ •Ûã‡áç J»QIÓÚP”wtN˜ ÕÔTŒ<Ò)ë“=üÇSÙl¯?ÑA$ís~#bHò-ðì‹ÃwøÞ‡y1T·z r¾á¦ª9nÇ_"ÛŽ?€(}oSm·T¹@­e ¾ÇÅÝòq!Úz0ÒÉ|¹¡=hMœ ©ëÝM÷"r6¥U€š U%†®Š)e[Ðâºy\Ø_V´Z)/PÓŠ©N±X~^ ­V¡‚Ú·Ïzâž1ѹ#7$¡+fö\XÓ'ì8ù¦)ñBÅGàÅq‡rœòú0Ü`Ž•„ÇwJá’ÚÝ9'xm»Ç®Íé$OÇhq:>5»E5áuÊl³«Ò2‹ûÕŸ•ïŽÚEíÈŸÀÐÛ°ð0CòNÙÉ÷ÐÅåµ0$¥¼Ÿ>pYó ?K rsØŠ”“~›ÈlzdxÈÛoÌk¥êvsÏf*q¯Én°ðäàvy"tÑ9‰£FB]Â5Á¿§“:…¯‡ØŒÈ=¸T›»`FGd°ª\pTƒÐU£u >Í}“cv=ª:³·Á¾w3T¤Dô£Ýc1ù)[oÚ÷ê•è£ö]æNd~Ù‰‡ž?‘ô}fÐÇÿðßa&}¤ã+NI°øF}?º/ÇpÐâ=v;ˆ¦ÀMJzÏvœ¯Íê߄8õIJ|çº-Ú,!^° ̉3÷­ñ"$}W»Â„ç›ñ—N®[%G¯5·†6éLÃi“ЉrM¼xÑÎè÷úª’wŒ>DÌVÜ˜á  ÓÆ–•Ñjê[X¼G*j‹+PËxêÖ»Y{“iÒ\™µû6使¨¶m‹,3מ†ïᙢ>½ñÀ½©ìçT[BÔ4Ò0èOC•íöt¡9ö£+ù,_z~z™ <ŒÀC Ü&¢uT¢Ân˜‚ÝîèŠ)´&}uÓ¨wßž“70ã¢ËTWï.™p‚J8Íš´i=dJ‰hø’21ß·¬n:;t kðˆÓ™‡ÎÉ I…£vS&^‡ð=Dg?0_2 Ÿ´#w˜ÂYacäó|ÏÅC_NŒ¥>ªPg6ÞWÎŽO§&ªæcÈeYÒŸ7>:ßÕïM²¡7hé §¸4 K‡Qµyªán@íâïÚÕŠfÐUgUµPú%”µ®61EãZSPÀ þ}ÒÞ,6]›&(…¯‰è‰H*ôzS’ynr­6¯‘P~Ré8MN¹“ŧÔëKùû)Î ìòôä9E}¾þ7-b(6µ=KÒØø’ä¶ðž Û9séÝ´qúS™»Pâ.î Œ­qL¥ßAÁk\[÷×áZlDÞi 8aÆü £~/æl`Rl¨ák¢‹Í"/ež¯ÜÀ5¤»rõÖ”µõέ)i»{Ë®¬éQ í>5)‰ ™Cá|t˜¯4Êzr;òbÿÚBT›u¥D9Žu£D=ˆ5{§fËÞ³Ù²wi¶ì]š-¬Ù”M]ó!ËIpXUŒ~ú •œ’ÑÏ0œ5‹Â8P“P¥|-ÕEóF‘4¬YªÇSC}•—v‚‡b½ÜhŒ¹²;:ﵘ•”+¾HŒ³Ýi™.[önÝ*{—n•½[·ÊÞ©[eÃw+J +”Ñ´£¦‰×´$¦¯ê–ÂðØ ±‰Þd)Úñ·wAD! 2¶ÊÒ¿Ò¢;ñZ<áw¦!Q?š)Äb ý×u'èýA’¨ï'݃ ¾ñ÷ úÍø'í®µ¸Š=Dc¶JL¸OQ£o£Ã6½8ls©ÉÇ„^ßzº`|jxÑG&ï@» öQë”wŒu1›ír­™“AMŠzl¶[¸-웵ƓÀûäé´7FÓaGÃfEçà¦=WñðÃH–:ƒÑ.6ËÂA²>úø¬-v>Ù»sÖ‹íìÁv>ÙëM½“ß&¤Â…S‹¾ošû.jê!â§ß4@©§SªrßÒÅh•¬5’L%}¹)p!øËK‘ ^̘hÝþöä>Wãk3.±•h(~¼­ó“Ý‚ù'ý£qåxvë…ÄMÙ„P?ö:p\QŒ;0<“sã°ëâ~_°ˆóûøùíZ^3ûBxŸÁ cce'°!0"®¡)²ÈÓÀË­˜*»0~5ã¯pä͈̀“A *<Á·Xq#B• J ’$ñ‰×‡YrHr¦ˆ÷”$=Ìz´×ìÌz…/(ÿ0*©Nír´7ûÖ9¸/Aœö¬ˆVB%[¾’³àp43Ì©Ì!É_è$˜‘oÄ*Á’îõ@HˆrÎT •q|Ê-ºÆÄ›æT„’Ô Ì´Øz"5Ú߃#­(…°yqSÓ|j˜JÅžÅ\fÆ9(*õ.%hFgL\¨´GøZw¦¬¯’&ç~¤¯’ö™µJx•v}p§ãÉ|UËC3ù»ÞÂw¸ï´0°nÙú™>äú¥ij҇¬õ!}È'}Èg}È}È“>ÄÓ‡øúƒ>$Їõ!¿ëC2}ÈIò¢yÓ†ÜÞÞ"&')]É~7Ú‡q–+L OñPùû\ï™}vè>‘’¾Ü<‹Èë7ßßþÀ’cš±÷ů!ñ÷èe0£ØŒ]³³âüÀU¯’râ%þ;9¸xÀ†³Ä{¸Yróý­ú±µ@A{E—æóB)þÂ(Ô¤³êßüº/%VÏàZHE²yýz4;&)]¹ø_ãç­éšìX’›ïÿÚú¡mûÐþ† Î]L[1c!wèTIùݰ‰ñ‡Û¿ñóÙFàŠ¿ñ(>¬ÈôÍÍ_´ž‚Ür=ˆJb¥ÁþWòÇÀ¯A”’=ËéÂxF·É»ùïÿþïkfØ»ñÆžÜÏǘ@'Ñ èp ó<{‹q«• rz?ï2|îìûåÚvÆ›_çólûAq‚vE­ø¿ÈÉÞ¨PMÐl1^ÿê,WÚÍ|6±õQw›ÉZ6Ÿn>ŽáV“^u/ç[g¼ZÍuŠÆÒÐèÜt_ðÌ›íúñÁ^l{’­Ç‹ k›cLWƒ1è÷y E-Ð%úmñZHrrF÷8›OÉ’–ÎùÙþÕyë4"‚ç³ÍVcÍÆÖÎE·w1Ðâ¿ë¢¶«¹NVp Йôë È1ŸÝ3LÆ“…žŒçóÁ:ž$뜵sÿ¸˜lgË…cÑDÎШ›ÙÄùÎù½&òçÏ€‰qNôdÚdb" ûuT“I’aè\d^\g<Ÿ7b,ë‚Ë©kÝ¡íÉÏŽýŠ·íDcþä¸/màÜ/WÎöãz©ÓÝ–+Z«Z6`f4;Ú³—«­nk.÷³4K£¾PA›µMAb¤{øùtxp6³­m<—‹í˜J4ÍÆ_|²×[çÓxþ¨3a,W¿ê¥vî×¶M³Yo4p«•qe¨]¡/¾{+®Š Â+šüäq½±7=NÒKSøòEóõx¶±‡Sœµ½}\/#œÚs{K×HÛ­ÆB‚ƒ¦öÚ¾×FÝuôdú0_ÞçÚ°ÅøÁÖé.«¦³ÉÖy°×lMÌãj:Þj€ìñýªËh±S¾è¥Šƒ=…i6ô Ä—‰½BÝõãx1s…fˆ~lÙÒ’ØôÃ×tʳ]&cZQTI¡SЃւ‡Ã`_ÁÙ¬ì‰Ò¡óFó|˜Þ=ô™HÑG`~°·ÎX³ØYЖф|¦ùŒït`úe›Û =À¯3›®Œï×ËÝÜf¶¹>Ž?ÙÐíqW¥;ûÇ%ìÄ,öÚ´qgS^ÛW HÇÈ’jMPÝ‹ÇAzœí.áêx0É2ÓÓÿñø°,oIÖ9kçn<ùùóx=5„Q}ûözý¸ÚjrPe³Y†ó»_; u•bÐÅ9}–6f[´TDßõÝGc}„=ÅÚÓÙ¶9¥gIîïgý $=˾ÇúYz–äa5µ'ý‹Âhz–em§óÙÂî_ÉÔ³Dý±:£uóËœê?ôÿ|AÔó gÓþ…A–~%Y ·V¦d?ëî Ã!œui­jÄ  åVi{Íöò×k¥Ck-ÚÂϹæ4cMäd¾Ü<®m]œ¨ib4·£·© !t¡_f×&`ܯ6Ö¸u@ƒFÕ¤°=Ý~ »ƒ ùr¢ŸÓƒ½ý¸ÔQzë„lWöZs0=Œ'§¶ñ†ëÃøg:õÙ:GG:°•3žNuÛÉG] Á@?Û¿jchùVZçÌ ¶±y´:§Úëe—õÂBg­G—.ö'ºèÞ 6g-Wšë…ÕxaÏ{ÎÜŒ£Ï¼½²;mL®~þà°³ãŒ$”w:[@„ŽVLi–téL¥›Æ4¶ª‡ÁÅòìžÎóm],ÆÈmôvý¨‰Üê ÃÕzy?›ÛÎv¼ùYsô<®g÷¿êb6ñz¶¸_j`‹G‘¿ú•N€ ÜÉŸ-§öÝcý¾jeª–Ѱ>úyµœ-¶úØÉxc/6À-íõÄžà¤oëà ¿tjz µd\.¶Ÿ×tu÷ëÖž˜QÐŽNµ±9:°>ÚþbO‹]o-ìýøq¾eÇ0k}ôÇñæãÆnاjG. J;[ÀqÉÖ¸}Ùl×ÓÙ‡ÙÖ …fKªh,§M4­Ø¹ýa<ùõóŒö±Ï›ûÍ@D›ít¶ÔçxÏé4Åm¶c“Ú[0Áµ/>ØFðÇ xÆÛtªKÍfÿ4@J@e¯ïéZŽ- ÀóñVÑhô°lþc'&àЀâW4‚Q4û¢ߌïm³J£Ýj½}\é·ëñÄ6íÒÜ»^ WïXíú¸¸{¼¿·×&¢ú2˜½ ·÷ÿ©úD³[šd÷y¼¦ó)Îæ2 H(Ð`Ô;´’f`겚€'Ëùrm’ëdõHÁ&ªì¬üÓ^8Tyœ›:Göê¡>Î NɯHVÎ?f[ÇXd9TZèKfÜ‚ÜÎDU_P–^0£“9@/׿^šÚLØÈÀŽM3ìs„Iá´c<> ·“Œƒš½QFœy†%œöëÓ…ùò@%z\€Æ8$#Ú² GÈ @øöÛo¯m±¤&ÀÀë²é4¬&²eîxýAc³SNe× k\\…¯cAåÈí˜Æ¥î9G}°´|[ƒ/ŽÔ´ÀÖ³RÜÐèšgF¿LÊï#èÂîg ªÏþª Ó»¤¸[¹©^mWŽà”dv÷¸µõ𺖮T©Y¡±ÜxPS.Œé¦yÒÃ0š‡m ¤wÂW`Lδyæºg{ ¥wäÆ0šW9HÓ0{óYçzåv2ßþÜó$…qô9IÙ.»åR£ ñ*ð /¦è¢ö:À>ÙÚ¸¥VV«1^ëÒ†èò¡]5;\Qô»NPf¥«9¯`è†~])úô˜’hïFÝ.ÓÐÄV¼ãÚ;Îî”SrÇé¢8NÆyÖ-±ûŸ˜Ë6ü}7˜h4Æwù!ÈF¥ç”±x’%ÿžå&Á7âðx?i—O¿/ÏFs÷LÒ‚¼ÉX„+Åt@o1ˆuSó”48VÈωœyhÁÂÀ…¤y<¯V.]§£ñÝ$}†„‡vŸ¼R.ýÐ:Þd¡Îkó:¥!Ä«HYójeܢ’ˆpé¼rŠ|ü`>çûf4EšÖœö©{<º ìî%åèÜhº²  ­pn9¾ÿ[£óŽ¿¨Šç—Y¨/oºOÜ4SBй¾êEWŠÈÆ-},óÁ„b„Ò^áF£`¹%i 5F‰^À±±o¡Wɽð5^éþUh F†MA+Ÿ{¹q¡ïé‚Àoç`tÒï‰ùhªÒqPËÊür¿/þw¼4 펯DîYt¸LHÊ ±a!|@»2½:îÓSJ^º´+¦·xz(FØ×3ÎÓ ºym[]OÀezK¤ÏFÓ cQM`øß¸Ü£ dKÉŽ¤„6ì7ä cܨAæïâ8$´çÄìcAD…R°šh²à˜@´Åü˜½1Šb*miçaXŒù ÞÖÄ `ï¡+x´:ÀÁ|7©'q#ÅB$œ{h3è™Ï‹S:‘v:ð)xÔÌòS¢_z’4Ýn¨©­Ì£M”5·,ñ°ýT‰«ÙCfW3ë¿rïÄ’}£$ºdÊFwTBVè; C\1y*ÊLeÊ;ðiÎàäøD˜4»·eJ&÷\ÄÒÉ®ãeRD‘ž|r7c G€W@éë8úÃ?yL ¡°9Xr¹÷õh{N¸³¬µhíÑ$>ᘢ|´ xðË'šì:o…„¶âJ\Ú, žŸ“5‰ßéݧ@ Uhò"ª ¾˜o wÉnQaGe\Ú(a!Biy5ðCGñ)£Uƒ,£ÎÂɃ\,ߌXgÎé˜ÞX«¹À€ ú^‰†kí ÊPØ)=@ÐóOñO¿º¨`«Õ&º¦ – W-ÒÕH'jÔŒW6H5|‹yZ ª#Ó-cCÉ”²‹A§§ÔÖ¢¤jÊ«Vñ1È©ø,ÎXÎ0IcÜ?m\4î9—*¼q۱徺AÞ²Ç#Òånö,ó’IÓ¢6aå?G`Q€…ð¤ÓUG¼'Ì~{|òRìTä0–#B¡ìƒHyCHäsÊòøh%1]S×>SåLÐÑŠCoŠR<öÅ´ß­´:èB–Ð=÷Û® ³Ã8N¤Jƪ º §Áí·'ØW2xÑ(ß cè[lVàInJ%° š%µñØ~NŸÔpY¨ý˃ Ì˜{«Duyµ®]ºäñ ŽØ÷{¼aÓì‚[¦«ÛCã‰Øqß&–H°ÜŸ” ’¾e‡FÖS{ÏÍ»%—k}Š0úNÔ16$íL6gû;~<…yÀb&锉*'Þ)É}ÆíŒ‡dOè’Óz&ç×8Õî6VÁRqî~EñÔ²5«àoJ¢JS ]H2öZ„—Ô¡²ª{B¥8Éu8>÷Þ¡ 6üo8EÂbž«ÔnÍvøÞsqF)¥qÓ¹ñ O%Éøï‚ç÷9]kL#(~A€ÄSõ•´¬¢ÕDÊ.@ ŠÓ-'žƒ¶³Èt’I>!utòó3½Z*hk*L¡Wc•{Í–±ÔfºQ#Æü¶’¢Èàb¹yQfúÉÅu2½›ÖHp àL° ̽Ä"Þ•)˜N úÄv²ňQ°ãÉQVé)2£Œ¤ 2õɈQ4e$jM¯Þk*»hBÓÞXbw¹ì‘'¿\ýf¢áqºÙP1d¹dçuÞ“}ƒ,’ýÕÍ©˜êIDÞµ?slAD~üšu(•2nD°Úã?3Z‘CÞ\Üð}rÓ4¸6óC©Ðvn ¸uA j°CËv»2cW3‹ÑLƒŒ~ìg&¦6ä#Ÿ¾aŠr‘ßWº, 9ýOE¤Íʤ¶á/Š„d–B6áÒAh¿³… jɳëEÑW $)×dO‹CëŽY.ÐÏÆRÁ:j¸£;=õ¤Zæ¢K:pŽ®7e4Z™(ó²#1u¸»Ê\͈ã/‰÷îeh¦æ…w•–£/iC0j¢‹8ç@Àœ%NC¿×|–Â÷þ}Æ¿‹ÞŸÅqTÓùQóq|Ú:kHÕ2à+ ).?8 IL«‘kH±+š Ž¯!.:1ó>’ ª¥°+Ãä«?1½Z>“_[Ñ2hì~L)c«© úÓaöü†òwÃ%=´:"„¦ÝÞXœ¢&/q`Zw˜Òà˜Lë9z Š(]5ºÅea˜Ž1laªz‹,LY‘…9EÁ›Ǭ®æ–Vµ@d ¢ ,+ñï·]Íp ÙÌ>€‹ìæ³[{ýpQ ˜n.Ž`º÷·jˆ©åìúÉX$±( ªc#w´ B2òIæ¥A‚ûÒôÿ°Â!!sòäß7¦‘{¯ø‹êˆ ñÑ àø2Ø-§;5Ú3õÕÂ=’ìOŒ.Ä=íP‡kÎ!H µ«éoàè—¶Z {QœëëFw²ã‰yܼÎWló8ø78dŽŒDa• zµ ø xÁ™YQÖ¾[òv o3÷íºIUÃÉœ-6,îâÎõˆÊÝ›X¥=ùЛ_ZÌ ›+Н‡g|DŒðH‚5n‚ÙOÑú§½ùìi{´Íæ³§œ Ë„x^c!¤uÌ‘êÂo ØûâÐñt®ç(Yê-b¨L°@Sp7x¢Ó•0}ñâFž upàǵ∼å$‚ýÔoä_Ò‹¢`-+*!˜ðº—îí}¬þ†ÂÅWï$ g*Ì‚[¯=HËA>¿¨´kÎÀé‘ö¯?Ò¹‘®›Š%’‹c‘–.Ù2*~……ßSa¢†=8¸”ìp$yà©ö‚œ '‘+Ç’Z˜ÔâÀ«C;M¼[¦žTŠ#+œñྠnÔSÁM*¯úI[ â4ù½ÉZÔG[Ö Á:ؼ2“Üÿëÿ=éùÿ㦩{þzÄ®¼äUæÂ. 4´"¦©d„& ò²Mâ;L‡:Æß€²вâéE¼ádSK4ÎlŽù3FÌUŠçKM›) OÄø5•Ÿ ©DÉàoåpÝâ$íZ‡J’!ȹ‰iãñØe;比E\쀼©tƒÙΈ˜aòO£=bP.¡=[áå5„‚/¤Ê]ã:.Õ=ÏYPt„Ðq$fIÝ´Ù2Ùòâøù‰þ¿8wuCyè* òƒ›—²SÑc=±k 3äíXüêÚàY# àlx“±u°W¹àЮåHB—vAB«„uãMÀæ@Î'讜w^žs°ÅS<U åÁ2ñµ H'ÖY/3:…¡>!•iÐ\O«°Ë-PcÐߨD]ñQÄ»†V?R†%ÈVÚW¿Åé×#ZÛ‚0ëw³ XZ.`Àë‹B‰‰Ó4~¥­%ó¼:rD¡ƒÔºxX;šw)!–<0¶PÁQ>.ð¿®$¶~øë·ÿùK\ClŒþŸŠÜDÔ~÷7 ÔŽ[óÓ²ò÷ÿÁŒI¸³ÄU[2ª©1Lò^ÐÍý–g]+q„‰–Y=Âø½0(ç‰(ÖÉl Ù]ÜS ¨ÎæBÜ@€«ÓTIwaü*Ê ðÑ„ÁaJà ÚehKÓÙ߉ñ$å•<ñ´²÷±ŸhåÈ EGÌüo«4íò øŸöziMæãÇ=ºÛLGàaAÿ† lè‡t¶m?Ú#ænk4]N0Äúĸa/,®û6_éaéŠÑ¿F…w'2AÕÝ„’³\ÚP–˜.öÌ[ $œ‚Ž;>OêL¡Œ @,¯Aÿ‹Oyr’¶G0òôÑ@× Ãs‘qvÌ\7lIs™']×#]}Íæô'Q:žU›|««#¹kKGj¦^¬‡ßì^}I‡¸£oðâp6^‡þ‹:3øXQ¼¿5¡JÈžpŠ‹–(uâ}‰í{ÙŸÿLçe!% ^~ÒØDÌ_wâUOe sEÿ›ˆu7wáù?¿°ˆ4¨fu_Î Š±¼iö‰]¸zâÓ.Ș,o\ Ñw®²¿!'ë‰xSZÞÀ#5ö ž{iÝÊ }®›Î^lgI²ÝÙÛØf*aˉز°ndЃ—5ˆ¹¾k\"$¡h‹Ñ'…ì¦T*Œâ²ì?…¿ÍëfÌ@œSÈ;œÕI¨tIðëÊ/ñån ;³ž2ØÞW qTS_ðT~ž<”QG7I‚: Á°gH®1TÚ\Yƒ•ì9CòMµK$ì¼ÖBáE [¾H&¯ 1]…íhÿ¬™ÙÄ›’´ÆÝã}ýXT½p?T[÷ÊKÆTqATÌ4eHæžâ$)O™8ã½€Š7ü;DºJŠ J¨˜«HUDd°y[ƒ€Çƒ)*¨Â~¢ Ãç\… b“ » ‘BìªÖ¡Å;©§åĬîÇòäç:n sé<ï1‡r^¯Æ*\Í’]¨Õ`à1Z˜¢\YÒçODŸrŒzðU¼<âXü‡¯Ô±Á¬‘èc öE¾Ã]O#¶¾ëJÀ7¤.êNÕè‘§¥À L¶eÅS‹J¯!¯8U¥y¨ú~>ûÍG¼)„]VŸé‡ÓÄs0è.-E^‰ûœrÿ%þ‚±|†sbD+u‘4eµÜìFÊ¢¨$%Rªñw©j+xeS£®B„ö‚{Ý«3}ÇÊúšÒ9Î!â]þêV!vîï—;·ŸSf56Ùèà¦þÿ5 XØÚZt5Y¸qÌþ~ãý×u00ó\ï@vyÒÇši÷ÛUÕ¢‰®ãé¸ióÕ{‘¢Åm‘LbY^Öìl¦ÌT¸^)Ê€s6[¸bz0¢” %Þ]f¹õJȳÏ7TŒÈË4¥Z=]%Ft‰¯0*1"dð2c ?zP2|‰“Nù¡G19¾Ä™%®×« ‚«œ$Œ(]â{ üü`NÈà*ã|Жñ†”dàÁ¸{ñwpì…C»ã ƒ#vT䎈×~¹9èŒBá<îRܘ1¬S<ä9ξ¥=r¸ *vŬ< în]^V_†>»A³ð*Ó•…›ä‡Œ"m¡Gãkà÷—‘}<… n™…£˜®@§2ÍzÏåA—¸ý)rÇÌ”½x¶©‘¡ º²OøV×ÿä‘öh—ÆÇÑ䦯‹N«ùð±²d–›µúŒ®µÜgu“MÆ­5/HI!I`ué|Q'>šõvüR-Îi0?qRã&°$4t#:aè—(î´yH–‡?.ÿ QIïìQä{‰84VÍ6-?Ü~÷mŽ.°ÐŸë3X‹â=[ÊöS{ÃAj¾E¡q³¡øe š<Θ‹]KœP¢÷ݦ+Th•(&œ.QKϳÂ0Ý‘.º7+B G…ÙM§pøM˜ð…D]C`jK¦æpôÛÉÌút G¸?.”ƒÓyüž•{y¿¾ãí,öUx1wš–{›O»#€;²aÌ1ßGD^>­#Üø»°èXâ„v f¦~ƒ¿7Ìg1Y”s6ê-…¨ ˆÒVjµ­ö$rr´àì½ëáÏÎ\Žƒ,Ç9ΈÑòÍHh Z6¨í¾ ëº÷mL_êÝG¿ýŠM «åèË›O€kÝ"¨à¤P=ú ×øâü«bäV Æú¬|ž›vEÀZŽæ’‰•ý†î7È›~ô' pÿ .O›×%0ìŸñð›æ{ m=¤î»oBû”o(1¸ Vôg$:ðÃ_ðc—Æ/uþ”•ùIÞ›©©ó˜ó'ÉÈÝÞ%JåCÍ!\NµÅ’ š`JØN(M$êfniO!?÷šEMÔ¦ (æze³ŽÂ%¥¥k©âɲ – K§JCÉNœú`ë ¶ŽC~ç­Zób+ ¥Ì ‡E~‡AÂ:¼;ù’»gËGŽCæu¥É„»rfÀÁõ§TüÓš¡…n~°~ËšÜÕ$÷¬ŽPc›²€SÈ´tòÅò¾w¸auí °´¾iElÑjå§Ì,ûV¤y`ÿý‰.W  ü†Õµ0™°çæjp2ç|LD?î5ªAÛFÁpùŒû±Ì'<,3›·uešêÆ.`ZJ•64Ý„ɳ埒¶È ­²v. Uá²Ê9Ñ‹>‹‚Óê ½Sf—tåüžúÑ?Uèü~t~…®çÇW?v×nW¡ z6M…î·~t¿UèÂ~ta….îGWè’~tI…î÷~t¿WèÒ~ti…®§À¨Êˆ·~toe:?HéÔ%,`ŒYQw–T¥¤_/r~·ž=m¹I#£k•ç ¡ÅÁ,Ó•IáHù™ögxnÏ&Ê¢k´kŒi(* >{Ô|붬ŠËÄ7Â’V±Ši[,±ÔO-ò}kÕ ‹\™÷¥M._²ê€(éÀC¤Ìnã¥Zª'ååê`,A†”ÄͤîZ÷/ÈŒÀ`-ûz”n.à kÕ@fA—ò¯p»¾¾Ài²ÒÀйòE+ËÜ+rÉ2 û‡†Lá•’¯dB2wËM7 D‚ Íຈ»O¸‘W÷Ltmm%òN¯RžeÑçÀÞ€mä][¤)>úlŶË”°×]¯ üÒb&ˆQ)xÐq^»§“²‘… Iƒ^Š…åT/>J0Á…\²8$רJ=‚á_p)`õÓý^n©Šûp]¢Ã—çp³!Ê»ÙcF<±ÂöìqÙ~‡?+YÛNÕ€Ç2‰ÄCxf¶)m5ùœ,žFNJ$o¢’•#œ°eÕ_\«WœK>êªÄ©YªtÞ€ã êD»Xç*/ÔËá'7\‰XäÞs|JåE_vŸø¦úùñ°„áfø•}ÂF GW3jóúКrÐtäÆ8ÆÏgXáËx`—Q~ŠŒŽ±ýòñ—Õ²lG~ÐRÜ€iÕÊ÷dÄ ­¿)sAé$1:ÏêtØ!S§\.™,…ÉâL¬3R ÑX:ŽKp| ëAÁ ' %*Ý9’Ç)† º±UwB+o+îûiÞ) ¾Rà¡×qÑ£u“â¤[A/{±®¬whemÄÒç¤Kò‹·{VPÀ³¢vǼ5¬‚›n(¯Aî*à¿뱎ǜäB«ÈóF ̼¬0v}â[~cÀ4¯äb,xÿ‡h.ÆxF>š€Uód~ ,o j ËY?KîªOаŒK9Âÿn¤˜²ŒZ§V¼€ ¶§ ·v }ïÅy!8Ïiõ^ÕoãˆQŒ0†”¼Âù1‘›0´®8¤,g0©1",øJý¤+]©S4²I‡d¾ï×R?÷›³ÀÃÖø5 a"l›Ð®äÉaqÅJI¹úÇ‚mð‰®\\?ûgV…]S—ì?ew|WXÙ¯B)¨pÕ™o¥Á¤W¹Ó‹ZÛ1«VvpÑ[™°jéÂvÏý¼2ì(,,b8wœ’`é5Ä=]Ö¿4XÆMšÎŸ,ïû0…Ñwq+Ô¬ÇûYÓëy­ïû3ƒK)væÑ½¬3‰á¾29LÐòþ™.­èpÁq­öŸ3†gï±Ï—½@äcÒÃDM= ¢‡4©cÅ0rÞ}§?ñTÎÝ£§s1pÿ$sÔÉ©23¥„Î"Q÷–[cúrßêÁî  7ì¨R¹]Йy#YÐD0;Œé«Ýåà‘5!§E_sðH¿¦‡Í×›¶Z2âcË ¹·{tƬŸºäaI¾Sš¤AÖñ[EbæaíöêIðºni«ˆq±µg=ùÊîÝž/嫨w¶²àí6ýT—Ú•zuÜÓçÉ¥=¥ª³™Õ➎¥R‹¡®UM Uxäèæ%T 7Á_p¿F†ºæ·yÑ#'K܈„WΘ-¦,ÄýQì0.¨šÄõ;±‰ND!Oñ[a8ʘ˜sJ"¶iX)û¸nµ]ú<µÎQ[dç…÷l}CaQN{`W}A±,LÜš«Æê­ Úì°ñ«?‰xœ1nÿªŸÏHBãÝɲu[Ñ+Ló2švQâQ¡gúíóx¿FΈ3\ÒÔÞEŠjrá²Þ¥|­ltïáÎÍ`#]ÅJ&b10wý§µ›4Š›µ —3c ¾A¡Øã<èßÕWàä–• &Mž8 @ùNƒÄ•8é2ýu¦ºgÉîí¦ÌD’Ì NWÂY©pÑ…z…gƒï lå›Aþš…E™r´‰"ûYçŽÈ|Á“Œ±œ¬,Œ5¶‰-„·Ì¼òu)g¦Û ¯tÆól‚Õ'¦T:c¿täÙÆ‘«îï$Oä/Är/ÝW4Q1)¨q €^2êÇt¹pÎ'¹ºaÞˆ§M—»ÝIXòZ¦?âH£4ÄQ]‰þ¸4hdai«-‡9U†ËšÿûúµÃÓ¿“¥°AÁÜÐ[5£Ø7 {™ñŒèŸîa[Ü£œr¬$U‚Û¨â+dV)]›hÈ܄Á;ÔšݰýOT5 ˆeÍ3þßNSž¶Ê±s×Âß‘Ø(ÎW]q‰Ñ¿$ ÕR‚¤#Ñš¥-°`ÅÄÇwÖµÉm«IkÌÇ zÃÒJlk(ø ösœ>ËD­„¦_N÷l¡íÂh+¶ imKÀž ¯«úÜ×RgcU¹!§^Ø¥»§°³ÝOnõ>Ÿ2¢Úsp‘‰Éië4HNJg¹·Íƒ°1²7O“UY?³ç`[£Ð‚z¤\y­[±Lj4¾wD¿R˜‘B9D}â'µ#íNñéKW²Þsã&07Tìy ]É"ì¦E©j² baDf'S„`ìîç`ÊÑJG` ŽAÞb8Ötum*‘”„Ž3ÚÌMg+uÝX ¬n¼vÍRMmýHs?UžÖ‰«|¶]Œ8JÁ˜ü†ysOXß¡ù¿¸p‰–õ»UÛî(nAÊ#¬ÙK8cÇÌ4M÷BüŨéPˆ¿t,„R]«J˜õº‚Ü3ÚOb¿¯Dzá±÷ò­p(]QXºoGH/¼5_§¡¯A=S#Ëð–ïj.É¥¾–èÏ:r–ѼØlg§ä/$ŒÁ¸VO– Šª)0Û8FÝýEÞõ­c8*÷?ä²± yˆ±~_¨*ze“ŸÄ * þ@#ª}†J,’éö¿ë¥+Àç÷¯Â÷×ãÝ(/ª“l¿xÞe$©KWÈ®a œUÆ•(¥§ïnL˜œ´Á h‘šp ~€ñàÚ Ê ‘²Œš"FÚsŠÖžQÏ ‚¾µ邤 Éxä©×ê 0"<)eN]ƒÒóÎAÓX;k2õŽIû‡Àð¥‰J<˜?Ê‘5;ðƒ¬Ö#€,:À¯y8•$ÅeVÈô€èŒ M Ûfº$“2I¼ÛáåFM–¥`™ç¿4Îó`Xqò“Þî:àäéÍÉ÷5êv¡-Z 8Ågx‚ïF/»8}þêO#:Ë)|õ'™-&ÐËA´²‰kT5'.ï²^[WX”#FÞj©‰þ¥¾/›*oÄ$&”X¥oº$ ¸zá󱎮W·EK·å2V˜n. XÒIë‹ß±ðÍ¿1 HÆ#É]Ps.Ë+ †™Õ´ž¯uÐ'a7~„îO%žg2´©u)wÉ¥èZ±g±c BÁ7*ßü¨„¥‘u{_æëÕdƒ‹É'öøµ¡. @ìÁ®‹åú/pêW·SJ–²¸·ÈèF°`]v¦`ÇÁ*^†ÊÂ.À_z7™¼``ÿí½-Q»T8À\³i»°žéž!âz¾6÷õ|ü¦w_zŠ"=¾5CTùZ/oU˜¸ŽuQg×"°Whp¡1~ŠO¹€ýï ¿µå˜æ’ôßÿúßÚÙÉ‹ÿâô¦`v¸WtÌ2ùÇøÝûOåè”A"ÁHc)L‘h+An½ÒI¬;ÿ,Ç#Š¢Vc*e÷øG÷¡»Wð¾1ú-íØU–ˆ˜pDµ«°Mc‹ Ž]fƒ>à'æµT}A4rÑd /ðÕeuqØ<'Ù|Ê7ÅnÖZr`i%Vìzw–™P›bo¦[/ç‰ ô!ð–œ5º@ Rjc4N VUÿ‹nü“Èl.ŠŸIÔHŠyàRY"½˜¬ V'.v™z‘ ¢æ<œHDº"#ÆÖœ[6XN™’ Í7Nº2f/€õÈ/.Åi{¬^8g¢©Ë×”¨bþÀC0#SžwpŽ¡²P€2‹&pc£Eµ á/bŸ<¸I‰’;IÔ"ã+<̧C£-Ö‡LxŸ;ðÐ*ˆðúQ*‰`B}Õ„­ ÛrÔ"³¦ÌÓ¦E<ì)ª…Z>»6ÈÑ®ET‹!“#:(1Ó«åC%šúiQ£ëئ©:- úÝš‚J‘I‡@øbâžÌéG"Eh±¬äm%øx©LèD‡g{ h5oÇRC‰ë1%ZâUJè×ý©p˜!=½vvkKß§zà8âÞ\ŠC‚‚YXs›Þ)WÇYÖxjüš2ƒ>ôÁf)˜vèôƒÜâvžÌˆ‚«nXº| 8a¡³—ÑI™P§m†(Ã$øÄ Ý”´H:+~7,üäüJð[ ÍC¾ÞÀ|›Ÿ-p󾪌6r¬9E1΢<=³›ÕÚ‡76`Ù]n :‹S‡zãif,)•µÛ˜»¶ùz$!¬&GGhyÞ—hW ÒQazÊre§]³´ˆ]÷ò…ó NløÚôw;[«#A‘ÿó|×ø€€Ab•·­,"ã ÇT )d¡uÕƒIˆR#®äÖo1»ù¡+#8~„¶Yœä¦]öŠÔV%§Æ‘Ç Þzwp0EÒ5+†È)N—¬M¦20Ïi–YìŸO€)XdFwøF%cžô‘e‰Î[m"@*¶µ½ºÅû n½ÉÄ?±¹‡é"¥î%¨k ” æ’u’-32©Z+/¥,ò4î\ø"NŠ›‡0êBÕ]Ò²Š¬©-ôcqâ[ [AˆŽÑ –L²À¬N”µ‹á& å†T—o™!ÕH;aÛÝþ³âFò¦¦ õ¤årv_Ø]V]©f;7Û¥AY]w3îlÒàª>‡’9V˵º1œUÂ.Ð6NÀÜ«Å8¬=£² Y—‹|ßõ¨»âí]Fõ$],¿•ˆ´-Ó€L/"ØZ7;«»¥™H«­âR¨û{á8ë%³¤ã¯{ÜÿQÚÆ.Öo£'~^PÄ0ÅÝéSìï,ÛÓ^‚a\– oT¯™ âŽ%8ü<^9ýŸÁÿýùŒþç¿ÿõÿ@àÐÿëÿ¯QêÁpRÑ{=vðæú< 𱂡=4?v·jâO7V¥~<š?bx ”-x!„,…7òéJ""ÖS ota÷è7ÌX@[‚ÌA_D¦gBg¤®ºLmc™×L@Siê1¸Ñ]K”‡g:@Ÿ Æ ÉØ„LÇS˜>] buC fdÍŸPåÂÙ¸ˆ…ËÜ£©sÏÄŽ­œÑeîá«oþ$Ž[‹^Žew´(ÒÉÛ‘¸/BÁk¥î«8ÊÁÀéáExÖ†’L°Ñ¨8Oîî«ð;r›¾Š}_D[¢ÅÔ„÷™ô‰bX)ÊRÅU)ïÊž¬lJQt ÎÅ£NqyŲ:EO°Dc›‰ZòÍF{5À…sBdšS¹åx“a=.³Ì™.Ëž»uãÁ¤Pó Ã¨ê˜J±‡p5…¹0ûPÞÒü39ÙH’8 sÌ.ŠƒMEêOÅ,‚)@v}¤ÝN7ÁÅ$«´K/*óë7ÅÜ*8øgTÔ¯}àiÄÈÐSp;Â=N?Â~¨W@/ô‚²è&¾A¿HaÈæ%&þ‚±ýû_ÿ/t:EHmBa¤1ÚÖ‰–ñïÿþ×ÿ'ËÈ—pUnþ˜1Ïy¦q‹éV0(µÊRíÃ%‡u_£T‡˜ø¢bÚçè b¶yüî Læþ Ñzþ»Â§\Ž&Å]ÖïðÆÃl¯¥?£GÀü0R²C| }:^ñ`šÁyéô#`\ƒ‹Õ[H x—Ð÷‘tž%ÄN²㦫aj¢âë3ºèE¶¸ÊÓ›ï\õ&®›íü’¯1ŇL`“[º‹Šy±¾¦6BR^? Â/%ÞGkÈæz"åßZæ®H½ØŠqŠ€ÕòJžFO)4„—ÛÌ_ä*|•©,&^鮢§é® ¶ÛA ï¨Ñ<¾ÒF²›vÜ0ŠË—0b.%€®R$22·-y‚Ò71'ÿŒ Å¹4½P–ÑrU½ðŒ9©íÐëFûzÆsnù\ú²TŒ·.«‘™× º¾50;ÓšÔŽ|¥‰‰%HÐð©tîÁ«¬’®]h𒃉Föµ¸< áFÉh,tè¯Ù¼½iŒ ¯"¶ÖM›/žŠsMæ–@m%”íB$Äk¨èð£[ベû´çù#‹àÌ,%Š›v%î=&¹ö!ó†˜p23¸ͼ­i9Ub- ‡—¾Qwt¥óì˃Wâ>S)£8™G”ZÙôIxÖ; ~΢ܣº¶Å€)3ì¡e£Ùwªw|qfÌ7;pyö“û”Ḩ7›¯H²á<‚­2‹Å¹y˜Ù~Ô%Áì{®™P2t‰ ûc/>dœ—$> L(%`ÄÕ€À¤*G‰·_ Õ´<ÎàNílÌ) cäFÆd€•<ýª°R{rñjÂ…`Îdpê%]¢Á ÍLC>®Û¦wxßà÷Pе0¦s¨¼]ÎÏ0µ%SÓOŠÃ0~u²ó±&€ß…z‰-™੾U ³2¸b²Öý!«è`Åþ¿¯íâKv·þ†ûÿ¦“ w7@0£Åò‹‚F ’4- +¾L)ã~õ'Ùôg vàÄô‹O×í³9Ål­Gl)=‚­=ùµ 2ü„‘ ê{6Û:«íÖ mÀÚR’î`e݃ñJüÛ¿{1+4å öi|‚;­7ñºeR¥*g÷û•Ñf–EAR&Ïz¶fvÙ˜pâÓ‡ñeÊ×Àçv­†œŒ  EÐOnºo¹Üx™í7K– za}7D&e®jVEÜ~Ù(<Õ,¸Ù°SìPçR¦ªf„1ͨ2…ç"‹þÂçQ+X&øuŽzLhšK‰©šMJ’ÐõX…â'÷Ëë’NÉðÜ`¶Ø¼¾Ã0[pµæ…(.FŒ³·/·ÔÒË K@8ƒlÍnh¶²åþ£:ç[s7ÚŸÀù+'l»^ÏÉšhsŽr÷³À *¯[¡‰é*tòYR^ÙA«îT¡Æ‰'7нî!¯ÈšAD·@%ѤkðÛ/yàB7½ìàg:>í va`=ÅÑÏÍ.m¼"Óâ&ðPÒ5r´ø™(ÞWŒ°¾¡$„Œ¤IèU…ïËÈÔƒËS·.h™˜9ídeÐ AiÊ(,ä'–AËx5Ën*¸îNðY)ërl6¾¾ZÈ Þ¥*Ε6ÜTý{¨Ñ­WrçÇ]sfÉ+çïtNÒ€G¿Ë”œË]rægt8—n:!oŽ“®òQAêÛƒ”: ßþW=O¹ê㣮k¶?¤®˜ò¤×¾ývu±mŸ_sÍ—'Šw@ÊPŒY|s-¾e/cßìàŸ˜ý²ÒÐPKnTïªêiW\ßjÕ$o©¤-ªº÷9"AG$ÁÆ ‰~èÿ`?\GدþaýKn®) ÅŠ¸Ý%"×úVHd®\¹†u¯vÀyüLÈ'=!Ên”ƒ‰I¶ê«úž ƒ×¦¯¤ªy,V£ïÖúq3ò=a˜hH+¿öÀUá½phŸlÂè>‡žÍˆj63ƒý.)n¡&ƒ)Nß7è˜#¶8ÌâÖ÷Õåä[µ fé´z¤2U‚\Dm'Z,¤‚BÞþ<¹K;‡´jädçÁ¾ÓW|‰†xx\‚æ‰-Jûp ÍaÁøsuF|h2ˆÛQ6¬™ìÙ<\*† VáëA7h•Ù–ÛSÆNÕÏÎ6)ù©­1ñFíÕîÒvVïpîË,^öˆ.4†àý²úAffÆSõZz£ù8ì¡)ëj?`Y¡˜øŠýt› @LjD2Ú­oq<î¼r•¼ÓÎàÏ ;eây»æaE¨„ý¯zrØÒšç0ب¶Ë)yóÖ%/î´”¼|:. Vq Eÿu/Kê[ÊÖ¸¢ôÀ|Rº÷ˆü’º•ú`o“Ñez4—lpøBf")"ÈÎÑ;7 [l±ÅÈ3 æ>ƒízïÓê€ìIµ25#E¶íîèôð—Kj†žÓùo‘ÃÁC¼C³Îèox£+æÜÕ™ã=8ó‡Ïg¶ú6¹û=Û…ó®Ck#ñ:ùÎhWZ¨!“eâdcéL—ù2B^„yÇ·’×—‡Ü ˜Dä(¹bÁ0åÍÆ#- øfŽ@.Ñ™>ˆ±Ò‹çÜÁê’uÒÔQìIÅ]`í&Fl9øõ¨ò×éx¨í9d]kwÃ7£-WI¹RKü]^({ò^UD–éæ#„qÂPO-»ý¼o†6Ô1PÇÈ>ÄÐúï™LM·ëä>}5¨·?ŸŸüíÝ+ÍY¦©ÚRäƒX<°ª¥·Lçu!Ž2¸ÓdçÓ«i±5ðõSUĬOëù`@}M;*X¸â2¯‹yÐEÔòEøˆ2ˆz²ÊîV\¥||@ê/)|º Zè‹Ñ"ä,—ÍÁÂÕì^Ô}âèZk°i²ºOÁ¯Ÿãå‡L'ÿöî_fÿdÕð\åe5kç.ÑߘÒ%RV€4FRõ<Ñšé@¡ö¦7ð×;•œD¤ɱ}kŒ9N8E«‘‹úgXã®0¹!¾,âàQãÁ²Çز‰NG|ºTÀ»H“âEvW»”f•Ð á`J¹ØòìëÓuÝ?J!{JÄóÇø&xØ·…CP˜ÌTp)è”Ä‘f]”©çÐuÂ;%ÛØ…ö ´nVØjƒ5ªoµko™,4mf›ÆNˆÓs`ÓFé>Ûs²ÓqbÓ+Ö:Ø{¬Ó4zd¹ÆÄn;eËtÕÆkÚµžaÕ"ÀV™uÙy&ØÅRs;›Éóëáwd£+±¿vXüÛ›Bß ËSô ¿Iv"k—,Zav¥H“ÌHfVXpv]õeA ¡ mÁ-ó5¹5vQß´´¿é‰3ë¶Aíé8:g*{ 4OʃîYºÆÒ²ˆxÃÙ&u‘˜¬ºâ¯^¼æ|ä&=£€ŒKÒ ‘K¼åÌ­‡ç ºíβ?$x/j¯V?EÀq=-dœUuACœœz͈+÷€~žiU-À¾¡Ž— M©ùè`e±ß -ÞA 2ÅÁÏ» ÝYiß×”©nÒùj›Á!*³ƒ‡g±\´Ù¹ØñG3 ÍéFMYòÚ8Ü@8œKÏåÄ£ÝWNjàÑû´š¯xòQÏÆ)d]‚‘G\ºÖ20svÌî;¾ ÊæÆ}qoR†§®VÝkþ£ê77WXšçD´¶n€/¥¼é >Î%¼5»¾Èî° Æ3zÿ!‚ݧà%ÝûCŽóA nâ 8A•8AåÐzo¾³PFMZúÔYwIY>ª}r¼¹ýJ®˜÷â®Ñ”²îìŠL½xÿÏò«Bº ®Ä"¥‡Ž®kæ÷>^*ѶÜávëxÅÌ©ô…ºìò‚o½bAŸÄ3rܳmnöÁî¹fʰWSZ±dñ ?¡Ùv»þð¬Oøè®ÕÞq›ÎûË-P8¯M‰°;oa£¾H0|yA¼žZwêÖ¼ff±|î·=m޾ìåO¡$ôªÅÉFéÝ!R|v€Àçq íŠiqˆ XÍÐ1Q'îUvÃê`¶è;Å ¹{ªç#r!y`“VÃPiÐË lÖßc¡Ï,Vèì}º‡i0C>+¶uæìíù»É³“CgËÙ¨s¶l‰ÇÜð9.8çŒsÞ–Ýmóâ€<ÆÙ&¹ódøð5£Ž­RX»ïº&‰e³;»8¹úNç_#þÎOÂ˯?‚ÍFi¼í±l*ÚºjuKÄ£J­=˜¶8±Ûw¹bJ »8&Jо›BÝí¾z ´+½•¿1e”†²ƒéAâs2Ižm|™$µ¯äó<,ù‚NÞÞñJˆ4FÒ˜c»°¨jB¥º»L8*Ÿ ÁH'±³§Ïè¥MY6I¶pŒn÷tØi­­¸?œÍf6l5“¢§Å¨2ðÀX¤kòZÐé¯#“ë-×_ Ð ¯&Ú@ÆZ÷þÎ4©Ø\æA’`sʺòÚ=¬7 ¥XjTcRkuÉ@÷l²ÌØhn± Âà:ŸÒG]CB³ Y!»ÃYâÖÑ-ÍÃ˃î•nˆ-0Ëú•‰šÓC ±ö'‹l€Qä×±J×kʼZe·Ù:¨x–«µÉãvÆì˜]É0¢Ml ˆêIoGx¡\PÞ÷U¶‹°@¢=íçÙ4ñº·D‡›²ṵ̂¿%n³àXȘøâ`äwÛ0òКÆà €FÁ 0sÚ6Ì1«€a€GìËBaü;fîñ«6Ë*%Fë :-…… þ6ã ˜|*@ЄI6M5fq(*óøú+ÐA·¥F|€šqdÍz=¾7î“ûF2yð/P$oØÛH㢋äxЩ©…Oæz½;Ø‚"áK_éO°…ä íš%ÞŒÈ?Á$F|ÆzØ·¢ø§Èn}%ÞPɦ¯P“k×+»”qöLzaSAÌïçÓDËã%–™iÊ F?+µŽŠÿ $ú7 ‹s—»Hó T`¼‚ÙrÔ›A÷-úaež *ë F=ú!B®S¥Ò¤¨MÖ=ÑHU2¿„«šåñ†œ§ÃC·‰ƒ”¸ñ¥eH0Ðï©J²uØéÉ2ž;Ò a'ì›`.ˆwKæyÙã\v¦*ñôPË©w®öo~ˆhoOo¤ñ. 3Ÿ“û¯¥öõ¸ÎƒÕ-ßæ[È„»†ý­ÎÒç¡~>0œWg°x„\‹ö¥=ÇÛl1±Y—pϤ@ “»iuóéZåyU«ì ûÜËև¥”Š>gúöf†µ)Z8õqGtQ´€ø”Ïšâ[º ß‘.#Ž9ÔUÚÛaû s" òþÏ *FòX1sÁ쇳Z[é ÖéöN‰_g›L+hH;ÞFÆ2Œm‡kx`a’êŸé Ï,nÊSØÄëÛLÊ\`"§V׺I§˜U>W ½E®—d‹Å`Ù²¶¬êJ—í2nX®ÔšÇêßbøž•#ßüU‘hc‰~ dÉ[ÏJýj · 0%Xí‹0ÂVvf]›d¹É9Èî¯0׋wKWEãR÷€Ô]TÍB`:àÁ²ÄÝé #–$ÁŠ»Œ“µn']ûa|zÿšß¡õNMé@=DqÞGº^ `lè’íI"Tƒ1ZTÜ/y#yøàq'*£p\ÎsL¡^+Sgód\½&‹7FÞ3s!:… ?Cqs²Ãbïq;ó¢ª8—ªSø¯bþ}¹ZêÎ# ‰ÉsſɂÊTí“CŠÆ®Ó»d¾!t7†£¤¾ŒÚ(ã3Šòõ‚Ž Ð(‰ûÚaÊćCuÔ3 cìwsÎlr]p†¹ÊA3hÓëˆÎQ¥*Ò†Š”wT4%¾ “ƽIÍ}¹ÌåÙÁ`N¤ðІôØÃÌ&Ì2†YáS¬€U,öPh&ÍàD?¢h¦)áðIðkÝ’Æ‚ûÒƒ ì‚Sʤ™}3’Ý«%V5ˆL ï–Ël~‘t ]ù°b»zp˜Ýæàî&,O³´;ã²LF7Eê ìÕ*– ¨Mð˜Añ‡öy­Þ;3ð­+> R9Õ•åAQl"Š„®%èìÛË&ì£ã–Í=HÄq:Ná(·F“g‘>tAªf ®R8ùÎ#‚ê¬ë<ßu¡j"œC=÷\ýnù!×!ìBÖD¤Ä?µ‘»EŸ¯³ù¾)$Þ|=÷½c‹S'Óp|V‘lKôT™H›ä[Фïwà"þþlCõv›]DÁñRøwEÍJi²éB! Žu¿=(: f'’›+S½>ùí¹=ø§LýÃ0<7‰VUÀiRöà­’ò¾ Ú9¢5W:^…&ô&Q ¸¼ãÃÊå“Ò”¡éº5;WJqõ¯ØÂ°D…kü!‘9¦^d¡IŽšˆéé"Ùf»\‡°¤&6Ep¾é V3œË´´sœ'\‰˜‹ 2<üð†Z€©  I½üáü÷õ+ú÷‡ïèߟ¾'D4Ì“¸å¬sMl‰ Ð):Ô Él Œ»y[¸Ž1Qa7ZÊj‹žÚ¸?B :Å“ÒJ×þåž v5==;³ðKqxi¡—bhˆ˜ Ç"*;t6ÉÖa ël­>ï‹–6†J|Kðµr³å"žß_y¡~ç0Dh;vüö÷l÷*šÅR£A9UØîø ¥p5%€KéO¹¨Ž»“F¹ó¾-u™qïäm6ê~P0ꑚâ ecuR Õm²ºÕ G<‡Êµ%«#ý÷ôà ¶:ºª·ÞY iô~6xIݹó…Ï7âÍÞÄ‚ÆBš1ªU U¾`ûnÎFXQ‡2O\+Öêìfá‡_ø¢5 ÐçNN¬k- ®íåÀ룶hâ´ìèì'2ýê¾:=?ûº ª~–ÀÐ,k‡ï˜…÷å¶¢Ñ ëmˆÓÞž‡}s¢Nè:ê†ÐØ…7b*·êϨtIÐá °%¡ÌëÈ)•%»•4‹›Ë°…¨›ÖÐó„X%è=0lÑëćý"°Mo´d¼èܾÓ(=pç…@lKÄ6-Š(ÍäN¾a2Á#;¬Ûð‰&ëµ_„´ŠMòsH:ïdíŠÛy]€¡nï—¡›eŒó…Ji lÓlƒUX•Ij`q°{†ìmVÊP¦’‡ÑâF3š¹–”N^G•£â‘MÙòt»–d8øýZùºðp†Zm!V&¢¶jdõBÖU“ŠÈ¢× ?iãp«î§PC/?Câ*ãÑÍÆ^Ç´-Q:=¢_4;Â^¾_p<1…1 ë.0òv{ÁÚ‘yfºƒÖ#NY‹4ÝQ‹?aóª .+iž"¡£õ&Dõ wÙü~šTm¡ØïüiȺÕ..óUªÖõÏÛì)’pòlÄeëÌ)þ}ÇeÉ[úæ¼ôšMÔeî—jÇ÷1ý… ›^³­žé¯J´­ sãWÜÄ3~Sáì c)…SúOªÃ ãÄG¤•CÉmax¢BÕ(-6àïOÛ1¢ëªnñ‹@b4‡[\ÒDÛZÒЀº ,ÚV³cpEú›Ü¶q)ÒM@±å&'× ta늋,Yçþ‡ˆ-¬=ÐÉlô¯Y½È–ËÀŠÈMüô˜žÞGÎå%˜HéºJl`ÿøÍäSÈøÈJþ›Û5ïÝø~¡jÈܽO+R׬Û%åвzLŠ”SñȲKs;èP¾<ÔijÔ]gZÙÉë5!kyÊKL”ÆÊçUê? á&Ât4“«?l÷hÕcWõiì&ɼ_60Ñàì¹åÐ󋳋wf¥hj—Ä“¸”ñ `K\z£–N°N°W½^ Ò£lŠÁÈÑׯbNøÑ!¯Ai –Kœù£ÑDî]dÐ…J6:š"O½„m%¦†Ž~ÙtÎãpZÞPz| ¨†çJ­ ©Z†å …-I®½‰8µù˜1iK(¶;xåuÄßSºsYsCÌ,v4¶_Œ<½1QÙ.­[{ §IìŽë‹o$«½~ä „ëN5¾ "ìø–Lad_yÓL¤’."jñKЖ…IB×–(-oDcùH]¿lX‰Ç­ã¨IlYúâ«zŒË×û¹†¿Òž¯³)( Þ˜VG„ú­ž ¤×ìÆ”'^3TMl®nl¾jˆ ooív[$]yƒGòF&ßLó±…áìv¯¼¡#¹}àEW3«ÙÅ jBÊJØ(µŽ_jQå 8ºÌî2¯­XÓâB™çPu’SpÀL -¼bZÛýf‚VûºàÖ›–ù3E׃ÆÖV}[ï& iN|ƒ˜ó(ì7·¶âÖ}˜Ý<ÄÖ Ïiö2©×•®¬e·šÃêx±Vƽ©{¸2ßV^iPÕ€šâø Õ¥„¬å9P8¶æVÛáùÆ; ¸‰¡™7÷°…& 2­AÚéS+úÂæç"¥!xnæy€¥2 .Æ•… °Ñô‘hqÿ‚Ó[AlQDîç89lÔ£Ï ’Àº‡¼èd´öX7=ád§»i»YÈ‹DMÜm¦Ã=7l³@–梶ۛ¥%j¡«^Ó¢Á‡´ÖQE£ÐhÓ.3ê+2êÖ5ô‚˜Š‡Sápº¹Š´óeh›4‹Ñù7†“_4tw7®%€Ùüîß·,bH‰1*!ÑZÜyewàÄKµLo`4Y™¥8uƨ4ï¼ÿ;¶ñß‘YÿW2;%“?”ÜÒQ0Vö¸Üo×£$*´¡Q]6îÚnj‘í¦f Ø%~O nÒ"]à MØ- !{!‡–6RÞ0u6¼Hèðô>{ØÕRF§‘h/*›fE(‘ã•¡®³ Ðß­<˜jå|½éÓ.Ù:ǹhÈ |$ÒÆ„Ò6ª&³Xå)Œi°Sì’ñßK†í¥‚‚¦812^éÆ;y@茤:ò#™,H‘ÐÑ)ý½{vSKur{´­Ì}y=œžšTðöø#ÄùŸýþOÁu•´tôàô ”ɨ±Môÿh52¼¦Å'võðÝ·ê?À¼K.¨Ö‘±%À? 8Ĉü­ {/®Z[ñ,©º%¦ôÔöG4Úèˆ#°wª¶ë“§C|#¤)ÿyzù)bs7{;àßKg=pÜDˆB'g.²”òø×ù¤›Qœ>`'³#¾SÈÕd?Oæ+?–4˜¡¥ùv‘oxªÁÌ  ï¡Ý çàÉé…ÆÆ%*}†Û»Ïñ”ƒñ…@FBG‘cЗÌ)…ŠÇÕÁ-ð×oeÓŠ\¶0§&à$T°¡ó>ì¢Q‹ÓÖò2_ÿ¾ñ®vpñ€Bœ%8ÌœHÿëʼng“fÛÛÜ;s¡ ]Ã{/«Õ!£‚¬`ð]6Ìh@4Ov!ªÉÈ:’A£/—è¤"îD¼€Ð¢C(h'º­ —äÀ*+ÎAºb+W~;7I‰ÎßúÔLˆi‘µõdz¯ñõíqõ5H™RPB®º‘15-ï´y¿r<¼p46^³ù†æhÇØ¤‹Ìãw¿Ù^ñF¿ß¼Ütå!7øƒgëö^’öPKP"¼J¯ÝÎè6YŠM±ŠFF·û(aŸî† 2,C [µÒVk²2 ¨¼ÔÂÝ#*OœéE6/ò2_V’#‹ÙæEå—-"©Ð"T¦Ëz鸴=Ã,p1~\‰E½…i$ ]Ú|>Q. Ëlð¡~JRvúÌÊMHÚ õI©t¶Áa1D¶ÃàíI“}a*à+°dP±Yöx eÔŽ“Rªah³ÁM ==ª>6'-ÕRÌ +VD½¥¿È•Uß•Áò{ÆlÅ3fËž1Æ%^}îÓzûçÿ³Œ>M£¯þ%EÇÎ+8L4þ÷ÛmЀÎMŒ¿5&ôOŸ:Ìlé÷Zu äD€9þ7ñmCld¤ò#C몂§µöÔÚüŽÉ é÷ 6‚ԔΟ\-!Ñ|{ÞöÕël¶ |Õ‘í7uûÃÑ%=çÀ9˜Íu`:ŸÎi ½ ûbXº¸I¦£mÑöVÃF.1î-vl5‹Co$òèr:Õ)æá\Œ¢éRwy‚ÞF67‘¡t(nâaø ¥Uµ2ýx%òm·0fí‹GApövî"V³å¶ye® ö¦ö`` w‰¨pˆ Ð_ˆ<¸ƒ³šmx³‹»Ñ;[Ì^޶޻ËZ 6®h3ÎÔ†u°ÑÝ´ìîïÀýÀ JM ËtôÅ‘YƸ“ƒÙK´–ÛW(زXd½G³“àÔÌ•‹°Á±A©Ù¶ YÖ¤MD2¥ Âc\šTNv`ñ~d =c—‡&ja¹žú¯.¯^§þ]^fO~0Õ Xeö¤?M^RV-¾œžýÃï6°Û™ß”N- MTÚEü®ÕV¯ÚŸÙô¢QSˆuÅu9­U^µE]æ¾T¤‚\•i½€ì¡ìUßvÙ=zM ê2#=.ôãÒ”­Ó²Ý~&aH>0Ýʘ†ÚTÆ¶ÉÆHÙÏ×·…Z©¬9†KÌÜù#Ù4´þÝм¨ê‚€B¾Vv»#Ë‚ñëãl¬QrC*Jb°àPÔ?~¨‰=飢òqà®ÒŒüƒ‰à3¥-ö; °D4‹,»-ŽAçštñöŽ*˜¹ÀÊX@4ÇPêè|µVQ*M`®†¿Z©0â b–=Œ¶ŒŠ eà§„GåÎTU1É#…ZH S9“ÔŽ>nj:‰ø“Hø?þK±Z“UÍ­†öP𪴠,SbG?}6Úî …ÚY<¤ ?(6IA]¢k* Eº+ó/7IŸˆߺ.!W¿úºqŠba[5ZàÔ&èLI“Ôxšœmɶ㠯)¹òoÔ­fÆ·èeºQ¿2»jè öÃ6Âê­?v XÒ5mžTb¤æ|pa¯úr¾ò¿[Öâœqìd—ˆ d:žnæD? Î©1ÜȯI¦ Þgß^rhº3\ˆ:à;¬mä¼0ŽKœä°Å•îÉÞƒøàäÔ¸DÕÖ}wu+Sù¥dUD4–CÁº•hO3…ôQj<­ZwzŠg6Ãb,~LlaL¢"w“Ç„²âÙéR(…ˆ…^ù¨º.ÈUÊ7_¥1ÉeKµáv æÑ ¯kÞMµójÐÀ¨Hƒ°7W¸:ÚŸÙ&h¨à&ƒ¢½x.: åv8_§†#ªÆù:Ÿ­—Àß’A¥Ô3>TlaT¢r“âmÍ[{©áÚêA »Ý–ayIœ˜ô+8X^Ëq¢ ¨¤¥ÖIK[)-WÉ"ìÐMËß nÄk/$51*Óá³þ9†¼V¯^üÅÊžþýŸ 4RJÿQÊ×P²¯òùôÛéôý_b‡©E‹*ã®L¿%WœTiT5(¶ÉvëÕõ¥5¥,ÊsytÉæVuT«¢¥%¡~¸Ið‰ÎóÁ©¦ˆÒ³6í#UºVsT@AÑ,@Ó’Ïüjj'j¤ï‚½å6AcJgcWsÊ ý¤=ÇŽdN¿•Jã?Jà&‘BtdDKúŸWX œ Ö¦?TÙÄ)ë eµ{—];eן<ƒGÈ ßxà£Ó²uäß2°),ü!h“2%½"…û(Ah é|ÎQŸIhìC–F†Ö´¤BÁ¯Ž³ÓPZ“’A§c¸?äps»°Š•œ†¢ÉI/Òò"^äú»õ‹±Ñ†ó:q&+ôiáJÆ8Ä逧rò]#cö{ÄWm– ¤ó:úáäÓ [ ½Y‡–8j`¢ÓÇÙÑÍåÅysÅĪˆ>(h` ¤Ñ)#Õ3¸t¯û§³i¥­Di:¨5(-9HKúœ#¡ó3––MÁ‰“µeñ ̃þc «Ù’Äôæ¾ØE/7öN•ÿ¤©’“¦ŠOštâ&^=úpUU@£À$*Ĥ?9–Ú_Z›Kã…öv¿M6J_G« ¹ôròKÃd¤²uÓԯКŠ-Z”¬¦v(Ê[e¶i Ü”Õrr Ù¦gM—»·M‡Ôÿ šÚ ‘¦d¼ ´¡¶’ãjÎMîÆ‹O< ÿ`{gÓïÛ¡Ž»ònø)C2!’M!E•.lyOt^$p³0•=^Kƒ«‹Ð D-Ü¡BÏ?Ÿ¯ÏMˆSó€y0EI&¶ÛÈÌA{CIk"aE’±ï!éoµz:M©Aç »Ý‘Úp‚{ƒ­d½Ó¥R›RXx‡¦p$ Ý!ºEÀþÞÎ4'!+Љ–Ðü6¯ÂQzm*W¸Ån–K¶•/ª'ëqzí'5Oê:p°Y×äýßX–ê:[øñ²… f¤‹}þ|öV§Qjw^ V¯^ÇõûÓè»—¯^iÔ‡tëuz‚넊¸ÜË ©4쇬¨j ƒÔXLO»ÎÜcMIG6T>µ¹k{Lüç(p]`¼»â_O~i<ÄÇ4¹/Ò¥Žš‘é°_êoSÑÈÆºÕÞ>8iDM­óÀn1VDµÄÒÄ÷nãÙΛ2¬:WàPËõ±:f)ÅÈI-ÜW¢Â›gH“2Ø S¤tØà‡Ä6 J”¸Â_Pâaïœ*¸U¾¦¼ËBXÓa·§άÌ0$tå_p öL?-BgÞÔ¾ }âÝþtÿñöÚ5Ð>ùƒ›Ÿ$¶Ü7,»@ÓÒ tn2"âÃÆ;ñGa¿AL±·v2€ »RͲ LâHÒ|ä˹Åüƒo//B‡þ¶«×ëqL⊾V®xIW§fe0WbÜm@¤péEJ=ƒ¿ChšnPÓ;j‚J>úiÛ×à…6Ÿs™<…äa3‘tÍœžüãG—“@ù³Np;ßN“O ,8•Jb;èG8‹`1—•§þeF×]ÄýCG¹·¥“>9®,õŸÉf÷ò¹åÙÝñ‡R‹*€Q}¼¾:åóH2`QLü¦ý½8ÂÞ!°åØQöJ\ òøRcÆeúð‰«CàȰÎ\)AªHjž´þží’×I-„ÊTˆEŽª°ÙÅÞCÖ 6L;ð!27iÚ€ü+œ£‰á_Ï®¼ î¥F Ë´8éO‰¾Á•õö:°:ü®×†ßee°ß,pJP¢ÄL¥ÃQ†—Û[(±Ï'ŸNÈÚ —-kÛ<Ý6Uw¼$Å·JÌÐDu °Añl2p¡ƒ2Nèˆë)–]š#5*hXai¥ž7Oy„„<ý0ÂzÊÌÛ4Ö [FW“$:æžèûU WsõUQÍÈ7X½á»e÷pGìrLGrh6·WVV^¼V±Ïî®Àl%ƆˆåuÌYy‚@¸oS:Ú¬¬6•……ÜjßCÜWŽaÄ7áêã^Tž,fx*ãVëê)~ lqƒÍ@™Róp-ThžCŒS®?Ô; <ÖäÛ‰œ<åWuL³zpæ[¬Òs¥oRd0Ñå+§ZË4ÖtEV3ÆÀ”Ò‰ãĽÅ•î%J}@IÙk¢¶îše‚;êÑÚùAñgœ,’]5 á„9l Hž,5”ÕЩሲYêÖ[óõ„ytõ ójÔ[XÇ@Æš•ÂÛ“lG4¹U†6qü[Ë&ê"›ÃTµÝÚ[Šà͆ÅíE‡hˆVVãk ºù`ü¢¿¿Uÿu|‹š¦Y û”¯k0¨ë‹¯óa·ÛHÌÚ=;˜t¬bå]PÅi¦Ó¨È=Pîìãå¯7—š›Ôæó×u»äyϧEŠ¶ÅŒ;)ÊòLȸÉï°8å*«W³$ ¡_3ôüÛ.Ê!yûlWWøwÒaŒ‰\Ðå2 e.V³ð§åÈÏ0*´í]—F˜o­iªJÝU/ßÍ’…R×зzf[Z}ñW¼ýwð—Zí1aH%v’ ÈÒ™µïLì1³3¦ñHœÁÖq<ªˆpÕÔ‡ŽËqº¹MZ3ÓÀº’î·š@žëÏé>Wƒò—TMhàïNUî£wB÷Ê @*݇,}œl’yÌ~A±E_Kˆ¬o%K"¾7]QáDJ–VŽæ#¨Rá.ÔG»¥¢ZFç³PÀ'o™ý“U'j“¨± ÄVù¢O1EÚ˜iã‰ý³ìѬõùôAýVL}AüNÌ¢4žÛ}àÛ³ó»ÀK@JYµ~;)û É>ÙDsNûtþ~á†Y1ˆù‚i›=`Œ1wÄP¡{ÚˆÛKú¸Çtèƒ=¡ëÝ[ˆæÍ…°VÃØ?z8G=ÜÁlËaoß{¹GõC„z2êyrž$ c;åКj™Èþ£Cs4›!ãìÅ ™g3´73]5&¦]Ç"¥û ,•ml¢Ú—ú>v»Ö”NÌÜdžQê½ÍÚ³µgÒí$Z²ØIŽlTÞ;¯õ`š¼©šš\ÆVÊ)ÏÐI§Æ€´SœF V¤Ø˜Û[ÏÕ$žR°Än1jû£Iô>øIa°=³±ËÁtõ"5Ï ˜ÍàxŠu–Æ2lŸ–*FŒ}•œd'ÿ˜]Ü|$«$mø6)=®²ZÔvJÄõÓÛ…F Çb+ÅÊ0Ô|Ø&•AÝ… Рö¸SÃóæyÓh¹ü8!ùWB__Ì2ˆ—Í©íлÐ'?Œ ŠT?À¥V¹ð2üwÌ"†ÊíÁ/ LOˆÚÛ3Vúw4v¼Fq³fadóç"ﵑS½•¤¬KÛSôø‘ÄLOx8hjLÝ2žoÎÀ¡æÛÚ<~Í3qRæ¶î44¦»dšN <$¡ Iqgœ•äaA­Ó"þ·< ì^X4ÃDϾ–I”gˤ¬ mjÔ3I»é? ‘IžWaë°ÇšýâÝÍÇÙû“éÍéÉùyôßè÷ßÞý˯—×o§$˜4øø° :;ÿ»|+Ýéîžzkêý”öÈæntâ—“ë“ëS¯Ôþͼ7Ei™*¨HEÿ—Oɰqm Œo³m{;ÚñÙhÆÎ¹(¬ z®×Cüi¢å|{`ùË^’Éã®ñ1(Ç®&Yq'›ìŽ’xÅ’6,ÖµÎÆ&ù.c'Ýt_0Lê1ĨòHx”Ë­¹sÕ@åB¨Ù‡9/4@lŸ—ZŸ³91…mOtË-ÜÉ{‰·´I1ŽÕ¼ò‰á&¶©À'ÔL7ksN“/ùß#cw­ HÐxKñ™›ÁÄ´h½ÈƒØ$²%û’ž}{)Ü·:€k£]Z{>âÏÕ¼] á¿DBSqkS>²Uœ{cI`˜äÜÝn§w0&°‰wêËLãFÜwÿp1¼˜ñ‰äÅQ;g$Iú³‚Çkàí åêl,#A§Ê=^­Wê¯uÊ'ƒN<“†·‘Ù7æz±]Þ¯ýN=ÙŠÑE€j!H¥¿vMzíHT§Èäøo#÷˜¾ýÀfïß@[c–"õÆ\gì¹p£BsÊõ}š2øleô3µÐvVM¼1€v-½»:[p‚OüÓ~Ö­#úxqôŒw¿Ì<Ö -ÒùCLéL{N÷· Ëd ‡Zj±ü*ŸñVÝgJ,t7|ªh„ ŸjÁc0;›U ÓÿæE´\'wåc0¨í[L 0K«“u‰q?†¶– âÁGÍf3‹þÓŠþƒÐþÄVê|½ë¿$‰”:UîZ&¸ù4Oš…†VcÀc$bɇ0 ¿È ¥Ò± Ö³Žº÷‰x` ð&ZÁÃ^%v\-xfžïúûŒ´1Ñk6WÙi×ù\Þ“xS¨wk/&õº‚¡”ëòµs>¬Ø.P2p‰›ÒÖŸ›š$ÒìÁr áüj’n>‘Ä[JàcüêûxN_^`úõ©¶¿”u¿Y3#âʘI¾¤¦Jå;³ˆOWÉÎÚçÇA8?öá°Iàõ‹×À„ÈDÕ´{Y –i.8Põd6ãc‰ƒ/ÿ¢?¢ô1z¯¸Á”]æ €€äÜ! ~ýòeXÈË‘Bd Kïµk÷øƒ³5ô„€`”òe[“ÇD#vª|—5w‘ÆP¦èՇČ»’žíª±KÙ®EºLR8Œ™Q–5Lþûˬ B‰‹yºÀìÕ¦ÑÈùIwo†6XK`ÐþM“ë-•(¢½<»áS„ðÎÀàÈç•óè鍸'út3A—`Ô(“®‘Ö<ä9ÑW\!Æß×R¿;Pêw"5)n³ “Þ¨å„\É;Ä} ¸ïßDËìI}yjU«)ÍKXÈ ùá Vì$PÝëÝ@p"¶¸óír QHñmZ=¦jÏw#>ÖSa˜]×;3¸pl%êÇ•ÂÖÏFƒ¸H—I½®b^6¾%^j,ܦš×Åãu(*mrýÉòðHõ`5rëyj…;v*Õõ"¾>í¯ Qšâ4ñ8?™83cz˜ÔkâçOLY‰G«ÒjvŒG–¡uª¬ÀdÐÄÓL×c>«<)}Ýͨ‰>Bnb7´`H{’ro‡¢£pq7 ÕZÉ×ùÝ~ ”a°P¼‘ÿAI ù±l#>0kÐŽzŸm„à;;¼¬nñ2øEüª¸pÕ‘uQqþs .Rü£™ç``̼§Å 8²º¤Í"Ûp›âǕծù°ˆ¡Ëk,”wjù^,¥&ÜD!h.²Lƒc’ûª÷M6椧—&]V®^|‹= ìçô6¥ræ`™°žG‰îRq0XÒE! Ñ[«PBÞ!ýÀóío°ô ¥9ڦщ ŒoÿIZýÂû”¤††Z‚Òd3LÛÇ$/& ´cÚ¼néc~ìgV=‹¼Œíª.PÎe‡1³N*5€ûJ×+n.$‡ªB®v>û>˜¸À™·X.úh¡@Ñ0(¶ÞbtµÙË·iÜá¦á ~‚u-&¤²'øúÃç3Û‰gB ›X{;Ãoöv\áÉ:ƒ O`5#_>eq³z)Ík¯_|<:ýŽÍ®ø;VϬˌ¯ÐÝ€é ÙÏm ÎrZíèªh]{àL3;ž 7ëaˆ—€Øã¾ìôÃ?"ø‚Oæ÷ÛüQíghÐSâ³íãb\ÂÞfVh…e_!ÚìŒøãPÄõí‚§²B™ÍàžÉÆ@`?ýeàxþIMÿôñãWñ6]§w| ±zÓúöN^!ÿõõ@俪¦‰[ãíÕD±Ý©&™*~cBÚ炯[à ÷?uÓBÍëjWWÏÅÍ[¸;[2ÈŸ¼k"'GmÇW›£¿§üèohw´7trªÚÈ„B"+sÄ._÷˜ËtmE\唈ROùîuÏj“ïlKßåÕkYrˆ¹S¸‡ÙH.Ú‘ ^¥™ •BËú…jö¸Ê•†Wã×aEôÇ0–j,Âigâò* Ä(èB€0ý5nPÛBcä [umÓi;rôZ—Û.$Ø }5ß®÷³@°†‹×`²"<ð‰"U·á!QfOvTLsœJG &1ÐZõ5íÊ"ã`5˜KÔ¦j¯0˜4¶Œªþ0†D§]4Ÿ™#ìÓ1ž/xÆ‚<Ý1Ùrd‰¦Âžij¡pê@›!¼ÖÉWAyn0É…OU‡F“~ n`üI¸ÉïIr…ÎOF­™ría‹ 2H®´åàkl¦3–(¹½-Ò‡Œ3ë|E`‘€} èpô98^ë éÅ„DÒ:ƒ UÕŸiÚÁä>ù+d¼"F3UÙ&'Ÿ1²ÂÜPPsl¨u·îÌ)áC~¼½Hø'üMH©ònß)ñŸ9債̼·0´nå=¹¼À÷ .àˆùŲ¶\jè+õŽ¡ÌØ§ÍfàÁS£ý¡JuÞ£–þ¦ÀvŠl€zƒTÂ]ÞXsd”|×ÓB9JF™lëd­±Ö:[s9Ïdw6Ov%åà‡9EjÜ\jô}A6·Dªž §à±€=ðùX jõG'íÆ{äJ·§¸+MóöM‘Ìù[¸š^hg½åû|©]€Ó*ü§Ãša·œDœ«iÂÿ§ŠJid°Ãš™zãÖÖOµUú;¨‰lŽÉ»7if£n'=6x‡5×Í´FW¡êÌvx|§î ²ÅÆ®“>I805=mC5=hÌŠpYIºÚÉÕY¯šfÈc wú-Ù &•%É¢bj˜1­3#€?®Ska=TȞƙìש$,²;Hÿ,o#,ãPÜZ¹”Ùöê_n>^~Ц—ïo~=¹~½¿üüéíÉÍ™ºv~vúîÓô]ôË»ë)ü~5Ù©Oò€z:Wð%ëê9„^å0 àFò‰•rµ'+æNJVE¤vTÿVBPAØÊíÖ”¶õ#fíR7&Ä8Ý®à_O@ŽÏ§ÀIƒs²XP½Õû5ÎÓÒOqö8ƒu¯¬š†¿Ð}ló°©+FÏg“q·Ÿ«õåA-á‹î,%ŽÞiÞÀ8…áÒFÈõmAytzÔA¤4!š WêáJ(¢ÊjùjîöPon¶mÓ$[ÿ²HÓX§i§8CK)$öpf2û¤þV¨KØ RÇbÈu¯²Ex—o²!58cà ãkLkMmÒX«Ké&^¤·õºg ¨àðˆ#"Áh‡\ÂÊõ€ð·XyÐçŽíOÍ.5/(Ïžƒ‘/—eZ D‰Ö`²£ xrÄÞí·[pfTjÐi}‹Ã^Õßë´Ne’ /HæÊ´#ÑÌJªXq‚Åö4‰L©HñqËFÞ£\:Þ–M‘ëcO ×ç}_`ðàúeC€Í:ÙQª úåewêxÂ.,éˆ&ĦGæq7ƒ ^†l%Ç…˜mqy}‹=)Ž®©ù°[PÐJ¡÷®g¿E×–ô žZ¶•¨ ¿W·HƒÛ–ùD8G|™2(l¹Î“/!‘p}bªgté¦íˆ_~´Kê&jKìr€ wÀë\vjoË™•n÷X²-Œ\™Žô@­-&S(_àË·pÛ29üèj¸xˆô#~0AtO?Ô”aÕ2:Ò€²PÛ"}>¢xÖ™©´~œ»³PÛ" +ßð‘ä d[Ø‘¸‚‡[ÐpT³”ôˆB‹ãçq±%Ng”8ú<àEnËÇ`écÞ®ØWäõî芤‹ÚZCŽî£ C´–Uút쀆ô ;¾yÁm Ì31±Q¢êIu—* = ~9&_;¦8 Ú¸<î"$xmA›äîè ¬êþ¤ÇœQl͏í*-2L·q¼‡iazûà uàGŸ`Ű/¡ž¶áÛ=¨ÒÍñ¼\Ô–Ðût?ÃzBGÜFö ‡T¶G´S9ˆAy_àVÜ>Áå—’\E—3·Ô̱ž´Û’½N6·‹c:ˆÚ€mij×s\«ƒè‘×éË0VTÀƒfóUrÌyÐ =ö‡ÒÄm ηw&?õ‘nUc†Åûé:¸a±]ßsÄú§?h>ºAÉíøž¯Ü!ø êq·ØÆº°×aaG—µß¥¤‡O˜ìW~ye@à5mƒçõG½¯ß~‰ªz·N-@["ë#ÚêÀ,_ïvÇuα[Ò¾ŒWmGíã*ëÙø–hA¶ÅeëÅ<ùµ-`èjÕ¹ç<@¦ ú…÷©†“ü香Sê¨R tKüÓ—‰?yê @yú(Oá”§£:?<½ö`¤ž/k£…uì½m 5 wY÷/ ëxg / ëˆ+»¨…eR½ÁŸòR»·²2Û¤\¥ëuq2)pÔÌ:{õâõlSx3Ø«ËR⥼Š_Ç@jœRÜÑVÿ†¢°P<K5 (y´ÿ=Ûañ¤ÎrLdúø¯ÙKáIÁ‡ßªø®ÎÆdö?‰þ^E>ŸaÙe&šüV'kªo¼Ã¥h4µxþºë·°¤RŒ@ºc`œ5W8HŠRç£E ‡Ù<Ôš]ëFóÃÓÆPVòöÇ@e-„‰ÍD d»È7áÚ'ÔÞ¨ê} @wéµ ©ãÀPü³‚£«Vóå8ž¯r9>ö%†·ÙM^x¹ â šñž &>«³Œ@'Ôʆ±}#G#HÁ›ø¨,ˆåÈ‚X=ÿÁd>„xŒ©K`ð•v¹N¡ø q1K€BRZÕ2£rm=5ÎlJ õ/_áe=E©d.nèí¯DÏ4_ýóWÙâ[˜7¿Þ§¥x ý·m.~ Ø=ßœUlü®^'PQË7Ÿ`ÏÚÒ‰bÝ'8¾eX ÎÜ{ábäÚo«äéÉSdœ@¢ÞV> ·º@õ“®ÓªãÅa£XTwÒÝóh@ŠTªëkÚŽ.âEVRîpFw½6̑ì`çuÑgôA¤Oó?¦Ò ¹Ôè,ÒE=WÛòü!-ô™@g9zæˆ5Ç)Õn¼N¥ð"h7PTãScö¬tY­œ·¢–8u§U<ß„¿Á*}ª¬h×Âq+U+]x˹Úà5‡‡UŽMþ2`¼ «çŒ"ÛúŠ ‡‚ÃËl˪ßD/ü¢÷)Tš<&òØÄž’oV±2]öÍ­uzžlïj¨¨{9 í‹z‘Ï{÷Îøm°uˆÕ•râ©»ât˲DoÕÏOBå1ý8žšÑ;¹æAÔûœ»!²f"ûæx“âÇàFâ_«ÍÆ!çß O(n9?šUߨÐî•f»™4^}Â:&Z•W= †‚°íâ«“°-ÂçµXDçt5’«¿í‡léØÑ4ã‰Û3^=‰ ü°¡ŒÑM¾‹±%2- `z ØÒ mZ­i„¯(Ö»ô)\3 ›;Η¿Þ\HQË¥VVíÌëz»ÅJ×êZ©vºêµ½ÈŒ*Å–+er«T©øFÒ€.‡Õ_¼bökfŽÎˆÐÒXJvé.°–Tû5_Oío—,«ú¬^JóàOl£–ºEh¹ÇFóˆ–Šm–«&m1o¼þk°Hâ_(Š{—B]¢ø1)àÕÄe½“׋…ªÒ"Küýbká'¨èWjˆ¦ WäJ}·u³¾!\1e¬Ž ÒègÕœEŸz¹+œe|g+XŵÀ_ñõškúÚPYz]íBÍLï)bªB8Â@ô&­X·ôc°T±ÈR¨e¾~HgX+~LõgfŒ‰Ñ2åK*<ÐÚò;ΑBoñP?ì2ÆÈøs­Õ)QŸ“&õ¼5ç„\€G[>„K›> ÌÈû¬Øëc$_]B-ÜqÊ(ñxtÑl¾šovÍ­\šNubÍ;”³x«¦¿+à¨jïupr&†š+ÒP(¢û“Ò¥_5Ô9—Ðß”EƒÙ “™ó®Œ”bÕ+êCÞj­¦<ÚhôšnÕJöçÿ‡÷%Ùï0“*n˱¿›çïI™û‘õuôP¾ˆã«¯'PÉ"1Ô7r.TýÊ|Nk\)Ï|"e oóf‰I‹÷µVOÞ]E¯~úþMtý¬Ò„T ¢>{¨Í}W„úáõ›hš×î*ÕVûð[ˆõ’)¦üë›HóðÓqh×¾WwÉúôÍ«á”öú//‡A)Â7Z±Ãú丶\ðj‹º,µjuY7$¼*áÕ,@NÚbô >»HßER·Öñ¸LÁÉF Ln ¸‡Âý¨n+êÝšrêÌ[Û Ç”0ö¦d¢ƒ©¿ñèD—Éhôåð¶ß¡¥C!M‘økõiïE+ M9L6kª¢SnT™-RZ®w“ìVy¯µ³µhM…QÛ Ô¸åÏ é™‘œÞbÛÜf¸™öŠKҺܛ#Xà„–&ºÐR³È–KKÿ‘ÌÓìvìDL;>g³H,f‘¡võÕ".]zã×µP‘`M.q]¦±¶ìÀ‹?èÐqJ½Q`ßD‚ưài$˜hb6a y¦×Ã^7°óL-§Ú–Ž jXmÍX;• Q²ÍývÆmŠ6lÚ¹1<žg­ w†é ñÍÀÖØA+%Xg\ó®IK‹¶jˆ¾yÿá§•šc6GB‘?Æjþ©òb?j@à{ƒÏ> …J–6$ §±‚½«³…€y#‡ mGŽ4:4“­JG‚-ÅO¸ ¦ž‡†™ðVùz1r.€÷åðªk·™z‘<+d[˜&"¥õ9/ÓøûZ‘´¦æWoxÌTâ[Ó¬™TfØgO¨Ì8©á°KmJªCᣇ,ÁU΂JD‰ì”•ù§v¼|n4ÞŒð<½¨á»MŠbbýªóñâs†w½©‹,׫ûQÃYmpîÉÎÌøŸ¯Ïf¹Žçj–'wÈ`K­¡ŸZÄÈ­æÏ[p7 Ͼ†YoaÒ5\´áE´ž¥’ç£dR­Ë‡—³×=š2~ÝœO£—/^OÊ -†ZôSˆN®Î¢©º–©g½g¾Xýìç’Œ“ŸÏ,6m ïçÓ{ë]ÍÈ‹¤X„Ó –ËQRFID£ÉÂ[å ¨(½7x ÐÄý˜)rX8”quÒ'mb‘{äÏ^Âà(5÷@=x_žçwZƘƀQ7š{›¼é)f¢62}ÞÙIag(Òë$pv® FþpÔ—¨XÝ"5)À·0å3ÓfeYS'0”©×+§ã|,Æ1Gà”ºS°aJýîZF¸"â*}€j<Õå°^¾'Ù°d°º=à]„›¤p²IIs³ ;äÖãàÏý¼‰;¶ùEvÒzö 2· ¨3§ÇYÒ ˜Â>Œëê0µ¹Í˃ßǃ 6?ø­Ú(÷Å냷røs¶Qžùt³í2ûõ)ëë+ïų'å~;Ïòè Fy}@¦Iu«>»ûñV0SñGÖ9Ó3Y{ªþÍ=ü7ÜzzÞz³P%xæ"NÿbIëÝpY[îcË£-l13ðR-ÕžÐÛé䊽¤D@¥ã$‘`X^v{•1QÓ­ >ïØJ?Ì‚yónz3»º~÷þìˆ0OÊžÙ¾©NßÀ´l%`bôq8ÒtÒˆ‰sxÚ·¹G—M3ŒƒAŒHHLt2* Z'n4 BH}‚^S 2ë­.:tþùÇÿ€!Å8϶‘¨ñß&óûRmWp"pW§½é?@ÿY˜àp@1)$¥•7 ;2Ý(úè ôtr±œ2ØÕ ÉïѱվAžèxÔþxX‰óÂA _ÝœO¿†£ãǘz’% ‰c> w’HÄêàÔéà"1;BÞã†Kµ„°e¡r®¬+…ôŽ‘Ììá‹d÷¶W˜È>¬óÛdM*-ÛH ÀM¼sL -úÜlCï)ðDgo :aõãÛÖn8á¿çÛtL/n˜§Ñ‹E|„Œ€½ð¥5 ì³±.g\ÂÚÐ.ƒp{cÉ,Åt¹'¹¥Íl©3$b)4ŒU˜k4ÐÖûÔAH;‰’äTØ@íwNiãÔb= SÛ0½*~Òƒ…´zþ{ß>ÿïžÿª‹ç¿äòùï·~þ»}xÞK-Æ®xÀc<øµ\„C˜@¸ñ'ée‹”lm`yÄèƒê'¸@?î–˜ìV˜Ç Zzjc“õͽ9ÓrèöˆÔ2z¤Æ*8nœwù&6$Ìø’MÓ˜•\)úaû¤PtØT5 X<:í–õí´î ½l倥ÇêÕê¹=Ú¬Õ•x·÷…C¨ÍÚ ¬tÞ½À)D(c|¾œ}ÕŽN‡eýùÇÿªvšUþùÇÿž€iÊ£Aê€fc0¦ôµ°}„<Â7ì°=ŽM¶QïK1(Ö!Éð\5ˆZ‹$ªy$Ω…£÷v#1–F÷°c ŸÉî¶äÀ›²¸[V–©ÃPu¥hK¢I#6tmm2Új³±`¶yLyº¶€G±Ÿ{Yotíó‘0¥£–¹ŸÍR$óƒ^~Õ¹W«åú 7~c!ÙŽ.#a„Uƒ>óó¿~æp¹yæÃ˜?s\>sêzîØZ?óóß<ó»/žù¡–ÏüB«g}V`Që±Í Ys †ÞÏ:.ùÄç¨3X¦ -Ä«›wD;Þ§và¯”Ž­D`¦“Ed—ï°‰Ú<±.*^uµhÙDÒ_ ¬ ÃàŒV5ã\úG’˜$‹gUÝ—Õ~t‹%¼2Û…›ê>šÏ=†z"Ý`±<½ÑÁœƒýñEnbC;¼ˆVn3€™¼ìÿüãÿkw´€Ë¬ƒSœl·hbjm^ñ[]X®óǦå,:1Ü„¶ƒü,ÍÓ %ÍÚƒ™oq›UÐݤ¸Ñ—aSQˆmÅx¸?šk”‘6 å´eæŠ8èó“e)ä~š¯ë–«TW­=ÃϦ4nb#´l«:ìwSh él‘¿æÅ½I…Œ”Ìr‹Etžì16ñõyÜ GHä…G•à¦&{ fé²ÊëbØ]µÚñ(röÏÑ~vç4"]Ðê1ö úñä \ìÏÎPˆRoæÌ-/Â#ÀM/¯Ý8}N5 ¡‚ÃFdh÷=FÎ CϼgH|ægŒ ¦D}ê("éžšSJ7ê[ÃÅEM4à¨îÙ=ñNÝÝ0ó#`¤É¶™*@³“'‰šQø0ÅŠëdn¦FŠ)qÚ¸«"ÅǨ>Y? ¢¨u þÉàz“þþ{Y:ñ`V’”-Ë| u@àÓq2óœèœ‚ª~$Í¥ÒËÀ›Çde;WrõňBødÎ ™}#2 ’"8]‚RQêôŠø <\”R=¦ Åu:.*GÁ_@žLª$Ãì„&Y—.6 2H¦fZ¥ÅõÐ@4g ô”S ¼-œO “ ¥œú+Û¤åP ~f`Cå¥ól“¬ãånØäø–è£÷Po l’W¹j©e“VÙœQ1øzÜ+Ûziü…R50`¶ š¼Oý[Ú-®G x¯Ä£UA@É2iGõÀ½µ)´tÅíHV|õµ¾!âÏç^ç‘ΞÏñAÓ8×}@ùmàˆ·|wE¡©qмùŽHaPZ!v¢ÓŠÏWI×Ëoê¤â§ÌÖ@óù °®“¬” ¹ÁèBQØDŸ·çÖö|›WéÀç°…û€oÀH ÃS·÷$ˆ{wl»SÖ•ú¿ÔwÈÑ4çCƒ€扤ZÍÃf <ÞøUX4Š7<¨«c’¯]ég®‡'Ž€º¦Ó%|Ç&†SE&ö2+Êj”Ns¢¸OtžçJÛ­Ñ v°¡&ܹnòG|ÖVæ,~­žÏˆš7$;L×åÕ©Õ½Õ%E?«ÛÝi,oäXç@Sªi§zxÕ CÄ]ç„´ä¥h+‘]%-õä†ÓŒ€‘ûô´àj÷&ŠÎô©MfoTn"=‹ o¹ å³B+‡½°?íÆò:ÕÂf%sàéGO˜y([ж1R3Ìl”¬G|@'P5™¹ì¢^ ªrA;BÆI+ÍEè9 -Ö¨MŒÞ—ÙSëØ`[ÕVÊfjCT=F W‘w•vøß-hFÄ0+1U „aåá4î¸öxVÅ4„ÂCãGtÊÌ'Áq³ƒWÑMOìõn=òFnˆÆ‚>…D¬z ‹1æsï9ŠõLªŸ…Ùç]¹6FZþ!.!–ãèÆ|_óS1®b<5éö!M‚óK¦¾Èdm[Tè1:ZÉcšÜ›üXC}VÀ¯D'e URm•ºîàVc©Qí§üñŸ'B᫽f=¥…ª*UÖ-'BQw{Ê´: µ†‡ÇØP0&×rÒšìc‘ÏÃ3¡ãé䥂ÌÚbÕa¯rÖK/’½–Ú'­ïçn|]Û.ßÇéõ|¿¾iÈþ|¼8Ö—>¬ôxÁÚ‡ -X#Ñ+ÈŒÑÎj/JwÑòÚ[&¹”k²;ÐÚÜš©Â¬™¬wk¯}!\wqxºàh>^d[+²U!{Êó2Zëþ@Ÿ8gíÀ|5´ég¦/º^†­ Μí´çs{6ßïFÕ´Cw_âÑNÑ“ÕéÔþ,){]›!‹-Q2Û*ëÏœ‹\HHL„ ŸÞïì€IÝ7xžÀœ/Ù·°;òÒE‘‡£Æ2o€„âÑ›‘uy±cȈßñšT@Óù :‡6Ówð£^Ó;piÝF»,zȿܮ‡Å0¾R´"Ù„ÑäN7öá´å€Òaæ‚*ÝÕYZ¹©Ë3±7Õe Äê{Ÿ¨ ÕW×a¼v9»3‰±¾1-màsý˜mIYW?ÂI‰›7#ÇSÖ]lÒZK²ôqÄÒ«é)}$€žÛ¨h[ây•çuj¯¿'$ò€¸c™ezoãq@nXÅc¨VÑå`.Þ”êü•”&­¾‰–Eþ{ºU2–ú ƒµNhŸ‘Ôô£¯FƒÝr‰€ËtXœ ü`Ï„Úy=¥wä8nŒ &6üÿhL†cImLœ>cIS¸¸U[Ex½ƒ8?1±é¼z~êm‚úù † ¯Ó'Âíɶۘ:x[€U§q]—îe^­†~¹´‚RÖpFn}¿åàŒFN2#ºOXW¾‰p³ÿ Ù.5&º;½§Dk2›jÀ/ÛlêŽ`ïÖ Ôän¿  NÚ׋u®—OdÕ‹ž^xŒÄ!¨k µXñ¹ŒÙªõÒñC”’Xˆ;¤x88qºR0‘Ä£$Ö[3JDWo“pš'µðg¤Oô†?±Û¬zÌÊÔ.E­ôª¬Ü­YåÙ%L(ΞÁͤ”/ƒÇÖÔã©+S2ò\ª:ÌjÝè—øƒ¢]JÊš¥‚);ðÀ=Š6‚¦\›ˆÂ9*+RSK äE²°»êMµÐ¬éwiß4©‘ù G›‚Cq§.² =maaŸª^Bó¼#†}¸¤2 IçÈ8ª´* ­/wÑp ~ »/÷RÊ/÷ª/÷ÈŽý¬7ÙÃ\:c¦›ßÖÏI©f]f1X¥/a:˜ Êmúøí«?ê™d·ÀðÒðÞD‚`AÝgúg³?S¡¦óðÉ«eCõR‹&ÙmšÛyû)4ß ‹… 9ÁUåA­˜^óƒ~¯_¼|e/˜,$Žoëå²#áZï˽uáæÚ[ê ´¹‹¶¨ +Dù°Á§!dx9ˤ|Ƴt1õä3úÉü6æí±ÞÊüX/dy¬Çu8Ð=AúµuTcVü^Ÿæˆ5„óá>YÚÇ“•ƒSҨɌbgJõÛ–ecÛ‹ÇzU-G†X¸JÚ·l§Ós…y¿Í·’ ¬'UnYª Ø ˜ùâ3ApÂ2KƒòÅ ™)ƒyl¡@µ6ÇÄ e:D“á‚róPS·’Üg¦§rvнÊÖèÅ»Cxî ô£ƒ4mxWq;±¿[Ë[Sj=6»cç1i‰j[3ÏD½ °¶bý–îê'üÎ Ÿ¯Ï#掄{B„=99ý# æðÛõz€[‡è} 3·vÝ¿–cO>§cñi7.Ãâê«6NªfzþjÁ„ÒÂ>î‡85ÞëWø’®G—é²^Rþy}?#‡IùW[G÷±É“5Ä‚èœú[{‘mˆƒ,þ3rÙN`ë<2µK “:˜§ÕýùÇç° ÀcwÆHšI 9y¶ðÍwÜäͨ€‰C°™qæÉ®l[Ú:2H_ùCFÁ\,ÓÉj]‡¼$"§ê¦ã1 î`˜Ÿ¦ÊqÇÍšÉ —¡{Ì.§Ì ‘²O‚à¦f ¸fñ[¥Ëçùºr@Ò<ò¸tûðåëÐÊ‹¯(üm=ò¾HC¶Bx\¼lîO¨éÅS:s(½û¦wým¸M3ì‹ÔåÐPMöØ(í}DW.yº^'Û4¯ËvwÚJKSÑhwU©ýjaq½Ô!½Š-.õñä^&n õãWjf(HD©èšÆèÛú® z;a¨hÄ|gÒ±O¢Û³Q÷\ô­ý¡:öƒ0˜Y‰RK‘JšL(Ù-¨ÒEV5|_œö|a* Œ{ûŽjÛ¥9ßuÎvßÊlç2ó\žé¾•Äá“O¤ëùVg‡UæAcù[{H:8ö€ Çjtÿï.Åk¼ÿ#JúAËðç›Óè«7_cÝoyëäœZWËŸ ¢¦· ÷óóÍûø'ŠÇ¬+¨(k¬õ—#&u 9öC©^¡µ#Ó«0÷jB âü0ƒ!l®¾¤¬†ÏÚ-‚4ÈøË°…€\:ŸD¬ þ„\‚ñi<ÿ'ͼzÎóÜð4s}Àm+æH=#&ó‘n-”t=¯K†ò8HGÔ¬Q¤w.7¿‰&™üy}¦a±RîÁí ’°z««ë¶Â/†Z©ÀU®´º øl…s@Ûž¾°íFjµuè§Ž‰¶7­¡Íy áw=æÅ=x"³Câ<Õ’û7/P«³ÈnÑ 1bŒÀö«ìnå{ªÒæ>Ùb}ʸyžì•ZñÀ^pÖêÞûš‰%¶Yb ¤V¼•h@„<ž°Š‡jÚë!˪œö n—²oCAÌnè¬P€Û§… mY>ØnvìR½…‚öpfp¢STOCv•9l"Âk”>qBƒÓíÏÒ„:yH²5Þ‹9®(JŸ½„ øüa¥WjBô)}„Á†£<öÞ¦àžB–#Š»­4 ÁOáãPÓËz• Þµ·^PEéV‡¢±…Rn℞L¶Ú¶ð{7·'e™nþ¿Ú®m¹m#‰¾û+PyrR [$HðRµµ¥Èv¢-ÙV,UåòÂIÈB4AZ¦Ÿò›ßË—l_æ €!m?¸Ê’ºOæ>==§çP4½Y{ÄÛô¢œœçWF0Óâ<&ÑmÔ•§ Å4ÊÇtfei"Êe$ éh˜4yØêø¼ ªîu¤n»}[×~hQ+†3‹µv¯¬ÓG+Ú£‚&“k ´–ûzÏRÝÃ8!Jƒ.±ÐoKJØ7^óŬnˇ1Åÿó×ß%Ál-wÖ ðDj‡"×aãõÙ@~œÔšéËn²M·®5µ÷+ÅýJë8¼ZJÉrl¹?Ü* ¢KsœG\¥µf¾Ý;ßJC‡Ò*Ù–÷°ºEݦ„¨¡KïÚU:;LsÊÃQ….¶Kè’K <ªÜø*TÆú·Ú‚†fº QÜ­yýò:Á©~nv‘cr½ Ô ï 5èOƒ›äN>X×*yvsè‹9œVˆ4a»ƒIÄçžçx‘IL4+¾•0€J€î ëÆ%õ]2ŸÜ •¨„:Jƒ7p6sœ=mkãîfkã ΗÌ/ŠùƒÔ»?æÜ’uVýœaÏós†=hSá”ÆKDHF¯5ñÑîVðTMD¾&¢)±Šg‹l‡Qð;Œ¢ã°h ëËkcΣHÐe+¤ HQDÕLìkÆÎëä!5]ÚW XÅ8ëöâôœÇqþ¬}˜ï7Ãôgòu<Í ¿¿ÔLßæCó2WÍæ>)SÞSÒ!^d"ò¦gÒ÷4‚!œ ¶òÉln´£aÕ…™™ê\›’pà±`g‹sñ(ÓœFXÙ­ªD mè,yŠ¡¿uÎ¨Ž­IYæü"fÞ²VÃüxùVoÒº˜Ê:Ք󔬡¿Ÿ›¹3[Ë¡eµþ./Ã^/ìõú~•Þ¤Û¨­h¯Ü{t[5„ Aߢ–‚ë:×ꟳ LÝeg¹'Æ™å̼³0¯ŸN:.g&H¯¤×Ò3Aú ý¾ 5€D ‘ 2ht€ LaȰdh‚Ä qHl‚Œ@F £'Fr÷“ÈÙO.Hdö“¨¡ŸDÎ~b‚ô,¦¢ôºÊÒ³ ÓXšÎâØåé7áô»púNÔ„uáD&NSqºJc¦©,]E±J2ht€ LaȰdh‚Ä qHl‚Œ@F Ö87€Œ;@Æ&ȤdÒ2Ñ |^Ê‹÷N(õW( |bûyœºÆU‡ÿ§?jˆÀr…Î_IS{CxQ¯[}ô¯(ã<í¬Õ§÷mQï,\"E8¿×à3ÍË-…- ØaãÈCÿöÚkØ…—Ùç”3 á{kñH»¬aNF˜œÚ—蘄K^Ãp¨ÿîßßQ°wúî±L‰p~[ïXÒÐî…ɰa±ÁÕ–§=õú…¶q:¼¡¡êEíù>Ë—!÷aŸRàóÒ¥èäv9ðXOÏáüÁ¤CJª ®#tÙ F1†M^¶×Úa%î"ïÒÄAäÕØp¯„¸mÃ~îMÕ(='à5kp¯̽N&»£´Tô+ö®!;û~¯­ïs;Óû¹]—Ï¥]×H¨ò™ÍWFçY§Í¹¬µ°…wâÎ|NCºŠ’b“‰Úú_a$h šB Óø|ÑTóæìç? &Ì\~ML'U€Rò4tBí85ú °õ6IcX×I¨ÇÆpÁ ºÂ+öH^Tvu¬0!ànD¢ nºK>øìü—6ŒÅõi,sÁp4ŠOs¿"NŽ@*<'®ÞlÁG ãF´ba‹ïc¼†¡h‚w¬àÂrRv¶a –Nk±Þù, $gk~…åìŽèÈ ¯‰@ÉÚèÑ­Sù»¤¬…ðþsæS‡(féÝ'å½ß˜’–v¶Ì}zÁå‹«—´»DùÆj³¥UËšº*Îù*[Óóz:¼‹Ä—ŠlYúÖI¿ŒüR>Ó„´´óäó!”³Ä6Yy.Ïi²I·ÿ P[‘´mpÚ«{²`Eב A™ó˜Ú«±SÅ,=º.÷npz®¶V7_ÝR´¢ï?'¾£÷­)o#ù¨ä­ ‰QNåÓ;âÁ´7ò(qiÁ8lwG[‘{ 'nWè«Æö·<ðAcJÓíÖkÛ‡w:x6_%‡`]ì‚9߂ݑ_ †=x@†Uµ¸šZK|‚ý1(ý–sùz¯†7øâ Sƒxå'Î…ÈLøËêÐCoè!^6rFÌÞó1[â­"^–.1ß%Þ#¯(/¾Ḛ̈aÀCÓ„òçºñ‘·ñÑÃNÒð=súœ1ŽâChN€¾¥îr8ñ64™òûl¼o$Êæe²©¥¹­âÎNê'£È·X££$ò;z YÇñnD¼«×ÉÎsÎ÷0cxöØ{<5³[.’5Ê”2_B‹Ñ}+e̬Ù÷|mŒ{S¦ùeŽJøŒu)ÂAM̯éNG¯ä¡1à¾íŒjØùFÕñe.*“»tã·7±ÇÏÖgUY ¾¬À߯k³M79Ì[ÇÀ¾*N¼ò~/9ÚQXÐÖ-©ÏÆ™mÝ8›øLhBÒÖÞmýz( ÚºûÕªF½ævwIŒ‰z—æ)F›àŽþUNÐåÁ§%@ªªÅôütYÖBÀœMã Em}wŽ(7€J e!øy j~‚ݽ•öQeI[»3fÃЯmÂaãùÙv¼…Ö=ÕÉ€}‰˜6±ch@Ð+B7¸!]E!Þ?&£2õëÉœº(f鹟v;uÕCSŸßÆÂ‘FĦuãˆç´Áæð,ý”JSx!ì 6´ñ9ó7BÒÐîÓ.$žxëÐ.$†MâUñˆ…I`ã»*Öø|Zäyqø1yñiÕÔ¾Å0êðÎŽf²ÓŸ.¯ê˜coÌ1…ÊV‚áK†ÅœÈ5›S”^Ððᓾ¯µI*)bÔ±‚‚e1cn§ó‡)¿gm sCos°¦{­XÊoì@…9:©í&Þõ}QèHaáì½u~íè¬çi$§üy „ãœFð½J _71ñ6‘ºfŽÑ€¼|µró63ÜêÖß«‹¸v§} ¹s‡×oÞá)m|ƒˆ)r<%ëD‚¤ í(ähm3ñrX͈áŒtÖDðmfíÈœØ(\‹öEEÁ IKûn›¦b×ßÛyéé; CÏ1gFÚáLgýpMÒê ‹j~göHM ýÏŒx^1‰ŸW®‰Œq›‘ë.þÜ—HbÍ»¥§ÿ½¼ý>h–¦â Ët•¬[ãhµ©‚“ )žùÅ´R¾>¼|ú½e¦ËÇYn³É`*ú?¦2\âÛÅ9½gÏÒÞaŠÅÉXŸ×îÁ¦xŠé~è´~c¼·Xs\¯¶Gà°FuDGƒ°\gÇD £F™ËÈãa.RÊhYlzûÅÔ a„¶>$‹Ím'IÚÞhºÂ wCñKXv®a ‹D„0³Vaoñ•[}<òD`Å¡’nƒEõ±{pø.ËñãQ¸4^iùÛÓo»»Aðr}Œõ•M#jÙ-Øà¸Y¸Â±Ü☑•Éjž½ß;‹?ñm\÷ñ -[´‹~¹jY‡gq7j™B•Ö>sØóüL„-ØZN§x¶mÌ-pÕP¬ÞĶø>Ž(×x·Î/†È$7•Uøþ™'|ÿÌêÌœ®à|ÇÄ)Ï«3’;ºõ°ùÚ‰ÌÏÀc" wm ò‚JmEF|Û¡žZŒ‡»ßk$ïú©þ¸Ibƾ˜ñT¦YÄØÁ?RÇûbÕ#-yvCþ"OU¬"V 1ÕŒøv þ¤f„žq/ê´ùö¤è¬RùÖ¦Ö®$jæŽ÷"ý÷ÛŸß¾y}~uõöÂ"‘maBwûDâ—ë·¹D”ö‹[Tgz}ªÊÛdƒÃqn¼‘´€Û]-q“§%n÷@ÄnÄHFúøßG±¤S[w„¦ê2ýØú¬}T!Úzü/ņšé¶‚§áo€|o‚R—{`R—„ñu•¾O‡à‚×!Ì¡˜nÖê#@'¾ zmÐ_ÞÒŽ<ä…îf¤íªÀÎ<ág´¸¡«š™ÇD|3UQ‡¾¨03Êj.{}—~‹·AÕçÈ}Ø÷Dö§ôè8œ'%”ûÙæ° GãuÄÈ– •ôfÕû@ù!ŸÖúBËuè¨5ä’Õcßf‰Ïð”¾M­í¸ÓâÉ%N’“ɪªÆ|ë3î;ú3),ÎÓ"íµZ¥ë†|«9†j¾.Êݦ ´¸zAF{®Nà|ñ<‡3!k; 3øç¯¿wÁexÈÞ¬ñp°$ä·xÿ!û%ÆQx ¯`Ö þùë(&0˜aKB*è1òàlFÁR.8Þ†ÂÁ^–/Évy"×à¯B]Ó ’«A‡6Q1UˆsÏa² çH>`˜e-ð²‚}ò/®®èöRCAŠ+¬ZP7¶k¢FØÊAÞaŸçžå ÑŒPð,)Öû÷ÕôF’}Vl5z©`ð@Œµêô*[/+´Æé§t±T6Ðá™j,×ël±-ÊânGÀZ=ÏîØâÌê@UµËz*žT–|%#F³jˆ×´ÅZjÖ‚ÑëúÐD‚//½í¾M˜(qÍô=ðÆèzÝÜKGÈÎan›í$+^“E#GÁ¯,ŽÄÃ;"Ç+}ùàeUZ5佄?H¬ß^_¡‹KÒë˸[?îstÌ¿ PÄáäí$r›̇ÉÎ@¢œ :[à“¿KS&Ÿž±¬÷Ž~aÁmH"Œ+F+7‹>cÑ CÑMÑ5ñ!Kóå±é¤¥’ì ô3®7Gäü0Œ?<ùœn‹ÕŸ45é›ýœNØ!~Ù§{IlH™~y$qcIðzý‹JÅ„Ge¿c•ŽÜw·=§‡¤ry]uæ£-¦A¤ªúœqú ™âoÓÜx7J›‰¶Œ‹4!’_±mº‰MHMI,ë¶ ª•¡ºæÞ„ÉÖwÅi0¤i áyã4¤• Sìw›ýî4 «<Æ%ãÑ@hñåuÿå µúòª-¾¼R7_TDÂ*&ŽzŠ »§ ‚k].*Pdbö“«„ÄP¾I«vI }oQç,24%.Žw"l«4fÕWk‡ l¾q_}*¢T7!UváðHד”Ù¡r³Ÿˆ\2ͨP„I×[œÜ<&ÊÉlµo~rC˜(§W £°+¤{)f9×jÌéÚÍÕ MsâJsì@¿ÄÕIaëAfØ£f"c†Næ™+W¡ 5†•?ÑówB¡¡¹0~WÉÁQ-ñ·Sl›ê2§‹â7§ðQáÁ²íwI³Æ\×m– Ì2`¥ljû`ñÆøäÚú²¢å mòÿ`h˜Lglobus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_autodoc_signature_hook.py000066400000000000000000000020661513221403200312450ustar00rootroot00000000000000import pytest @pytest.fixture def process_signature(sphinxext): def func(signature, return_annotation): return sphinxext.after_autodoc_signature_replace_MISSING_repr( object(), "", "", object(), None, signature, return_annotation ) return func def test_autodoc_signature_MISSING_hook_skips_nulls(process_signature): assert process_signature(None, None) == (None, None) def test_autodoc_signature_MISSING_hook_can_update_signature(process_signature): input_sig = "(x: str | = , y: int)" output_sig, output_annotation = process_signature(input_sig, "str") assert output_annotation == "str" assert output_sig == "(x: str | MISSING = MISSING, y: int)" def test_autodoc_signature_MISSING_hook_can_update_return_type(process_signature): input_sig = "(x: str | int = 0, y: int = 0)" output_sig, output_annotation = process_signature( input_sig, "int | " ) assert input_sig == output_sig assert output_annotation == "int | MISSING" globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_automethodlist.py000066400000000000000000000021461513221403200275520ustar00rootroot00000000000000from xml.etree import ElementTree import pytest def test_automethodlist_requires_an_argument( sphinxext, docutils_runner, register_temporary_directive ): register_temporary_directive("automethodlist", sphinxext.directives.AutoMethodList) with pytest.raises(Exception, match=r"1 argument\(s\) required, 0 supplied\."): docutils_runner.to_etree(".. automethodlist::") # choose an arbitrary object from the SDK and confirm that `automethodlist` # will render one of its public methods # for this case, we're using `GlobusApp.login_required()` def test_automethodlist_of_globus_app_shows_login(sphinx_runner): etree = sphinx_runner.to_etree(".. automethodlist:: globus_sdk.GlobusApp") assert etree.tag == "document" paragraph_element = etree.find("paragraph") assert paragraph_element is not None emphasized_text = paragraph_element.find("strong") assert emphasized_text is not None assert emphasized_text.text == "Methods" method_list = etree.find("bullet_list") assert method_list is not None assert b"login_required()" in ElementTree.tostring(method_list) globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_base_add_content_directive.py000066400000000000000000000026071513221403200320210ustar00rootroot00000000000000import textwrap def test_addcontent_generating_text( sphinxext, docutils_runner, register_temporary_directive ): class MyDirective(sphinxext.directives.AddContentDirective): def gen_rst(self): yield "a" yield "b" register_temporary_directive("mydirective", MyDirective) etree = docutils_runner.to_etree(".. mydirective::") assert etree.tag == "document" assert etree.get("source") == "TEST" paragraph_element = etree.find("paragraph") assert paragraph_element is not None assert paragraph_element.text == textwrap.dedent( """\ a b""" ) def test_addcontent_generating_warning( sphinxext, docutils_runner, register_temporary_directive ): class MyDirective(sphinxext.directives.AddContentDirective): def gen_rst(self): yield ".. note::" yield "" yield " Some note content here." yield " Multiline." register_temporary_directive("mydirective", MyDirective) etree = docutils_runner.to_etree(".. mydirective::") assert etree.tag == "document" assert etree.get("source") == "TEST" note_element = etree.find("note") assert note_element is not None paragraph_element = note_element.find("paragraph") assert paragraph_element is not None assert paragraph_element.text == "Some note content here.\nMultiline." globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_copyparams_directive.py000066400000000000000000000122001513221403200307110ustar00rootroot00000000000000from xml.etree import ElementTree import pytest # tests below will check the exact contents of BaseClient params # if BaseClient changes, this will need to update -- but that's rare enough to be # acceptable BASE_CLIENT_PARAMS = ( "app", "app_scopes", "authorizer", "app_name", "base_url", "transport", "retry_config", ) def test_copy_params_requires_an_argument( sphinxext, docutils_runner, register_temporary_directive ): register_temporary_directive("sdk-copy-params", sphinxext.directives.CopyParams) with pytest.raises(Exception, match=r"1 argument\(s\) required, 0 supplied\."): docutils_runner.to_etree(".. sdk-copy-params::") def test_copy_params_finds_base_client_with_or_without_qualified_name(sphinx_runner): etree1 = sphinx_runner.to_etree( """\ .. py:function:: build_client(**kwargs) .. sdk-sphinx-copy-params:: globus_sdk.BaseClient """, ) etree2 = sphinx_runner.to_etree( """\ .. py:function:: build_client(**kwargs) .. sdk-sphinx-copy-params:: BaseClient """, ) # the source attribute records the temporary filenames used # it doesn't matter what we set them to; just normalize them to be the same etree1.set("source", "/dev/stdin") etree2.set("source", "/dev/stdin") # now render and compare equal doc1 = ElementTree.tostring(etree1) doc2 = ElementTree.tostring(etree2) assert doc1 == doc2 def test_copy_params_renders_params_of_base_client(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. py:function:: build_client(**kwargs) .. sdk-sphinx-copy-params:: globus_sdk.BaseClient """, ) assert etree.tag == "document" parameters_field = etree.find("./desc/desc_content/field_list/field") assert parameters_field is not None assert parameters_field.find("field_name").text == "Parameters" parameters_list = parameters_field.find("./field_body/bullet_list") assert parameters_list is not None parameter_names = parameters_list.findall("./list_item/paragraph/literal_strong") assert len(parameter_names) == len(BASE_CLIENT_PARAMS) param_names = tuple(p.text for p in parameter_names) assert param_names == BASE_CLIENT_PARAMS def test_copy_params_can_render_after_content(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. py:function:: build_client(priority: int = 0, **kwargs) .. sdk-sphinx-copy-params:: globus_sdk.BaseClient :param priority: How cool this client will be """, ) assert etree.tag == "document" parameters_field = etree.find("./desc/desc_content/field_list/field") assert parameters_field is not None assert parameters_field.find("field_name").text == "Parameters" parameters_list = parameters_field.find("./field_body/bullet_list") assert parameters_list is not None parameter_names = parameters_list.findall("./list_item/paragraph/literal_strong") assert len(parameter_names) == len(BASE_CLIENT_PARAMS) + 1 param_names = tuple(p.text for p in parameter_names) assert param_names == ("priority",) + BASE_CLIENT_PARAMS def test_copy_params_can_render_before_content(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. py:function:: build_client(**kwargs, priority: int = 0) .. sdk-sphinx-copy-params:: globus_sdk.BaseClient :param priority: How cool this client will be """, ) assert etree.tag == "document" parameters_field = etree.find("./desc/desc_content/field_list/field") assert parameters_field is not None assert parameters_field.find("field_name").text == "Parameters" parameters_list = parameters_field.find("./field_body/bullet_list") assert parameters_list is not None parameter_names = parameters_list.findall("./list_item/paragraph/literal_strong") assert len(parameter_names) == len(BASE_CLIENT_PARAMS) + 1 param_names = tuple(p.text for p in parameter_names) assert param_names == BASE_CLIENT_PARAMS + ("priority",) def test_copy_params_can_render_in_the_middle_of_content(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. py:function:: build_client(awesomeness: float, **kwargs, priority: int = 0) .. sdk-sphinx-copy-params:: globus_sdk.BaseClient :param awesomeness: The awesomeness quotient :param priority: How cool this client will be """, ) assert etree.tag == "document" parameters_field = etree.find("./desc/desc_content/field_list/field") assert parameters_field is not None assert parameters_field.find("field_name").text == "Parameters" parameters_list = parameters_field.find("./field_body/bullet_list") assert parameters_list is not None parameter_names = parameters_list.findall("./list_item/paragraph/literal_strong") assert len(parameter_names) == len(BASE_CLIENT_PARAMS) + 2 param_names = tuple(p.text for p in parameter_names) assert param_names == ("awesomeness",) + BASE_CLIENT_PARAMS + ("priority",) globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_enumerate_fixtures.py000066400000000000000000000044011513221403200304170ustar00rootroot00000000000000import json import re import types import pytest import globus_sdk def test_enumerate_fixtures_rejects_wrong_object_type(sphinx_runner, capsys): sphinx_runner.ensure_failure( ".. enumeratetestingfixtures:: globus_sdk.NullAuthorizer", ) captured = capsys.readouterr() err_lines = captured.err.splitlines() test_line = None for line in err_lines: if "BaseClient" in line: test_line = line break else: pytest.fail("Didn't find 'BaseClient' in stderr") assert re.search( r"Expected to be a subclass of BaseClient", test_line, ) # choose an arbitrary client to test against def test_enumerate_fixtures_of_search_client(sphinx_runner): etree = sphinx_runner.to_etree( ".. enumeratetestingfixtures:: globus_sdk.SearchClient", ) assert etree.tag == "document" title = etree.find("./section/title") assert title.text == "globus_sdk.SearchClient" dropdowns = etree.findall("./section/container[@design_component='dropdown']") # we don't care about exactly what methods are found and produced as dropdowns # we just want to make sure there are "some" assert len(dropdowns) > 1 # grab the first dropdown and inspect it first_dropdown = dropdowns[0] # find the title, make sure it matches a real method fixture_title = first_dropdown.find("./rubric/literal") assert fixture_title is not None first_method_name = fixture_title.text assert hasattr(globus_sdk.SearchClient, first_method_name) first_method = getattr(globus_sdk.SearchClient, first_method_name) assert isinstance(first_method, types.FunctionType) # for each dropdown, there should be a content area and it should contain valid JSON for dropdown in dropdowns: fixture_title = dropdown.find("./rubric/literal").text example_block = dropdown.find("./literal_block") assert example_block is not None assert example_block.get("language") == "json" content = example_block.text try: json.loads(content) except json.JSONDecodeError: pytest.fail( f"{fixture_title} in SearchClient fixture docs didn't have JSON content" ) globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_expand_testing_fixture.py000066400000000000000000000033441513221403200312700ustar00rootroot00000000000000import json import pytest def test_expand_testing_fixture_fails_on_bad_reference(sphinx_runner, capsys): sphinx_runner.ensure_failure( ".. expandtestfixture:: NO_SUCH_FIXTURE", ) captured = capsys.readouterr() err_lines = captured.err.splitlines() test_line = None for line in err_lines: if "ValueError: no fixtures defined" in line: test_line = line break else: pytest.fail("Didn't find 'ValueError: no fixtures defined' in stderr") assert ( "no fixtures defined for globus_sdk.testing.data.NO_SUCH_FIXTURE" in test_line ) # choose an arbitrary example fixture to test def test_expand_testing_fixture_on_valid_fixture(sphinx_runner): etree = sphinx_runner.to_etree( ".. expandtestfixture:: groups.set_group_policies", ) code_block = etree.find("./literal_block") assert code_block is not None assert code_block.get("language") == "json" # check against the known values for this fixture data = json.loads(code_block.text) assert data["is_high_assurance"] is False assert data["group_visibility"] == "private" # choose an arbitrary example fixture to test with multiple cases def test_expand_testing_fixture_on_non_default_case(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. expandtestfixture:: auth.userinfo :case: unauthorized """, ) code_block = etree.find("./literal_block") assert code_block is not None assert code_block.get("language") == "json" # check against the known values for this fixture data = json.loads(code_block.text) assert data["error_description"] == "Unauthorized" assert data["errors"][0]["status"] == "401" globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_extdoclink_directive.py000066400000000000000000000044651513221403200307150ustar00rootroot00000000000000import pytest def test_extdoclink_renders_simple(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. extdoclink:: Create Demuddler :ref: demuddler/create """, ) assert etree.tag == "document" paragraph = etree.find("paragraph") assert paragraph is not None text_parts = list(paragraph.itertext()) assert "See " in text_parts[0] assert "API documentation for details" in text_parts[-1] link = paragraph.find("reference") assert link is not None assert link.text == "Create Demuddler" uri = link.get("refuri") assert uri is not None assert uri == "https://docs.globus.org/api/demuddler/create" @pytest.mark.parametrize( "service, base_url", ( ("groups", "https://groups.api.globus.org/redoc#operation"), ("gcs", "https://docs.globus.org/globus-connect-server/v5/api"), ("flows", "https://globusonline.github.io/globus-flows#tag"), ("compute", "https://compute.api.globus.org/redoc#tag"), ), ) def test_extdoclink_renders_with_custom_service(sphinx_runner, service, base_url): etree = sphinx_runner.to_etree( f"""\ .. extdoclink:: Battle Cry :ref: theTick/SPOOOON :service: {service} """, ) assert etree.tag == "document" paragraph = etree.find("paragraph") assert paragraph is not None text_parts = list(paragraph.itertext()) assert "See " in text_parts[0] assert "API documentation for details" in text_parts[-1] link = paragraph.find("reference") assert link is not None assert link.text == "Battle Cry" uri = link.get("refuri") assert uri is not None assert uri == f"{base_url}/theTick/SPOOOON" def test_extdoclink_renders_with_custom_base_url(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. extdoclink:: Battle Cry :ref: SPOOOON :base_url: https://docs.globus.org/90sNostalgia/TheCity """, ) assert etree.tag == "document" paragraph = etree.find("paragraph") assert paragraph is not None link = paragraph.find("reference") assert link is not None assert link.text == "Battle Cry" uri = link.get("refuri") assert uri is not None assert uri == "https://docs.globus.org/90sNostalgia/TheCity/SPOOOON" globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_extdoclink_role.py000066400000000000000000000014261513221403200276720ustar00rootroot00000000000000import pytest def test_extdoclink_role_rejects_no_spaces(sphinxext): with pytest.raises( ValueError, match="extdoclink role must contain space-separated text" ): sphinxext.extdoclink_role("ref", "foobar", "foobar", 0, object()) def test_extdoclink_role_requires_angle_brackets(sphinxext): with pytest.raises( ValueError, match="extdoclink role reference must be in angle brackets" ): sphinxext.extdoclink_role("ref", "foo bar", "foo bar", 0, object()) def test_extdoclink_role_simple(sphinxext): nodes, _ = sphinxext.extdoclink_role( "ref", "foo bar <baz/quxx>", "foo bar ", 0, object() ) assert len(nodes) == 1 node = nodes[0] assert node["refuri"] == "https://docs.globus.org/api/baz/quxx" globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_list_known_scopes.py000066400000000000000000000047521513221403200302550ustar00rootroot00000000000000import re import pytest def test_listknownscopes_rejects_wrong_object_type(sphinx_runner, capsys): sphinx_runner.ensure_failure( ".. listknownscopes:: globus_sdk.BaseClient", ) captured = capsys.readouterr() err_lines = captured.err.splitlines() test_line = None for line in err_lines: if "scope collection" in line: test_line = line break else: pytest.fail("Didn't find 'scope collection' in stderr") assert re.search( r"Expected to be a scope collection", test_line ) # choose an arbitrary scope collection from the SDK and confirm that listknownscopes # will render its list of members # for this case, we're using `TimersScopes` def test_listknownscopes_of_timers(sphinx_runner): etree = sphinx_runner.to_etree( ".. listknownscopes:: globus_sdk.scopes.TimersScopes", ) assert etree.tag == "document" paragraphs = etree.findall("paragraph") assert len(paragraphs) == 2 paragraph0, paragraph1 = paragraphs assert paragraph0.text.startswith( "Various scopes are available as attributes of this object." ) console_block = etree.find("doctest_block") assert console_block is not None assert console_block.text == ">>> TimersScopes.timer" emphasized_text = paragraph1.find("strong") assert emphasized_text is not None assert emphasized_text.text == "Supported Scopes" scope_list = etree.find("bullet_list") assert scope_list is not None scope_items = scope_list.findall("./list_item/paragraph/literal") assert len(scope_items) == 1 assert scope_items[0].text == "timer" def test_listknownscopes_of_timers_with_forced_example(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. listknownscopes:: globus_sdk.scopes.TimersScopes :example_scope: frobulate """, ) assert etree.tag == "document" console_block = etree.find("doctest_block") assert console_block is not None assert console_block.text == ">>> TimersScopes.frobulate" def test_listknownscopes_of_timers_with_altered_basename(sphinx_runner): etree = sphinx_runner.to_etree( """\ .. listknownscopes:: globus_sdk.scopes.TimersScopes :base_name: ScopeMuddler """, ) assert etree.tag == "document" console_block = etree.find("doctest_block") assert console_block is not None assert console_block.text == ">>> ScopeMuddler.timer" globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_paginated_usage.py000066400000000000000000000015671513221403200276330ustar00rootroot00000000000000import pytest def test_paginated_usage_requires_an_argument( sphinxext, docutils_runner, register_temporary_directive ): register_temporary_directive("paginatedusage", sphinxext.directives.PaginatedUsage) with pytest.raises(Exception, match=r"1 argument\(s\) required, 0 supplied\."): docutils_runner.to_etree(".. paginatedusage::") def test_paginated_usage_simple(sphinx_runner): etree = sphinx_runner.to_etree(".. paginatedusage:: foo") assert etree.tag == "document" paragraph = etree.find("paragraph") assert paragraph is not None text_parts = list(paragraph.itertext()) assert text_parts[0].startswith("This method supports paginated access.") code_block = etree.find("literal_block") assert code_block is not None assert code_block.get("language") == "default" assert code_block.text == "client.paginated.foo(...)" globus-globus-sdk-python-6a080e4/tests/unit/sphinxext/test_utils.py000066400000000000000000000032321513221403200256420ustar00rootroot00000000000000import textwrap import pytest import globus_sdk def test_locate_class_finds_transfer_client(sphinxext): assert ( sphinxext.utils.locate_class("globus_sdk.TransferClient") is globus_sdk.TransferClient ) def test_locate_class_rejects_missing(sphinxext): with pytest.raises(RuntimeError, match="MISSING is not a class name"): sphinxext.utils.locate_class("globus_sdk.MISSING") def test_read_sphinx_params(sphinxext): docstring = """ preamble :param param1: some doc on one line :param param2: other doc spanning multiple lines :param param3: a doc spanning many lines :param param4: a doc spanning lines with a break in the middle ^ :param param5: another :param param6: and a final one after some whitespace epilogue """ params = sphinxext.utils.read_sphinx_params(docstring) assert len(params) == 6 assert params[0] == ":param param1: some doc on one line" assert params[1] == ":param param2: other doc\n spanning multiple lines" assert params[2] == textwrap.dedent( """\ :param param3: a doc spanning many lines""" ) assert params[3] == textwrap.dedent( """\ :param param4: a doc spanning lines with a break in the middle ^""" ) assert params[4] == ":param param5: another" assert params[5] == ":param param6: and a final one after some whitespace" # clear the cache after the test # not essential, but reduces the risk that this impacts some future test sphinxext.utils.read_sphinx_params.cache_clear() globus-globus-sdk-python-6a080e4/tests/unit/test_auth_clients.py000066400000000000000000000024451513221403200251370ustar00rootroot00000000000000import unittest.mock import uuid import pytest import globus_sdk CLIENT_ID_UUID = uuid.uuid4() CLIENT_ID_STR = str(CLIENT_ID_UUID) @pytest.mark.parametrize( "client_type", ( globus_sdk.AuthLoginClient, globus_sdk.ConfidentialAppAuthClient, globus_sdk.NativeAppAuthClient, ), ) @pytest.mark.parametrize( "pass_value", (CLIENT_ID_STR, CLIENT_ID_UUID), ids=("str", "uuid") ) def test_can_use_uuid_or_str_for_client_id(client_type, pass_value): if client_type in (globus_sdk.AuthLoginClient, globus_sdk.NativeAppAuthClient): client = client_type(client_id=pass_value) elif client_type is globus_sdk.ConfidentialAppAuthClient: client = globus_sdk.ConfidentialAppAuthClient(pass_value, "bogus_secret") else: raise NotImplementedError assert client.client_id == CLIENT_ID_STR def test_native_app_auth_client_rejects_authorizer(): authorizer = unittest.mock.Mock() with pytest.raises(TypeError): globus_sdk.NativeAppAuthClient(CLIENT_ID_UUID, authorizer=authorizer) def test_confidential_app_auth_client_rejects_authorizer(): authorizer = unittest.mock.Mock() with pytest.raises(TypeError): globus_sdk.ConfidentialAppAuthClient( CLIENT_ID_UUID, "foo-secret", authorizer=authorizer ) globus-globus-sdk-python-6a080e4/tests/unit/test_auth_requirements_error.py000066400000000000000000000517451513221403200274410ustar00rootroot00000000000000import uuid import pytest from globus_sdk.exc import ErrorSubdocument from globus_sdk.gare import ( GARE, GlobusAuthorizationParameters, _variants, has_gares, is_gare, to_gare, to_gares, ) from globus_sdk.testing import construct_error @pytest.mark.parametrize( "error_dict, status, expected_required_scopes, expected_message", ( ( { "code": "ConsentRequired", "message": "Missing required foo_bar consent", "request_id": "WmMV97A1w", "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*foo *bar]" ], "resource": "/transfer", }, 403, ["urn:globus:auth:scope:transfer.api.globus.org:all[*foo *bar]"], "Missing required foo_bar consent", ), ( { "code": "ConsentRequired", "required_scope": ( "urn:globus:auth:scope:transfer.api.globus.org:all[*foo *bar]" ), "description": "Missing required foo_bar consent", }, 401, ["urn:globus:auth:scope:transfer.api.globus.org:all[*foo *bar]"], "Missing required foo_bar consent", ), pytest.param( { "error": "dependent_consent_required", "error_description": "User must approve your client using scopes. See 'unapproved_scopes'.", # noqa: E501 "errors": [ { "code": "DEPENDENT_CONSENT_REQUIRED", "id": str(uuid.uuid4()), "title": "User must approve your client using scopes. See 'unapproved_scopes'.", # noqa: E501 "unapproved_scopes": [ "https://auth.globus.org/scopes/00000000-ec3c-427d-bfb5-51049530122b/flow_00000000_ec3c_427d_bfb5_51049530122b_user" # noqa: E501 ], "status": "403", "detail": "User hasn't approved the following scopes: https://auth.globus.org/scopes/00000000-ec3c-427d-bfb5-51049530122b/flow_00000000_ec3c_427d_bfb5_51049530122b_user", # noqa: E501 } ], }, 403, [ "https://auth.globus.org/scopes/00000000-ec3c-427d-bfb5-51049530122b/flow_00000000_ec3c_427d_bfb5_51049530122b_user" # noqa: E501 ], None, id="Auth 'dependent_consent_required' error", ), ), ) def test_create_auth_requirements_error_from_consent_error( error_dict, status, expected_required_scopes, expected_message ): """ Test that various ConsentRequired error shapes can be detected and converted to a GlobusAuthRequirementsError. """ # Create various supplementary objects representing this error error_subdoc = ErrorSubdocument(error_dict) api_error = construct_error(body=error_dict, http_status=status) for error in (error_dict, error_subdoc, api_error): # Test boolean utility functions assert is_gare(error) assert has_gares([error]) # Check that this only produces one error assert len(to_gares([error])) == 1 # Create a Globus Auth requirements error from the original error authreq_error = to_gare(error) assert isinstance(authreq_error, GARE) assert authreq_error.code == "ConsentRequired" assert ( authreq_error.authorization_parameters.required_scopes == expected_required_scopes ) if expected_message is not None: assert ( authreq_error.authorization_parameters.session_message == expected_message ) @pytest.mark.parametrize( "authorization_parameters", ( { "session_message": ( "To gain access you need to authenticate with your baz identity" ), "session_required_identities": ["urn:globus:auth:identity:baz"], "session_required_mfa": True, }, { "session_message": ( "You need to authenticate with an identity that " "matches the required policies" ), "session_required_policies": ["foo", "baz"], }, { "session_message": ( "You need to authenticate with an identity that " "belongs to an authorized domain" ), "session_required_single_domain": ["foo.com", "baz.org"], }, { "session_message": "You need to re-authenticate", "session_required_single_domain": ["foo.com", "baz.org"], "prompt": "login", }, ), ) def test_create_auth_requirements_error_from_authorization_error( authorization_parameters, ): """ Test that various authorization parameters error shapes can be detected and converted to a GlobusAuthRequirementsError. """ # Create various supplementary objects representing this error error_dict = {"authorization_parameters": authorization_parameters} error_subdoc = ErrorSubdocument(error_dict) api_error = construct_error(body=error_dict, http_status=403) for error in (error_dict, error_subdoc, api_error): # Test boolean utility functions assert is_gare(error) assert has_gares([error]) # Check that this only produces one error assert len(to_gares([error])) == 1 # Create a Globus Auth requirements error from a legacy # authorization parameters format error authreq_error = to_gare(error) assert isinstance(authreq_error, GARE) # Check that the default error code is set assert authreq_error.code == "AuthorizationRequired" # Iterate over the expected attributes and check that they match for name, value in authorization_parameters.items(): assert getattr(authreq_error.authorization_parameters, name) == value @pytest.mark.parametrize( "authorization_parameters", ( { "session_message": ( "You need to authenticate with an identity that " "matches the required policies" ), "session_required_policies": ["foo", "baz"], }, { "session_message": ( "You need to authenticate with an identity that " "belongs to an authorized domain" ), "session_required_single_domain": ["foo.com", "baz.org"], }, ), ) def test_create_auth_requirements_error_from_authorization_error_csv( authorization_parameters, ): """ Test that authorization parameters error shapes that provide lists as comma- delimited values can be detected and converted to a GlobusAuthRequirementsError normalizing to lists of strings for those values. """ # Create various supplementary objects representing this error error_dict = {"authorization_parameters": {}} for key, value in authorization_parameters.items(): if key in ("session_required_policies", "session_required_single_domain"): # Convert the list to a comma-separated string for known variants error_dict["authorization_parameters"][key] = ",".join(value) else: error_dict["authorization_parameters"][key] = value error_subdoc = ErrorSubdocument(error_dict) api_error = construct_error(body=error_dict, http_status=403) for error in (error_dict, error_subdoc, api_error): # Test boolean utility functions assert is_gare(error) assert has_gares([error]) # Check that this only produces one error assert len(to_gares([error])) == 1 # Create a Globus Auth requirements error from a legacy # authorization parameters format error authreq_error = to_gare(error) assert isinstance(authreq_error, GARE) # Check that the default error code is set assert authreq_error.code == "AuthorizationRequired" # Iterate over the expected attributes and check that they match for name, value in authorization_parameters.items(): assert getattr(authreq_error.authorization_parameters, name) == value def test_create_auth_requirements_errors_from_multiple_errors(): """ Test that a GlobusAPIError with multiple subdocuments is converted to multiple GlobusAuthRequirementsErrors, and additionally test that this is correct even when mingled with other accepted data types. """ consent_errors = construct_error( body={ "errors": [ { "code": "ConsentRequired", "message": "Missing required foo_bar consent", "authorization_parameters": { "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*bar]" ], "session_message": "Missing required foo_bar consent", }, }, { "code": "ConsentRequired", "message": "Missing required foo_baz consent", "authorization_parameters": { "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*baz]" ], "session_message": "Missing required foo_baz consent", }, }, ] }, http_status=403, ) authorization_error = construct_error( body={ "authorization_parameters": { "session_message": ( "You need to authenticate with an identity that " "matches the required policies" ), "session_required_policies": ["foo", "baz"], } }, http_status=403, ) not_an_error = construct_error( body={ "code": "NotAnError", "message": "This is not an error", }, http_status=403, ) all_errors = [consent_errors, not_an_error, authorization_error] # Test boolean utility function assert has_gares(all_errors) # Create auth requirements errors from a all errors authreq_errors = to_gares(all_errors) assert isinstance(authreq_errors, list) assert len(authreq_errors) == 3 # Check that errors properly converted for authreq_error in authreq_errors: assert isinstance(authreq_error, GARE) # Check that the proper auth requirements errors were produced assert authreq_errors[0].code == "ConsentRequired" assert authreq_errors[0].authorization_parameters.required_scopes == [ "urn:globus:auth:scope:transfer.api.globus.org:all[*bar]" ] assert ( authreq_errors[0].authorization_parameters.session_message == "Missing required foo_bar consent" ) assert authreq_errors[1].code == "ConsentRequired" assert authreq_errors[1].authorization_parameters.required_scopes == [ "urn:globus:auth:scope:transfer.api.globus.org:all[*baz]" ] assert ( authreq_errors[1].authorization_parameters.session_message == "Missing required foo_baz consent" ) assert authreq_errors[2].code == "AuthorizationRequired" assert authreq_errors[2].authorization_parameters.session_required_policies == [ "foo", "baz", ] assert authreq_errors[2].authorization_parameters.session_message == ( "You need to authenticate with an identity that matches the required policies" ) def test_create_auth_requirements_error_from_legacy_authorization_error_with_code(): """ Test that legacy authorization parameters error shapes that provide a `code` can be detected and converted to a GlobusAuthRequirementsError while retaining the `code`. """ # Create a legacy authorization parameters error with a code error_dict = { "code": "UnsatisfiedPolicy", "authorization_parameters": { "session_message": ( "You need to authenticate with an identity that " "matches the required policies" ), "session_required_policies": "foo,baz", }, } # Create various supplementary objects representing this error error_subdoc = ErrorSubdocument(error_dict) api_error = construct_error(body=error_dict, http_status=403) for error in (error_dict, error_subdoc, api_error): # Test boolean utility functions assert is_gare(error) assert has_gares([error]) # Check that this only produces one error assert len(to_gares([error])) == 1 # Create a Globus Auth requirements error from a legacy # authorization parameters format error authreq_error = to_gare(error) assert isinstance(authreq_error, GARE) # Check that the custom error code is set assert authreq_error.code == "UnsatisfiedPolicy" # Iterate over the expected attributes and check that they match assert authreq_error.authorization_parameters.session_required_policies == [ "foo", "baz", ] def test_backward_compatibility_consent_required_error(): """ Test that a consent required error with a comingled backward-compatible data schema is converted to a GlobusAuthRequirementsError. """ # Create an API error with a backward compatible data schema using # distinct values for duplicative fields to facilitate testing # (in practice these would be the same) error = construct_error( body={ "code": "ConsentRequired", "message": "Missing required foo_bar consent", "request_id": "WmMV97A1w", "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*foo *bar]" ], "resource": "/transfer", "authorization_parameters": { "session_message": "Missing baz consent", "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*baz]" ], "optional": "A non-canonical field", }, }, http_status=403, ) # Test boolean utility functions assert is_gare(error) assert has_gares([error]) # Check that this only produces one error assert len(to_gares([error])) == 1 # Create a Globus Auth requirements error authreq_error = to_gare(error) assert isinstance(authreq_error, GARE) assert authreq_error.code == "ConsentRequired" assert authreq_error.authorization_parameters.required_scopes == [ "urn:globus:auth:scope:transfer.api.globus.org:all[*baz]" ] assert ( authreq_error.authorization_parameters.session_message == "Missing baz consent" ) # Test that only suppotred fields are present in the dict assert authreq_error.to_dict() == { "code": "ConsentRequired", "authorization_parameters": { "session_message": "Missing baz consent", "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*baz]" ], }, } # Test that extra fields are present in the dict assert authreq_error.to_dict(include_extra=True) == { "code": "ConsentRequired", "message": "Missing required foo_bar consent", "request_id": "WmMV97A1w", "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*foo *bar]" ], "resource": "/transfer", "authorization_parameters": { "session_message": "Missing baz consent", "required_scopes": [ "urn:globus:auth:scope:transfer.api.globus.org:all[*baz]" ], "optional": "A non-canonical field", }, } @pytest.mark.parametrize( "target_class, data, expect_message", [ ( # missing 'code' GARE, {"authorization_parameters": {"session_required_policies": "foo"}}, "'code' must be a string", ), ( # missing 'authorization_parameters' _variants.LegacyAuthorizationParametersError, {}, ( "'authorization_parameters' must be a 'LegacyAuthorizationParameters' " "object or a dictionary" ), ), ( # missing 'code' _variants.LegacyConsentRequiredTransferError, {"required_scopes": []}, "'code' must be the string 'ConsentRequired'", ), ( # missing 'code' _variants.LegacyConsentRequiredAPError, {"required_scope": "foo"}, "'code' must be the string 'ConsentRequired'", ), ], ) def test_error_from_dict_insufficient_input(target_class, data, expect_message): """ """ with pytest.raises(ValueError) as exc_info: target_class.from_dict(data) assert str(exc_info.value) == expect_message @pytest.mark.parametrize( "target_class", [ GlobusAuthorizationParameters, _variants.LegacyAuthorizationParameters, ], ) def test_authorization_parameters_from_empty_dict(target_class): """ """ authorization_params = target_class.from_dict({}) assert authorization_params.to_dict() == {} def test_gare_repr_shows_attrs(): error_doc = GARE( code="NeedsReauth", authorization_parameters={"session_required_policies": ["foo"]}, ) # the repr will include the parameters repr -- tested separately below assert repr(error_doc) == ( "GARE(" "code='NeedsReauth', " f"authorization_parameters={error_doc.authorization_parameters!r}" ")" ) def test_gare_repr_indicates_presence_of_extra(): error_doc_no_extra = GARE( code="NeedsReauth", authorization_parameters={"session_required_policies": ["foo"]}, ) error_doc_with_extra = GARE( code="NeedsReauth", authorization_parameters={"session_required_policies": ["foo"]}, extra={"alpha": "beta"}, ) assert "extra=..." not in repr(error_doc_no_extra) assert "extra=..." in repr(error_doc_with_extra) def test_authorization_parameters_repr_shows_all_attrs(): params = GlobusAuthorizationParameters() assert repr(params) == ( "GlobusAuthorizationParameters(" "session_message=None, " "session_required_identities=None, " "session_required_policies=None, " "session_required_single_domain=None, " "session_required_mfa=None, " "required_scopes=None, " "prompt=None" ")" ) def test_authorization_parameters_repr_indicates_presence_of_extra(): params_no_extra = GlobusAuthorizationParameters() params_with_extra = GlobusAuthorizationParameters(extra={"gamma": "delta"}) assert "extra=..." not in repr(params_no_extra) assert "extra=..." in repr(params_with_extra) @pytest.mark.parametrize("method", (to_gare, to_gares)) def test_create_gare_from_policy_error_when_non_gare_subdocuments_are_present( method, ): # this error data is based on a real API error shape from Auth # the top-level error is a GARE; but the subdocuments are not policy_id = str(uuid.uuid1()) error_dict = { "errors": [ { "detail": ( "To access this project you must have an identity with admin " "privileges in session within the last 30 minutes." ), "id": "4a156297-a2e5-4095-a13c-ba9486035f79", "title": "Forbidden", "status": "403", "code": "FORBIDDEN", } ], "error": "forbidden", "error_description": "Forbidden", "authorization_parameters": { "session_required_policies": [policy_id], "session_message": ( "To access this project you must have an identity with admin " "privileges in session within the last 30 minutes." ), }, } api_error = construct_error(body=error_dict, http_status=403) # pass singular or plural, to match the relevant method if method is to_gare: gare = method(api_error) else: all_gares = method([api_error]) assert len(all_gares) == 1 gare = all_gares[0] assert isinstance(gare, GARE) # no 'code' was provided in the original error data, so the default will be induced assert gare.code == "AuthorizationRequired" # there are no scopes assert gare.authorization_parameters.required_scopes is None # the message matches the input doc assert ( gare.authorization_parameters.session_message == error_dict["authorization_parameters"]["session_message"] ) # and the policy ID is provided in the required policies field assert gare.authorization_parameters.session_required_policies == [policy_id] globus-globus-sdk-python-6a080e4/tests/unit/test_base_client.py000066400000000000000000000310031513221403200247150ustar00rootroot00000000000000import json import logging import os import uuid from unittest import mock import pytest import globus_sdk from globus_sdk import GlobusApp, GlobusAppConfig, GlobusSDKUsageError, UserApp from globus_sdk.authorizers import NullAuthorizer from globus_sdk.scopes import Scope, TransferScopes from globus_sdk.testing import RegisteredResponse, get_last_request from globus_sdk.token_storage import TokenValidationError from globus_sdk.transport import RequestsTransport @pytest.fixture def auth_client(): return globus_sdk.NativeAppAuthClient(client_id=uuid.uuid1()) @pytest.fixture def base_client_class(): class CustomClient(globus_sdk.BaseClient): service_name = "transfer" scopes = TransferScopes default_scope_requirements = [TransferScopes.all] def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.retry_config.max_retries = 0 return CustomClient @pytest.fixture def base_client(base_client_class): return base_client_class() # not particularly special, just a handy array of codes which should raise # errors when encountered ERROR_STATUS_CODES = (400, 404, 405, 409, 500, 503) def test_cannot_instantiate_plain_base_client(): # attempting to instantiate a BaseClient errors with pytest.raises(GlobusSDKUsageError): globus_sdk.BaseClient() def test_can_instantiate_base_client_with_explicit_url(): client = globus_sdk.BaseClient(base_url="https://example.org") assert client.base_url == "https://example.org" def test_can_instantiate_with_base_url_class_attribute(): class MyCoolClient(globus_sdk.BaseClient): base_url = "https://example.org/" client = MyCoolClient() assert client.base_url == "https://example.org/" def test_base_url_resolution_precedence(): """ Base URL can come from one of 3 different places; this test asserts that we maintain a consistent precedence between the three (init-base_url > class-base_url > class-service_name) """ class BothAttributesClient(globus_sdk.BaseClient): base_url = "class-base" service_name = "service-name" class OnlyServiceClient(globus_sdk.BaseClient): service_name = "service-name" # All 3 are set assert BothAttributesClient(base_url="init-base").base_url == "init-base" assert BothAttributesClient().base_url == "class-base" assert OnlyServiceClient().base_url == "https://service-name.api.globus.org/" def test_set_http_timeout(base_client): class FooClient(globus_sdk.BaseClient): service_name = "foo" with mock.patch.dict(os.environ): # ensure not set os.environ.pop("GLOBUS_SDK_HTTP_TIMEOUT", None) client = FooClient() assert client.transport.http_timeout == 60.0 client = FooClient(transport=RequestsTransport(http_timeout=None)) assert client.transport.http_timeout == 60.0 client = FooClient(transport=RequestsTransport(http_timeout=-1)) assert client.transport.http_timeout is None os.environ["GLOBUS_SDK_HTTP_TIMEOUT"] = "120" client = FooClient() assert client.transport.http_timeout == 120.0 os.environ["GLOBUS_SDK_HTTP_TIMEOUT"] = "-1" client = FooClient() assert client.transport.http_timeout is None @pytest.mark.parametrize("mode", ("init", "post_init")) def test_set_app_name(base_client, base_client_class, mode): """ Sets app name, confirms results """ # set app name if mode == "post_init": c = base_client base_client.app_name = "SDK Test" elif mode == "init": c = base_client_class(app_name="SDK Test") else: raise NotImplementedError # confirm results assert c.app_name == "SDK Test" assert c.transport.user_agent == f"{c.transport.BASE_USER_AGENT}/SDK Test" @pytest.mark.parametrize( "method, allows_body", [("get", False), ("delete", False), ("post", True), ("put", True), ("patch", True)], ) def test_http_methods(method, allows_body, base_client): """ BaseClient.{get, delete, post, put, patch} on a path does "the right thing" Sends a text body or JSON body as requested Raises a GlobusAPIError if the response is not a 200 NOTE: tests sending request bodies even on GET (which *shouldn't* have bodies but *may* have them in reality). """ methodname = method.upper() resolved_method = getattr(base_client, method) path = "/v0.10/madeuppath/objectname" RegisteredResponse( service="transfer", path=path, method=methodname, json={"x": "y"} ).add() # request with no body res = resolved_method(path) req = get_last_request() assert req.method == methodname assert req.body is None assert "x" in res assert res["x"] == "y" if allows_body: jsonbody = {"foo": "bar"} res = resolved_method(path, data=jsonbody) req = get_last_request() assert req.method == methodname assert req.body == json.dumps(jsonbody).encode("utf-8") assert "x" in res assert res["x"] == "y" res = resolved_method(path, data="abc") req = get_last_request() assert req.method == methodname assert req.body == "abc" assert "x" in res assert res["x"] == "y" # send "bad" request for status in ERROR_STATUS_CODES: RegisteredResponse( service="transfer", path=path, method=methodname, json={"x": "y", "code": "ErrorCode", "message": "foo"}, status=status, ).replace() with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: resolved_method(path) assert excinfo.value.http_status == status assert excinfo.value.raw_json["x"] == "y" assert excinfo.value.code == "ErrorCode" assert excinfo.value.message == "foo" def test_handle_url_unsafe_chars(base_client): # make sure this path (escaped) and the request path (unescaped) match RegisteredResponse( service="transfer", path="/v0.10/foo/foo%20bar", json={"x": "y"} ).add() res = base_client.get("/v0.10/foo/foo bar") assert "x" in res assert res["x"] == "y" def test_access_resource_server_property_via_instance(base_client): # get works (and returns accurate info) assert base_client.resource_server == TransferScopes.resource_server def test_access_resource_server_property_via_class(base_client_class): # get works (and returns accurate info) assert base_client_class.resource_server == TransferScopes.resource_server def test_app_integration(base_client_class): def _reraise_token_error(_: GlobusApp, error: TokenValidationError): raise error config = GlobusAppConfig(token_validation_error_handler=_reraise_token_error) app = UserApp("SDK Test", client_id="client_id", config=config) c = base_client_class(app=app) # confirm app_name set assert c.app_name == "SDK Test" # confirm default_required_scopes were automatically added assert [str(s) for s in app.scope_requirements[c.resource_server]] == [ str(TransferScopes.all) ] # confirm attempt at getting an authorizer from app RegisteredResponse( service="transfer", path="foo", method="get", json={"x": "y"} ).add() with pytest.raises(TokenValidationError) as ex: c.get("foo") assert str(ex.value) == "No token data for transfer.api.globus.org" def test_app_scopes(base_client_class): app = UserApp("SDK Test", client_id="client_id") c = base_client_class(app=app, app_scopes=[Scope("foo")]) # confirm app_scopes were added and default_required_scopes were not assert [str(s) for s in app.scope_requirements[c.resource_server]] == ["foo"] def test_add_app_scope(base_client_class): app = UserApp("SDK Test", client_id="client_id") c = base_client_class(app=app) c.add_app_scope("foo") str_list = [str(s) for s in app.scope_requirements[c.resource_server]] assert len(str_list) == 2 assert str(TransferScopes.all) in str_list assert "foo" in str_list def test_add_app_scope_chaining(base_client_class): app = UserApp("SDK Test", client_id="client_id") c = base_client_class(app=app).add_app_scope("foo").add_app_scope("bar") str_list = [str(s) for s in app.scope_requirements[c.resource_server]] assert len(str_list) == 3 assert str(TransferScopes.all) in str_list assert "foo" in str_list assert "bar" in str_list def test_app_mutually_exclusive(base_client_class): app = UserApp("SDK Test", client_id="client_id") expected = "A CustomClient cannot use both an 'app' and an 'authorizer'." authorizer = NullAuthorizer() with pytest.raises(globus_sdk.exc.GlobusSDKUsageError) as ex: base_client_class(app=app, authorizer=authorizer) assert str(ex.value) == expected def test_app_name_override(base_client_class): app = UserApp("SDK Test", client_id="client_id") c = base_client_class(app=app, app_name="foo") assert c.app_name == "foo" def test_app_scopes_requires_app(base_client_class): with pytest.raises( globus_sdk.exc.GlobusSDKUsageError, match=r"A CustomClient must have an 'app' to use 'app_scopes'\.", ): base_client_class(app_scopes=[Scope("foo")]) def test_cannot_double_attach_app(base_client_class): app = UserApp("SDK Test", client_id="client_id") c = base_client_class(app=app) with pytest.raises( globus_sdk.exc.GlobusSDKUsageError, match=r"Cannot attach GlobusApp to CustomClient when one is already attached\.", ): c.attach_globus_app(app) def test_cannot_attach_app_after_manually_setting_app_scopes(base_client_class): c = base_client_class() c.app_scopes = [Scope("foo")] app = UserApp("SDK Test", client_id="client_id") with pytest.raises( globus_sdk.exc.GlobusSDKUsageError, match=( r"Cannot attach GlobusApp to CustomClient when `app_scopes` is already " r"set\." ), ): c.attach_globus_app(app) def test_cannot_attach_app_when_authorizer_was_provided(base_client_class): c = base_client_class(authorizer=NullAuthorizer()) app = UserApp("SDK Test", client_id="client_id") with pytest.raises( globus_sdk.exc.GlobusSDKUsageError, match=( r"Cannot attach GlobusApp to CustomClient when it has an authorizer " r"assigned\." ), ): c.attach_globus_app(app) def test_cannot_attach_app_when_resource_server_is_not_resolvable(): class CustomClient(globus_sdk.BaseClient): service_name = "transfer" default_scope_requirements = [TransferScopes.all] c = CustomClient() app = UserApp("SDK Test", client_id="client_id") with pytest.raises( globus_sdk.exc.GlobusSDKUsageError, match=( r"Unable to use an 'app' with a client with no 'resource_server' defined\." ), ): c.attach_globus_app(app) def test_cannot_attach_app_with_mismatched_environment(base_client_class): c = base_client_class(environment="preview") app = UserApp("SDK Test", client_id="client_id") with pytest.raises( globus_sdk.exc.GlobusSDKUsageError, match=( r"\[Environment Mismatch\] CustomClient's environment \(preview\) does not " r"match the GlobusApp's configured environment \(production\)\." ), ): c.attach_globus_app(app) def test_client_close_implicitly_closes_internal_transport(base_client_class): # test the private _close() method directly client = base_client_class() with mock.patch.object(client.transport, "close") as transport_close: client.close() transport_close.assert_called_once() def test_client_close_debug_logs_internal_transport_close(base_client_class, caplog): caplog.set_level(logging.DEBUG) client = base_client_class() client.close() assert "closing resource of type RequestsTransport for CustomClient" in caplog.text def test_client_close_does_not_close_explicitly_passed_transport(base_client_class): # test the private _close() method directly client = base_client_class(transport=RequestsTransport()) with mock.patch.object(client.transport, "close") as transport_close: client.close() transport_close.assert_not_called() def test_client_context_manager_exit_calls_close(base_client_class): with mock.patch.object(globus_sdk.BaseClient, "close") as client_close_method: with base_client_class(): client_close_method.assert_not_called() client_close_method.assert_called_once() globus-globus-sdk-python-6a080e4/tests/unit/test_classproperty.py000066400000000000000000000010161513221403200253600ustar00rootroot00000000000000from globus_sdk._internal.classprop import classproperty def test_classproperty_simple(): class Foo: x = {"x": 1} @classproperty def y(self_or_cls): return self_or_cls.x["x"] assert Foo.y == 1 def test_classproperty_prefers_instance(): class Foo: x = {"x": 1} def __init__(self) -> None: self.x = {"x": 2} @classproperty def y(self_or_cls): return self_or_cls.x["x"] assert Foo.y == 1 assert Foo().y == 2 globus-globus-sdk-python-6a080e4/tests/unit/test_config.py000066400000000000000000000166141513221403200237250ustar00rootroot00000000000000import os import pathlib from unittest import mock import pytest import globus_sdk.config def test_get_service_url(): """ Confirms get_service_url returns expected results Tests environments, services, and missing values """ assert ( globus_sdk.config.get_service_url("auth", environment="production") == "https://auth.globus.org/" ) assert ( globus_sdk.config.get_service_url("transfer", environment="production") == "https://transfer.api.globus.org/" ) assert ( globus_sdk.config.get_service_url("auth", environment="preview") == "https://auth.preview.globus.org/" ) assert ( globus_sdk.config.get_service_url("search", environment="preview") == "https://search.api.preview.globus.org/" ) assert ( globus_sdk.config.get_service_url("timer", environment="preview") == "https://preview.timer.automate.globus.org/" ) with pytest.raises(ValueError): globus_sdk.config.get_service_url("auth", environment="nonexistent") ca_bundle_file = pathlib.Path(__file__).parent.absolute() / "CA-Bundle.cert" ca_bundle_directory = pathlib.Path(__file__).parent.absolute() ca_bundle_non_existent = pathlib.Path(__file__).parent.absolute() / "bogus.bogus" @pytest.mark.parametrize( "value, expected_result", [(x, True) for x in ["1", "YES", "true", "t", "True", "ON"]] + [(x, False) for x in ["0", "NO", "false", "f", "False", "OFF"]] + [(str(ca_bundle_file), str(ca_bundle_file))] + [ ("invalid", ValueError), ("1.0", ValueError), ("0.0", ValueError), (str(ca_bundle_directory), ValueError), (str(ca_bundle_non_existent), ValueError), ], ) def test_get_ssl_verify(value, expected_result, monkeypatch): """ Confirms bool cast returns correct bools from sets of string values """ monkeypatch.setenv("GLOBUS_SDK_VERIFY_SSL", value) if expected_result is not ValueError: assert globus_sdk.config.get_ssl_verify() == expected_result else: with pytest.raises(expected_result): globus_sdk.config.get_ssl_verify() @pytest.mark.parametrize( "value", [ "invalid", 1.0, object(), ca_bundle_directory, str(ca_bundle_directory), ca_bundle_non_existent, str(ca_bundle_non_existent), ], ) def test_get_ssl_verify_rejects_bad_explicit_value(value, monkeypatch): monkeypatch.delenv("GLOBUS_SDK_VERIFY_SSL", raising=False) with pytest.raises(ValueError): globus_sdk.config.get_ssl_verify(value) def test_get_ssl_verify_with_explicit_value(): with mock.patch.dict(os.environ): os.environ["GLOBUS_SDK_VERIFY_SSL"] = "false" assert globus_sdk.config.get_ssl_verify(True) is True assert globus_sdk.config.get_ssl_verify(False) is False assert globus_sdk.config.get_ssl_verify(ca_bundle_file) == str(ca_bundle_file) assert globus_sdk.config.get_ssl_verify(str(ca_bundle_file)) == str( ca_bundle_file ) os.environ["GLOBUS_SDK_VERIFY_SSL"] = "on" assert globus_sdk.config.get_ssl_verify(True) is True assert globus_sdk.config.get_ssl_verify(False) is False assert globus_sdk.config.get_ssl_verify(str(ca_bundle_file)) == str( ca_bundle_file ) @pytest.mark.parametrize( "value, expected_result", [(x, 1.0) for x in ["1.0", "1", " 1", "1.0 "]] + [(x, 0.0) for x in ["0", "0.0"]] + [("", 60.0)] + [("-1", None)] # type: ignore + [(x, ValueError) for x in ["invalid", "no", "1.1.", "t", "f"]], # type: ignore ) def test_get_http_timeout(value, expected_result): """ Confirms bool cast returns correct bools from sets of string values """ with mock.patch.dict(os.environ): os.environ["GLOBUS_SDK_HTTP_TIMEOUT"] = value if expected_result is None or isinstance(expected_result, float): assert globus_sdk.config.get_http_timeout() == expected_result else: with pytest.raises(expected_result): globus_sdk.config.get_http_timeout() def test_get_http_timeout_with_explicit_value(): with mock.patch.dict(os.environ): os.environ["GLOBUS_SDK_HTTP_TIMEOUT"] = "120" assert globus_sdk.config.get_http_timeout(10) == 10.0 assert globus_sdk.config.get_http_timeout(0) == 0.0 del os.environ["GLOBUS_SDK_HTTP_TIMEOUT"] assert globus_sdk.config.get_http_timeout(60) == 60.0 def test_get_environment_name(): with mock.patch.dict(os.environ): # set an environment value, ensure that it's returned os.environ["GLOBUS_SDK_ENVIRONMENT"] = "beta" assert globus_sdk.config.get_environment_name() == "beta" # clear that value, "production" should be returned del os.environ["GLOBUS_SDK_ENVIRONMENT"] assert globus_sdk.config.get_environment_name() == "production" # ensure that passing a value returns that value assert globus_sdk.config.get_environment_name("beta") == "beta" def test_env_config_registration(): with mock.patch.dict(globus_sdk.config.EnvConfig._registry): # should be None, we don't have an environment named 'moon' assert globus_sdk.config.EnvConfig.get_by_name("moon") is None # now, create the moon class MoonEnvConfig(globus_sdk.config.EnvConfig): envname = "moon" domain = "apollo.globus.org" # a lookup by "moon" should now get this config object assert globus_sdk.config.EnvConfig.get_by_name("moon") is MoonEnvConfig def test_service_url_overrides(): with mock.patch.dict(globus_sdk.config.EnvConfig._registry): class MarsEnvConfig(globus_sdk.config.EnvConfig): envname = "mars" domain = "mars.globus.org" auth_url = "https://perseverance.mars.globus.org/" # this one was customized assert ( MarsEnvConfig.get_service_url("auth") == "https://perseverance.mars.globus.org/" ) # but this one was not assert ( MarsEnvConfig.get_service_url("search") == "https://search.api.mars.globus.org/" ) def test_service_url_from_env_var(): with mock.patch.dict(os.environ): os.environ["GLOBUS_SDK_SERVICE_URL_TRANSFER"] = "https://transfer.example.org/" # environment setting gets ignored at this point -- only the override applies assert ( globus_sdk.config.get_service_url("transfer", environment="preview") == "https://transfer.example.org/" ) assert ( globus_sdk.config.get_service_url("transfer", environment="production") == "https://transfer.example.org/" ) # also try with a made up service os.environ["GLOBUS_SDK_SERVICE_URL_ION_CANNON"] = ( "https://ion-cannon.example.org/" ) assert ( globus_sdk.config.get_service_url("ion_cannon", environment="production") == "https://ion-cannon.example.org/" ) for env in ["sandbox", "test", "integration"]: os.environ["GLOBUS_SDK_ENVIRONMENT"] = env assert ( globus_sdk.config.get_service_url("auth") == f"https://auth.{env}.globuscs.info/" ) assert ( globus_sdk.config.get_webapp_url() == f"https://app.{env}.globuscs.info/" ) globus-globus-sdk-python-6a080e4/tests/unit/test_gcs_client.py000066400000000000000000000022631513221403200245650ustar00rootroot00000000000000from globus_sdk import GCSClient from globus_sdk.testing import load_response def test_client_address_handling(): # variants of the same location c1 = GCSClient("foo.data.globus.org") c2 = GCSClient("https://foo.data.globus.org") c3 = GCSClient("https://foo.data.globus.org/api/") # explicit subpath of /api/ c4 = GCSClient("https://foo.data.globus.org/api/bar") # explicit construction can point at the root (rather than /api/) c5 = GCSClient("foo.data.globus.org") c5.base_url = "https://foo.data.globus.org/" # 1, 2, and 3 are all the same assert c1.base_url == c2.base_url assert c1.base_url == c3.base_url # 4 and 5 are different from the rest assert c4.base_url != c1.base_url assert c5.base_url != c1.base_url # 5 is the root of 1 assert c1.base_url.startswith(c5.base_url) def test_gcs_client_resource_server_and_endpoint_client_id(): meta = load_response(GCSClient.get_gcs_info).metadata endpoint_client_id = meta["endpoint_client_id"] domain_name = meta["domain_name"] c = GCSClient(domain_name) assert c.endpoint_client_id == endpoint_client_id assert c.resource_server == endpoint_client_id globus-globus-sdk-python-6a080e4/tests/unit/test_guards.py000066400000000000000000000147021513221403200237410ustar00rootroot00000000000000import uuid import pytest from globus_sdk import exc from globus_sdk._internal import guards from globus_sdk._internal.serializable import Serializable @pytest.mark.parametrize( "value, typ, ok", [ # passing ([], str, True), ([1, 2], int, True), (["1", ""], str, True), ([], list, True), ([[], [1, 2], ["foo"]], list, True), # failing ([1], str, False), (["foo"], int, False), ((1, 2), int, False), (list, list, False), (list, str, False), (["foo", 1], str, False), ([1, 2], list, False), ], ) def test_list_of_guard(value, typ, ok): assert guards.is_list_of(value, typ) == ok @pytest.mark.parametrize( "value, typ, ok", [ # passing (None, str, True), ("foo", str, True), # failing (b"foo", str, False), ("", int, False), (type(None), str, False), ], ) def test_opt_guard(value, typ, ok): assert guards.is_optional(value, typ) == ok @pytest.mark.parametrize( "value, typ, ok", [ # passing ([], str, True), ([], int, True), ([1, 2], int, True), (["1", ""], str, True), (None, str, True), # failing # NB: the guard checks `list[str] | None`, not `list[str | None]` ([None], str, False), (b"foo", str, False), ("", str, False), (type(None), str, False), ], ) def test_opt_list_guard(value, typ, ok): assert guards.is_optional_list_of(value, typ) == ok @pytest.mark.parametrize("value", (uuid.UUID(int=0), str(uuid.UUID(int=1)))) def test_uuidlike_ok(value): assert guards.validators.uuidlike("foo", value) == value @pytest.mark.parametrize("value", (str(uuid.UUID(int=0))[:-1], "")) def test_uuidlike_fails_value(value): with pytest.raises( exc.ValidationError, match="'foo' must be a valid UUID" ) as excinfo: guards.validators.uuidlike("foo", value) err = excinfo.value assert f"value='{value}'" in str(err) @pytest.mark.parametrize("value", (object(), None, ["bar"])) def test_uuidlike_fails_type(value): with pytest.raises( exc.ValidationError, match="'foo' must be a UUID or str" ) as excinfo: guards.validators.uuidlike("foo", value) err = excinfo.value assert f"value='{value}'" in str(err) @pytest.mark.parametrize( "validator, value", ( pytest.param(guards.validators.str_, "bar", id="str"), pytest.param(guards.validators.int_, 0, id="int-0"), pytest.param(guards.validators.int_, 1, id="int-1"), pytest.param(guards.validators.opt_str, "bar", id="opt_str-str"), pytest.param(guards.validators.opt_str, None, id="opt_str-None"), pytest.param(guards.validators.opt_bool, True, id="opt_bool-True"), pytest.param(guards.validators.opt_bool, False, id="opt_bool-False"), pytest.param(guards.validators.opt_bool, None, id="opt_bool-None"), pytest.param(guards.validators.str_list, [], id="str_list-empty"), pytest.param(guards.validators.str_list, ["foo"], id="str_list-onestr"), pytest.param(guards.validators.opt_str_list, [], id="opt_str_list-empty"), pytest.param(guards.validators.opt_str_list, ["foo"], id="opt_str_list-onestr"), pytest.param(guards.validators.opt_str_list, None, id="opt_str_list-None"), pytest.param( guards.validators.opt_str_list_or_commasep, [], id="opt_str_list_or_commasep-emptylist", ), pytest.param( guards.validators.opt_str_list_or_commasep, ["foo"], id="opt_str_list_or_commasep-list", ), pytest.param( guards.validators.opt_str_list_or_commasep, None, id="opt_str_list_or_commasep-None", ), ), ) def test_simple_validator_passing(validator, value): assert validator("foo", value) == value @pytest.mark.parametrize( "validator, value, match_message", ( pytest.param(guards.validators.str_, 1, "'foo' must be a string", id="str-int"), pytest.param( guards.validators.str_, False, "'foo' must be a string", id="str-bool" ), pytest.param( guards.validators.str_, None, "'foo' must be a string", id="str-None" ), pytest.param( guards.validators.int_, "bar", "'foo' must be an int", id="int-str" ), pytest.param( guards.validators.opt_str, 0, "'foo' must be a string or null", id="opt_str-int", ), pytest.param( guards.validators.opt_bool, 0, "'foo' must be a bool or null", id="opt_bool-int", ), pytest.param( guards.validators.str_list, "x", "'foo' must be a list of strings", id="str_list-str", ), pytest.param( guards.validators.opt_str_list, "x", "'foo' must be a list of strings or null", id="opt_str_list-str", ), pytest.param( guards.validators.opt_str_list_or_commasep, 0, "'foo' must be a list of strings or a comma-delimited string or null", id="opt_str_list_or_commasep-int", ), ), ) def test_simple_validator_failing(validator, value, match_message): with pytest.raises(exc.ValidationError, match=match_message): validator("foo", value) def test_instance_or_dict_validator_failing(): class MyObj(Serializable): def __init__(self, *, extra=None) -> None: pass with pytest.raises( exc.ValidationError, match="'foo' must be a 'MyObj' object or a dictionary" ): guards.validators.instance_or_dict("foo", object(), MyObj) def test_instance_or_dict_validator_pass_on_simple_instance(): class MyObj(Serializable): def __init__(self, *, extra=None) -> None: pass x = MyObj() y = guards.validators.instance_or_dict("foo", x, MyObj) assert x is y def test_instance_or_dict_validator_pass_on_simple_dict(): class MyObj(Serializable): def __init__(self, *, extra=None) -> None: pass x = guards.validators.instance_or_dict("foo", {}, MyObj) assert isinstance(x, MyObj) def test_strlist_or_commasep_splits_str(): x = guards.validators.opt_str_list_or_commasep("foo", "foo,bar,baz") assert x == ["foo", "bar", "baz"] globus-globus-sdk-python-6a080e4/tests/unit/test_imports.py000066400000000000000000000017301513221403200241460ustar00rootroot00000000000000""" Various tests for imports from the SDK to ensure that our `__all__` declarations are correct and complete. To ensure that we aren't contaminating these tests with the current interpreter's state, invoke these via subprocess check_call() calls. These should all be using `shell=True` (the subprocess invoked interpreter doesn't behave correctly without it). """ import os import subprocess import sys import pytest PYTHON_BINARY = os.environ.get("GLOBUS_TEST_PY", sys.executable) @pytest.mark.parametrize( "importstring", [ "from globus_sdk import *", "from globus_sdk import TransferClient, AuthClient, SearchClient", ], ) def test_import_str(importstring): proc = subprocess.Popen( f'{PYTHON_BINARY} -c "{importstring}"', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) status = proc.wait() assert status == 0, str(proc.communicate()) proc.stdout.close() proc.stderr.close() globus-globus-sdk-python-6a080e4/tests/unit/test_lazy_imports.py000066400000000000000000000011171513221403200252040ustar00rootroot00000000000000import pytest import globus_sdk def test_explicit_dir_func_works(): assert "TransferClient" in dir(globus_sdk) assert "__all__" in dir(globus_sdk) def test_force_eager_imports_can_run(): # this check will not do much, other than ensuring that this does not crash globus_sdk._force_eager_imports() def test_attribute_error_on_bad_name(): with pytest.raises(AttributeError) as excinfo: globus_sdk.DEIMOS_DOWN_REMOVE_ALL_PLANTS err = excinfo.value assert ( str(err) == "module globus_sdk has no attribute DEIMOS_DOWN_REMOVE_ALL_PLANTS" ) globus-globus-sdk-python-6a080e4/tests/unit/test_local_gcp.py000066400000000000000000000033111513221403200243710ustar00rootroot00000000000000import os import pytest from globus_sdk import GlobusSDKUsageError, LocalGlobusConnectPersonal @pytest.fixture def pretend_is_windows(monkeypatch): """Patch LocalGlobusConnectPersonal to think it's on Windows *even if it isn't*. """ monkeypatch.setattr( "globus_sdk.local_endpoint.personal.endpoint._on_windows", lambda: True ) @pytest.fixture def pretend_is_not_windows(monkeypatch): """Patch LocalGlobusConnectPersonal to think it's NOT on Windows *even if it is*. """ monkeypatch.setattr( "globus_sdk.local_endpoint.personal.endpoint._on_windows", lambda: False ) def test_windows_config_dir_requires_localappdata(pretend_is_windows, monkeypatch): monkeypatch.delenv("LOCALAPPDATA", raising=False) gcp = LocalGlobusConnectPersonal() with pytest.raises(GlobusSDKUsageError): gcp.config_dir def test_windows_config_dir_ignores_localappdata_with_explicit_init( pretend_is_windows, monkeypatch ): monkeypatch.delenv("LOCALAPPDATA", raising=False) gcp = LocalGlobusConnectPersonal(config_dir="/foo/bar") assert gcp.config_dir == "/foo/bar" def test_windows_default_config_dir(pretend_is_windows, monkeypatch, tmp_path): monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) gcp = LocalGlobusConnectPersonal() assert gcp.config_dir == str(tmp_path / "Globus Connect") def test_nonwindows_default_config_dir(pretend_is_not_windows, monkeypatch, tmp_path): def _fake_expanduser(p: str): assert p.startswith("~/") return str(tmp_path / p[2:]) monkeypatch.setattr(os.path, "expanduser", _fake_expanduser) gcp = LocalGlobusConnectPersonal() assert gcp.config_dir == str(tmp_path / ".globusonline") globus-globus-sdk-python-6a080e4/tests/unit/test_missing_type.py000066400000000000000000000011671513221403200251670ustar00rootroot00000000000000import copy import pickle import pytest from globus_sdk._missing import MISSING, MissingType def test_missing_type_cannot_be_instantiated(): with pytest.raises(TypeError, match="MissingType should not be instantiated"): MissingType() def test_missing_sentinel_bools_as_false(): assert bool(MISSING) is False def test_str_of_missing(): assert str(MISSING) == "" def test_copy_of_missing_is_self(): assert copy.copy(MISSING) is MISSING assert copy.deepcopy(MISSING) is MISSING def test_pickle_of_missing_is_self(): assert pickle.loads(pickle.dumps(MISSING)) is MISSING globus-globus-sdk-python-6a080e4/tests/unit/test_paginator_signature_matching.py000066400000000000000000000024361513221403200303740ustar00rootroot00000000000000""" Inspect the signatures of paginated methods and compare them against their attached paginator requirements. """ import inspect import pytest import globus_sdk _CLIENTS_TO_CHECK = [] for attrname in dir(globus_sdk): obj = getattr(globus_sdk, attrname) if obj is globus_sdk.BaseClient: continue if isinstance(obj, type) and issubclass(obj, globus_sdk.BaseClient): _CLIENTS_TO_CHECK.append(obj) _METHODS_TO_CHECK = [] for cls in _CLIENTS_TO_CHECK: methods = inspect.getmembers(cls, predicate=inspect.isfunction) for name, value in methods: if name.startswith("_"): continue # inherited, non-overloaded methods if name not in cls.__dict__: continue if getattr(value, "_has_paginator", False): _METHODS_TO_CHECK.append(value) @pytest.mark.parametrize("method", _METHODS_TO_CHECK) def test_paginated_method_matches_paginator_requirements(method): paginator_class = method._paginator_class sig = inspect.signature(method) kwarg_names = { p.name for p in sig.parameters.values() if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY) } for param_name in paginator_class._REQUIRES_METHOD_KWARGS: assert param_name in kwarg_names, method.__qualname__ globus-globus-sdk-python-6a080e4/tests/unit/test_paging.py000066400000000000000000000036341513221403200237230ustar00rootroot00000000000000import json from unittest import mock import pytest import requests from globus_sdk.paging import HasNextPaginator from globus_sdk.response import GlobusHTTPResponse from globus_sdk.services.transfer.response import IterableTransferResponse N = 25 class PagingSimulator: def __init__(self, n) -> None: self.n = n # the number of simulated items def simulate_get(self, *args, **params): """ Simulates a paginated response from a Globus API get supporting limit, offset, and has next page """ offset = params.get("offset", 0) limit = params["limit"] data = {} # dict that will be treated as the json data of a response data["offset"] = offset data["limit"] = limit # fill data field data["DATA"] = [] for i in range(offset, min(self.n, offset + limit)): data["DATA"].append({"value": i}) # fill has_next_page field data["has_next_page"] = (offset + limit) < self.n # make the simulated response response = requests.Response() response._content = json.dumps(data).encode() response.headers["Content-Type"] = "application/json" return IterableTransferResponse(GlobusHTTPResponse(response, mock.Mock())) @pytest.fixture def paging_simulator(): return PagingSimulator(N) def test_has_next_paginator(paging_simulator): """ Walk the paging simulator with HasNextPaginator and confirm the results are good """ paginator = HasNextPaginator( paging_simulator.simulate_get, get_page_size=lambda x: len(x["DATA"]), max_total_results=1000, page_size=10, client_args=[], client_kwargs={}, ) def all_items(): for page in paginator: yield from page["DATA"] # confirm results for item, expected in zip(all_items(), range(N)): assert item["value"] == expected globus-globus-sdk-python-6a080e4/tests/unit/test_payload.py000066400000000000000000000034031513221403200241010ustar00rootroot00000000000000import abc import pytest from globus_sdk._payload import AbstractGlobusPayload, GlobusPayload def test_payload_methods(): # just make sure that PayloadWrapper acts like a dict... data = GlobusPayload() assert "foo" not in data with pytest.raises(KeyError): data["foo"] data["foo"] = 1 assert "foo" in data assert data["foo"] == 1 del data["foo"] assert "foo" not in data assert len(data) == 0 assert list(data) == [] data["foo"] = 1 data["bar"] = 2 assert len(data) == 2 assert data == {"foo": 1, "bar": 2} data.update({"x": "hello", "y": "world"}) assert data == {"foo": 1, "bar": 2, "x": "hello", "y": "world"} def test_abstract_payload_detects_abstract_methods(): # A has no abstract methods so it will instantiate class A(AbstractGlobusPayload): pass A() # B has an abstract method and inherits from AbstractGlobusPayload so it should # fail to instantiate class B(A): @abc.abstractmethod def f(self): ... with pytest.raises( TypeError, match=( "Can't instantiate abstract class B without an " "implementation for abstract method 'f'" ), ): B() # C has two abstract methods, so these should be listed comma separated class C(B): @abc.abstractmethod def g(self): ... with pytest.raises( TypeError, match=( "Can't instantiate abstract class C without an " "implementation for abstract methods ('f', 'g'|'g', 'f')" ), ): C() # D should be instantiable because it defines the abstract methods class D(C): def f(self): return 1 def g(self): return 2 D() globus-globus-sdk-python-6a080e4/tests/unit/test_remarshal.py000066400000000000000000000045461513221403200244370ustar00rootroot00000000000000import collections.abc import uuid import pytest from globus_sdk import MISSING from globus_sdk._internal.remarshal import ( commajoin, list_map, listify, strseq_iter, strseq_listify, ) @pytest.mark.parametrize( "value, expected_result", ( ("foo", ["foo"]), ((1, 2, 3), ["1", "2", "3"]), (uuid.UUID(int=10), [f"{uuid.UUID(int=10)}"]), (["foo", uuid.UUID(int=5)], ["foo", f"{uuid.UUID(int=5)}"]), ), ) def test_strseq_iter(value, expected_result): iter_ = strseq_iter(value) assert not isinstance(iter_, list) assert isinstance(iter_, collections.abc.Iterator) assert list(iter_) == expected_result assert list(iter_) == [] @pytest.mark.parametrize( "value, expected_result", ( ("foo", ["foo"]), ((1, 2, 3), ["1", "2", "3"]), (uuid.UUID(int=10), [f"{uuid.UUID(int=10)}"]), (["foo", uuid.UUID(int=5)], ["foo", f"{uuid.UUID(int=5)}"]), (MISSING, MISSING), (None, None), ), ) def test_strseq_listify(value, expected_result): list_ = strseq_listify(value) assert isinstance(list_, list) or list_ in (MISSING, None) assert list_ == expected_result @pytest.mark.parametrize( "value, expected_result", ( ("foo", "foo"), (uuid.UUID(int=10), f"{uuid.UUID(int=10)}"), ((1, 2, 3), "1,2,3"), (range(5), "0,1,2,3,4"), (["foo", uuid.UUID(int=5)], f"foo,{uuid.UUID(int=5)}"), (MISSING, MISSING), (None, None), ), ) def test_commajoin(value, expected_result): joined = commajoin(value) assert joined == expected_result @pytest.mark.parametrize( "value, expected_result", ( ("foo", ["f", "o", "o"]), ((1, 2, 3), [1, 2, 3]), (range(5), [0, 1, 2, 3, 4]), (["foo", uuid.UUID(int=5)], ["foo", uuid.UUID(int=5)]), (MISSING, MISSING), (None, None), ), ) def test_listify(value, expected_result): converted = listify(value) assert converted == expected_result @pytest.mark.parametrize( "value, expected_result", ( ("foo", ["ff", "oo", "oo"]), ((1, 2, 3), [2, 4, 6]), (range(5), [0, 2, 4, 6, 8]), (MISSING, MISSING), (None, None), ), ) def test_list_map(value, expected_result): converted = list_map(value, lambda x: x * 2) assert converted == expected_result globus-globus-sdk-python-6a080e4/tests/unit/test_specific_flows_client.py000066400000000000000000000045331513221403200270120ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.scopes import Scope def test_specific_flow_client_class_errors_on_scope_access(): scopes = globus_sdk.SpecificFlowClient.scopes assert scopes is not None # for the 'user' scope, which is well-defined, we get a special error with pytest.raises(AttributeError) as excinfo: scopes.user err = excinfo.value assert ( "It is not valid to attempt to access the 'scopes' attribute of the " "SpecificFlowClient class." ) in str(err) # but for any other scope we get something a little more generic with pytest.raises(AttributeError) as excinfo: scopes.demuddle err = excinfo.value assert str(err).endswith("has no attribute 'demuddle'") def test_specific_flow_client_class_errors_on_resource_server_access(): scopes = globus_sdk.SpecificFlowClient.scopes assert scopes is not None # access via the scopes object raises an error with pytest.raises(AttributeError) as excinfo: scopes.resource_server err = excinfo.value assert ( "It is not valid to attempt to access the 'resource_server' attribute of the " "SpecificFlowClient class." ) in str(err) # and access via the client class raises the same error with pytest.raises(AttributeError) as excinfo: globus_sdk.SpecificFlowClient.resource_server err = excinfo.value assert ( "It is not valid to attempt to access the 'resource_server' attribute of the " "SpecificFlowClient class." ) in str(err) def test_specific_flow_client_instance_supports_scope_access(): client = globus_sdk.SpecificFlowClient("foo") scopes = client.scopes assert scopes is not None # for the 'user' scope, we get a string user_scope = scopes.user assert isinstance(user_scope, Scope) assert str(user_scope).endswith("flow_foo_user") # but for any other scope we still get the generic attribute error with pytest.raises(AttributeError) as excinfo: scopes.demuddle err = excinfo.value assert str(err).endswith("has no attribute 'demuddle'") def test_specific_flow_client_instance_supports_resource_server_access(): client = globus_sdk.SpecificFlowClient("foo") resource_server = client.resource_server assert isinstance(resource_server, str) assert resource_server == "foo" globus-globus-sdk-python-6a080e4/tests/unit/test_timers_client.py000066400000000000000000000013041513221403200253070ustar00rootroot00000000000000import pytest import globus_sdk def test_create_job_rejects_transfer_timer(): client = globus_sdk.TimersClient() payload = globus_sdk.TransferTimer(schedule={"type": "once"}, body={}) with pytest.raises( globus_sdk.GlobusSDKUsageError, match=r"Cannot pass a TransferTimer to create_job\(\)\.", ): client.create_job(payload) def test_create_timer_rejects_timer_job(): client = globus_sdk.TimersClient() payload = globus_sdk.TimerJob("https://bogus", {}, "2021-01-01T00:00:00Z", 300) with pytest.raises( globus_sdk.GlobusSDKUsageError, match=r"Cannot pass a TimerJob to create_timer\(\)\.", ): client.create_timer(payload) globus-globus-sdk-python-6a080e4/tests/unit/test_utils.py000066400000000000000000000025461513221403200236170ustar00rootroot00000000000000import pytest from globus_sdk._internal.utils import get_nice_hostname, sha256_string, slash_join def test_sha256string(): test_string = "foo" expected_sha = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" assert sha256_string(test_string) == expected_sha @pytest.mark.parametrize( "platform_value, result", ( # platform.node() can return '' when it doesn't know the hostname # turn this into None pytest.param("", None, id="empty-is-none"), # macOS adds '.local' to the user's chosen machine name pytest.param( "VeryCoolMacbook.local", "VeryCoolMacbook", id="remove-local-suffix" ), # the "boring" case is when we do no extra work pytest.param("linux-workstation", "linux-workstation", id="boring"), ), ) def test_get_nice_hostname(platform_value, result, monkeypatch): monkeypatch.setattr("platform.node", lambda: platform_value) assert get_nice_hostname() == result @pytest.mark.parametrize( "a, b", [(a, b) for a in ["a", "a/"] for b in ["b", "/b"]] + [("a/b", c) for c in ["", None]], # type: ignore ) def test_slash_join(a, b): """ slash_joins a's with and without trailing "/" to b's with and without leading "/" Confirms all have the same correct slash_join output """ assert slash_join(a, b) == "a/b" globus-globus-sdk-python-6a080e4/tests/unit/testing/000077500000000000000000000000001513221403200225145ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/testing/test_construct_error.py000066400000000000000000000032311513221403200273610ustar00rootroot00000000000000import pytest import globus_sdk from globus_sdk.testing import construct_error def test_construct_error_defaults_to_base_error_class(): err = construct_error(body="foo", http_status=400) assert isinstance(err, globus_sdk.GlobusAPIError) @pytest.mark.parametrize( "error_class", ( globus_sdk.SearchAPIError, globus_sdk.AuthAPIError, globus_sdk.TransferAPIError, globus_sdk.FlowsAPIError, globus_sdk.GCSAPIError, ), ) def test_construct_error_can_be_customized_to_service_error_classes(error_class): err = construct_error(body="foo", http_status=400, error_class=error_class) assert isinstance(err, error_class) def test_construct_error_defaults_to_json_for_dict_body(): err = construct_error(body={"foo": "bar"}, http_status=400) assert err.text == '{"foo": "bar"}' assert err.headers == {"Content-Type": "application/json"} @pytest.mark.parametrize( "body, add_params, expect_encoding, expect_text", ( (b"foo-bar", {}, "utf-8", "foo-bar"), (b"foo-bar", {"response_encoding": "utf-8"}, "utf-8", "foo-bar"), (b"foo-bar", {"response_encoding": "latin-1"}, "latin-1", "foo-bar"), # this is invalid utf-8 (continuation byte), # but valid in latin-1 (e with acute accent) (b"\xe9", {"response_encoding": "latin-1"}, "latin-1", "é"), ), ) def test_construct_error_allows_binary_content( body, add_params, expect_encoding, expect_text ): err = construct_error(body=body, http_status=400, **add_params) assert err.binary_content == body assert err.text == expect_text assert err._underlying_response.encoding == expect_encoding globus-globus-sdk-python-6a080e4/tests/unit/testing/test_registered_response.py000066400000000000000000000011031513221403200301730ustar00rootroot00000000000000import http import sys import typing as t import pytest from globus_sdk.testing import RegisteredResponse @pytest.mark.skipif( sys.version_info < (3, 11), reason="test requires http.HTTPMethod (Python 3.11+)" ) def test_registered_response_method_literal_type_is_correct(): all_known_methods = [m.value for m in http.HTTPMethod] init_signature = t.get_type_hints(RegisteredResponse.__init__) method_arg_type = init_signature["method"] expected_method_arg_type = t.Literal[tuple(all_known_methods)] assert method_arg_type == expected_method_arg_type globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/000077500000000000000000000000001513221403200235445ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v1/000077500000000000000000000000001513221403200240725ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v1/test_memory_adapter.py000066400000000000000000000037021513221403200305150ustar00rootroot00000000000000import time from unittest import mock from globus_sdk.token_storage.legacy import MemoryAdapter def test_memory_adapter_store_overwrites_only_new_data(): # setup a mock token response expiration_time = int(time.time()) + 3600 mock_response = mock.Mock() mock_response.by_resource_server = { "resource_server_1": { "access_token": "access_token_1", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "bearer", }, "resource_server_2": { "access_token": "access_token_2", "expires_in": expiration_time, "refresh_token": "refresh_token_2", "resource_server": "resource_server_2", "scope": "scope2 scope2:0 scope2:1", "token_type": "bearer", }, } # "store" it in memory adapter = MemoryAdapter() adapter.store(mock_response) # read back a sample piece of data fetch1 = adapter.get_token_data("resource_server_1") assert fetch1["access_token"] == "access_token_1" # "store" a new mock response which overwrites only one piece of data mock_response2 = mock.Mock() mock_response2.by_resource_server = { "resource_server_1": { "access_token": "access_token_1_new", "expires_at_seconds": expiration_time, "refresh_token": "refresh_token_1", "resource_server": "resource_server_1", "scope": "scope1", "token_type": "bearer", } } adapter.store(mock_response2) # the overwritten data is updated fetch2 = adapter.get_token_data("resource_server_1") assert fetch2["access_token"] == "access_token_1_new" # but the existing data is preserved fetch3 = adapter.get_token_data("resource_server_2") assert fetch3["access_token"] == "access_token_2" globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v1/test_simplejson_adapter.py000066400000000000000000000027011513221403200313660ustar00rootroot00000000000000import json import pytest from globus_sdk import __version__ as sdkversion from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter def test_simplejson_reading_bad_data(tmp_path): # non-dict data at root foo_file = tmp_path / "foo.json" foo_file.write_text('["foobar"]') foo_adapter = SimpleJSONFileAdapter(str(foo_file)) with pytest.raises(ValueError, match="Found non-dict root data while loading"): foo_adapter.get_by_resource_server() # non-dict data in 'by_rs' bar_file = tmp_path / "bar.json" bar_file.write_text( json.dumps( {"by_rs": [], "format_version": "1.0", "globus-sdk.version": sdkversion} ) ) bar_adapter = SimpleJSONFileAdapter(str(bar_file)) with pytest.raises(ValueError, match="existing data file is malformed"): bar_adapter.get_by_resource_server() def test_simplejson_reading_unsupported_format_version(tmp_path): # data appears valid, but lists a value for "format_version" which instructs the # adapter explicitly that it is in a format which is unknown / not supported foo_file = tmp_path / "foo.json" foo_file.write_text( json.dumps( {"by_rs": {}, "format_version": "0.0", "globus-sdk.version": sdkversion} ) ) adapter = SimpleJSONFileAdapter(str(foo_file)) with pytest.raises(ValueError, match="existing data file is in an unknown format"): adapter.get_by_resource_server() globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v1/test_sqlite_adapter.py000066400000000000000000000022651513221403200305110ustar00rootroot00000000000000import pytest from globus_sdk.token_storage.legacy import SQLiteAdapter def test_sqlite_reading_bad_config(): adapter = SQLiteAdapter(":memory:") # inject bad data (array, needs to be dict) # store_config does not check the input type, just uses json.dumps() adapter.store_config("foo_conf", []) with pytest.raises(ValueError, match="reading config data and got non-dict result"): adapter.read_config("foo_conf") adapter.close() def test_sqlite_reading_bad_token_data(): adapter = SQLiteAdapter(":memory:") # inject bad data (array, needs to be dict) adapter._connection.execute( """\ INSERT INTO token_storage(namespace, resource_server, token_data_json) VALUES (?, ?, ?)""", (adapter.namespace, "foo_rs", "[]"), ) with pytest.raises( ValueError, match="data error: token data was not saved as a dict" ): adapter.get_token_data("foo_rs") adapter.close() def test_sqliteadapter_passes_connect_params(): with pytest.raises(TypeError): SQLiteAdapter(":memory:", connect_params={"invalid_kwarg": True}) adapter = SQLiteAdapter(":memory:", connect_params={"timeout": 10}) adapter.close() globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v2/000077500000000000000000000000001513221403200240735ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v2/test_token_storage.py000066400000000000000000000032111513221403200303450ustar00rootroot00000000000000import pytest from globus_sdk import GlobusSDKUsageError from globus_sdk.token_storage.base import _slugify_app_name @pytest.mark.parametrize( "app_name, expected_slug", ( ("Globus CLI v3.30.0", "globus-cli-v3-30-0"), ("my-cool-app", "my-cool-app"), ("jonathan", "jonathan"), ), ) def test_app_name_slugification_of_basic_app_names(app_name, expected_slug): actual_slug = _slugify_app_name(app_name) assert actual_slug == expected_slug def test_app_name_slugification_replaces_reserved_characters(): app_name = 'glob<>:"/\\|?*us' expected = "glob+++++++++us" actual_slug = _slugify_app_name(app_name) assert actual_slug == expected def test_app_name_slugification_removes_control_characters(): app_name = "glob\0\n\t\rus" expected = "globus" actual_slug = _slugify_app_name(app_name) assert actual_slug == expected def test_app_name_slugification_rejects_empty_name(): with pytest.raises(GlobusSDKUsageError, match="name results in the empty string"): _slugify_app_name("\n\t\r") @pytest.mark.parametrize("app_name", ("CON", "PRN\n", "nul", "LPT9", "COM1")) def test_app_name_slugification_rejects_reserved_names(app_name): # # https://stackoverflow.com/a/31976060 with pytest.raises(GlobusSDKUsageError, match="reserved filename"): _slugify_app_name(app_name) def test_app_name_allows_reserved_name_prefixes(): # Prefix of app name ("CON") is reserved, but the slugified name is not. app_name = "Continuous Deployment App" expected = "continuous-deployment-app" actual_slug = _slugify_app_name(app_name) assert actual_slug == expected globus-globus-sdk-python-6a080e4/tests/unit/tokenstorage/v2/test_validating_token_storage.py000066400000000000000000000300641513221403200325550ustar00rootroot00000000000000from __future__ import annotations import random import string import uuid from unittest.mock import Mock import pytest import globus_sdk from globus_sdk import ( MISSING, MissingType, OAuthRefreshTokenResponse, OAuthTokenResponse, Scope, ) from globus_sdk.scopes.consents import ConsentForest from globus_sdk.token_storage import ( MemoryTokenStorage, ScopeRequirementsValidator, UnchangingIdentityIDValidator, ValidatingTokenStorage, ) from globus_sdk.token_storage.validating_token_storage import ( IdentityMismatchError, MissingIdentityError, MissingTokenError, UnmetScopeRequirementsError, ) from tests.common import make_consent_forest def _make_memstorage_with_scope_validator(consent_client, scope_requirements): scope_validator = ScopeRequirementsValidator(scope_requirements, consent_client) return ValidatingTokenStorage(MemoryTokenStorage(), validators=(scope_validator,)) def _make_memstorage_with_identity_id_validator(): identity_validator = UnchangingIdentityIDValidator() return ValidatingTokenStorage( MemoryTokenStorage(), validators=(identity_validator,) ) def test_validating_token_storage_passes_calls_through(): # test that the major methods are called on the inner storage: # - get_token_data # - get_token_data_by_resource_server # - remove_token_data mock_storage = Mock() mock_storage.get_token_data_by_resource_server.return_value = {} instance = ValidatingTokenStorage(mock_storage) mock_storage.get_token_data.assert_not_called() instance.get_token_data("foo") mock_storage.get_token_data.assert_called_once_with("foo") mock_storage.remove_token_data.assert_not_called() instance.remove_token_data("foo") mock_storage.remove_token_data.assert_called_once_with("foo") # small subtlety here: # get_token_data_by_resource_server gets called once on init # so check that after we invoke it, the call count is 2 mock_storage.get_token_data_by_resource_server.assert_called_once() instance.get_token_data_by_resource_server() assert len(mock_storage.get_token_data_by_resource_server.mock_calls) == 2 def test_validating_token_storage_defaults_to_no_validators(): # basic contract test -- there should be no "out of the box" validators instance = ValidatingTokenStorage(MemoryTokenStorage()) assert len(instance.validators) == 0 def test_validating_token_storage_initially_calls_validators_with_null_identity_id( make_token_response, ): # assuming there's no initial token data in storage, on the initial store call # there will be an identity_id in the token data but not in the "prior" data slot mock_validator = Mock() instance = ValidatingTokenStorage( MemoryTokenStorage(), validators=(mock_validator,) ) identity_id = str(uuid.uuid4()) instance.store_token_response(make_token_response(identity_id=identity_id)) mock_validator.before_store.assert_called_once() call_args = mock_validator.before_store.call_args assert len(call_args.kwargs) == 0 assert len(call_args.args) == 2 token_data, context = call_args.args assert context.prior_identity_id is None assert context.token_data_identity_id == identity_id def test_validators_are_invoked_even_when_retrieving_empty_data(): # `get_token-data_by_resource_server` will invoke validators, even if there is no # data available mock_validator = Mock() instance = ValidatingTokenStorage( MemoryTokenStorage(), validators=(mock_validator,) ) data = instance.get_token_data_by_resource_server() assert data == {} mock_validator.after_retrieve.assert_called_once() def test_validating_token_storage_evaluates_identity_requirements(make_token_response): id_a, id_b = str(uuid.uuid4()), str(uuid.uuid4()) adapter = _make_memstorage_with_identity_id_validator() # Seed the adapter with an initial identity. assert adapter.identity_id is None adapter.store_token_response(make_token_response(identity_id=id_a)) assert adapter.identity_id == id_a # We should be able to store a token with the same identity. adapter.store_token_response(make_token_response(identity_id=id_a)) # We should not be able to store a token with a different identity. with pytest.raises(IdentityMismatchError): adapter.store_token_response(make_token_response(identity_id=id_b)) def test_validating_token_storage_evaluates_root_scope_requirements( make_token_response, ): adapter = _make_memstorage_with_scope_validator( consent_client, {"rs1": [Scope.parse("scope1")]} ) identity_id = str(uuid.uuid4()) valid_token_response = make_token_response( scopes={"rs1": "scope1"}, identity_id=identity_id ) invalid_token_response = make_token_response( scopes={"rs1": "scope2"}, identity_id=identity_id ) adapter.store_token_response(valid_token_response) with pytest.raises(UnmetScopeRequirementsError): adapter.store_token_response(invalid_token_response) assert ( adapter.get_token_data("rs1").access_token == valid_token_response.by_resource_server["rs1"]["access_token"] ) def test_storage_with_scope_validator_evaluates_dependent_scope_requirements( make_token_response, consent_client ): adapter = _make_memstorage_with_scope_validator( consent_client, {"rs1": [Scope.parse("scope[subscope]")]} ) token_response = make_token_response(scopes={"rs1": "scope"}) adapter.store_token_response(token_response) consent_client.mocked_forest = make_consent_forest("scope[different_subscope]") with pytest.raises(UnmetScopeRequirementsError): adapter.get_token_data("rs1") consent_client.mocked_forest = make_consent_forest("scope[subscope]") adapter.store_token_response(token_response) assert ( adapter.get_token_data("rs1").access_token == token_response.by_resource_server["rs1"]["access_token"] ) def test_validating_token_storage_fails_non_identifiable_responses( make_token_response, ): adapter = _make_memstorage_with_identity_id_validator() token_response = make_token_response(identity_id=None) with pytest.raises(MissingIdentityError): adapter.store_token_response(token_response) def test_validating_token_storage_loads_identity_info_from_storage( make_token_response, ): # Create an in memory storage adapter storage = MemoryTokenStorage() adapter = ValidatingTokenStorage(storage) # Store an identifiable token response identity_id = str(uuid.uuid4()) token_response = make_token_response(identity_id=identity_id) adapter.store_token_response(token_response) # Create a net new adapter, pointing at the same storage. new_adapter = ValidatingTokenStorage(storage) # Verify that the new adapter loads the identity info from storage. assert new_adapter.identity_id == identity_id def test_validating_token_storage_stores_with_saved_identity_id_on_refresh_tokens( make_token_response, ): # Create an in memory storage adapter with identity_id verification adapter = _make_memstorage_with_identity_id_validator() # Store an identifiable token response identity_id = str(uuid.uuid4()) token_response = make_token_response(identity_id=identity_id) adapter.store_token_response(token_response) # now get and store a replacement token response, identified with a different user # however, in this case make it a refresh token response other_identity_id = str(uuid.uuid4()) refresh_token_response = make_token_response( response_class=OAuthRefreshTokenResponse, identity_id=other_identity_id ) adapter.store_token_response(refresh_token_response) # read back the data, and verify that it contains tokens from the refresh, but the # original identity_id result = adapter.get_token_data("auth.globus.org") assert result.access_token == refresh_token_response["access_token"] assert result.identity_id == identity_id def test_validating_token_storage_raises_error_when_no_token_data(): adapter = ValidatingTokenStorage(MemoryTokenStorage()) with pytest.raises(MissingTokenError): adapter.get_token_data("rs1") @pytest.fixture def make_token_response(make_response): def _make_token_response( scopes: dict[str, str] | None = None, identity_id: str | None | MissingType = MISSING, response_class: type[OAuthTokenResponse] = OAuthTokenResponse, ): """ :param scopes: A dictionary of resource server to scope mappings to fill in other tokens. :param identity_id: The identity ID to use in the ID token. If None, no ID token will be included in the response. If MISSING, the ID token will be generated with a random identity ID. """ if scopes is None: scopes = {} auth_scopes = "openid" if "auth.globus.org" in scopes: auth_scopes = scopes.pop("auth.globus.org") if "openid" not in auth_scopes: auth_scopes = f"openid {auth_scopes}" data = { "access_token": _make_access_token(), "expires_in": 172800, "other_tokens": [ { "access_token": _make_access_token(), "expires_in": 172800, "resource_server": resource_server, "scope": scope, "token_type": "Bearer", } for resource_server, scope in scopes.items() ], "resource_server": "auth.globus.org", "scope": auth_scopes, "token_type": "Bearer", } if identity_id is not None: # We'll be mocking out the decode_id_token method, so this doesn't need to # be a real JWT ID token. data["id_token"] = _make_id_token() response = make_response(response_class=response_class, json_body=data) if identity_id is not None: decoded_id_token = _decoded_id_token(identity_id) response.decode_id_token = lambda: decoded_id_token return response return _make_token_response def _decoded_id_token( identity_id: str | MissingType = MISSING, ): if identity_id is MISSING: identity_id = str(uuid.uuid4()) identity_provider = str(uuid.uuid4()) aud = str(uuid.uuid4()) return { "at_hash": "RcNb88Asztn-GnRg_0ojS0sSJs1T8YWeYmVkLp7YhdQ", "aud": aud, "email": None, "exp": 1712945792, "iat": 1712772992, "identity_provider": identity_provider, "identity_provider_display_name": "Globus Auth", "identity_set": [ { "email": None, "identity_provider": identity_provider, "identity_provider_display_name": "Globus Auth", "last_authentication": None, "name": "Pete Sampras", "sub": identity_id, "username": "kingoftennis@gmail.com", } ], "iss": "https://auth.globus.org", "last_authentication": None, "name": "Pete Sampras", "preferred_username": "kingoftennis@gmail.com", "sub": identity_id, } def _make_access_token(): vocab = string.ascii_letters + string.digits return "".join(random.choices(vocab, k=91)) def _make_id_token(): return "".join(random.choices(string.ascii_letters, k=1000)) @pytest.fixture def consent_client() -> MockedConsentClient: client = Mock(spec=MockedConsentClient) client.mocked_forest = None get_consents_response_mock = Mock(spec=globus_sdk.GetConsentsResponse) def retrieve_mocked_forest(): if client.mocked_forest is None: raise ValueError("No mocked_forest has been set on the client") return client.mocked_forest get_consents_response_mock.to_forest.side_effect = retrieve_mocked_forest client.get_consents.return_value = get_consents_response_mock return client class MockedConsentClient(globus_sdk.AuthClient): mocked_forest: ConsentForest | None globus-globus-sdk-python-6a080e4/tests/unit/transport/000077500000000000000000000000001513221403200230735ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/transport/__init__.py000066400000000000000000000000001513221403200251720ustar00rootroot00000000000000globus-globus-sdk-python-6a080e4/tests/unit/transport/test_clientinfo.py000066400000000000000000000075141513221403200266450ustar00rootroot00000000000000import pytest from globus_sdk import __version__ from globus_sdk.transport import GlobusClientInfo def make_empty_clientinfo(): # create a clientinfo with no contents, as a starting point for tests obj = GlobusClientInfo() obj.clear() return obj def parse_clientinfo(header): """ Sample parser. Including this in the testsuite not only validates the mechanical implementation of X-Globus-Client-Info, but also acts as a safety check that we've thought through the ability of consumers to parse this data. """ mappings = {} for segment in header.split(";"): segment_dict = {} segment = segment.strip() elements = segment.split(",") for element in elements: if "=" not in element: raise ValueError( f"Bad X-Globus-Client-Info element: '{element}' in '{header}'" ) key, _, value = element.partition("=") if "=" in value: raise ValueError( f"Bad X-Globus-Client-Info element: '{element}' in '{header}'" ) if key in segment_dict: raise ValueError( f"Bad X-Globus-Client-Info element: '{element}' in '{header}'" ) segment_dict[key] = value if "product" not in segment_dict: raise ValueError( "Bad X-Globus-Client-Info segment missing product: " f"'{segment}' in '{header}'" ) product = segment_dict["product"] if product in mappings: raise ValueError( "Bad X-Globus-Client-Info header repeats product: " f"'{product}' in '{header}'" ) mappings[product] = segment_dict return mappings def test_clientinfo_bool(): # base clientinfo starts with the SDK version and should bool true info = GlobusClientInfo() assert bool(info) is True # but we can clear it and it will bool False info.infos = [] assert bool(info) is False @pytest.mark.parametrize( "value, expect_str", ( ("x=y", "x=y"), ("x=y,omicron=iota", "x=y,omicron=iota"), ({"x": "y"}, "x=y"), ({"x": "y", "alpha": "b01"}, "x=y,alpha=b01"), ), ) def test_format_of_simple_item(value, expect_str): info = make_empty_clientinfo() info.add(value) assert info.format() == expect_str @pytest.mark.parametrize( "values, expect_str", ( (("x=y",), "x=y"), (("x=y", "alpha=b01,omicron=iota"), "x=y;alpha=b01,omicron=iota"), ), ) def test_format_of_multiple_items(values, expect_str): info = make_empty_clientinfo() for value in values: info.add(value) assert info.format() == expect_str def test_clientinfo_parses_as_expected(): info = GlobusClientInfo() info.add("alpha=b01,product=my-cool-tool") header_str = info.format() parsed = parse_clientinfo(header_str) assert parsed == { "python-sdk": { "product": "python-sdk", "version": __version__, }, "my-cool-tool": { "product": "my-cool-tool", "alpha": "b01", }, } def test_client_info_can_write_back_via_callback(): myvalue = "" def onupdate(info): nonlocal myvalue myvalue = info.format() info = GlobusClientInfo(update_callback=onupdate) # initializing with the callback does not make it fire # the value is unchanged assert myvalue == "" segment = "version=1.0.1,product=my-cool-tool" # now, add something and make sure it rendered back into the value # (along with python-sdk info) info.add(segment) # our new segment is visible assert segment in myvalue # but other values (the default, python-sdk version!) are also there assert myvalue != segment globus-globus-sdk-python-6a080e4/tests/unit/transport/test_default_retry_policy.py000066400000000000000000000061751513221403200307450ustar00rootroot00000000000000from unittest import mock import pytest from globus_sdk.transport import ( RequestCallerInfo, RequestsTransport, RetryCheckResult, RetryCheckRunner, RetryConfig, RetryContext, ) from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, check_retry_after_header, check_transient_error, ) @pytest.mark.parametrize("http_status", (429, 503)) def test_retry_policy_respects_retry_after(mocksleep, http_status): retry_config = RetryConfig() retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) transport = RequestsTransport() checker = RetryCheckRunner(retry_config.checks) dummy_response = mock.Mock() dummy_response.headers = {"Retry-After": "5"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() transport._retry_sleep(retry_config, ctx) mocksleep.assert_called_once_with(5) @pytest.mark.parametrize("http_status", (429, 503)) def test_retry_policy_ignores_retry_after_too_high(mocksleep, http_status): # set explicit max sleep to confirm that the value is capped here retry_config = RetryConfig(max_sleep=5) retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) transport = RequestsTransport() checker = RetryCheckRunner(retry_config.checks) dummy_response = mock.Mock() dummy_response.headers = {"Retry-After": "20"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() transport._retry_sleep(retry_config, ctx) mocksleep.assert_called_once_with(5) @pytest.mark.parametrize("http_status", (429, 503)) def test_retry_policy_ignores_malformed_retry_after(mocksleep, http_status): retry_config = RetryConfig() retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) transport = RequestsTransport() checker = RetryCheckRunner(retry_config.checks) dummy_response = mock.Mock() dummy_response.headers = {"Retry-After": "not-an-integer"} dummy_response.status_code = http_status caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() transport._retry_sleep(retry_config, ctx) mocksleep.assert_called_once() @pytest.mark.parametrize( "check_method", [check_retry_after_header, check_transient_error], ids=lambda f: f.__name__, ) def test_default_retry_check_noop_on_exception(check_method, mocksleep): retry_config = RetryConfig() retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, exception=Exception("foo")) assert check_method(ctx) is RetryCheckResult.no_decision globus-globus-sdk-python-6a080e4/tests/unit/transport/test_retry_check_runner.py000066400000000000000000000030661513221403200304040ustar00rootroot00000000000000from unittest import mock from globus_sdk.transport import ( RequestCallerInfo, RetryCheckResult, RetryCheckRunner, RetryConfig, RetryContext, ) from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS def _make_test_retry_context(*, status=200, exception=None, response=None): retry_config = RetryConfig() retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) caller_info = RequestCallerInfo(retry_config=retry_config) if exception: return RetryContext(1, caller_info=caller_info, exception=exception) elif response: return RetryContext(1, caller_info=caller_info, response=response) dummy_response = mock.Mock() dummy_response.status_code = 200 return RetryContext(1, caller_info=caller_info, response=dummy_response) def test_retry_check_runner_should_retry_explicit_on_first_check(): def check1(ctx): return RetryCheckResult.do_not_retry def check2(ctx): return RetryCheckResult.do_retry failing_checker = RetryCheckRunner([check1, check2]) assert failing_checker.should_retry(_make_test_retry_context()) is False passing_checker = RetryCheckRunner([check2, check1]) assert passing_checker.should_retry(_make_test_retry_context()) is True def test_retry_check_runner_fallthrough_to_false(): def check1(ctx): return RetryCheckResult.no_decision def check2(ctx): return RetryCheckResult.no_decision checker = RetryCheckRunner([check1, check2]) assert checker.should_retry(_make_test_retry_context()) is False globus-globus-sdk-python-6a080e4/tests/unit/transport/test_transfer_transport.py000066400000000000000000000060421513221403200304460ustar00rootroot00000000000000from unittest import mock from globus_sdk.services.transfer.transport import TRANSFER_DEFAULT_RETRY_CHECKS from globus_sdk.transport import ( RequestCallerInfo, RetryCheckCollection, RetryCheckRunner, RetryConfig, RetryContext, ) from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS def test_transfer_only_replaces_checks(): # their length matches, meaning things line up assert len(TRANSFER_DEFAULT_RETRY_CHECKS) == len(DEFAULT_RETRY_CHECKS) # also confirm that this holds once loaded # if the implementation of the RetryCheckCollection becomes sensitive to # the contents of these tuples, this could fail default_variant = RetryCheckCollection() default_variant.register_many_checks(DEFAULT_RETRY_CHECKS) transfer_variant = RetryCheckCollection() transfer_variant.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) assert len(default_variant) == len(transfer_variant) def test_transfer_does_not_retry_external(): retry_config = RetryConfig() retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) checker = RetryCheckRunner(retry_config.checks) body = { "HTTP status": "502", "code": "ExternalError.DirListingFailed.GCDisconnected", "error_name": "Transfer API Error", "message": "The GCP endpoint is not currently connected to Globus", "request_id": "rhvcR0aHX", } dummy_response = mock.Mock() dummy_response.json = lambda: body dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is False def test_transfer_does_not_retry_endpoint_error(): retry_config = RetryConfig() retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) checker = RetryCheckRunner(retry_config.checks) body = { "HTTP status": "502", "code": "EndpointError", "error_name": "Transfer API Error", "message": ( "This GCSv5 is older than version 5.4.62 and does not support local user " "selection" ), "request_id": "istNh0Zpz", } dummy_response = mock.Mock() dummy_response.json = lambda: body dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is False def test_transfer_retries_others(): retry_config = RetryConfig() retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) checker = RetryCheckRunner(retry_config.checks) def _raise_value_error(): raise ValueError() dummy_response = mock.Mock() dummy_response.json = _raise_value_error dummy_response.status_code = 502 caller_info = RequestCallerInfo(retry_config=retry_config) ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True globus-globus-sdk-python-6a080e4/tests/unit/transport/test_transport.py000066400000000000000000000115551513221403200265470ustar00rootroot00000000000000import pathlib from unittest import mock import pytest from globus_sdk.transport import RequestsTransport, RetryConfig, RetryContext from globus_sdk.transport.retry_config import _exponential_backoff def _linear_backoff(ctx: RetryContext) -> float: if ctx.backoff is not None: return ctx.backoff return 0.5 * (2**ctx.attempt) ca_bundle_file = pathlib.Path(__file__).parent.parent.absolute() / "CA-Bundle.cert" ca_bundle_directory = ca_bundle_file.parent ca_bundle_non_existent = ca_bundle_directory / "bogus.bogus" @pytest.mark.parametrize( "param_name, init_value, tune_value", [ ("verify_ssl", True, True), ("verify_ssl", True, False), ("verify_ssl", True, ca_bundle_file), ("verify_ssl", True, str(ca_bundle_file)), ("verify_ssl", True, ca_bundle_directory), ("verify_ssl", True, str(ca_bundle_directory)), ("verify_ssl", True, ca_bundle_non_existent), ("verify_ssl", True, str(ca_bundle_non_existent)), ("http_timeout", 60, 120), ], ) def test_transport_tuning(param_name, init_value, tune_value): expected_value = ( str(tune_value) if isinstance(tune_value, pathlib.Path) else tune_value ) init_kwargs = {param_name: init_value} transport = RequestsTransport(**init_kwargs) assert getattr(transport, param_name) == init_value tune_kwargs = {param_name: tune_value} with transport.tune(**tune_kwargs): assert getattr(transport, param_name) == expected_value assert getattr(transport, param_name) == init_value @pytest.mark.parametrize( "param_name, init_value, tune_value", [ ("backoff", _exponential_backoff, _linear_backoff), ("max_sleep", 10, 10), ("max_sleep", 10, 1), ("max_retries", 0, 5), ("max_retries", 10, 0), ], ) def test_retry_tuning(param_name, init_value, tune_value): init_kwargs = {param_name: init_value} config = RetryConfig(**init_kwargs) assert getattr(config, param_name) == init_value tune_kwargs = {param_name: tune_value} with config.tune(**tune_kwargs): assert getattr(config, param_name) == tune_value assert getattr(config, param_name) == init_value def test_transport_can_manipulate_user_agent(): transport = RequestsTransport() # starting state -- the user agent is visible and equivalent in two places assert "User-Agent" in transport.headers assert transport.headers["User-Agent"] == transport.user_agent # setting it slash-appends it to the base agent (in both places) transport.user_agent = "frobulator-1.0" assert ( transport.headers["User-Agent"] == f"{transport.BASE_USER_AGENT}/frobulator-1.0" ) assert transport.user_agent == f"{transport.BASE_USER_AGENT}/frobulator-1.0" # deleting it doesn't clear the property # but accessing the property doesn't add it back into the dict del transport.headers["User-Agent"] assert transport.user_agent == f"{transport.BASE_USER_AGENT}/frobulator-1.0" assert "User-Agent" not in transport.headers # but updating it will add it back transport.user_agent = "demuddler-2.2" assert ( transport.headers["User-Agent"] == f"{transport.BASE_USER_AGENT}/demuddler-2.2" ) assert transport.user_agent == f"{transport.BASE_USER_AGENT}/demuddler-2.2" def test_transport_can_manipulate_client_info(): transport = RequestsTransport() # starting state -- the clientinfo is visible in headers # and a fake product is not assert "X-Globus-Client-Info" in transport.headers assert "frobulator" not in transport.headers["X-Globus-Client-Info"] # we can add said product to the header transport.globus_client_info.add({"product": "frobulator", "version": "1.0"}) assert "product=frobulator,version=1.0" in transport.headers["X-Globus-Client-Info"] # clearing the client info removes the header transport.globus_client_info.clear() assert "X-Globus-Client-Info" not in transport.headers # adding a product re-adds the header transport.globus_client_info.add({"product": "demuddler", "version": "8.8"}) assert "product=demuddler,version=8.8" in transport.headers["X-Globus-Client-Info"] def test_double_clear_of_client_info_is_allowed(): # clear the client info twice -- catching any potential bugs with `del ...` or # similar assumptions that the value is present transport = RequestsTransport() transport.globus_client_info.clear() transport.globus_client_info.clear() assert "X-Globus-Client-Info" not in transport.headers def test_transport_close_closes_session(): # there's no easy way to check if a real requests.Session object is closed, # so just check that `close()` was called transport = RequestsTransport() with mock.patch.object(transport, "session") as mocked_session: transport.close() mocked_session.close.assert_called_once_with() globus-globus-sdk-python-6a080e4/tests/unit/transport/test_transport_authz_handling.py000066400000000000000000000042331513221403200316210ustar00rootroot00000000000000from unittest import mock import pytest from globus_sdk.authorizers import NullAuthorizer from globus_sdk.transport import RequestCallerInfo, RequestsTransport, RetryConfig def test_will_not_modify_authz_header_without_authorizer(): request = mock.Mock() request.headers = {} transport = RequestsTransport() transport._set_authz_header(None, request) assert request.headers == {} request.headers["Authorization"] = "foo bar" transport._set_authz_header(None, request) assert request.headers == {"Authorization": "foo bar"} def test_will_null_authz_header_with_null_authorizer(): request = mock.Mock() request.headers = {} transport = RequestsTransport() transport._set_authz_header(NullAuthorizer(), request) assert request.headers == {} request.headers["Authorization"] = "foo bar" transport._set_authz_header(NullAuthorizer(), request) assert request.headers == {} def test_requests_transport_accepts_caller_info(): retry_config = RetryConfig() transport = RequestsTransport() mock_authorizer = mock.Mock() mock_authorizer.get_authorization_header.return_value = "Bearer token" caller_info = RequestCallerInfo( retry_config=retry_config, authorizer=mock_authorizer ) with mock.patch.object(transport, "session") as mock_session: mock_response = mock.Mock(status_code=200) mock_session.send.return_value = mock_response response = transport.request( "GET", "https://example.com", caller_info=caller_info ) assert response.status_code == 200 sent_request = mock_session.send.call_args[0][0] assert sent_request.headers["Authorization"] == "Bearer token" def test_requests_transport_caller_info_required(): transport = RequestsTransport() with pytest.raises(TypeError): transport.request("GET", "https://example.com") def test_requests_transport_keyword_only(): retry_config = RetryConfig() transport = RequestsTransport() caller_info = RequestCallerInfo(retry_config=retry_config) with pytest.raises(TypeError): transport.request("GET", "https://example.com", caller_info) globus-globus-sdk-python-6a080e4/tests/unit/transport/test_transport_encoders.py000066400000000000000000000114021513221403200304200ustar00rootroot00000000000000import uuid import pytest from globus_sdk import MISSING from globus_sdk._payload import GlobusPayload from globus_sdk.transport import FormRequestEncoder, JSONRequestEncoder, RequestEncoder @pytest.mark.parametrize("data", ("foo", b"bar")) def test_text_request_encoder_accepts_string_data(data): encoder = RequestEncoder() request = encoder.encode( "GET", "http://bogus/foo", data=data, params={}, headers={}, ) assert request.data == data @pytest.mark.parametrize( "encoder_class", [FormRequestEncoder, JSONRequestEncoder, RequestEncoder], ) def test_all_request_encoders_stringify_uuids_in_params_and_headers(encoder_class): # ensure the different classes get data which they will find palatable data = "bar" if encoder_class is RequestEncoder else {"bar": "baz"} id_ = uuid.uuid1() id_str = str(id_) encoder = encoder_class() request = encoder.encode( "GET", "http://bogus/foo", params={"id": id_}, data=data, headers={"X-UUID": id_}, ) assert request.params["id"] == id_str assert request.headers["X-UUID"] == id_str @pytest.mark.parametrize( "encoder_class", [FormRequestEncoder, JSONRequestEncoder, RequestEncoder], ) def test_all_request_encoders_remove_missing_in_params_and_headers(encoder_class): # ensure the different classes get data which they will find palatable data = "bar" if encoder_class is RequestEncoder else {"bar": "baz"} encoder = encoder_class() request = encoder.encode( "GET", "http://bogus/foo", params={"foo": "foo", "bar": MISSING}, data=data, headers={ "X-FOO": "foo", "X-BAR": MISSING, }, ) assert request.params == {"foo": "foo"} if encoder_class is JSONRequestEncoder: assert request.headers == {"X-FOO": "foo", "Content-Type": "application/json"} else: assert request.headers == {"X-FOO": "foo"} @pytest.mark.parametrize( "using_payload_type, payload_contents, expected_data", [ # basic dicts (False, {"foo": 1}, {"foo": 1}), (True, {"foo": 1}, {"foo": 1}), # containing a UUID (stringifies) (True, {"foo": uuid.UUID(int=1)}, {"foo": str(uuid.UUID(int=1))}), (False, {"foo": uuid.UUID(int=1)}, {"foo": str(uuid.UUID(int=1))}), # containing MISSING (removes) (True, {"foo": MISSING}, {}), (False, {"foo": MISSING}, {}), # nested payload wrappers (get dictified / "unwrapped") ( True, {"bar": GlobusPayload(foo=1), "baz": [2, GlobusPayload(foo=1)]}, {"bar": {"foo": 1}, "baz": [2, {"foo": 1}]}, ), # document with UUIDs and tuples buried inside nested structures ( True, {"foo": ({"bar": uuid.UUID(int=2)},)}, {"foo": [{"bar": str(uuid.UUID(int=2))}]}, ), ], ) def test_json_encoder_payload_preparation( using_payload_type, payload_contents, expected_data ): encoder = JSONRequestEncoder() x = GlobusPayload() if using_payload_type else {} for k, v in payload_contents.items(): x[k] = v request = encoder.encode( "POST", "http://bogus/foo", params={}, data=x, headers={}, ) assert request.json == expected_data def test_json_encoder_is_well_defined_on_array_containing_missing(): # this is a test for a behavior which is publicly undefined # (we do not advertise or encourage its use) # # it ensures that we will not crash and will behave "reasonably" in this situation encoder = JSONRequestEncoder() x = [None, 1, MISSING, 0] request = encoder.encode( "POST", "http://bogus/foo", params={}, data=x, headers={}, ) assert request.json == [None, 1, 0] @pytest.mark.parametrize( "using_payload_type, payload_contents, expected_data", [ # basic dicts (True, {"foo": 1}, {"foo": 1}), (False, {"foo": 1}, {"foo": 1}), # containing a UUID (stringifies) (True, {"foo": uuid.UUID(int=1)}, {"foo": str(uuid.UUID(int=1))}), (False, {"foo": uuid.UUID(int=1)}, {"foo": str(uuid.UUID(int=1))}), # containing MISSING (removes) (True, {"foo": MISSING}, {}), (False, {"foo": MISSING}, {}), ], ) def test_form_encoder_payload_preparation( using_payload_type, payload_contents, expected_data ): encoder = FormRequestEncoder() x = GlobusPayload() if using_payload_type else {} for k, v in payload_contents.items(): x[k] = v request = encoder.encode( "POST", "http://bogus/foo", params={}, data=x, headers={}, ) assert request.data == expected_data globus-globus-sdk-python-6a080e4/tox.ini000066400000000000000000000105521513221403200202340ustar00rootroot00000000000000[tox] envlist = lint mypy pylint test-lazy-imports coverage_clean py{3.14,3.13,3.12,3.11,3.10,3.9} py3.9-mindeps py3.11-sphinxext coverage_report docs minversion = 4.22.0 labels = freezedeps = freezedeps-print,freezedeps-py{3.14,3.13,3.12,3.11,3.10,3.9} [testenv] # build a wheel, not a tarball, and use a common env to do it (so that the wheel is shared) package = wheel wheel_build_env = build_wheel deps = !mindeps: -r requirements/py{py_dot_ver}/test.txt mindeps: -r requirements/py{py_dot_ver}/test-mindeps.txt sphinxext: -r requirements/py{py_dot_ver}/docs.txt commands = coverage run -m pytest {posargs} depends = py{3.14,3.13,3.12,3.11,3.10,3.9}{-mindeps,-sphinxext,}: coverage_clean, lint coverage_report: py{3.14,3.13,3.12,3.11,3.10,3.9}{-mindeps,-sphinxext,} [testenv:coverage_clean] dependency_groups = coverage skip_install = true commands = coverage erase [testenv:coverage_report] dependency_groups = coverage skip_install = true commands_pre = -coverage combine commands = coverage report --skip-covered [testenv:lint] deps = pre-commit skip_install = true commands = pre-commit run --all-files [testenv:mypy,mypy-{py3.9,py3.14}] deps = -r requirements/py{py_dot_ver}/typing.txt commands = mypy src/ {posargs} [testenv:mypy-test] base = mypy commands = mypy --show-error-codes --warn-unused-ignores tests/non-pytest/mypy-ignore-tests/ [testenv:test-lazy-imports] deps = -r requirements/py{py_dot_ver}/test.txt commands = pytest -n auto tests/non-pytest/lazy-imports/ pytest tests/unit/test_lazy_imports.py [testenv:benchmark] deps = -r requirements/py{py_dot_ver}/test.txt pytest-benchmark commands = pytest tests/benchmark/ {posargs} [testenv:pylint,pylint-{py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}] deps = pylint commands = pylint {posargs:src/} [testenv:pyright] deps = pyright commands = pyright src/ {posargs} [testenv:docs] # force use of py3.11 for doc builds so that we get the same behaviors as the # readthedocs doc build basepython = python3.11 deps = -r requirements/py{py_dot_ver}/docs.txt # clean the build dir before rebuilding globus_sdk_rmtree = docs/_build changedir = docs/ commands = sphinx-build -j auto -d _build/doctrees -b html -W . _build/html {posargs} [testenv:twine-check] skip_install = true deps = build twine!=5.1.0 globus_sdk_rmtree = dist # check that twine validating package data works commands = python -m build twine check --strict dist/* [testenv:poetry-check] skip_install = true deps = poetry # remove the dist dir because it can lead to (confusing) spurious failures globus_sdk_rmtree = dist # use `poetry lock` to ensure that poetry can parse our dependencies changedir = tests/non-pytest/poetry-lock-test commands = poetry lock [testenv:freezedeps-print] description = print dependency-groups to temporary files for use in pip-compile skip_install = true deps = dependency-groups>=1,<2 commands = python -m dependency_groups test -o requirements/.test.in python -m dependency_groups typing -o requirements/.typing.in python -m dependency_groups test-mindeps -o requirements/.test-mindeps.in python -m dependency_groups docs -o requirements/.docs.in [testenv:freezedeps-py{3.14,3.13,3.12,3.11,3.10,3.9}] description = freeze development dependencies using pip-compile skip_install = true setenv = CUSTOM_COMPILE_COMMAND=tox p -m freezedeps change_dir = requirements/ deps = pip-tools commands = pip-compile --strip-extras -q -U --resolver=backtracking .test.in -o py{py_dot_ver}/test.txt pip-compile --strip-extras -q -U --resolver=backtracking .typing.in -o py{py_dot_ver}/typing.txt # Minimum dependencies are only tested against the lowest supported Python version. py3.9: pip-compile --strip-extras -q -U --resolver=backtracking .test-mindeps.in -o py{py_dot_ver}/test-mindeps.txt # The docs requirements are only generated for Python 3.11. py3.11: pip-compile --strip-extras -q -U --resolver=backtracking .docs.in -o py{py_dot_ver}/docs.txt depends = freezedeps-print [testenv:check-min-python-is-tested] description = Check the Requires-Python metadata against CI config skip_install = true dependency_groups = check-project-metadata commands = python scripts/ensure_min_python_is_tested.py [testenv:prepare-release] skip_install = true deps = scriv[toml] commands = python changelog.d/check-version-is-new.py scriv collect globus-globus-sdk-python-6a080e4/toxfile.py000066400000000000000000000040271513221403200207450ustar00rootroot00000000000000""" This is a very small 'tox' plugin. 'toxfile.py' is a special name for auto-loading a plugin without defining package metadata. For full doc, see: https://tox.wiki/en/latest/plugins.html Methods decorated below with `tox.plugin.impl` are hook implementations. We only implement hooks which we need. """ from __future__ import annotations import logging import pathlib import shutil import typing as t from tox.plugin import impl if t.TYPE_CHECKING: from tox.config.sets import EnvConfigSet from tox.session.state import State from tox.tox_env.api import ToxEnv log = logging.getLogger(__name__) REPO_ROOT = pathlib.Path(__file__).parent BUILD_DIR = REPO_ROOT / "build" @impl def tox_on_install( tox_env: ToxEnv, arguments: t.Any, section: str, of_type: str ) -> None: # when setting up the wheel build, clear the 'build/' directory if present if tox_env.name != "build_wheel": return if BUILD_DIR.exists(): shutil.rmtree(BUILD_DIR) @impl def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: env_conf.add_config( keys=["globus_sdk_rmtree"], of_type=list[str], default=[], desc="A dir tree to remove before running the environment commands", ) @impl def tox_before_run_commands(tox_env: ToxEnv) -> None: # determine if it was a parallel invocation by examining the CLI command parallel_detected = tox_env.options.command in ("p", "run-parallel") if parallel_detected: # tox is running parallel, set an indicator env var # and effectively disable pytest-xdist by setting xdist-workers to 0 # -- 0 means tests will run in the main process, not even in a worker setenv = tox_env.conf.load("set_env") setenv.update({"TOX_PARALLEL": "1", "PYTEST_XDIST_AUTO_NUM_WORKERS": "0"}) sdk_rmtree = tox_env.conf.load("globus_sdk_rmtree") for name in sdk_rmtree: path = pathlib.Path(name) if path.exists(): log.warning(f"globus_sdk_rmtree: {path}") shutil.rmtree(path)