dirac-cwl 1.0.2__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.
@@ -0,0 +1,342 @@
1
+ """Core metadata framework for DIRAC CWL integration.
2
+
3
+ This module provides the foundational classes and interfaces for the extensible
4
+ metadata plugin system in DIRAC/DIRACX.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from abc import ABC, abstractmethod
12
+ from pathlib import Path
13
+ from typing import (
14
+ Any,
15
+ ClassVar,
16
+ Dict,
17
+ List,
18
+ Mapping,
19
+ Optional,
20
+ Self,
21
+ Sequence,
22
+ TypeVar,
23
+ Union,
24
+ )
25
+
26
+ from DIRAC.DataManagementSystem.Client.DataManager import ( # type: ignore[import-untyped]
27
+ DataManager,
28
+ )
29
+ from DIRACCommon.Core.Utilities.ReturnValues import ( # type: ignore[import-untyped]
30
+ returnSingleResult,
31
+ )
32
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
33
+
34
+ from dirac_cwl.commands import PostProcessCommand, PreProcessCommand
35
+ from dirac_cwl.data_management_mocks.data_manager import MockDataManager
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # TypeVar for generic class methods
40
+ T = TypeVar("T", bound="SchedulingHint")
41
+
42
+
43
+ class ExecutionHooksBasePlugin(BaseModel):
44
+ """Base class for all runtime plugin models with execution hooks.
45
+
46
+ This class uses composition instead of inheritance for data catalog operations,
47
+ providing better separation of concerns and flexibility.
48
+ """
49
+
50
+ model_config = ConfigDict(
51
+ extra="ignore",
52
+ validate_assignment=True,
53
+ arbitrary_types_allowed=True,
54
+ json_schema_extra={
55
+ "title": "DIRAC Metadata Model",
56
+ "description": "Base metadata model for DIRAC jobs",
57
+ },
58
+ )
59
+
60
+ # Class-level metadata for plugin discovery
61
+ vo: ClassVar[Optional[str]] = None
62
+ version: ClassVar[str] = "1.0.0"
63
+ description: ClassVar[str] = "Base metadata model"
64
+
65
+ output_paths: Dict[str, Any] = {}
66
+ output_sandbox: list[str] = []
67
+ output_se: list[str] = []
68
+
69
+ _datamanager: DataManager = PrivateAttr(default_factory=DataManager)
70
+
71
+ def __init__(self, **kwargs):
72
+ """Initialize the execution hooks base plugin.
73
+
74
+ :param kwargs: Additional keyword arguments passed to the parent class.
75
+ """
76
+ super().__init__(**kwargs)
77
+ if os.getenv("DIRAC_PROTO_LOCAL") == "1":
78
+ self._datamanager = MockDataManager()
79
+
80
+ _preprocess_commands: List[type[PreProcessCommand]] = PrivateAttr(default=[])
81
+ _postprocess_commands: List[type[PostProcessCommand]] = PrivateAttr(default=[])
82
+
83
+ @property
84
+ def preprocess_commands(self) -> List[type[PreProcessCommand]]:
85
+ """Get the list of pre-processing commands."""
86
+ return self._preprocess_commands
87
+
88
+ @preprocess_commands.setter
89
+ def preprocess_commands(self, value: List[type[PreProcessCommand]]) -> None:
90
+ """Set the list of pre-processing commands."""
91
+ self._preprocess_commands = value
92
+
93
+ @property
94
+ def postprocess_commands(self) -> List[type[PostProcessCommand]]:
95
+ """Get the list of post-processing commands."""
96
+ return self._postprocess_commands
97
+
98
+ @postprocess_commands.setter
99
+ def postprocess_commands(self, value: List[type[PostProcessCommand]]) -> None:
100
+ """Set the list of post-processing commands."""
101
+ self._postprocess_commands = value
102
+
103
+ @classmethod
104
+ def name(cls) -> str:
105
+ """Auto-derive hook plugin identifier from class name."""
106
+ return cls.__name__
107
+
108
+ def store_output(
109
+ self,
110
+ outputs: dict[str, str | Path | Sequence[str | Path]],
111
+ **kwargs: Any,
112
+ ) -> None:
113
+ """Store an output file or set of files via the appropriate storage interface.
114
+
115
+ :param dict[str, str | Path | Sequence[str | Path]] outputs:
116
+ Dictionary containing the path or list of paths to the source file(s) to be stored
117
+ for each cwl output.
118
+ :param Any **kwargs:
119
+ Additional keyword arguments for extensibility.
120
+ """
121
+ for output_name, src_path in outputs.items():
122
+ logger.info("Storing output %s, with source %s", output_name, src_path)
123
+
124
+ if not src_path:
125
+ raise RuntimeError(f"src_path parameter required for filesystem storage of {output_name}")
126
+
127
+ lfn = self.output_paths.get(output_name, None)
128
+
129
+ if lfn:
130
+ if isinstance(src_path, str) or isinstance(src_path, Path):
131
+ src_path = [src_path]
132
+ for src in src_path:
133
+ file_lfn = Path(lfn) / Path(src).name
134
+ res = None
135
+ for se in self.output_se:
136
+ res = returnSingleResult(self._datamanager.putAndRegister(str(file_lfn), src, se))
137
+ if res["OK"]:
138
+ logger.info("Successfully saved file %s with LFN %s", src, file_lfn)
139
+ break
140
+ if res and not res["OK"]:
141
+ raise RuntimeError(f"Could not save file {src} with LFN {str(lfn)} : {res['Message']}")
142
+
143
+ def get_input_query(self, input_name: str, **kwargs: Any) -> Union[Path, List[Path], None]:
144
+ """Generate LFN-based input query path.
145
+
146
+ Accepts and ignores extra kwargs for interface compatibility.
147
+ """
148
+ # Build LFN: /query_root/vo/campaign/site/data_type/input_name
149
+ pass
150
+
151
+ @classmethod
152
+ def get_schema_info(cls) -> Dict[str, Any]:
153
+ """Get schema information for this metadata model."""
154
+ return {
155
+ "hook_plugin": cls.name(),
156
+ "vo": cls.vo,
157
+ "version": cls.version,
158
+ "description": cls.description,
159
+ "schema": cls.model_json_schema(),
160
+ }
161
+
162
+
163
+ class Hint(ABC):
164
+ """Base class for all DIRAC hints and requirements models."""
165
+
166
+ @classmethod
167
+ @abstractmethod
168
+ def from_cwl(cls, cwl_object: Any) -> "Hint":
169
+ """Extract hint information from a CWL object."""
170
+ pass
171
+
172
+
173
+ class SchedulingHint(BaseModel, Hint):
174
+ """Descriptor for job execution configuration."""
175
+
176
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
177
+
178
+ platform: Optional[str] = Field(default=None, description="Target platform (e.g., 'DIRAC', 'DIRACX')")
179
+
180
+ priority: int = Field(default=10, description="Job priority (higher values = higher priority)")
181
+
182
+ sites: Optional[List[str]] = Field(default=None, description="Candidate execution sites")
183
+
184
+ @classmethod
185
+ def from_cwl(cls: type[T], cwl_object: Any) -> T:
186
+ """Extract task descriptor from CWL hints."""
187
+ descriptor = cls()
188
+
189
+ hints = getattr(cwl_object, "hints", []) or []
190
+ for hint in hints:
191
+ if hint.get("class") == "dirac:Scheduling":
192
+ hint_data = {k: v for k, v in hint.items() if k != "class"}
193
+ descriptor = descriptor.model_copy(update=hint_data)
194
+
195
+ return descriptor
196
+
197
+
198
+ class ExecutionHooksHint(BaseModel, Hint):
199
+ """Descriptor for data management configuration in CWL hints.
200
+
201
+ This class represents the serializable data management configuration that
202
+ can be embedded in CWL hints and later instantiated into concrete
203
+ metadata models.
204
+
205
+ Enhanced with submission functionality for DIRAC CWL integration.
206
+ """
207
+
208
+ model_config = ConfigDict(
209
+ extra="allow", # Allow vo-specific fields
210
+ validate_assignment=True,
211
+ json_schema_extra={
212
+ "title": "DIRAC Data Manager",
213
+ "description": "Data management configuration for DIRAC jobs",
214
+ },
215
+ )
216
+
217
+ hook_plugin: str = Field(
218
+ default="QueryBasedPlugin",
219
+ description="Registry key for the metadata implementation class",
220
+ )
221
+
222
+ # Enhanced fields for submission functionality
223
+ configuration: Dict[str, Any] = Field(
224
+ default_factory=dict, description="Additional parameters for metadata plugins"
225
+ )
226
+
227
+ output_paths: Dict[str, Any] = Field(default_factory=dict, description="LFNs for outputs on the Data Catalog")
228
+
229
+ output_sandbox: list[str] = Field(
230
+ default_factory=list,
231
+ description="List of the outputs stored in the output sandbox",
232
+ )
233
+
234
+ output_se: list[str] = Field(
235
+ default_factory=lambda: ["SE-USER"],
236
+ description="List of Storage Elements that can be used to store the outputs",
237
+ )
238
+
239
+ def model_copy(
240
+ self,
241
+ update: Optional[Mapping[str, Any]] = None,
242
+ *,
243
+ deep: bool = False,
244
+ ) -> Self:
245
+ """Enhanced model copy with intelligent merging of dict fields (including configuration)."""
246
+ if update is None:
247
+ update = {}
248
+ else:
249
+ update = dict(update)
250
+
251
+ merged_update = {}
252
+ for key, value in update.items():
253
+ if hasattr(self, key) and isinstance(getattr(self, key), dict) and isinstance(value, dict):
254
+ existing_value = getattr(self, key).copy()
255
+ existing_value.update(value)
256
+ merged_update[key] = existing_value
257
+ else:
258
+ merged_update[key] = value
259
+
260
+ return super().model_copy(update=merged_update, deep=deep)
261
+
262
+ def to_runtime(self, submitted: Optional[Any] = None) -> "ExecutionHooksBasePlugin":
263
+ """Build and instantiate the runtime metadata implementation.
264
+
265
+ The returned object is an instance of :class:`ExecutionHooksBasePlugin` created
266
+ by the metadata registry. The instantiation parameters are constructed
267
+ by merging, in order:
268
+
269
+ 1. Input defaults declared on the CWL task (if ``submitted`` is provided).
270
+ 2. The first set of CWL parameter overrides (``submitted.parameters``),
271
+ if present.
272
+ 3. The descriptor's ``configuration``.
273
+
274
+ During merging, keys are normalized from dash-case to snake_case to
275
+ align with typical Python argument names used by runtime implementations.
276
+
277
+ :param submitted: Optional submission context used to resolve CWL input defaults
278
+ and parameter overrides.
279
+ :type submitted: JobSubmissionModel | None
280
+ :return: Runtime plugin implementation instantiated from the registry.
281
+ :rtype: ExecutionHooksBasePlugin
282
+ """
283
+ # Import here to avoid circular imports
284
+ from .registry import get_registry
285
+
286
+ # Quick helper to convert dash-case to snake_case without importing utils
287
+ def _dash_to_snake(s: str) -> str:
288
+ return s.replace("-", "_")
289
+
290
+ if submitted is None:
291
+ descriptor = ExecutionHooksHint(
292
+ hook_plugin=self.hook_plugin,
293
+ output_paths=self.output_paths,
294
+ output_sandbox=self.output_sandbox,
295
+ output_se=self.output_se,
296
+ **self.configuration,
297
+ )
298
+ return get_registry().instantiate_plugin(descriptor)
299
+
300
+ # Build inputs from task defaults and parameter overrides
301
+ inputs: dict[str, Any] = {}
302
+ for inp in submitted.task.inputs:
303
+ input_name = inp.id.split("#")[-1].split("/")[-1]
304
+ input_value = getattr(inp, "default", None)
305
+ params_list = getattr(submitted, "parameters", None)
306
+ if params_list and params_list[0]:
307
+ input_value = params_list[0].cwl.get(input_name, input_value)
308
+ inputs[input_name] = input_value
309
+
310
+ # Merge with explicit configuration
311
+ if self.configuration:
312
+ inputs.update(self.configuration)
313
+
314
+ params = {_dash_to_snake(key): value for key, value in inputs.items()}
315
+
316
+ descriptor = ExecutionHooksHint(
317
+ hook_plugin=self.hook_plugin,
318
+ output_paths=self.output_paths,
319
+ output_sandbox=self.output_sandbox,
320
+ output_se=self.output_se,
321
+ **params,
322
+ )
323
+ return get_registry().instantiate_plugin(descriptor)
324
+
325
+ @classmethod
326
+ def from_cwl(cls, cwl_object: Any) -> Self:
327
+ """Extract metadata descriptor from CWL object using Hint interface."""
328
+ descriptor = cls()
329
+ hints = getattr(cwl_object, "hints", []) or []
330
+ for hint in hints:
331
+ if hint.get("class") == "dirac:ExecutionHooks":
332
+ hint_data = {k: v for k, v in hint.items() if k != "class"}
333
+ descriptor = descriptor.model_copy(update=hint_data)
334
+ return descriptor
335
+
336
+
337
+ class TransformationExecutionHooksHint(ExecutionHooksHint):
338
+ """Extended data manager for transformations."""
339
+
340
+ group_size: Optional[Dict[str, int]] = Field(
341
+ default=None, description="Input grouping configuration for transformation jobs"
342
+ )
@@ -0,0 +1,16 @@
1
+ """Plugin package initialization.
2
+
3
+ This module ensures that core plugins are automatically registered
4
+ when the metadata system is imported.
5
+ """
6
+
7
+ from .core import (
8
+ QueryBasedPlugin,
9
+ )
10
+
11
+ # Plugins will be auto-registered through the metaclass or discovery system
12
+
13
+ __all__ = [
14
+ # Core plugins
15
+ "QueryBasedPlugin",
16
+ ]
@@ -0,0 +1,58 @@
1
+ """Core DIRAC metadata models.
2
+
3
+ This module contains the standard metadata models provided by DIRAC core.
4
+ These serve as examples and provide basic functionality for common use cases.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any, ClassVar, List, Optional, Union
12
+
13
+ from pydantic import Field
14
+
15
+ from ..core import (
16
+ ExecutionHooksBasePlugin,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class QueryBasedPlugin(ExecutionHooksBasePlugin):
23
+ """Metadata plugin using LFN-based data catalog for structured data discovery.
24
+
25
+ This plugin demonstrates filesystem-based data organization using
26
+ Logical File Names (LFNs) with campaign, site, and data type parameters.
27
+ """
28
+
29
+ description: ClassVar[str] = "LFN-based metadata for structured data discovery"
30
+
31
+ # LFN parameters
32
+ query_root: str = Field(default="/grid/data", description="Base path for LFN structure")
33
+ site: Optional[str] = Field(default=None, description="Site identifier for LFN path")
34
+ campaign: Optional[str] = Field(default=None, description="Campaign name for LFN path")
35
+ data_type: Optional[str] = Field(default=None, description="Data type classification")
36
+
37
+ def get_input_query(self, input_name: str, **kwargs: Any) -> Union[Path, List[Path], None]:
38
+ """Generate LFN-based input query path.
39
+
40
+ Accepts and ignores extra kwargs for interface compatibility.
41
+ """
42
+ # Build LFN: /query_root/vo/campaign/site/data_type/input_name
43
+ path_parts = []
44
+
45
+ if self.vo:
46
+ path_parts.append(self.vo)
47
+
48
+ if self.campaign:
49
+ path_parts.append(self.campaign)
50
+ if self.site:
51
+ path_parts.append(self.site)
52
+ if self.data_type:
53
+ path_parts.append(self.data_type)
54
+
55
+ if len(path_parts) > 0: # More than just VO
56
+ return Path(self.query_root) / Path(*path_parts) / Path(input_name)
57
+
58
+ return Path(self.query_root) / Path(input_name)
@@ -0,0 +1,209 @@
1
+ """Enhanced plugin registry for metadata models.
2
+
3
+ This module provides a sophisticated plugin discovery and registration system
4
+ for DIRAC metadata models, supporting virtual organization-specific extensions and
5
+ automatic discovery.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from importlib.metadata import entry_points
12
+ from typing import Any, Dict, List, Optional, Type
13
+
14
+ from .core import ExecutionHooksBasePlugin, ExecutionHooksHint
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ExecutionHooksPluginRegistry:
20
+ """
21
+ Registry for execution hooks plugins.
22
+
23
+ This class manages the registration and retrieval of execution hooks plugins
24
+ for different steps in CWL workflows. Plugins are registered using
25
+ entry points and can be retrieved by name.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize the execution hooks registry."""
30
+ self._plugins: Dict[str, Type[ExecutionHooksBasePlugin]] = {}
31
+ self._vo_plugins: Dict[str, Dict[str, Type[ExecutionHooksBasePlugin]]] = {}
32
+ self._plugin_info: Dict[str, Dict[str, Any]] = {}
33
+
34
+ def register_plugin(self, plugin_class: Type[ExecutionHooksBasePlugin], override: bool = False) -> None:
35
+ """Register a metadata plugin.
36
+
37
+ :param plugin_class: The metadata model class to register.
38
+ :type plugin_class: Type[ExecutionHooksBasePlugin]
39
+ :param override: Whether to override existing registrations, by default False.
40
+ :type override: bool
41
+ :raises ValueError: If plugin is already registered and override=False.
42
+ """
43
+ if not issubclass(plugin_class, ExecutionHooksBasePlugin):
44
+ raise ValueError(f"Plugin {plugin_class} must inherit from ExecutionHooksBasePlugin")
45
+
46
+ plugin_key = plugin_class.name()
47
+ vo = plugin_class.vo
48
+
49
+ # Check for conflicts
50
+ if plugin_key in self._plugins and not override:
51
+ existing = self._plugins[plugin_key]
52
+ raise ValueError(
53
+ f"Plugin '{plugin_key}' already registered by {existing.__module__}.{existing.__name__}. "
54
+ f"Use override=True to replace."
55
+ )
56
+
57
+ # Register globally
58
+ self._plugins[plugin_key] = plugin_class
59
+ self._plugin_info[plugin_key] = plugin_class.get_schema_info()
60
+
61
+ # Register by VO if specified
62
+ if vo:
63
+ if vo not in self._vo_plugins:
64
+ self._vo_plugins[vo] = {}
65
+ self._vo_plugins[vo][plugin_key] = plugin_class
66
+
67
+ vo_suffix = f" (VO: {vo})" if vo else ""
68
+ logger.info(
69
+ "Registered metadata plugin '%s' from %s.%s%s",
70
+ plugin_key,
71
+ plugin_class.__module__,
72
+ plugin_class.__name__,
73
+ vo_suffix,
74
+ )
75
+
76
+ def get_plugin(self, plugin_key: str, vo: Optional[str] = None) -> Optional[Type[ExecutionHooksBasePlugin]]:
77
+ """Get a registered plugin.
78
+
79
+ :param plugin_key: The plugin identifier.
80
+ :type plugin_key: str
81
+ :param vo: Virtual Organization namespace to search first, by default None.
82
+ :type vo: Optional[str]
83
+ :return: The plugin class or None if not found.
84
+ :rtype: Optional[Type[ExecutionHooksBasePlugin]]
85
+ """
86
+ # Try VO-specific first if specified
87
+ if vo and vo in self._vo_plugins:
88
+ if plugin_key in self._vo_plugins[vo]:
89
+ return self._vo_plugins[vo][plugin_key]
90
+
91
+ # Fall back to global registry
92
+ return self._plugins.get(plugin_key)
93
+
94
+ def instantiate_plugin(self, descriptor: ExecutionHooksHint, **kwargs: Any) -> ExecutionHooksBasePlugin:
95
+ """Instantiate a metadata plugin from a descriptor.
96
+
97
+ :param descriptor: The data manager containing configuration.
98
+ :type descriptor: ExecutionHooksHint
99
+ :param kwargs: Additional parameters to pass to the plugin constructor.
100
+ :type kwargs: Any
101
+ :return: Instantiated metadata model.
102
+ :rtype: ExecutionHooksBasePlugin
103
+ :raises KeyError: If the requested plugin is not registered.
104
+ :raises ValueError: If plugin instantiation fails.
105
+ """
106
+ plugin_class = self.get_plugin(descriptor.hook_plugin)
107
+
108
+ if plugin_class is None:
109
+ available = self.list_plugins()
110
+ raise KeyError(f"Unknown execution hooks plugin: '{descriptor.hook_plugin}'" f"Available: {available}")
111
+
112
+ # Extract plugin parameters from descriptor
113
+ plugin_params = descriptor.model_dump(
114
+ exclude={
115
+ "hook_plugin",
116
+ }
117
+ )
118
+ plugin_params.update(kwargs)
119
+
120
+ try:
121
+ return plugin_class(**plugin_params)
122
+ except Exception as e:
123
+ raise ValueError(f"Failed to instantiate plugin '{descriptor.hook_plugin}': {e}") from e
124
+
125
+ def list_plugins(self, vo: Optional[str] = None) -> List[str]:
126
+ """List available plugins.
127
+
128
+ :param vo: Filter by Virtual Organization, by default None.
129
+ :type vo: Optional[str]
130
+ :return: List of available plugin keys.
131
+ :rtype: List[str]
132
+ """
133
+ if vo and vo in self._vo_plugins:
134
+ return list(self._vo_plugins[vo].keys())
135
+ return list(self._plugins.keys())
136
+
137
+ def list_virtual_organizations(self) -> List[str]:
138
+ """List available Virtual Organizations."""
139
+ return list(self._vo_plugins.keys())
140
+
141
+ def get_plugin_info(self, plugin_key: str) -> Optional[Dict[str, Any]]:
142
+ """Get detailed information about a plugin."""
143
+ return self._plugin_info.get(plugin_key)
144
+
145
+ def discover_plugins(self) -> int:
146
+ """Discover and register plugins from the entry points defined in the pyproject.toml.
147
+
148
+ :return: Number of plugins discovered and registered.
149
+ :rtype: int
150
+ """
151
+ entrypoints = entry_points(group="dirac_cwl.execution_hooks")
152
+ discovered = 0
153
+ for hook_name in entrypoints.names:
154
+ try:
155
+ hook = entrypoints[hook_name].load()
156
+ if issubclass(hook, ExecutionHooksBasePlugin):
157
+ self.register_plugin(hook)
158
+ discovered += 1
159
+ else:
160
+ logger.warning(
161
+ "Tried to discover execution hook with name '%s' that does not inherit %s",
162
+ hook_name,
163
+ ExecutionHooksBasePlugin.__name__,
164
+ )
165
+ except Exception as e:
166
+ logger.error("Failed to import plugin %s: %s", hook_name, e)
167
+
168
+ return discovered
169
+
170
+ def validate_descriptor(self, descriptor: ExecutionHooksHint) -> List[str]:
171
+ """Validate a data manager against registered plugins.
172
+
173
+ :param descriptor: The data manager to validate.
174
+ :type descriptor: ExecutionHooksHint
175
+ :return: List of validation errors (empty if valid).
176
+ :rtype: List[str]
177
+ """
178
+ errors = []
179
+
180
+ plugin_class = self.get_plugin(descriptor.hook_plugin)
181
+
182
+ if plugin_class is None:
183
+ available = self.list_plugins()
184
+ errors.append(f"Unknown metadata plugin: '{descriptor.hook_plugin}'. " f"Available: {available}")
185
+ return errors
186
+
187
+ # Validate descriptor against plugin schema
188
+ try:
189
+ plugin_params = descriptor.model_dump(exclude={"hook_plugin"})
190
+ plugin_class.model_validate(plugin_params)
191
+ except Exception as e:
192
+ errors.append(f"Plugin validation failed: {e}")
193
+
194
+ return errors
195
+
196
+
197
+ # Global registry instance
198
+ _registry = ExecutionHooksPluginRegistry()
199
+
200
+
201
+ # Public API
202
+ def get_registry() -> ExecutionHooksPluginRegistry:
203
+ """Get the global execution hooks plugin registry."""
204
+ return _registry
205
+
206
+
207
+ def discover_plugins() -> int:
208
+ """Discover and register plugins from packages."""
209
+ return _registry.discover_plugins()