freva-client 2508.0.0__tar.gz → 2509.0.0__tar.gz

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.

Potentially problematic release.


This version of freva-client might be problematic. Click here for more details.

Files changed (21) hide show
  1. {freva_client-2508.0.0 → freva_client-2509.0.0}/PKG-INFO +1 -1
  2. {freva_client-2508.0.0 → freva_client-2509.0.0}/assets/share/freva/freva.toml +5 -0
  3. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/__init__.py +2 -1
  4. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/auth.py +21 -12
  5. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/cli/auth_cli.py +6 -0
  6. freva_client-2509.0.0/src/freva_client/cli/cli_utils.py +69 -0
  7. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/cli/databrowser_cli.py +232 -32
  8. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/query.py +255 -38
  9. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/utils/auth_utils.py +38 -0
  10. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/utils/databrowser_utils.py +118 -32
  11. freva_client-2508.0.0/src/freva_client/cli/cli_utils.py +0 -32
  12. {freva_client-2508.0.0 → freva_client-2509.0.0}/MANIFEST.in +0 -0
  13. {freva_client-2508.0.0 → freva_client-2509.0.0}/README.md +0 -0
  14. {freva_client-2508.0.0 → freva_client-2509.0.0}/pyproject.toml +0 -0
  15. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/__main__.py +0 -0
  16. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/cli/__init__.py +0 -0
  17. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/cli/cli_app.py +0 -0
  18. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/cli/cli_parser.py +0 -0
  19. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/py.typed +0 -0
  20. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/utils/__init__.py +0 -0
  21. {freva_client-2508.0.0 → freva_client-2509.0.0}/src/freva_client/utils/logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: freva-client
3
- Version: 2508.0.0
3
+ Version: 2509.0.0
4
4
  Summary: Search for climate data based on key-value pairs
5
5
  Author-email: "DKRZ, Clint" <freva@dkrz.de>
6
6
  Requires-Python: >=3.8
@@ -17,3 +17,8 @@
17
17
  ## You can set a port by separating <hostname:port>
18
18
  ## for example freva.example.org:7777
19
19
  # host = ""
20
+
21
+ ##
22
+ ## The default flavour to use when accessing freva. You can simply list
23
+ ## the flavours you want to use and choose one as default.
24
+ # default_flavour = "cmip6"
@@ -12,10 +12,11 @@ science community. With help of Freva researchers can:
12
12
  The code described here is currently in testing phase. The client and server
13
13
  library described in the documentation only support searching for data. If you
14
14
  need to apply data analysis plugins, please visit the
