├── tests ├── __init__.py ├── functional │ ├── __init__.py │ ├── endpoint │ │ ├── .coverage │ │ ├── permission │ │ │ └── test_endpoint_permission_create.py │ │ ├── test_storage_gateway_commands.py │ │ └── test_endpoint_set_subscription_id.py │ ├── task │ │ ├── test_task_event_list.py │ │ ├── test_task_cancel.py │ │ ├── test_task_show.py │ │ └── test_task_pause_info.py │ ├── groups │ │ ├── test_group_delete.py │ │ ├── test_group_show.py │ │ ├── test_group_list.py │ │ ├── test_group_create.py │ │ ├── test_get_subscription_info.py │ │ └── test_group_set_subscription_admin_verified.py │ ├── conftest.py │ ├── flows │ │ ├── test_cancel_run.py │ │ ├── test_delete_run.py │ │ ├── test_show_run_definition.py │ │ └── test_delete_flow.py │ ├── test_helptext.py │ ├── test_rename.py │ ├── test_mkdir.py │ ├── search │ │ ├── test_task_commands.py │ │ ├── test_errors.py │ │ ├── test_subject_commands.py │ │ └── test_index.py │ ├── test_gcs_aliasing.py │ ├── test_stubbed_removals.py │ ├── collection │ │ └── role │ │ │ ├── test_role_delete.py │ │ │ └── test_role_show.py │ ├── gcp │ │ └── test_gcp_set_subscription_admin_verified.py │ ├── exception_handling │ │ └── test_json_output.py │ └── gcs │ │ └── endpoint │ │ └── test_update.py ├── files │ └── api_fixtures │ │ ├── get_submission_id.yaml │ │ ├── rename_result.yaml │ │ ├── gcp_create.yaml │ │ ├── task_event_list.yaml │ │ └── endpoint_acl_operations.yaml ├── unit │ ├── termio │ │ └── printer │ │ │ ├── conftest.py │ │ │ ├── test_custom_printer.py │ │ │ ├── test_json_printer.py │ │ │ └── test_record_list_printer.py │ ├── formatters │ │ ├── test_compound_formatters.py │ │ ├── test_connector_formatter.py │ │ └── test_primitive_formatters.py │ ├── test_entity_type.py │ ├── test_client_login.py │ ├── test_explicit_null.py │ ├── test_version.py │ ├── test_timezone_handling.py │ ├── test_common_options.py │ ├── conftest.py │ └── param_types │ │ └── test_location_type.py └── plugins │ └── api_mockers │ └── __init__.py ├── reference ├── .gitignore └── changelog.adoc ├── src └── globus_cli │ ├── services │ ├── __init__.py │ ├── transfer │ │ ├── activation.py │ │ └── __init__.py │ └── gcs.py │ ├── __main__.py │ ├── termio │ ├── printers │ │ ├── unix_printer │ │ │ ├── __init__.py │ │ │ └── unix_printer.py │ │ ├── __init__.py │ │ ├── custom_printer.py │ │ ├── base.py │ │ └── json_printer.py │ ├── formatters │ │ └── __init__.py │ ├── __init__.py │ └── field.py │ ├── __init__.py │ ├── login_manager │ ├── utils.py │ ├── context.py │ ├── __init__.py │ ├── client_login.py │ └── errors.py │ ├── commands │ ├── search │ │ ├── task │ │ │ ├── __init__.py │ │ │ ├── list.py │ │ │ └── show.py │ │ ├── index │ │ │ ├── role │ │ │ │ ├── __init__.py │ │ │ │ ├── delete.py │ │ │ │ └── list.py │ │ │ ├── __init__.py │ │ │ ├── show.py │ │ │ ├── list.py │ │ │ ├── delete.py │ │ │ └── create.py │ │ ├── subject │ │ │ ├── __init__.py │ │ │ ├── show.py │ │ │ └── delete.py │ │ ├── __init__.py │ │ └── _common.py │ ├── endpoint │ │ ├── storage_gateway │ │ │ ├── __init__.py │ │ │ └── list.py │ │ ├── user_credential │ │ │ ├── update │ │ │ │ ├── __init__.py │ │ │ │ └── from_json.py │ │ │ ├── create │ │ │ │ ├── __init__.py │ │ │ │ ├── from_json.py │ │ │ │ └── posix.py │ │ │ ├── __init__.py │ │ │ ├── delete.py │ │ │ ├── _common.py │ │ │ ├── show.py │ │ │ └── list.py │ │ ├── role │ │ │ ├── __init__.py │ │ │ ├── _common.py │ │ │ ├── delete.py │ │ │ └── list.py │ │ ├── permission │ │ │ ├── __init__.py │ │ │ ├── delete.py │ │ │ ├── _common.py │ │ │ ├── list.py │ │ │ └── show.py │ │ ├── delete.py │ │ ├── my_shared_endpoint_list.py │ │ └── __init__.py │ ├── group │ │ ├── invite │ │ │ ├── __init__.py │ │ │ ├── accept.py │ │ │ └── decline.py │ │ ├── member │ │ │ ├── __init__.py │ │ │ ├── list.py │ │ │ ├── remove.py │ │ │ └── reject.py │ │ ├── delete.py │ │ ├── show.py │ │ ├── list.py │ │ ├── get_subscription_info.py │ │ ├── __init__.py │ │ ├── create.py │ │ └── set_subscription_admin_verified.py │ ├── timer │ │ ├── create │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── list.py │ │ ├── pause.py │ │ ├── show.py │ │ └── delete.py │ ├── gcp │ │ ├── create │ │ │ ├── __init__.py │ │ │ └── _common.py │ │ ├── update │ │ │ └── __init__.py │ │ └── __init__.py │ ├── collection │ │ ├── create │ │ │ └── __init__.py │ │ ├── role │ │ │ ├── __init__.py │ │ │ ├── delete.py │ │ │ ├── show.py │ │ │ └── list.py │ │ ├── __init__.py │ │ └── delete.py │ ├── session │ │ └── __init__.py │ ├── gcs │ │ ├── endpoint │ │ │ ├── role │ │ │ │ ├── __init__.py │ │ │ │ ├── _common.py │ │ │ │ ├── delete.py │ │ │ │ ├── show.py │ │ │ │ └── list.py │ │ │ ├── __init__.py │ │ │ └── show.py │ │ └── __init__.py │ ├── bookmark │ │ ├── __init__.py │ │ ├── delete.py │ │ ├── rename.py │ │ └── _common.py │ ├── task │ │ ├── _common.py │ │ ├── __init__.py │ │ ├── generate_submission_id.py │ │ └── update.py │ ├── flows │ │ ├── __init__.py │ │ ├── run │ │ │ ├── __init__.py │ │ │ ├── show_definition.py │ │ │ ├── cancel.py │ │ │ ├── delete.py │ │ │ └── show.py │ │ ├── show.py │ │ └── delete.py │ ├── _removal_stub.py │ ├── mkdir.py │ ├── rename.py │ ├── list_commands.py │ └── __init__.py │ ├── parsing │ ├── shared_callbacks.py │ ├── shared_options │ │ ├── flow_options.py │ │ └── id_args.py │ └── param_types │ │ ├── location.py │ │ ├── timedelta.py │ │ └── __init__.py │ ├── _warnings.py │ ├── exception_handling │ └── hooks │ │ ├── endpoint_types.py │ │ ├── transfer_hooks.py │ │ ├── authapi_hooks.py │ │ └── generic_hooks.py │ ├── endpointish │ └── __init__.py │ ├── types.py │ ├── constants.py │ ├── version.py │ └── _click_compat.py ├── changelog.d ├── 20251205_165556_sirosen_close_every_client.md ├── 20251118_125149_sirosen_main.md ├── new_fragment.md.j2 └── post-fix-changelog.py ├── .flake8 ├── .gitignore ├── .github ├── dependabot.yml ├── CODEOWNERS └── workflows │ ├── docs.yaml │ ├── has_changelog.yaml │ ├── publish_to_pypi.yaml │ └── publish_to_test_pypi.yaml ├── .editorconfig ├── README.rst └── Makefile /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reference/.gitignore: -------------------------------------------------------------------------------- 1 | *.adoc 2 | -------------------------------------------------------------------------------- /src/globus_cli/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reference/changelog.adoc: -------------------------------------------------------------------------------- 1 | ../changelog.adoc -------------------------------------------------------------------------------- /src/globus_cli/__main__.py: -------------------------------------------------------------------------------- 1 | from globus_cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /tests/functional/endpoint/.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/globus/globus-cli/HEAD/tests/functional/endpoint/.coverage -------------------------------------------------------------------------------- /src/globus_cli/termio/printers/unix_printer/__init__.py: -------------------------------------------------------------------------------- 1 | from .unix_printer import UnixPrinter 2 | 3 | __all__ = ["UnixPrinter"] 4 | -------------------------------------------------------------------------------- /changelog.d/20251205_165556_sirosen_close_every_client.md: -------------------------------------------------------------------------------- 1 | ### Bugfixes 2 | 3 | * Improved internal resource cleanup for network connections. 4 | -------------------------------------------------------------------------------- /src/globus_cli/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.commands import main 2 | from globus_cli.version import __version__ 3 | 4 | __all__ = ["main", "__version__"] 5 | -------------------------------------------------------------------------------- /src/globus_cli/login_manager/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def is_remote_session() -> bool: 5 | return bool(os.environ.get("SSH_TTY", os.environ.get("SSH_CONNECTION"))) 6 | -------------------------------------------------------------------------------- /changelog.d/20251118_125149_sirosen_main.md: -------------------------------------------------------------------------------- 1 | ### Bugfixes 2 | 3 | * Fixed a bug in `globus timer create transfer` which caused the timer name to 4 | be set incorrectly when filter rules are used. 5 | -------------------------------------------------------------------------------- /tests/files/api_fixtures/get_submission_id.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | submission_id: "5902daab-ffea-4e83-84f6-f69e5f8b7bb3" 3 | 4 | transfer: 5 | - path: /v0.10/submission_id 6 | json: {"value": "5902daab-ffea-4e83-84f6-f69e5f8b7bb3"} 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] # black and isort compatible rules 2 | exclude = .git,.tox,__pycache__dist,.venv*,build 3 | max-line-length = 88 4 | extend-ignore = W503,W504,E203 5 | 6 | [flake8:local-plugins] 7 | extension = 8 | CLI = globus_cli_flake8:Plugin 9 | paths = ./src/globus_cli/ 10 | -------------------------------------------------------------------------------- /tests/unit/termio/printer/conftest.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def click_context(): 7 | def func(command: str = "cmd") -> click.Context: 8 | ctx = click.Context(click.Command(command), obj={"jmespath": "a.b."}) 9 | return ctx 10 | 11 | return func 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | /*.egg-info 4 | /src/*.egg-info 5 | *.pyc 6 | /docs-source/_build/ 7 | /.coverage 8 | /.coverage.* 9 | .idea 10 | 11 | #virtualenv 12 | /virtualenv/ 13 | /.virtualenv/ 14 | /.venv/ 15 | /venv/ 16 | /.env/ 17 | /env/ 18 | /ENV/ 19 | /venv27/ 20 | /.venv27/ 21 | /.venv3/ 22 | /venv3/ 23 | 24 | /.tox 25 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/task/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "task", 6 | lazy_subcommands={ 7 | "list": (".list", "list_command"), 8 | "show": (".show", "show_command"), 9 | }, 10 | ) 11 | def task_command() -> None: 12 | """View task documents.""" 13 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/storage_gateway/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "storage_gateway", 6 | lazy_subcommands={ 7 | "list": (".list", "storage_gateway_list"), 8 | }, 9 | ) 10 | def storage_gateway_command() -> None: 11 | """Manage Storage Gateways on a GCS Endpoint.""" 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | groups: 12 | github-actions: 13 | patterns: 14 | - "*" 15 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/invite/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "invite", 6 | lazy_subcommands={ 7 | "accept": (".accept", "invite_accept"), 8 | "decline": (".decline", "invite_decline"), 9 | }, 10 | ) 11 | def group_invite() -> None: 12 | """Manage invitations to a Globus Group.""" 13 | -------------------------------------------------------------------------------- /src/globus_cli/commands/timer/create/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "create", 6 | short_help="Create a timer.", 7 | lazy_subcommands={ 8 | "transfer": (".transfer", "transfer_command"), 9 | "flow": (".flow", "flow_command"), 10 | }, 11 | ) 12 | def create_command() -> None: 13 | pass 14 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcp/create/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "create", 6 | lazy_subcommands={ 7 | "mapped": (".mapped", "mapped_command"), 8 | "guest": (".guest", "guest_command"), 9 | }, 10 | ) 11 | def create_command() -> None: 12 | """Create Globus Connect Personal collections.""" 13 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcp/update/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "create", 6 | lazy_subcommands={ 7 | "mapped": (".mapped", "mapped_command"), 8 | "guest": (".guest", "guest_command"), 9 | }, 10 | ) 11 | def update_command() -> None: 12 | """Update Globus Connect Personal collections.""" 13 | -------------------------------------------------------------------------------- /tests/functional/task/test_task_event_list.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response_set 2 | 3 | 4 | def test_task_event_list_success(run_line): 5 | meta = load_response_set("cli.task_event_list").metadata 6 | task_id = meta["task_id"] 7 | result = run_line(f"globus task event-list {task_id}") 8 | assert "Canceled by the task owner" in result.output 9 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/update/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "user_credential", 6 | lazy_subcommands={ 7 | "from-json": (".from_json", "from_json"), 8 | }, 9 | hidden=True, 10 | ) 11 | def user_credential_update() -> None: 12 | """Update a User Credential on an Endpoint.""" 13 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/create/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "create", 6 | lazy_subcommands={ 7 | "guest": (".guest", "collection_create_guest"), 8 | "mapped": (".mapped", "collection_create_mapped"), 9 | }, 10 | ) 11 | def collection_create() -> None: 12 | """Create a new Collection.""" 13 | -------------------------------------------------------------------------------- /tests/plugins/api_mockers/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .get_identities import GetIdentitiesMocker 4 | from .userinfo import UserinfoMocker 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def get_identities_mocker(): 9 | return GetIdentitiesMocker() 10 | 11 | 12 | @pytest.fixture(scope="session") 13 | def userinfo_mocker(): 14 | return UserinfoMocker() 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [*.{json,yml,yaml}] 17 | indent_size = 2 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/role/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "role", 6 | lazy_subcommands={ 7 | "list": (".list", "list_command"), 8 | "show": (".show", "show_command"), 9 | "delete": (".delete", "delete_command"), 10 | }, 11 | ) 12 | def role_command() -> None: 13 | """Manage Roles on Collections.""" 14 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/role/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "role", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "delete": (".delete", "delete_command"), 9 | "list": (".list", "list_command"), 10 | }, 11 | ) 12 | def role_command() -> None: 13 | """View and manage index roles.""" 14 | -------------------------------------------------------------------------------- /src/globus_cli/commands/session/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "session", 6 | lazy_subcommands={ 7 | "consent": (".consent", "session_consent"), 8 | "show": (".show", "session_show"), 9 | "update": (".update", "session_update"), 10 | }, 11 | ) 12 | def session_command() -> None: 13 | """Manage your CLI auth session.""" 14 | -------------------------------------------------------------------------------- /tests/unit/termio/printer/test_custom_printer.py: -------------------------------------------------------------------------------- 1 | from globus_cli.termio.printers import CustomPrinter 2 | 3 | 4 | def test_custom_printer_does_whatever_it_wants(): 5 | called = False 6 | 7 | def custom_print(data): 8 | nonlocal called 9 | called = True 10 | 11 | printer = CustomPrinter(custom_print) 12 | printer.echo({"data": "data"}) 13 | 14 | assert called 15 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/create/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "user_credential", 6 | lazy_subcommands={ 7 | "from-json": (".from_json", "from_json"), 8 | "posix": (".posix", "posix"), 9 | "s3": (".s3", "s3"), 10 | }, 11 | ) 12 | def user_credential_create() -> None: 13 | """Create a User Credential on an Endpoint.""" 14 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/role/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "role", 6 | lazy_subcommands={ 7 | "create": (".create", "role_create"), 8 | "delete": (".delete", "role_delete"), 9 | "list": (".list", "role_list"), 10 | "show": (".show", "role_show"), 11 | }, 12 | ) 13 | def role_command() -> None: 14 | """Manage endpoint roles.""" 15 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/subject/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "subject", 6 | short_help="Manage data by subject.", 7 | lazy_subcommands={ 8 | "delete": (".delete", "delete_command"), 9 | "show": (".show", "show_command"), 10 | }, 11 | ) 12 | def subject_command() -> None: 13 | """View and manage individual documents in an index by subject.""" 14 | -------------------------------------------------------------------------------- /src/globus_cli/login_manager/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class LoginContext: 8 | # A string representing the shell command a user should issue to resolve their 9 | # login-related issue 10 | login_command: str = "globus login" 11 | 12 | # Error message to display if the asserted login fails. 13 | error_message: str | None = None 14 | -------------------------------------------------------------------------------- /tests/functional/groups/test_group_delete.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response_set 2 | 3 | 4 | def test_group_delete(run_line): 5 | """ 6 | Basic success test for globus group delete. 7 | """ 8 | meta = load_response_set("cli.groups").metadata 9 | 10 | group1_id = meta["group1_id"] 11 | 12 | result = run_line(f"globus group delete {group1_id}") 13 | 14 | assert "Group deleted successfully" in result.output 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Globus CLI 2 | ========== 3 | 4 | A Command Line Interface to `Globus `_. 5 | 6 | Source Code: https://github.com/globus/globus-cli 7 | 8 | Installation, Running, Other Documentation: https://docs.globus.org/cli 9 | 10 | Bugs and Feature Requests 11 | ------------------------- 12 | 13 | Bugs reports and feature requests are open submission, and should be filed at 14 | https://github.com/globusonline/globus-cli/issues 15 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/role/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "role", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "delete": (".delete", "delete_command"), 9 | "list": (".list", "list_command"), 10 | "show": (".show", "show_command"), 11 | }, 12 | ) 13 | def role_command() -> None: 14 | """Manage Globus Connect Server (GCS) roles.""" 15 | -------------------------------------------------------------------------------- /src/globus_cli/login_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from .client_login import get_client_login, is_client_login 2 | from .errors import MissingLoginError 3 | from .manager import LoginManager 4 | from .scopes import compute_timer_scope 5 | from .utils import is_remote_session 6 | 7 | __all__ = [ 8 | "MissingLoginError", 9 | "is_remote_session", 10 | "LoginManager", 11 | "is_client_login", 12 | "get_client_login", 13 | "compute_timer_scope", 14 | ] 15 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "index", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "delete": (".delete", "delete_command"), 9 | "list": (".list", "list_command"), 10 | "role": (".role", "role_command"), 11 | "show": (".show", "show_command"), 12 | }, 13 | ) 14 | def index_command() -> None: 15 | """View and manage indices.""" 16 | -------------------------------------------------------------------------------- /src/globus_cli/commands/bookmark/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "bookmark", 6 | lazy_subcommands={ 7 | "create": (".create", "bookmark_create"), 8 | "delete": (".delete", "bookmark_delete"), 9 | "list": (".list", "bookmark_list"), 10 | "rename": (".rename", "bookmark_rename"), 11 | "show": (".show", "bookmark_show"), 12 | }, 13 | ) 14 | def bookmark_command() -> None: 15 | """Manage endpoint bookmarks.""" 16 | -------------------------------------------------------------------------------- /src/globus_cli/termio/printers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Printer 2 | from .custom_printer import CustomPrinter 3 | from .json_printer import JsonPrinter 4 | from .record_printer import RecordListPrinter, RecordPrinter 5 | from .table_printer import TablePrinter 6 | from .unix_printer import UnixPrinter 7 | 8 | __all__ = ( 9 | "Printer", 10 | "CustomPrinter", 11 | "JsonPrinter", 12 | "UnixPrinter", 13 | "TablePrinter", 14 | "RecordPrinter", 15 | "RecordListPrinter", 16 | ) 17 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "endpoint", 6 | lazy_subcommands={ 7 | "role": (".role", "role_command"), 8 | "set-subscription-id": (".set_subscription_id", "set_subscription_id_command"), 9 | "show": (".show", "show_command"), 10 | "update": (".update", "update_command"), 11 | }, 12 | ) 13 | def endpoint_command() -> None: 14 | """Manage Globus Connect Server (GCS) endpoints.""" 15 | -------------------------------------------------------------------------------- /tests/functional/task/test_task_cancel.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from globus_sdk.testing import load_response_set 3 | 4 | 5 | @pytest.mark.parametrize("cli_arg", ("task_id", "--all")) 6 | def test_cancel(run_line, cli_arg): 7 | """Validate task cancellation.""" 8 | meta = load_response_set("cli.task_cancel").metadata 9 | if cli_arg == "task_id": 10 | cli_arg = meta["task_id"] 11 | result = run_line(f"globus task cancel {cli_arg}") 12 | assert "cancelled successfully" in result.output 13 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/role/_common.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import click 4 | 5 | from globus_cli.termio import formatters 6 | from globus_cli.types import AnyCommand 7 | 8 | C = t.TypeVar("C", bound=AnyCommand) 9 | 10 | 11 | class RolePrincipalFormatter(formatters.auth.PrincipalDictFormatter): 12 | def render_group_id(self, group_id: str) -> str: 13 | return f"https://app.globus.org/groups/{group_id}" 14 | 15 | 16 | def role_id_arg(f: C) -> C: 17 | return click.argument("role_id")(f) 18 | -------------------------------------------------------------------------------- /changelog.d/new_fragment.md.j2: -------------------------------------------------------------------------------- 1 | 11 | 12 | {% for cat in config.categories -%} 13 | 19 | {% endfor -%} 20 | -------------------------------------------------------------------------------- /src/globus_cli/commands/task/_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import click 6 | 7 | from globus_cli.types import AnyCommand 8 | 9 | C = t.TypeVar("C", bound=AnyCommand) 10 | 11 | 12 | def task_id_arg(*, required: bool = True) -> t.Callable[[C], C]: 13 | """ 14 | By default, the task ID is made required; pass `required=False` to the 15 | decorator arguments to make it optional. 16 | """ 17 | return click.argument("TASK_ID", type=click.UUID, required=required) 18 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/permission/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "permission", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "delete": (".delete", "delete_command"), 9 | "list": (".list", "list_command"), 10 | "show": (".show", "show_command"), 11 | "update": (".update", "update_command"), 12 | }, 13 | ) 14 | def permission_command() -> None: 15 | """Manage endpoint permissions (Access Control Lists).""" 16 | -------------------------------------------------------------------------------- /tests/files/api_fixtures/rename_result.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | endpoint_id: aa752cea-8222-5bc8-acd9-555b090c0ccb 3 | 4 | transfer: 5 | - path: /v0.10/operation/endpoint/aa752cea-8222-5bc8-acd9-555b090c0ccb/rename 6 | method: post 7 | json: 8 | { 9 | "DATA_TYPE": "result", 10 | "code": "FileRenamed", 11 | "message": "File or directory renamed successfully", 12 | "request_id": "PL6I0Gsll", 13 | "resource": "/operation/endpoint/aa752cea-8222-5bc8-acd9-555b090c0ccb/rename" 14 | } 15 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/member/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "member", 6 | lazy_subcommands={ 7 | "add": (".add", "member_add"), 8 | "approve": (".approve", "member_approve"), 9 | "invite": (".invite", "member_invite"), 10 | "list": (".list", "member_list"), 11 | "reject": (".reject", "member_reject"), 12 | "remove": (".remove", "member_remove"), 13 | }, 14 | ) 15 | def group_member() -> None: 16 | """Manage members in a Globus Group.""" 17 | -------------------------------------------------------------------------------- /src/globus_cli/commands/timer/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "timer", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "delete": (".delete", "delete_command"), 9 | "list": (".list", "list_command"), 10 | "pause": (".pause", "pause_command"), 11 | "resume": (".resume", "resume_command"), 12 | "show": (".show", "show_command"), 13 | }, 14 | ) 15 | def timer_command() -> None: 16 | """Schedule and manage timers in Globus Timers.""" 17 | -------------------------------------------------------------------------------- /src/globus_cli/parsing/shared_callbacks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | 6 | def emptyable_opt_list_callback( 7 | ctx: click.Context, param: click.Parameter, value: tuple[str, ...] 8 | ) -> list[str] | None: 9 | """ 10 | A callback which converts multiple=True options as follows: 11 | - empty results, () => None 12 | - ("",) => [] 13 | - * => passthrough 14 | """ 15 | if len(value) == 0: 16 | return None 17 | if value == ("",): 18 | return [] 19 | return list(value) 20 | -------------------------------------------------------------------------------- /src/globus_cli/termio/printers/custom_printer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from globus_cli.termio.printers import Printer 6 | 7 | 8 | class CustomPrinter(Printer[t.Any]): 9 | """ 10 | A printer that uses a custom print function to print data. 11 | """ 12 | 13 | def __init__(self, custom_print: t.Callable[[t.Any], None]) -> None: 14 | self._custom_print = custom_print 15 | 16 | def echo(self, data: t.Any, stream: t.IO[str] | None = None) -> None: 17 | self._custom_print(data) 18 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "collection", 6 | lazy_subcommands={ 7 | "create": (".create", "collection_create"), 8 | "delete": (".delete", "collection_delete"), 9 | "list": (".list", "collection_list"), 10 | "show": (".show", "collection_show"), 11 | "update": (".update", "collection_update"), 12 | "role": (".role", "role_command"), 13 | }, 14 | ) 15 | def collection_command() -> None: 16 | """Manage your Collections.""" 17 | -------------------------------------------------------------------------------- /tests/files/api_fixtures/gcp_create.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | endpoint_id: "1405823f-0597-4a16-b296-46d4f0ae4b15" 3 | setup_key: "b379e973-f5c5-4501-8377-5a0ecf37a99b" 4 | 5 | transfer: 6 | - path: /v0.10/endpoint 7 | method: post 8 | json: 9 | { 10 | "DATA_TYPE": "endpoint_create_result", 11 | "code": "Created", 12 | "message": "Endpoint created", 13 | "globus_connect_setup_key": "b379e973-f5c5-4501-8377-5a0ecf37a99b", 14 | "id": "1405823f-0597-4a16-b296-46d4f0ae4b15", 15 | "request_id": "ABCdef789" 16 | } 17 | -------------------------------------------------------------------------------- /tests/functional/groups/test_group_show.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response_set 2 | 3 | 4 | def test_group_show(run_line): 5 | """ 6 | Basic success test for globus group show. 7 | """ 8 | meta = load_response_set("cli.groups").metadata 9 | 10 | group1_id = meta["group1_id"] 11 | group1_name = meta["group1_name"] 12 | group1_description = meta["group1_description"] 13 | 14 | result = run_line(f"globus group show {group1_id}") 15 | 16 | assert group1_name in result.output 17 | assert group1_description in result.output 18 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "user_credential", 6 | lazy_subcommands={ 7 | "create": (".create", "user_credential_create"), 8 | "delete": (".delete", "user_credential_delete"), 9 | "list": (".list", "user_credential_list"), 10 | "show": (".show", "user_credential_show"), 11 | "update": (".update", "user_credential_update"), 12 | }, 13 | ) 14 | def user_credential_command() -> None: 15 | """Manage User Credentials on a GCS Endpoint.""" 16 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/role/_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from globus_cli.termio import Field 6 | from globus_cli.termio.formatters.auth import PrincipalURNFormatter 7 | 8 | if t.TYPE_CHECKING: 9 | from globus_cli.services.auth import CustomAuthClient 10 | 11 | 12 | def role_fields(auth_client: CustomAuthClient) -> list[Field]: 13 | return [ 14 | Field("ID", "id"), 15 | Field("Role", "role"), 16 | Field("Principal", "principal", formatter=PrincipalURNFormatter(auth_client)), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/functional/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def mock_link_flow(): 8 | with mock.patch("globus_cli.login_manager.manager.do_link_auth_flow") as m: 9 | yield m 10 | 11 | 12 | @pytest.fixture 13 | def mock_local_server_flow(): 14 | with mock.patch("globus_cli.login_manager.manager.do_local_server_auth_flow") as m: 15 | yield m 16 | 17 | 18 | @pytest.fixture 19 | def mock_remote_session(): 20 | with mock.patch("globus_cli.login_manager.manager.is_remote_session") as m: 21 | yield m 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 2 | # codeowners reference: 3 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 4 | # 5 | # for ease of comparison, usernames are kept alphabetized 6 | # 7 | 8 | # default rule 9 | * @aaschaer @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen 10 | 11 | # Flows service 12 | **/flows/ @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen 13 | 14 | # Timer service 15 | **/timer/ @ada-globus @derek-globus @kurtmckee @m1yag1 @MaxTueckeGlobus @sirosen 16 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "search", 6 | lazy_subcommands={ 7 | "delete-by-query": (".delete_by_query", "delete_by_query_command"), 8 | "index": (".index", "index_command"), 9 | "ingest": (".ingest", "ingest_command"), 10 | "query": (".query", "query_command"), 11 | "subject": (".subject", "subject_command"), 12 | "task": (".task", "task_command"), 13 | }, 14 | ) 15 | def search_command() -> None: 16 | """Use Globus Search to store and query for data.""" 17 | -------------------------------------------------------------------------------- /src/globus_cli/_warnings.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import warnings 3 | 4 | # this bool turns off all warning controls, handing control of python 5 | # warnings to the testsuite 6 | # this ensures that `pytest --filterwarnings error` works 7 | _TEST_WARNING_CONTROL: bool = False 8 | 9 | 10 | def simplefilter( 11 | filterstr: t.Literal["default", "error", "ignore", "always", "module", "once"], 12 | ) -> None: 13 | """ 14 | Wrap `warnings.simplefilter` with a check on `_TEST_WARNING_CONTROL`. 15 | """ 16 | if not _TEST_WARNING_CONTROL: 17 | warnings.simplefilter(filterstr) 18 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcp/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "gcp", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "update": (".update", "update_command"), 9 | "set-subscription-id": (".set_subscription_id", "set_endpoint_subscription_id"), 10 | "set-subscription-admin-verified": ( 11 | ".set_subscription_admin_verified", 12 | "set_collection_subscription_admin_verified", 13 | ), 14 | }, 15 | ) 16 | def gcp_command() -> None: 17 | """Manage Globus Connect Personal endpoints.""" 18 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from .._common import INDEX_FIELDS, index_id_arg 8 | 9 | 10 | @command("show") 11 | @index_id_arg 12 | @LoginManager.requires_login("search") 13 | def show_command(login_manager: LoginManager, *, index_id: uuid.UUID) -> None: 14 | """Display information about an index.""" 15 | search_client = login_manager.get_search_client() 16 | display( 17 | search_client.get_index(index_id), text_mode=display.RECORD, fields=INDEX_FIELDS 18 | ) 19 | -------------------------------------------------------------------------------- /src/globus_cli/commands/timer/list.py: -------------------------------------------------------------------------------- 1 | from globus_cli.login_manager import LoginManager 2 | from globus_cli.parsing import command 3 | from globus_cli.termio import display 4 | 5 | from ._common import TIMER_FORMAT_FIELDS 6 | 7 | 8 | @command("list", short_help="List your timers.") 9 | @LoginManager.requires_login("timers") 10 | def list_command(login_manager: LoginManager) -> None: 11 | """ 12 | List your timers. 13 | """ 14 | timer_client = login_manager.get_timer_client() 15 | response = timer_client.list_jobs(query_params={"order": "submitted_at asc"}) 16 | display(response["jobs"], text_mode=display.RECORD_LIST, fields=TIMER_FORMAT_FIELDS) 17 | -------------------------------------------------------------------------------- /tests/functional/groups/test_group_list.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response_set 2 | 3 | 4 | def test_group_list(run_line): 5 | """ 6 | Runs globus group list and validates results. 7 | """ 8 | meta = load_response_set("cli.groups").metadata 9 | 10 | group1_id = meta["group1_id"] 11 | group2_id = meta["group2_id"] 12 | group1_name = meta["group1_name"] 13 | group2_name = meta["group2_name"] 14 | 15 | result = run_line("globus group list") 16 | 17 | assert group1_id in result.output 18 | assert group2_id in result.output 19 | assert group1_name in result.output 20 | assert group2_name in result.output 21 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from ._common import group_id_arg 8 | 9 | 10 | @group_id_arg 11 | @command("delete") 12 | @LoginManager.requires_login("groups") 13 | def group_delete( 14 | login_manager: LoginManager, 15 | *, 16 | group_id: uuid.UUID, 17 | ) -> None: 18 | """Delete a group.""" 19 | groups_client = login_manager.get_groups_client() 20 | 21 | response = groups_client.delete_group(group_id) 22 | 23 | display(response, simple_text="Group deleted successfully") 24 | -------------------------------------------------------------------------------- /src/globus_cli/commands/task/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "task", 6 | lazy_subcommands={ 7 | "cancel": (".cancel", "cancel_task"), 8 | "event-list": (".event_list", "task_event_list"), 9 | "generate-submission-id": (".generate_submission_id", "generate_submission_id"), 10 | "list": (".list", "task_list"), 11 | "pause-info": (".pause_info", "task_pause_info"), 12 | "show": (".show", "show_task"), 13 | "update": (".update", "update_task"), 14 | "wait": (".wait", "task_wait"), 15 | }, 16 | ) 17 | def task_command() -> None: 18 | """Manage asynchronous tasks.""" 19 | -------------------------------------------------------------------------------- /src/globus_cli/commands/timer/pause.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command 7 | from globus_cli.termio import display 8 | 9 | 10 | @command("pause", short_help="Pause a timer.") 11 | @click.argument("TIMER_ID", type=click.UUID) 12 | @LoginManager.requires_login("timers") 13 | def pause_command(login_manager: LoginManager, *, timer_id: uuid.UUID) -> None: 14 | """ 15 | Pause a timer. 16 | """ 17 | timer_client = login_manager.get_timer_client() 18 | paused = timer_client.pause_job(timer_id) 19 | display(paused, text_mode=display.RAW, simple_text=paused["message"]) 20 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "flows", 6 | lazy_subcommands={ 7 | "create": (".create", "create_command"), 8 | "update": (".update", "update_command"), 9 | "validate": (".validate", "validate_command"), 10 | "delete": (".delete", "delete_command"), 11 | "list": (".list", "list_command"), 12 | "show": (".show", "show_command"), 13 | "start": (".start", "start_command"), 14 | # "run" is a subgroup of commands. 15 | "run": (".run", "run_command"), 16 | }, 17 | ) 18 | def flows_command() -> None: 19 | """Interact with the Globus Flows service.""" 20 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/run/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "run", 6 | lazy_subcommands={ 7 | "show": (".show", "show_command"), 8 | "show-definition": (".show_definition", "show_definition_command"), 9 | "show-logs": (".show_logs", "show_logs_command"), 10 | "list": (".list", "list_command"), 11 | "update": (".update", "update_command"), 12 | "delete": (".delete", "delete_command"), 13 | "resume": (".resume", "resume_command"), 14 | "cancel": (".cancel", "cancel_command"), 15 | }, 16 | ) 17 | def run_command() -> None: 18 | """Interact with a run in the Globus Flows service.""" 19 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "gcs", 6 | lazy_subcommands={ 7 | "collection": ("collection", "collection_command"), 8 | # Note: endpoint is not an alias for the root 'endpoint' group as that group is 9 | # broken up into slightly different subcommand structures here. 10 | "endpoint": (".endpoint", "endpoint_command"), 11 | "storage-gateway": ("endpoint.storage_gateway", "storage_gateway_command"), 12 | "user-credential": ("endpoint.user_credential", "user_credential_command"), 13 | }, 14 | ) 15 | def gcs_command() -> None: 16 | """Manage Globus Connect Server (GCS) resources.""" 17 | -------------------------------------------------------------------------------- /tests/functional/flows/test_cancel_run.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response 2 | 3 | 4 | def test_cancel_run_text_output(run_line, load_identities_for_flow_run): 5 | cancel_response = load_response("flows.cancel_run") 6 | run_id = cancel_response.metadata["run_id"] 7 | 8 | load_identities_for_flow_run(cancel_response.json) 9 | 10 | result = run_line(f"globus flows run cancel {run_id}") 11 | # Verify all fields are present. 12 | for fieldname in ( 13 | "Flow ID", 14 | "Flow Title", 15 | "Run ID", 16 | "Run Label", 17 | "Started At", 18 | "Completed At", 19 | "Status", 20 | ): 21 | assert fieldname in result.output 22 | -------------------------------------------------------------------------------- /tests/functional/flows/test_delete_run.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response 2 | 3 | 4 | def test_delete_run_text_output(run_line, load_identities_for_flow_run): 5 | delete_response = load_response("flows.delete_run") 6 | run_id = delete_response.metadata["run_id"] 7 | 8 | load_identities_for_flow_run(delete_response.json) 9 | 10 | result = run_line(f"globus flows run delete {run_id}") 11 | # Verify all fields are present. 12 | for fieldname in ( 13 | "Flow ID", 14 | "Flow Title", 15 | "Run ID", 16 | "Run Label", 17 | "Started At", 18 | "Completed At", 19 | "Status", 20 | ): 21 | assert fieldname in result.output 22 | -------------------------------------------------------------------------------- /tests/functional/flows/test_show_run_definition.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from globus_sdk.testing import load_response 4 | 5 | 6 | def test_show_run_definition(run_line, add_flow_login): 7 | # Load the response mock and extract critical metadata. 8 | response = load_response("flows.get_run_definition") 9 | run_id = response.metadata["run_id"] 10 | 11 | # Construct the command line. 12 | cli = f"globus flows run show-definition {run_id}" 13 | result = run_line(cli) 14 | 15 | # Verify the output is JSON. 16 | parsed_output = json.loads(result.stdout) 17 | 18 | # Verify the keys have not been sorted. 19 | assert list(parsed_output.keys()) == list(response.responses[0].json.keys()) 20 | -------------------------------------------------------------------------------- /tests/functional/test_helptext.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests which ensure that helptext rendering is correct 3 | """ 4 | 5 | 6 | # This is a regression test for 7 | # https://github.com/globus/globus-cli/issues/496 8 | def test_helptext_for_commands_with_security_principal_opts(run_line): 9 | """ 10 | Test commands which use the security principal options and ensure that their 11 | helptext renders correctly. 12 | """ 13 | result = run_line("globus endpoint role create --help") 14 | assert "Create a role on an endpoint" in result.output 15 | 16 | result = run_line("globus endpoint permission create --help") 17 | assert "Create a new access control rule on the target endpoint" in result.output 18 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/list.py: -------------------------------------------------------------------------------- 1 | from globus_cli.login_manager import LoginManager 2 | from globus_cli.parsing import command 3 | from globus_cli.termio import Field, display, formatters 4 | 5 | from .._common import INDEX_FIELDS 6 | 7 | INDEX_LIST_FIELDS = INDEX_FIELDS + [ 8 | Field("Permissions", "permissions", formatter=formatters.Array), 9 | ] 10 | 11 | 12 | @command("list") 13 | @LoginManager.requires_login("search") 14 | def list_command(login_manager: LoginManager) -> None: 15 | """List indices where you have some permissions.""" 16 | search_client = login_manager.get_search_client() 17 | display( 18 | search_client.index_list(), fields=INDEX_LIST_FIELDS, text_mode=display.TABLE 19 | ) 20 | -------------------------------------------------------------------------------- /src/globus_cli/commands/timer/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command 7 | from globus_cli.termio import display 8 | 9 | from ._common import TIMER_FORMAT_FIELDS 10 | 11 | 12 | @command("show", short_help="Display a timer.") 13 | @click.argument("TIMER_ID", type=click.UUID) 14 | @LoginManager.requires_login("timers") 15 | def show_command(login_manager: LoginManager, *, timer_id: uuid.UUID) -> None: 16 | """ 17 | Display information about a particular timer. 18 | """ 19 | timer_client = login_manager.get_timer_client() 20 | response = timer_client.get_job(timer_id) 21 | display(response, text_mode=display.RECORD, fields=TIMER_FORMAT_FIELDS) 22 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.commands.gcs.endpoint._common import GCS_ENDPOINT_FIELDS 4 | from globus_cli.login_manager import LoginManager 5 | from globus_cli.parsing import command, endpoint_id_arg 6 | from globus_cli.termio import display 7 | 8 | 9 | @command("show") 10 | @endpoint_id_arg 11 | @LoginManager.requires_login("transfer") 12 | def show_command( 13 | login_manager: LoginManager, 14 | *, 15 | endpoint_id: uuid.UUID, 16 | ) -> None: 17 | """Display information about a particular GCS Endpoint.""" 18 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 19 | 20 | res = gcs_client.get_endpoint() 21 | 22 | display(res, text_mode=display.RECORD, fields=GCS_ENDPOINT_FIELDS) 23 | -------------------------------------------------------------------------------- /src/globus_cli/parsing/shared_options/flow_options.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from globus_cli.parsing import JSONStringOrFile 4 | 5 | flow_input_document_option = click.option( 6 | "--input", 7 | "input_document", 8 | type=JSONStringOrFile(), 9 | help=""" 10 | The JSON input parameters used to start the flow. 11 | 12 | The input document may be specified inline, 13 | or it may be a path to a JSON file, prefixed with "file:". 14 | 15 | Example: Inline JSON: 16 | 17 | \b 18 | --input '{"src": "~/source"}' 19 | 20 | Example: Path to JSON file: 21 | 22 | \b 23 | --input parameters.json 24 | 25 | If unspecified, the default is an empty JSON object ('{}'). 26 | """, 27 | ) 28 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from .._common import index_id_arg 8 | 9 | 10 | @command("delete") 11 | @LoginManager.requires_login("search") 12 | @index_id_arg 13 | def delete_command(login_manager: LoginManager, *, index_id: uuid.UUID) -> None: 14 | """Delete a Search index.""" 15 | search_client = login_manager.get_search_client() 16 | display( 17 | search_client.delete_index(index_id), 18 | simple_text=( 19 | f"Index {index_id} is now marked for deletion.\n" 20 | "It will be fully deleted after cleanup steps complete." 21 | ), 22 | ) 23 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/role/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import collection_id_arg, command 7 | from globus_cli.termio import display 8 | 9 | 10 | @command("delete") 11 | @collection_id_arg 12 | @click.argument("ROLE_ID", type=click.UUID) 13 | @LoginManager.requires_login("transfer") 14 | def delete_command( 15 | login_manager: LoginManager, 16 | *, 17 | collection_id: uuid.UUID, 18 | role_id: uuid.UUID, 19 | ) -> None: 20 | """Delete a particular role on a Collection.""" 21 | gcs_client = login_manager.get_gcs_client(collection_id=collection_id) 22 | res = gcs_client.delete_role(role_id) 23 | display(res, text_mode=display.RAW, response_key="code") 24 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/role/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command, endpoint_id_arg 7 | from globus_cli.termio import display 8 | 9 | 10 | @command("delete") 11 | @endpoint_id_arg 12 | @click.argument("ROLE_ID", type=click.UUID) 13 | @LoginManager.requires_login("transfer") 14 | def delete_command( 15 | login_manager: LoginManager, 16 | *, 17 | endpoint_id: uuid.UUID, 18 | role_id: uuid.UUID, 19 | ) -> None: 20 | """Delete a role from a GCS Endpoint.""" 21 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 22 | 23 | res = gcs_client.delete_role(role_id) 24 | 25 | display(res, text_mode=display.RAW, response_key="message") 26 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/create.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from .._common import INDEX_FIELDS 8 | 9 | 10 | @command("create") 11 | @LoginManager.requires_login("search") 12 | @click.argument("DISPLAY_NAME") 13 | @click.argument("DESCRIPTION") 14 | def create_command( 15 | login_manager: LoginManager, *, display_name: str, description: str 16 | ) -> None: 17 | """Create a new index.""" 18 | search_client = login_manager.get_search_client() 19 | display( 20 | search_client.create_index(display_name=display_name, description=description), 21 | text_mode=display.RECORD, 22 | fields=INDEX_FIELDS, 23 | ) 24 | -------------------------------------------------------------------------------- /tests/unit/formatters/test_compound_formatters.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from globus_cli.termio import formatters 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "data", 10 | [None, True, False, {"foo": "bar"}, 1.0, 1, ["foo", {"bar": "baz"}], "foo-bar"], 11 | ) 12 | def test_format_valid_json_value(data): 13 | fmt = formatters.SortedJsonFormatter() 14 | result = fmt.format(data) 15 | assert result == json.dumps(data, sort_keys=True) 16 | 17 | 18 | def test_formatting_invalid_json_value(): 19 | # `json.dumps` supports tuples, but the SortedJsonFormatter does not 20 | data = (1, 2) 21 | fmt = formatters.SortedJsonFormatter() 22 | with pytest.warns(formatters.FormattingFailedWarning): 23 | result = fmt.format(data) 24 | assert result == str(data) 25 | -------------------------------------------------------------------------------- /src/globus_cli/commands/_removal_stub.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | from globus_cli.parsing import command 6 | 7 | 8 | @command( 9 | hidden=True, 10 | context_settings={"ignore_unknown_options": True}, 11 | disable_options=( 12 | "format", 13 | "map_http_status", 14 | ), 15 | ) 16 | @click.argument("UNKNOWN_ARG", nargs=-1) 17 | def removal_stub_command(unknown_arg: tuple[str, ...]) -> None: 18 | """This command has been removed from the Globus CLI.""" 19 | command_string = click.get_current_context().command_path 20 | click.echo( 21 | click.style( 22 | f"`{command_string}` has been removed from the Globus CLI.", fg="red" 23 | ), 24 | err=True, 25 | ) 26 | click.get_current_context().exit(1) 27 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/role/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command 7 | from globus_cli.termio import display 8 | 9 | from ..._common import index_id_arg 10 | 11 | 12 | @command("delete") 13 | @index_id_arg 14 | @click.argument("ROLE_ID") 15 | @LoginManager.requires_login("search") 16 | def delete_command( 17 | login_manager: LoginManager, 18 | *, 19 | index_id: uuid.UUID, 20 | role_id: str, 21 | ) -> None: 22 | """Delete a role (requires admin or owner).""" 23 | search_client = login_manager.get_search_client() 24 | display( 25 | search_client.delete_role(index_id, role_id), 26 | simple_text=f"Successfully removed role {role_id} from index {index_id}", 27 | ) 28 | -------------------------------------------------------------------------------- /src/globus_cli/commands/timer/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command 7 | from globus_cli.termio import display 8 | 9 | from ._common import DELETED_TIMER_FORMAT_FIELDS 10 | 11 | 12 | @command("delete", short_help="Delete a timer.") 13 | @click.argument("TIMER_ID", type=click.UUID) 14 | @LoginManager.requires_login("timers") 15 | def delete_command(login_manager: LoginManager, *, timer_id: uuid.UUID) -> None: 16 | """ 17 | Delete a timer. 18 | 19 | The contents of the deleted timer are printed afterward. 20 | """ 21 | timer_client = login_manager.get_timer_client() 22 | deleted = timer_client.delete_job(timer_id) 23 | display(deleted, text_mode=display.RECORD, fields=DELETED_TIMER_FORMAT_FIELDS) 24 | -------------------------------------------------------------------------------- /src/globus_cli/services/transfer/activation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import globus_sdk 4 | 5 | 6 | def supported_activation_methods( 7 | res: globus_sdk.GlobusHTTPResponse, 8 | ) -> list[str]: 9 | """ 10 | Given an activation_requirements document 11 | returns a list of activation methods supported by this endpoint. 12 | """ 13 | supported = ["web"] # web activation is always supported. 14 | 15 | # oauth 16 | if res["oauth_server"]: 17 | supported.append("oauth") 18 | 19 | for req in res["DATA"]: 20 | # myproxy 21 | if ( 22 | req["type"] == "myproxy" 23 | and req["name"] == "hostname" 24 | and req["value"] != "myproxy.globusonline.org" 25 | ): 26 | supported.append("myproxy") 27 | 28 | return supported 29 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from ._common import GROUP_FIELDS, GROUP_FIELDS_W_SUBSCRIPTION, group_id_arg 8 | 9 | 10 | @group_id_arg 11 | @command("show") 12 | @LoginManager.requires_login("groups") 13 | def group_show(login_manager: LoginManager, *, group_id: uuid.UUID) -> None: 14 | """Show a group definition.""" 15 | groups_client = login_manager.get_groups_client() 16 | 17 | group = groups_client.get_group(group_id, include="my_memberships") 18 | 19 | if group.get("subscription_id") is not None: 20 | fields = GROUP_FIELDS_W_SUBSCRIPTION 21 | else: 22 | fields = GROUP_FIELDS 23 | 24 | display(group, text_mode=display.RECORD, fields=fields) 25 | -------------------------------------------------------------------------------- /src/globus_cli/parsing/shared_options/id_args.py: -------------------------------------------------------------------------------- 1 | """ 2 | These are standardized arg names. 3 | 4 | Basic usage: 5 | 6 | >>> @endpoint_id_arg 7 | >>> def command_func(endpoint_id: uuid.UUID): 8 | >>> ... 9 | """ 10 | 11 | import typing as t 12 | 13 | import click 14 | 15 | C = t.TypeVar("C", bound=t.Union[click.Command, t.Callable[..., t.Any]]) 16 | 17 | 18 | def collection_id_arg(f: C) -> C: 19 | return click.argument("collection_id", metavar="COLLECTION_ID", type=click.UUID)(f) 20 | 21 | 22 | def endpoint_id_arg(f: C) -> C: 23 | return click.argument("endpoint_id", metavar="ENDPOINT_ID", type=click.UUID)(f) 24 | 25 | 26 | def flow_id_arg(f: C) -> C: 27 | return click.argument("flow_id", metavar="FLOW_ID", type=click.UUID)(f) 28 | 29 | 30 | def run_id_arg(f: C) -> C: 31 | return click.argument("run_id", metavar="RUN_ID", type=click.UUID)(f) 32 | -------------------------------------------------------------------------------- /src/globus_cli/services/gcs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import globus_sdk 6 | 7 | from globus_cli.endpointish import Endpointish 8 | from globus_cli.termio import formatters 9 | 10 | 11 | class ConnectorIdFormatter(formatters.StrFormatter): 12 | def parse(self, value: t.Any) -> str: 13 | if not isinstance(value, str): 14 | raise ValueError("bad connector ID") 15 | connector = globus_sdk.ConnectorTable.lookup(value) 16 | if not connector: 17 | return f"UNKNOWN ({value})" 18 | return connector.name 19 | 20 | 21 | class CustomGCSClient(globus_sdk.GCSClient): 22 | def __init__( 23 | self, *args: t.Any, source_epish: Endpointish, **kwargs: t.Any 24 | ) -> None: 25 | super().__init__(*args, **kwargs) 26 | self.source_epish = source_epish 27 | -------------------------------------------------------------------------------- /tests/functional/flows/test_delete_flow.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response 2 | 3 | 4 | def test_delete_flow_text_output(run_line, get_identities_mocker): 5 | delete_response = load_response("flows.delete_flow") 6 | flow_id = delete_response.metadata["flow_id"] 7 | user_meta = get_identities_mocker.configure_one( 8 | id=delete_response.json["flow_owner"].split(":")[-1] 9 | ).metadata 10 | 11 | result = run_line( 12 | f"globus flows delete {flow_id}", 13 | search_stdout=[ 14 | ("Flow ID", flow_id), 15 | ("Owner", user_meta["username"]), 16 | ("Deleted", "True"), 17 | ], 18 | ) 19 | # all other fields also present 20 | for fieldname in ( 21 | "Title", 22 | "Created At", 23 | "Updated At", 24 | ): 25 | assert fieldname in result.output 26 | -------------------------------------------------------------------------------- /tests/functional/test_rename.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from globus_sdk.testing import get_last_request, load_response_set 4 | 5 | 6 | def test_simple_rename_success(run_line, go_ep1_id): 7 | """ 8 | Just confirm that args make it through the command successfully and we render the 9 | message as output. 10 | """ 11 | load_response_set("cli.rename_result") 12 | 13 | result = run_line(f"globus rename {go_ep1_id} foo/bar /baz/buzz") 14 | assert "File or directory renamed successfully" in result.output 15 | 16 | 17 | def test_local_user(run_line, go_ep1_id): 18 | """ 19 | Confirms --local-user makes it to the request body. 20 | """ 21 | load_response_set("cli.rename_result") 22 | 23 | run_line(f"globus rename {go_ep1_id} foo/bar /baz/buzz --local-user my-user") 24 | 25 | sent_data = json.loads(get_last_request().body) 26 | assert sent_data["local_user"] == "my-user" 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CLI_VERSION=$(shell grep '^__version__' src/globus_cli/version.py | cut -d '"' -f2) 2 | 3 | .venv: 4 | python -m venv --upgrade-deps .venv 5 | .venv/bin/pip install -e '.' 6 | .venv/bin/pip install --group dev 7 | 8 | .PHONY: install 9 | install: .venv 10 | 11 | 12 | .PHONY: lint test reference 13 | lint: 14 | tox -e lint,mypy 15 | reference: 16 | tox -e reference 17 | test: 18 | tox 19 | 20 | .PHONY: showvars prepare-release tag-release 21 | showvars: 22 | @echo "CLI_VERSION=$(CLI_VERSION)" 23 | prepare-release: 24 | tox -e prepare-release 25 | tag-release: 26 | git tag -s "$(CLI_VERSION)" -m "v$(CLI_VERSION)" 27 | -git push $(shell git rev-parse --abbrev-ref @{push} | cut -d '/' -f1) refs/tags/$(CLI_VERSION) 28 | 29 | .PHONY: update-dependencies 30 | update-dependencies: 31 | python ./scripts/update_dependencies.py 32 | 33 | .PHONY: clean 34 | clean: 35 | rm -rf .venv .tox dist build *.egg-info 36 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/list.py: -------------------------------------------------------------------------------- 1 | from globus_cli.login_manager import LoginManager 2 | from globus_cli.parsing import command 3 | from globus_cli.termio import Field, display, formatters 4 | 5 | from ._common import SESSION_ENFORCEMENT_FIELD 6 | 7 | 8 | @command("list", short_help="List groups you belong to.") 9 | @LoginManager.requires_login("groups") 10 | def group_list(login_manager: LoginManager) -> None: 11 | """List all groups for the current user.""" 12 | groups_client = login_manager.get_groups_client() 13 | 14 | groups = groups_client.get_my_groups() 15 | 16 | display( 17 | groups, 18 | fields=[ 19 | Field("Group ID", "id"), 20 | Field("Name", "name"), 21 | Field("Type", "group_type"), 22 | SESSION_ENFORCEMENT_FIELD, 23 | Field("Roles", "my_memberships[].role", formatter=formatters.SortedArray), 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/role/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.commands.gcs.endpoint.role._common import role_fields 6 | from globus_cli.login_manager import LoginManager 7 | from globus_cli.parsing import command, endpoint_id_arg 8 | from globus_cli.termio import display 9 | 10 | 11 | @command("show") 12 | @endpoint_id_arg 13 | @click.argument("ROLE_ID", type=click.UUID) 14 | @LoginManager.requires_login("transfer") 15 | def show_command( 16 | login_manager: LoginManager, 17 | *, 18 | endpoint_id: uuid.UUID, 19 | role_id: uuid.UUID, 20 | ) -> None: 21 | """Describe a particular role on a GCS Endpoint.""" 22 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 23 | auth_client = login_manager.get_auth_client() 24 | 25 | res = gcs_client.get_role(role_id) 26 | 27 | display(res, text_mode=display.RECORD, fields=role_fields(auth_client)) 28 | -------------------------------------------------------------------------------- /src/globus_cli/exception_handling/hooks/endpoint_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | from globus_cli.endpointish import WrongEntityTypeError 6 | 7 | from ..registry import error_handler 8 | 9 | 10 | @error_handler(error_class=WrongEntityTypeError, exit_status=3) 11 | def wrong_endpoint_type_error_hook(exception: WrongEntityTypeError) -> None: 12 | msg = exception.expected_message + "\n" + exception.actual_message + "\n\n" 13 | click.secho(msg, fg="yellow", err=True) 14 | 15 | should_use = exception.should_use_command() 16 | if should_use: 17 | click.echo( 18 | "Please run the following command instead:\n\n" 19 | f" {should_use} {exception.endpoint_id}\n", 20 | err=True, 21 | ) 22 | else: 23 | msg = "This operation is not supported on objects of this type." 24 | click.secho(msg, fg="red", bold=True, err=True) 25 | -------------------------------------------------------------------------------- /tests/unit/test_entity_type.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from globus_cli.endpointish import EntityType 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "doc,expected", 8 | [ 9 | ({}, EntityType.UNRECOGNIZED), 10 | ({"entity_type": "foo"}, EntityType.UNRECOGNIZED), 11 | ({"entity_type": "GCP_mapped_collection"}, EntityType.GCP_MAPPED), 12 | ({"entity_type": "GCP_guest_collection"}, EntityType.GCP_GUEST), 13 | ({"entity_type": "GCSv5_endpoint"}, EntityType.GCSV5_ENDPOINT), 14 | ({"entity_type": "GCSv5_mapped_collection"}, EntityType.GCSV5_MAPPED), 15 | ({"entity_type": "GCSv5_guest_collection"}, EntityType.GCSV5_GUEST), 16 | ({"entity_type": "GCSv4_host"}, EntityType.GCSV4_HOST), 17 | ({"entity_type": "GCSv4_share"}, EntityType.GCSV4_SHARE), 18 | ], 19 | ) 20 | def test_determine_entity_type(doc, expected): 21 | assert EntityType.determine_entity_type(doc) == expected 22 | -------------------------------------------------------------------------------- /src/globus_cli/endpointish/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Endpointish, a dialect of Elvish spoken only within Globus. 3 | 4 | 5 | The Endpointish is mostly an Endpoint document from the Transfer service, wrapped 6 | with additional helpers and functionality. 7 | 8 | Importantly, it is not meant to represent an Endpoint either in the sense of GCSv4 and 9 | GCP nor in the sense of GCSv5. It represents a wrapped Endpoint document, with 10 | introspection and other niceties. 11 | 12 | Think of it as a TransferClient.get_endpoint call + a location to cache the result + any 13 | decoration we might want for this. 14 | """ 15 | 16 | from .endpointish import Endpointish 17 | from .entity_type import EntityType 18 | from .errors import ExpectedCollectionError, ExpectedEndpointError, WrongEntityTypeError 19 | 20 | __all__ = [ 21 | "Endpointish", 22 | "WrongEntityTypeError", 23 | "ExpectedCollectionError", 24 | "ExpectedEndpointError", 25 | "EntityType", 26 | ] 27 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/run/show_definition.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from globus_cli.commands.flows._common import FlowScopeInjector 6 | from globus_cli.login_manager import LoginManager 7 | from globus_cli.parsing import command, run_id_arg 8 | from globus_cli.termio import display 9 | 10 | 11 | @command("show-definition", short_help="Show a run's flow definition and input schema.") 12 | @run_id_arg 13 | @LoginManager.requires_login("auth", "flows", "search") 14 | def show_definition_command(login_manager: LoginManager, *, run_id: uuid.UUID) -> None: 15 | """ 16 | Show the flow definition and input schema used to start a given run. 17 | """ 18 | 19 | flows_client = login_manager.get_flows_client() 20 | 21 | with FlowScopeInjector(login_manager).for_run(run_id): 22 | response = flows_client.get_run_definition(run_id) 23 | 24 | display(response, text_mode=display.JSON, sort_json_keys=False) 25 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command, endpoint_id_arg 5 | from globus_cli.termio import display 6 | 7 | from ._common import user_credential_id_arg 8 | 9 | 10 | @command("delete", short_help="Delete a specific User Credential on an Endpoint.") 11 | @endpoint_id_arg 12 | @user_credential_id_arg() 13 | @LoginManager.requires_login("auth", "transfer") 14 | def user_credential_delete( 15 | login_manager: LoginManager, 16 | *, 17 | endpoint_id: uuid.UUID, 18 | user_credential_id: uuid.UUID, 19 | ) -> None: 20 | """ 21 | Delete a specific User Credential on a given Globus Connect Server v5 Endpoint. 22 | """ 23 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 24 | 25 | res = gcs_client.delete_user_credential(user_credential_id) 26 | 27 | display(res, simple_text=res.data.get("message")) 28 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/get_subscription_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command 9 | from globus_cli.termio import Field, display 10 | 11 | from ._common import SUBSCRIPTION_FIELDS 12 | 13 | 14 | @click.argument("subscription_id", type=click.UUID) 15 | @command("get-subscription-info") 16 | @LoginManager.requires_login("groups") 17 | def group_get_subscription_info( 18 | login_manager: LoginManager, *, subscription_id: uuid.UUID 19 | ) -> None: 20 | """Show data about a specific Subscription.""" 21 | groups_client = login_manager.get_groups_client() 22 | 23 | subscription_data = groups_client.get_group_by_subscription_id(subscription_id) 24 | display( 25 | subscription_data, 26 | text_mode=display.RECORD, 27 | fields=[Field("Group ID", "group_id")] + SUBSCRIPTION_FIELDS, 28 | ) 29 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/task/list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import Field, display 6 | 7 | from .._common import index_id_arg 8 | 9 | TASK_FIELDS = [ 10 | Field("State", "state"), 11 | Field("Task ID", "task_id"), 12 | Field("Creation Date", "creation_date"), 13 | Field("Completion Date", "completion_date"), 14 | ] 15 | 16 | 17 | @command("list", short_help="List recent tasks for an index.") 18 | @index_id_arg 19 | @LoginManager.requires_login("search") 20 | def list_command(login_manager: LoginManager, *, index_id: uuid.UUID) -> None: 21 | """List the 1000 most recent tasks for an index.""" 22 | search_client = login_manager.get_search_client() 23 | display( 24 | search_client.get_task_list(index_id), 25 | fields=TASK_FIELDS, 26 | text_mode=display.TABLE, 27 | response_key="tasks", 28 | ) 29 | -------------------------------------------------------------------------------- /src/globus_cli/termio/printers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing as t 5 | 6 | import globus_sdk 7 | 8 | from globus_cli.types import JsonValue 9 | 10 | from ..context import get_jmespath_expression 11 | 12 | DataType = t.TypeVar("DataType") 13 | 14 | 15 | class Printer(abc.ABC, t.Generic[DataType]): 16 | 17 | @abc.abstractmethod 18 | def echo(self, data: DataType, stream: t.IO[str] | None = None) -> None: 19 | raise NotImplementedError 20 | 21 | @classmethod 22 | def jmespath_preprocess( 23 | cls, res: JsonValue | globus_sdk.GlobusHTTPResponse 24 | ) -> t.Any: 25 | jmespath_expr = get_jmespath_expression() 26 | 27 | if isinstance(res, globus_sdk.GlobusHTTPResponse): 28 | res = res.data 29 | 30 | if not isinstance(res, str): 31 | if jmespath_expr is not None: 32 | res = jmespath_expr.search(res) 33 | 34 | return res 35 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/show.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from globus_cli.commands.flows._common import FlowScopeInjector 6 | from globus_cli.commands.flows._fields import flow_format_fields 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command, flow_id_arg 9 | from globus_cli.termio import display 10 | 11 | 12 | @command("show") 13 | @flow_id_arg 14 | @LoginManager.requires_login("auth", "flows", "search") 15 | def show_command(login_manager: LoginManager, *, flow_id: uuid.UUID) -> None: 16 | """ 17 | Show a flow. 18 | """ 19 | flows_client = login_manager.get_flows_client() 20 | auth_client = login_manager.get_auth_client() 21 | 22 | with FlowScopeInjector(login_manager).for_flow(flow_id): 23 | res = flows_client.get_flow(flow_id) 24 | 25 | fields = flow_format_fields(auth_client, res.data) 26 | 27 | display(res, fields=fields, text_mode=display.RECORD) 28 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/index/role/list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import Field, display 6 | 7 | from ..._common import index_id_arg, resolved_principals_field 8 | 9 | 10 | @command("list") 11 | @index_id_arg 12 | @LoginManager.requires_login("auth", "search") 13 | def list_command(login_manager: LoginManager, *, index_id: uuid.UUID) -> None: 14 | """List roles on an index (requires admin).""" 15 | search_client = login_manager.get_search_client() 16 | auth_client = login_manager.get_auth_client() 17 | 18 | res = search_client.get_role_list(index_id) 19 | display( 20 | res, 21 | fields=[ 22 | Field("ID", "id"), 23 | Field("Role", "role_name"), 24 | resolved_principals_field(auth_client, res["role_list"]), 25 | ], 26 | text_mode=display.TABLE, 27 | response_key="role_list", 28 | ) 29 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/run/cancel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from globus_cli.commands.flows._common import FlowScopeInjector 6 | from globus_cli.commands.flows._fields import flow_run_format_fields 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command, run_id_arg 9 | from globus_cli.termio import display 10 | 11 | 12 | @command("cancel") 13 | @run_id_arg 14 | @LoginManager.requires_login("auth", "flows", "search") 15 | def cancel_command(login_manager: LoginManager, *, run_id: uuid.UUID) -> None: 16 | """ 17 | Cancel a run. 18 | """ 19 | 20 | flows_client = login_manager.get_flows_client() 21 | auth_client = login_manager.get_auth_client() 22 | 23 | with FlowScopeInjector(login_manager).for_run(run_id): 24 | res = flows_client.cancel_run(run_id) 25 | 26 | fields = flow_run_format_fields(auth_client, res.data) 27 | 28 | display(res, fields=fields, text_mode=display.RECORD) 29 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/run/delete.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from globus_cli.commands.flows._common import FlowScopeInjector 6 | from globus_cli.commands.flows._fields import flow_run_format_fields 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command, run_id_arg 9 | from globus_cli.termio import display 10 | 11 | 12 | @command("delete") 13 | @run_id_arg 14 | @LoginManager.requires_login("auth", "flows", "search") 15 | def delete_command(login_manager: LoginManager, *, run_id: uuid.UUID) -> None: 16 | """ 17 | Delete a run. 18 | """ 19 | 20 | flows_client = login_manager.get_flows_client() 21 | auth_client = login_manager.get_auth_client() 22 | 23 | with FlowScopeInjector(login_manager).for_run(run_id): 24 | res = flows_client.delete_run(run_id) 25 | 26 | fields = flow_run_format_fields(auth_client, res.data) 27 | 28 | display(res, fields=fields, text_mode=display.RECORD) 29 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/task/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import Field, display, formatters 6 | 7 | from .._common import task_id_arg 8 | 9 | TASK_FIELDS = [ 10 | Field( 11 | "State", 12 | "[state, state_description]", 13 | formatter=formatters.ParentheticalDescriptionFormatter(), 14 | ), 15 | Field("Index ID", "index_id"), 16 | Field("Message", "message"), 17 | Field("Creation Date", "creation_date"), 18 | Field("Completion Date", "completion_date"), 19 | ] 20 | 21 | 22 | @command("show") 23 | @task_id_arg 24 | @LoginManager.requires_login("search") 25 | def show_command(login_manager: LoginManager, *, task_id: uuid.UUID) -> None: 26 | """Display a task.""" 27 | search_client = login_manager.get_search_client() 28 | display( 29 | search_client.get_task(task_id), fields=TASK_FIELDS, text_mode=display.RECORD 30 | ) 31 | -------------------------------------------------------------------------------- /tests/functional/task/test_task_show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_sdk.testing import load_response_set 4 | 5 | ID_ZERO = uuid.UUID(int=0) 6 | 7 | 8 | def test_skipped_errors(run_line): 9 | """ 10 | Confirms --skipped-errors option for task show parses the output of 11 | GET /task//skipped_errors 12 | """ 13 | meta = load_response_set("cli.skipped_error_list").metadata 14 | 15 | result = run_line(f"globus task show --skipped-errors {meta['task_id']}") 16 | assert "/~/no-such-file" in result.output 17 | assert "FILE_NOT_FOUND" in result.output 18 | assert "/~/restricted-file" in result.output 19 | assert "PERMISSION_DENIED" in result.output 20 | 21 | 22 | def test_mutex_options(run_line): 23 | result = run_line( 24 | f"globus task show --skipped-errors --successful-transfers {ID_ZERO}", 25 | assert_exit_code=2, 26 | ) 27 | assert ( 28 | "--successful-transfers and --skipped-errors are mutually exclusive" 29 | in result.stderr 30 | ) 31 | -------------------------------------------------------------------------------- /tests/functional/test_mkdir.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import globus_sdk 4 | from globus_sdk.testing import get_last_request, load_response 5 | 6 | 7 | def test_simple_mkdir_success(run_line): 8 | """ 9 | Just confirm that args make it through the command successfully and we render the 10 | message as output. 11 | """ 12 | meta = load_response(globus_sdk.TransferClient.operation_mkdir).metadata 13 | endpoint_id = meta["endpoint_id"] 14 | 15 | result = run_line(f"globus mkdir {endpoint_id}:foo/") 16 | assert "The directory was created successfully" in result.output 17 | 18 | 19 | def test_local_user(run_line): 20 | """ 21 | Confirms --local-user makes it to the request body. 22 | """ 23 | meta = load_response(globus_sdk.TransferClient.operation_mkdir).metadata 24 | endpoint_id = meta["endpoint_id"] 25 | 26 | run_line(f"globus mkdir {endpoint_id}:foo/ --local-user my-user") 27 | 28 | sent_data = json.loads(get_last_request().body) 29 | assert sent_data["local_user"] == "my-user" 30 | -------------------------------------------------------------------------------- /tests/unit/test_client_login.py: -------------------------------------------------------------------------------- 1 | import globus_sdk 2 | import pytest 3 | 4 | from globus_cli.login_manager import get_client_login, is_client_login 5 | 6 | 7 | def test_is_client_login_success(client_login): 8 | assert is_client_login() is True 9 | 10 | 11 | def test_is_client_login_no_login(): 12 | assert is_client_login() is False 13 | 14 | 15 | def test_is_client_login_no_secret(client_login_no_secret): 16 | with pytest.raises(ValueError): 17 | is_client_login() 18 | 19 | 20 | def test_get_client_login_success(client_login): 21 | client = get_client_login() 22 | assert isinstance(client, globus_sdk.ConfidentialAppAuthClient) 23 | assert client.authorizer.username == "fake_client_id" 24 | assert client.authorizer.password == "fake_client_secret" 25 | 26 | 27 | def test_get_client_login_no_login(): 28 | with pytest.raises(ValueError): 29 | get_client_login() 30 | 31 | 32 | def test_get_client_login_no_secret(client_login_no_secret): 33 | with pytest.raises(ValueError): 34 | get_client_login() 35 | -------------------------------------------------------------------------------- /src/globus_cli/termio/printers/json_printer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing as t 5 | 6 | import click 7 | import globus_sdk 8 | 9 | from globus_cli.types import JsonValue 10 | 11 | from .base import Printer 12 | 13 | DataObject = t.Union[JsonValue, globus_sdk.GlobusHTTPResponse] 14 | 15 | 16 | class JsonPrinter(Printer[DataObject]): 17 | """ 18 | A printer to render a json data object in a pretty-printed format: 19 | 20 | { 21 | "a": "b", 22 | "c": [ 23 | "d", 24 | "e" 25 | ], 26 | "f": 7 27 | } 28 | 29 | :param sort_keys: if True, sort the keys of the json object before printing. 30 | """ 31 | 32 | def __init__(self, *, sort_keys: bool = True) -> None: 33 | self._sort_keys = sort_keys 34 | 35 | def echo(self, data: DataObject, stream: t.IO[str] | None = None) -> None: 36 | res = JsonPrinter.jmespath_preprocess(data) 37 | res = json.dumps(res, indent=2, sort_keys=self._sort_keys) 38 | click.echo(res, file=stream) 39 | -------------------------------------------------------------------------------- /tests/functional/search/test_task_commands.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response_set 2 | 3 | 4 | def test_task_show(run_line): 5 | meta = load_response_set("cli.search").metadata 6 | task_id = meta["task_id"] 7 | 8 | result = run_line(["globus", "search", "task", "show", task_id]) 9 | assert "SUCCESS (Task succeeded)" in result.output 10 | 11 | 12 | def test_task_list(run_line): 13 | meta = load_response_set("cli.search").metadata 14 | index_id = meta["index_id"] 15 | success_task_id = meta["task_id"] 16 | pending_task_id = meta["pending_task_id"] 17 | 18 | result = run_line(["globus", "search", "task", "list", index_id]) 19 | 20 | found_success, found_pending = False, False 21 | for line in result.output.split("\n"): 22 | if success_task_id in line: 23 | found_success = True 24 | assert "SUCCESS" in line 25 | elif pending_task_id in line: 26 | found_pending = True 27 | assert "PENDING" in line 28 | assert found_success 29 | assert found_pending 30 | -------------------------------------------------------------------------------- /tests/functional/endpoint/permission/test_endpoint_permission_create.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from globus_sdk.testing import load_response_set 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "principal", 7 | ( 8 | "--anonymous", 9 | "--all-authenticated", 10 | "--group 2c9a8a6b-b0b7-4f30-acc4-37e741a311c7", 11 | "--identity 7c939269-fafe-43d4-bbb5-d4e6321643a8", 12 | ), 13 | ) 14 | def test_endpoint_permission_create_with_standard_principals(run_line, principal): 15 | meta = load_response_set("cli.endpoint_operations").metadata 16 | ep_id = meta["endpoint_id"] 17 | 18 | result = run_line( 19 | f"globus endpoint permission create {ep_id}:/ --permissions r {principal}" 20 | ) 21 | assert "Access rule created successfully" in result.output 22 | 23 | 24 | def test_endpoint_permission_create_with_no_principal(run_line): 25 | ep_id = "4c799ca5-6525-44d0-8887-a4bece7f4e09" 26 | 27 | run_line( 28 | f"globus endpoint permission create {ep_id}:/ --permissions r", 29 | assert_exit_code=2, 30 | ) 31 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/storage_gateway/list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command, endpoint_id_arg 5 | from globus_cli.termio import Field, display, formatters 6 | 7 | STANDARD_FIELDS = [ 8 | Field("ID", "id"), 9 | Field("Display Name", "display_name"), 10 | Field("High Assurance", "high_assurance"), 11 | Field("Allowed Domains", "allowed_domains", formatter=formatters.SortedArray), 12 | ] 13 | 14 | 15 | @command("list", short_help="List the Storage Gateways on an Endpoint.") 16 | @endpoint_id_arg 17 | @LoginManager.requires_login("auth", "transfer") 18 | def storage_gateway_list( 19 | login_manager: LoginManager, 20 | *, 21 | endpoint_id: uuid.UUID, 22 | ) -> None: 23 | """ 24 | List the Storage Gateways on a given Globus Connect Server v5 Endpoint. 25 | """ 26 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 27 | res = gcs_client.get_storage_gateway_list() 28 | display(res, text_mode=display.TABLE, fields=STANDARD_FIELDS) 29 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.termio import Field, formatters 9 | from globus_cli.types import AnyCommand 10 | 11 | C = t.TypeVar("C", bound=AnyCommand) 12 | 13 | 14 | def index_id_arg(f: C) -> C: 15 | return click.argument("index_id", metavar="INDEX_ID", type=click.UUID)(f) 16 | 17 | 18 | def task_id_arg(f: C) -> C: 19 | return click.argument("task_id", metavar="TASK_ID", type=click.UUID)(f) 20 | 21 | 22 | def resolved_principals_field( 23 | auth_client: globus_sdk.AuthClient, 24 | items: t.Iterable[dict[str, t.Any]] | None = None, 25 | ) -> Field: 26 | formatter = formatters.auth.PrincipalURNFormatter(auth_client) 27 | if items is not None: 28 | formatter.add_items(*items) 29 | 30 | return Field("Principal", "principal", formatter=formatter) 31 | 32 | 33 | INDEX_FIELDS = [ 34 | Field("Index ID", "id"), 35 | Field("Display Name", "display_name"), 36 | Field("Status", "status"), 37 | ] 38 | -------------------------------------------------------------------------------- /tests/functional/groups/test_group_create.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | 4 | import pytest 5 | from globus_sdk.testing import get_last_request, load_response_set 6 | 7 | 8 | @pytest.mark.parametrize("parent_group_id", (None, str(uuid.UUID(int=0)))) 9 | def test_group_create(run_line, parent_group_id): 10 | """ 11 | Basic success test for globus group create. 12 | """ 13 | meta = load_response_set("cli.groups").metadata 14 | 15 | group1_id = meta["group1_id"] 16 | group1_name = meta["group1_name"] 17 | group1_description = meta["group1_description"] 18 | 19 | cmd = [ 20 | "globus", 21 | "group", 22 | "create", 23 | group1_name, 24 | "--description", 25 | group1_description, 26 | ] 27 | if parent_group_id: 28 | cmd.extend(["--parent-id", parent_group_id]) 29 | result = run_line(cmd) 30 | 31 | assert f"Group {group1_id} created successfully" in result.output 32 | 33 | last_req = get_last_request() 34 | sent = json.loads(last_req.body) 35 | assert sent["parent_id"] == parent_group_id 36 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/delete.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from globus_cli.commands.flows._common import FlowScopeInjector 6 | from globus_cli.commands.flows._fields import flow_format_fields 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command, flow_id_arg 9 | from globus_cli.termio import Field, display, formatters 10 | 11 | 12 | @command("delete") 13 | @flow_id_arg 14 | @LoginManager.requires_login("auth", "flows", "search") 15 | def delete_command(login_manager: LoginManager, *, flow_id: uuid.UUID) -> None: 16 | """ 17 | Delete a flow. 18 | """ 19 | flows_client = login_manager.get_flows_client() 20 | auth_client = login_manager.get_auth_client() 21 | 22 | with FlowScopeInjector(login_manager).for_flow(flow_id): 23 | res = flows_client.delete_flow(flow_id) 24 | 25 | fields = [ 26 | Field("Deleted", "DELETED", formatter=formatters.Bool), 27 | *flow_format_fields(auth_client, res.data), 28 | ] 29 | 30 | display(res, fields=fields, text_mode=display.RECORD) 31 | -------------------------------------------------------------------------------- /src/globus_cli/exception_handling/hooks/transfer_hooks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import globus_sdk 4 | 5 | from globus_cli.termio import PrintableErrorField, write_error_info 6 | 7 | from ..messages import emit_unauthorized_message 8 | from ..registry import sdk_error_handler 9 | 10 | 11 | @sdk_error_handler( 12 | error_class="TransferAPIError", 13 | condition=lambda err: err.code == "ClientError.AuthenticationFailed", 14 | ) 15 | def transfer_unauthenticated_hook(exception: globus_sdk.TransferAPIError) -> None: 16 | emit_unauthorized_message() 17 | 18 | 19 | @sdk_error_handler(error_class="TransferAPIError") 20 | def transferapi_hook(exception: globus_sdk.TransferAPIError) -> None: 21 | write_error_info( 22 | "Transfer API Error", 23 | [ 24 | PrintableErrorField("HTTP status", exception.http_status), 25 | PrintableErrorField("request_id", exception.request_id), 26 | PrintableErrorField("code", exception.code), 27 | PrintableErrorField("message", exception.message, multiline=True), 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /src/globus_cli/termio/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | from . import auth 2 | from .base import FieldFormatter, FormattingFailedWarning 3 | from .compound import ( 4 | ArrayFormatter, 5 | ParentheticalDescriptionFormatter, 6 | SortedJsonFormatter, 7 | ) 8 | from .primitive import ( 9 | BoolFormatter, 10 | DateFormatter, 11 | FuzzyBoolFormatter, 12 | StaticStringFormatter, 13 | StrFormatter, 14 | ) 15 | 16 | Str = StrFormatter() 17 | Date = DateFormatter() 18 | Bool = BoolFormatter() 19 | FuzzyBool = FuzzyBoolFormatter() 20 | SortedJson = SortedJsonFormatter() 21 | Array = ArrayFormatter() 22 | SortedArray = ArrayFormatter(sort=True) 23 | 24 | __all__ = ( 25 | "FormattingFailedWarning", 26 | "FieldFormatter", 27 | "StrFormatter", 28 | "DateFormatter", 29 | "BoolFormatter", 30 | "FuzzyBoolFormatter", 31 | "StaticStringFormatter", 32 | "ArrayFormatter", 33 | "SortedJsonFormatter", 34 | "ParentheticalDescriptionFormatter", 35 | "Str", 36 | "Date", 37 | "Bool", 38 | "FuzzyBool", 39 | "SortedJson", 40 | "Array", 41 | "SortedArray", 42 | "auth", 43 | ) 44 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "group", 6 | lazy_subcommands={ 7 | "create": (".create", "group_create"), 8 | "delete": (".delete", "group_delete"), 9 | "invite": (".invite", "group_invite"), 10 | "join": (".join", "group_join"), 11 | "leave": (".leave", "group_leave"), 12 | "list": (".list", "group_list"), 13 | "member": (".member", "group_member"), 14 | "set-policies": (".set_policies", "group_set_policies"), 15 | "show": (".show", "group_show"), 16 | "get-subscription-info": ( 17 | ".get_subscription_info", 18 | "group_get_subscription_info", 19 | ), 20 | "get-by-subscription": (".get_by_subscription", "group_get_by_subscription"), 21 | "update": (".update", "group_update"), 22 | "set-subscription-admin-verified": ( 23 | ".set_subscription_admin_verified", 24 | "group_set_subscription_admin_verified", 25 | ), 26 | }, 27 | ) 28 | def group_command() -> None: 29 | """Manage Globus Groups.""" 30 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.endpointish import Endpointish 4 | from globus_cli.login_manager import LoginManager 5 | from globus_cli.parsing import command, endpoint_id_arg 6 | from globus_cli.termio import display 7 | 8 | 9 | @command( 10 | "delete", 11 | short_help="Delete an endpoint.", 12 | adoc_examples="""[source,bash] 13 | ---- 14 | $ ep_id=aa752cea-8222-5bc8-acd9-555b090c0ccb 15 | $ globus endpoint delete $ep_id 16 | ---- 17 | """, 18 | ) 19 | @endpoint_id_arg 20 | @LoginManager.requires_login("transfer") 21 | def endpoint_delete(login_manager: LoginManager, *, endpoint_id: uuid.UUID) -> None: 22 | """Delete a given endpoint. 23 | 24 | WARNING: Deleting an endpoint will permanently disable any existing shared 25 | endpoints that are hosted on it. 26 | """ 27 | transfer_client = login_manager.get_transfer_client() 28 | Endpointish( 29 | endpoint_id, transfer_client=transfer_client 30 | ).assert_is_traditional_endpoint() 31 | 32 | res = transfer_client.delete_endpoint(endpoint_id) 33 | display(res, text_mode=display.RAW, response_key="message") 34 | -------------------------------------------------------------------------------- /src/globus_cli/parsing/param_types/location.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import typing as t 5 | 6 | import click 7 | 8 | 9 | class LocationType(click.ParamType): 10 | """ 11 | Validates that given location string is two comma separated floats 12 | """ 13 | 14 | name = "LATITUDE,LONGITUDE" 15 | 16 | def convert( 17 | self, value: t.Any, param: click.Parameter | None, ctx: click.Context | None 18 | ) -> str: 19 | match_result = re.match(r"^([^,]+),([^,]+)$", value) 20 | if not match_result: 21 | self.fail( 22 | f"location '{value}' does not match the expected " 23 | "'latitude,longitude' format" 24 | ) 25 | 26 | maybe_lat = match_result.group(1) 27 | maybe_lon = match_result.group(2) 28 | 29 | try: 30 | float(maybe_lat) 31 | float(maybe_lon) 32 | except ValueError: 33 | self.fail( 34 | f"location '{value}' is not a well-formed 'latitude,longitude' pair" 35 | ) 36 | else: 37 | return f"{maybe_lat},{maybe_lon}" 38 | -------------------------------------------------------------------------------- /src/globus_cli/services/transfer/__init__.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from globus_cli.termio import Field, formatters 4 | 5 | from .client import CustomTransferClient 6 | from .data import ( 7 | add_batch_to_transfer_data, 8 | assemble_generic_doc, 9 | display_name_or_cname, 10 | iterable_response_to_dict, 11 | ) 12 | from .recursive_ls import RecursiveLsResponse 13 | 14 | 15 | class _NameFormatter(formatters.StrFormatter): 16 | def parse(self, value: t.Any) -> str: 17 | if not isinstance(value, list) or len(value) != 2: 18 | raise ValueError("cannot parse display_name from malformed data") 19 | return str(value[0] or value[1]) 20 | 21 | 22 | ENDPOINT_LIST_FIELDS = [ 23 | Field("ID", "id"), 24 | Field("Owner", "owner_string"), 25 | Field("Display Name", "[display_name, canonical_name]", formatter=_NameFormatter()), 26 | ] 27 | 28 | 29 | __all__ = ( 30 | "ENDPOINT_LIST_FIELDS", 31 | "CustomTransferClient", 32 | "RecursiveLsResponse", 33 | "display_name_or_cname", 34 | "iterable_response_to_dict", 35 | "assemble_generic_doc", 36 | "add_batch_to_transfer_data", 37 | ) 38 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/my_shared_endpoint_list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command, endpoint_id_arg 5 | from globus_cli.termio import display 6 | 7 | 8 | @command( 9 | "my-shared-endpoint-list", 10 | short_help="List the current user's shared endpoints.", 11 | adoc_examples="""[source,bash] 12 | ---- 13 | $ ep_id=aa752cea-8222-5bc8-acd9-555b090c0ccb 14 | $ globus endpoint my-shared-endpoint-list $ep_id 15 | ---- 16 | """, 17 | ) 18 | @endpoint_id_arg 19 | @LoginManager.requires_login("transfer") 20 | def my_shared_endpoint_list( 21 | login_manager: LoginManager, *, endpoint_id: uuid.UUID 22 | ) -> None: 23 | """ 24 | Show a list of all shared endpoints hosted on the target endpoint for which the user 25 | has the "administrator" or "access_manager" effective roles. 26 | """ 27 | from globus_cli.services.transfer import ENDPOINT_LIST_FIELDS 28 | 29 | transfer_client = login_manager.get_transfer_client() 30 | ep_iterator = transfer_client.my_shared_endpoint_list(endpoint_id) 31 | 32 | display(ep_iterator, fields=ENDPOINT_LIST_FIELDS) 33 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | # build and release new docs as a release asset 2 | name: Build Docs 3 | on: 4 | release: 5 | types: [created] 6 | jobs: 7 | doc: 8 | name: Build Docs 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 12 | - name: Setup Python 13 | uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 14 | with: 15 | python-version: "3.12" 16 | - name: Install CLI 17 | run: | 18 | python -m pip install -e . 19 | - name: Generate Autodoc 20 | run: | 21 | ./reference/_generate.py --debug 22 | # bundle as tarball without the _generate.py script or .gitignore 23 | # use `-h` to dereference the changelog link 24 | tar --exclude "*.py" --exclude '.gitignore' -czf cli-reference.tar.gz -h reference/ 25 | # upload as a release asset 26 | - name: Upload Autodoc 27 | env: 28 | GH_TOKEN: ${{ github.token }} 29 | GH_REPO: ${{ github.repository }} 30 | run: gh release upload "${{ github.ref_name }}" cli-reference.tar.gz 31 | -------------------------------------------------------------------------------- /.github/workflows/has_changelog.yaml: -------------------------------------------------------------------------------- 1 | name: has_changelog 2 | on: 3 | pull_request: 4 | types: 5 | - labeled 6 | - unlabeled 7 | - opened 8 | - reopened 9 | - synchronize 10 | 11 | jobs: 12 | check_has_news_in_changelog_dir: 13 | if: | 14 | ! ( 15 | contains(github.event.pull_request.labels.*.name, 'no-news-is-good-news') || 16 | github.event.pull_request.user.login == 'pre-commit-ci[bot]' || 17 | github.event.pull_request.user.login == 'dependabot[bot]' 18 | ) 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 22 | with: # do a deep fetch to allow merge-base and diff 23 | fetch-depth: 0 24 | - name: check PR adds a news file 25 | run: | 26 | news_files="$(git diff --name-only "$(git merge-base origin/main "$GITHUB_SHA")" "$GITHUB_SHA" -- changelog.d/*.md)" 27 | if [ -n "$news_files" ]; then 28 | echo "Saw new files. changelog.d:" 29 | echo "$news_files" 30 | else 31 | echo "No news files seen" 32 | exit 1 33 | fi 34 | -------------------------------------------------------------------------------- /tests/unit/termio/printer/test_json_printer.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from globus_cli.termio.printers import JsonPrinter 4 | 5 | 6 | def test_json_printer_prints_with_sorted_keys(click_context): 7 | printer = JsonPrinter() 8 | data = {"b": 1, "a": 2, "c": 3} 9 | 10 | with StringIO() as stream: 11 | with click_context(): 12 | printer.echo(data, stream) 13 | printed_json = stream.getvalue() 14 | 15 | # fmt: off 16 | assert printed_json == ( 17 | "{\n" 18 | ' "a": 2,\n' 19 | ' "b": 1,\n' 20 | ' "c": 3\n' 21 | "}\n" 22 | ) 23 | # fmt: on 24 | 25 | 26 | def test_json_printer_prints_without_sorted_keys(click_context): 27 | printer = JsonPrinter(sort_keys=False) 28 | data = {"b": 1, "a": 2, "c": 3} 29 | 30 | with StringIO() as stream: 31 | with click_context(): 32 | printer.echo(data, stream) 33 | printed_json = stream.getvalue() 34 | 35 | # fmt: off 36 | assert printed_json == ( 37 | "{\n" 38 | ' "b": 1,\n' 39 | ' "a": 2,\n' 40 | ' "c": 3\n' 41 | "}\n" 42 | ) 43 | # fmt: on 44 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/role/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import collection_id_arg, command 7 | from globus_cli.termio import Field, display 8 | from globus_cli.termio.formatters.auth import PrincipalURNFormatter 9 | 10 | 11 | @command("show") 12 | @collection_id_arg 13 | @click.argument("ROLE_ID", type=click.UUID) 14 | @LoginManager.requires_login("transfer") 15 | def show_command( 16 | login_manager: LoginManager, 17 | *, 18 | collection_id: uuid.UUID, 19 | role_id: uuid.UUID, 20 | ) -> None: 21 | """Describe a particular role on a Collection.""" 22 | gcs_client = login_manager.get_gcs_client(collection_id=collection_id) 23 | auth_client = login_manager.get_auth_client() 24 | 25 | res = gcs_client.get_role(role_id) 26 | 27 | display( 28 | res, 29 | text_mode=display.RECORD, 30 | fields=[ 31 | Field("ID", "id"), 32 | Field("Role", "role"), 33 | Field( 34 | "Principal", "principal", formatter=PrincipalURNFormatter(auth_client) 35 | ), 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /src/globus_cli/commands/bookmark/delete.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from ._common import resolve_id_or_name 8 | 9 | 10 | @command( 11 | "delete", 12 | adoc_output=( 13 | "When textual output is requested, the response contains a message " 14 | "indicating the success or failure of the operation." 15 | ), 16 | adoc_examples="""Delete a bookmark by name: 17 | 18 | [source,bash] 19 | ---- 20 | $ globus bookmark delete "Bookmark Name" 21 | ---- 22 | """, 23 | short_help="Delete a bookmark.", 24 | ) 25 | @click.argument("bookmark_id_or_name") 26 | @LoginManager.requires_login("transfer") 27 | def bookmark_delete(login_manager: LoginManager, *, bookmark_id_or_name: str) -> None: 28 | """ 29 | Delete one bookmark, given its ID or name. 30 | """ 31 | transfer_client = login_manager.get_transfer_client() 32 | bookmark_id = resolve_id_or_name(transfer_client, bookmark_id_or_name)["id"] 33 | 34 | res = transfer_client.delete_bookmark(bookmark_id) 35 | display(res, text_mode=display.RAW, response_key="message") 36 | -------------------------------------------------------------------------------- /tests/functional/endpoint/test_storage_gateway_commands.py: -------------------------------------------------------------------------------- 1 | import globus_sdk 2 | from globus_sdk.testing import get_response_set, load_response_set 3 | 4 | 5 | def test_storage_gateway_list(add_gcs_login, run_line): 6 | load_response_set(globus_sdk.GCSClient.get_storage_gateway_list) 7 | get_response_set(globus_sdk.AuthClient.get_identities).lookup("default").add() 8 | 9 | meta = load_response_set("cli.collection_operations").metadata 10 | ep_id = meta["endpoint_id"] 11 | add_gcs_login(ep_id) 12 | 13 | line = f"globus endpoint storage-gateway list {ep_id}" 14 | result = run_line(line) 15 | 16 | print(result.output) 17 | 18 | expected = ( 19 | "ID | Display Name | High Assurance | Allowed Domains\n" # noqa: E501 20 | "------------------------------------ | ----------------- | -------------- | ---------------\n" # noqa: E501 21 | "a0cbde58-0183-11ea-92bd-9cb6d0d9fd63 | example gateway 1 | False | example.edu \n" # noqa: E501 22 | "6840c8ba-eb98-11e9-b89c-9cb6d0d9fd63 | example gateway 2 | False | example.edu \n" # noqa: E501 23 | ) 24 | assert expected == result.output 25 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/create/from_json.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import ( 7 | JSONStringOrFile, 8 | ParsedJSONData, 9 | command, 10 | endpoint_id_arg, 11 | ) 12 | from globus_cli.termio import display 13 | 14 | 15 | @command( 16 | "from-json", 17 | short_help="Create a User Credential from a JSON document.", 18 | ) 19 | @endpoint_id_arg 20 | @click.argument("user_credential_json", type=JSONStringOrFile()) 21 | @LoginManager.requires_login("auth", "transfer") 22 | def from_json( 23 | login_manager: LoginManager, 24 | *, 25 | endpoint_id: uuid.UUID, 26 | user_credential_json: ParsedJSONData, 27 | ) -> None: 28 | """ 29 | Create a User Credential on an endpoint from a JSON document. 30 | """ 31 | if not isinstance(user_credential_json.data, dict): 32 | raise click.UsageError("User Credential JSON must be a JSON object") 33 | 34 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 35 | res = gcs_client.create_user_credential(user_credential_json.data) 36 | display(res, simple_text=res.full_data.get("message")) 37 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish PyPI Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-dists: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 13 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 14 | with: 15 | python-version: "3.12" 16 | 17 | - run: python -m pip install build 18 | 19 | - name: Build Dists 20 | run: python -m build . 21 | 22 | - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 23 | with: 24 | name: packages 25 | path: dist/* 26 | 27 | publish_pypi: 28 | needs: [build-dists] 29 | runs-on: ubuntu-latest 30 | environment: publish-pypi 31 | permissions: 32 | id-token: write 33 | 34 | steps: 35 | - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 36 | with: 37 | name: packages 38 | path: dist 39 | 40 | - name: Publish to PyPI 41 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 42 | -------------------------------------------------------------------------------- /src/globus_cli/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Internal types for type annotations 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import sys 8 | import typing as t 9 | 10 | import click 11 | 12 | if sys.version_info >= (3, 10): 13 | from typing import TypeAlias 14 | else: 15 | from typing_extensions import TypeAlias 16 | 17 | # all imports from globus_cli modules done here are done under TYPE_CHECKING 18 | # in order to ensure that the use of type annotations never introduces circular 19 | # imports at runtime 20 | if t.TYPE_CHECKING: 21 | import globus_sdk 22 | 23 | 24 | AnyCallable: TypeAlias = t.Callable[..., t.Any] 25 | AnyCommand: TypeAlias = t.Union[click.Command, AnyCallable] 26 | 27 | 28 | ClickContextTree: TypeAlias = t.Tuple[ 29 | click.Context, t.List[click.Context], t.List["ClickContextTree"] 30 | ] 31 | 32 | 33 | DATA_CONTAINER_T: TypeAlias = t.Union[ 34 | t.Mapping[str, t.Any], 35 | "globus_sdk.GlobusHTTPResponse", 36 | ] 37 | 38 | JsonValue: TypeAlias = t.Union[ 39 | int, float, str, bool, None, t.List["JsonValue"], t.Dict[str, "JsonValue"] 40 | ] 41 | 42 | 43 | ServiceNameLiteral: TypeAlias = t.Literal[ 44 | "auth", "transfer", "groups", "search", "timers", "flows" 45 | ] 46 | -------------------------------------------------------------------------------- /src/globus_cli/exception_handling/hooks/authapi_hooks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import globus_sdk 4 | 5 | from globus_cli.termio import PrintableErrorField, write_error_info 6 | 7 | from ..messages import emit_unauthorized_message 8 | from ..registry import sdk_error_handler 9 | 10 | 11 | @sdk_error_handler( 12 | error_class="AuthAPIError", condition=lambda err: err.code == "UNAUTHORIZED" 13 | ) 14 | def authapi_unauthenticated_hook(exception: globus_sdk.AuthAPIError) -> None: 15 | emit_unauthorized_message() 16 | 17 | 18 | @sdk_error_handler( 19 | error_class="AuthAPIError", 20 | condition=lambda err: err.message == "invalid_grant", 21 | ) 22 | def invalidrefresh_hook(exception: globus_sdk.AuthAPIError) -> None: 23 | emit_unauthorized_message() 24 | 25 | 26 | @sdk_error_handler(error_class="AuthAPIError") 27 | def authapi_hook(exception: globus_sdk.AuthAPIError) -> None: 28 | write_error_info( 29 | "Auth API Error", 30 | [ 31 | PrintableErrorField("HTTP status", exception.http_status), 32 | PrintableErrorField("code", exception.code), 33 | PrintableErrorField("message", exception.message, multiline=True), 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /tests/unit/test_explicit_null.py: -------------------------------------------------------------------------------- 1 | from globus_cli.constants import EXPLICIT_NULL, ExplicitNullType 2 | 3 | 4 | def test_explicit_null_object_is_falsy(): 5 | assert bool(EXPLICIT_NULL) is False 6 | 7 | 8 | def test_explicit_null_object_matches_type(): 9 | assert isinstance(EXPLICIT_NULL, ExplicitNullType) 10 | 11 | 12 | def test_explicit_null_stringify(): 13 | assert str(EXPLICIT_NULL) == "null" 14 | 15 | 16 | def test_nullify(): 17 | assert ExplicitNullType.nullify(EXPLICIT_NULL) is None 18 | assert ExplicitNullType.nullify(None) is None 19 | assert ExplicitNullType.nullify(False) is False 20 | assert ExplicitNullType.nullify("foo") == "foo" 21 | 22 | 23 | def test_nullify_dict(): 24 | assert ExplicitNullType.nullify_dict({}) == {} 25 | assert ExplicitNullType.nullify_dict({"k": "v"}) == {"k": "v"} 26 | assert ExplicitNullType.nullify_dict({"k": None}) == {} 27 | assert ExplicitNullType.nullify_dict({"k": EXPLICIT_NULL}) == {"k": None} 28 | assert ExplicitNullType.nullify_dict({"k1": None, "k2": EXPLICIT_NULL}) == { 29 | "k2": None 30 | } 31 | assert ExplicitNullType.nullify_dict( 32 | {"k1": None, "k2": EXPLICIT_NULL, "k3": "v"} 33 | ) == {"k2": None, "k3": "v"} 34 | -------------------------------------------------------------------------------- /src/globus_cli/commands/bookmark/rename.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command 5 | from globus_cli.termio import display 6 | 7 | from ._common import resolve_id_or_name 8 | 9 | 10 | @command( 11 | "rename", 12 | adoc_output=( 13 | "When textual output is requested, the only output on a successful rename " 14 | "is a success message." 15 | ), 16 | adoc_examples=""" 17 | Rename a bookmark named "oldname" to "newname": 18 | 19 | [source,bash] 20 | ---- 21 | $ globus bookmark rename oldname newname 22 | ---- 23 | """, 24 | ) 25 | @click.argument("bookmark_id_or_name") 26 | @click.argument("new_bookmark_name") 27 | @LoginManager.requires_login("transfer") 28 | def bookmark_rename( 29 | login_manager: LoginManager, *, bookmark_id_or_name: str, new_bookmark_name: str 30 | ) -> None: 31 | """Change a bookmark's name.""" 32 | transfer_client = login_manager.get_transfer_client() 33 | bookmark_id = resolve_id_or_name(transfer_client, bookmark_id_or_name)["id"] 34 | 35 | submit_data = {"name": new_bookmark_name} 36 | 37 | res = transfer_client.update_bookmark(bookmark_id, submit_data) 38 | display(res, simple_text="Success") 39 | -------------------------------------------------------------------------------- /tests/functional/groups/test_get_subscription_info.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_sdk.testing import RegisteredResponse 4 | 5 | 6 | def test_group_get_subscription_info_text(run_line): 7 | subscription_id = str(uuid.uuid1()) 8 | group_id = str(uuid.uuid1()) 9 | connector_id = str(uuid.uuid1()) 10 | 11 | RegisteredResponse( 12 | service="groups", 13 | path=f"/v2/subscription_info/{subscription_id}", 14 | json={ 15 | "group_id": group_id, 16 | "subscription_id": subscription_id, 17 | "subscription_info": { 18 | "connectors": { 19 | connector_id: { 20 | "is_baa": False, 21 | "is_ha": True, 22 | } 23 | }, 24 | "is_baa": False, 25 | "is_high_assurance": False, 26 | }, 27 | }, 28 | ).add() 29 | 30 | run_line( 31 | f"globus group get-subscription-info {subscription_id}", 32 | search_stdout=[ 33 | ("Group ID", group_id), 34 | ("Subscription ID", subscription_id), 35 | ("BAA", "False"), 36 | ("High Assurance", "False"), 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /tests/functional/test_gcs_aliasing.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests dedicated to the `globus gcs` aliasing of other commands, 3 | which specifically exercise the behavior of *aliasing* 4 | 5 | these tests do not exercise the aliased commands at all 6 | """ 7 | 8 | import pytest 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "from_command, to_command", 13 | ( 14 | ("collection delete", "gcs collection delete"), 15 | ("collection list", "gcs collection list"), 16 | ("endpoint storage-gateway list", "gcs storage-gateway list"), 17 | ("endpoint user-credential list", "gcs user-credential list"), 18 | ), 19 | ) 20 | def test_aliased_commands_have_unique_usage_lines(run_line, from_command, to_command): 21 | unaliased_help = run_line(f"globus {from_command} --help").stdout 22 | aliased_help = run_line(f"globus {to_command} --help").stdout 23 | 24 | unaliased_usage_line = unaliased_help.splitlines()[0] 25 | aliased_usage_line = aliased_help.splitlines()[0] 26 | 27 | # verify: 28 | # - they aren't the same 29 | assert unaliased_usage_line != aliased_usage_line 30 | # - they each start correctly 31 | assert f"globus {from_command}" in unaliased_usage_line 32 | assert f"globus {to_command}" in aliased_usage_line 33 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/create.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command 9 | from globus_cli.termio import display 10 | 11 | 12 | @command("create") 13 | @click.argument("name") 14 | @click.option("--description", help="Description for the group") 15 | @click.option( 16 | "--parent-id", 17 | type=click.UUID, 18 | help=( 19 | "Make the new group a subgroup of the specified parent group. " 20 | "You must be an admin of the parent group to do this." 21 | ), 22 | ) 23 | @LoginManager.requires_login("groups") 24 | def group_create( 25 | login_manager: LoginManager, 26 | *, 27 | name: str, 28 | description: str | None, 29 | parent_id: uuid.UUID | None, 30 | ) -> None: 31 | """Create a new group.""" 32 | groups_client = login_manager.get_groups_client() 33 | 34 | response = groups_client.create_group( 35 | { 36 | "name": name, 37 | "description": description, 38 | "parent_id": parent_id, 39 | } 40 | ) 41 | group_id = response["id"] 42 | 43 | display(response, simple_text=f"Group {group_id} created successfully") 44 | -------------------------------------------------------------------------------- /src/globus_cli/termio/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from ._display import display 4 | from .context import ( 5 | env_interactive, 6 | err_is_terminal, 7 | get_jmespath_expression, 8 | is_verbose, 9 | out_is_terminal, 10 | outformat_is_json, 11 | outformat_is_text, 12 | outformat_is_unix, 13 | term_is_interactive, 14 | verbosity, 15 | ) 16 | from .errors import PrintableErrorField, write_error_info 17 | from .field import Field 18 | 19 | 20 | def print_command_hint(message: str, *, color: str = "yellow") -> None: 21 | """ 22 | Wrapper around echo that checks terminal state 23 | before printing a given command hint message 24 | """ 25 | if term_is_interactive() and err_is_terminal() and out_is_terminal(): 26 | click.echo(click.style(message, fg=color), err=True) 27 | 28 | 29 | __all__ = [ 30 | "print_command_hint", 31 | "PrintableErrorField", 32 | "write_error_info", 33 | "Field", 34 | "display", 35 | "out_is_terminal", 36 | "env_interactive", 37 | "err_is_terminal", 38 | "term_is_interactive", 39 | "outformat_is_json", 40 | "outformat_is_text", 41 | "outformat_is_unix", 42 | "get_jmespath_expression", 43 | "verbosity", 44 | "is_verbose", 45 | ] 46 | -------------------------------------------------------------------------------- /tests/functional/test_stubbed_removals.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "command", 6 | [ 7 | pytest.param(["globus", "endpoint", "activate"], id="endpoint_activate"), 8 | pytest.param(["globus", "endpoint", "deactivate"], id="endpoint_deactivate"), 9 | pytest.param( 10 | ["globus", "endpoint", "is-activated"], id="endpoint_is_activated" 11 | ), 12 | pytest.param(["globus", "endpoint", "server"], id="endpoint_server"), 13 | pytest.param(["globus", "endpoint", "create"], id="endpoint_create"), 14 | ], 15 | ) 16 | @pytest.mark.parametrize( 17 | "add_args", 18 | [ 19 | pytest.param([], id="zero_args"), 20 | pytest.param(["foo"], id="one_arg"), 21 | pytest.param(["foo"] * 100, id="one_hundred_args"), 22 | pytest.param(["foo", "--bar", "--baz"], id="one_arg_two_opts"), 23 | pytest.param(["--bar", "quux"] * 10, id="ten_opts_with_values"), 24 | ], 25 | ) 26 | def test_stubbed_removal_emits_explicit_error(run_line, command, add_args): 27 | result = run_line([*command, *add_args], assert_exit_code=1) 28 | expect_message = f"`{' '.join(command)}` has been removed from the Globus CLI." 29 | assert expect_message in result.stderr 30 | -------------------------------------------------------------------------------- /tests/unit/test_version.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import responses 4 | from packaging.version import Version 5 | 6 | import globus_cli.version 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "injected_version, expected", 11 | ( 12 | ("0.0.0", globus_cli.version.__version__), 13 | ("1000.1000.1000", "1000.1000.1000"), 14 | ), 15 | ) 16 | def test_get_versions_success(injected_version, expected): 17 | # Only a portion of the PyPI response is needed. 18 | pypi_json_response = { 19 | "releases": { 20 | injected_version: [], 21 | globus_cli.version.__version__: [], 22 | } 23 | } 24 | responses.add( 25 | "GET", "https://pypi.python.org/pypi/globus-cli/json", json=pypi_json_response 26 | ) 27 | assert globus_cli.version.get_versions() == ( 28 | Version(expected), 29 | Version(globus_cli.version.__version__), 30 | ) 31 | 32 | 33 | def test_get_versions_failure(): 34 | responses.add( 35 | "GET", 36 | "https://pypi.python.org/pypi/globus-cli/json", 37 | body=requests.RequestException(), 38 | ) 39 | assert globus_cli.version.get_versions() == ( 40 | None, 41 | Version(globus_cli.version.__version__), 42 | ) 43 | -------------------------------------------------------------------------------- /tests/functional/groups/test_group_set_subscription_admin_verified.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from globus_sdk.testing import get_last_request, load_response_set 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "subscription_id, expected_value", 9 | [ 10 | ( 11 | "e787245d-b5d8-47d1-8ff1-74bc3c5d72f3", 12 | "e787245d-b5d8-47d1-8ff1-74bc3c5d72f3", 13 | ), 14 | ( 15 | "null", 16 | None, 17 | ), 18 | ], 19 | ) 20 | def test_group_set_subscription_admin_verified( 21 | run_line, subscription_id, expected_value 22 | ): 23 | """ 24 | Basic success tests for globus group subscription-verify. 25 | """ 26 | meta = load_response_set("cli.groups").metadata 27 | 28 | group1_id = meta["group1_id"] 29 | 30 | result = run_line( 31 | [ 32 | "globus", 33 | "group", 34 | "set-subscription-admin-verified", 35 | group1_id, 36 | subscription_id, 37 | ] 38 | ) 39 | 40 | assert "Group subscription verification updated successfully" in result.output 41 | 42 | last_req = get_last_request() 43 | sent = json.loads(last_req.body) 44 | assert sent["subscription_admin_verified_id"] == expected_value 45 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/permission/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command, endpoint_id_arg 7 | from globus_cli.termio import display 8 | 9 | 10 | @command( 11 | "delete", 12 | short_help="Delete an access control rule.", 13 | adoc_examples="""[source,bash] 14 | ---- 15 | $ ep_id=aa752cea-8222-5bc8-acd9-555b090c0ccb 16 | $ rule_id=1ddeddda-1ae8-11e7-bbe4-22000b9a448b 17 | $ globus endpoint permission delete $ep_id $rule_id 18 | ---- 19 | """, 20 | ) 21 | @endpoint_id_arg 22 | @click.argument("rule_id") 23 | @LoginManager.requires_login("transfer") 24 | def delete_command( 25 | login_manager: LoginManager, *, endpoint_id: uuid.UUID, rule_id: str 26 | ) -> None: 27 | """ 28 | Delete an existing access control rule, removing whatever permissions it previously 29 | granted users on the endpoint. 30 | 31 | Note you cannot remove the built in rule that gives the endpoint owner full 32 | read and write access to the endpoint. 33 | """ 34 | transfer_client = login_manager.get_transfer_client() 35 | 36 | res = transfer_client.delete_endpoint_acl_rule(endpoint_id, rule_id) 37 | display(res, text_mode=display.RAW, response_key="message") 38 | -------------------------------------------------------------------------------- /src/globus_cli/commands/mkdir.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.login_manager import LoginManager 9 | from globus_cli.parsing import ENDPOINT_PLUS_REQPATH, command, local_user_option 10 | from globus_cli.termio import display 11 | 12 | 13 | @command( 14 | "mkdir", 15 | short_help="Create a directory on an endpoint.", 16 | adoc_examples="""Create a directory under your home directory: 17 | 18 | [source,bash] 19 | ---- 20 | $ EP_ID=aa752cea-8222-5bc8-acd9-555b090c0ccb 21 | $ mkdir $EP_ID:~/testfolder 22 | ---- 23 | """, 24 | ) 25 | @click.argument("endpoint_plus_path", type=ENDPOINT_PLUS_REQPATH) 26 | @local_user_option 27 | @LoginManager.requires_login("transfer") 28 | def mkdir_command( 29 | login_manager: LoginManager, 30 | *, 31 | endpoint_plus_path: tuple[uuid.UUID, str], 32 | local_user: str | globus_sdk.MissingType, 33 | ) -> None: 34 | """Make a directory on an endpoint at the given path.""" 35 | endpoint_id, path = endpoint_plus_path 36 | transfer_client = login_manager.get_transfer_client() 37 | 38 | res = transfer_client.operation_mkdir(endpoint_id, path=path, local_user=local_user) 39 | display(res, text_mode=display.RAW, response_key="message") 40 | -------------------------------------------------------------------------------- /src/globus_cli/termio/field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from . import formatters 6 | 7 | 8 | class Field: 9 | """A field which will be shown in record or table output. 10 | When fields are provided as tuples, they are converted into this. 11 | 12 | :param name: the displayed name for the record field or the column 13 | name for table output 14 | :param key: a jmespath expression for indexing into print data 15 | :param wrap_enabled: in record output, is this field allowed to wrap 16 | """ 17 | 18 | def __init__( 19 | self, 20 | name: str, 21 | key: str, 22 | *, 23 | wrap_enabled: bool = False, 24 | formatter: formatters.FieldFormatter[t.Any] = formatters.Str, 25 | ) -> None: 26 | self.name = name 27 | self.key = key 28 | self.wrap_enabled = wrap_enabled 29 | self.formatter = formatter 30 | 31 | def get_value(self, data: t.Any) -> t.Any: 32 | import jmespath 33 | 34 | return jmespath.search(self.key, data) 35 | 36 | def format(self, value: t.Any) -> str: 37 | return self.formatter.format(value) 38 | 39 | def serialize(self, data: t.Any) -> str: 40 | return self.format(self.get_value(data)) 41 | -------------------------------------------------------------------------------- /tests/unit/test_timezone_handling.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import globus_sdk 4 | 5 | from globus_cli.commands.timer.create._common import _to_local_tz 6 | 7 | EST = datetime.timezone(datetime.timedelta(hours=-5), name="EST") 8 | 9 | 10 | def test_timer_create_local_tz_conversion_of_missing(): 11 | value = _to_local_tz(None) 12 | assert value is globus_sdk.MISSING 13 | 14 | 15 | def test_timer_create_local_tz_conversion_preserves_existing_tzinfo(): 16 | original = datetime.datetime.now().astimezone(EST) 17 | resolved = _to_local_tz(original) 18 | assert resolved.tzinfo == EST 19 | assert resolved == original 20 | 21 | 22 | def test_timer_create_local_tz_conversion_adds_tzinfo_if_missing(): 23 | original = datetime.datetime.now() 24 | resolved = _to_local_tz(original) 25 | assert resolved.tzinfo is not None 26 | 27 | # but the true time represented remains the same if normalized to a given timezone 28 | # (UTC and EST both tested to guard against potential variations if a developer's 29 | # local time matches one of these and that impacts behavior unexpectedly) 30 | assert original.astimezone(datetime.timezone.utc) == resolved.astimezone( 31 | datetime.timezone.utc 32 | ) 33 | assert original.astimezone(EST) == resolved.astimezone(EST) 34 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/role/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command, endpoint_id_arg 5 | from globus_cli.termio import display 6 | 7 | from ._common import role_id_arg 8 | 9 | 10 | @command( 11 | "delete", 12 | short_help="Remove a role from an endpoint.", 13 | adoc_output="Textual output is a simple success message in the absence of errors.", 14 | adoc_examples="""Delete role '0f007eec-1aeb-11e7-aec4-3c970e0c9cc4' on endpoint 15 | 'aa752cea-8222-5bc8-acd9-555b090c0ccb': 16 | 17 | [source,bash] 18 | ---- 19 | $ globus endpoint role delete 'aa752cea-8222-5bc8-acd9-555b090c0ccb' \ 20 | '0f007eec-1aeb-11e7-aec4-3c970e0c9cc4' 21 | ---- 22 | """, 23 | ) 24 | @endpoint_id_arg 25 | @role_id_arg 26 | @LoginManager.requires_login("transfer") 27 | def role_delete( 28 | login_manager: LoginManager, *, role_id: str, endpoint_id: uuid.UUID 29 | ) -> None: 30 | """ 31 | Remove a role from an endpoint. 32 | 33 | You must have sufficient privileges to modify the roles on the endpoint. 34 | """ 35 | transfer_client = login_manager.get_transfer_client() 36 | res = transfer_client.delete_endpoint_role(endpoint_id, role_id) 37 | display(res, text_mode=display.RAW, response_key="message") 38 | -------------------------------------------------------------------------------- /tests/unit/formatters/test_connector_formatter.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from globus_cli.services.gcs import ConnectorIdFormatter 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "connector_id, connector_name", 10 | [ 11 | ("145812c8-decc-41f1-83cf-bb2a85a2a70b", "POSIX"), 12 | ("7e3f3f5e-350c-4717-891a-2f451c24b0d4", "BlackPearl"), 13 | ("7c100eae-40fe-11e9-95a3-9cb6d0d9fd63", "Box"), 14 | ("1b6374b0-f6a4-4cf7-a26f-f262d9c6ca72", "Ceph"), 15 | ("28ef55da-1f97-11eb-bdfd-12704e0d6a4d", "OneDrive"), 16 | ("976cf0cf-78c3-4aab-82d2-7c16adbcc281", "Google Drive"), 17 | ("56366b96-ac98-11e9-abac-9cb6d0d9fd63", "Google Cloud Storage"), 18 | ("7251f6c8-93c9-11eb-95ba-12704e0d6a4d", "ActiveScale"), 19 | ("7643e831-5f6c-4b47-a07f-8ee90f401d23", "S3"), 20 | ("052be037-7dda-4d20-b163-3077314dc3e6", "POSIX Staging"), 21 | ("e47b6920-ff57-11ea-8aaa-000c297ab3c2", "iRODS"), 22 | ], 23 | ) 24 | def test_connector_id_to_name_formatting(connector_id, connector_name): 25 | assert ConnectorIdFormatter().format(connector_id) == connector_name 26 | 27 | 28 | def test_name_of_unknown_connector_id(): 29 | fake_id = str(uuid.UUID(int=0)) 30 | assert ConnectorIdFormatter().format(fake_id) == f"UNKNOWN ({fake_id})" 31 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import collection_id_arg, command 5 | from globus_cli.termio import display 6 | 7 | 8 | @command("delete", short_help="Delete an existing Collection.") 9 | @collection_id_arg 10 | @LoginManager.requires_login("transfer") 11 | def collection_delete(login_manager: LoginManager, *, collection_id: uuid.UUID) -> None: 12 | """ 13 | Delete an existing Collection. 14 | 15 | This requires that you are an owner or administrator on the Collection. 16 | 17 | Endpoint owners and administrators may delete Collections on the Endpoint. 18 | For Guest Collections, administrators of the Mapped Collection may also delete. 19 | 20 | If the collection has the 'delete_protection' property set to true, the Collection 21 | can not be deleted. 22 | 23 | All Collection-specific roles and 'sharing_policies' are also deleted. 24 | 25 | If a Mapped Collection is deleted, then all Guest Collections and roles associated 26 | with it are also deleted. 27 | """ 28 | gcs_client = login_manager.get_gcs_client(collection_id=collection_id) 29 | res = gcs_client.delete_collection(collection_id) 30 | display(res, text_mode=display.RAW, response_key="code") 31 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import group 2 | 3 | 4 | @group( 5 | "endpoint", 6 | lazy_subcommands={ 7 | "activate": ("_removal_stub", "removal_stub_command"), 8 | "create": ("_removal_stub", "removal_stub_command"), 9 | "deactivate": ("_removal_stub", "removal_stub_command"), 10 | "delete": (".delete", "endpoint_delete"), 11 | "is-activated": ("_removal_stub", "removal_stub_command"), 12 | "local-id": (".local_id", "local_id"), 13 | "my-shared-endpoint-list": ( 14 | ".my_shared_endpoint_list", 15 | "my_shared_endpoint_list", 16 | ), 17 | "permission": (".permission", "permission_command"), 18 | "role": (".role", "role_command"), 19 | "search": (".search", "endpoint_search"), 20 | "server": ("_removal_stub", "removal_stub_command"), 21 | "set-subscription-id": (".set_subscription_id", "set_endpoint_subscription_id"), 22 | "show": (".show", "endpoint_show"), 23 | "storage-gateway": (".storage_gateway", "storage_gateway_command"), 24 | "update": (".update", "endpoint_update"), 25 | "user-credential": (".user_credential", "user_credential_command"), 26 | }, 27 | ) 28 | def endpoint_command() -> None: 29 | """Manage Globus endpoint definitions.""" 30 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcp/create/_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import click 6 | 7 | from globus_cli import utils 8 | from globus_cli.parsing import MutexInfo, mutex_option_group 9 | from globus_cli.termio import print_command_hint 10 | from globus_cli.types import AnyCommand 11 | 12 | F = t.TypeVar("F", bound=AnyCommand) 13 | 14 | 15 | def deprecated_verify_option(f: F) -> F: 16 | return utils.fold_decorators( 17 | f, 18 | [ 19 | click.option( 20 | "--disable-verify/--no-disable-verify", 21 | hidden=True, 22 | is_flag=True, 23 | default=None, 24 | callback=_deprecated_verify_warning_callback, 25 | ), 26 | mutex_option_group( 27 | MutexInfo("--disable-verify", present=lambda val: val is not None), 28 | "--verify", 29 | ), 30 | ], 31 | ) 32 | 33 | 34 | def _deprecated_verify_warning_callback( 35 | ctx: click.Context, param: click.Parameter, value: bool | None 36 | ) -> bool | None: 37 | if value is not None: 38 | print_command_hint( 39 | """\ 40 | '--disable-verify/--no-disable-verify' is deprecated 41 | 42 | Use the '--verify' option instead.""" 43 | ) 44 | return value 45 | -------------------------------------------------------------------------------- /tests/functional/collection/role/test_role_delete.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import RegisteredResponse, load_response_set 2 | 3 | 4 | def test_successful_gcs_collection_role_delete( 5 | run_line, 6 | add_gcs_login, 7 | ): 8 | # setup data for the collection_id -> endpoint_id lookup 9 | # and create dummy credentials for the test to run against that GCS 10 | meta = load_response_set("cli.collection_operations").metadata 11 | endpoint_id = meta["endpoint_id"] 12 | collection_id = meta["mapped_collection_id"] 13 | add_gcs_login(endpoint_id) 14 | 15 | role_id = meta["role_id"] 16 | 17 | # mock the responses for the Get Role API (GCS) 18 | RegisteredResponse( 19 | service="gcs", 20 | path=f"/roles/{role_id}", 21 | json={ 22 | "DATA_TYPE": "result#1.1.0", 23 | "code": "success", 24 | "detail": "success", 25 | "has_next_page": False, 26 | "http_response_code": 200, 27 | "message": f"Deleted role {role_id}", 28 | }, 29 | ).add() 30 | 31 | # now test the command and confirm that a successful role deletion is reported 32 | run_line( 33 | ["globus", "gcs", "collection", "role", "delete", collection_id, role_id], 34 | search_stdout=[ 35 | "success", 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /src/globus_cli/commands/collection/role/list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import collection_id_arg, command 7 | from globus_cli.termio import Field, display 8 | from globus_cli.termio.formatters.auth import PrincipalURNFormatter 9 | 10 | 11 | @command("list") 12 | @collection_id_arg 13 | @click.option("--all-roles", is_flag=True, help="Include all collection roles.") 14 | @LoginManager.requires_login("transfer") 15 | def list_command( 16 | login_manager: LoginManager, 17 | *, 18 | collection_id: uuid.UUID, 19 | all_roles: bool, 20 | ) -> None: 21 | """List roles on a particular Collection.""" 22 | gcs_client = login_manager.get_gcs_client(collection_id=collection_id) 23 | auth_client = login_manager.get_auth_client() 24 | 25 | if all_roles: 26 | res = gcs_client.get_role_list(collection_id, include="all_roles") 27 | else: 28 | res = gcs_client.get_role_list(collection_id) 29 | 30 | display( 31 | res, 32 | text_mode=display.RECORD_LIST, 33 | fields=[ 34 | Field("ID", "id"), 35 | Field("Role", "role"), 36 | Field( 37 | "Principal", "principal", formatter=PrincipalURNFormatter(auth_client) 38 | ), 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/update/from_json.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import ( 7 | JSONStringOrFile, 8 | ParsedJSONData, 9 | command, 10 | endpoint_id_arg, 11 | ) 12 | from globus_cli.termio import display 13 | 14 | from .._common import user_credential_id_arg 15 | 16 | 17 | @command("from-json", short_help="Update a User Credential with a JSON document.") 18 | @endpoint_id_arg 19 | @user_credential_id_arg() 20 | @click.argument("user_credential_json", type=JSONStringOrFile()) 21 | @LoginManager.requires_login("auth", "transfer") 22 | def from_json( 23 | login_manager: LoginManager, 24 | *, 25 | endpoint_id: uuid.UUID, 26 | user_credential_id: uuid.UUID, 27 | user_credential_json: ParsedJSONData, 28 | ) -> None: 29 | """ 30 | Update a User Credential on an endpoint with a JSON document. 31 | """ 32 | if not isinstance(user_credential_json.data, dict): 33 | raise click.UsageError("User Credential JSON must be a JSON object") 34 | 35 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 36 | res = gcs_client.update_user_credential( 37 | user_credential_id, user_credential_json.data 38 | ) 39 | display(res, simple_text=res.full_data.get("message")) 40 | -------------------------------------------------------------------------------- /src/globus_cli/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is used to define constants used throughout the code. 3 | It should not depend on any other part of the globus-cli codebase. 4 | 5 | (If you need to import something else, maybe it's not simple enough to be a constant...) 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import typing as t 11 | 12 | T = t.TypeVar("T") 13 | K = t.TypeVar("K") 14 | V = t.TypeVar("V") 15 | 16 | 17 | class ExplicitNullType: 18 | """ 19 | Magic sentinel value used to disambiguate values which are being 20 | intentionally nulled from values which are `None` because no argument was 21 | provided 22 | """ 23 | 24 | def __bool__(self) -> bool: 25 | return False 26 | 27 | def __repr__(self) -> str: 28 | return "null" 29 | 30 | @staticmethod 31 | def nullify(value: T | ExplicitNullType) -> T | None: 32 | if isinstance(value, ExplicitNullType): 33 | return None 34 | return value 35 | 36 | @staticmethod 37 | def nullify_dict(value: dict[K, V | ExplicitNullType]) -> dict[K, V | None]: 38 | # - filter out Nones 39 | # - pass through EXPLICIT_NULL as None 40 | return { 41 | k: ExplicitNullType.nullify(v) for k, v in value.items() if v is not None 42 | } 43 | 44 | 45 | EXPLICIT_NULL = ExplicitNullType() 46 | -------------------------------------------------------------------------------- /src/globus_cli/commands/gcs/endpoint/role/list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | from globus_sdk.paging import Paginator 5 | 6 | from globus_cli.commands.gcs.endpoint.role._common import role_fields 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command, endpoint_id_arg 9 | from globus_cli.termio import display 10 | from globus_cli.utils import PagingWrapper 11 | 12 | 13 | @command("list") 14 | @endpoint_id_arg 15 | @click.option( 16 | "--all-roles", is_flag=True, help="Show all roles, not just yours.", default=False 17 | ) 18 | @LoginManager.requires_login("transfer") 19 | def list_command( 20 | login_manager: LoginManager, 21 | *, 22 | endpoint_id: uuid.UUID, 23 | all_roles: bool, 24 | ) -> None: 25 | """List all roles on a GCS Endpoint associated with you.""" 26 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 27 | auth_client = login_manager.get_auth_client() 28 | 29 | paginator = Paginator.wrap(gcs_client.get_role_list) 30 | paginated_call = paginator(include="all_roles") if all_roles else paginator() 31 | paging_wrapper = PagingWrapper(paginated_call.items(), json_conversion_key="DATA") 32 | 33 | display( 34 | paging_wrapper, 35 | fields=role_fields(auth_client), 36 | json_converter=paging_wrapper.json_converter, 37 | ) 38 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/permission/_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import click 6 | 7 | from globus_cli.termio import formatters 8 | from globus_cli.types import AnyCommand 9 | 10 | C = t.TypeVar("C", bound=AnyCommand) 11 | 12 | 13 | class AclPrincipalFormatter(formatters.auth.PrincipalDictFormatter): 14 | # customize the formatter to provide the `principal_type` as the fallback value for 15 | # unrecognized types. This handles various cases in which 16 | # `principal_type=all_authenticated_users` or similar, which is the shape of the 17 | # data from Globus Transfer 18 | def fallback_rendering(self, principal: str, principal_type: str) -> str: 19 | return principal_type 20 | 21 | # TODO: re-assess Group rendering in the CLI 22 | # see also the implementation in the base class 23 | # 24 | # this URL is a real part of the webapp which displays info on a given group 25 | # it could be made multi-environment using `globus_sdk.config.get_webapp_url()` 26 | def render_group_id(self, group_id: str) -> str: 27 | return f"https://app.globus.org/groups/{group_id}" 28 | 29 | 30 | def expiration_date_option(f: C) -> C: 31 | return click.option( 32 | "--expiration-date", 33 | help="Expiration date for the permission in ISO 8601 format", 34 | )(f) 35 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/permission/list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command, endpoint_id_arg 7 | from globus_cli.termio import Field, display 8 | 9 | from ._common import AclPrincipalFormatter 10 | 11 | 12 | @command( 13 | "list", 14 | short_help="List access control rules.", 15 | adoc_examples="""[source,bash] 16 | ---- 17 | $ ep_id=aa752cea-8222-5bc8-acd9-555b090c0ccb 18 | $ globus endpoint permission list $ep_id 19 | ---- 20 | """, 21 | ) 22 | @endpoint_id_arg 23 | @LoginManager.requires_login("auth", "transfer") 24 | def list_command(login_manager: LoginManager, *, endpoint_id: uuid.UUID) -> None: 25 | """List all rules in an endpoint's access control list.""" 26 | transfer_client = login_manager.get_transfer_client() 27 | auth_client = login_manager.get_auth_client() 28 | 29 | rules = transfer_client.endpoint_acl_list(endpoint_id) 30 | 31 | formatter = AclPrincipalFormatter(auth_client) 32 | formatter.add_items(*rules) 33 | 34 | display( 35 | rules, 36 | fields=[ 37 | Field("Rule ID", "id"), 38 | Field("Permissions", "permissions"), 39 | Field("Shared With", "@", formatter=formatter), 40 | Field("Path", "path"), 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /changelog.d/post-fix-changelog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | This is a small "fixer" script which is meant to modify scriv-generated changelog 4 | content to match the style of adoc content which we need for our changelog 5 | """ 6 | 7 | import argparse 8 | import re 9 | 10 | MD_H1_PATTERN = re.compile(r"^(#) (.+)$", re.MULTILINE) 11 | MD_H2_PATTERN = re.compile(r"^(##) (.+)$", re.MULTILINE) 12 | MD_H3_PATTERN = re.compile(r"^(###) (.+)$", re.MULTILINE) 13 | SCRIV_LINK_PATTERN = re.compile( 14 | r"\n\n", re.MULTILINE 15 | ) 16 | 17 | 18 | def process_file(filename): 19 | with open(filename) as f: 20 | content = f.read() 21 | 22 | content = MD_H1_PATTERN.sub(r"== \2", content) 23 | content = MD_H2_PATTERN.sub(r"\2:", content) 24 | content = SCRIV_LINK_PATTERN.sub("\n", content) 25 | 26 | with open(filename, "w") as f: 27 | f.write(content) 28 | 29 | 30 | def main(): 31 | parser = argparse.ArgumentParser( 32 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 33 | ) 34 | parser.add_argument("FILES", nargs="*") 35 | args = parser.parse_args() 36 | 37 | if not args.FILES: 38 | process_file("changelog.adoc") 39 | 40 | for filename in args.FILES: 41 | process_file(filename) 42 | 43 | 44 | if __name__ == "__main__": 45 | main() 46 | -------------------------------------------------------------------------------- /tests/functional/gcp/test_gcp_set_subscription_admin_verified.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from globus_sdk.testing import ( 3 | load_response_set, 4 | ) 5 | 6 | 7 | def test_gcp_set_subscription_admin_verified_success(run_line): 8 | meta = load_response_set("cli.gcp_set_subscription_admin_verified").metadata 9 | result = run_line( 10 | [ 11 | "globus", 12 | "gcp", 13 | "set-subscription-admin-verified", 14 | meta["collection_id_success"], 15 | "false", 16 | ] 17 | ) 18 | assert result.output == "Endpoint updated successfully\n" 19 | 20 | 21 | def test_gcp_set_subscription_admin_verified_fail(run_line): 22 | meta = load_response_set("cli.gcp_set_subscription_admin_verified").metadata 23 | 24 | with pytest.raises(Exception) as excinfo: 25 | run_line( 26 | [ 27 | "globus", 28 | "gcp", 29 | "set-subscription-admin-verified", 30 | meta["collection_id_fail"], 31 | "true", 32 | ] 33 | ) 34 | 35 | exc_val_str = str(excinfo.value) 36 | 37 | assert "exited with 1 when expecting 0" in exc_val_str 38 | 39 | assert ( 40 | "User does not have an admin role on the collection's subscription " 41 | + "to set subscription_admin_verified.\n" 42 | ) in exc_val_str 43 | -------------------------------------------------------------------------------- /tests/unit/formatters/test_primitive_formatters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from globus_cli.termio import Field, formatters 4 | 5 | 6 | def test_date_format_badly_typed_input(): 7 | fmt = formatters.DateFormatter() 8 | with pytest.warns(formatters.FormattingFailedWarning): 9 | data = fmt.format(0) 10 | assert data == "0" 11 | 12 | 13 | def test_date_format_via_field(): 14 | f = Field("foo", "foo", formatter=formatters.Date) 15 | assert f.serialize({"foo": None}) == "None" 16 | assert f.serialize({"foo": "2022-04-05T16:27:48.805427"}) == "2022-04-05 16:27:48" 17 | 18 | 19 | def test_bool_format_via_field(): 20 | f = Field("foo", "foo", formatter=formatters.Bool) 21 | assert f.serialize({"foo": None}) == "None" 22 | assert f.serialize({}) == "None" 23 | assert f.serialize({"foo": True}) == "True" 24 | assert f.serialize({"foo": False}) == "False" 25 | 26 | with pytest.warns(formatters.FormattingFailedWarning): 27 | assert f.serialize({"foo": "hi there"}) == "hi there" 28 | 29 | 30 | def test_fuzzy_bool_format_via_field(): 31 | f = Field("foo", "foo", formatter=formatters.FuzzyBool) 32 | assert f.serialize({"foo": None}) == "False" 33 | assert f.serialize({}) == "False" 34 | assert f.serialize({"foo": True}) == "True" 35 | assert f.serialize({"foo": False}) == "False" 36 | assert f.serialize({"foo": "hi there"}) == "True" 37 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_test_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Test PyPI Release 2 | 3 | on: 4 | push: 5 | tags: ["*"] 6 | 7 | jobs: 8 | build-dists: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 13 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 14 | with: 15 | python-version: "3.12" 16 | 17 | - run: python -m pip install build 18 | 19 | - name: Set dev version prior to upload 20 | run: python ./scripts/set_dev_version.py 21 | 22 | - name: Build Dists 23 | run: python -m build . 24 | 25 | - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 26 | with: 27 | name: packages 28 | path: dist/* 29 | 30 | publish: 31 | needs: [build-dists] 32 | runs-on: ubuntu-latest 33 | environment: publish-test-pypi 34 | permissions: 35 | id-token: write 36 | 37 | steps: 38 | - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 39 | with: 40 | name: packages 41 | path: dist 42 | 43 | - name: Publish to TestPyPI 44 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 45 | with: 46 | repository-url: https://test.pypi.org/legacy/ 47 | -------------------------------------------------------------------------------- /src/globus_cli/version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | if t.TYPE_CHECKING: 6 | from packaging.version import Version 7 | 8 | # single source of truth for package version, 9 | # see https://packaging.python.org/en/latest/single_source_version/ 10 | __version__ = "3.40.0" 11 | 12 | # app name to send as part of SDK requests 13 | app_name = f"Globus CLI v{__version__}" 14 | 15 | 16 | # pull down version data from PyPi 17 | def get_versions() -> tuple[Version | None, Version]: 18 | """ 19 | Wrap in a function to ensure that we don't run this every time a CLI 20 | command runs or when version number is loaded by setuptools. 21 | 22 | Returns a pair: (latest_version, current_version) 23 | """ 24 | # import in the func (rather than top-level scope) so that at setup time, 25 | # libraries aren't required -- otherwise, setuptools will fail to run 26 | # because these packages aren't installed yet. 27 | import requests 28 | from packaging.version import Version 29 | 30 | try: 31 | response = requests.get("https://pypi.python.org/pypi/globus-cli/json") 32 | # if the fetch from pypi fails 33 | except requests.RequestException: 34 | return None, Version(__version__) 35 | parsed_versions = [Version(v) for v in response.json()["releases"]] 36 | latest = max(parsed_versions) 37 | return latest, Version(__version__) 38 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/subject/show.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | 4 | import click 5 | import globus_sdk 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command 9 | from globus_cli.termio import display 10 | 11 | from .._common import index_id_arg 12 | 13 | 14 | def _print_subject(subject_doc: "globus_sdk.GlobusHTTPResponse") -> None: 15 | entries = subject_doc["entries"] 16 | if len(entries) == 1: 17 | click.echo(json.dumps(entries[0], indent=2, separators=(",", ": "))) 18 | else: 19 | click.echo(json.dumps(entries, indent=2, separators=(",", ": "))) 20 | 21 | 22 | @command("show") 23 | @index_id_arg 24 | @click.argument("subject") 25 | @LoginManager.requires_login("auth", "search") 26 | def show_command( 27 | login_manager: LoginManager, *, index_id: uuid.UUID, subject: str 28 | ) -> None: 29 | """ 30 | Show the data for a given subject in an index. 31 | 32 | This is subject the visible_to access control list on the entries for that subject. 33 | If there are one or more entries visible to the current user, they will be 34 | displayed. 35 | 36 | If there are no entries visible to the current user, a NotFound error will be 37 | raised. 38 | """ 39 | search_client = login_manager.get_search_client() 40 | res = search_client.get_subject(index_id, subject) 41 | display(res, text_mode=_print_subject) 42 | -------------------------------------------------------------------------------- /src/globus_cli/commands/task/generate_submission_id.py: -------------------------------------------------------------------------------- 1 | from globus_cli.login_manager import LoginManager 2 | from globus_cli.parsing import command 3 | from globus_cli.termio import display 4 | 5 | 6 | @command( 7 | "generate-submission-id", 8 | short_help="Get a task submission ID.", 9 | adoc_output=( 10 | "When text output is requested, the generated 'UUID' is the only output." 11 | ), 12 | adoc_examples="""Submit a transfer, using a submission ID generated by this command: 13 | 14 | [source,bash] 15 | ---- 16 | $ sub_id="$(globus task generate-submission-id)" 17 | $ globus transfer --submission-id "$sub_id" ... 18 | ---- 19 | """, 20 | ) 21 | @LoginManager.requires_login("transfer") 22 | def generate_submission_id(login_manager: LoginManager) -> None: 23 | """ 24 | Generate a new task submission ID for use in `globus transfer` and `globus delete`. 25 | Submission IDs allow you to safely retry submission of a task in the presence of 26 | network errors. No matter how many times you submit a task with a given ID, it will 27 | only be accepted and executed once. The response status may change between 28 | submissions. 29 | 30 | \b 31 | Important Note: submission IDs are not the same as task IDs. 32 | """ 33 | transfer_client = login_manager.get_transfer_client() 34 | 35 | res = transfer_client.get_submission_id() 36 | display(res, text_mode=display.RAW, response_key="value") 37 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/_common.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import click 4 | import globus_sdk 5 | 6 | from globus_cli.parsing import OMITTABLE_STRING 7 | from globus_cli.types import AnyCommand 8 | 9 | C = t.TypeVar("C", bound=AnyCommand) 10 | 11 | 12 | def user_credential_id_arg( 13 | *, metavar: str = "USER_CREDENTIAL_ID" 14 | ) -> t.Callable[[C], C]: 15 | return click.argument("user_credential_id", metavar=metavar, type=click.UUID) 16 | 17 | 18 | def user_credential_create_and_update_params( 19 | *, create: bool = False 20 | ) -> t.Callable[[C], C]: 21 | """ 22 | Collection of options consumed by user credential create and update. 23 | Passing create as True makes any values required for create 24 | arguments instead of options. 25 | """ 26 | 27 | def decorator(f: C) -> C: 28 | # identity_id, username, and storage gateway are required for create 29 | # and immutable on update 30 | if create: 31 | f = click.argument("local-username")(f) 32 | f = click.argument("globus-identity")(f) 33 | f = click.argument("storage-gateway", type=click.UUID)(f) 34 | 35 | f = click.option( 36 | "--display-name", 37 | help="Display name for the credential.", 38 | default=globus_sdk.MISSING, 39 | type=OMITTABLE_STRING, 40 | )(f) 41 | 42 | return f 43 | 44 | return decorator 45 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/create/posix.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import globus_sdk 6 | from globus_sdk.services.gcs import UserCredentialDocument 7 | 8 | from globus_cli.login_manager import LoginManager 9 | from globus_cli.parsing import command, endpoint_id_arg 10 | from globus_cli.termio import display 11 | 12 | from .._common import user_credential_create_and_update_params 13 | 14 | 15 | @command("posix") 16 | @endpoint_id_arg 17 | @user_credential_create_and_update_params(create=True) 18 | @LoginManager.requires_login("auth", "transfer") 19 | def posix( 20 | login_manager: LoginManager, 21 | *, 22 | endpoint_id: uuid.UUID, 23 | storage_gateway: uuid.UUID, 24 | globus_identity: str, 25 | local_username: str, 26 | display_name: str | globus_sdk.MissingType, 27 | ) -> None: 28 | """ 29 | Create a User Credential for a POSIX storage gateway. 30 | """ 31 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 32 | auth_client = login_manager.get_auth_client() 33 | 34 | data = UserCredentialDocument( 35 | storage_gateway_id=storage_gateway, 36 | identity_id=( 37 | auth_client.maybe_lookup_identity_id(globus_identity) or globus_sdk.MISSING 38 | ), 39 | username=local_username, 40 | display_name=display_name, 41 | ) 42 | res = gcs_client.create_user_credential(data) 43 | 44 | display(res, simple_text=res.full_data.get("message")) 45 | -------------------------------------------------------------------------------- /src/globus_cli/commands/search/subject/delete.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import command 7 | from globus_cli.termio import Field, display, formatters 8 | 9 | from .._common import index_id_arg 10 | 11 | 12 | @command("delete") 13 | @index_id_arg 14 | @click.argument("subject") 15 | @LoginManager.requires_login("search") 16 | def delete_command( 17 | login_manager: LoginManager, 18 | *, 19 | index_id: uuid.UUID, 20 | subject: str, 21 | ) -> None: 22 | """ 23 | Delete a subject (requires writer, admin, or owner). 24 | 25 | Delete a submit a delete_by_subject task on an index. This requires writer or 26 | stronger privileges on the index. 27 | 28 | Returns the 'task_id' for the deletion task. Deletions are not guaranteed to be 29 | immediate, but will be put into the task queue for that index. Monitor tasks using 30 | commands like 'globus search task show' 31 | """ 32 | search_client = login_manager.get_search_client() 33 | display( 34 | search_client.delete_subject(index_id, subject), 35 | text_mode=display.RECORD, 36 | fields=[ 37 | Field( 38 | "Message", 39 | "@", 40 | formatter=formatters.StaticStringFormatter( 41 | "delete-by-subject task successfully submitted" 42 | ), 43 | ), 44 | Field("Task ID", "task_id"), 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/functional/task/test_task_pause_info.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_sdk.testing import RegisteredResponse 4 | 5 | TASK_ID = str(uuid.UUID(int=0)) 6 | RULE_ID = str(uuid.UUID(int=1)) 7 | EP_ID = str(uuid.UUID(int=2)) 8 | USER_ID = str(uuid.UUID(int=3)) 9 | 10 | _pause_rule_data = { 11 | "DATA_TYPE": "pause_rule", 12 | "id": RULE_ID, 13 | "message": "SDK Test Pause Rule", 14 | "start_time": None, 15 | "endpoint_id": EP_ID, 16 | "identity_id": None, 17 | "modified_by_id": USER_ID, 18 | "created_by_host_manager": False, 19 | "editable": True, 20 | "pause_ls": True, 21 | "pause_mkdir": True, 22 | "pause_rename": True, 23 | "pause_task_delete": True, 24 | "pause_task_transfer_write": True, 25 | "pause_task_transfer_read": True, 26 | } 27 | 28 | 29 | def test_show_task_pause_info(run_line): 30 | RegisteredResponse( 31 | service="transfer", 32 | path=f"/v0.10/task/{TASK_ID}/pause_info", 33 | json={ 34 | "endpoint_display_name": "ExamplePauseEndpoint", 35 | "message": "This task is like super paused", 36 | "source_pause_message": None, 37 | "source_pause_message_share": None, 38 | "destination_pause_message": None, 39 | "destination_pause_message_share": None, 40 | "pause_rules": [_pause_rule_data], 41 | }, 42 | ).add() 43 | result = run_line(["globus", "task", "pause-info", TASK_ID]) 44 | assert "write/read/delete/rename/mkdir/ls" in result.output 45 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/set_subscription_admin_verified.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.constants import EXPLICIT_NULL, ExplicitNullType 8 | from globus_cli.login_manager import LoginManager 9 | from globus_cli.parsing import command 10 | from globus_cli.termio import display 11 | 12 | from ._common import GroupSubscriptionVerifiedIdType, group_id_arg 13 | 14 | 15 | @command("set-subscription-admin-verified") 16 | @group_id_arg 17 | @click.argument( 18 | "SUBSCRIPTION_ID", 19 | type=GroupSubscriptionVerifiedIdType(), 20 | metavar="[SUBSCRIPTION_ID|null]", 21 | ) 22 | @LoginManager.requires_login("groups") 23 | def group_set_subscription_admin_verified( 24 | login_manager: LoginManager, 25 | *, 26 | group_id: uuid.UUID, 27 | subscription_id: uuid.UUID | ExplicitNullType, 28 | ) -> None: 29 | """ 30 | Mark a group as a subscription-verified resource. 31 | 32 | SUBSCRIPTION_ID is the ID of the subscription to which this group shall belong, 33 | or "null" to mark the group as non-verified. 34 | """ 35 | groups_client = login_manager.get_groups_client() 36 | 37 | admin_verified_id: str | None = ( 38 | None if subscription_id == EXPLICIT_NULL else str(subscription_id) 39 | ) 40 | 41 | response = groups_client.set_subscription_admin_verified( 42 | group_id, admin_verified_id 43 | ) 44 | 45 | display( 46 | response, simple_text="Group subscription verification updated successfully" 47 | ) 48 | -------------------------------------------------------------------------------- /tests/functional/search/test_errors.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from globus_sdk.testing import load_response_set 4 | 5 | 6 | def test_notfound_error(run_line): 7 | meta = load_response_set("cli.search").metadata 8 | index_id = meta["error_index_id"] 9 | 10 | run_line( 11 | ["globus", "search", "query", index_id, "-q", "*"], 12 | assert_exit_code=1, 13 | search_stderr=[ 14 | ("code", "NotFound.NoSuchIndex"), 15 | f'There is no search index named "{index_id}"', 16 | ], 17 | ) 18 | 19 | 20 | def test_validation_error(run_line, tmp_path): 21 | meta = load_response_set("cli.search").metadata 22 | index_id = meta["error_index_id"] 23 | 24 | # although not strictly necessary for the test (since we mock the response data), 25 | # this is an example of the malformed data on submit: missing 'visible_to', which 26 | # is a required field 27 | data = { 28 | "ingest_type": "GMetaEntry", 29 | "ingest_data": { 30 | "@datatype": "GMetaEntry", 31 | "content": {"alpha": {"beta": "delta"}}, 32 | "id": "testentry2", 33 | "subject": "http://example.com", 34 | }, 35 | } 36 | 37 | doc = tmp_path / "doc.json" 38 | doc.write_text(json.dumps(data)) 39 | 40 | run_line( 41 | ["globus", "search", "ingest", index_id, str(doc)], 42 | assert_exit_code=1, 43 | search_stderr=[ 44 | ("code", "BadRequest.ValidationError"), 45 | "Missing data for required field.", 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /src/globus_cli/commands/flows/run/show.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.commands.flows._common import FlowScopeInjector 9 | from globus_cli.commands.flows._fields import flow_run_format_fields 10 | from globus_cli.login_manager import LoginManager 11 | from globus_cli.parsing import command, run_id_arg 12 | from globus_cli.termio import display 13 | 14 | 15 | def _none_to_missing( 16 | ctx: click.Context, param: click.Parameter, value: bool | None 17 | ) -> bool | globus_sdk.MissingType: 18 | if value is None: 19 | return globus_sdk.MISSING 20 | return value 21 | 22 | 23 | @command("show") 24 | @run_id_arg 25 | @click.option( 26 | "--include-flow-description", is_flag=True, default=None, callback=_none_to_missing 27 | ) 28 | @LoginManager.requires_login("auth", "flows", "search") 29 | def show_command( 30 | login_manager: LoginManager, 31 | *, 32 | run_id: uuid.UUID, 33 | include_flow_description: bool | globus_sdk.MissingType, 34 | ) -> None: 35 | """ 36 | Show a run. 37 | """ 38 | 39 | flows_client = login_manager.get_flows_client() 40 | auth_client = login_manager.get_auth_client() 41 | 42 | with FlowScopeInjector(login_manager).for_run(run_id): 43 | response = flows_client.get_run( 44 | run_id, include_flow_description=include_flow_description 45 | ) 46 | 47 | fields = flow_run_format_fields(auth_client, response.data) 48 | 49 | display(response, fields=fields, text_mode=display.RECORD) 50 | -------------------------------------------------------------------------------- /src/globus_cli/parsing/param_types/timedelta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import re 5 | 6 | import click 7 | 8 | _timedelta_regex = re.compile( 9 | r""" 10 | ^ 11 | \s* 12 | ((?P\d+)w)? 13 | \s* 14 | ((?P\d+)d)? 15 | \s* 16 | ((?P\d+)h)? 17 | \s* 18 | ((?P\d+)m)? 19 | \s* 20 | ((?P\d+)s)? 21 | \s* 22 | $ 23 | """, 24 | flags=re.VERBOSE, 25 | ) 26 | 27 | 28 | class TimedeltaType(click.ParamType): 29 | """ 30 | Parse a number of seconds, minutes, hours, days, and weeks from a string into a 31 | timedelta object 32 | """ 33 | 34 | name = "TIMEDELTA" 35 | 36 | def __init__(self, *, convert_to_seconds: bool = True) -> None: 37 | self._convert_to_seconds = convert_to_seconds 38 | 39 | def get_type_annotation(self, param: click.Parameter) -> type: 40 | if self._convert_to_seconds: 41 | return int 42 | return datetime.timedelta 43 | 44 | def convert( 45 | self, value: str, param: click.Parameter | None, ctx: click.Context | None 46 | ) -> datetime.timedelta | int: 47 | matches = _timedelta_regex.match(value) 48 | if not matches: 49 | self.fail(f"couldn't parse timedelta: '{value}'") 50 | delta = datetime.timedelta( 51 | **{k: int(v) for k, v in matches.groupdict(0).items()} 52 | ) 53 | if self._convert_to_seconds: 54 | return int(delta.total_seconds()) 55 | else: 56 | return delta 57 | -------------------------------------------------------------------------------- /tests/functional/search/test_subject_commands.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | from globus_sdk.testing import load_response_set 6 | 7 | 8 | @pytest.mark.parametrize("multiple_entries", (False, True)) 9 | def test_search_subject_show(run_line, multiple_entries): 10 | meta = load_response_set("cli.search").metadata 11 | index_id = meta["index_id"] 12 | subject = meta["multi_entry_subject"] if multiple_entries else meta["subject"] 13 | 14 | res = run_line(["globus", "search", "subject", "show", index_id, subject]) 15 | data = json.loads(res.output) 16 | if multiple_entries: 17 | assert isinstance(data, list) 18 | for item in data: 19 | assert "entry_id" in item 20 | assert "content" in item 21 | else: 22 | assert isinstance(data, dict) 23 | assert "content" in data 24 | assert "entry_id" in data 25 | 26 | sent = responses.calls[-1].request 27 | assert sent.method == "GET" 28 | assert sent.params == {"subject": subject} 29 | assert sent.body is None 30 | 31 | 32 | def test_search_subject_delete(run_line): 33 | meta = load_response_set("cli.search").metadata 34 | index_id = meta["index_id"] 35 | subject = meta["subject"] 36 | 37 | res = run_line(["globus", "search", "subject", "delete", index_id, subject]) 38 | assert meta["delete_by_subject_task_id"] in res.output 39 | 40 | sent = responses.calls[-1].request 41 | assert sent.method == "DELETE" 42 | assert sent.params == {"subject": subject} 43 | assert sent.body is None 44 | -------------------------------------------------------------------------------- /src/globus_cli/parsing/param_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .delimited import ColonDelimitedChoiceTuple, CommaDelimitedList 2 | from .endpoint_plus_path import ( 3 | ENDPOINT_PLUS_OPTPATH, 4 | ENDPOINT_PLUS_REQPATH, 5 | EndpointPlusPath, 6 | ) 7 | from .guest_activity_notify_param import ( 8 | GCSManagerGuestActivityNotificationParamType, 9 | TransferGuestActivityNotificationParamType, 10 | ) 11 | from .identity_type import IdentityType, ParsedIdentity 12 | from .json_strorfile import JSONStringOrFile, ParsedJSONData 13 | from .location import LocationType 14 | from .notify_param import NotificationParamType 15 | from .nullable import StringOrNull, UrlOrNull 16 | from .omittable import ( 17 | OMITTABLE_INT, 18 | OMITTABLE_STRING, 19 | OMITTABLE_UUID, 20 | OmittableChoice, 21 | OmittableDateTime, 22 | ) 23 | from .task_path import TaskPath 24 | from .timedelta import TimedeltaType 25 | 26 | __all__ = ( 27 | "CommaDelimitedList", 28 | "ColonDelimitedChoiceTuple", 29 | "ENDPOINT_PLUS_OPTPATH", 30 | "ENDPOINT_PLUS_REQPATH", 31 | "EndpointPlusPath", 32 | "GCSManagerGuestActivityNotificationParamType", 33 | "IdentityType", 34 | "JSONStringOrFile", 35 | "LocationType", 36 | "NotificationParamType", 37 | "ParsedIdentity", 38 | "ParsedJSONData", 39 | "StringOrNull", 40 | "TaskPath", 41 | "TimedeltaType", 42 | "TransferGuestActivityNotificationParamType", 43 | "UrlOrNull", 44 | "OmittableChoice", 45 | "OmittableDateTime", 46 | "OMITTABLE_INT", 47 | "OMITTABLE_STRING", 48 | "OMITTABLE_UUID", 49 | ) 50 | -------------------------------------------------------------------------------- /src/globus_cli/login_manager/client_login.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logic for using client identities with the Globus CLI 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import os 8 | 9 | import globus_sdk 10 | 11 | 12 | def _get_client_creds_from_env() -> tuple[str | None, str | None]: 13 | client_id = os.getenv("GLOBUS_CLI_CLIENT_ID") 14 | client_secret = os.getenv("GLOBUS_CLI_CLIENT_SECRET") 15 | return client_id, client_secret 16 | 17 | 18 | def is_client_login() -> bool: 19 | """ 20 | Return True if the correct env variables have been set to use a 21 | client identity with the Globus CLI 22 | """ 23 | client_id, client_secret = _get_client_creds_from_env() 24 | 25 | if bool(client_id) ^ bool(client_secret): 26 | raise ValueError( 27 | "Both GLOBUS_CLI_CLIENT_ID and GLOBUS_CLI_CLIENT_SECRET must " 28 | "be set to use a client identity. Either set both environment " 29 | "variables, or unset them to use a normal login." 30 | ) 31 | 32 | else: 33 | return bool(client_id) and bool(client_secret) 34 | 35 | 36 | def get_client_login() -> globus_sdk.ConfidentialAppAuthClient: 37 | """ 38 | Return the ConfidentialAppAuthClient for the client identity 39 | logged into the CLI 40 | """ 41 | if not is_client_login(): 42 | raise ValueError("No client is logged in") 43 | 44 | client_id, client_secret = _get_client_creds_from_env() 45 | 46 | return globus_sdk.ConfidentialAppAuthClient( 47 | client_id=str(client_id), 48 | client_secret=str(client_secret), 49 | ) 50 | -------------------------------------------------------------------------------- /src/globus_cli/commands/task/update.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command 9 | from globus_cli.termio import display 10 | 11 | from ._common import task_id_arg 12 | 13 | 14 | @command( 15 | "update", 16 | short_help="Update a task.", 17 | adoc_output=( 18 | "When text output is requested, the output will be a simple success " 19 | "message (or an error)." 20 | ), 21 | adoc_examples="""Update both label and deadline for a task 22 | 23 | [source,bash] 24 | ---- 25 | $ globus task update TASK_ID --label 'my task updated by me' \ 26 | --deadline '1987-01-22' 27 | ---- 28 | """, 29 | ) 30 | @task_id_arg() 31 | @click.option("--label", help="New Label for the task") 32 | @click.option("--deadline", help="New Deadline for the task") 33 | @LoginManager.requires_login("transfer") 34 | def update_task( 35 | login_manager: LoginManager, 36 | *, 37 | deadline: str | None, 38 | label: str | None, 39 | task_id: uuid.UUID, 40 | ) -> None: 41 | """ 42 | Update label and/or deadline on an active task. 43 | 44 | If a task has completed, these attributes may no longer be updated. 45 | """ 46 | from globus_cli.services.transfer import assemble_generic_doc 47 | 48 | transfer_client = login_manager.get_transfer_client() 49 | 50 | task_doc = assemble_generic_doc("task", label=label, deadline=deadline) 51 | 52 | res = transfer_client.update_task(task_id, task_doc) 53 | display(res, simple_text="Success") 54 | -------------------------------------------------------------------------------- /tests/functional/exception_handling/test_json_output.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from globus_sdk.testing import RegisteredResponse 5 | 6 | 7 | def test_base_json_hook(run_line): 8 | """ 9 | Confirms that the base json hook captures the error JSON and prints it verbatim. 10 | """ 11 | response = RegisteredResponse( 12 | service="transfer", 13 | path="/v0.10/foo", 14 | status=400, 15 | json={"bar": "baz"}, 16 | ).add() 17 | result = run_line("globus api transfer GET /foo -Fjson", assert_exit_code=1) 18 | assert response.json == json.loads(result.stderr) 19 | 20 | 21 | @pytest.mark.parametrize("output_format", ("json", "text")) 22 | def test_base_json_hook_when_no_body_is_present(run_line, output_format): 23 | """ 24 | Confirms that the base json hook captures the error JSON and prints it verbatim. 25 | """ 26 | RegisteredResponse( 27 | service="transfer", 28 | path="/v0.10/foo", 29 | status=500, 30 | json=None, 31 | ).add() 32 | 33 | add_opts = [] 34 | if output_format == "json": 35 | add_opts = ["-Fjson"] 36 | 37 | result = run_line( 38 | ["globus", "api", "transfer", "GET", "/foo", *add_opts], assert_exit_code=1 39 | ) 40 | 41 | if output_format == "json": 42 | assert json.loads(result.stderr) == { 43 | "error_name": "GlobusAPINullDataError", 44 | "error_type": "TransferAPIError", 45 | } 46 | else: 47 | assert "GlobusAPINullDataError" in result.stderr 48 | assert "TransferAPIError" in result.stderr 49 | -------------------------------------------------------------------------------- /tests/functional/endpoint/test_endpoint_set_subscription_id.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | 4 | import pytest 5 | import responses 6 | from globus_sdk.testing import load_response_set 7 | 8 | 9 | @pytest.mark.parametrize("ep_type", ["personal", "share", "server"]) 10 | def test_endpoint_set_subscription_id(run_line, ep_type): 11 | meta = load_response_set("cli.endpoint_operations").metadata 12 | if ep_type == "personal": 13 | epid = meta["gcp_endpoint_id"] 14 | elif ep_type == "share": 15 | epid = meta["share_id"] 16 | else: 17 | epid = meta["endpoint_id"] 18 | subscription_id = str(uuid.UUID(int=0)) 19 | run_line(f"globus endpoint set-subscription-id {epid} {subscription_id}") 20 | assert ( 21 | json.loads(responses.calls[-1].request.body)["subscription_id"] 22 | == subscription_id 23 | ) 24 | 25 | 26 | def test_endpoint_set_subscription_id_null(run_line): 27 | meta = load_response_set("cli.endpoint_operations").metadata 28 | epid = meta["gcp_endpoint_id"] 29 | subscription_id = "null" 30 | run_line(f"globus endpoint set-subscription-id {epid} {subscription_id}") 31 | assert json.loads(responses.calls[-1].request.body)["subscription_id"] is None 32 | 33 | 34 | def test_endpoint_set_subscription_id_invalid_subscription_id(run_line): 35 | endpoint_id = str(uuid.UUID(int=0)) 36 | subscription_id = "invalid-uuid" 37 | run_line( 38 | f"globus endpoint set-subscription-id {endpoint_id} {subscription_id}", 39 | assert_exit_code=2, 40 | search_stderr="Invalid value for 'SUBSCRIPTION_ID'", 41 | ) 42 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/member/list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import CommaDelimitedList, command 9 | from globus_cli.termio import Field, display 10 | 11 | from .._common import MEMBERSHIP_FIELDS, group_id_arg 12 | 13 | 14 | def _str2field(fieldname: str) -> Field: 15 | return Field(fieldname.title(), f"membership_fields.{fieldname}") 16 | 17 | 18 | @group_id_arg 19 | @click.option( 20 | "--fields", 21 | help=( 22 | "Additional membership fields to display in the output, " 23 | "as a comma-delimited string. Has no effect on non-text output." 24 | ), 25 | type=CommaDelimitedList(choices=MEMBERSHIP_FIELDS, convert_values=str.lower), 26 | ) 27 | @command("list") 28 | @LoginManager.requires_login("groups") 29 | def member_list( 30 | login_manager: LoginManager, 31 | *, 32 | group_id: uuid.UUID, 33 | fields: list[str] | None, 34 | ) -> None: 35 | """List group members.""" 36 | groups_client = login_manager.get_groups_client() 37 | 38 | group = groups_client.get_group(group_id, include="memberships") 39 | 40 | add_fields = [] 41 | if fields: 42 | add_fields = [_str2field(x) for x in fields] 43 | 44 | display( 45 | group, 46 | text_mode=display.TABLE, 47 | fields=[ 48 | Field("Username", "username"), 49 | Field("Role", "role"), 50 | Field("Status", "status"), 51 | ] 52 | + add_fields, 53 | response_key="memberships", 54 | ) 55 | -------------------------------------------------------------------------------- /tests/functional/gcs/endpoint/test_update.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from globus_sdk.testing import load_response_set 3 | 4 | 5 | def test_update_endpoint(run_line, add_gcs_login): 6 | endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] 7 | load_response_set("cli.gcs_endpoint_operations") 8 | 9 | add_gcs_login(endpoint_id) 10 | 11 | resp = run_line(f"globus gcs endpoint update {endpoint_id} --display-name new-name") 12 | 13 | assert endpoint_id in resp.stdout 14 | 15 | 16 | @pytest.mark.parametrize("field", ("subscription-id", "gridftp-control-channel-port")) 17 | def test_update_endpoint__nullable_fields_are_nullable(field, run_line, add_gcs_login): 18 | endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] 19 | load_response_set("cli.gcs_endpoint_operations") 20 | 21 | add_gcs_login(endpoint_id) 22 | 23 | run_line(f"globus gcs endpoint update {endpoint_id} --{field} null") 24 | 25 | 26 | def test_update_endpoint__network_use_custom_fields_are_required( 27 | run_line, add_gcs_login 28 | ): 29 | endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] 30 | load_response_set("cli.gcs_endpoint_operations") 31 | 32 | add_gcs_login(endpoint_id) 33 | 34 | resp = run_line( 35 | f"globus gcs endpoint update {endpoint_id} --network-use custom", 36 | assert_exit_code=2, 37 | ) 38 | 39 | for k in ( 40 | "max-concurrency", 41 | "max-parallelism", 42 | "preferred-concurrency", 43 | "preferred-parallelism", 44 | ): 45 | assert k in resp.stderr 46 | -------------------------------------------------------------------------------- /src/globus_cli/commands/rename.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.login_manager import LoginManager 9 | from globus_cli.parsing import command, endpoint_id_arg, local_user_option 10 | from globus_cli.termio import display 11 | 12 | 13 | @command( 14 | "rename", 15 | short_help="Rename a file or directory on an endpoint.", 16 | adoc_examples="""Rename a directory: 17 | 18 | [source,bash] 19 | ---- 20 | $ ep_id=aa752cea-8222-5bc8-acd9-555b090c0ccb 21 | $ globus rename $ep_id:~/tempdir $ep_id:~/project-foo 22 | ---- 23 | """, 24 | ) 25 | @endpoint_id_arg 26 | @click.argument("source", metavar="SOURCE_PATH") 27 | @click.argument("destination", metavar="DEST_PATH") 28 | @local_user_option 29 | @LoginManager.requires_login("transfer") 30 | def rename_command( 31 | login_manager: LoginManager, 32 | *, 33 | endpoint_id: uuid.UUID, 34 | source: str, 35 | destination: str, 36 | local_user: str | globus_sdk.MissingType, 37 | ) -> None: 38 | """Rename a file or directory on an endpoint. 39 | 40 | The old path must be an existing file or directory. The new path must not yet 41 | exist. 42 | 43 | The new path does not have to be in the same directory as the old path, but 44 | most endpoints will require it to stay on the same filesystem. 45 | """ 46 | transfer_client = login_manager.get_transfer_client() 47 | 48 | res = transfer_client.operation_rename( 49 | endpoint_id, oldpath=source, newpath=destination, local_user=local_user 50 | ) 51 | display(res, text_mode=display.RAW, response_key="message") 52 | -------------------------------------------------------------------------------- /src/globus_cli/login_manager/errors.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from globus_cli import utils 4 | 5 | from .context import LoginContext 6 | from .scopes import CLI_SCOPE_REQUIREMENTS 7 | 8 | 9 | class MissingLoginError(ValueError): 10 | def __init__( 11 | self, 12 | missing_servers: t.Sequence[str], 13 | context: LoginContext, 14 | ) -> None: 15 | self.missing_servers = missing_servers 16 | 17 | error_message = context.error_message or self._default_error_message() 18 | 19 | self.message = f"{error_message}\nPlease run:\n\n {context.login_command}\n" 20 | super().__init__(self.message) 21 | 22 | def __str__(self) -> str: 23 | return self.message 24 | 25 | def _default_error_message(self) -> str: 26 | """ 27 | Default error message if the context doesn't provide one. 28 | 29 | :returns: error message in the format: 30 | "Missing logins for Globus Auth and 12b3a34c-b818-4e5c-87e9-a294f43a845c." 31 | """ 32 | 33 | server_names = sorted(_resolve_server_names(self.missing_servers)) 34 | formatted_server_names = utils.format_list_of_words(*server_names) 35 | 36 | login = "login" if len(self.missing_servers) == 1 else "logins" 37 | return f"Missing {login} for {formatted_server_names}." 38 | 39 | 40 | def _resolve_server_names(server_names: t.Sequence[str]) -> t.Iterator[str]: 41 | for name in server_names: 42 | try: 43 | req = CLI_SCOPE_REQUIREMENTS.get_by_resource_server(name) 44 | yield req["nice_server_name"] 45 | except LookupError: 46 | yield name 47 | -------------------------------------------------------------------------------- /tests/files/api_fixtures/task_event_list.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | task_id: 42277910-0c18-11ec-ba76-138ac5bdb19f 3 | 4 | transfer: 5 | - path: /v0.10/task/42277910-0c18-11ec-ba76-138ac5bdb19f/event_list 6 | json: 7 | { 8 | "DATA_TYPE": "event_list", 9 | "length": 3, 10 | "limit": 10, 11 | "offset": 0, 12 | "total": 3, 13 | "DATA": [ 14 | { 15 | "DATA_TYPE": "event", 16 | "code": "CANCELED", 17 | "description": "canceled", 18 | "details": "Canceled by the task owner", 19 | "is_error": true, 20 | "time": "2021-10-06T16:14:05+00:00" 21 | }, 22 | { 23 | "DATA_TYPE": "event", 24 | "code": "PERMISSION_DENIED", 25 | "description": "permission denied", 26 | "details": "Error (make directories)\nEndpoint: Globus Tutorial Endpoint 1 (aa752cea-8222-5bc8-acd9-555b090c0ccb)\nServer: ep1.transfer.globus.org:2811\nFile: /foo/\nCommand: MKD /foo\nMessage: Fatal FTP response\n---\nDetails: 550-GlobusError: v=1 c=PERMISSION_DENIED\\r\\n550-GridFTP-Error: Path not allowed.\\r\\n550 End.\\r\\n", 27 | "is_error": true, 28 | "time": "2021-10-06T16:13:57+00:00" 29 | }, 30 | { 31 | "DATA_TYPE": "event", 32 | "code": "STARTED", 33 | "description": "started", 34 | "details": "{\n \"type\": \"GridFTP Transfer\",\n \"concurrency\": 2,\n \"parallelism\": 4,\n \"pipelining\": 20\n}", 35 | "is_error": false, 36 | "time": "2021-10-06T16:13:57+00:00" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/globus_cli/commands/bookmark/_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from uuid import UUID 5 | 6 | import click 7 | import globus_sdk 8 | 9 | 10 | def resolve_id_or_name( 11 | client: globus_sdk.TransferClient, bookmark_id_or_name: str 12 | ) -> globus_sdk.GlobusHTTPResponse | dict[str, t.Any]: 13 | # leading/trailing whitespace doesn't make sense for UUIDs and the Transfer 14 | # service outright forbids it for bookmark names, so we can strip it off 15 | bookmark_id_or_name = bookmark_id_or_name.strip() 16 | 17 | res = None 18 | try: 19 | UUID(bookmark_id_or_name) # raises ValueError if argument not a UUID 20 | except ValueError: 21 | pass 22 | else: 23 | try: 24 | res = client.get_bookmark(bookmark_id_or_name.lower()) 25 | except globus_sdk.TransferAPIError as err: 26 | if err.code != "BookmarkNotFound": 27 | raise 28 | if res: 29 | return res 30 | 31 | # non-UUID input or UUID not found; fallback to match by name 32 | try: 33 | # n.b. case matters to the Transfer service for bookmark names, so 34 | # two bookmarks can exist whose names vary only by their case 35 | return t.cast( 36 | t.Dict[str, t.Any], 37 | next( 38 | bookmark_row 39 | for bookmark_row in client.bookmark_list() 40 | if bookmark_row["name"] == bookmark_id_or_name 41 | ), 42 | ) 43 | 44 | except StopIteration: 45 | click.echo(f'No bookmark found for "{bookmark_id_or_name}"', err=True) 46 | click.get_current_context().exit(1) 47 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/permission/show.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import command, endpoint_id_arg 9 | from globus_cli.termio import Field, display 10 | 11 | from ._common import AclPrincipalFormatter 12 | 13 | 14 | @command( 15 | "show", 16 | short_help="Display an access control rule.", 17 | adoc_examples="""[source,bash] 18 | ---- 19 | $ ep_id=aa752cea-8222-5bc8-acd9-555b090c0ccb 20 | $ rule_id=1ddeddda-1ae8-11e7-bbe4-22000b9a448b 21 | $ globus endpoint permission show $ep_id $rule_id 22 | ---- 23 | """, 24 | ) 25 | @endpoint_id_arg 26 | @click.argument("rule_id") 27 | @LoginManager.requires_login("auth", "transfer") 28 | def show_command( 29 | login_manager: LoginManager, *, endpoint_id: uuid.UUID, rule_id: str 30 | ) -> None: 31 | """ 32 | Show detailed information about a single access control rule on an endpoint. 33 | """ 34 | transfer_client = login_manager.get_transfer_client() 35 | auth_client = login_manager.get_auth_client() 36 | 37 | rule = transfer_client.get_endpoint_acl_rule(endpoint_id, rule_id) 38 | display( 39 | rule, 40 | text_mode=display.RECORD, 41 | fields=[ 42 | Field("Rule ID", "id"), 43 | Field("Permissions", "permissions"), 44 | Field( 45 | "Shared With", 46 | "@", 47 | formatter=AclPrincipalFormatter(auth_client=auth_client), 48 | ), 49 | Field("Path", "path"), 50 | Field("Expiration Date", "expiration_date"), 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command, endpoint_id_arg 5 | from globus_cli.termio import Field, display, formatters 6 | 7 | from ._common import user_credential_id_arg 8 | 9 | 10 | @command("show", short_help="Show a specific User Credential on an Endpoint.") 11 | @endpoint_id_arg 12 | @user_credential_id_arg() 13 | @LoginManager.requires_login("auth", "transfer") 14 | def user_credential_show( 15 | login_manager: LoginManager, 16 | *, 17 | endpoint_id: uuid.UUID, 18 | user_credential_id: uuid.UUID, 19 | ) -> None: 20 | """ 21 | Show a specific User Credential on a given Globus Connect Server v5 Endpoint. 22 | """ 23 | from globus_cli.services.gcs import ConnectorIdFormatter 24 | 25 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 26 | auth_client = login_manager.get_auth_client() 27 | 28 | fields = [ 29 | Field("ID", "id"), 30 | Field("Display Name", "display_name"), 31 | Field( 32 | "Globus Identity", 33 | "identity_id", 34 | formatter=formatters.auth.IdentityIDFormatter(auth_client), 35 | ), 36 | Field("Local Username", "username"), 37 | Field("Connector", "connector_id", formatter=ConnectorIdFormatter()), 38 | Field("Invalid", "invalid"), 39 | Field("Provisioned", "provisioned"), 40 | Field("Policies", "policies", formatter=formatters.SortedJson), 41 | ] 42 | 43 | res = gcs_client.get_user_credential(user_credential_id) 44 | display(res, text_mode=display.RECORD, fields=fields) 45 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/user_credential/list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.login_manager import LoginManager 9 | from globus_cli.parsing import OMITTABLE_UUID, command, endpoint_id_arg 10 | from globus_cli.termio import Field, display, formatters 11 | 12 | 13 | @command("list", short_help="List all User Credentials on an Endpoint.") 14 | @endpoint_id_arg 15 | @click.option( 16 | "--storage-gateway", 17 | default=globus_sdk.MISSING, 18 | type=OMITTABLE_UUID, 19 | help=( 20 | "Filter results to User Credentials on a Storage Gateway specified by " 21 | "this UUID" 22 | ), 23 | ) 24 | @LoginManager.requires_login("auth", "transfer") 25 | def user_credential_list( 26 | login_manager: LoginManager, 27 | *, 28 | endpoint_id: uuid.UUID, 29 | storage_gateway: uuid.UUID | globus_sdk.MissingType, 30 | ) -> None: 31 | """ 32 | List all of your User Credentials on a Globus Connect Server v5 Endpoint. 33 | """ 34 | gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) 35 | auth_client = login_manager.get_auth_client() 36 | res = gcs_client.get_user_credential_list(storage_gateway=storage_gateway) 37 | fields = [ 38 | Field("ID", "id"), 39 | Field("Display Name", "display_name"), 40 | Field( 41 | "Globus Identity", 42 | "identity_id", 43 | formatter=formatters.auth.IdentityIDFormatter(auth_client), 44 | ), 45 | Field("Local Username", "username"), 46 | Field("Invalid", "invalid"), 47 | ] 48 | display(res, text_mode=display.TABLE, fields=fields) 49 | -------------------------------------------------------------------------------- /tests/functional/collection/role/test_role_show.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_sdk.testing import RegisteredResponse, load_response_set 4 | 5 | 6 | def test_successful_gcs_collection_role_show( 7 | run_line, 8 | add_gcs_login, 9 | get_identities_mocker, 10 | ): 11 | # setup data for the collection_id -> endpoint_id lookup 12 | # and create dummy credentials for the test to run against that GCS 13 | meta = load_response_set("cli.collection_operations").metadata 14 | endpoint_id = meta["endpoint_id"] 15 | collection_id = meta["mapped_collection_id"] 16 | add_gcs_login(endpoint_id) 17 | 18 | role_id = str(uuid.UUID(int=1)) 19 | user_id = str(uuid.UUID(int=2)) 20 | 21 | # mock the responses for the Get Role API (GCS) 22 | RegisteredResponse( 23 | service="gcs", 24 | path=f"/roles/{role_id}", 25 | json={ 26 | "DATA_TYPE": "role#1.0.0", 27 | "collection": collection_id, 28 | "id": role_id, 29 | "principal": f"urn:globus:auth:identity:{user_id}", 30 | "role": "administrator", 31 | }, 32 | ).add() 33 | 34 | # Mock the Get Identities API (Auth) 35 | # so that CLI output rendering can show a username 36 | user_meta = get_identities_mocker.configure_one(id=user_id).metadata 37 | username = user_meta["username"] 38 | 39 | # now test the command and confirm that output shows the role name and the 40 | # username 41 | run_line( 42 | ["globus", "gcs", "collection", "role", "show", collection_id, role_id], 43 | search_stdout=[ 44 | ("Role", "administrator"), 45 | ("Principal", username), 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /src/globus_cli/_click_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | A compatibility module for handling click v8.2.0+ and 8.1.x API differences. 3 | """ 4 | 5 | import functools 6 | import importlib.metadata 7 | import typing as t 8 | 9 | import click 10 | 11 | C = t.TypeVar("C", bound=t.Callable[..., t.Any]) 12 | 13 | CLICK_VERSION = importlib.metadata.version("click") 14 | 15 | OLDER_CLICK_API = CLICK_VERSION.startswith("8.1.") 16 | NEWER_CLICK_API = not OLDER_CLICK_API 17 | 18 | 19 | def shim_get_metavar(f: C) -> C: 20 | """ 21 | Make a ParamType.get_metavar function compatible with both the 8.1.x and 22 | the 8.2.0+ APIs. 23 | 24 | Under 8.2.0, `ctx: click.Context` is passed, while older versions do not. 25 | Therefore, do nothing on 8.2.0+ and pass `ctx=None' if the older click 26 | version is in use. 27 | 28 | NOTE: we pass `ctx=None` which violates the declared types (but works at 29 | runtime) because when running under older click versions, there may not be 30 | a current click context. 31 | """ 32 | if OLDER_CLICK_API: 33 | 34 | @functools.wraps(f) 35 | def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any: 36 | return f(*args, **kwargs, ctx=None) 37 | 38 | return wrapper # type: ignore[return-value] 39 | 40 | return f 41 | 42 | 43 | def shim_get_missing_message(f: C) -> C: 44 | """ 45 | Shim `get_missing_message` in a similar way to `get_metavar` above. 46 | """ 47 | if OLDER_CLICK_API: 48 | 49 | @functools.wraps(f) 50 | def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any: 51 | return f(*args, **kwargs, ctx=click.get_current_context()) 52 | 53 | return wrapper # type: ignore[return-value] 54 | 55 | return f 56 | -------------------------------------------------------------------------------- /tests/files/api_fixtures/endpoint_acl_operations.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | user_id: "25de0aed-aa83-4600-a1be-a62a910af116" 3 | endpoint_id: "1405823f-0597-4a16-b296-46d4f0ae4b15" 4 | permission_id: "fbe71e48-9fb4-4265-a5b5-4408d8bb5d1b" 5 | 6 | transfer: 7 | - path: /v0.10/endpoint/1405823f-0597-4a16-b296-46d4f0ae4b15/access 8 | method: post 9 | json: 10 | { 11 | "code": "Created", 12 | "resource": "/endpoint/1405823f-0597-4a16-b296-46d4f0ae4b15/access", 13 | "DATA_TYPE": "access_create_result", 14 | "request_id": "abc123", 15 | "access_id": 12345, 16 | "message": "Access rule created successfully." 17 | } 18 | - path: /v0.10/endpoint/1405823f-0597-4a16-b296-46d4f0ae4b15/access/fbe71e48-9fb4-4265-a5b5-4408d8bb5d1b 19 | method: put 20 | json: 21 | { 22 | "DATA_TYPE": "result", 23 | "code": "Updated", 24 | "message": "Access rule 'fbe71e48-9fb4-4265-a5b5-4408d8bb5d1b' updated successfully", 25 | "request_id": "abc123", 26 | "resource": "/endpoint/1405823f-0597-4a16-b296-46d4f0ae4b15/access/fbe71e48-9fb4-4265-a5b5-4408d8bb5d1b" 27 | } 28 | - path: /v0.10/endpoint/1405823f-0597-4a16-b296-46d4f0ae4b15/access/fbe71e48-9fb4-4265-a5b5-4408d8bb5d1b 29 | method: get 30 | json: 31 | { 32 | "DATA_TYPE": "access", 33 | "create_time": "2024-12-16T21:30:15+00:00", 34 | "expiration_date": "2025-01-01T00:00:00+00:00", 35 | "id": "fbe71e48-9fb4-4265-a5b5-4408d8bb5d1b", 36 | "path": "/", 37 | "permissions": "rw", 38 | "principal": "25de0aed-aa83-4600-a1be-a62a910af116", 39 | "principal_type": "identity", 40 | "role_id": null, 41 | "role_type": null 42 | } 43 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/invite/accept.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import IdentityType, ParsedIdentity, command 9 | from globus_cli.termio import display 10 | 11 | from .._common import group_id_arg 12 | from ._common import build_invite_actions, get_invite_formatter 13 | 14 | 15 | @command("accept", short_help="Accept an invitation.") 16 | @group_id_arg 17 | @click.option( 18 | "--identity", 19 | type=IdentityType(), 20 | help="Only accept invitations for a specific identity", 21 | ) 22 | @LoginManager.requires_login("groups") 23 | def invite_accept( 24 | login_manager: LoginManager, *, group_id: uuid.UUID, identity: ParsedIdentity | None 25 | ) -> None: 26 | """ 27 | Accept an invitation to a group 28 | 29 | By default, all invitations to the group are accepted. To restrict this action to 30 | only specific invitations when there are multiple, use the `--identity` flag. 31 | """ 32 | auth_client = login_manager.get_auth_client() 33 | groups_client = login_manager.get_groups_client() 34 | 35 | actions = build_invite_actions( 36 | auth_client, groups_client, "accept", group_id, identity 37 | ) 38 | response = groups_client.batch_membership_action(group_id, actions) 39 | # if this failed to return at least one accepted user, figure out an error to show 40 | if not response.get("accept", None): 41 | try: 42 | raise ValueError(response["errors"]["accept"][0]["detail"]) 43 | except LookupError: 44 | raise ValueError("Could not accept invite") 45 | 46 | display(response, text_mode=get_invite_formatter("accept")) 47 | -------------------------------------------------------------------------------- /tests/unit/termio/printer/test_record_list_printer.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from globus_cli.termio import Field 4 | from globus_cli.termio.printers import RecordListPrinter 5 | 6 | 7 | def test_record_list_printer_prints(): 8 | fields = ( 9 | Field("Column A", "a"), 10 | Field("Column B", "b"), 11 | Field("Column C", "c"), 12 | ) 13 | data = ( 14 | {"a": 1, "b": 4, "c": 7}, 15 | {"a": 2, "b": 5, "c": 8}, 16 | {"a": 3, "b": 6, "c": 9}, 17 | ) 18 | 19 | printer = RecordListPrinter(fields=fields, max_width=80) 20 | 21 | with StringIO() as stream: 22 | printer.echo(data, stream) 23 | printed_records = stream.getvalue() 24 | 25 | assert printed_records == ( 26 | "Column A: 1\n" 27 | "Column B: 4\n" 28 | "Column C: 7\n" 29 | "\n" 30 | "Column A: 2\n" 31 | "Column B: 5\n" 32 | "Column C: 8\n" 33 | "\n" 34 | "Column A: 3\n" 35 | "Column B: 6\n" 36 | "Column C: 9\n" 37 | ) 38 | 39 | 40 | def test_record_list_printer_wraps_long_values(): 41 | fields = ( 42 | Field("Column A", "a"), 43 | Field("Column B", "b", wrap_enabled=True), 44 | ) 45 | data = ( 46 | {"a": 1, "b": "b" * 10}, 47 | {"a": 2, "b": "b"}, 48 | ) 49 | 50 | printer = RecordListPrinter(fields=fields, max_width=15) 51 | 52 | with StringIO() as stream: 53 | printer.echo(data, stream) 54 | printed_records = stream.getvalue() 55 | 56 | assert printed_records == ( 57 | "Column A: 1\n" 58 | "Column B: bbbbb\n" 59 | " bbbbb\n" 60 | "\n" 61 | "Column A: 2\n" 62 | "Column B: b\n" 63 | ) 64 | -------------------------------------------------------------------------------- /src/globus_cli/exception_handling/hooks/generic_hooks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.termio import PrintableErrorField, outformat_is_json, write_error_info 9 | 10 | from ..registry import sdk_error_handler 11 | 12 | 13 | @sdk_error_handler( 14 | error_class="GlobusAPIError", condition=lambda err: err.raw_json is None 15 | ) 16 | def null_data_error_handler(exception: globus_sdk.GlobusAPIError) -> None: 17 | write_error_info( 18 | "GlobusAPINullDataError", 19 | [PrintableErrorField("error_type", exception.__class__.__name__)], 20 | ) 21 | 22 | 23 | @sdk_error_handler( 24 | error_class="GlobusAPIError", condition=lambda err: outformat_is_json() 25 | ) 26 | def json_error_handler(exception: globus_sdk.GlobusAPIError) -> None: 27 | msg = json.dumps(exception.raw_json, indent=2) 28 | click.secho(msg, fg="yellow", err=True) 29 | 30 | 31 | @sdk_error_handler() # catch-all 32 | def globusapi_hook(exception: globus_sdk.GlobusAPIError) -> None: 33 | write_error_info( 34 | "Globus API Error", 35 | [ 36 | PrintableErrorField("HTTP status", exception.http_status), 37 | PrintableErrorField("code", exception.code), 38 | PrintableErrorField("message", exception.message, multiline=True), 39 | ], 40 | ) 41 | 42 | 43 | @sdk_error_handler(error_class="GlobusError") 44 | def globus_error_hook(exception: globus_sdk.GlobusError) -> None: 45 | write_error_info( 46 | "Globus Error", 47 | [ 48 | PrintableErrorField("error_type", exception.__class__.__name__), 49 | PrintableErrorField("message", str(exception), multiline=True), 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/invite/decline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | import click 6 | 7 | from globus_cli.login_manager import LoginManager 8 | from globus_cli.parsing import IdentityType, ParsedIdentity, command 9 | from globus_cli.termio import display 10 | 11 | from .._common import group_id_arg 12 | from ._common import build_invite_actions, get_invite_formatter 13 | 14 | 15 | @command("decline", short_help="Decline an invitation.") 16 | @group_id_arg 17 | @click.option( 18 | "--identity", 19 | type=IdentityType(), 20 | help="Only decline invitations for a specific identity", 21 | ) 22 | @LoginManager.requires_login("groups") 23 | def invite_decline( 24 | login_manager: LoginManager, *, group_id: uuid.UUID, identity: ParsedIdentity | None 25 | ) -> None: 26 | """ 27 | Decline an invitation to a group 28 | 29 | By default, all invitations to the group are declined. To restrict this action to 30 | only specific invitations when there are multiple, use the `--identity` flag. 31 | """ 32 | auth_client = login_manager.get_auth_client() 33 | groups_client = login_manager.get_groups_client() 34 | 35 | actions = build_invite_actions( 36 | auth_client, groups_client, "decline", group_id, identity 37 | ) 38 | response = groups_client.batch_membership_action(group_id, actions) 39 | 40 | # if this failed to return at least one accepted user, figure out an error to show 41 | if not response.get("decline", None): 42 | try: 43 | raise ValueError(response["errors"]["decline"][0]["detail"]) 44 | except LookupError: 45 | raise ValueError("Could not decline invite") 46 | 47 | display(response, text_mode=get_invite_formatter("decline")) 48 | -------------------------------------------------------------------------------- /tests/unit/test_common_options.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | 4 | from globus_cli.parsing.command_state import CommandState 5 | from globus_cli.parsing.shared_options import common_options 6 | 7 | 8 | def test_common_options_are_not_exposed(runner): 9 | """ 10 | The common options decorator only produces options which are stored to the 11 | CommandState object via callbacks. 12 | 13 | Therefore, a command with no arguments should work. 14 | """ 15 | 16 | @common_options() 17 | @click.command 18 | def foo(): 19 | pass 20 | 21 | result = runner.invoke(foo, []) 22 | assert result.exit_code == 0 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "add_args, expect_verbosity", 27 | ( 28 | pytest.param([], 0, id="default"), 29 | pytest.param(["-v"], 1, id="v"), 30 | pytest.param(["--verbose"], 1, id="verbose"), 31 | pytest.param(["-vv"], 2, id="vv"), 32 | pytest.param(["-vvv"], 3, id="vvv"), 33 | pytest.param(["--quiet"], -1, id="quiet"), 34 | pytest.param(["-v", "--quiet"], -1, id="v-quiet"), 35 | pytest.param(["-vv", "--quiet"], -1, id="vv-quiet"), 36 | pytest.param(["-vv", "--quiet", "--quiet"], -1, id="vv-quiet-quiet"), 37 | pytest.param(["--debug"], 3, id="debug"), 38 | pytest.param(["--debug", "--quiet"], -1, id="debug-quiet"), 39 | ), 40 | ) 41 | def test_verbosity_control(runner, add_args, expect_verbosity): 42 | @common_options() 43 | @click.command 44 | def foo(): 45 | ctx = click.get_current_context() 46 | state = ctx.ensure_object(CommandState) 47 | print(state.verbosity) 48 | 49 | result = runner.invoke(foo, add_args) 50 | assert result.exit_code == 0 51 | assert int(result.output) == expect_verbosity 52 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from click.shell_completion import ShellComplete 3 | from click.testing import CliRunner 4 | 5 | 6 | @pytest.fixture 7 | def runner(): 8 | return CliRunner() 9 | 10 | 11 | @pytest.fixture 12 | def run_command(runner): 13 | def _run(cmd, args, exit_code=0): 14 | result = runner.invoke(cmd, args, catch_exceptions=bool(exit_code)) 15 | assert result.exit_code == exit_code 16 | return result 17 | 18 | return _run 19 | 20 | 21 | @pytest.fixture 22 | def get_completions(): 23 | """ 24 | This fixture provides a function which accepts a command, 25 | arguments, and an incomplete string (the last, partial arg). 26 | 27 | It then uses test helpers defined in click's own testsuite to 28 | render this to a list of strings. 29 | """ 30 | 31 | def complete(cli, args, incomplete, *, as_strings: bool = True): 32 | if as_strings: 33 | return _get_words(cli, args, incomplete) 34 | else: 35 | return _get_completions(cli, args, incomplete) 36 | 37 | return complete 38 | 39 | 40 | # NOTE: this function was lifted directly from click test_shell_completion.py 41 | # https://github.com/pallets/click/blob/923d197b56caa9ffea21edeef5baf1816585b099/tests/test_shell_completion.py#L21-L22 42 | def _get_words(cli, args, incomplete): 43 | return [c.value for c in _get_completions(cli, args, incomplete)] 44 | 45 | 46 | # NOTE: this function was lifted directly from click test_shell_completion.py 47 | # https://github.com/pallets/click/blob/923d197b56caa9ffea21edeef5baf1816585b099/tests/test_shell_completion.py#L16-L18 48 | def _get_completions(cli, args, incomplete): 49 | comp = ShellComplete(cli, {}, cli.name, "_CLICK_COMPLETE") 50 | return comp.get_completions(args, incomplete) 51 | -------------------------------------------------------------------------------- /src/globus_cli/commands/endpoint/role/list.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from globus_cli.login_manager import LoginManager 4 | from globus_cli.parsing import command, endpoint_id_arg 5 | from globus_cli.termio import Field, display 6 | 7 | from ._common import RolePrincipalFormatter 8 | 9 | 10 | @command( 11 | "list", 12 | short_help="List roles on an endpoint.", 13 | adoc_output="""Textual output has the following fields: 14 | 15 | - 'Principal Type' 16 | - 'Role ID' 17 | - 'Principal' 18 | - 'Role' 19 | 20 | The principal is a user or group ID, and the principal type says which of these 21 | types the principal is. The term "Principal" is used in the sense of "a 22 | security principal", an entity which has some privileges associated with it. 23 | """, 24 | adoc_examples="""Show all roles on 'aa752cea-8222-5bc8-acd9-555b090c0ccb': 25 | 26 | [source,bash] 27 | ---- 28 | $ globus endpoint role list 'aa752cea-8222-5bc8-acd9-555b090c0ccb' 29 | ---- 30 | """, 31 | ) 32 | @endpoint_id_arg 33 | @LoginManager.requires_login("auth", "transfer") 34 | def role_list(login_manager: LoginManager, *, endpoint_id: uuid.UUID) -> None: 35 | """ 36 | List the assigned roles on an endpoint. 37 | 38 | You must have sufficient privileges to see the roles on the endpoint. 39 | """ 40 | transfer_client = login_manager.get_transfer_client() 41 | roles = transfer_client.endpoint_role_list(endpoint_id) 42 | 43 | formatter = RolePrincipalFormatter(login_manager.get_auth_client()) 44 | formatter.add_items(*roles) 45 | 46 | display( 47 | roles, 48 | fields=[ 49 | Field("Principal Type", "principal_type"), 50 | Field("Role ID", "id"), 51 | Field("Principal", "@", formatter=formatter), 52 | Field("Role", "role"), 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /src/globus_cli/termio/printers/unix_printer/unix_printer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import click 6 | import globus_sdk 7 | 8 | from globus_cli.types import JsonValue 9 | 10 | from ..base import Printer 11 | from ._formatter import UnixFormattingError, emit_any_value 12 | 13 | DataObject = t.Union[JsonValue, globus_sdk.GlobusHTTPResponse] 14 | 15 | 16 | class UnixPrinter(Printer[DataObject]): 17 | """ 18 | Printer to render data objects in a format suitable for consumption by UNIX tools. 19 | 20 | This is a thin wrapper around the AWS CLI's text formatter, which is a simple 21 | key-value pair format with no headers or footers. 22 | """ 23 | 24 | def echo(self, data: DataObject, stream: t.IO[str] | None = None) -> None: 25 | res = UnixPrinter.jmespath_preprocess(data) 26 | 27 | try: 28 | for line in emit_any_value(res): 29 | click.echo(line, file=stream) 30 | 31 | # Attr errors indicate that we got data which cannot be unix formatted 32 | # likely a scalar + non-scalar in an array, though there may be other cases 33 | # print good error and exit(2) (Count this as UsageError!) 34 | except UnixFormattingError as err: 35 | click.echo( 36 | "UNIX formatting of output failed." 37 | f"\n{err}" 38 | "\n " 39 | "This means that data has a structure which cannot be " 40 | "handled by the UNIX formatter." 41 | "\n " 42 | "To avoid this error in the future, ensure that you query the " 43 | 'exact properties you want from output data with "--jmespath"', 44 | err=True, 45 | ) 46 | click.get_current_context().exit(2) 47 | -------------------------------------------------------------------------------- /src/globus_cli/commands/list_commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | from globus_cli.parsing import command 6 | from globus_cli.reflect import walk_contexts 7 | from globus_cli.types import ClickContextTree 8 | 9 | 10 | def _print_command(cmd_ctx: click.Context) -> None: 11 | # print commands with short_help 12 | short_help = cmd_ctx.command.get_short_help_str() 13 | name = cmd_ctx.command_path 14 | 15 | click.echo(f" {name}") 16 | click.echo(f" {short_help}\n") 17 | 18 | 19 | def _print_tree( 20 | ctx: click.Context, 21 | subcommands: list[click.Context], 22 | subgroups: list[ClickContextTree], 23 | ) -> None: 24 | click.echo(f"\n=== {ctx.command_path} ===\n") 25 | for cmd_ctx in subcommands: 26 | _print_command(cmd_ctx) 27 | for subctx, subsubcommands, subsubgroups in subgroups: 28 | _print_tree(subctx, subsubcommands, subsubgroups) 29 | 30 | 31 | @command( 32 | "list-commands", 33 | short_help="List all CLI Commands.", 34 | help=( 35 | "List all Globus CLI Commands with short help output. " 36 | "For full command help, run the command with the " 37 | "`--help` flag" 38 | ), 39 | ) 40 | def list_commands() -> None: 41 | """ 42 | Prints the name and a short description of every command available in the globus 43 | cli. Commands are grouped by their parent commands. 44 | """ 45 | # get the root context (the click context for the entire CLI tree) 46 | root_ctx = click.get_current_context().find_root() 47 | root_command: click.Group = root_ctx.command # type: ignore[assignment] 48 | ctx, subcmds, subgroups = walk_contexts("globus", root_command) 49 | _print_tree(ctx, subcmds, subgroups) 50 | # get an extra newline at the end 51 | click.echo("") 52 | -------------------------------------------------------------------------------- /tests/unit/param_types/test_location_type.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | 4 | from globus_cli.parsing.param_types import LocationType 5 | 6 | 7 | @click.command() 8 | @click.argument( 9 | "LOCATION", 10 | type=LocationType(), 11 | ) 12 | def loc_cmd(location): 13 | assert isinstance(location, str) 14 | click.echo(location) 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "loc_value", 19 | ( 20 | ",1,1", 21 | "1,1,", 22 | ",1,1,", 23 | ",", 24 | "1,", 25 | ",1", 26 | ), 27 | ) 28 | def test_location_cannot_parse_under_regex(runner, loc_value): 29 | # no arg becomes empty dict 30 | result = runner.invoke(loc_cmd, [loc_value]) 31 | assert result.exit_code == 2 32 | assert "does not match the expected 'latitude,longitude' format" in result.output 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "loc_value", 37 | ( 38 | "foo,bar", 39 | "10.0a,40.0", 40 | "10.0,40.0a", 41 | ), 42 | ) 43 | def test_location_cannot_parse_as_float(runner, loc_value): 44 | # no arg becomes empty dict 45 | result = runner.invoke(loc_cmd, [loc_value]) 46 | assert result.exit_code == 2 47 | assert "is not a well-formed 'latitude,longitude' pair" in result.output 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "loc_value", 52 | ( 53 | "40,40.0", 54 | "10,-40.0", 55 | "-0.0,180.0", 56 | "190,255", # not valid coordinates, but should parse okay 57 | "100 , 20.0", 58 | " 100 , 20.0 ", 59 | " 100.0,20.0 ", 60 | ), 61 | ) 62 | def test_location_parses_okay(runner, loc_value): 63 | # no arg becomes empty dict 64 | result = runner.invoke(loc_cmd, ["--", loc_value]) 65 | assert result.exit_code == 0, result.output 66 | assert result.output == f"{loc_value}\n" 67 | -------------------------------------------------------------------------------- /src/globus_cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from globus_cli.parsing import main_group 2 | 3 | 4 | @main_group( 5 | lazy_subcommands={ 6 | "api": ("api", "api_command"), 7 | "bookmark": ("bookmark", "bookmark_command"), 8 | "cli-profile-list": ("cli_profile_list", "cli_profile_list"), 9 | "collection": ("collection", "collection_command"), 10 | "delete": ("delete", "delete_command"), 11 | "endpoint": ("endpoint", "endpoint_command"), 12 | "flows": ("flows", "flows_command"), 13 | "gcp": ("gcp", "gcp_command"), 14 | "gcs": ("gcs", "gcs_command"), 15 | "get-identities": ("get_identities", "get_identities_command"), 16 | "group": ("group", "group_command"), 17 | "list-commands": ("list_commands", "list_commands"), 18 | "login": ("login", "login_command"), 19 | "logout": ("logout", "logout_command"), 20 | "ls": ("ls", "ls_command"), 21 | "mkdir": ("mkdir", "mkdir_command"), 22 | "rename": ("rename", "rename_command"), 23 | "rm": ("rm", "rm_command"), 24 | "search": ("search", "search_command"), 25 | "session": ("session", "session_command"), 26 | "stat": ("stat", "stat_command"), 27 | "task": ("task", "task_command"), 28 | "timer": ("timer", "timer_command"), 29 | "transfer": ("transfer", "transfer_command"), 30 | "update": ("update", "update_command"), 31 | "version": ("version", "version_command"), 32 | "whoami": ("whoami", "whoami_command"), 33 | } 34 | ) 35 | def main() -> None: 36 | """ 37 | Interact with Globus from the command line 38 | 39 | All `globus` subcommands support `--help` documentation. 40 | 41 | Use `globus login` to get started! 42 | 43 | The documentation is also online at https://docs.globus.org/cli/ 44 | """ 45 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/member/remove.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import IdentityType, ParsedIdentity, command 7 | from globus_cli.termio import Field, display 8 | 9 | from .._common import group_id_arg 10 | 11 | REMOVED_USER_FIELDS = [ 12 | Field("Group ID", "group_id"), 13 | Field("Removed User ID", "identity_id"), 14 | Field("Removed User Username", "username"), 15 | ] 16 | 17 | 18 | @command("remove", short_help="Remove a member from a group.") 19 | @group_id_arg 20 | @click.argument("user", type=IdentityType()) 21 | @LoginManager.requires_login("groups") 22 | def member_remove( 23 | login_manager: LoginManager, *, group_id: uuid.UUID, user: ParsedIdentity 24 | ) -> None: 25 | """ 26 | Remove a member from a group. 27 | 28 | The USER argument may be an identity ID or username (whereas the group must be 29 | specified with an ID). 30 | """ 31 | auth_client = login_manager.get_auth_client() 32 | groups_client = login_manager.get_groups_client() 33 | identity_id = auth_client.maybe_lookup_identity_id(user.value) 34 | if not identity_id: 35 | raise click.UsageError(f"Couldn't determine identity from user value: {user}") 36 | actions = {"remove": [{"identity_id": identity_id}]} 37 | response = groups_client.batch_membership_action(group_id, actions) 38 | if not response.get("remove", None): 39 | try: 40 | raise ValueError(response["errors"]["remove"][0]["detail"]) 41 | except (IndexError, KeyError): 42 | raise ValueError("Could not remove the user from the group") 43 | display( 44 | response, 45 | text_mode=display.RECORD, 46 | fields=REMOVED_USER_FIELDS, 47 | response_key=lambda data: data["remove"][0], 48 | ) 49 | -------------------------------------------------------------------------------- /src/globus_cli/commands/group/member/reject.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import click 4 | 5 | from globus_cli.login_manager import LoginManager 6 | from globus_cli.parsing import IdentityType, ParsedIdentity, command 7 | from globus_cli.termio import Field, display 8 | 9 | from .._common import group_id_arg 10 | 11 | REJECTED_USER_FIELDS = [ 12 | Field("Group ID", "group_id"), 13 | Field("Rejected User ID", "identity_id"), 14 | Field("Rejected User Username", "username"), 15 | ] 16 | 17 | 18 | @command("reject", short_help="Reject a member from a group.") 19 | @group_id_arg 20 | @click.argument("user", type=IdentityType()) 21 | @LoginManager.requires_login("groups") 22 | def member_reject( 23 | login_manager: LoginManager, *, group_id: uuid.UUID, user: ParsedIdentity 24 | ) -> None: 25 | """ 26 | Reject a pending member from a group. 27 | 28 | The USER argument may be an identity ID or username (whereas the group must be 29 | specified with an ID). 30 | """ 31 | auth_client = login_manager.get_auth_client() 32 | groups_client = login_manager.get_groups_client() 33 | identity_id = auth_client.maybe_lookup_identity_id(user.value) 34 | if not identity_id: 35 | raise click.UsageError(f"Couldn't determine identity from user value: {user}") 36 | actions = {"reject": [{"identity_id": identity_id}]} 37 | response = groups_client.batch_membership_action(group_id, actions) 38 | if not response.get("reject", None): 39 | try: 40 | raise ValueError(response["errors"]["reject"][0]["detail"]) 41 | except (IndexError, KeyError): 42 | raise ValueError("Could not reject the user from the group") 43 | display( 44 | response, 45 | text_mode=display.RECORD, 46 | fields=REJECTED_USER_FIELDS, 47 | response_key=lambda data: data["reject"][0], 48 | ) 49 | -------------------------------------------------------------------------------- /tests/functional/search/test_index.py: -------------------------------------------------------------------------------- 1 | from globus_sdk.testing import load_response, load_response_set 2 | 3 | 4 | def test_index_list(run_line): 5 | meta = load_response_set("cli.search").metadata 6 | list_data = meta["index_list_data"] 7 | 8 | result = run_line(["globus", "search", "index", "list"]) 9 | 10 | found = set() 11 | for index_id, attrs in list_data.items(): 12 | for line in result.output.split("\n"): 13 | if index_id in line: 14 | found.add(index_id) 15 | for v in attrs.values(): 16 | assert v in line 17 | assert len(found) == len(list_data) 18 | 19 | 20 | def test_index_show(run_line): 21 | meta = load_response_set("cli.search").metadata 22 | index_id = meta["index_id"] 23 | 24 | run_line( 25 | ["globus", "search", "index", "show", index_id], 26 | search_stdout=("Index ID", index_id), 27 | ) 28 | 29 | 30 | def test_index_create(run_line): 31 | meta = load_response("search.create_index").metadata 32 | index_id = meta["index_id"] 33 | 34 | run_line( 35 | [ 36 | "globus", 37 | "search", 38 | "index", 39 | "create", 40 | "example_cookery", 41 | "Example index of Cookery", 42 | ], 43 | search_stdout=("Index ID", index_id), 44 | ) 45 | 46 | 47 | def test_index_delete(run_line): 48 | meta = load_response("search.delete_index").metadata 49 | index_id = meta["index_id"] 50 | 51 | run_line( 52 | f"globus search index delete {index_id}", 53 | search_stdout=f"Index {index_id} is now marked for deletion.", 54 | ) 55 | 56 | 57 | def test_index_delete_rejects_empty_str(run_line): 58 | result = run_line(["globus", "search", "index", "delete", ""], assert_exit_code=2) 59 | assert "Invalid value for 'INDEX_ID'" in result.stderr 60 | --------------------------------------------------------------------------------