1616from bluesky_stomp .models import Broker
1717from click .exceptions import ClickException
1818from observability_utils .tracing import setup_tracing
19- from pydantic import ValidationError
19+ from pydantic import HttpUrl , ValidationError
2020from requests .exceptions import ConnectionError
2121
2222from blueapi import __version__ , config
4444
4545LOGGER = 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
0 commit comments