15
+ official documentation: https://freva-org.github.io/freva-legacy
15
16
  """
16
17
 
17
18
  from .auth import authenticate
18
19
  from .query import databrowser
19
20
 
20
- __version__ = "2508.0.0"
21
+ __version__ = "2509.0.0"
21
22
  __all__ = ["authenticate", "databrowser", "__version__"]
@@ -5,6 +5,7 @@ import json
5
5
  import socket
6
6
  import urllib.parse
7
7
  import webbrowser
8
+ from getpass import getuser
8
9
  from http.server import BaseHTTPRequestHandler, HTTPServer
9
10
  from pathlib import Path
10
11
  from threading import Event, Lock, Thread
@@ -113,7 +114,7 @@ class Auth:
113
114
  self,
114
115
  auth_url: str,
115
116
  force: bool = False,
116
- _timeout: int = 30,
117
+ _timeout: Optional[int] = 30,
117
118
  ) -> Token:
118
119
  login_endpoint = f"{auth_url}/login"
119
120
  token_endpoint = f"{auth_url}/token"
@@ -121,6 +122,7 @@ class Auth:
121
122
  redirect_uri = REDIRECT_URI.format(port=port)
122
123
  params = {
123
124
  "redirect_uri": redirect_uri,
125
+ "offline_access": "true",
124
126
  }
125
127
  if force:
126
128
  params["prompt"] = "login"
@@ -129,11 +131,13 @@ class Auth:
129
131
  logger.info("Opening browser for login:\n%s", login_url)
130
132
  logger.info(
131
133
  "If you are using this on a remote host, you might need to "
132
- "forward port %d:\n"
133
- " ssh -L %d:localhost:%d user@remotehost",
134
+ "increase the login timeout and forward port %d:\n"
135
+ " ssh -L %d:localhost:%d %s@%s",
134
136
  port,
135
137
  port,
136
138
  port,
139
+ getuser(),
140
+ socket.gethostname(),
137
141
  )
138
142
  event = Event()
139
143
  server = start_local_server(port, event)
@@ -142,7 +146,7 @@ class Auth:
142
146
  try:
143
147
  wait_for_port("localhost", port)
144
148
  webbrowser.open(login_url)
145
- success = event.wait(timeout=_timeout)
149
+ success = event.wait(timeout=_timeout or None)
146
150
  if not success:
147
151
  raise TimeoutError(
148
152
  f"Login did not complete within {_timeout} seconds. "
@@ -223,9 +227,7 @@ class Auth:
223
227
  return self._auth_token
224
228
 
225
229
  def _refresh(
226
- self,
227
- url: str,
228
- refresh_token: str,
230
+ self, url: str, refresh_token: str, timeout: Optional[int] = 30
229
231
  ) -> Token:
230
232
  """Refresh the access_token with a refresh token."""
231
233
  try:
@@ -235,7 +237,7 @@ class Auth:
235
237
  )
236
238
  except (AuthError, KeyError) as error:
237
239
  logger.warning("Failed to refresh token: %s", error)
238
- return self._login(url)
240
+ return self._login(url, _timeout=timeout)
239
241
 
240
242
  def authenticate(
241
243
  self,
@@ -243,6 +245,7 @@ class Auth:
243
245
  config: Optional[Config] = None,
244
246
  *,
245
247
  force: bool = False,
248
+ timeout: Optional[int] = 30,
246
249
  _cli: bool = False,
247
250
  ) -> Token:
248
251
  """Authenticate the user to the host."""
@@ -258,9 +261,11 @@ class Auth:
258
261
  return self._auth_token
259
262
  try:
260
263
  if strategy == "refresh_token" and token:
261
- return self._refresh(cfg.auth_url, token["refresh_token"])
264
+ return self._refresh(
265
+ cfg.auth_url, token["refresh_token"], timeout=timeout
266
+ )
262
267
  if strategy == "browser_auth":
263
- return self._login(cfg.auth_url, force=force)
268
+ return self._login(cfg.auth_url, force=force, _timeout=timeout)
264
269
  except AuthError as error:
265
270
  reason = str(error)
266
271
 
@@ -287,6 +292,7 @@ def authenticate(
287
292
  token_file: Optional[Union[Path, str]] = None,
288
293
  host: Optional[str] = None,
289
294
  force: bool = False,
295
+ timeout: Optional[int] = 30,
290
296
  ) -> Token:
291
297
  """Authenticate to the host.
292
298
 
