esgpull 0.7.3__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/show.py +29 -0
- esgpull/cli/status.py +6 -4
- esgpull/cli/update.py +72 -18
- esgpull/cli/utils.py +16 -1
- esgpull/config.py +83 -25
- esgpull/constants.py +3 -0
- esgpull/context.py +15 -15
- esgpull/database.py +8 -2
- esgpull/download.py +3 -0
- esgpull/esgpull.py +49 -5
- esgpull/graph.py +1 -1
- esgpull/migrations/versions/0.8.0_update_tables.py +28 -0
- esgpull/migrations/versions/0.9.0_update_tables.py +28 -0
- esgpull/migrations/versions/14c72daea083_query_add_column_updated_at.py +36 -0
- esgpull/migrations/versions/c7c8541fa741_query_add_column_added_at.py +37 -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/options.py +1 -1
- esgpull/models/query.py +98 -15
- esgpull/models/sql.py +40 -9
- esgpull/plugin.py +574 -0
- esgpull/processor.py +3 -3
- esgpull/tui.py +23 -1
- esgpull/utils.py +19 -3
- {esgpull-0.7.3.dist-info → esgpull-0.9.0.dist-info}/METADATA +11 -2
- {esgpull-0.7.3.dist-info → esgpull-0.9.0.dist-info}/RECORD +36 -29
- {esgpull-0.7.3.dist-info → esgpull-0.9.0.dist-info}/WHEEL +1 -1
- esgpull/cli/datasets.py +0 -78
- {esgpull-0.7.3.dist-info → esgpull-0.9.0.dist-info}/entry_points.txt +0 -0
- {esgpull-0.7.3.dist-info → esgpull-0.9.0.dist-info}/licenses/LICENSE +0 -0
esgpull/plugin.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Literal
|
|
12
|
+
|
|
13
|
+
import tomlkit
|
|
14
|
+
from packaging import version
|
|
15
|
+
|
|
16
|
+
import esgpull.models
|
|
17
|
+
from esgpull.config import cast_value
|
|
18
|
+
from esgpull.tui import logger
|
|
19
|
+
from esgpull.version import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Define event types
|
|
23
|
+
class Event(str, Enum):
|
|
24
|
+
file_complete = "file_complete"
|
|
25
|
+
file_error = "file_error"
|
|
26
|
+
dataset_complete = "dataset_complete"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HandlerSpec:
|
|
31
|
+
event: Event
|
|
32
|
+
func: Callable
|
|
33
|
+
source: str
|
|
34
|
+
parameters: list[inspect.Parameter]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
EventSpecs: dict[Event, HandlerSpec] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def spec(event: Event):
|
|
41
|
+
def decorator(func: Callable):
|
|
42
|
+
global EventSpecs
|
|
43
|
+
source = inspect.getsource(func)
|
|
44
|
+
sig = inspect.signature(func)
|
|
45
|
+
parameters = list(sig.parameters.values())
|
|
46
|
+
EventSpecs[event] = HandlerSpec(
|
|
47
|
+
event=event,
|
|
48
|
+
func=func,
|
|
49
|
+
source=source,
|
|
50
|
+
parameters=parameters,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return decorator
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@spec(Event.file_complete)
|
|
57
|
+
def my_file_complete(
|
|
58
|
+
file: esgpull.models.File,
|
|
59
|
+
destination: Path,
|
|
60
|
+
start_time: datetime,
|
|
61
|
+
end_time: datetime,
|
|
62
|
+
logger: logging.Logger,
|
|
63
|
+
):
|
|
64
|
+
"""Spec for Event.file_complete handler."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@spec(Event.file_error)
|
|
68
|
+
def my_file_error(
|
|
69
|
+
file: esgpull.models.File,
|
|
70
|
+
exception: Exception,
|
|
71
|
+
logger: logging.Logger,
|
|
72
|
+
):
|
|
73
|
+
"""Spec for Event.file_error handler."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@spec(Event.dataset_complete)
|
|
77
|
+
def my_dataset_complete(
|
|
78
|
+
dataset: esgpull.models.Dataset,
|
|
79
|
+
logger: logging.Logger,
|
|
80
|
+
):
|
|
81
|
+
"""Spec for Event.dataset_complete handler."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class EventHandler:
|
|
86
|
+
"""Represents a registered event handler function"""
|
|
87
|
+
|
|
88
|
+
func: Callable
|
|
89
|
+
event: Event
|
|
90
|
+
plugin_name: str
|
|
91
|
+
priority: Literal["low", "normal", "high"] = "normal"
|
|
92
|
+
|
|
93
|
+
def validate_signature(self):
|
|
94
|
+
"""Validate handler signature based on parameter names"""
|
|
95
|
+
sig = inspect.signature(self.func)
|
|
96
|
+
spec = EventSpecs[self.event]
|
|
97
|
+
checked: set[str] = set()
|
|
98
|
+
for param in spec.parameters:
|
|
99
|
+
if param.name not in sig.parameters:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Handler for {self.event} must have '{param.name}' parameter"
|
|
102
|
+
)
|
|
103
|
+
checked.add(param.name)
|
|
104
|
+
for name, param in sig.parameters.items():
|
|
105
|
+
if name in checked:
|
|
106
|
+
continue
|
|
107
|
+
if param.default == inspect._empty:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Handler cannot have extra parameters without default values: {param.name}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Plugin:
|
|
115
|
+
"""Represents a loaded plugin"""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
module: Any
|
|
119
|
+
handlers: list[EventHandler] = field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def config_class(self) -> type | None:
|
|
123
|
+
"""Get the plugin's Config class if it exists"""
|
|
124
|
+
return getattr(self.module, "Config", None)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def min_version(self) -> str | None:
|
|
128
|
+
"""Get the plugin's minimum compatible version"""
|
|
129
|
+
return getattr(self.module, "MIN_ESGPULL_VERSION", None)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def max_version(self) -> str | None:
|
|
133
|
+
"""Get the plugin's maximum compatible version"""
|
|
134
|
+
return getattr(self.module, "MAX_ESGPULL_VERSION", None)
|
|
135
|
+
|
|
136
|
+
def is_compatible(self) -> bool:
|
|
137
|
+
"""Check if the plugin is compatible with the current app version"""
|
|
138
|
+
current = version.parse(__version__)
|
|
139
|
+
|
|
140
|
+
if self.min_version and current < version.parse(self.min_version):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
if self.max_version and current > version.parse(self.max_version):
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class PluginConfig:
|
|
151
|
+
enabled: set[str] = field(default_factory=set)
|
|
152
|
+
disabled: set[str] = field(default_factory=set)
|
|
153
|
+
plugins: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
154
|
+
_raw: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PluginManager:
|
|
158
|
+
"""Manages plugin discovery, loading and execution"""
|
|
159
|
+
|
|
160
|
+
config_path: Path
|
|
161
|
+
enabled: bool
|
|
162
|
+
plugins: dict[str, Plugin]
|
|
163
|
+
config: PluginConfig
|
|
164
|
+
|
|
165
|
+
def __init__(self, config_path: Path):
|
|
166
|
+
self.plugins = {}
|
|
167
|
+
self._handlers_by_event: dict[Event, list[EventHandler]] = {
|
|
168
|
+
event_type: [] for event_type in Event
|
|
169
|
+
}
|
|
170
|
+
self.config_path = config_path
|
|
171
|
+
self.config = PluginConfig()
|
|
172
|
+
self.load_config()
|
|
173
|
+
self._lock = threading.RLock()
|
|
174
|
+
self.enabled = False
|
|
175
|
+
|
|
176
|
+
def discover_plugins(
|
|
177
|
+
self,
|
|
178
|
+
plugins_dir: Path,
|
|
179
|
+
load_all: bool = False,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Discover and load plugins from the plugins directory"""
|
|
182
|
+
# Load each Python file as a plugin
|
|
183
|
+
python_files = list(plugins_dir.glob("*.py"))
|
|
184
|
+
|
|
185
|
+
for plugin_path in python_files:
|
|
186
|
+
plugin_name = plugin_path.stem
|
|
187
|
+
|
|
188
|
+
# For normal operation (not load_all), only load enabled plugins
|
|
189
|
+
if not load_all and not self.is_plugin_enabled(plugin_name):
|
|
190
|
+
logger.debug(f"Skipping disabled plugin: {plugin_name}")
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
self._load_plugin(plugin_name, plugin_path)
|
|
194
|
+
|
|
195
|
+
def load_config(self) -> None:
|
|
196
|
+
"""Load plugin configuration from dedicated plugins.toml file"""
|
|
197
|
+
if not self.config_path or not self.config_path.exists():
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
with open(self.config_path, "r") as f:
|
|
202
|
+
raw = tomlkit.parse(f.read())
|
|
203
|
+
self.config.enabled = set(raw.get("enabled", []))
|
|
204
|
+
self.config.disabled = set(raw.get("disabled", []))
|
|
205
|
+
self.config.plugins = raw.get("plugins", {})
|
|
206
|
+
# Store the raw plugin configuration to preserve what's on disk
|
|
207
|
+
self.config._raw = dict(raw.get("plugins", {}))
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to load plugin config: {e}")
|
|
210
|
+
|
|
211
|
+
def write_config(self, generate_full_config: bool = False) -> None:
|
|
212
|
+
"""Save plugin configuration to dedicated plugins.toml file
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
generate_full_config: If True, write the entire config.
|
|
216
|
+
If False, only write explicitly changed values.
|
|
217
|
+
"""
|
|
218
|
+
if not self.config_path:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
doc = tomlkit.document()
|
|
223
|
+
doc["enabled"] = list(self.config.enabled)
|
|
224
|
+
doc["disabled"] = list(self.config.disabled)
|
|
225
|
+
|
|
226
|
+
# For plugins section, handle differently based on generate_full_config flag
|
|
227
|
+
if generate_full_config:
|
|
228
|
+
doc["plugins"] = self.config.plugins
|
|
229
|
+
else:
|
|
230
|
+
doc["plugins"] = self.config._raw
|
|
231
|
+
|
|
232
|
+
with open(self.config_path, "w") as f:
|
|
233
|
+
f.write(tomlkit.dumps(doc))
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.error(f"Failed to save plugin config: {e}")
|
|
236
|
+
|
|
237
|
+
def _load_plugin(self, name: str, path: Path) -> None:
|
|
238
|
+
"""Load a plugin module from the given path"""
|
|
239
|
+
if name in self.plugins:
|
|
240
|
+
logger.debug(f"Plugin {name} already loaded")
|
|
241
|
+
return
|
|
242
|
+
try:
|
|
243
|
+
logger.debug(f"Loading plugin: {name} from {path}")
|
|
244
|
+
|
|
245
|
+
# Load the module using importlib
|
|
246
|
+
spec = importlib.util.spec_from_file_location(
|
|
247
|
+
f"esgpull.plugins.{name}", path
|
|
248
|
+
)
|
|
249
|
+
if spec is None or spec.loader is None:
|
|
250
|
+
raise ValueError()
|
|
251
|
+
|
|
252
|
+
module = importlib.util.module_from_spec(spec)
|
|
253
|
+
sys.modules[spec.name] = module
|
|
254
|
+
|
|
255
|
+
# Create plugin instance
|
|
256
|
+
plugin = Plugin(name=name, module=module)
|
|
257
|
+
self.plugins[name] = plugin
|
|
258
|
+
|
|
259
|
+
# Execute the module
|
|
260
|
+
spec.loader.exec_module(module)
|
|
261
|
+
logger.debug(f"Successfully executed module code for {name}")
|
|
262
|
+
|
|
263
|
+
# Check compatibility after module execution
|
|
264
|
+
if not plugin.is_compatible():
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Plugin {name} is not compatible with current app version"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Validate handler signatures after version compatibility check
|
|
270
|
+
for handler in plugin.handlers:
|
|
271
|
+
handler.validate_signature()
|
|
272
|
+
|
|
273
|
+
# Apply configuration
|
|
274
|
+
if plugin.config_class is not None:
|
|
275
|
+
if name not in self.config.plugins:
|
|
276
|
+
self.config.plugins[name] = {}
|
|
277
|
+
for key, value in vars(plugin.config_class).items():
|
|
278
|
+
if key.startswith("__"):
|
|
279
|
+
continue
|
|
280
|
+
if key in self.config.plugins[name]:
|
|
281
|
+
continue
|
|
282
|
+
self.config.plugins[name][key] = value
|
|
283
|
+
self._configure_plugin(plugin)
|
|
284
|
+
|
|
285
|
+
# Register plugin
|
|
286
|
+
self.plugins[name] = plugin
|
|
287
|
+
logger.info(f"Successfully loaded plugin: {name}")
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
if name in self.plugins:
|
|
291
|
+
plugin = self.plugins.pop(name)
|
|
292
|
+
for handler in plugin.handlers:
|
|
293
|
+
for handlers in self._handlers_by_event.values():
|
|
294
|
+
if handler in handlers:
|
|
295
|
+
handlers.remove(handler)
|
|
296
|
+
logger.error(f"Failed to load plugin {name}: {e}")
|
|
297
|
+
logger.exception(e)
|
|
298
|
+
|
|
299
|
+
def is_plugin_enabled(self, plugin_name: str) -> bool:
|
|
300
|
+
"""Check if a plugin is enabled in the configuration"""
|
|
301
|
+
# Check if explicitly disabled
|
|
302
|
+
if plugin_name in self.config.disabled:
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
else:
|
|
306
|
+
return plugin_name in self.config.enabled
|
|
307
|
+
|
|
308
|
+
def _configure_plugin(self, plugin: Plugin) -> None:
|
|
309
|
+
"""Configure a plugin based on plugins.toml configuration"""
|
|
310
|
+
if not plugin.config_class:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# Get plugin-specific configuration
|
|
314
|
+
plugin_section = f"{plugin.name}"
|
|
315
|
+
if plugin_section in self.config.plugins:
|
|
316
|
+
plugin_config = self.config.plugins[plugin_section]
|
|
317
|
+
|
|
318
|
+
# Apply configuration to the plugin's Config class
|
|
319
|
+
for key, value in plugin_config.items():
|
|
320
|
+
if hasattr(plugin.config_class, key):
|
|
321
|
+
setattr(plugin.config_class, key, value)
|
|
322
|
+
else:
|
|
323
|
+
logger.error(
|
|
324
|
+
f"Skipping wrong option for {plugin.name}: {key}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def register_handler(
|
|
328
|
+
self,
|
|
329
|
+
event: Event,
|
|
330
|
+
func: Callable,
|
|
331
|
+
plugin_name: str,
|
|
332
|
+
priority: Literal["low", "normal", "high"] = "normal",
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Register an event handler"""
|
|
335
|
+
handler = EventHandler(
|
|
336
|
+
func=func,
|
|
337
|
+
event=event,
|
|
338
|
+
plugin_name=plugin_name,
|
|
339
|
+
priority=priority,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Add handler to plugin
|
|
343
|
+
if plugin_name in self.plugins:
|
|
344
|
+
self.plugins[plugin_name].handlers.append(handler)
|
|
345
|
+
|
|
346
|
+
# Add to event registry
|
|
347
|
+
self._handlers_by_event[event].append(handler)
|
|
348
|
+
|
|
349
|
+
# Sort handlers by priority
|
|
350
|
+
self._sort_handlers(event)
|
|
351
|
+
|
|
352
|
+
def _sort_handlers(self, event: Event) -> None:
|
|
353
|
+
"""Sort handlers by priority (high, normal, low)"""
|
|
354
|
+
|
|
355
|
+
def handler_key(h: EventHandler) -> int:
|
|
356
|
+
match h.priority:
|
|
357
|
+
case "high":
|
|
358
|
+
return 0
|
|
359
|
+
case "normal":
|
|
360
|
+
return 1
|
|
361
|
+
case "low":
|
|
362
|
+
return 2
|
|
363
|
+
case _:
|
|
364
|
+
return 1
|
|
365
|
+
|
|
366
|
+
self._handlers_by_event[event].sort(key=handler_key)
|
|
367
|
+
|
|
368
|
+
def trigger_event(
|
|
369
|
+
self, event_type: Event, reraise: bool = False, **kwargs
|
|
370
|
+
) -> list[Any]:
|
|
371
|
+
"""Trigger an event, executing all registered handlers synchronously"""
|
|
372
|
+
# Skip if plugins are globally disabled
|
|
373
|
+
if not self.enabled:
|
|
374
|
+
return []
|
|
375
|
+
|
|
376
|
+
with self._lock:
|
|
377
|
+
handlers = self._handlers_by_event[event_type]
|
|
378
|
+
results = []
|
|
379
|
+
|
|
380
|
+
handler_kwargs = {}
|
|
381
|
+
spec = EventSpecs[event_type]
|
|
382
|
+
for param in spec.parameters:
|
|
383
|
+
if param.name in kwargs:
|
|
384
|
+
handler_kwargs[param.name] = kwargs[param.name]
|
|
385
|
+
|
|
386
|
+
for handler in handlers:
|
|
387
|
+
plugin = self.plugins.get(handler.plugin_name)
|
|
388
|
+
if not plugin or not self.is_plugin_enabled(
|
|
389
|
+
handler.plugin_name
|
|
390
|
+
):
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
# Execute handler with timing
|
|
394
|
+
start_time = time.perf_counter()
|
|
395
|
+
try:
|
|
396
|
+
plugin_logger = logging.getLogger(
|
|
397
|
+
f"esgpull.plugins.{plugin.name}"
|
|
398
|
+
)
|
|
399
|
+
result = handler.func(
|
|
400
|
+
logger=plugin_logger, **handler_kwargs
|
|
401
|
+
)
|
|
402
|
+
end_time = time.perf_counter()
|
|
403
|
+
execution_time = (
|
|
404
|
+
end_time - start_time
|
|
405
|
+
) * 1000 # Convert to ms
|
|
406
|
+
|
|
407
|
+
# Always log trace info (will only show at INFO level)
|
|
408
|
+
logger.info(
|
|
409
|
+
f"[TRACE] Plugin {plugin.name}.{handler.func.__name__} executed ({execution_time:.1f}ms)"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
results.append(result)
|
|
413
|
+
|
|
414
|
+
except Exception as e:
|
|
415
|
+
end_time = time.perf_counter()
|
|
416
|
+
execution_time = (end_time - start_time) * 1000
|
|
417
|
+
|
|
418
|
+
logger.error(
|
|
419
|
+
f"Plugin {plugin.name} failed on {event_type}: {e}"
|
|
420
|
+
)
|
|
421
|
+
logger.exception(e)
|
|
422
|
+
|
|
423
|
+
# Always log trace info for failed execution too
|
|
424
|
+
logger.info(
|
|
425
|
+
f"[TRACE] Plugin {plugin.name}.{handler.func.__name__} failed ({execution_time:.1f}ms)"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if reraise:
|
|
429
|
+
raise
|
|
430
|
+
|
|
431
|
+
return results
|
|
432
|
+
|
|
433
|
+
# Configuration management methods
|
|
434
|
+
def enable_plugin(self, plugin_name: str) -> bool:
|
|
435
|
+
"""Enable a plugin by name"""
|
|
436
|
+
if plugin_name in self.config.disabled:
|
|
437
|
+
self.config.disabled.remove(plugin_name)
|
|
438
|
+
self.config.enabled.add(plugin_name)
|
|
439
|
+
self.write_config()
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
def disable_plugin(self, plugin_name: str) -> bool:
|
|
443
|
+
"""Disable a plugin by name"""
|
|
444
|
+
self.config.disabled.add(plugin_name)
|
|
445
|
+
if plugin_name in self.config.enabled:
|
|
446
|
+
self.config.enabled.remove(plugin_name)
|
|
447
|
+
self.write_config()
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
def set_plugin_config(
|
|
451
|
+
self,
|
|
452
|
+
plugin_name: str,
|
|
453
|
+
key: str,
|
|
454
|
+
value: Any,
|
|
455
|
+
) -> Any:
|
|
456
|
+
"""Set a configuration value for a plugin"""
|
|
457
|
+
# Ensure plugin section exists
|
|
458
|
+
if plugin_name not in self.plugins:
|
|
459
|
+
raise ValueError(f"Plugin {plugin_name} not found")
|
|
460
|
+
plugin = self.plugins[plugin_name]
|
|
461
|
+
if plugin.config_class is None:
|
|
462
|
+
raise ValueError(f"Plugin {plugin_name} has no Config class")
|
|
463
|
+
|
|
464
|
+
# Make sure plugin exists in both plugins and _raw dicts
|
|
465
|
+
if plugin_name not in self.config.plugins:
|
|
466
|
+
self.config.plugins[plugin_name] = {}
|
|
467
|
+
if plugin_name not in self.config._raw:
|
|
468
|
+
self.config._raw[plugin_name] = {}
|
|
469
|
+
|
|
470
|
+
# Update the value in both places
|
|
471
|
+
if key in self.config.plugins[plugin_name]:
|
|
472
|
+
old_value = self.config.plugins[plugin_name][key]
|
|
473
|
+
new_value = cast_value(old_value, value, key)
|
|
474
|
+
self.config.plugins[plugin_name][key] = new_value
|
|
475
|
+
# Also update in _raw to keep in sync
|
|
476
|
+
self.config._raw[plugin_name][key] = new_value
|
|
477
|
+
else:
|
|
478
|
+
raise KeyError(key, self.config.plugins[plugin_name])
|
|
479
|
+
|
|
480
|
+
self.write_config()
|
|
481
|
+
setattr(plugin.config_class, key, value)
|
|
482
|
+
return old_value
|
|
483
|
+
|
|
484
|
+
def unset_plugin_config(
|
|
485
|
+
self,
|
|
486
|
+
plugin_name: str,
|
|
487
|
+
key: str,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Unset a configuration value for a plugin"""
|
|
490
|
+
# Ensure plugin section exists
|
|
491
|
+
if plugin_name not in self.plugins:
|
|
492
|
+
raise ValueError(f"Plugin {plugin_name} not found")
|
|
493
|
+
plugin = self.plugins[plugin_name]
|
|
494
|
+
if plugin.config_class is None:
|
|
495
|
+
raise ValueError(f"Plugin {plugin_name} has no Config class")
|
|
496
|
+
|
|
497
|
+
# Make sure the plugin exists in both configs
|
|
498
|
+
if plugin_name not in self.config.plugins:
|
|
499
|
+
self.config.plugins[plugin_name] = {}
|
|
500
|
+
if plugin_name not in self.config._raw:
|
|
501
|
+
self.config._raw[plugin_name] = {}
|
|
502
|
+
|
|
503
|
+
# Remove the key from both configs
|
|
504
|
+
if key in self.config.plugins[plugin_name]:
|
|
505
|
+
self.config.plugins[plugin_name].pop(key)
|
|
506
|
+
else:
|
|
507
|
+
raise KeyError(key, self.config.plugins[plugin_name])
|
|
508
|
+
|
|
509
|
+
# Also remove from _raw if it exists
|
|
510
|
+
if (
|
|
511
|
+
plugin_name in self.config._raw
|
|
512
|
+
and key in self.config._raw[plugin_name]
|
|
513
|
+
):
|
|
514
|
+
self.config._raw[plugin_name].pop(key)
|
|
515
|
+
|
|
516
|
+
self.write_config()
|
|
517
|
+
|
|
518
|
+
def get_plugin_config(self, plugin_name: str) -> dict:
|
|
519
|
+
"""Get configuration for a plugin"""
|
|
520
|
+
if plugin_name in self.config.plugins:
|
|
521
|
+
return self.config.plugins[plugin_name]
|
|
522
|
+
return {}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# Lazy-loaded singleton
|
|
526
|
+
_plugin_manager: PluginManager | None = None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def set_plugin_manager(pm: PluginManager) -> None:
|
|
530
|
+
"""Create the plugin manager singleton"""
|
|
531
|
+
global _plugin_manager
|
|
532
|
+
if _plugin_manager is None:
|
|
533
|
+
_plugin_manager = pm
|
|
534
|
+
else:
|
|
535
|
+
raise ValueError("PluginManager is already initialized")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def get_plugin_manager() -> PluginManager:
|
|
539
|
+
"""Get the plugin manager singleton"""
|
|
540
|
+
if _plugin_manager is None:
|
|
541
|
+
raise ValueError("PluginManager was never initialized")
|
|
542
|
+
return _plugin_manager
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def on(
|
|
546
|
+
event: Event,
|
|
547
|
+
*,
|
|
548
|
+
priority: Literal["low", "normal", "high"] = "normal",
|
|
549
|
+
) -> Callable:
|
|
550
|
+
"""Decorator to register a function as an event handler."""
|
|
551
|
+
|
|
552
|
+
def decorator(handler_func):
|
|
553
|
+
# Get plugin name from module
|
|
554
|
+
module_parts = handler_func.__module__.split(".")
|
|
555
|
+
plugin_name = module_parts[-1]
|
|
556
|
+
|
|
557
|
+
get_plugin_manager().register_handler(
|
|
558
|
+
event=event,
|
|
559
|
+
func=handler_func,
|
|
560
|
+
plugin_name=plugin_name,
|
|
561
|
+
priority=priority,
|
|
562
|
+
)
|
|
563
|
+
return handler_func
|
|
564
|
+
|
|
565
|
+
return decorator
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def emit(event_type, **kwargs):
|
|
569
|
+
"""
|
|
570
|
+
Emit an event to be handled by plugins.
|
|
571
|
+
|
|
572
|
+
This function runs handlers in the current thread and returns their results.
|
|
573
|
+
"""
|
|
574
|
+
return get_plugin_manager().trigger_event(event_type, **kwargs)
|
esgpull/processor.py
CHANGED
|
@@ -84,7 +84,7 @@ class Task:
|
|
|
84
84
|
self,
|
|
85
85
|
semaphore: asyncio.Semaphore,
|
|
86
86
|
client: AsyncClient,
|
|
87
|
-
) -> AsyncIterator[Result]:
|
|
87
|
+
) -> AsyncIterator[Result[DownloadCtx]]:
|
|
88
88
|
ctx = self.ctx
|
|
89
89
|
try:
|
|
90
90
|
async with semaphore, self.fs.open(ctx.file) as file_obj:
|
|
@@ -131,7 +131,7 @@ class Processor:
|
|
|
131
131
|
self.auth = auth
|
|
132
132
|
self.fs = fs
|
|
133
133
|
self.files = list(filter(self.should_download, files))
|
|
134
|
-
self.tasks = []
|
|
134
|
+
self.tasks: list[Task] = []
|
|
135
135
|
msg: str | None = None
|
|
136
136
|
if not default_ssl_context_loaded:
|
|
137
137
|
msg = load_default_ssl_context()
|
|
@@ -157,7 +157,7 @@ class Processor:
|
|
|
157
157
|
else:
|
|
158
158
|
return True
|
|
159
159
|
|
|
160
|
-
async def process(self) -> AsyncIterator[Result]:
|
|
160
|
+
async def process(self) -> AsyncIterator[Result[DownloadCtx]]:
|
|
161
161
|
semaphore = asyncio.Semaphore(self.config.download.max_concurrent)
|
|
162
162
|
async with AsyncClient(
|
|
163
163
|
follow_redirects=True,
|
esgpull/tui.py
CHANGED
|
@@ -28,6 +28,7 @@ from tomlkit import dumps as tomlkit_dumps
|
|
|
28
28
|
from yaml import dump as yaml_dump
|
|
29
29
|
|
|
30
30
|
from esgpull.config import Config
|
|
31
|
+
from esgpull.constants import ESGPULL_DEBUG
|
|
31
32
|
|
|
32
33
|
logger = logging.getLogger("esgpull")
|
|
33
34
|
logging.root.setLevel(logging.DEBUG)
|
|
@@ -91,6 +92,15 @@ LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
|
91
92
|
FILE_DATE_FORMAT = "%Y-%m-%d_%H-%M-%S"
|
|
92
93
|
|
|
93
94
|
|
|
95
|
+
class ExceptionToDebugFilter(logging.Filter):
|
|
96
|
+
def filter(self, record):
|
|
97
|
+
# If it's an exception record, change its level to DEBUG
|
|
98
|
+
if record.exc_info is not None:
|
|
99
|
+
record.levelno = logging.DEBUG
|
|
100
|
+
record.levelname = "DEBUG"
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
94
104
|
@define
|
|
95
105
|
class UI:
|
|
96
106
|
path: Path = field(converter=Path)
|
|
@@ -155,9 +165,16 @@ class UI:
|
|
|
155
165
|
temp_path = self.path / filename
|
|
156
166
|
handler = logging.FileHandler(temp_path)
|
|
157
167
|
handler.setLevel(logging.DEBUG)
|
|
168
|
+
error_print_handler = logging.StreamHandler()
|
|
169
|
+
error_print_handler.setLevel(logging.ERROR)
|
|
170
|
+
error_print_handler.setFormatter(
|
|
171
|
+
logging.Formatter(fmt=fmt, datefmt=datefmt)
|
|
172
|
+
)
|
|
173
|
+
logging.root.addHandler(error_print_handler)
|
|
158
174
|
else:
|
|
159
175
|
handler = logging.NullHandler()
|
|
160
176
|
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
|
|
177
|
+
logger.addFilter(ExceptionToDebugFilter())
|
|
161
178
|
logging.root.addHandler(handler)
|
|
162
179
|
try:
|
|
163
180
|
yield
|
|
@@ -188,7 +205,12 @@ class UI:
|
|
|
188
205
|
f"See [yellow]{temp_path}[/] for error log.",
|
|
189
206
|
err=True,
|
|
190
207
|
)
|
|
191
|
-
if
|
|
208
|
+
if ESGPULL_DEBUG:
|
|
209
|
+
from rich.traceback import install
|
|
210
|
+
|
|
211
|
+
install()
|
|
212
|
+
raise
|
|
213
|
+
elif onraise is not None:
|
|
192
214
|
raise onraise
|
|
193
215
|
elif self.default_onraise is not None:
|
|
194
216
|
raise self.default_onraise
|
esgpull/utils.py
CHANGED
|
@@ -31,7 +31,9 @@ def format_size(size: int) -> str:
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def
|
|
34
|
+
def parse_date(
|
|
35
|
+
date: str | datetime.datetime, fmt: str = "%Y-%m-%d"
|
|
36
|
+
) -> datetime.datetime:
|
|
35
37
|
match date:
|
|
36
38
|
case datetime.datetime():
|
|
37
39
|
...
|
|
@@ -39,7 +41,17 @@ def format_date(date: str | datetime.datetime, fmt: str = "%Y-%m-%d") -> str:
|
|
|
39
41
|
date = datetime.datetime.strptime(date, fmt)
|
|
40
42
|
case _:
|
|
41
43
|
raise ValueError(date)
|
|
42
|
-
return date
|
|
44
|
+
return date
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_date(date: str | datetime.datetime, fmt: str = "%Y-%m-%d") -> str:
|
|
48
|
+
return parse_date(date, fmt).strftime(fmt)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_date_iso(
|
|
52
|
+
date: str | datetime.datetime, fmt: str = "%Y-%m-%d"
|
|
53
|
+
) -> str:
|
|
54
|
+
return parse_date(date, fmt).replace(microsecond=0).isoformat() + "Z"
|
|
43
55
|
|
|
44
56
|
|
|
45
57
|
def url2index(url: str) -> str:
|
|
@@ -51,4 +63,8 @@ def url2index(url: str) -> str:
|
|
|
51
63
|
|
|
52
64
|
|
|
53
65
|
def index2url(index: str) -> str:
|
|
54
|
-
|
|
66
|
+
url = "https://" + url2index(index)
|
|
67
|
+
if "esgf-1-5-bridge" in index:
|
|
68
|
+
return url
|
|
69
|
+
else:
|
|
70
|
+
return url + "/esg-search/search"
|