├── 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 |
--------------------------------------------------------------------------------