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/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 onraise is not None:
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
@@ -63,4 +63,8 @@ def url2index(url: str) -> str:
63
63
 
64
64
 
65
65
  def index2url(index: str) -> str:
66
- return "https://" + url2index(index) + "/esg-search/search"
66
+ url = "https://" + url2index(index)
67
+ if "esgf-1-5-bridge" in index:
68
+ return url
69
+ else:
70
+ return url + "/esg-search/search"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: esgpull
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: ESGF data discovery, download, replication tool
5
5
  Project-URL: Repository, https://github.com/ESGF/esgf-download
6
6
  Project-URL: Documentation, https://esgf.github.io/esgf-download/
@@ -23,6 +23,7 @@ Requires-Dist: click>=8.1.3
23
23
  Requires-Dist: httpx>=0.23.0
24
24
  Requires-Dist: myproxyclient>=2.1.0
25
25
  Requires-Dist: nest-asyncio>=1.5.6
26
+ Requires-Dist: packaging>=25.0
26
27
  Requires-Dist: platformdirs>=2.6.2
27
28
  Requires-Dist: pyopenssl>=22.1.0
28
29
  Requires-Dist: pyparsing>=3.0.9