@@ -294,13 +300,15 @@ def authenticate(
294
300
 
295
301
  Parameters
296
302
  ----------
297
- refresh_token: str, optional
303
+ token_file: str, optional
298
304
  Instead of setting a password, you can set a refresh token to refresh
299
305
  the access token. This is recommended for non-interactive environments.
300
306
  host: str, optional
301
307
  The hostname of the REST server.
302
308
  force: bool, default: False
303
309
  Force token recreation, even if current token is still valid.
310
+ timeout: int, default: 30
311
+ Set the timeout, None for indefinate.
304
312
 
305
313
  Returns
306
314
  -------
@@ -313,7 +321,7 @@ def authenticate(
313
321
  .. code-block:: python
314
322
 
315
323
  from freva_client import authenticate
316
- token = authenticate()
324
+ token = authenticate(timeout=120)
317
325
  print(token)
318
326
 
319
327
  Batch mode authentication with a refresh token:
@@ -327,4 +335,5 @@ def authenticate(
327
335
  return auth.authenticate(
328
336
  host=host,
329
337
  force=force,
338
+ timeout=timeout,
330
339
  )
@@ -44,6 +44,11 @@ def authenticate_cli(
44
44
  "-f",
45
45
  help="Force token recreation, even if current token is still valid.",
46
46
  ),
47
+ timeout: int = typer.Option(
48
+ 30,
49
+ "--timeout",
50
+ help="Set the timeout for login in secdonds, 0 for indefinate",
51
+ ),
47
52
  verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
48
53
  version: Optional[bool] = typer.Option(
49
54
  False,
@@ -59,5 +64,6 @@ def authenticate_cli(
59
64
  host=host,
60
65
  force=force,
61
66
  _cli=True,
67
+ timeout=timeout,
62
68
  )
63
69
  print(json.dumps(token, indent=3))
@@ -0,0 +1,69 @@
1
+ """Utilities for the command line interface."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ import pandas as pd
6
+ import typer
7
+ from rich import print as pprint
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from freva_client import __version__
12
+ from freva_client.utils import logger
13
+
14
+ APP_NAME: str = "freva-client"
15
+
16
+
17
+ def version_callback(version: bool) -> None:
18
+ """Print the version and exit."""
19
+ if version:
20
+ pprint(f"{APP_NAME}: {__version__}")
21
+ raise typer.Exit()
22
+
23
+
24
+ def parse_cli_args(cli_args: List[str]) -> Dict[str, List[str]]:
25
+ """Convert the cli arguments to a dictionary."""
26
+ logger.debug("parsing command line arguments.")
27
+ kwargs = {}
28
+ for entry in cli_args:
29
+ key, _, value = entry.partition("=")
30
+ if value and key not in kwargs:
31
+ kwargs[key] = [value]
32
+ elif value:
33
+ kwargs[key].append(value)
34
+ logger.debug(kwargs)
35
+ return kwargs
36
+
37
+
38
+ def _summarize(val: Any, max_items: int = 6) -> str:
39
+ """Summarize values for table display, truncating long lists."""
40
+ n = len(val)
41
+ head = ", ".join(map(str, val[:max_items]))
42
+ if n > max_items:
43
+ return f"{head} … (+{n - max_items} more)"
44
+ return head
45
+
46
+
47
+ def print_df(s: pd.Series, max_items: int = 6) -> None:
48
+ """Print a pandas Series as a rich table.
49
+
50
+ Parameters
51
+ ----------
52
+ s : pd.Series
53
+ The pandas Series to print.
54
+ max_items : int, optional
55
+ Maximum number of items to display for list-like values,
56
+ by default 6.
57
+ """
58
+ left_col: str = s.index.name or "index"
59
+ right_col: str = s.name or "value"
60
+
61
+ table: Table = Table(show_header=True, header_style="bold magenta")
62
+ table.add_column(left_col, style="cyan", no_wrap=True)
63
+ table.add_column(right_col, style="green")
64
+
65
+ for key, val in s.items():
66
+ table.add_row(str(key), _summarize(val, max_items=max_items))
67
+
68
+ console: Console = Console()
69
+ console.print(table)
@@ -7,16 +7,21 @@ import json
7
7
  from enum import Enum
8
8
  from pathlib import Path
9
9
  from tempfile import NamedTemporaryFile
10
- from typing import Dict, List, Optional, Tuple, Union, cast
10
+ from typing import Any, Dict, List, Optional, Tuple, Union, cast
11
11
 
12
12
  import typer
13
+ import typer.models
13
14
  import xarray as xr
14
15
 
16
+ # import pprinr from rich
17
+ from rich import print as pprint
18
+
15
19
  from freva_client import databrowser
16
20
  from freva_client.auth import Auth
17
21
  from freva_client.utils import exception_handler, logger
22
+ from freva_client.utils.auth_utils import requires_authentication
18
23
 
19
- from .cli_utils import parse_cli_args, version_callback
24
+ from .cli_utils import parse_cli_args, print_df, version_callback
20
25
 
21
26
 
22
27
  class UniqKeys(str, Enum):
@@ -26,14 +31,18 @@ class UniqKeys(str, Enum):
26
31
  uri = "uri"
27
32
 
28
33
 
29
- class Flavours(str, Enum):
30
- """Literal implementation for the cli."""
34
+ class FlavourAction(str, Enum):
35
+ add = "add"
36
+ delete = "delete"
37
+ list = "list"
38
+
31
39
 
40
+ class BuiltinFlavours(str, Enum):
41
+ """Literal implementation for the cli."""
32
42
  freva = "freva"
33
43
  cmip6 = "cmip6"
34
44
  cmip5 = "cmip5"
35
45
  cordex = "cordex"
36
- nextgems = "nextgems"
37
46
  user = "user"
38
47
 
39
48
 
@@ -131,10 +140,9 @@ def metadata_search(
131
140
  " containing era5, regardless of project, product etc."
132
141
  ),
133
142
  ),
134
- flavour: Flavours = typer.Option(
135
- "freva",
143
+ flavour: Optional[str] = typer.Option(
144
+ None,
136
145
  "--flavour",
137
- "-f",
138
146
  help=(
139
147
  "The Data Reference Syntax (DRS) standard specifying the type "
140
148
  "of climate datasets to query."
@@ -200,6 +208,16 @@ def metadata_search(
200
208
  parse_json: bool = typer.Option(
201
209
  False, "-j", "--json", help="Parse output in json format."
202
210
  ),
211
+ token_file: Optional[Path] = typer.Option(
212
+ None,
213
+ "--token-file",
214
+ "-tf",
215
+ help=(
216
+ "Instead of authenticating via code based authentication flow "
217
+ "you can set the path to the json file that contains a "
218
+ "`refresh token` containing a refresh_token key."
219
+ ),
220
+ ),
203
221
  verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
204
222
  version: Optional[bool] = typer.Option(
205
223
  False,
@@ -219,13 +237,15 @@ def metadata_search(
219
237
  """
220
238
  logger.set_verbosity(verbose)
221
239
  logger.debug("Search the databrowser")
240
+ if requires_authentication(flavour=flavour, databrowser_url=host):
241
+ Auth(token_file).authenticate(host=host, _cli=True)
222
242
  result = databrowser.metadata_search(
223
243
  *(facets or []),
224
244
  time=time or "",
225
245
  time_select=time_select.value,
226
246
  bbox=bbox or None,
227
247
  bbox_select=bbox_select.value,
228
- flavour=flavour.value,
248
+ flavour=flavour,
229
249
  host=host,
230
250
  extended_search=extended_search,
231
251
  multiversion=multiversion,
@@ -233,10 +253,9 @@ def metadata_search(
233
253
  **(parse_cli_args(search_keys or [])),
234
254
  )
235
255
  if parse_json:
236
- print(json.dumps(result))
256
+ print(result.to_json())
237
257
  return
238
- for key, values in result.items():
239
- print(f"{key}: {', '.join(values)}")
258
+ print_df(result)
240
259
 
241
260
 
242
261
  @databrowser_app.command(
@@ -270,10 +289,9 @@ def data_search(
270
289
  "based on file paths or Uniform Resource Identifiers"
271
290
  ),
272
291
  ),
273
- flavour: Flavours = typer.Option(
274
- "freva",
292
+ flavour: Optional[str] = typer.Option(
293
+ None,
275
294
  "--flavour",
276
- "-f",
277
295
  help=(
278
296
  "The Data Reference Syntax (DRS) standard specifying the type "
279
297
  "of climate datasets to query."
@@ -361,13 +379,14 @@ def data_search(
361
379
  """
362
380
  logger.set_verbosity(verbose)
363
381
  logger.debug("Search the databrowser")
382
+
364
383
  result = databrowser(
365
384
  *(facets or []),
366
385
  time=time or "",
367
386
  time_select=time_select.value,
368
387
  bbox=bbox or None,
369
388
  bbox_select=bbox_select.value,
370
- flavour=flavour.value,
389
+ flavour=flavour,
371
390
  uniq_key=uniq_key.value,
372
391
  host=host,
373
392
  fail_on_error=False,
@@ -375,7 +394,7 @@ def data_search(
375
394
  stream_zarr=zarr,
376
395
  **(parse_cli_args(search_keys or [])),
377
396
  )
378
- if zarr:
397
+ if requires_authentication(flavour=flavour, zarr=zarr, databrowser_url=host):
379
398
  Auth(token_file).authenticate(host=host, _cli=True)
380
399
  if parse_json:
381
400
  print(json.dumps(sorted(result)))
@@ -415,10 +434,9 @@ def intake_catalogue(
415
434
  "based on file paths or Uniform Resource Identifiers"
416
435
  ),
417
436
  ),
418
- flavour: Flavours = typer.Option(
419
- "freva",
437
+ flavour: Optional[str] = typer.Option(
438
+ None,
420
439
  "--flavour",
421
- "-f",
422
440
  help=(
423
441
  "The Data Reference Syntax (DRS) standard specifying the type "
424
442
  "of climate datasets to query."
@@ -508,7 +526,7 @@ def intake_catalogue(
508
526
  ) -> None:
509
527
  """Create an intake catalogue for climate datasets based on the specified "
510
528
  "Data Reference Syntax (DRS) standard (flavour) and the type of search "
511
- result (uniq_key), which can be either file or uri”."""
529
+ result (uniq_key), which can be either "file" or "uri"."""
512
530
  logger.set_verbosity(verbose)
513
531
  logger.debug("Search the databrowser")
514
532
  result = databrowser(
@@ -517,7 +535,7 @@ def intake_catalogue(
517
535
  time_select=time_select.value,
518
536
  bbox=bbox or None,
519
537
  bbox_select=bbox_select.value,
520
- flavour=flavour.value,
538
+ flavour=flavour,
521
539
  uniq_key=uniq_key.value,
522
540
  host=host,
523
541
  fail_on_error=False,
@@ -525,7 +543,7 @@ def intake_catalogue(
525
543
  stream_zarr=zarr,
526
544
  **(parse_cli_args(search_keys or [])),
527
545
  )
528
- if zarr:
546
+ if requires_authentication(flavour=flavour, zarr=zarr, databrowser_url=host):
529
547
  Auth(token_file).authenticate(host=host, _cli=True)
530
548
  with NamedTemporaryFile(suffix=".json") as temp_f:
531
549
  result._create_intake_catalogue_file(str(filename or temp_f.name))
@@ -564,10 +582,9 @@ def stac_catalogue(
564
582
  "based on file paths or Uniform Resource Identifiers"
565
583
  ),
566
584
  ),
567
- flavour: Flavours = typer.Option(
568
- "freva",
585
+ flavour: Optional[str] = typer.Option(
586
+ None,
569
587
  "--flavour",
570
- "-f",
571
588
  help=(
572
589
  "The Data Reference Syntax (DRS) standard specifying the type "
573
590
  "of climate datasets to query."
@@ -619,6 +636,16 @@ def stac_catalogue(
619
636
  "the hostname is read from a config file"
620
637
  ),
621
638
  ),
639
+ token_file: Optional[Path] = typer.Option(
640
+ None,
641
+ "--token-file",
642
+ "-tf",
643
+ help=(
644
+ "Instead of authenticating via code based authentication flow "
645
+ "you can set the path to the json file that contains a "
646
+ "`refresh token` containing a refresh_token key."
647
+ ),
648
+ ),
622
649
  verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
623
650
  multiversion: bool = typer.Option(
624
651
  False,
@@ -654,7 +681,7 @@ def stac_catalogue(
654
681
  time_select=time_select.value,
655
682
  bbox=bbox or None,
656
683
  bbox_select=bbox_select.value,
657
- flavour=flavour.value,
684
+ flavour=flavour,
658
685
  uniq_key=uniq_key.value,
659
686
  host=host,
660
687
  fail_on_error=False,
@@ -662,6 +689,8 @@ def stac_catalogue(
662
689
  stream_zarr=False,
663
690
  **(parse_cli_args(search_keys or [])),
664
691
  )
692
+ if requires_authentication(flavour=flavour, databrowser_url=host):
693
+ Auth(token_file).authenticate(host=host, _cli=True)
665
694
  print(result.stac_catalogue(filename=filename))
666
695
 
667
696
 
@@ -692,10 +721,9 @@ def count_values(
692
721
  "-d",
693
722
  help=("Separate the count by search facets."),
694
723
  ),
695
- flavour: Flavours = typer.Option(
696
- "freva",
724
+ flavour: Optional[str] = typer.Option(
725
+ None,
697
726
  "--flavour",
698
- "-f",
699
727
  help=(
700
728
  "The Data Reference Syntax (DRS) standard specifying the type "
701
729
  "of climate datasets to query."
@@ -761,6 +789,16 @@ def count_values(
761
789
  parse_json: bool = typer.Option(
762
790
  False, "-j", "--json", help="Parse output in json format."
763
791
  ),
792
+ token_file: Optional[Path] = typer.Option(
793
+ None,
794
+ "--token-file",
795
+ "-tf",
796
+ help=(
797
+ "Instead of authenticating via code based authentication flow "
798
+ "you can set the path to the json file that contains a "
799
+ "`refresh token` containing a refresh_token key."
800
+ ),
801
+ ),
764
802
  verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
765
803
  version: Optional[bool] = typer.Option(
766
804
  False,
@@ -788,6 +826,8 @@ def count_values(
788
826
  bbox or search_kws.pop("bbox", None),
789
827
  )
790
828
  facets = facets or []
829
+ if requires_authentication(flavour=flavour, databrowser_url=host):
830
+ Auth(token_file).authenticate(host=host, _cli=True)
791
831
  if detail:
792
832
  result = databrowser.count_values(
793
833
  *facets,
@@ -795,7 +835,7 @@ def count_values(
795
835
  time_select=time_select.value,
796
836
  bbox=bbox or None,
797
837
  bbox_select=bbox_select.value,
798
- flavour=flavour.value,
838
+ flavour=flavour,
799
839
  host=host,
800
840
  extended_search=extended_search,
801
841
  multiversion=multiversion,
@@ -810,7 +850,7 @@ def count_values(
810
850
  time_select=time_select.value,
811
851
  bbox=bbox or None,
812
852
  bbox_select=bbox_select.value,
813
- flavour=flavour.value,
853
+ flavour=flavour,
814
854
  host=host,
815
855
  multiversion=multiversion,
816
856
  fail_on_error=False,
@@ -945,3 +985,163 @@ def user_data_delete(
945
985
  key, value = search_key.split("=", 1)
946
986
  search_key_dict[key] = value
947
987
  databrowser.userdata(action="delete", metadata=search_key_dict, host=host)
988
+
989
+
990
+ flavour_app = typer.Typer(help="Manage custom flavours")
991
+ databrowser_app.add_typer(flavour_app, name="flavour")
992
+
993
+
994
+ @flavour_app.command("add", help="Add a new custom flavour.")
995
+ @exception_handler
996
+ def flavour_add(
997
+ name: str = typer.Argument(..., help="Name of the flavour to add"),
998
+ mapping: List[str] = typer.Option(
999
+ [],
1000
+ "--map",
1001
+ "-m",
1002
+ help="Key-value mappings in the format key=value",
1003
+ ),
1004
+ global_: bool = typer.Option(
1005
+ False,
1006
+ "--global",
1007
+ help="Make the flavour available to all users (requires admin privileges)",
1008
+ ),
1009
+ host: Optional[str] = typer.Option(
1010
+ None,
1011
+ "--host",
1012
+ help=(
1013
+ "Set the hostname of the databrowser. If not set (default), "
1014
+ "the hostname is read from a config file."
1015
+ ),
1016
+ ),
1017
+ token_file: Optional[Path] = typer.Option(
1018
+ None,
1019
+ "--token-file",
1020
+ "-tf",
1021
+ help=(
1022
+ "Instead of authenticating via code based authentication flow "
1023
+ "you can set the path to the json file that contains a "
1024
+ "`refresh token` containing a refresh_token key."
1025
+ ),
1026
+ ),
1027
+ verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
1028
+ ) -> None:
1029
+ """Add a new custom flavour to the databrowser.
1030
+ This command allows user to create custom DRS (Data Reference Syntax)
1031
+ flavours that define how search facets are mapped to different data
1032
+ standards."""
1033
+ logger.set_verbosity(verbose)
1034
+ logger.debug(f"Adding flavour '{name}' with mapping {mapping} and global={global_}")
1035
+ Auth(token_file).authenticate(host=host, _cli=True)
1036
+ mapping_dict = {}
1037
+ for map_item in mapping:
1038
+ if "=" not in map_item:
1039
+ logger.error(
1040
+ f"Invalid mapping format: {map_item}. Expected format: key=value."
1041
+ )
1042
+ raise typer.Exit(code=1)
1043
+ key, value = map_item.split("=", 1)
1044
+ mapping_dict[key] = value
1045
+
1046
+ if not mapping_dict:
1047
+ logger.error("At least one mapping must be provided using --map")
1048
+ raise typer.Exit(code=1)
1049
+
1050
+ databrowser.flavour(
1051
+ action="add",
1052
+ name=name,
1053
+ mapping=mapping_dict,
1054
+ is_global=global_,
1055
+ host=host,
1056
+ )
1057
+
1058
+
1059
+ @flavour_app.command("delete", help="Delete an existing custom flavour.")
1060
+ @exception_handler
1061
+ def flavour_delete(
1062
+ name: str = typer.Argument(..., help="Name of the flavour to delete"),
1063
+ global_: bool = typer.Option(
1064
+ False,
1065
+ "--global",
1066
+ help="Delete global flavour (requires admin privileges)",
1067
+ ),
1068
+ host: Optional[str] = typer.Option(
1069
+ None,
1070
+ "--host",
1071
+ help=(
1072
+ "Set the hostname of the databrowser. If not set (default), "
1073
+ "the hostname is read from a config file."
1074
+ ),
1075
+ ),
1076
+ token_file: Optional[Path] = typer.Option(
1077
+ None,
1078
+ "--token-file",
1079
+ "-tf",
1080
+ help=(
1081
+ "Instead of authenticating via code based authentication flow "
1082
+ "you can set the path to the json file that contains a "
1083
+ "`refresh token` containing a refresh_token key."
1084
+ ),
1085
+ ),
1086
+ verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
1087
+ ) -> None:
1088
+ """Delete a custom flavour from the databrowser.
1089
+ This command removes a custom flavour that was previously created.
1090
+ You can only delete flavours that you own, unless you are an admin
1091
+ user who can delete global flavours."""
1092
+ logger.set_verbosity(verbose)
1093
+ logger.debug(f"Deleting flavour '{name}'")
1094
+ Auth(token_file).authenticate(host=host, _cli=True)
1095
+
1096
+ databrowser.flavour(
1097
+ action="delete",
1098
+ name=name,
1099
+ is_global=global_,
1100
+ host=host,
1101
+ )
1102
+
1103
+
1104
+ @flavour_app.command("list", help="List all available custom flavours.")
1105
+ @exception_handler
1106
+ def flavour_list(
1107
+ host: Optional[str] = typer.Option(
1108
+ None,
1109
+ "--host",
1110
+ help=(
1111
+ "Set the hostname of the databrowser. If not set (default), "
1112
+ "the hostname is read from a config file."
1113
+ ),
1114
+ ),
1115
+ parse_json: bool = typer.Option(
1116
+ False, "-j", "--json", help="Parse output in json format."
1117
+ ),
1118
+ token_file: Optional[Path] = typer.Option(
1119
+ None,
1120
+ "--token-file",
1121
+ "-tf",
1122
+ help=(
1123
+ "Instead of authenticating via code based authentication flow "
1124
+ "you can set the path to the json file that contains a "
1125
+ "`refresh token` containing a refresh_token key."
1126
+ ),
1127
+ ),
1128
+ verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
1129
+ ) -> None:
1130
+ """List all custom flavours available to the current user.
1131
+ This command shows both personal flavours created by the user and
1132
+ global flavours available to all users."""
1133
+ logger.set_verbosity(verbose)
1134
+ logger.debug("Listing custom flavours")
1135
+ results = cast(Dict[str, Any], databrowser.flavour(action="list", host=host))
1136
+ flavours_json = json.dumps(results["flavours"], indent=2)
1137
+ if parse_json:
1138
+ print(flavours_json)
1139
+ else:
1140
+ pprint(f"[yellow]{results.get('Note', '')}[/yellow]")
1141
+ print("🎨 Available Data Reference Syntax (DRS) Flavours")
1142
+ print("=" * 55)
1143
+ for flavour in results["flavours"]:
1144
+ owner_icon = "🌐" if flavour['owner'] == "global" else "👤"
1145
+ print(f" {owner_icon} {flavour['flavour_name']} (by {flavour['owner']})")
1146
+ print(f" Mapping: {flavour['mapping']}")
1147
+ print()