Skip to content

Commit 5974322

Browse files
committed
Add url option to cli interface
1 parent 162ef03 commit 5974322

5 files changed

Lines changed: 204 additions & 90 deletions

File tree

src/blueapi/cli/cli.py

Lines changed: 176 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from bluesky_stomp.models import Broker
1717
from click.exceptions import ClickException
1818
from observability_utils.tracing import setup_tracing
19-
from pydantic import ValidationError
19+
from pydantic import HttpUrl, ValidationError
2020
from requests.exceptions import ConnectionError
2121

2222
from blueapi import __version__, config
@@ -44,37 +44,87 @@
4444

4545
LOGGER = logging.getLogger(__name__)
4646

47+
P = ParamSpec("P")
48+
T = TypeVar("T")
4749

48-
@click.group(
49-
invoke_without_command=True, context_settings={"auto_envvar_prefix": "BLUEAPI"}
50-
)
51-
@click.version_option(version=__version__, prog_name="blueapi")
52-
@click.option(
53-
"-c", "--config", type=Path, help="Path to configuration YAML file", multiple=True
54-
)
55-
@click.pass_context
56-
def main(ctx: click.Context, config: Path | None | tuple[Path, ...]) -> None:
57-
# if no command is supplied, run with the options passed
5850

59-
# Set umask to DLS standard
60-
os.umask(stat.S_IWOTH)
51+
def check_connection(func: Callable[P, T]) -> Callable[P, T]:
52+
@wraps(func)
53+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
54+
try:
55+
return func(*args, **kwargs)
56+
except ConnectionError as ce:
57+
raise ClickException(
58+
"Failed to establish connection to blueapi server."
59+
) from ce
60+
except BlueskyRemoteControlError as e:
61+
if str(e) == "<Response [401]>":
62+
raise ClickException(
63+
"Access denied. Please check your login status and try again."
64+
) from e
65+
else:
66+
raise e
6167

68+
return wrapper
69+
70+
71+
def _default_config(ctx: click.Context) -> None:
72+
ctx.ensure_object(dict)
6273
config_loader = ConfigLoader(ApplicationConfig)
74+
75+
loaded_config: ApplicationConfig = config_loader.load()
76+
77+
set_up_logging(loaded_config.logging)
78+
79+
ctx.obj["config"] = loaded_config
80+
81+
82+
def _load_config(
83+
ctx: click.Context,
84+
config: Path | None | tuple[Path, ...],
85+
) -> None:
86+
ctx.ensure_object(dict)
87+
88+
config_loader = ConfigLoader(ApplicationConfig)
89+
ctx.obj["custom_config"] = False
90+
6391
if config is not None:
92+
ctx.obj["custom_config"] = True
6493
configs = (config,) if isinstance(config, Path) else config
6594
for path in configs:
6695
if path.exists():
6796
config_loader.use_values_from_yaml(path)
6897
else:
6998
raise FileNotFoundError(f"Cannot find file: {path}")
7099

71-
ctx.ensure_object(dict)
72100
loaded_config: ApplicationConfig = config_loader.load()
73-
74101
set_up_logging(loaded_config.logging)
75-
76102
ctx.obj["config"] = loaded_config
77103

104+
105+
@click.group(
106+
invoke_without_command=True, context_settings={"auto_envvar_prefix": "BLUEAPI"}
107+
)
108+
@click.version_option(version=__version__, prog_name="blueapi")
109+
@click.option(
110+
"-c",
111+
"--config",
112+
type=Path,
113+
help="Path to configuration YAML file",
114+
multiple=True,
115+
)
116+
@click.pass_context
117+
def main(ctx: click.Context, config: Path | None | tuple[Path, ...]) -> None:
118+
# if no command is supplied, run with the options passed
119+
120+
# Set umask to DLS standard
121+
os.umask(stat.S_IWOTH)
122+
123+
if config == ():
124+
config = None
125+
126+
_load_config(ctx, config)
127+
78128
if ctx.invoked_subcommand is None:
79129
print("Please invoke subcommand!")
80130

@@ -136,10 +186,10 @@ def config_schema(output: Path | None = None, update: bool = False) -> None:
136186

137187

138188
@main.command(name="serve")
139-
@click.pass_obj
140-
def start_application(obj: dict):
189+
@click.pass_context
190+
def start_application(ctx: click.Context):
141191
"""Run a worker that accepts plans to run"""
142-
config: ApplicationConfig = obj["config"]
192+
config: ApplicationConfig = ctx.obj["config"]
143193

