esgpull 0.6.3__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.
- esgpull/__init__.py +12 -0
- esgpull/auth.py +181 -0
- esgpull/cli/__init__.py +73 -0
- esgpull/cli/add.py +103 -0
- esgpull/cli/autoremove.py +38 -0
- esgpull/cli/config.py +116 -0
- esgpull/cli/convert.py +285 -0
- esgpull/cli/decorators.py +342 -0
- esgpull/cli/download.py +74 -0
- esgpull/cli/facet.py +23 -0
- esgpull/cli/get.py +28 -0
- esgpull/cli/install.py +85 -0
- esgpull/cli/link.py +105 -0
- esgpull/cli/login.py +56 -0
- esgpull/cli/remove.py +73 -0
- esgpull/cli/retry.py +43 -0
- esgpull/cli/search.py +201 -0
- esgpull/cli/self.py +238 -0
- esgpull/cli/show.py +66 -0
- esgpull/cli/status.py +67 -0
- esgpull/cli/track.py +87 -0
- esgpull/cli/update.py +184 -0
- esgpull/cli/utils.py +247 -0
- esgpull/config.py +410 -0
- esgpull/constants.py +56 -0
- esgpull/context.py +724 -0
- esgpull/database.py +161 -0
- esgpull/download.py +162 -0
- esgpull/esgpull.py +447 -0
- esgpull/exceptions.py +167 -0
- esgpull/fs.py +253 -0
- esgpull/graph.py +460 -0
- esgpull/install_config.py +185 -0
- esgpull/migrations/README +1 -0
- esgpull/migrations/env.py +82 -0
- esgpull/migrations/script.py.mako +24 -0
- esgpull/migrations/versions/0.3.0_update_tables.py +170 -0
- esgpull/migrations/versions/0.3.1_update_tables.py +25 -0
- esgpull/migrations/versions/0.3.2_update_tables.py +26 -0
- esgpull/migrations/versions/0.3.3_update_tables.py +25 -0
- esgpull/migrations/versions/0.3.4_update_tables.py +25 -0
- esgpull/migrations/versions/0.3.5_update_tables.py +25 -0
- esgpull/migrations/versions/0.3.6_update_tables.py +26 -0
- esgpull/migrations/versions/0.3.7_update_tables.py +26 -0
- esgpull/migrations/versions/0.3.8_update_tables.py +26 -0
- esgpull/migrations/versions/0.4.0_update_tables.py +25 -0
- esgpull/migrations/versions/0.5.0_update_tables.py +26 -0
- esgpull/migrations/versions/0.5.1_update_tables.py +26 -0
- esgpull/migrations/versions/0.5.2_update_tables.py +25 -0
- esgpull/migrations/versions/0.5.3_update_tables.py +26 -0
- esgpull/migrations/versions/0.5.4_update_tables.py +25 -0
- esgpull/migrations/versions/0.5.5_update_tables.py +25 -0
- esgpull/migrations/versions/0.6.0_update_tables.py +25 -0
- esgpull/migrations/versions/0.6.1_update_tables.py +25 -0
- esgpull/migrations/versions/0.6.2_update_tables.py +25 -0
- esgpull/migrations/versions/0.6.3_update_tables.py +25 -0
- esgpull/models/__init__.py +31 -0
- esgpull/models/base.py +50 -0
- esgpull/models/dataset.py +34 -0
- esgpull/models/facet.py +18 -0
- esgpull/models/file.py +65 -0
- esgpull/models/options.py +164 -0
- esgpull/models/query.py +481 -0
- esgpull/models/selection.py +201 -0
- esgpull/models/sql.py +258 -0
- esgpull/models/synda_file.py +85 -0
- esgpull/models/tag.py +19 -0
- esgpull/models/utils.py +54 -0
- esgpull/presets.py +13 -0
- esgpull/processor.py +172 -0
- esgpull/py.typed +0 -0
- esgpull/result.py +53 -0
- esgpull/tui.py +346 -0
- esgpull/utils.py +54 -0
- esgpull/version.py +1 -0
- esgpull-0.6.3.dist-info/METADATA +110 -0
- esgpull-0.6.3.dist-info/RECORD +80 -0
- esgpull-0.6.3.dist-info/WHEEL +4 -0
- esgpull-0.6.3.dist-info/entry_points.txt +3 -0
- esgpull-0.6.3.dist-info/licenses/LICENSE +28 -0
esgpull/cli/utils.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections import OrderedDict
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal, MutableMapping, Sequence
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import yaml
|
|
9
|
+
from click.exceptions import BadArgumentUsage
|
|
10
|
+
from rich.box import MINIMAL_DOUBLE_HEAD
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from esgpull import Esgpull
|
|
15
|
+
from esgpull.graph import Graph
|
|
16
|
+
from esgpull.models import Dataset, File, Option, Options, Query, Selection
|
|
17
|
+
from esgpull.tui import UI, TempUI, Verbosity
|
|
18
|
+
from esgpull.utils import format_size
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_command() -> Text:
|
|
22
|
+
exe, *args = sys.argv
|
|
23
|
+
args = [arg for arg in args if arg != "--record"]
|
|
24
|
+
return Text(" ".join(["$", Path(exe).name, *args]))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_esgpull(
|
|
28
|
+
verbosity: Verbosity,
|
|
29
|
+
safe: bool = True,
|
|
30
|
+
record: bool = False,
|
|
31
|
+
load_db: bool = True,
|
|
32
|
+
) -> Esgpull:
|
|
33
|
+
TempUI.verbosity = Verbosity.Errors
|
|
34
|
+
with TempUI.logging():
|
|
35
|
+
esg = Esgpull(
|
|
36
|
+
verbosity=verbosity,
|
|
37
|
+
safe=safe,
|
|
38
|
+
record=record,
|
|
39
|
+
load_db=load_db,
|
|
40
|
+
)
|
|
41
|
+
if record:
|
|
42
|
+
esg.ui.print(get_command())
|
|
43
|
+
return esg
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Messages:
|
|
47
|
+
@staticmethod
|
|
48
|
+
def no_such_query(name: str) -> str:
|
|
49
|
+
return f":stop_sign: No such query [green]{name}[/]"
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def none_tagged(tag: str) -> str:
|
|
53
|
+
return f":stop_sign: No query tagged with [magenta]{tag}[/]"
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def multimatch(name: str) -> str:
|
|
57
|
+
return f"Found multiple queries starting with [b cyan]{name}[/]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EnumParam(click.Choice):
|
|
61
|
+
name = "enum"
|
|
62
|
+
|
|
63
|
+
def __init__(self, enum: type[Enum]):
|
|
64
|
+
self.__enum = enum
|
|
65
|
+
super().__init__(choices=[item.value for item in enum])
|
|
66
|
+
|
|
67
|
+
def convert(self, value, param, ctx) -> Enum:
|
|
68
|
+
converted_str = super().convert(value, param, ctx)
|
|
69
|
+
return self.__enum(converted_str)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def filter_keys(
|
|
73
|
+
docs: Sequence[File | Dataset],
|
|
74
|
+
ids: range,
|
|
75
|
+
size: bool = True,
|
|
76
|
+
data_node: bool = False,
|
|
77
|
+
# date: bool = False,
|
|
78
|
+
) -> list[OrderedDict[str, Any]]:
|
|
79
|
+
result: list[OrderedDict[str, Any]] = []
|
|
80
|
+
for i, doc in zip(ids, docs):
|
|
81
|
+
od: OrderedDict[str, Any] = OrderedDict()
|
|
82
|
+
od["id"] = str(i)
|
|
83
|
+
if isinstance(doc, File):
|
|
84
|
+
od["file"] = doc.file_id
|
|
85
|
+
else:
|
|
86
|
+
od["dataset"] = doc.dataset_id
|
|
87
|
+
od["#"] = str(doc.number_of_files)
|
|
88
|
+
if size:
|
|
89
|
+
od["size"] = doc.size
|
|
90
|
+
if data_node:
|
|
91
|
+
od["data_node"] = doc.data_node
|
|
92
|
+
# if date:
|
|
93
|
+
# od["date"] = doc.get("timestamp") or doc.get("_timestamp")
|
|
94
|
+
result.append(od)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def totable(docs: list[OrderedDict[str, Any]]) -> Table:
|
|
99
|
+
table = Table(box=MINIMAL_DOUBLE_HEAD, show_edge=False)
|
|
100
|
+
for key in docs[0].keys():
|
|
101
|
+
justify: Literal["left", "right", "center"]
|
|
102
|
+
if key in ["file", "dataset"]:
|
|
103
|
+
justify = "left"
|
|
104
|
+
else:
|
|
105
|
+
justify = "right"
|
|
106
|
+
table.add_column(
|
|
107
|
+
Text(key, justify="center"),
|
|
108
|
+
justify=justify,
|
|
109
|
+
)
|
|
110
|
+
for doc in docs:
|
|
111
|
+
row: list[str] = []
|
|
112
|
+
for key, value in doc.items():
|
|
113
|
+
if key == "size":
|
|
114
|
+
value = format_size(value)
|
|
115
|
+
row.append(value)
|
|
116
|
+
table.add_row(*row)
|
|
117
|
+
return table
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_facets(facets: list[str]) -> Selection:
|
|
121
|
+
facet_dict: dict[str, list[str]] = {}
|
|
122
|
+
exact_terms: list[str] | None = None
|
|
123
|
+
for facet in facets:
|
|
124
|
+
match facet.split(":"):
|
|
125
|
+
case [value]:
|
|
126
|
+
name = "query"
|
|
127
|
+
case [name, value] if name and value:
|
|
128
|
+
...
|
|
129
|
+
case _:
|
|
130
|
+
raise BadArgumentUsage(f"{facet!r} is not valid syntax.")
|
|
131
|
+
if value.startswith("/"):
|
|
132
|
+
if exact_terms is not None:
|
|
133
|
+
raise BadArgumentUsage("Nested exact string is forbidden.")
|
|
134
|
+
exact_terms = []
|
|
135
|
+
if exact_terms is not None:
|
|
136
|
+
if name != "query":
|
|
137
|
+
raise BadArgumentUsage(
|
|
138
|
+
"Cannot use facet term inside an exact string."
|
|
139
|
+
)
|
|
140
|
+
exact_terms.append(value)
|
|
141
|
+
if value.endswith("/"):
|
|
142
|
+
final_exact_str = " ".join(exact_terms)
|
|
143
|
+
value = '"' + final_exact_str.strip("/") + '"'
|
|
144
|
+
exact_terms = None
|
|
145
|
+
else:
|
|
146
|
+
continue
|
|
147
|
+
facet_dict.setdefault(name, [])
|
|
148
|
+
facet_dict[name].append(value)
|
|
149
|
+
else:
|
|
150
|
+
values = value.split(",")
|
|
151
|
+
facet_dict.setdefault(name, [])
|
|
152
|
+
facet_dict[name].extend(values)
|
|
153
|
+
selection = Selection()
|
|
154
|
+
for name, values in facet_dict.items():
|
|
155
|
+
selection[name] = values
|
|
156
|
+
return selection
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_query(
|
|
160
|
+
facets: list[str],
|
|
161
|
+
# query options
|
|
162
|
+
tags: list[str],
|
|
163
|
+
require: str | None,
|
|
164
|
+
distrib: str | None,
|
|
165
|
+
latest: str | None,
|
|
166
|
+
replica: str | None,
|
|
167
|
+
retracted: str | None,
|
|
168
|
+
) -> Query:
|
|
169
|
+
options = Options(
|
|
170
|
+
distrib=distrib or Option.notset,
|
|
171
|
+
latest=latest or Option.notset,
|
|
172
|
+
replica=replica or Option.notset,
|
|
173
|
+
retracted=retracted or Option.notset,
|
|
174
|
+
)
|
|
175
|
+
selection = parse_facets(facets)
|
|
176
|
+
return Query(
|
|
177
|
+
tags=tags,
|
|
178
|
+
require=require,
|
|
179
|
+
options=options,
|
|
180
|
+
selection=selection,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_list_of_maps(seq: Sequence) -> bool:
|
|
185
|
+
return all(isinstance(item, MutableMapping) for item in seq)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def serialize_queries_from_file(path: Path) -> list[Query]:
|
|
189
|
+
with path.open() as f:
|
|
190
|
+
content = yaml.safe_load(f)
|
|
191
|
+
queries: list[MutableMapping]
|
|
192
|
+
if isinstance(content, list):
|
|
193
|
+
queries = content
|
|
194
|
+
elif isinstance(content, MutableMapping):
|
|
195
|
+
values = list(content.values())
|
|
196
|
+
if is_list_of_maps(values):
|
|
197
|
+
queries = values
|
|
198
|
+
else:
|
|
199
|
+
queries = [content]
|
|
200
|
+
else:
|
|
201
|
+
raise ValueError(content)
|
|
202
|
+
return [Query(**query) for query in queries]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def valid_name_tag(
|
|
206
|
+
graph: Graph,
|
|
207
|
+
ui: UI,
|
|
208
|
+
query_id: str | None,
|
|
209
|
+
tag: str | None,
|
|
210
|
+
) -> bool:
|
|
211
|
+
result = True
|
|
212
|
+
if query_id is not None:
|
|
213
|
+
shas = graph.matching_shas(query_id, graph._shas)
|
|
214
|
+
if len(shas) > 1:
|
|
215
|
+
ui.print(Messages.multimatch(query_id))
|
|
216
|
+
ui.print(shas, json=True)
|
|
217
|
+
result = False
|
|
218
|
+
elif len(shas) == 0:
|
|
219
|
+
ui.print(Messages.no_such_query(query_id), err=True)
|
|
220
|
+
result = False
|
|
221
|
+
elif tag is not None:
|
|
222
|
+
tags = [t.name for t in graph.get_tags()]
|
|
223
|
+
if tag not in tags:
|
|
224
|
+
ui.print(Messages.none_tagged(tag), err=True)
|
|
225
|
+
result = False
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_queries(
|
|
230
|
+
graph: Graph,
|
|
231
|
+
query_id: str | None,
|
|
232
|
+
tag: str | None,
|
|
233
|
+
children: bool = False,
|
|
234
|
+
) -> list[Query]:
|
|
235
|
+
queries: list[Query] = []
|
|
236
|
+
if query_id is not None:
|
|
237
|
+
query = graph.get(name=query_id)
|
|
238
|
+
if query is not None:
|
|
239
|
+
queries = [query]
|
|
240
|
+
elif tag is not None:
|
|
241
|
+
queries = graph.with_tag(tag)
|
|
242
|
+
if children:
|
|
243
|
+
for i in range(len(queries)):
|
|
244
|
+
query = queries[0]
|
|
245
|
+
kids = graph.get_all_children(query.sha)
|
|
246
|
+
queries.extend(kids)
|
|
247
|
+
return queries
|
esgpull/config.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Container, Iterator, Mapping
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import tomlkit
|
|
10
|
+
from attrs import Factory, define, field
|
|
11
|
+
from cattrs import Converter
|
|
12
|
+
from cattrs.gen import make_dict_unstructure_fn, override
|
|
13
|
+
from tomlkit import TOMLDocument
|
|
14
|
+
|
|
15
|
+
from esgpull.constants import CONFIG_FILENAME
|
|
16
|
+
from esgpull.exceptions import BadConfigError, VirtualConfigError
|
|
17
|
+
from esgpull.install_config import InstallConfig
|
|
18
|
+
from esgpull.models.options import Options
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("esgpull")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@define
|
|
24
|
+
class Paths:
|
|
25
|
+
auth: Path = field(converter=Path)
|
|
26
|
+
data: Path = field(converter=Path)
|
|
27
|
+
db: Path = field(converter=Path)
|
|
28
|
+
log: Path = field(converter=Path)
|
|
29
|
+
tmp: Path = field(converter=Path)
|
|
30
|
+
|
|
31
|
+
@auth.default
|
|
32
|
+
def _auth_factory(self) -> Path:
|
|
33
|
+
if InstallConfig.current is not None:
|
|
34
|
+
root = InstallConfig.current.path
|
|
35
|
+
else:
|
|
36
|
+
root = InstallConfig.default
|
|
37
|
+
return root / "auth"
|
|
38
|
+
|
|
39
|
+
@data.default
|
|
40
|
+
def _data_factory(self) -> Path:
|
|
41
|
+
if InstallConfig.current is not None:
|
|
42
|
+
root = InstallConfig.current.path
|
|
43
|
+
else:
|
|
44
|
+
root = InstallConfig.default
|
|
45
|
+
return root / "data"
|
|
46
|
+
|
|
47
|
+
@db.default
|
|
48
|
+
def _db_factory(self) -> Path:
|
|
49
|
+
if InstallConfig.current is not None:
|
|
50
|
+
root = InstallConfig.current.path
|
|
51
|
+
else:
|
|
52
|
+
root = InstallConfig.default
|
|
53
|
+
return root / "db"
|
|
54
|
+
|
|
55
|
+
@log.default
|
|
56
|
+
def _log_factory(self) -> Path:
|
|
57
|
+
if InstallConfig.current is not None:
|
|
58
|
+
root = InstallConfig.current.path
|
|
59
|
+
else:
|
|
60
|
+
root = InstallConfig.default
|
|
61
|
+
return root / "log"
|
|
62
|
+
|
|
63
|
+
@tmp.default
|
|
64
|
+
def _tmp_factory(self) -> Path:
|
|
65
|
+
if InstallConfig.current is not None:
|
|
66
|
+
root = InstallConfig.current.path
|
|
67
|
+
else:
|
|
68
|
+
root = InstallConfig.default
|
|
69
|
+
return root / "tmp"
|
|
70
|
+
|
|
71
|
+
def __iter__(self) -> Iterator[Path]:
|
|
72
|
+
yield self.auth
|
|
73
|
+
yield self.data
|
|
74
|
+
yield self.db
|
|
75
|
+
yield self.log
|
|
76
|
+
yield self.tmp
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@define
|
|
80
|
+
class Credentials:
|
|
81
|
+
filename: str = "credentials.toml"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@define
|
|
85
|
+
class Cli:
|
|
86
|
+
page_size: int = 20
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@define
|
|
90
|
+
class Db:
|
|
91
|
+
filename: str = "esgpull.db"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@define
|
|
95
|
+
class Download:
|
|
96
|
+
chunk_size: int = 1 << 26 # 64 MiB
|
|
97
|
+
http_timeout: int = 20
|
|
98
|
+
max_concurrent: int = 5
|
|
99
|
+
disable_ssl: bool = False
|
|
100
|
+
disable_checksum: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@define
|
|
104
|
+
class DefaultOptions:
|
|
105
|
+
distrib: str = Options._distrib_.name
|
|
106
|
+
latest: str = Options._latest_.name
|
|
107
|
+
replica: str = Options._replica_.name
|
|
108
|
+
retracted: str = Options._retracted_.name
|
|
109
|
+
|
|
110
|
+
def asdict(self) -> dict[str, str]:
|
|
111
|
+
return dict(
|
|
112
|
+
distrib=self.distrib,
|
|
113
|
+
latest=self.latest,
|
|
114
|
+
replica=self.replica,
|
|
115
|
+
retracted=self.retracted,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@define
|
|
120
|
+
class API:
|
|
121
|
+
index_node: str = "esgf-node.ipsl.upmc.fr"
|
|
122
|
+
http_timeout: int = 20
|
|
123
|
+
max_concurrent: int = 5
|
|
124
|
+
page_limit: int = 50
|
|
125
|
+
default_options: DefaultOptions = Factory(DefaultOptions)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fix_rename_search_api(doc: TOMLDocument) -> TOMLDocument:
|
|
129
|
+
if "api" in doc and "search" in doc:
|
|
130
|
+
raise KeyError(
|
|
131
|
+
"Both 'api' and 'search' (deprecated) are used in your "
|
|
132
|
+
"config, please use 'api' only."
|
|
133
|
+
)
|
|
134
|
+
elif "search" in doc:
|
|
135
|
+
logger.warn(
|
|
136
|
+
"Deprecated key 'search' is used in your config, "
|
|
137
|
+
"please use 'api' instead."
|
|
138
|
+
)
|
|
139
|
+
doc["api"] = doc.pop("search")
|
|
140
|
+
return doc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
config_fixers = [fix_rename_search_api]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ConfigKind(Enum):
|
|
147
|
+
Virtual = auto()
|
|
148
|
+
NoFile = auto()
|
|
149
|
+
Partial = auto()
|
|
150
|
+
Complete = auto()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ConfigKey:
|
|
154
|
+
path: tuple[str, ...]
|
|
155
|
+
|
|
156
|
+
def __init__(self, first: str | tuple[str, ...], *rest: str) -> None:
|
|
157
|
+
if isinstance(first, tuple):
|
|
158
|
+
self.path = first + rest
|
|
159
|
+
elif "." in first:
|
|
160
|
+
self.path = tuple(first.split(".")) + rest
|
|
161
|
+
else:
|
|
162
|
+
self.path = (first,) + rest
|
|
163
|
+
|
|
164
|
+
def __iter__(self) -> Iterator[str]:
|
|
165
|
+
yield from self.path
|
|
166
|
+
|
|
167
|
+
def __hash__(self) -> int:
|
|
168
|
+
return hash(self.path)
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
return ".".join(self)
|
|
172
|
+
|
|
173
|
+
def __add__(self, path: str) -> ConfigKey:
|
|
174
|
+
return ConfigKey(self.path, path)
|
|
175
|
+
|
|
176
|
+
def __len__(self) -> int:
|
|
177
|
+
return len(self.path)
|
|
178
|
+
|
|
179
|
+
def exists_in(self, source: Mapping | None) -> bool:
|
|
180
|
+
if source is None:
|
|
181
|
+
return False
|
|
182
|
+
doc = source
|
|
183
|
+
for key in self:
|
|
184
|
+
if key in doc:
|
|
185
|
+
doc = doc[key]
|
|
186
|
+
else:
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def value_of(self, source: Mapping) -> Any:
|
|
191
|
+
doc = source
|
|
192
|
+
for key in self:
|
|
193
|
+
doc = doc[key]
|
|
194
|
+
return doc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def iter_keys(
|
|
198
|
+
source: Mapping,
|
|
199
|
+
path: ConfigKey | None = None,
|
|
200
|
+
) -> Iterator[ConfigKey]:
|
|
201
|
+
for key in source.keys():
|
|
202
|
+
if path is None:
|
|
203
|
+
local_path = ConfigKey(key)
|
|
204
|
+
else:
|
|
205
|
+
local_path = path + key
|
|
206
|
+
if isinstance(source[key], Mapping):
|
|
207
|
+
yield from iter_keys(source[key], local_path)
|
|
208
|
+
else:
|
|
209
|
+
yield local_path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@define
|
|
213
|
+
class Config:
|
|
214
|
+
paths: Paths = Factory(Paths)
|
|
215
|
+
credentials: Credentials = Factory(Credentials)
|
|
216
|
+
cli: Cli = Factory(Cli)
|
|
217
|
+
db: Db = Factory(Db)
|
|
218
|
+
download: Download = Factory(Download)
|
|
219
|
+
api: API = Factory(API)
|
|
220
|
+
_raw: TOMLDocument | None = field(init=False, default=None)
|
|
221
|
+
_config_file: Path | None = field(init=False, default=None)
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def load(cls, path: Path) -> Config:
|
|
225
|
+
config_file = path / CONFIG_FILENAME
|
|
226
|
+
if config_file.is_file():
|
|
227
|
+
with config_file.open() as fh:
|
|
228
|
+
doc = tomlkit.load(fh)
|
|
229
|
+
for fixer in config_fixers:
|
|
230
|
+
try:
|
|
231
|
+
doc = fixer(doc)
|
|
232
|
+
except Exception:
|
|
233
|
+
raise BadConfigError(config_file)
|
|
234
|
+
raw = doc
|
|
235
|
+
else:
|
|
236
|
+
doc = TOMLDocument()
|
|
237
|
+
raw = None
|
|
238
|
+
config = _converter_defaults.structure(doc, cls)
|
|
239
|
+
config._raw = raw
|
|
240
|
+
config._config_file = config_file
|
|
241
|
+
return config
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def default(cls) -> Config:
|
|
245
|
+
if InstallConfig.current is not None:
|
|
246
|
+
root = InstallConfig.current.path
|
|
247
|
+
else:
|
|
248
|
+
root = InstallConfig.default
|
|
249
|
+
return cls.load(root)
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def kind(self) -> ConfigKind:
|
|
253
|
+
if self._config_file is None:
|
|
254
|
+
return ConfigKind.Virtual
|
|
255
|
+
elif not self._config_file.is_file():
|
|
256
|
+
return ConfigKind.NoFile
|
|
257
|
+
elif self.unset_options():
|
|
258
|
+
return ConfigKind.Partial
|
|
259
|
+
else:
|
|
260
|
+
return ConfigKind.Complete
|
|
261
|
+
|
|
262
|
+
def dumps(self, defaults: bool = True, comments: bool = False) -> str:
|
|
263
|
+
return self.dump(defaults, comments).as_string()
|
|
264
|
+
|
|
265
|
+
def dump(
|
|
266
|
+
self,
|
|
267
|
+
defaults: bool = True,
|
|
268
|
+
comments: bool = False,
|
|
269
|
+
) -> TOMLDocument:
|
|
270
|
+
if defaults:
|
|
271
|
+
converter = _converter_defaults
|
|
272
|
+
else:
|
|
273
|
+
converter = _converter_no_defaults
|
|
274
|
+
dump = converter.unstructure(self)
|
|
275
|
+
if not defaults:
|
|
276
|
+
pop_empty(dump)
|
|
277
|
+
doc = TOMLDocument()
|
|
278
|
+
doc.update(dump)
|
|
279
|
+
# if comments and self._raw is not None:
|
|
280
|
+
# original = tomlkit.loads(self._raw)
|
|
281
|
+
return doc
|
|
282
|
+
|
|
283
|
+
def update_item(
|
|
284
|
+
self,
|
|
285
|
+
key: str,
|
|
286
|
+
value: int | str,
|
|
287
|
+
empty_ok: bool = False,
|
|
288
|
+
) -> int | str | None:
|
|
289
|
+
if self._raw is None and empty_ok:
|
|
290
|
+
self._raw = TOMLDocument()
|
|
291
|
+
if self._raw is None:
|
|
292
|
+
raise VirtualConfigError
|
|
293
|
+
else:
|
|
294
|
+
doc: dict = self._raw
|
|
295
|
+
obj = self
|
|
296
|
+
*parts, last = ConfigKey(key)
|
|
297
|
+
for part in parts:
|
|
298
|
+
doc.setdefault(part, {})
|
|
299
|
+
doc = doc[part]
|
|
300
|
+
obj = getattr(obj, part)
|
|
301
|
+
old_value = getattr(obj, last)
|
|
302
|
+
if isinstance(old_value, str):
|
|
303
|
+
...
|
|
304
|
+
elif isinstance(old_value, Container):
|
|
305
|
+
raise KeyError(key)
|
|
306
|
+
try:
|
|
307
|
+
value = int(value)
|
|
308
|
+
except ValueError:
|
|
309
|
+
...
|
|
310
|
+
setattr(obj, last, value)
|
|
311
|
+
doc[last] = value
|
|
312
|
+
return old_value
|
|
313
|
+
|
|
314
|
+
def set_default(self, key: str) -> int | str | None:
|
|
315
|
+
ckey = ConfigKey(key)
|
|
316
|
+
if self._raw is None:
|
|
317
|
+
raise VirtualConfigError()
|
|
318
|
+
elif not ckey.exists_in(self._raw):
|
|
319
|
+
return None
|
|
320
|
+
default_config = self.__class__()
|
|
321
|
+
default_value = ckey.value_of(default_config.dump())
|
|
322
|
+
old_value: Any = ckey.value_of(self.dump())
|
|
323
|
+
first_pass = True
|
|
324
|
+
obj = self
|
|
325
|
+
for idx in range(len(ckey), 0, -1):
|
|
326
|
+
*parts, last = ckey.path[:idx]
|
|
327
|
+
doc: tomlkit.container.Container = self._raw
|
|
328
|
+
for part in parts:
|
|
329
|
+
if first_pass:
|
|
330
|
+
obj = getattr(obj, part)
|
|
331
|
+
doc = cast(tomlkit.container.Container, doc[part])
|
|
332
|
+
if first_pass:
|
|
333
|
+
doc.remove(last)
|
|
334
|
+
setattr(obj, last, default_value)
|
|
335
|
+
first_pass = False
|
|
336
|
+
elif (
|
|
337
|
+
(value := doc[last])
|
|
338
|
+
and isinstance(value, tomlkit.container.Container)
|
|
339
|
+
and len(value) == 0
|
|
340
|
+
):
|
|
341
|
+
doc.remove(last)
|
|
342
|
+
return old_value
|
|
343
|
+
|
|
344
|
+
def unset_options(self) -> list[ConfigKey]:
|
|
345
|
+
result: list[ConfigKey] = []
|
|
346
|
+
raw: dict
|
|
347
|
+
dump = self.dump()
|
|
348
|
+
if self._raw is None:
|
|
349
|
+
raw = {}
|
|
350
|
+
else:
|
|
351
|
+
raw = self._raw
|
|
352
|
+
for ckey in iter_keys(dump):
|
|
353
|
+
if not ckey.exists_in(raw):
|
|
354
|
+
result.append(ckey)
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
def generate(
|
|
358
|
+
self,
|
|
359
|
+
overwrite: bool = False,
|
|
360
|
+
) -> None:
|
|
361
|
+
match (self.kind, overwrite):
|
|
362
|
+
case (ConfigKind.Virtual, _):
|
|
363
|
+
raise VirtualConfigError
|
|
364
|
+
case (ConfigKind.Partial, overwrite):
|
|
365
|
+
defaults = self.dump()
|
|
366
|
+
for ckey in self.unset_options():
|
|
367
|
+
self.update_item(str(ckey), ckey.value_of(defaults))
|
|
368
|
+
case (ConfigKind.Partial | ConfigKind.Complete, _):
|
|
369
|
+
raise FileExistsError(self._config_file)
|
|
370
|
+
case (ConfigKind.NoFile, _):
|
|
371
|
+
self._raw = self.dump()
|
|
372
|
+
case _:
|
|
373
|
+
raise ValueError(self.kind)
|
|
374
|
+
self.write()
|
|
375
|
+
|
|
376
|
+
def write(self) -> None:
|
|
377
|
+
if self.kind == ConfigKind.Virtual or self._raw is None:
|
|
378
|
+
raise VirtualConfigError
|
|
379
|
+
config_file = cast(Path, self._config_file)
|
|
380
|
+
with config_file.open("w") as f:
|
|
381
|
+
tomlkit.dump(self._raw, f)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _make_converter(omit_default: bool) -> Converter:
|
|
385
|
+
conv = Converter(omit_if_default=omit_default, forbid_extra_keys=True)
|
|
386
|
+
conv.register_unstructure_hook(Path, str)
|
|
387
|
+
conv.register_unstructure_hook(
|
|
388
|
+
Config,
|
|
389
|
+
make_dict_unstructure_fn(
|
|
390
|
+
Config,
|
|
391
|
+
conv,
|
|
392
|
+
_raw=override(omit=True),
|
|
393
|
+
_config_file=override(omit=True),
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
return conv
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_converter_defaults = _make_converter(omit_default=False)
|
|
400
|
+
_converter_no_defaults = _make_converter(omit_default=True)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def pop_empty(d: dict[str, Any]) -> None:
|
|
404
|
+
keys = list(d.keys())
|
|
405
|
+
for key in keys:
|
|
406
|
+
value = d[key]
|
|
407
|
+
if isinstance(value, dict):
|
|
408
|
+
pop_empty(value)
|
|
409
|
+
if not value:
|
|
410
|
+
d.pop(key)
|
esgpull/constants.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
CONFIG_FILENAME = "config.toml"
|
|
2
|
+
ROOT_ENV = "ESGPULL_CURRENT"
|
|
3
|
+
|
|
4
|
+
IDP = "/esgf-idp/openid/"
|
|
5
|
+
CEDA_IDP = "/OpenID/Provider/server/"
|
|
6
|
+
PROVIDERS = {
|
|
7
|
+
"esg-dn1.nsc.liu.se": IDP,
|
|
8
|
+
"esgf-data.dkrz.de": IDP,
|
|
9
|
+
"ceda.ac.uk": CEDA_IDP,
|
|
10
|
+
"esgf-node.ipsl.upmc.fr": IDP,
|
|
11
|
+
"esgf-node.llnl.gov": IDP,
|
|
12
|
+
"esgf.nci.org.au": IDP,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_FACETS = [
|
|
17
|
+
"project",
|
|
18
|
+
"mip_era",
|
|
19
|
+
"experiment",
|
|
20
|
+
"experiment_id",
|
|
21
|
+
"institute",
|
|
22
|
+
"institution_id",
|
|
23
|
+
"model",
|
|
24
|
+
"table_id",
|
|
25
|
+
"activity_id",
|
|
26
|
+
"ensemble",
|
|
27
|
+
"variant_label",
|
|
28
|
+
"realm",
|
|
29
|
+
"frequency",
|
|
30
|
+
"time_frequency",
|
|
31
|
+
"variable",
|
|
32
|
+
"variable_id",
|
|
33
|
+
"dataset_id",
|
|
34
|
+
"source_id",
|
|
35
|
+
"domain",
|
|
36
|
+
"driving_model",
|
|
37
|
+
"rcm_name",
|
|
38
|
+
"member_id",
|
|
39
|
+
"cmor_table",
|
|
40
|
+
]
|
|
41
|
+
EXTRA_FACETS = [
|
|
42
|
+
"query",
|
|
43
|
+
"start",
|
|
44
|
+
"end",
|
|
45
|
+
# "fields",
|
|
46
|
+
"facets",
|
|
47
|
+
"url",
|
|
48
|
+
"data_node",
|
|
49
|
+
"index_node",
|
|
50
|
+
"master_id",
|
|
51
|
+
"instance_id", # search does not work with instance_id
|
|
52
|
+
"title",
|
|
53
|
+
"variable_long_name",
|
|
54
|
+
"experiment_family",
|
|
55
|
+
]
|
|
56
|
+
DEFAULT_CONSTRAINTS_WITH_VALUE: dict[str, str] = {}
|