freva-client 2508.0.0__py3-none-any.whl → 2509.1.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.
Potentially problematic release.
This version of freva-client might be problematic. Click here for more details.
- freva_client/__init__.py +2 -1
- freva_client/auth.py +36 -129
- freva_client/cli/auth_cli.py +6 -0
- freva_client/cli/cli_utils.py +38 -1
- freva_client/cli/databrowser_cli.py +232 -32
- freva_client/query.py +255 -38
- freva_client/utils/auth_utils.py +297 -21
- freva_client/utils/databrowser_utils.py +118 -32
- {freva_client-2508.0.0.data → freva_client-2509.1.0.data}/data/share/freva/freva.toml +5 -0
- {freva_client-2508.0.0.dist-info → freva_client-2509.1.0.dist-info}/METADATA +1 -1
- freva_client-2509.1.0.dist-info/RECORD +20 -0
- freva_client-2508.0.0.dist-info/RECORD +0 -20
- {freva_client-2508.0.0.dist-info → freva_client-2509.1.0.dist-info}/WHEEL +0 -0
- {freva_client-2508.0.0.dist-info → freva_client-2509.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
30
|
-
|
|
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:
|
|
135
|
-
|
|
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
|
|
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(
|
|
256
|
+
print(result.to_json())
|
|
237
257
|
return
|
|
238
|
-
|
|
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:
|
|
274
|
-
|
|
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
|
|
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:
|
|
419
|
-
|
|
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
|
|
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
|
|
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:
|
|
568
|
-
|
|
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
|
|
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:
|
|
696
|
-
|
|
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
|
|
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
|
|
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()
|