144194
"""Only import the service functions when starting the service or generating
145195
the schema, not the controller as a new FastAPI app will be started each time.
@@ -154,15 +204,107 @@ def start_application(obj: dict):
154204
start(config)
155205

156206

207+
@main.command(name="login")
208+
@click.option(
209+
"--url",
210+
type=HttpUrl,
211+
help="The url of the blueapi server you want to connect to.",
212+
default=None,
213+
)
214+
@click.pass_obj
215+
@check_connection
216+
def login(
217+
obj: dict,
218+
url: HttpUrl | None,
219+
) -> None:
220+
"""
221+
Authenticate with the blueapi using the OIDC (OpenID Connect) flow.
222+
"""
223+
config: ApplicationConfig = obj["config"]
224+
225+
if url is not None:
226+
if obj["custom_config"] is True:
227+
LOGGER.warning(
228+
"Custom config has been used. This will take precidence "
229+
"over a provided url"
230+
)
231+
else:
232+
config.api.url = HttpUrl(url)
233+
try:
234+
auth: SessionManager = SessionManager.from_cache(config.auth_token_path)
235+
access_token = auth.get_valid_access_token()
236+
assert access_token
237+
print("Logged in")
238+
except Exception:
239+
client = BlueapiClient.from_config(config)
240+
oidc_config = client.get_oidc_config()
241+
if oidc_config is None:
242+
print("Server is not configured to use authentication!")
243+
return
244+
auth = SessionManager(
245+
oidc_config, cache_manager=SessionCacheManager(config.auth_token_path)
246+
)
247+
auth.start_device_flow()
248+
249+
250+
@main.command(name="logout")
251+
@click.option(
252+
"--url",
253+
type=HttpUrl,
254+
help="The url of the blueapi server you want to connect to.",
255+
default=None,
256+
)
257+
@click.pass_obj
258+
def logout(
259+
obj: dict,
260+
url: HttpUrl | None,
261+
) -> None:
262+
"""
263+
Logs out from the OIDC provider and removes the cached access token.
264+
"""
265+
config: ApplicationConfig = obj["config"]
266+
267+
if url is not None:
268+
if obj["custom_config"] is True:
269+
LOGGER.warning(
270+
"Custom config has been used. This will take precidence "
271+
"over a provided url"
272+
)
273+
else:
274+
config.api.url = HttpUrl(url)
275+
try:
276+
auth: SessionManager = SessionManager.from_cache(config.auth_token_path)
277+
auth.logout()
278+
except FileNotFoundError:
279+
print("Logged out")
280+
except ValueError as e:
281+
LOGGER.debug("Invalid login token: %s", e)
282+
raise ClickException(
283+
"Login token is not valid - remove before trying again"
284+
) from e
285+
except Exception as e:
286+
raise ClickException(f"Error logging out: {e}") from e
287+
288+
157289
@main.group()
158290
@click.option(
159291
"-o",
160292
"--output",
161293
type=click.Choice([o.name.lower() for o in OutputFormat]),
162294
default="compact",
163295
)
296+
@click.option(
297+
"--url",
298+
type=HttpUrl,
299+
help="The url of the blueapi server you want to connect to.",
300+
default=None,
301+
)
164302
@click.pass_context
165-
def controller(ctx: click.Context, output: str) -> None:
303+
def controller(
304+
ctx: click.Context,
305+
output: str,
306+
url: HttpUrl | None,
307+
) -> None:
166308
"""Client utility for controlling and introspecting the worker"""
167309

168310
setup_tracing("BlueAPICLI", OTLP_EXPORT_ENABLED)
@@ -171,33 +313,25 @@ def controller(ctx: click.Context, output: str) -> None:
171313
return
172314

173315
ctx.ensure_object(dict)
174-
config: ApplicationConfig = ctx.obj["config"]
175316
ctx.obj["fmt"] = OutputFormat(output)
176-
ctx.obj["client"] = BlueapiClient.from_config(config)
177317

318+
config: ApplicationConfig = ctx.obj["config"]
178319

179-
P = ParamSpec("P")
180-
T = TypeVar("T")
320+
if url is not None:
321+
if ctx.obj["custom_config"] is True:
322+
LOGGER.warning(
323+
"Custom config has been used. This will take precidence "
324+
"over a provided url"
325+
)
326+
else:
327+
config.api.url = HttpUrl(url)
181328

329+
tmp_client = BlueapiClient.from_config(config)
330+
config.stomp = tmp_client.get_stomp_config()
331+
ctx.obj["config"] = config
182332

183-
def check_connection(func: Callable[P, T]) -> Callable[P, T]:
184-
@wraps(func)
185-
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
186-
try:
187-
return func(*args, **kwargs)
188-
except ConnectionError as ce:
189-
raise ClickException(
190-
"Failed to establish connection to blueapi server."
191-
) from ce
192-
except BlueskyRemoteControlError as e:
193-
if str(e) == "<Response [401]>":
194-
raise ClickException(
195-
"Access denied. Please check your login status and try again."
196-
) from e
197-
else:
198-
raise e
199-
200-
return wrapper
333+
set_up_logging(config.logging)
334+
ctx.obj["client"] = BlueapiClient.from_config(config)
201335

202336

203337
@controller.command(name="plans")
@@ -455,49 +589,3 @@ def get_python_env(obj: dict, name: str, source: SourceInfo) -> None:
455589
"""
456590
client: BlueapiClient = obj["client"]
457591
obj["fmt"].display(client.get_python_env(name=name, source=source))
458-
459-
460-
@main.command(name="login")
461-
@click.pass_obj
462-
@check_connection
463-
def login(obj: dict) -> None:
464-
"""
465-
Authenticate with the blueapi using the OIDC (OpenID Connect) flow.
466-
"""
467-
config: ApplicationConfig = obj["config"]
468-
try:
469-
auth: SessionManager = SessionManager.from_cache(config.auth_token_path)
470-
access_token = auth.get_valid_access_token()
471-
assert access_token
472-
print("Logged in")
473-
except Exception:
474-
client = BlueapiClient.from_config(config)
475-
oidc_config = client.get_oidc_config()
476-
if oidc_config is None:
477-
print("Server is not configured to use authentication!")
478-
return
479-
auth = SessionManager(
480-
oidc_config, cache_manager=SessionCacheManager(config.auth_token_path)
481-
)
482-
auth.start_device_flow()
483-
484-
485-
@main.command(name="logout")
486-
@click.pass_obj
487-
def logout(obj: dict) -> None:
488-
"""
489-
Logs out from the OIDC provider and removes the cached access token.
490-
"""
491-
config: ApplicationConfig = obj["config"]
492-
try:
493-
auth: SessionManager = SessionManager.from_cache(config.auth_token_path)
494-
auth.logout()
495-
except FileNotFoundError:
496-
print("Logged out")
497-
except ValueError as e:
498-
LOGGER.debug("Invalid login token: %s", e)
499-
raise ClickException(
500-
"Login token is not valid - remove before trying again"
501-
) from e
502-
except Exception as e:
503-
raise ClickException(f"Error logging out: {e}") from e

