esgpull 0.8.0__py3-none-any.whl → 0.9.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.
- esgpull/cli/__init__.py +2 -2
- esgpull/cli/add.py +7 -1
- esgpull/cli/config.py +5 -21
- esgpull/cli/plugins.py +398 -0
- esgpull/cli/update.py +58 -15
- esgpull/cli/utils.py +16 -1
- esgpull/config.py +82 -25
- esgpull/constants.py +3 -0
- esgpull/context.py +9 -9
- esgpull/database.py +8 -2
- esgpull/download.py +3 -0
- esgpull/esgpull.py +49 -5
- esgpull/graph.py +1 -1
- esgpull/migrations/versions/0.9.0_update_tables.py +28 -0
- esgpull/migrations/versions/d14f179e553c_file_add_composite_index_dataset_id_.py +32 -0
- esgpull/migrations/versions/e7edab5d4e4b_add_dataset_tracking.py +39 -0
- esgpull/models/__init__.py +2 -1
- esgpull/models/base.py +31 -14
- esgpull/models/dataset.py +48 -5
- esgpull/models/query.py +58 -14
- esgpull/models/sql.py +40 -9
- esgpull/plugin.py +574 -0
- esgpull/processor.py +3 -3
- esgpull/tui.py +23 -1
- esgpull/utils.py +5 -1
- {esgpull-0.8.0.dist-info → esgpull-0.9.0.dist-info}/METADATA +2 -1
- {esgpull-0.8.0.dist-info → esgpull-0.9.0.dist-info}/RECORD +30 -26
- esgpull/cli/datasets.py +0 -78
- {esgpull-0.8.0.dist-info → esgpull-0.9.0.dist-info}/WHEEL +0 -0
- {esgpull-0.8.0.dist-info → esgpull-0.9.0.dist-info}/entry_points.txt +0 -0
- {esgpull-0.8.0.dist-info → esgpull-0.9.0.dist-info}/licenses/LICENSE +0 -0
esgpull/cli/__init__.py
CHANGED
|
@@ -7,9 +7,9 @@ from esgpull import __version__
|
|
|
7
7
|
from esgpull.cli.add import add
|
|
8
8
|
from esgpull.cli.config import config
|
|
9
9
|
from esgpull.cli.convert import convert
|
|
10
|
-
from esgpull.cli.datasets import datasets
|
|
11
10
|
from esgpull.cli.download import download
|
|
12
11
|
from esgpull.cli.login import login
|
|
12
|
+
from esgpull.cli.plugins import plugins
|
|
13
13
|
from esgpull.cli.remove import remove
|
|
14
14
|
from esgpull.cli.retry import retry
|
|
15
15
|
from esgpull.cli.search import search
|
|
@@ -35,13 +35,13 @@ SUBCOMMANDS: list[click.Command] = [
|
|
|
35
35
|
# autoremove,
|
|
36
36
|
config,
|
|
37
37
|
convert,
|
|
38
|
-
datasets,
|
|
39
38
|
download,
|
|
40
39
|
# facet,
|
|
41
40
|
# get,
|
|
42
41
|
self,
|
|
43
42
|
# install,
|
|
44
43
|
login,
|
|
44
|
+
plugins,
|
|
45
45
|
remove,
|
|
46
46
|
retry,
|
|
47
47
|
search,
|
esgpull/cli/add.py
CHANGED
|
@@ -89,6 +89,7 @@ def add(
|
|
|
89
89
|
esg.ui.print(subgraph)
|
|
90
90
|
empty = Query()
|
|
91
91
|
empty.compute_sha()
|
|
92
|
+
next_steps_queries = []
|
|
92
93
|
for query in queries:
|
|
93
94
|
query.compute_sha()
|
|
94
95
|
esg.graph.resolve_require(query)
|
|
@@ -99,7 +100,8 @@ def add(
|
|
|
99
100
|
esg.ui.print(f"Skipping existing query: {query.rich_name}")
|
|
100
101
|
else:
|
|
101
102
|
esg.graph.add(query)
|
|
102
|
-
|
|
103
|
+
if query.tracked:
|
|
104
|
+
next_steps_queries.append(query)
|
|
103
105
|
new_queries = esg.graph.merge()
|
|
104
106
|
nb = len(new_queries)
|
|
105
107
|
ies = "ies" if nb > 1 else "y"
|
|
@@ -107,4 +109,8 @@ def add(
|
|
|
107
109
|
esg.ui.print(f":+1: {nb} new quer{ies} added.")
|
|
108
110
|
else:
|
|
109
111
|
esg.ui.print(":stop_sign: No new query was added.")
|
|
112
|
+
if next_steps_queries:
|
|
113
|
+
esg.ui.print("\nNext steps:\n")
|
|
114
|
+
for query in next_steps_queries:
|
|
115
|
+
esg.ui.print(f"\tesgpull update {query.sha[:6]}")
|
|
110
116
|
esg.ui.raise_maybe_record(Exit(0))
|
esgpull/cli/config.py
CHANGED
|
@@ -4,26 +4,11 @@ import click
|
|
|
4
4
|
from click.exceptions import Abort, BadOptionUsage, Exit
|
|
5
5
|
|
|
6
6
|
from esgpull.cli.decorators import args, opts
|
|
7
|
-
from esgpull.cli.utils import init_esgpull
|
|
7
|
+
from esgpull.cli.utils import extract_subdict, init_esgpull
|
|
8
8
|
from esgpull.config import ConfigKind
|
|
9
9
|
from esgpull.tui import Verbosity
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def extract_command(doc: dict, key: str | None) -> dict:
|
|
13
|
-
if key is None:
|
|
14
|
-
return doc
|
|
15
|
-
for part in key.split("."):
|
|
16
|
-
if not part:
|
|
17
|
-
raise KeyError(key)
|
|
18
|
-
elif part in doc:
|
|
19
|
-
doc = doc[part]
|
|
20
|
-
else:
|
|
21
|
-
raise KeyError(part)
|
|
22
|
-
for part in key.split(".")[::-1]:
|
|
23
|
-
doc = {part: doc}
|
|
24
|
-
return doc
|
|
25
|
-
|
|
26
|
-
|
|
27
12
|
@click.command()
|
|
28
13
|
@args.key
|
|
29
14
|
@args.value
|
|
@@ -73,7 +58,7 @@ def config(
|
|
|
73
58
|
)
|
|
74
59
|
kind = esg.config.kind
|
|
75
60
|
old_value = esg.config.update_item(key, value, empty_ok=True)
|
|
76
|
-
info =
|
|
61
|
+
info = extract_subdict(esg.config.dump(), key)
|
|
77
62
|
esg.config.write()
|
|
78
63
|
esg.ui.print(info, toml=True)
|
|
79
64
|
if kind == ConfigKind.NoFile:
|
|
@@ -86,12 +71,12 @@ def config(
|
|
|
86
71
|
elif key is not None:
|
|
87
72
|
if default:
|
|
88
73
|
old_value = esg.config.set_default(key)
|
|
89
|
-
info =
|
|
74
|
+
info = extract_subdict(esg.config.dump(), key)
|
|
90
75
|
esg.config.write()
|
|
91
76
|
esg.ui.print(info, toml=True)
|
|
92
77
|
esg.ui.print(f"Previous value: {old_value}")
|
|
93
78
|
else:
|
|
94
|
-
info =
|
|
79
|
+
info = extract_subdict(esg.config.dump(), key)
|
|
95
80
|
esg.ui.print(info, toml=True)
|
|
96
81
|
elif generate:
|
|
97
82
|
overwrite = False
|
|
@@ -102,8 +87,7 @@ def config(
|
|
|
102
87
|
)
|
|
103
88
|
esg.ui.raise_maybe_record(Exit(0))
|
|
104
89
|
elif esg.config.kind == ConfigKind.Partial and esg.ui.ask(
|
|
105
|
-
"A config file already exists,"
|
|
106
|
-
" fill it with missing defaults?",
|
|
90
|
+
"A config file already exists, fill it with missing defaults?",
|
|
107
91
|
default=False,
|
|
108
92
|
):
|
|
109
93
|
overwrite = True
|
esgpull/cli/plugins.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from textwrap import dedent
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from click.exceptions import Abort, BadOptionUsage
|
|
6
|
+
|
|
7
|
+
from esgpull import Esgpull
|
|
8
|
+
from esgpull.cli.decorators import args, opts
|
|
9
|
+
from esgpull.cli.utils import extract_subdict, init_esgpull, totable
|
|
10
|
+
from esgpull.models import Dataset, File, FileStatus
|
|
11
|
+
from esgpull.plugin import Event, emit
|
|
12
|
+
from esgpull.tui import Verbosity
|
|
13
|
+
from esgpull.version import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def plugins():
|
|
18
|
+
"""Manage plugins."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_enabled(esg: Esgpull):
|
|
23
|
+
if not esg.plugin_manager.enabled:
|
|
24
|
+
esg.ui.print("Plugin system is [red]disabled[/]")
|
|
25
|
+
esg.ui.print("To enable it, run:")
|
|
26
|
+
esg.ui.print(" esgpull config plugins.enabled true")
|
|
27
|
+
raise Abort
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@plugins.command("ls")
|
|
31
|
+
@click.option(
|
|
32
|
+
"--json", "json_output", is_flag=True, help="Output in JSON format"
|
|
33
|
+
)
|
|
34
|
+
@opts.verbosity
|
|
35
|
+
def list_plugins(verbosity: Verbosity, json_output: bool = False):
|
|
36
|
+
"""List all available plugins and their status."""
|
|
37
|
+
esg = init_esgpull(verbosity=verbosity)
|
|
38
|
+
|
|
39
|
+
with esg.ui.logging("plugins", onraise=Abort):
|
|
40
|
+
check_enabled(esg)
|
|
41
|
+
esg.plugin_manager.discover_plugins(
|
|
42
|
+
esg.config.paths.plugins, load_all=True
|
|
43
|
+
)
|
|
44
|
+
if json_output:
|
|
45
|
+
# Format for JSON output
|
|
46
|
+
result = {}
|
|
47
|
+
for name, p in esg.plugin_manager.plugins.items():
|
|
48
|
+
# Get handlers by event type
|
|
49
|
+
handlers = {}
|
|
50
|
+
for h in p.handlers:
|
|
51
|
+
event_type = h.event.value
|
|
52
|
+
if event_type not in handlers:
|
|
53
|
+
handlers[event_type] = []
|
|
54
|
+
handlers[event_type].append(
|
|
55
|
+
{
|
|
56
|
+
"function": h.func.__name__,
|
|
57
|
+
"priority": h.priority,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
result[name] = {
|
|
62
|
+
"enabled": esg.plugin_manager.is_plugin_enabled(name),
|
|
63
|
+
"handlers": handlers,
|
|
64
|
+
"min_version": p.min_version,
|
|
65
|
+
"max_version": p.max_version,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
esg.ui.print(result, json=True)
|
|
69
|
+
else:
|
|
70
|
+
# Format for human-readable output
|
|
71
|
+
if not esg.plugin_manager.plugins:
|
|
72
|
+
esg.ui.print("No plugins found.")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Prepare table data
|
|
76
|
+
table_data = []
|
|
77
|
+
for name, p in esg.plugin_manager.plugins.items():
|
|
78
|
+
enabled = esg.plugin_manager.is_plugin_enabled(name)
|
|
79
|
+
status_circle = "🟢" if enabled else "🔴"
|
|
80
|
+
plugin_name = f"{status_circle} {name}"
|
|
81
|
+
|
|
82
|
+
# Collect event handlers with their details
|
|
83
|
+
handler_rows = []
|
|
84
|
+
events_by_type = {}
|
|
85
|
+
for h in p.handlers:
|
|
86
|
+
event_type = h.event.value
|
|
87
|
+
if event_type not in events_by_type:
|
|
88
|
+
events_by_type[event_type] = []
|
|
89
|
+
events_by_type[event_type].append(h.func.__name__)
|
|
90
|
+
|
|
91
|
+
# Create handler rows with event type and function names
|
|
92
|
+
for event_type, handler_names in events_by_type.items():
|
|
93
|
+
for handler_name in handler_names:
|
|
94
|
+
handler_rows.append(
|
|
95
|
+
{"event": event_type, "function": handler_name}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# First row with plugin info and first handler
|
|
99
|
+
if handler_rows:
|
|
100
|
+
first_handler = handler_rows[0]
|
|
101
|
+
first_row = OrderedDict(
|
|
102
|
+
[
|
|
103
|
+
("plugin", plugin_name),
|
|
104
|
+
("event", first_handler["event"]),
|
|
105
|
+
("function", first_handler["function"]),
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
table_data.append(first_row)
|
|
109
|
+
|
|
110
|
+
# Additional rows for remaining handlers
|
|
111
|
+
for handler in handler_rows[1:]:
|
|
112
|
+
additional_row = OrderedDict(
|
|
113
|
+
[
|
|
114
|
+
("plugin", ""),
|
|
115
|
+
("event", handler["event"]),
|
|
116
|
+
("function", handler["function"]),
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
table_data.append(additional_row)
|
|
120
|
+
else:
|
|
121
|
+
# Plugin with no handlers
|
|
122
|
+
row = OrderedDict(
|
|
123
|
+
[
|
|
124
|
+
("plugin", plugin_name),
|
|
125
|
+
("event", ""),
|
|
126
|
+
("function", ""),
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
table_data.append(row)
|
|
130
|
+
|
|
131
|
+
# Create and print table
|
|
132
|
+
table = totable(table_data)
|
|
133
|
+
esg.ui.print(table)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@plugins.command("enable")
|
|
137
|
+
@click.argument("plugin_name")
|
|
138
|
+
@opts.verbosity
|
|
139
|
+
def enable_plugin_cmd(plugin_name: str, verbosity: Verbosity):
|
|
140
|
+
"""Enable a plugin."""
|
|
141
|
+
esg = init_esgpull(verbosity=verbosity)
|
|
142
|
+
plugin_path = esg.config.paths.plugins / f"{plugin_name}.py"
|
|
143
|
+
if not plugin_path.exists():
|
|
144
|
+
esg.ui.print(f"Plugin file '{plugin_name}.py' not found.")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
result = esg.plugin_manager.enable_plugin(plugin_name)
|
|
148
|
+
if result:
|
|
149
|
+
esg.ui.print(f"Plugin '{plugin_name}' enabled.")
|
|
150
|
+
else:
|
|
151
|
+
esg.ui.print(f"Failed to enable plugin '{plugin_name}'.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@plugins.command("disable")
|
|
155
|
+
@click.argument("plugin_name")
|
|
156
|
+
@opts.verbosity
|
|
157
|
+
def disable_plugin_cmd(plugin_name: str, verbosity: Verbosity):
|
|
158
|
+
"""Disable a plugin."""
|
|
159
|
+
esg = init_esgpull(verbosity=verbosity)
|
|
160
|
+
|
|
161
|
+
# Check if the plugin file exists (even if not loaded)
|
|
162
|
+
plugin_path = esg.config.paths.plugins / f"{plugin_name}.py"
|
|
163
|
+
if not plugin_path.exists():
|
|
164
|
+
esg.ui.print(f"Plugin file '{plugin_name}.py' not found.")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
result = esg.plugin_manager.disable_plugin(plugin_name)
|
|
168
|
+
if result:
|
|
169
|
+
esg.ui.print(f"Plugin '{plugin_name}' disabled.")
|
|
170
|
+
else:
|
|
171
|
+
esg.ui.print(f"Failed to disable plugin '{plugin_name}'.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@plugins.command("config")
|
|
175
|
+
@args.key
|
|
176
|
+
@args.value
|
|
177
|
+
@opts.default
|
|
178
|
+
@opts.generate
|
|
179
|
+
@opts.verbosity
|
|
180
|
+
def config_plugin(
|
|
181
|
+
verbosity: Verbosity,
|
|
182
|
+
key: str | None,
|
|
183
|
+
value: str | None,
|
|
184
|
+
default: bool,
|
|
185
|
+
generate: bool,
|
|
186
|
+
# config_set: str | None = None,
|
|
187
|
+
# config_unset: str | None = None,
|
|
188
|
+
):
|
|
189
|
+
"""View or modify plugin configuration."""
|
|
190
|
+
esg = init_esgpull(verbosity=verbosity)
|
|
191
|
+
|
|
192
|
+
with esg.ui.logging("plugins", onraise=Abort):
|
|
193
|
+
check_enabled(esg)
|
|
194
|
+
esg.plugin_manager.discover_plugins(
|
|
195
|
+
esg.config.paths.plugins, load_all=True
|
|
196
|
+
)
|
|
197
|
+
if key is not None:
|
|
198
|
+
plugin_name = key.split(".", 1)[0]
|
|
199
|
+
plugin_path = esg.config.paths.plugins / f"{plugin_name}.py"
|
|
200
|
+
if not plugin_path.exists():
|
|
201
|
+
esg.ui.print(f"Plugin file '{plugin_name}.py' not found.")
|
|
202
|
+
raise Abort
|
|
203
|
+
if key is not None and value is not None:
|
|
204
|
+
"""update config"""
|
|
205
|
+
if default:
|
|
206
|
+
raise BadOptionUsage(
|
|
207
|
+
"default",
|
|
208
|
+
dedent(
|
|
209
|
+
f"""
|
|
210
|
+
--default/-d is invalid with a value.
|
|
211
|
+
Instead use:
|
|
212
|
+
|
|
213
|
+
$ esgpull plugins config {key} -d
|
|
214
|
+
"""
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
# Split key into plugin_name and remaining_path
|
|
218
|
+
plugin_name, *rest = key.split(".", 1)
|
|
219
|
+
remaining_path = rest[0] if rest else None
|
|
220
|
+
|
|
221
|
+
# Set the configuration value
|
|
222
|
+
if remaining_path:
|
|
223
|
+
old_value = esg.plugin_manager.set_plugin_config(
|
|
224
|
+
plugin_name, remaining_path, value
|
|
225
|
+
)
|
|
226
|
+
info = extract_subdict(esg.plugin_manager.config.plugins, key)
|
|
227
|
+
esg.ui.print(info, toml=True)
|
|
228
|
+
esg.ui.print(f"Previous value: {old_value}")
|
|
229
|
+
else:
|
|
230
|
+
esg.ui.print(f"Cannot set plugin name directly: {plugin_name}")
|
|
231
|
+
raise Abort
|
|
232
|
+
elif key is not None:
|
|
233
|
+
if default:
|
|
234
|
+
# Split key into plugin_name and remaining_path
|
|
235
|
+
plugin_name, *rest = key.split(".", 1)
|
|
236
|
+
remaining_path = rest[0] if rest else None
|
|
237
|
+
|
|
238
|
+
if remaining_path:
|
|
239
|
+
esg.plugin_manager.unset_plugin_config(
|
|
240
|
+
plugin_name, remaining_path
|
|
241
|
+
)
|
|
242
|
+
msg = f":+1: Config reset to default for {key}"
|
|
243
|
+
esg.ui.print(msg)
|
|
244
|
+
else:
|
|
245
|
+
esg.ui.print(
|
|
246
|
+
f"Cannot reset plugin directly: {plugin_name}"
|
|
247
|
+
)
|
|
248
|
+
raise Abort
|
|
249
|
+
else:
|
|
250
|
+
config = esg.plugin_manager.config.plugins
|
|
251
|
+
config = extract_subdict(config, key)
|
|
252
|
+
esg.ui.print(config, toml=True)
|
|
253
|
+
elif generate:
|
|
254
|
+
# Write the config file with all current settings
|
|
255
|
+
esg.plugin_manager.write_config(generate_full_config=True)
|
|
256
|
+
msg = f":+1: Plugin config generated at {esg.plugin_manager.config_path}"
|
|
257
|
+
esg.ui.print(msg)
|
|
258
|
+
|
|
259
|
+
else:
|
|
260
|
+
esg.ui.rule(str(esg.plugin_manager.config_path))
|
|
261
|
+
esg.ui.print(esg.plugin_manager.config.plugins, toml=True)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@plugins.command("test")
|
|
265
|
+
@click.argument(
|
|
266
|
+
"event_type",
|
|
267
|
+
type=click.Choice([e.value for e in Event]),
|
|
268
|
+
)
|
|
269
|
+
@opts.verbosity
|
|
270
|
+
def test_plugin(
|
|
271
|
+
event_type: str,
|
|
272
|
+
verbosity: Verbosity,
|
|
273
|
+
):
|
|
274
|
+
"""Test plugin events."""
|
|
275
|
+
esg = init_esgpull(verbosity=verbosity)
|
|
276
|
+
with esg.ui.logging("plugins", onraise=Abort):
|
|
277
|
+
check_enabled(esg)
|
|
278
|
+
event = Event(event_type)
|
|
279
|
+
# Create sample test data
|
|
280
|
+
file1 = File(
|
|
281
|
+
file_id="file1",
|
|
282
|
+
dataset_id="dataset",
|
|
283
|
+
master_id="master",
|
|
284
|
+
url="file",
|
|
285
|
+
version="v0",
|
|
286
|
+
filename="file.nc",
|
|
287
|
+
local_path="project/folder",
|
|
288
|
+
data_node="data_node",
|
|
289
|
+
checksum="0",
|
|
290
|
+
checksum_type="0",
|
|
291
|
+
size=2**42,
|
|
292
|
+
status=FileStatus.Queued,
|
|
293
|
+
)
|
|
294
|
+
file2 = file1.clone()
|
|
295
|
+
file2.file_id = "file2"
|
|
296
|
+
file2.status = FileStatus.Done
|
|
297
|
+
error = ValueError("Placeholder example error")
|
|
298
|
+
dataset = Dataset(dataset_id="dataset", total_files=2)
|
|
299
|
+
dataset.files = [file1, file2]
|
|
300
|
+
emit(
|
|
301
|
+
event,
|
|
302
|
+
file=file1,
|
|
303
|
+
dataset=dataset,
|
|
304
|
+
error=error,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@plugins.command("create")
|
|
309
|
+
@click.argument(
|
|
310
|
+
"events",
|
|
311
|
+
nargs=-1,
|
|
312
|
+
type=click.Choice([e.value for e in Event]),
|
|
313
|
+
)
|
|
314
|
+
@click.option("-n", "--name", required=True, type=str)
|
|
315
|
+
@opts.verbosity
|
|
316
|
+
def create_plugin(
|
|
317
|
+
name: str, verbosity: Verbosity, events: list[str] | None = None
|
|
318
|
+
):
|
|
319
|
+
"""Create a new plugin template."""
|
|
320
|
+
esg = init_esgpull(verbosity=verbosity)
|
|
321
|
+
|
|
322
|
+
# Create plugin file path
|
|
323
|
+
plugin_path = esg.config.paths.plugins / f"{name}.py"
|
|
324
|
+
if plugin_path.exists():
|
|
325
|
+
esg.ui.print(f"Plugin file already exists: {plugin_path}")
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# If no events specified, include them all
|
|
329
|
+
if not events:
|
|
330
|
+
events = [e.value for e in Event]
|
|
331
|
+
|
|
332
|
+
# Generate template
|
|
333
|
+
template = f"""# {name} plugin for ESGPull
|
|
334
|
+
#
|
|
335
|
+
# This plugin was auto-generated. Edit as needed.
|
|
336
|
+
from datetime import datetime
|
|
337
|
+
from pathlib import Path
|
|
338
|
+
from logging import Logger
|
|
339
|
+
|
|
340
|
+
from esgpull.models import File, Query
|
|
341
|
+
from esgpull.plugin import Event, on
|
|
342
|
+
|
|
343
|
+
# Specify version compatibility (optional)
|
|
344
|
+
MIN_ESGPULL_VERSION = "{__version__}"
|
|
345
|
+
MAX_ESGPULL_VERSION = None
|
|
346
|
+
|
|
347
|
+
# Configuration class for the plugin (optional)
|
|
348
|
+
class Config:
|
|
349
|
+
\"""Configuration for {name} plugin\"""
|
|
350
|
+
# Add your configuration options here
|
|
351
|
+
timeout = 30 # Example config option
|
|
352
|
+
log_level = "INFO" # Example config option
|
|
353
|
+
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
# Add handlers for requested events
|
|
357
|
+
handlers = {
|
|
358
|
+
"file_complete": """
|
|
359
|
+
# File complete event handler
|
|
360
|
+
@on(Event.file_complete, priority="normal")
|
|
361
|
+
def handle_file_complete(
|
|
362
|
+
file: File,
|
|
363
|
+
destination: Path,
|
|
364
|
+
start_time: datetime,
|
|
365
|
+
end_time: datetime,
|
|
366
|
+
logger: Logger
|
|
367
|
+
):
|
|
368
|
+
\"""Handle file complete event\"""
|
|
369
|
+
logger.info(f"File downloaded: {file.filename}")
|
|
370
|
+
# Add your custom logic here
|
|
371
|
+
""",
|
|
372
|
+
"file_error": """
|
|
373
|
+
# Download error event handler
|
|
374
|
+
@on(Event.file_error, priority="normal")
|
|
375
|
+
def handle_file_error(file: File, exception: Exception, logger: Logger):
|
|
376
|
+
\"""Handle file error event\"""
|
|
377
|
+
logger.error(f"Download failed for {file.filename}: {exception}")
|
|
378
|
+
# Add your custom logic here
|
|
379
|
+
""",
|
|
380
|
+
"dataset_complete": """
|
|
381
|
+
# Dataset complete event handler
|
|
382
|
+
@on(Event.dataset_complete, priority="normal")
|
|
383
|
+
def handle_dataset_complete(dataset: Dataset, logger: Logger):
|
|
384
|
+
\"""Handle dataset complete event\"""
|
|
385
|
+
logger.error(f"Dataset downloaded: {dataset.dataset_id}")
|
|
386
|
+
# Add your custom logic here
|
|
387
|
+
""",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for event in events:
|
|
391
|
+
template += handlers[event]
|
|
392
|
+
|
|
393
|
+
# Write the plugin file
|
|
394
|
+
with open(plugin_path, "w") as f:
|
|
395
|
+
f.write(template)
|
|
396
|
+
|
|
397
|
+
esg.ui.print(f"Plugin template created at: {plugin_path}")
|
|
398
|
+
esg.ui.print("Edit the file to implement your custom plugin logic.")
|
esgpull/cli/update.py
CHANGED
|
@@ -10,7 +10,7 @@ from esgpull.cli.decorators import args, opts
|
|
|
10
10
|
from esgpull.cli.utils import get_queries, init_esgpull, valid_name_tag
|
|
11
11
|
from esgpull.context import HintsDict, ResultSearch
|
|
12
12
|
from esgpull.exceptions import UnsetOptionsError
|
|
13
|
-
from esgpull.models import File, FileStatus, Query
|
|
13
|
+
from esgpull.models import Dataset, File, FileStatus, Query
|
|
14
14
|
from esgpull.tui import Verbosity, logger
|
|
15
15
|
from esgpull.utils import format_size
|
|
16
16
|
|
|
@@ -20,10 +20,13 @@ class QueryFiles:
|
|
|
20
20
|
query: Query
|
|
21
21
|
expanded: Query
|
|
22
22
|
skip: bool = False
|
|
23
|
+
datasets: list[Dataset] = field(default_factory=list)
|
|
23
24
|
files: list[File] = field(default_factory=list)
|
|
25
|
+
dataset_hits: int = field(init=False)
|
|
24
26
|
hits: int = field(init=False)
|
|
25
27
|
hints: HintsDict = field(init=False)
|
|
26
28
|
results: list[ResultSearch] = field(init=False)
|
|
29
|
+
dataset_results: list[ResultSearch] = field(init=False)
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
@click.command()
|
|
@@ -80,20 +83,27 @@ def update(
|
|
|
80
83
|
file=True,
|
|
81
84
|
facets=["index_node"],
|
|
82
85
|
)
|
|
83
|
-
|
|
86
|
+
dataset_hits = esg.context.hits(
|
|
87
|
+
*[qf.expanded for qf in qfs],
|
|
88
|
+
file=False,
|
|
89
|
+
)
|
|
90
|
+
for qf, qf_hints, qf_dataset_hits in zip(qfs, hints, dataset_hits):
|
|
84
91
|
qf.hits = sum(esg.context.hits_from_hints(qf_hints))
|
|
85
92
|
if qf_hints:
|
|
86
93
|
qf.hints = qf_hints
|
|
94
|
+
qf.dataset_hits = qf_dataset_hits
|
|
87
95
|
else:
|
|
88
96
|
qf.skip = True
|
|
89
97
|
for qf in qfs:
|
|
90
98
|
s = "s" if qf.hits > 1 else ""
|
|
91
|
-
esg.ui.print(
|
|
99
|
+
esg.ui.print(
|
|
100
|
+
f"{qf.query.rich_name} -> {qf.hits} file{s} (before replica de-duplication)."
|
|
101
|
+
)
|
|
92
102
|
total_hits = sum([qf.hits for qf in qfs])
|
|
93
103
|
if total_hits == 0:
|
|
94
104
|
esg.ui.print("No files found.")
|
|
95
105
|
esg.ui.raise_maybe_record(Exit(0))
|
|
96
|
-
|
|
106
|
+
elif len(qfs) > 1:
|
|
97
107
|
esg.ui.print(f"{total_hits} files found.")
|
|
98
108
|
qfs = [qf for qf in qfs if not qf.skip]
|
|
99
109
|
# Prepare optimally distributed requests to ESGF
|
|
@@ -101,6 +111,12 @@ def update(
|
|
|
101
111
|
# It might be interesting for the special case where all files already
|
|
102
112
|
# exist in db, then the detailed fetch could be skipped.
|
|
103
113
|
for qf in qfs:
|
|
114
|
+
qf_dataset_results = esg.context.prepare_search(
|
|
115
|
+
qf.expanded,
|
|
116
|
+
file=False,
|
|
117
|
+
hits=[qf.dataset_hits],
|
|
118
|
+
max_hits=None,
|
|
119
|
+
)
|
|
104
120
|
if esg.config.api.use_custom_distribution_algorithm:
|
|
105
121
|
qf_results = esg.context.prepare_search_distributed(
|
|
106
122
|
qf.expanded,
|
|
@@ -115,7 +131,7 @@ def update(
|
|
|
115
131
|
hits=[qf.hits],
|
|
116
132
|
max_hits=None,
|
|
117
133
|
)
|
|
118
|
-
nb_req = len(qf_results)
|
|
134
|
+
nb_req = len(qf_dataset_results) + len(qf_results)
|
|
119
135
|
if nb_req > 50:
|
|
120
136
|
msg = (
|
|
121
137
|
f"{nb_req} requests will be sent to ESGF to"
|
|
@@ -126,13 +142,32 @@ def update(
|
|
|
126
142
|
esg.ui.print(f"{qf.query.rich_name} is now untracked.")
|
|
127
143
|
qf.query.tracked = False
|
|
128
144
|
qf_results = []
|
|
145
|
+
qf_dataset_results = []
|
|
129
146
|
case "n":
|
|
130
147
|
qf_results = []
|
|
148
|
+
qf_dataset_results = []
|
|
131
149
|
case _:
|
|
132
150
|
...
|
|
133
151
|
qf.results = qf_results
|
|
152
|
+
qf.dataset_results = qf_dataset_results
|
|
134
153
|
# Fetch files and update db
|
|
135
154
|
# [?] TODO: dry_run to print urls here
|
|
155
|
+
with esg.ui.spinner("Fetching datasets"):
|
|
156
|
+
coros = []
|
|
157
|
+
for qf in qfs:
|
|
158
|
+
coro = esg.context._datasets(
|
|
159
|
+
*qf.dataset_results, keep_duplicates=False
|
|
160
|
+
)
|
|
161
|
+
coros.append(coro)
|
|
162
|
+
datasets = esg.context.sync_gather(*coros)
|
|
163
|
+
for qf, qf_datasets in zip(qfs, datasets):
|
|
164
|
+
qf.datasets = [
|
|
165
|
+
Dataset(
|
|
166
|
+
dataset_id=record.dataset_id,
|
|
167
|
+
total_files=record.number_of_files,
|
|
168
|
+
)
|
|
169
|
+
for record in qf_datasets
|
|
170
|
+
]
|
|
136
171
|
with esg.ui.spinner("Fetching files"):
|
|
137
172
|
coros = []
|
|
138
173
|
for qf in qfs:
|
|
@@ -142,23 +177,26 @@ def update(
|
|
|
142
177
|
for qf, qf_files in zip(qfs, files):
|
|
143
178
|
qf.files = qf_files
|
|
144
179
|
for qf in qfs:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if file.sha not in shas:
|
|
149
|
-
new_files.append(file)
|
|
180
|
+
new_files = [f for f in qf.files if f not in esg.db]
|
|
181
|
+
new_datasets = [d for d in qf.datasets if d not in esg.db]
|
|
182
|
+
nb_datasets = len(new_datasets)
|
|
150
183
|
nb_files = len(new_files)
|
|
151
184
|
if not qf.query.tracked:
|
|
152
185
|
esg.db.add(qf.query)
|
|
153
186
|
continue
|
|
154
|
-
elif nb_files == 0:
|
|
187
|
+
elif nb_datasets == nb_files == 0:
|
|
155
188
|
esg.ui.print(f"{qf.query.rich_name} is already up-to-date.")
|
|
156
189
|
continue
|
|
157
190
|
size = sum([file.size for file in new_files])
|
|
191
|
+
if size > 0:
|
|
192
|
+
queue_msg = " and send new files to download queue"
|
|
193
|
+
else:
|
|
194
|
+
queue_msg = ""
|
|
158
195
|
msg = (
|
|
159
|
-
f"\
|
|
160
|
-
f"
|
|
161
|
-
"
|
|
196
|
+
f"\n{qf.query.rich_name}: {nb_files} new"
|
|
197
|
+
f" files, {nb_datasets} new datasets"
|
|
198
|
+
f" ({format_size(size)})."
|
|
199
|
+
f"\nUpdate the database{queue_msg}?"
|
|
162
200
|
)
|
|
163
201
|
if yes:
|
|
164
202
|
choice = "y"
|
|
@@ -175,9 +213,14 @@ def update(
|
|
|
175
213
|
legacy = esg.legacy_query
|
|
176
214
|
has_legacy = legacy.state.persistent
|
|
177
215
|
with esg.db.commit_context():
|
|
216
|
+
for dataset in esg.ui.track(
|
|
217
|
+
new_datasets,
|
|
218
|
+
description=f"{qf.query.rich_name} (datasets)",
|
|
219
|
+
):
|
|
220
|
+
esg.db.session.add(dataset)
|
|
178
221
|
for file in esg.ui.track(
|
|
179
222
|
new_files,
|
|
180
|
-
description=qf.query.rich_name,
|
|
223
|
+
description=f"{qf.query.rich_name} (files)",
|
|
181
224
|
):
|
|
182
225
|
file_db = esg.db.get(File, file.sha)
|
|
183
226
|
if file_db is None:
|
esgpull/cli/utils.py
CHANGED
|
@@ -103,7 +103,7 @@ def totable(docs: list[OrderedDict[str, Any]]) -> Table:
|
|
|
103
103
|
table = Table(box=MINIMAL_DOUBLE_HEAD, show_edge=False)
|
|
104
104
|
for key in docs[0].keys():
|
|
105
105
|
justify: Literal["left", "right", "center"]
|
|
106
|
-
if key in ["file", "dataset"]:
|
|
106
|
+
if key in ["file", "dataset", "plugin"]:
|
|
107
107
|
justify = "left"
|
|
108
108
|
else:
|
|
109
109
|
justify = "right"
|
|
@@ -243,3 +243,18 @@ def get_queries(
|
|
|
243
243
|
kids = graph.get_all_children(query.sha)
|
|
244
244
|
queries.extend(kids)
|
|
245
245
|
return queries
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def extract_subdict(doc: dict, key: str | None) -> dict:
|
|
249
|
+
if key is None:
|
|
250
|
+
return doc
|
|
251
|
+
for part in key.split("."):
|
|
252
|
+
if not part:
|
|
253
|
+
raise KeyError(key)
|
|
254
|
+
elif part in doc:
|
|
255
|
+
doc = doc[part]
|
|
256
|
+
else:
|
|
257
|
+
raise KeyError(part)
|
|
258
|
+
for part in key.split(".")[::-1]:
|
|
259
|
+
doc = {part: doc}
|
|
260
|
+
return doc
|