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/authentication.py +0 -3
- iqm/qiskit_iqm/examples/bell_measure.py +42 -17
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/METADATA +1 -1
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/RECORD +9 -15
- iqm/iqm_client/cli/__init__.py +0 -14
- iqm/iqm_client/cli/auth.py +0 -168
- iqm/iqm_client/cli/cli.py +0 -798
- iqm/iqm_client/cli/models.py +0 -40
- iqm/iqm_client/cli/token_manager.py +0 -196
- iqm/qiskit_iqm/examples/resonance_example.py +0 -83
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/AUTHORS.rst +0 -0
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/LICENSE.txt +0 -0
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/WHEEL +0 -0
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/entry_points.txt +0 -0
- {iqm_client-31.8.0.dist-info → iqm_client-32.0.0.dist-info}/top_level.txt +0 -0
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:])
|