src/blueapi/client/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ def from_config(cls, config: ApplicationConfig) -> "BlueapiClient":
7171
else:
7272
return cls(rest)
7373

74+
def get_stomp_config(self):
75+
return self._rest.get_stomp_config()
76+
7477
@start_as_current_span(TRACER)
7578
def get_plans(self) -> PlanResponse:
7679
"""

src/blueapi/client/rest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
)
1111
from pydantic import BaseModel, TypeAdapter, ValidationError
1212

13-
from blueapi.config import RestConfig
13+
from blueapi.config import RestConfig, StompConfig
1414
from blueapi.service.authentication import JWTAuth, SessionManager
1515
from blueapi.service.model import (
1616
DeviceModel,
@@ -215,6 +215,9 @@ def cancel_current_task(
215215
data={"new_state": state, "reason": reason},
216216
)
217217

218+
def get_stomp_config(self):
219+
return self._request_and_deserialize("/config/stomp", StompConfig)
220+
218221
def get_environment(self) -> EnvironmentResponse:
219222
return self._request_and_deserialize("/environment", EnvironmentResponse)
220223

src/blueapi/service/interface.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ def get_oidc_config() -> OIDCConfig | None:
260260
return config().oidc
261261

262262

263+
def get_stomp_config() -> StompConfig | None:
264+
return config().stomp
265+
266+
263267
def get_python_env(
264268
name: str | None = None, source: SourceInfo | None = None
265269
) -> PythonEnvironmentResponse:

0 commit comments

Comments
 (0)