iqm-client 31.8.0__py3-none-any.whl → 32.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
iqm/iqm_client/cli/cli.py DELETED
@@ -1,798 +0,0 @@
1
- # Copyright 2021-2023 IQM client developers
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- """Command line interface for managing user authentication when using IQM quantum computers."""
15
-
16
- import contextlib
17
- from datetime import datetime, timedelta
18
- import json
19
- import logging
20
- import os
21
- from pathlib import Path
22
- import platform
23
- import sys
24
- from typing import Any
25
-
26
- import click
27
- from iqm.iqm_client import __version__
28
- from iqm.iqm_client.cli.auth import (
29
- AUTH_REQUESTS_TIMEOUT,
30
- ClientAccountSetupError,
31
- ClientAuthenticationError,
32
- login_request,
33
- logout_request,
34
- refresh_request,
35
- slash_join,
36
- time_left_seconds,
37
- )
38
- from iqm.iqm_client.cli.models import ConfigFile, TokensFile
39
- from iqm.iqm_client.cli.token_manager import check_token_manager, daemonize_token_manager, start_token_manager
40
- from psutil import NoSuchProcess, Process
41
- from pydantic import ValidationError
42
- import requests
43
- from requests.exceptions import ConnectionError, Timeout
44
-
45
- HOME_PATH = str(Path.home())
46
- DEFAULT_CONFIG_PATH = os.path.join(HOME_PATH, ".config", "iqm-client-cli", "config.json")
47
- DEFAULT_TOKENS_PATH = os.path.join(HOME_PATH, ".cache", "iqm-client-cli", "tokens.json")
48
- REALM_NAME = "cortex"
49
- CLIENT_ID = "iqm_client"
50
- USERNAME = ""
51
- REFRESH_PERIOD = 3 * 60 # in seconds
52
-
53
-
54
- class ClickLoggingHandler(logging.Handler):
55
- """Simple log handler using click's echo function."""
56
-
57
- def __init__(self):
58
- super().__init__(level=logging.NOTSET)
59
- self.formatter = logging.Formatter("%(message)s")
60
-
61
- def emit(self, record): # noqa: ANN001, ANN201
62
- click.echo(self.format(record))
63
-
64
-
65
- logger = logging.getLogger("iqm_client.cli.cli")
66
- logger.addHandler(ClickLoggingHandler())
67
- logger.setLevel(logging.INFO)
68
-
69
-
70
- def _set_log_level_by_verbosity(verbose: bool) -> int:
71
- """Sets logger log level to DEBUG if verbose is True, to INFO otherwise.
72
-
73
- Args:
74
- verbose: whether logging should be verbose (i.e. DEBUG level)
75
-
76
- Returns:
77
- int: logging level which was set
78
-
79
- """
80
- if verbose:
81
- logger.setLevel(logging.DEBUG)
82
- return logging.DEBUG
83
- logger.setLevel(logging.INFO)
84
- return logging.INFO
85
-
86
-
87
- class ResolvedPath(click.Path):
88
- """A click parameter type for a resolved path.
89
- Normal ``click.Path(resolve_path=True)`` fails under Windows running python <= 3.9.
90
- See https://github.com/pallets/click/issues/2466
91
- """
92
-
93
- def __init__(self, *args, **kwargs):
94
- super().__init__(*args, **kwargs)
95
-
96
- def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Any:
97
- abspath = Path(value).absolute()
98
- # fsdecode to ensure that the return value is a str.
99
- # (with click<8.0.3 Path.convert will return Path if passed a Path)
100
- return os.fsdecode(super().convert(abspath, param, ctx))
101
-
102
-
103
- def _read_json(path: str) -> dict:
104
- """Read a JSON file.
105
-
106
- Args:
107
- path: path to the file to read
108
- Raises:
109
- click.FileError: if file is not a valid JSON file
110
- Returns:
111
- dict: data parsed from the file
112
-
113
- """
114
- try:
115
- with open(path, "r", encoding="utf-8") as file:
116
- data = json.load(file)
117
- except FileNotFoundError as error:
118
- raise click.FileError(path, "file not found") from error
119
- except json.decoder.JSONDecodeError as error:
120
- raise click.FileError(path, f"file is not a valid JSON file: {error}") from error
121
- return data
122
-
123
-
124
- def _is_parameter_source_cmd_line(ctx: click.Context, param_name: str | None) -> bool:
125
- """Check if the given parameter is submitted via command line"""
126
- if isinstance(param_name, str):
127
- param_source = ctx.get_parameter_source(param_name)
128
- if isinstance(param_source, click.core.ParameterSource):
129
- if param_source.name == "COMMANDLINE":
130
- return True
131
- return False
132
-
133
-
134
- def _validate_path(ctx: click.Context, param: click.core.Option, path: str) -> str:
135
- """Callback for CLI prompt. If needed, confirmation to overwrite is prompted.
136
-
137
- The validation logic is not applied if the parameter is supplied via the
138
- command line.
139
-
140
- Args:
141
- ctx: click context
142
- param: click prompt param object
143
- path: path provided by user
144
- Returns:
145
- str: confirmed and finalized path
146
-
147
- """
148
- if ctx.obj is None:
149
- ctx.obj = {}
150
- if param.name in ctx.obj:
151
- return path
152
- ctx.obj[param.name] = True
153
-
154
- # Skip parameter validation completely if the parameter comes from command line
155
- if _is_parameter_source_cmd_line(ctx, param.name):
156
- msg = click.style(
157
- f'Skipping validation of "{param.opts[0]}", using the provided value "{path}" as is',
158
- fg="yellow",
159
- )
160
- click.echo(msg)
161
- return path
162
-
163
- # File doesn't exist, no need to confirm overwriting
164
- if not Path(path).is_file():
165
- return path
166
-
167
- # File exists, so user must either overwrite or enter a new path
168
- while True:
169
- msg = f"{click.style('File at given path already exists. Overwrite?', fg='red')}"
170
- if click.confirm(msg, default=None):
171
- return path
172
-
173
- new_path = click.prompt("New file path", type=ResolvedPath(dir_okay=False, writable=True, resolve_path=True))
174
-
175
- if new_path == path:
176
- continue
177
- return new_path
178
-
179
-
180
- def _validate_config_file(config_file: str) -> ConfigFile:
181
- """Checks if provided config file is valid, i.e. it:
182
- - is valid JSON
183
- - satisfies IQM Client CLI format
184
-
185
- Args:
186
- config_file (str): --config-file option value
187
- Raises:
188
- click.FileError: if config_file is not valid JSON
189
- click.FileError: if config_file does not satisfy IQM Client CLI format
190
- Returns:
191
- ConfigFile: validated config loaded from config_file
192
-
193
- """
194
- # config_file must be in correct format
195
- config = _read_json(config_file)
196
- try:
197
- validated_config = ConfigFile(**config)
198
- except ValidationError as ex:
199
- raise click.FileError(
200
- config_file,
201
- f"""Provided config file is valid JSON, but does not satisfy IQM Client CLI format. Possible reasons:
202
- - IQM Client CLI was upgraded and config file format is changed. Check the changelog.
203
- - Config file was manually edited by someone.
204
-
205
- Re-generate a valid config file by running 'iqmclient init'.
206
-
207
- Full validation error:
208
- {ex}""",
209
- )
210
-
211
- return validated_config
212
-
213
-
214
- def _validate_tokens_file(tokens_file: str) -> TokensFile:
215
- """Checks if provided tokens file is valid, i.e. it:
216
- - is valid JSON
217
- - satisfies IQM Client CLI format
218
-
219
- Args:
220
- tokens_file (str): path to tokens file
221
- Raises:
222
- click.FileError: if tokens file is not valid JSON
223
- click.FileError: if tokens file does not satisfy IQM Client CLI format
224
- Returns:
225
- TokensFile: validated tokens loaded from tokens_file
226
-
227
- """
228
- # tokens_file must be in correct format
229
- tokens = _read_json(tokens_file)
230
- try:
231
- validated_tokens = TokensFile(**tokens)
232
- except ValidationError as ex:
233
- raise click.FileError(
234
- tokens_file,
235
- f"""Provided tokens file is valid JSON, but does not satisfy IQM Client CLI format. Possible reasons:
236
- - IQM Client CLI was upgraded and tokens file format is changed. Check the changelog.
237
- - Tokens file was manually edited by someone.
238
-
239
- Re-generate a valid tokens file by running 'iqmclient auth login'.
240
-
241
- Full validation error:
242
- {ex}""",
243
- )
244
-
245
- return validated_tokens
246
-
247
-
248
- def _validate_auth_server_url(ctx: click.Context, param: click.Option, base_url: str) -> str:
249
- """Checks if provided auth server URL is valid, i.e. it:
250
- - is a valid HTTP/HTTPS URL
251
- - is accessible
252
- - points to an authentication server
253
-
254
- The validation logic is not applied if the parameter is supplied via the
255
- command line.
256
-
257
- Args:
258
- ctx: click context
259
- param: click prompt param object
260
- base_url (str): auth server base URL to validate
261
- Returns:
262
- str: validated auth server base URL
263
-
264
- """
265
- if ctx.obj is None:
266
- ctx.obj = {}
267
- if param.name in ctx.obj:
268
- return base_url
269
-
270
- if _is_parameter_source_cmd_line(ctx, param.name):
271
- msg = click.style(
272
- f'Skipping validation of "{param.opts[0]}", using the provided value "{base_url}" as is',
273
- fg="yellow",
274
- )
275
- click.echo(msg)
276
- ctx.obj[param.name] = base_url
277
- return base_url
278
-
279
- is_valid = False
280
- while not is_valid:
281
- try:
282
- master = requests.get(f"{base_url}/realms/master", timeout=AUTH_REQUESTS_TIMEOUT)
283
- assert master.status_code == 200
284
- assert "public_key" in master.json()
285
- is_valid = True
286
- except (ConnectionError, AssertionError, ValueError):
287
- click.echo(f"No auth server could be accessed with URL {base_url}")
288
- is_valid = click.confirm("Do you still want to use it?", default=False)
289
- if not is_valid:
290
- base_url = click.prompt(str(param.prompt))
291
-
292
- ctx.obj[param.name] = base_url
293
- return base_url
294
-
295
-
296
- def _validate_auth_realm(ctx: click.Context, param: click.Option, realm: str) -> str:
297
- """Checks if provided realm exists on auth server.
298
-
299
- The validation logic is not applied if the parameter is supplied via the
300
- command line.
301
-
302
- Args:
303
- ctx: click context
304
- param: click prompt param object
305
- realm (str): name of the realm
306
- Returns:
307
- str: validated realm name
308
-
309
- """
310
- if ctx.obj is None:
311
- ctx.obj = {}
312
- if param.name in ctx.obj:
313
- return realm
314
-
315
- if _is_parameter_source_cmd_line(ctx, param.name):
316
- msg = click.style(
317
- f'Skipping validation of "{param.opts[0]}", using the provided value "{realm}" as is',
318
- fg="yellow",
319
- )
320
- click.echo(msg)
321
- ctx.obj[param.name] = realm
322
- return realm
323
-
324
- base_url = ctx.obj.get("auth_server_url", None)
325
- if base_url is None:
326
- raise click.UsageError("Can not set realm name before setting auth server URL.")
327
-
328
- is_valid = False
329
- while not is_valid:
330
- try:
331
- realm_data = requests.get(f"{base_url}/realms/{realm}", timeout=AUTH_REQUESTS_TIMEOUT)
332
- assert realm_data.status_code == 200
333
- assert "public_key" in realm_data.json()
334
- is_valid = True
335
- except (ConnectionError, AssertionError, ValueError):
336
- click.echo(f"No auth realm could be accessed with URL {base_url}/realms/{realm}")
337
- is_valid = click.confirm("Do you still want to use it?", default=False)
338
- if not is_valid:
339
- realm = click.prompt(str(param.prompt))
340
-
341
- ctx.obj[param.name] = realm
342
- return realm
343
-
344
-
345
- class IQMClientCliCommand(click.Group):
346
- """A custom click command group class to wrap global constants."""
347
-
348
- default_config_path: str = DEFAULT_CONFIG_PATH
349
- default_tokens_path: str = DEFAULT_TOKENS_PATH
350
-
351
-
352
- @click.group(cls=IQMClientCliCommand)
353
- @click.version_option(__version__)
354
- def iqmclient() -> None:
355
- """IQM Client CLI for managing user authentication when using IQM quantum computers"""
356
- return
357
-
358
-
359
- @iqmclient.command()
360
- @click.help_option()
361
- @click.option(
362
- "--config-file",
363
- prompt="Where to save config",
364
- callback=_validate_path,
365
- default=IQMClientCliCommand.default_config_path,
366
- type=ResolvedPath(dir_okay=False, writable=True, resolve_path=True),
367
- is_eager=True,
368
- help="Location where the configuration file will be saved.",
369
- )
370
- @click.option(
371
- "--tokens-file",
372
- prompt="Where to save auth tokens",
373
- callback=_validate_path,
374
- default=IQMClientCliCommand.default_tokens_path,
375
- type=ResolvedPath(dir_okay=False, writable=True, resolve_path=True),
376
- is_eager=True,
377
- help="Location where the tokens file will be saved.",
378
- )
379
- @click.option(
380
- "--auth-server-url",
381
- prompt="Authentication server URL",
382
- callback=_validate_auth_server_url,
383
- is_eager=True,
384
- help="Authentication server URL.",
385
- )
386
- @click.option(
387
- "--realm",
388
- prompt="Realm on IQM auth server",
389
- prompt_required=False,
390
- default=REALM_NAME,
391
- show_default=True,
392
- callback=_validate_auth_realm,
393
- help="Name of the realm on the IQM authentication server.",
394
- )
395
- @click.option(
396
- "--client-id",
397
- prompt="Client ID",
398
- prompt_required=False,
399
- default=CLIENT_ID,
400
- show_default=True,
401
- help="Client ID on the IQM authentication server.",
402
- )
403
- @click.option(
404
- "--username",
405
- prompt="Username (optional)",
406
- required=False,
407
- default=USERNAME,
408
- help="Username. If not provided, it will be asked for at login.",
409
- )
410
- @click.option("-v", "--verbose", is_flag=True, help="Print extra information.")
411
- def init(
412
- config_file: str, tokens_file: str, auth_server_url: str, realm: str, client_id: str, username: str, verbose: bool
413
- ) -> None:
414
- """Initialize configuration and authentication."""
415
- _set_log_level_by_verbosity(verbose)
416
-
417
- path_to_dir = Path(config_file).parent
418
- config_json = json.dumps(
419
- {
420
- "auth_server_url": auth_server_url,
421
- "realm": realm,
422
- "client_id": client_id,
423
- "username": username,
424
- "tokens_file": tokens_file,
425
- },
426
- indent=2,
427
- )
428
-
429
- # Tokens file exist, so token manager may be running. Notify user and kill token manager.
430
- if Path(tokens_file).is_file():
431
- pid = check_token_manager(tokens_file)
432
- if pid:
433
- logger.info("Active token manager (PID %s) will be killed.", pid)
434
- _safe_process_terminate(pid)
435
-
436
- # Remove tokens file to start from scratch after init
437
- os.remove(tokens_file)
438
-
439
- try:
440
- path_to_dir.mkdir(parents=True, exist_ok=True)
441
- with open(Path(config_file), "w", encoding="UTF-8") as file:
442
- file.write(config_json)
443
- logger.debug("Saved configuration file: %s", config_file)
444
- except OSError as error:
445
- raise click.ClickException(f"Error writing configuration file, {error}") from error
446
-
447
- logger.info(
448
- "IQM Client CLI initialized successfully. Login and start the token manager with 'iqmclient auth login'."
449
- )
450
-
451
-
452
- @iqmclient.group()
453
- def auth() -> None:
454
- """Manage authentication."""
455
- return
456
-
457
-
458
- @auth.command()
459
- @click.option(
460
- "--config-file",
461
- default=IQMClientCliCommand.default_config_path,
462
- type=ResolvedPath(exists=True, dir_okay=False, resolve_path=True),
463
- help="Location of the configuration file to be used.",
464
- )
465
- @click.option("-v", "--verbose", is_flag=True, help="Print extra information.")
466
- def status(config_file, verbose) -> None: # noqa: ANN001
467
- """Check status of authentication."""
468
- _set_log_level_by_verbosity(verbose)
469
-
470
- logger.debug("Using configuration file: %s", config_file)
471
- config = _validate_config_file(config_file)
472
- tokens_file = str(config.tokens_file)
473
- if not config.tokens_file.is_file():
474
- click.echo('Not logged in. Use "iqmclient auth login" to login.')
475
- return
476
- try:
477
- tokens_data = _validate_tokens_file(tokens_file)
478
- except click.FileError:
479
- click.echo('Provided tokens.json file is invalid. Use "iqmclient auth login" to generate new tokens.')
480
- return
481
-
482
- click.echo(f"Tokens file: {tokens_file}")
483
- if not tokens_data.pid:
484
- click.echo(
485
- "Tokens file doesn't contain PID. Probably, 'iqmclient auth login' was launched with '--no-refresh'\n"
486
- )
487
-
488
- refresh_status = tokens_data.refresh_status or "SUCCESS"
489
- styled_status = click.style(refresh_status, fg="green" if refresh_status == "SUCCESS" else "red")
490
- refresh_timestamp = tokens_data.timestamp.strftime("%m/%d/%Y %H:%M:%S")
491
- click.echo(f"Last refresh: {refresh_timestamp} from {tokens_data.auth_server_url} {styled_status}")
492
- seconds_at = time_left_seconds(tokens_data.access_token)
493
- time_left_at = str(timedelta(seconds=seconds_at))
494
- click.echo(f"Time left on access token (hh:mm:ss): {time_left_at}")
495
- seconds_rt = time_left_seconds(tokens_data.refresh_token)
496
- time_left_rt = str(timedelta(seconds=seconds_rt))
497
- click.echo(f"Time left on refresh token (hh:mm:ss): {time_left_rt}")
498
-
499
- active_pid = check_token_manager(tokens_file)
500
- if active_pid:
501
- click.echo(f"Token manager: {click.style('RUNNING', fg='green')} (PID {active_pid})")
502
- else:
503
- click.echo(f"Token manager: {click.style('NOT RUNNING', fg='red')}")
504
-
505
-
506
- def _validate_iqm_client_cli_auth_login(no_daemon, no_refresh, config_file) -> ConfigFile: # noqa: ANN001
507
- """Checks if provided combination of auth login options is valid:
508
- - no_daemon and no_refresh are mutually exclusive
509
- - config file should pass validation
510
-
511
- Args:
512
- config_file (str): --config-file option value
513
- no_daemon (bool): --no-daemon option value
514
- no_refresh (bool): --no-refresh option value
515
- Raises:
516
- click.BadOptionUsage: if both mutually exclusive --no-daemon and --no-refresh are set
517
- click.BadParameter: if config_file does not exist
518
- Returns:
519
- ConfigFile: validated config loaded from config_file
520
-
521
- """
522
- # --no-refresh and --no-daemon are mutually exclusive
523
- if no_refresh and no_daemon:
524
- raise click.BadOptionUsage(
525
- "--no-refresh", "Cannot request a non-daemonic (foreground) token manager when using '--no-refresh'."
526
- )
527
-
528
- # config file, even the default one, should exist
529
- if not Path(config_file).is_file():
530
- raise click.BadParameter(
531
- f"Provided config {config_file} does not exist. "
532
- + "Provide a different file or run 'iqmclient auth init' to create a new config file."
533
- )
534
-
535
- # config file should be valid JSON and satisfy IQM Client CLI format
536
- config = _validate_config_file(config_file)
537
-
538
- return config
539
-
540
-
541
- def _refresh_tokens(
542
- refresh_period: int,
543
- no_daemon: bool,
544
- no_refresh: bool,
545
- config: ConfigFile,
546
- ) -> bool:
547
- """Refreshes token and returns success status
548
-
549
- Args:
550
- refresh_period (int): --refresh_period option value
551
- no_daemon (bool): --no-daemon option value
552
- no_refresh (bool): --no-refresh option value
553
- config (ConfigFile): IQM Client CLI config
554
- Returns:
555
- bool: whether token refresh was successful or not
556
-
557
- """
558
- # Tokens file exists; Refresh tokens without username/password
559
- tokens_file = str(config.tokens_file)
560
- try:
561
- refresh_token = _validate_tokens_file(tokens_file).refresh_token
562
- except (click.FileError, ValidationError):
563
- click.echo("Provided tokens.json file is invalid, continuing with login with username and password.")
564
- os.remove(tokens_file)
565
- return False
566
-
567
- logger.debug("Attempting to refresh tokens by using existing refresh token from file: %s", tokens_file)
568
-
569
- new_tokens = None
570
- try:
571
- new_tokens = refresh_request(str(config.auth_server_url), config.realm, config.client_id, refresh_token)
572
- except (Timeout, ConnectionError, ClientAuthenticationError):
573
- logger.info("Failed to refresh tokens by using existing token. Switching to username/password.")
574
-
575
- if new_tokens:
576
- save_tokens_file(tokens_file, new_tokens, str(config.auth_server_url))
577
- logger.debug("Saved new tokens file: %s", tokens_file)
578
- if no_refresh:
579
- logger.info("Existing token used to refresh session. Token manager not started due to '--no-refresh' flag.")
580
- elif no_daemon:
581
- logger.info("Existing token was used to refresh the auth session. Token manager started in foreground...")
582
- start_token_manager(refresh_period, config)
583
- else:
584
- logger.info("Existing token was used to refresh the auth session. Token manager daemon started.")
585
- daemonize_token_manager(refresh_period, config)
586
- return True
587
- return False
588
-
589
-
590
- @auth.command()
591
- @click.option(
592
- "--config-file",
593
- default=IQMClientCliCommand.default_config_path,
594
- type=ResolvedPath(exists=True, dir_okay=False, resolve_path=True),
595
- help="Location of the configuration file to be used.",
596
- )
597
- @click.option("--username", help="Username for authentication.")
598
- @click.option("--password", help="Password for authentication.")
599
- @click.option(
600
- "--refresh-period", default=REFRESH_PERIOD, show_default=True, help="How often to refresh tokens (in seconds)."
601
- )
602
- @click.option("--no-daemon", is_flag=True, default=False, help="Start token manager in foreground, not as daemon.")
603
- @click.option(
604
- "--no-refresh", is_flag=True, default=False, help="Login, but do not start token manager to refresh tokens."
605
- )
606
- @click.option("-v", "--verbose", is_flag=True, help="Print extra information.")
607
- def login(
608
- config_file: str,
609
- username: str,
610
- password: str,
611
- refresh_period: int,
612
- no_daemon: bool,
613
- no_refresh: bool,
614
- verbose: bool,
615
- ) -> None:
616
- """Authenticate on the IQM server, and optionally start a token manager to maintain the session."""
617
- _set_log_level_by_verbosity(verbose)
618
-
619
- if platform.system().lower().startswith("win") and not no_refresh and not no_daemon:
620
- click.echo(
621
- click.style("Warning", fg="yellow")
622
- + ": Daemonizing is not supported on Windows, and the application has started in foreground mode; "
623
- "please keep this terminal session open in order for IQM Client CLI to keep refreshing the tokens and "
624
- "maintaining the authentication.\n"
625
- )
626
- no_daemon = True
627
-
628
- # Validate whether the combination of options makes sense
629
- config = _validate_iqm_client_cli_auth_login(no_daemon, no_refresh, config_file)
630
-
631
- auth_server_url, realm, client_id = str(config.auth_server_url), config.realm, config.client_id
632
- tokens_file = str(config.tokens_file)
633
-
634
- if config.tokens_file.is_file():
635
- if check_token_manager(tokens_file):
636
- logger.info("Login aborted, because token manager is already running. See 'iqmclient auth status'.")
637
- return
638
-
639
- if _refresh_tokens(refresh_period, no_daemon, no_refresh, config):
640
- return
641
-
642
- # Login with username and password
643
- username = username or config.username or click.prompt("Username")
644
- if config.username:
645
- click.echo(f"Username: {username}")
646
- password = password or click.prompt("Password", hide_input=True)
647
- tokens = None
648
-
649
- while tokens is None:
650
- try:
651
- tokens = login_request(auth_server_url, realm, client_id, username, password)
652
- except ConnectionError as exc:
653
- raise click.ClickException(f"Authentication server at {auth_server_url} is not accessible") from exc
654
- except Timeout as exc:
655
- raise click.ClickException(f"Authentication server at {auth_server_url} is not responding") from exc
656
- except ClientAuthenticationError as exc:
657
- raise click.ClickException(f"Failed to authenticate, {exc}") from exc
658
- except ClientAccountSetupError as exc:
659
- password_update_form_url = slash_join(auth_server_url, f"realms/{realm}/account")
660
- raise click.ClickException(
661
- f"""
662
- Failed to authenticate, because your account is not fully set up yet.
663
- Please update your password at {password_update_form_url}
664
- """
665
- ) from exc
666
-
667
- logger.info("Logged in successfully as %s", username)
668
- save_tokens_file(tokens_file, tokens, auth_server_url)
669
- env_var_command = "set" if platform.system().lower().startswith("win") else "export"
670
- click.echo(
671
- f"""
672
- To use the tokens file with IQM Client or IQM Client-based software, set the environment variable:
673
-
674
- {env_var_command} IQM_TOKENS_FILE={tokens_file}
675
-
676
- Refer to IQM Client documentation for details: https://docs.meetiqm.com/iqm-client/
677
- """
678
- )
679
-
680
- if no_refresh:
681
- logger.info("Token manager not started due to '--no-refresh' flag.")
682
- elif no_daemon:
683
- logger.info("Starting token manager in foreground...")
684
- start_token_manager(refresh_period, config)
685
- else:
686
- logger.info("Starting token manager daemon...")
687
- daemonize_token_manager(refresh_period, config)
688
-
689
-
690
- @auth.command()
691
- @click.option(
692
- "--config-file",
693
- type=ResolvedPath(exists=True, dir_okay=False, resolve_path=True),
694
- default=IQMClientCliCommand.default_config_path,
695
- )
696
- @click.option("--keep-tokens", is_flag=True, default=False, help="Don't delete tokens file, but kill token manager.")
697
- @click.option("-f", "--force", is_flag=True, default=False, help="Don't ask for confirmation.")
698
- def logout(config_file: str, keep_tokens: str, force: bool) -> None:
699
- """Either logout completely, or just stop token manager while keeping tokens file."""
700
- config = _validate_config_file(config_file)
701
- auth_server_url, realm, client_id = str(config.auth_server_url), config.realm, config.client_id
702
- tokens_file = config.tokens_file
703
-
704
- if not tokens_file.is_file():
705
- click.echo("Not logged in.")
706
- return
707
-
708
- try:
709
- tokens = _validate_tokens_file(str(tokens_file))
710
- except click.FileError:
711
- click.echo("Found invalid tokens.json, cannot perform any logout steps.")
712
- return
713
-
714
- pid = check_token_manager(str(tokens_file))
715
- refresh_token = tokens.refresh_token
716
-
717
- # 1. Keep tokens, kill daemon
718
- if keep_tokens and pid:
719
- if force or click.confirm("Keep tokens file and kill token manager. OK?", default=None):
720
- _safe_process_terminate(pid, "Token manager killed.")
721
- return
722
-
723
- # 2. Keep tokens, do nothing
724
- if keep_tokens and not pid:
725
- click.echo("Token manager is not running, and you chose to keep tokens. Nothing to do, exiting.")
726
- return
727
-
728
- # 3. Delete tokens, perform logout, kill daemon
729
- if not keep_tokens and pid:
730
- if force or click.confirm("Logout from server, delete tokens and kill token manager. OK?", default=None):
731
- try:
732
- logout_request(auth_server_url, realm, client_id, refresh_token)
733
- except (Timeout, ConnectionError, ClientAuthenticationError) as error:
734
- logger.warning(
735
- "Failed to revoke tokens due to error when connecting to authentication server: %s", error
736
- )
737
-
738
- _safe_process_terminate(pid)
739
- os.remove(tokens_file)
740
- logger.info("Tokens file deleted. Logged out.")
741
- return
742
-
743
- # 4. Delete tokens, perform logout
744
- if not keep_tokens and not pid:
745
- if force or click.confirm("Logout from server and delete tokens. OK?", default=None):
746
- try:
747
- logout_request(auth_server_url, realm, client_id, refresh_token)
748
- except (Timeout, ConnectionError, ClientAuthenticationError) as error:
749
- logger.warning(
750
- "Failed to revoke tokens due to error when connecting to authentication server: %s", error
751
- )
752
-
753
- os.remove(tokens_file)
754
- logger.info("Tokens file deleted. Logged out.")
755
- return
756
-
757
- logger.info("Logout aborted.")
758
-
759
-
760
- def save_tokens_file(path: str, tokens: dict[str, str], auth_server_url: str) -> None:
761
- """Saves tokens as JSON file at given path.
762
-
763
- Args:
764
- path (str): path to the file to write
765
- tokens (dict[str, str]): authorization access and refresh tokens
766
- auth_server_url (str): base url of the authorization server
767
- Raises:
768
- OSError: if writing to file fails
769
-
770
- """
771
- path_to_dir = Path(path).parent
772
- tokens_data = {
773
- "timestamp": datetime.now().isoformat(),
774
- "access_token": tokens["access_token"],
775
- "refresh_token": tokens["refresh_token"],
776
- "auth_server_url": auth_server_url,
777
- }
778
-
779
- try:
780
- path_to_dir.mkdir(parents=True, exist_ok=True)
781
- with open(Path(path), "w", encoding="UTF-8") as file:
782
- file.write(json.dumps(tokens_data))
783
- except OSError as error:
784
- raise click.ClickException(f"Error writing tokens file, {error}") from error
785
-
786
-
787
- def _safe_process_terminate(pid: int, msg: str = "") -> None:
788
- """Try to terminate a process given a process ID (PID).
789
- Suppress the exception in case the process is not existing
790
- """
791
- with contextlib.suppress(NoSuchProcess):
792
- Process(pid).terminate()
793
- if msg:
794
- logger.info(msg)
795
-
796
-
797
- if __name__ == "__main__":
798
- iqmclient(sys.argv[1:])