Skip to content

Commit 1cfaa8e

Browse files
committed
Squash-merge cli-config-management-sc-48273 into main
1 parent d19ef4f commit 1cfaa8e

File tree

26 files changed

+2418
-48
lines changed

26 files changed

+2418
-48
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ repos:
6666
- id: "editorconfig-checker"
6767

6868
- repo: "https://github.com/python-jsonschema/check-jsonschema"
69-
rev: "0.36.1"
69+
rev: "0.36.2"
7070
hooks:
7171
- id: "check-dependabot"
7272

7373
- repo: "https://github.com/rhysd/actionlint"
74-
rev: "v1.7.10"
74+
rev: "v1.7.11"
7575
hooks:
7676
- id: "actionlint"
7777

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
2+
Added
3+
-----
4+
5+
* Add ``gra init`` command to initialize a new gra repository.
6+
* Add ``gra manage`` command to configure a gra repository.
7+
8+
Development
9+
-----------
10+
11+
* Add re-usable constructs for requesting single- and multi-value user selections.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ classifiers = [
1919

2020
dependencies = [
2121
"click >=8,<9",
22+
"prompt-toolkit >=3.0,<4.0",
23+
"rich >=14.0,<15.0",
2224
"globus-sdk >=4",
2325
"requests",
2426
"types-requests (>=2.32.4.20260107,<3.0.0.0)",

requirements/mypy/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
librt==0.7.8 ; python_version >= "3.10" and platform_python_implementation != "PyPy"
1+
librt==0.8.1 ; python_version >= "3.10" and platform_python_implementation != "PyPy"
22
mypy-extensions==1.1.0 ; python_version >= "3.10"
33
mypy==1.19.1 ; python_version >= "3.10"
44
pathspec==1.0.4 ; python_version >= "3.10"

src/globus_registered_api/cli.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@
88
import json
99
import os
1010
import typing as t
11-
from collections.abc import Iterable
1211
from uuid import UUID
1312

1413
import click
1514
import click.exceptions
16-
from globus_sdk import AuthClient
1715
from globus_sdk import ClientApp
18-
from globus_sdk import FlowsClient
1916
from globus_sdk import GlobusAPIError
2017
from globus_sdk import GlobusAppConfig
21-
from globus_sdk import Scope
2218
from globus_sdk import UserApp
19+
from globus_sdk.scopes import AuthScopes
20+
from globus_sdk.scopes import FlowsScopes
21+
from globus_sdk.scopes import GroupsScopes
22+
from globus_sdk.scopes import SearchScopes
2323
from globus_sdk.token_storage import JSONTokenStorage
2424
from globus_sdk.token_storage import TokenStorage
2525

@@ -96,9 +96,15 @@ def for_globus_app(
9696
)
9797

9898

99-
SCOPE_REQUIREMENTS: dict[str, str | Scope | Iterable[str | Scope]] = {
100-
AuthClient.scopes.resource_server: [AuthClient.scopes.openid],
101-
FlowsClient.scopes.resource_server: [FlowsClient.scopes.all],
99+
SCOPE_REQUIREMENTS = {
100+
AuthScopes.resource_server: [
101+
AuthScopes.openid,
102+
AuthScopes.profile,
103+
AuthScopes.email,
104+
],
105+
GroupsScopes.resource_server: [GroupsScopes.view_my_groups_and_memberships],
106+
SearchScopes.resource_server: [SearchScopes.search],
107+
FlowsScopes.resource_server: [FlowsScopes.all],
102108
}
103109

104110

src/globus_registered_api/clients.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from globus_sdk import AuthClient
77
from globus_sdk import GlobusApp
8+
from globus_sdk import GroupsClient
9+
from globus_sdk import SearchClient
810

911
from globus_registered_api import ExtendedFlowsClient
1012

@@ -31,3 +33,27 @@ def create_flows_client(app: GlobusApp) -> ExtendedFlowsClient:
3133
:return: An ExtendedFlowsClient configured with the provided app
3234
"""
3335
return ExtendedFlowsClient(app=app)
36+
37+
38+
def create_groups_client(app: GlobusApp) -> GroupsClient:
39+
"""
40+
Create a GroupsClient for the given app.
41+
42+
This function mostly exists for unified testing fixtures.
43+
44+
:param app: A Globus app instance to use for authentication
45+
:return: A GroupsClient configured with the provided app
46+
"""
47+
return GroupsClient(app=app)
48+
49+
50+
def create_search_client(app: GlobusApp) -> SearchClient:
51+
"""
52+
Create a SearchClient for the given app.
53+
54+
This function mostly exists for unified testing fixtures.
55+
56+
:param app: A Globus app instance to use for authentication
57+
:return: A SearchClient configured with the provided app
58+
"""
59+
return SearchClient(app=app)

src/globus_registered_api/commands/init.py

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,209 @@
33
# Copyright 2025-2026 Globus <support@globus.org>
44
# SPDX-License-Identifier: Apache-2.0
55

6+
import os
7+
import typing as t
8+
69
import click
10+
import openapi_pydantic as oa
11+
import prompt_toolkit
12+
from prompt_toolkit.completion import CompleteEvent
13+
from prompt_toolkit.completion import Completer
14+
from prompt_toolkit.completion import Completion
15+
from prompt_toolkit.completion import PathCompleter
16+
from prompt_toolkit.document import Document
17+
from prompt_toolkit.validation import ValidationError
18+
from prompt_toolkit.validation import Validator
719

20+
from globus_registered_api.clients import create_auth_client
21+
from globus_registered_api.config import CoreConfig
22+
from globus_registered_api.config import RegisteredAPIConfig
23+
from globus_registered_api.config import RoleConfig
824
from globus_registered_api.context import CLIContext
925
from globus_registered_api.context import with_cli_context
26+
from globus_registered_api.openapi import OpenAPISpecAnalyzer
27+
from globus_registered_api.openapi.loader import OpenAPILoadError
28+
from globus_registered_api.openapi.loader import load_openapi_spec
29+
from globus_registered_api.rendering import prompt_selection
30+
31+
32+
class OpenAPISpecPath(click.ParamType):
33+
name = "openapi_spec_path"
34+
35+
def convert(
36+
self,
37+
value: t.Any,
38+
param: click.Parameter | None,
39+
ctx: click.Context | None,
40+
) -> str:
41+
if not isinstance(value, str):
42+
self.fail(f"{value!r} is not a valid string", param, ctx)
43+
44+
try:
45+
load_openapi_spec(value)
46+
except OpenAPILoadError as e:
47+
self.fail(str(e), param, ctx)
48+
49+
return value
50+
51+
52+
class OpenAPISpecPathValidator(Validator):
53+
def validate(self, document: Document) -> None:
54+
try:
55+
load_openapi_spec(document.text)
56+
except OpenAPILoadError as e:
57+
raise ValidationError(cursor_position=len(document.text), message=str(e))
58+
59+
60+
class ClickURLParam(click.ParamType):
61+
name = "url"
1062

63+
def convert(
64+
self,
65+
value: t.Any,
66+
param: click.Parameter | None,
67+
ctx: click.Context | None,
68+
) -> str:
69+
if not isinstance(value, str):
70+
self.fail(f"{value!r} is not a valid string", param, ctx)
1171

12-
@click.command
72+
if not (value.startswith("http://") or value.startswith("https://")):
73+
self.fail(f"{value!r} is not a valid URL", param, ctx)
74+
75+
return value
76+
77+
78+
@click.command("init")
1379
@with_cli_context
1480
def init_command(ctx: CLIContext) -> None:
81+
"""Initialize a local Registered API Repository."""
82+
if RegisteredAPIConfig.exists():
83+
click.echo("Error: Cannot re-initialize an existing repository.")
84+
click.echo("Please use 'gra manage' instead.")
85+
raise click.Abort()
86+
87+
identity_id = create_auth_client(ctx.globus_app).userinfo()["sub"]
88+
89+
click.echo("Initializing a new local registered API repository...")
90+
click.echo("Registered APIs leverage the OpenAPI specification.")
91+
click.echo("Many common frameworks, like FastAPI, will generate these for you.")
92+
click.echo("For more information see: https://learn.openapis.org/")
93+
click.echo()
94+
95+
if click.confirm("Does your service have an OpenAPI specification?", default=True):
96+
core_config = _prompt_for_reference_core_config()
97+
else:
98+
core_config = _prompt_for_inline_core_config()
99+
100+
initial_role = RoleConfig(type="identity", id=identity_id, access_level="owner")
101+
config = RegisteredAPIConfig(core=core_config, targets=[], roles=[initial_role])
102+
config.commit()
103+
104+
click.echo()
105+
click.echo("Successfully initialized repository!")
106+
click.echo()
107+
click.echo("To start configuring Registered APIs, use 'gra manage'.")
108+
109+
110+
def _prompt_for_inline_core_config() -> CoreConfig:
111+
click.echo("No problem, I'll need 2 pieces of basic info about it then.")
112+
click.echo("You can always update the OpenAPI spec later.")
113+
click.echo()
114+
115+
# Prompt the user to fill in basic openapi.info fields
116+
# https://swagger.io/specification/#info-object
117+
click.echo("1. What is your service's name?")
118+
click.echo("This should be something human readable, for example, 'Globus Search'.")
119+
name = click.prompt("Service Name", type=str)
120+
click.echo()
121+
122+
click.echo("2. What is the base URL for your service?")
123+
click.echo("This is the common http prefix for all of the API endpoints.")
124+
click.echo("For example, 'https://api.example.com'.")
125+
base_url = click.prompt("Base URL", type=ClickURLParam())
126+
127+
return CoreConfig(
128+
base_url=base_url,
129+
specification=oa.OpenAPI(
130+
openapi="3.1.0",
131+
info=oa.Info(title=name, version="-1"),
132+
paths={},
133+
),
134+
)
135+
136+
137+
def _prompt_for_reference_core_config() -> CoreConfig:
138+
click.echo("Great, can I find this specification?")
139+
click.echo("This may be either a URL or a local filesystem path.")
140+
click.echo()
141+
142+
spec_path = prompt_toolkit.prompt(
143+
"Specification Location (Path or URL): ",
144+
completer=OpenAPISpecCompleter(),
145+
validator=OpenAPISpecPathValidator(),
146+
validate_while_typing=False,
147+
)
148+
click.echo("Looks like an OpenAPI specification, perfect.")
149+
150+
openapi_spec = load_openapi_spec(spec_path)
151+
analysis = OpenAPISpecAnalyzer().analyze(openapi_spec)
152+
153+
if len(analysis.https_servers) == 1:
154+
server = analysis.https_servers[0]
155+
click.echo(f"I found one HTTPS server in the specification: {server}.")
156+
if click.confirm("Should I use this as the base URL of your service?"):
157+
return CoreConfig(base_url=server, specification=spec_path)
158+
else:
159+
click.echo("No problem, I'll need you to tell me the base URL then.")
160+
161+
elif len(analysis.https_servers) > 1:
162+
click.echo("I found multiple HTTPS servers in the specification:")
163+
for server in analysis.https_servers:
164+
click.echo(f" - {server}")
165+
if click.confirm("Would you like to use one of these as the service base URL?"):
166+
server_options = [(server, server) for server in analysis.https_servers]
167+
selected_server = prompt_selection("Server", server_options)
168+
return CoreConfig(base_url=selected_server, specification=spec_path)
169+
else:
170+
click.echo("No problem, I'll need you to tell me the base URL then.")
171+
172+
else:
173+
click.echo("I couldn't find any HTTPS servers in the specification.")
174+
click.echo("Please enter the base URL manually.")
175+
176+
base_url = click.prompt("Base URL", type=ClickURLParam())
177+
return CoreConfig(base_url=base_url, specification=spec_path)
178+
179+
180+
class OpenAPISpecCompleter(Completer):
15181
"""
16-
[Placeholder] Initialize Repository Config.
182+
Specialized Completer for OpenAPI specs.
183+
184+
If the input looks like it could be a URL, do nothing.
185+
Otherwise, complete filesystem paths.
17186
"""
187+
188+
def __init__(self) -> None:
189+
self._path_completer = PathCompleter(
190+
expanduser=True,
191+
file_filter=_is_dir_or_data_file,
192+
)
193+
194+
def get_completions(
195+
self, document: Document, complete_event: CompleteEvent
196+
) -> t.Iterable[Completion]:
197+
text = document.text_before_cursor
198+
199+
could_input_be_http = text.startswith(
200+
"http://"[: len(text)]
201+
) or text.startswith("https://"[: len(text)])
202+
if not could_input_be_http:
203+
yield from self._path_completer.get_completions(document, complete_event)
204+
205+
206+
def _is_dir_or_data_file(filename: str) -> bool:
207+
if os.path.isdir(filename):
208+
return True
209+
210+
_, ext = os.path.splitext(filename)
211+
return ext.lower() in {".json", ".yaml", ".yml"}

0 commit comments

Comments
 (0)